ONLamp.com    
 Published on ONLamp.com (http://www.onlamp.com/)
 See this if you're having trouble printing code examples


PHP Cookbook

Trip Mapping with PHP

by David Sklar, coauthor of PHP Cookbook
11/07/2002

Remember Raiders of the Lost Ark? One of the distinctive images was a thick red line cruising across a map, showing Indiana Jones' routes when crisscrossing the globe and fighting bad guys. I don't think they used PHP for any of the special effects in 1981, but you can use PHP today to create a similar map of the United States.

To draw lines in the right places, you need a few things: data describing the location of U.S. places, a blank map image, and a short PHP program to draw the lines where you want them.

Data

First, you need a way to locate different places in the United States. The Census Bureau has already done this. The Gazetteer data file (zcta5.zip) contains information (including longitude and latitude) about over 30,000 US ZIP codes. The Gazetteer page explains the file format:

Columns 1-2:     United States Postal Service State Abbreviation 
Columns 3-66:    Name 
Columns 67-75:   Total Population (2000) 
Columns 76-84:   Total Housing Units (2000) 
Columns 85-98:   Land Area (square meters) 
Columns 99-112:  Water Area (square meters) 
Columns 113-124: Land Area (square miles) 
Columns 125-136: Water Area (square miles) 
Columns 137-146: Latitude (decimal degrees) First character is blank or "-"
                 denoting North or South latitude respectively 
Columns 147-157: Longitude (decimal degrees) First character is blank or "-" 
                 denoting East or West longitude respectively

The zcta5.zip Gazetteer file unzips to zcta.txt. A line of the text file looks like this (without the linebreaks):

PA19096 5-Digit ZCTA                                                  
13299     5456       8907922             0    3.439368    
0.000000 39.992345 -75.276248

The first five characters of the Name field are the ZIP code. To draw lines in the right places on your map, you just need the ZIP code, the longitude, and the latitude. Store the data in this zcta table:

CREATE TABLE zcta (
	zip CHAR(5) NOT NULL,
	lat DECIMAL(12,2) NOT NULL,
	lon DECIMAL(12,2) NOT NULL,
	KEY(zip)
);

This short PHP program parses the file and inserts information into the zcta table. You should change the DSN passed to DB::connect() to have the correct configuration settings for your database.

require 'DB.php';
$dbh = DB::connect('mysql://test:@localhost/test');

$fh = fopen('zcta5.txt','r') or die("can't open: $php_errormsg");
while ($s = fgets($fh, 256)) {
    if (preg_match('/^\d{5}$/',$zip = substr($s,2,5))) {
        $q = $dbh->query(
        'INSERT INTO zcta (zip,lat,lon) VALUES (?,?,?)',
        array($zip,substr($s,136,10),substr($s,146,11)));
        
        if (DB::isError($q)) {
            print_r($q);
            die();
        }
    }
}
fclose($fh);

Some of the entries in the file have some alphabetical characters in the ZIP codes. These seem to be consolidations of metropolitan areas. We want only actual ZIP codes in our table, so we'll use the regular expression /^\d{5}$/ to filter out ZIP codes that aren't just five digits. ZIP codes that match go into the database along with their latitudes and longitudes.

The line:

if (preg_match('/^\d{5}$/',$zip = substr($s,2,5))) {

takes advantage of a shortcut in PHP that an assignment operation also returns the value being assigned. $zip = substr($s,2,5) assigns the five-character substring of the line that holds the ZIP code to $zip. The value of the entire expression "$zip = substr($s,2,5)" is also that substring, so preg_match() thinks its second argument is the value of $zip. If this value matches the regular expression, then preg_match() returns true. You can do the same thing in multiple steps:

$zip = substr($s,2,5);
if (preg_match('/^\d{5}$/',$zip)) {

If the ZIP code passes numeric muster, then it goes into the database:

$q = $dbh->query(
	'INSERT INTO zcta (zip,lat,lon) VALUES (?,?,?)',
	array($zip,substr($s,136,10),substr($s,146,11)));

The first argument to the PEAR DB query() method is a SQL statement. Here, ? is a placeholder. When the query is executed, the placeholders are replaced with the values in the array passed as the second argument to query(): the ZIP code, the longitude, and the latitude. substr() extracts the longitude and latitude.

PHP Cookbook

Related Reading

PHP Cookbook
By David Sklar, Adam Trachtenberg

Map

We need a map on which to draw red lines. The Institute for the Study of the Continents at Cornell University has an Interactive Mapping Tool that we can use. Adjusting the map extent and turning on state borders produces the map shown in Figure 1.


Figure 1 - a US map with state borders

To turn longitude and latitude into a spot in the image, you need to translate a coordinate from longitude and latitude into x and y coordinates. The map in Figure 1 runs from -125 degrees longitude to -67 degrees longitude and is 602 pixels wide. This means that -125 degrees longitude has an x coordinate of 0, and -67 degrees longitude has an x coordinate of 601. Since 602 pixels represent 58 degrees of longitude, 1 degree of longitude corresponds to about 10.4 pixels (602 / 58). In latitude, the map runs from 23 degrees to 50 degrees and is 324 pixels high. Twelve pixels represent 1 degree of latitude (324 / (50 - 23)).

Using this logic, the latlon_to_pix() function translates latitude and longitude to x and y coordinates. The range of x and y coordinates in the image are set in $x_min, $x_max, $y_min, and $y_max. The range of latitude and longitude are set in $lon_min, $lon_max, $lat_min, and $lat_max. To simplify calculations, the latitude signs have been switched from negative to positive.

function latlon_to_pix($lat,$lon) {
	$lat = abs($lat);
	$lon = abs($lon);

	$x_min   = 0;  $x_max   = 602;
	$y_min   = 0;  $y_max   = 324;
	$lon_min = 67; $lon_max = 125;
	$lat_min = 23; $lat_max = 50;

	$x = $x_min + ($x_max - $x_min) * 
		( 1 - ($lon - $lon_min) / ($lon_max - $lon_min) );
	$y = $y_max - ($y_max - $y_min) * 
		( ($lat - $lat_min) / ($lat_max - $lat_min) );
	return array(intval($x),intval($y));
}

Drawing

With the data in the database and an appropriate map, you have all of the ingredients to create the image. The image display code follows. If it's not passed any ZIP codes, it displays a form to gather them.

// parse any ZIP codes in $_REQUEST['zip'] into an array
$zips = preg_split('/[^0-9]+/',$_REQUEST['zip'], 
                   -1, PREG_SPLIT_NO_EMPTY);

if (count($zips)) {
	$dbh = DB::connect('mysql://test:@localhost/test') 
		or die($php_errormsg);
	DB::isError($dbh) and die(print_r($dbh));

	$im = imagecreatefromjpeg('us.jpg');
	$red = imagecolorallocate($im,255,0,0);
	imagesetthickness($im,5);
	$x1 = $y1 = null;
	foreach ($zips as $zip) {
		$row = $dbh->getRow(
			'SELECT lat,lon FROM zcta WHERE zip LIKE ?',
			array($zip));
		DB::isError($row)
			and die("Can't get coordinates for $zip");
		is_null($row)
			and die("No coordinates for $zip");
		list($x,$y) = latlon_to_pix($row[0],$row[1]);
		if (is_null($x1) && is_null($y1)) {
			$x1 =$x; $y1 = $y;
		} else {
			imageline($im,$x1,$y1,$x,$y,$red);
			$x1 = $x; $y1 = $y;
		}
	}
	header('Content-type: image/jpeg');
	imagejpeg($im);
	imagedestroy($im);
} else {
	print<<<_HTML_
<form method="post" action="$_SERVER[PHP_SELF]">
Enter some ZIP Codes to have the path between them mapped:
<br>
<textarea name="zip" rows="4" cols="40"></textarea>
<br>
<input type="submit" value="Map It!">
</form>
_HTML_;
}

If $_REQUEST['zip'] contains ZIP codes (parsed by preg_split()), then $dbh and $im will contain a database connection and an image handle, respectively. Using imagecreatefromjpeg() loads us.jpg and returns a handle so you can draw lines on the image. After adding red to the color palette with imagecolorallocate(), call imagesetthickness() so that the lines drawn are bold on the map. Then, loop through each ZIP code and retrieve its latitude and longitude from the database. If $row contains latitude and longitude coordinates, translate them to x and y coordinates with latlon_to_pix() and draw a line with imageline(). Each time through the loop, the newly generated x and y coordinates are saved in $x1 and $y1. These become the starting point of the line the next time through the loop. The first time through the loop, no line is drawn, but the coordinates are saved for the next iteration.

After all the lines are drawn, header() tells the browser to expect a JPEG image. imagejpeg() sends the image data to the browser. imagedestroy() frees the memory allocated for the image.


Figure 2 - our circuitous tripometer

The map shown in Figure 2 is a trip from ZIP code 19151 (Philadelphia, PA) to 60615 (Chicago, IL) to 33433 (Boca Raton, FL) to 98052 (Redmond, WA) to 95472 (Sebastopol, CA). The ZIP codes, their longitudes, latitudes, x, and y coordinates are:

ZIP code Longitude Latitude x y
19151-75.2539.97516120
60615-87.6041.8038898
33433-80.1526.34465283
98052-122.1247.672927
95472-122.8338.3922139

The Census data file contains lots of additional information that might make interesting extensions to this program, such as drawing circles with diameters proportional to the population at each ZIP code. You could also calculate the total distance traveled using the code at http://px.sklar.com/code.html?id=88.

David Sklar is an independent consultant in New York City, the author of O'Reilly's Learning PHP 5, and a coauthor of PHP Cookbook.


O'Reilly & Associates will soon release (November 2002) PHP Cookbook .


Return to the PHP DevCenter.

Copyright © 2009 O'Reilly Media, Inc.