# mapserv.py - written by Schuyler Erle <schuyler@geocoder.us>
#
# This code is (c)opyright 2005 Schuyler Erle, and is redistributable under the
# GNU Lesser General Public License version 2 or later. Please see
# http://www.gnu.org/licenses/lgpl.html for more details.

"""mapserv.py aims to provide an easy-to-deploy frontend to UMN MapServer using
cross-browser compatible JavaScript. mapserv.py also supports setting
"markers", which can be provided on the CGI query string, or as a URL of a list
of markers to be fetched and parsed. mapserv.py uses Cheetah to generate HTML
from templates.

The CGI query string API looks like this:

    extent = minx+miny+maxx+maxy
    size = xsize+ysize
    marker = name,lat,lon,marker,link[,key1=value1,...]
    marker_url = url

"""

import cgi, cgitb, urllib, urllib2, random, time, math, sys
import mapscript
from Cheetah.Template import Template

# thanks, Ping!
cgitb.enable()

"""mapserv.py currently has a single class, mapserv.Map."""

class Map:
    inchesPerUnit = [1, 12, 63360.0, 39.3701, 39370.1, 4374754]

    def __init__ (self, mapfile, template = None,
	    zoom_levels = 8, scale_base = 3, scale_factor = None, 
	    default_control = None, q = None):
	"""The Map() constructor takes a mandatory mapfile argument,
	and the following named arguments: template, zoom_levels, scale_base,
	scale_factor, and q. The template argument is also required."""

	"""The Map object encapsulates a mapscript.mapObj object,
	instantiated with the provided mapfile path."""
	self.map	= mapscript.mapObj( mapfile )
	ext		= self.map.extent
	self.maxext	= mapscript.rectObj(
				ext.minx, ext.miny, ext.maxx, ext.maxy )

	"""We need to know the X and Y resolution of the map to figure out
	where to put the query areas."""
	self.x_res = self.map.width / (ext.maxx - ext.minx)
	self.y_res = self.map.height / (ext.maxy - ext.miny)

	"""The template parameter specifies an HTML Cheetah template file.
	This parameter is required."""
	self.template	 = template

	"""The zoom_levels parameter specifies the number of zoom
	levels available for this map, and the scale_factor specifies
	a constant scaling factor for map generation. If left unset,
	zoom_levels defaults to 8, and scale_factor is computed from
	the maximum scale of the map extents (as defined in the mapfile).""" 
	self.zoom_levels	= zoom_levels	
	self.scale_base		= scale_base
	self.scale_factor	= scale_factor
	if scale_factor is None:
	    self.scale_factor = self.compute_scale()

	"""The actual map scale is computed by dividing the scale_factor by
	scale_base taken to the power of the current zoom level minus one.
	With the default scale_base of 3, that would mean that each increment
	in zoom level will correspond to a 3x zoom in map scale."""
	self.scale_base = scale_base

	"""The q named parameter allows you to specify a CGI FieldStorage
	object. If left to None, a new cgi.FieldStorage is constructed."""
	self.q = q
	if q is None:
	    self.q = cgi.FieldStorage()

	"""The Map() object contains various other attributes for storing
	the query string parameters and the requested markers."""
	self.markers	 = []
	self.marker_list = []
	self.marker_url	 = ""
	self.size	 = ""

	"""The Map() object also contains some logic for default and 
	custom map controls."""
	self.controls	 = []
	self.default_control = default_control	

	"""The class cache is defined on a per-style basis to get around
	having to define a new class for each marker, and thereby avoid
	the per-layer class limit."""
	self.marker_cache = {}
	self.marker_layer = None

	"""By default, the zoom level is set to 1."""
	self.set_zoom_level(1)

    def compute_scale (self, x_range = None):
	map = self.map
	if x_range is None:
	    gd = map.extent.maxx - map.extent.minx
	else:
	    minx, maxx = x_range
	    gd = maxx - minx
	md = map.width / (map.resolution * self.inchesPerUnit[map.units]) 
	scale = gd / md
	return scale

    def set_zoom_level (self, zoom = None):
	"""The zoom attribute should not be set directly; instead, the
	set_zoom_level() method should be used. If no zoom is provided, then
	the zoom level should be computed from the map scale and rounded to
	the nearest integer."""
	if zoom is None:
	    scale = self.compute_scale()
	    zoom = 1.5 + math.log(
		self.scale_factor / scale, 3.0)
	self.zoom = min(max(int(zoom), 1), self.zoom_levels)

    def get_scale (self):
	"""get_scale() computes the desired map scale from the current
	zoom level, as described above."""
	return self.scale_factor / self.scale_base ** (self.zoom - 1)

    def zoom_to (self, zoom, xy = None, latlon = None):
	map = self.map

	"""If the zoom level is less than one or more than
	self.zoom_levels, adjust it accordingly."""
	if zoom < 1:
	    zoom = 1
	elif zoom >= self.zoom_levels:
	    zoom = self.zoom_levels
	self.set_zoom_level( zoom )

	"""Finally, the map object is zoomed to the computed 
	scale and center point, and the extents are adjusted.
	A wider maximum extent is used to make it possible to
	zoom to the edges of the map, unless the zoom level is
	set to 1."""
	ext = self.maxext
	rect = None
	if zoom > 1:
	    x_margin = (ext.maxx - ext.minx) / 2
	    y_margin = (ext.maxy - ext.miny) / 2
	    rect = mapscript.rectObj(
		ext.minx - x_margin,
		ext.miny - y_margin,
		ext.maxx + x_margin,
		ext.maxy + y_margin )
	else:
	    rect = ext

	if latlon is not None:
	    lat, lon = latlon
	    point = self.project_lon_lat( float(lon), float(lat) )
	    box = self.maxext
	    point.x = (point.x - box.minx) * map.width / (
						    box.maxx - box.minx)
	    point.y = (box.maxy - point.y) * map.height / (
						    box.maxy - box.miny)

	    map.zoomScale( self.get_scale(), point,
		map.width, map.height, self.maxext, rect )
	else:
	    x, y = map.width / 2, map.height / 2
	    if xy:
		x, y = xy
	    point = mapscript.pointObj( float(x), float(y) )
	    map.zoomScale( self.get_scale(), point,
		map.width, map.height, map.extent, rect )

    def fix_extents (self, q):
	map = self.map 

	"""If an 'extent' parameter is provided via query string, the
	current map extents are set."""
	if q.has_key("extent"):
	    extent = q["extent"].value
	    left, top, right, bottom = extent.split(" ")
	    map.setExtent(float(left), float(top), float(right), float(bottom))
    
	"""If a 'size' parameter is provided via query string, the
	map size is set."""
	if q.has_key("size"):
	    self.size = q["size"].value
	    width, height = self.size.split(" ")
	    map.setSize(int(width), int(height))

	"""If a 'zoom' parameter is provided via query string, the
	zoom level is set, and the map scale is recalculated.
	Several special cases are handled."""
	if q.has_key("zoom"):
	    zoom, x, y = 1, map.width / 2, map.height / 2
	    try:
		zoom, x, y = q["zoom"].value.split(" ", 2)
	    except:
		zoom = q["zoom"].value	
	    x, y, zoom = int(float(x)), int(float(y)), int(zoom)
	    if q.has_key("center"):
		center = q["center"].value.split(" ", 1)
		self.zoom_to( zoom, latlon = center )
	    else:
		self.zoom_to( zoom, xy = (x, y) )
	else:
	    """If no 'zoom' parameter is provided, the zoom level
	    is calculated from the current map extents."""
	    self.set_zoom_level()

    def draw (self):
	"""The draw() method does all of the heavy lifting for the Map
	object, generating HTML or an image, with appropriate HTTP
	headers, as needed."""
	map			= self.map
	q			= self.q

	"""Do extent/zoom settings based on the query string,
	unless it's already been done."""
	if q:
	    self.fix_extents(q)

	"""If one or more 'marker' parameters are specified in the query
	string, pass each one to add_marker_to_layer() with our new
	point layer, and append the returned tuples to self.points (the
	Python list) and self.marker_list (the JavaScript strings used
	when the map is zoomed or panned)."""

	if q.has_key("marker"):
	    for csv in q.getlist("marker"):
		marker = Marker(csv = csv)
		pt = self.add_marker_to_layer(pt_layer, marker)
		self.marker_list.append(marker)

	"""If a 'marker_url' parameter is specified in the query string, then
	fetch the URL, and pass each line to add_marker_to_layer() with our new
	point layer. Append the returned tuples to self.points (the Python
	list) and self.marker_list (the JavaScript strings used when the map is
	zoomed or panned). No error checking is done on the HTTP request."""

	if q.has_key("marker_url"):
	    self.marker_url = q["marker_url"].value
	    csv = urllib2.urlopen( self.marker_url )
	    for line in csv:
		marker = Marker(csv = line.strip())
		pt = self.add_marker_to_layer(pt_layer, marker)

	"""If any markers were added, then the marker layer is inserted
	into the map object."""
	if self.markers:
	    map.insertLayer(self.marker_layer)

	"""The current extent string is computed for the benefit of
	the JavaScript loader."""
	self.extent = "%.4f %.4f %.4f %.4f" % (
	    map.extent.minx, map.extent.miny,
	    map.extent.maxx, map.extent.maxy)

	"""If a 'mode' parameter is specified via query string, its
	value is examined."""
	mode = ""
	if q.has_key("mode"):
	    mode = str( q["mode"].value )

	"""Check the default control. If no control is set, use
	a sensible default."""
	if not self.default_control:
	    if self.zoom == 1:
		self.default_control = "zoom_in"
	    else:
		self.default_control = "pan_shift"

	"""Currently, the only supported mode is "overview", which is
	broken. Don't use it."""
	if mode == "overview":
	    image = map.drawReferenceMap()
	    print "Content-type: " + image.format.mimetype + "\n"
	    image.write()
	else:
	    if mode != "imagemap":
		self.filename = self.render_image()

	    """self.areas is populated by calling self.compute_areas() after
	    all the markers are added. The list of dicts stored thusly is used 
	    by the HTML template to produce the HTML image map for the markers."""
	    self.areas = self.compute_areas()

	    """Finally, the HTML is generated from
	    the supplied template, and printed to stdout with a suitable
	    HTTP header."""
	    template = Template(
		file = self.template,
		searchList = [ self, map, { "mode": mode } ]
	    )

	    print "Content-type: text/html\n\n"
	    print template
    
    def project_lon_lat( self, lon, lat ):
	point = mapscript.pointObj( float( lon ), float( lat ))
	point.project(mapscript.projectionObj("proj=latlong"),
		      mapscript.projectionObj(self.map.getProjection()))
	return point

    def add_marker_to_layer( self, layer, marker ):
	"""Finally, a tuple containing the point geometry and the link
	attribute of the marker is returned, so that the image map
	can be generated later."""
	self.markers.append(marker)

    def generate_marker_class (self, marker):
	hexval = marker.color
	if hexval[0] != "#":
	    hexval = "#" + hexval
	color = mapscript.colorObj()
	color.setHex(hexval)

	outline = mapscript.colorObj()
	outline.setHex('#ffffff')

	"""add_marker_to_layer() creates a new styleObj to contain the
	styling (symbol, color, outline color, and size) of the marker.
	Currently the marker size is hardcoded to 20 px."""
	style		    = mapscript.styleObj()
	style.symbol	    = self.map.getSymbolByName(marker.symbol)
	style.color	    = color
	style.outlinecolor  = outline
	style.size	    = int( marker.size )

	"""add_marker_to_layer() then creates a new classObj to contain
	the point feature. If the point has a name, then the class
	labelObj is populated with the specified font.	The label
	size is hardcoded to half of whatever the marker size is. The
	styleObj is applied to the class, then the new class is stored
	in the layer."""

	pt_class = mapscript.classObj()
	pt_class.label.type	     = mapscript.MS_TRUETYPE
	pt_class.label.antialias     = mapscript.MS_TRUE
	pt_class.label.position	     = mapscript.MS_AUTO
	pt_class.label.color	     = style.color
	pt_class.label.outlinecolor  = outline
	pt_class.label.font	     = marker.font
	pt_class.label.size	     = int(style.size / 2)
	pt_class.insertStyle(style)
	return pt_class	

    def add_marker_to_layer( self, marker, layer = None ):
	if layer is None:
	    if self.marker_layer is None:
		"""The draw() method instantiates a new point layer to hold
		any requested markers."""
		layer		  = mapscript.layerObj()
		layer.status	  = mapscript.MS_DEFAULT
		layer.type	  = mapscript.MS_LAYER_POINT
		self.marker_layer = layer
	    else:
		layer = self.marker_layer

	"""Finally, a tuple containing the point geometry and the link
	attribute of the marker is returned, so that the image map
	can be generated later."""
	self.markers.append(marker)

	class_id = None
	cache_item = (
	    layer, marker.symbol, marker.color, marker.size, marker.font )

	if self.marker_cache.has_key(cache_item):
	    class_id = self.marker_cache[cache_item]
	else:
	    pt_class = self.generate_marker_class(marker)
	    class_id = layer.insertClass(pt_class)
	    self.marker_cache[cache_item] = class_id

	"""Next, add_marker_to_layer() creates the actual point
	geometry (and parent line and shape geometries), projects the
	point from lat/lon into the map projection, assigns the
	label, and adds the new shape to the layer."""
	point = self.project_lon_lat( marker.lon, marker.lat )

	line = mapscript.lineObj()
	line.add(point)
	shape = mapscript.shapeObj(mapscript.MS_SHAPE_POINT)
	shape.add(line)
	if marker.name:
	    shape.text = marker.name
	shape.text = marker.name
	shape.classindex = class_id
	shape.setBounds()
	marker.feature = shape

	layer.addFeature(shape)    

    def point_to_pixel (self, pt):
	"""The points_to_pixels() method takes a list of (point, link)
	tuples, and returns a list of dicts. Each entry in the returned
	dict has an 'x' and 'y' key representing the point's location
	in pixel space, and 'url' key containing the link."""
	x = int((pt.x - self.map.extent.minx) * self.x_res + .5)
	y = int((self.map.extent.maxy - pt.y) * self.y_res + .5)
	return x, y

    def render_image (self):
	"""Then the map image is generated
	and stored to a temp file."""
	filename = "MS%10d%4d.jpg" % (
		    time.time(), random.randint(1000, 9999))
	image = self.map.draw()
	image.save(self.map.web.imagepath + "/" + filename)
	return filename

    def compute_areas (self):
	areas = []
	for marker in self.markers:
	    if marker.link:
		pt = marker.feature.get(0).get(0)
		x, y = self.point_to_pixel(pt)
		if x >= 0 and x <= self.map.width and \
		   y >= 0 and y <= self.map.height:
			continue

		"""Just link the area around the marker icon."""
		area = {'type': 'circle'}
		area["points"] = ",".join(x, y, int(marker.size / 2))
		area["link"] = marker.link

		areas.append(area)

	return areas

class Marker:
    def __init__ (self, csv = None):
	self.font  = 'universe'
	self.color = 'ffffff'
	self.size  = 20

    def render_image (self):
	"""Then the map image is generated
	and stored to a temp file."""
	filename = "MS%10d%4d.jpg" % (
		    time.time(), random.randint(1000, 9999))
	image = self.map.draw()
	image.save(self.map.web.imagepath + "/" + filename)
	return filename

    def compute_areas (self):
	areas = []
	for marker in self.markers:
	    if marker.link:
		pt = marker.feature.get(0).get(0)
		x, y = self.point_to_pixel(pt)
		if x <= 0 or x >= self.map.width or \
		   y <= 0 or y >= self.map.height:
			continue

		"""Just link the area around the marker icon."""
		size = int(marker.size) / 2
		area = {'type': 'circle'}
		area["points"] = ",".join( (str(x), str(y), str(size)) )
		area["link"] = marker.link

		areas.append(area)

	return areas

class Marker:
    fields = [
	"lat", "lon", "symbol", "color", "size", "name", "font", "link"]

    def __init__ (self, csv = None):
	self.lat   = None
	self.lon   = None
	self.symbol = None
	self.color = 'ff0000'
	self.size  = 20
	self.name  = ''
	self.font  = 'univers'
	self.link  = ''

	"""add_marker_to_layer() takes a layer and a marker_definition
	string, and adds a point to the layer, with the specified marker,
	label, and color. The marker definition is as follows:

	lat,lon,symbol,color,size,name,font,link[,key1=value1,...]

	Color, size, name, font and link can be omitted, in which case
	they will be replaced by sensible defaults or not used.
	Currently key/value pairing is unsupported."""
	if csv:
	    args = csv.split(",")
	    fields = [
		"lat", "lon", "symbol", "color", "size", "name", "font", "link"]
	    marker = Marker()

	    for n in range(len(fields)):
		if len(args) > n and args[n]:
		    setattr( self, fields[n], args[n] )

    def __str__ (self):
	csv = ",".join(map(lambda x: str(getattr(self, x)), self.fields))
	return urllib.quote(csv)
