ocitysmap/ocitysmap2/renderers.py

379 wiersze
13 KiB
Python

# -*- coding: utf-8 -*-
# ocitysmap, city map and street index generator from OpenStreetMap data
# Copyright (C) 2010 David Decotigny
# Copyright (C) 2010 Frédéric Lehobey
# Copyright (C) 2010 Pierre Mauduit
# Copyright (C) 2010 David Mentré
# Copyright (C) 2010 Maxime Petazzoni
# Copyright (C) 2010 Thomas Petazzoni
# Copyright (C) 2010 Gaël Utard
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import cairo
import logging
import mapnik
import math
import os
import grid
import map_canvas
import shapes
l = logging.getLogger('ocitysmap')
class Renderer:
"""
The job of an OCitySMap layout renderer is to lay out the resulting map and
render it from a given rendering configuration.
"""
# Portrait paper sizes in milimeters
PAPER_SIZES = [('A5', 148, 210),
('A4', 210, 297),
('A3', 297, 420),
('A2', 420, 594),
('A1', 594, 841),
('A0', 841, 1189),
('US letter', 216, 279),
('100x75cm', 750, 1000),
('80x60cm', 600, 800),
('60x45cm', 450, 600),
('40x30cm', 300, 400),
('60x60cm', 600, 600),
('50x50cm', 500, 500),
('40x40cm', 400, 400),
('Best fit', 0, 0),
]
# The PRINT_SAFE_MARGIN_PT is a small margin we leave on all page borders
# to ease printing as printers often eat up margins with misaligned paper,
# etc.
PRINT_SAFE_MARGIN_PT = 15
GRID_LEGEND_MARGIN_RATIO = .02
# The DEFAULT_KM_IN_MM represents the minimum acceptable size in milimeters
# on the rendered map of a kilometer
DEFAULT_KM_IN_MM = 80
def __init__(self, rc, tmpdir):
self.rc = rc
self.tmpdir = tmpdir
self.canvas = None
self.grid = None
# Switch to landscape mode if the geographic bounding box is wider than
# tall.
geo_height_m, geo_width_m = rc.bounding_box.spheric_sizes()
if geo_width_m > geo_height_m:
self.rc.paper_width_mm, self.rc.paper_height_mm = \
self.rc.paper_height_mm, self.rc.paper_width_mm
l.debug('Switching to landscape mode (%.1fx%.1fcm)' %
self.rc.paper_width_mm/10.0,
self.rc.paper_height_mm/10.0)
self.paper_width_pt = Renderer.convert_mm_to_pt(self.rc.paper_width_mm)
self.paper_height_pt = Renderer.convert_mm_to_pt(self.rc.paper_height_mm)
def _create_map_canvas(self, graphical_ratio):
self.canvas = map_canvas.MapCanvas(self.rc.stylesheet,
self.rc.bounding_box,
graphical_ratio)
# Add the grid
self.grid = grid.Grid(self.canvas.get_actual_bounding_box(),
self.rc.rtl)
grid_shape = self.grid.generate_shape_file(
os.path.join(self.tmpdir, 'grid.shp'))
self.canvas.add_shape_file(grid_shape,
self.rc.stylesheet.grid_line_color,
self.rc.stylesheet.grid_line_alpha,
self.rc.stylesheet.grid_line_width)
def _draw_rectangle(self, ctx, x, y, width, height, line_width):
ctx.save()
ctx.set_line_width(line_width)
ctx.move_to(x, y)
ctx.rel_line_to(0, height)
ctx.rel_line_to(width, 0)
ctx.rel_line_to(0, - height)
ctx.close_path()
ctx.stroke()
ctx.restore()
def render_shade(self, shade_wkt):
# Add the grey shade
shade_shape = shapes.PolyShapeFile(
self.canvas.get_actual_bounding_box(),
os.path.join(self.tmpdir, 'shade.shp'),
'shade')
shade_shape.add_shade_from_wkt(shade_wkt)
self.canvas.add_shape_file(shade_shape, self.rc.stylesheet.shade_color,
self.rc.stylesheet.shade_alpha)
@staticmethod
def convert_mm_to_pt(mm):
return ((mm/10.0) / 2.54) * 72
# The next three methods are to be overloaded by the actual renderer.
def create_map_canvas(self):
"""Creates the Mapnik map canvas to the appropriate graphical ratio so
it can be scaled and fitted into the zone on the page dedicated to the
map."""
raise NotImplementedError
def render(self, surface, street_index):
"""Renders the map, the index and all other visual map features on the
given Cairo surface.
Args:
surface (cairo.Surface): the Cairo surface to render into.
street_index (index.StreetIndex): the street index, that would be
rendered by a renderer that supports it.
"""
raise NotImplementedError
@staticmethod
def get_compatible_paper_sizes(bounding_box, zoom_level,
resolution_km_in_mm):
"""Returns a list of the compatible paper sizes for the given bounding
box. The list is sorted, smaller papers first, and a "custom" paper
matching the dimensions of the bounding box is added at the end.
Args:
bounding_box (coords.BoundingBox): the map geographic bounding box.
zoom_level (int): the Mapnik zoom level to use, generally 16.
resolution_km_in_mm (int): size of a geographic kilometer in
milimeters on the rendered map.
Returns a list of tuples (paper name, width in mm, height in mm). Paper
sizes are represented in portrait mode.
"""
raise NotImplementedError
class PlainRenderer(Renderer):
"""
The PlainRenderer is the simplest renderer we have. It creates a full-page
map, with the overlayed features like the grid, grid labels, scale and
compass rose but no index.
"""
name = 'plain'
description = 'A basic, full-page layout for the map.'
def __init__(self, rc, tmpdir):
Renderer.__init__(self, rc, tmpdir)
self.grid_legend_margin_pt = \
min(Renderer.GRID_LEGEND_MARGIN_RATIO * self.paper_width_pt,
Renderer.GRID_LEGEND_MARGIN_RATIO * self.paper_height_pt)
self.map_area_width_pt = (self.paper_width_pt -
2 * (Renderer.PRINT_SAFE_MARGIN_PT +
self.grid_legend_margin_pt))
self.map_area_height_pt = (self.paper_height_pt -
2 * (Renderer.PRINT_SAFE_MARGIN_PT +
self.grid_legend_margin_pt))
def create_map_canvas(self):
self._create_map_canvas(float(self.map_area_width_pt) /
float(self.map_area_height_pt))
def render(self, surface, street_index):
"""..."""
l.info('PlainRenderer rendering on %dx%dmm paper.' %
(self.rc.paper_width_mm, self.rc.paper_height_mm))
rendered_map = self.canvas.get_rendered_map()
ctx = cairo.Context(surface)
ctx.translate(Renderer.PRINT_SAFE_MARGIN_PT,
Renderer.PRINT_SAFE_MARGIN_PT)
ctx.save()
ctx.translate(self.grid_legend_margin_pt,
self.grid_legend_margin_pt)
ctx.scale(self.map_area_width_pt / rendered_map.width,
self.map_area_height_pt / rendered_map.height)
# Render the map canvas to the Cairo surface
mapnik.render(rendered_map, ctx)
# Draw a rectangle around the map
self._draw_rectangle(ctx, 0, 0, rendered_map.width, rendered_map.height,
self.rc.stylesheet.grid_line_width)
ctx.restore()
# Place the vertical and horizontal square labels
self._draw_labels(ctx)
# TODO: map scale
# TODO: compass rose
surface.flush()
return surface
def _draw_centered_text(self, ctx, text, x, y):
ctx.save()
xb, yb, tw, th, xa, ya = ctx.text_extents(text)
ctx.move_to(x - tw/2.0 - xb, y - yb/2.0)
ctx.show_text(text)
ctx.stroke()
ctx.restore()
def _draw_labels(self, ctx):
ctx.save()
step_horiz = self.map_area_width_pt / self.grid.horiz_count
last_horiz_portion = math.modf(self.grid.horiz_count)[0]
step_vert = self.map_area_height_pt / self.grid.vert_count
last_vert_portion = math.modf(self.grid.vert_count)[0]
ctx.set_font_size(min(0.75 * self.grid_legend_margin_pt,
0.5 * step_horiz))
for i, label in enumerate(self.grid.horizontal_labels):
x = self.grid_legend_margin_pt + i * step_horiz
if i < len(self.grid.horizontal_labels) - 1:
x += step_horiz/2.0
elif last_horiz_portion >= 0.25:
x += step_horiz * last_horiz_portion/2.0
else:
continue
self._draw_centered_text(ctx, label,
x, self.grid_legend_margin_pt/2.0)
self._draw_centered_text(ctx, label,
x, self.map_area_height_pt +
3*self.grid_legend_margin_pt/2.0)
for i, label in enumerate(self.grid.vertical_labels):
y = self.grid_legend_margin_pt + i * step_vert
if i < len(self.grid.vertical_labels) - 1:
y += step_vert/2.0
elif last_vert_portion >= 0.25:
y += step_vert * last_vert_portion/2.0
else:
continue
self._draw_centered_text(ctx, label,
self.grid_legend_margin_pt/2.0, y)
self._draw_centered_text(ctx, label,
self.map_area_width_pt +
3*self.grid_legend_margin_pt/2.0, y)
ctx.restore()
@staticmethod
def get_compatible_paper_sizes(bounding_box, zoom_level,
resolution_km_in_mm=Renderer.DEFAULT_KM_IN_MM):
"""Returns a list of paper sizes that can accomodate the provided
bounding box at the given zoom level and print resolution."""
geo_width_m, geo_height_m = bounding_box.spheric_sizes()
paper_width_mm = geo_width_m/1000.0 * resolution_km_in_mm
paper_height_mm = geo_height_m/1000.0 * resolution_km_in_mm
l.debug('Map represents %dx%dm, needs at least %.1fx%.1fcm '
'on paper.' % (geo_width_m, geo_height_m,
paper_width_mm/10, paper_height_mm/10))
# Test both portrait and landscape orientations when checking for paper
# sizes.
valid_sizes = filter(lambda (name,w,h):
(paper_width_mm <= w and paper_height_mm <= h) or
(paper_width_mm <= h and paper_height_mm <= w),
Renderer.PAPER_SIZES)
# Add a 'Custom' paper format to the list that perfectly matches the
# bounding box.
valid_sizes.append(('Best fit', paper_width_mm, paper_height_mm))
return valid_sizes
# The renderers registry
_RENDERERS = [PlainRenderer]
def get_renderer_class_by_name(name):
"""Retrieves a renderer class, by name."""
for renderer in _RENDERERS:
if renderer.name == name:
return renderer
raise LookupError, 'The requested renderer %s was not found!' % name
def get_renderers():
"""Returns the list of available renderers' names."""
return _RENDERERS
if __name__ == '__main__':
import coords
import cairo
logging.basicConfig(level=logging.DEBUG)
bbox = coords.BoundingBox(48.8162, 2.3417, 48.8063, 2.3699)
zoom = 16
renderer_cls = get_renderer_class_by_name('plain')
papers = renderer_cls.get_compatible_paper_sizes(bbox, zoom)
print 'Compatible paper sizes:'
for p in papers:
print ' * %s (%.1fx%.1fcm)' % (p[0], p[1]/10.0, p[2]/10.0)
print 'Using first available:', papers[0]
class StylesheetMock:
def __init__(self):
self.path = '/home/sam/src/python/maposmatic/mapnik-osm/osm.xml'
self.grid_line_color = 'black'
self.grid_line_alpha = 0.9
self.grid_line_width = 2
self.zoom_level = 16
class RenderingConfigurationMock:
def __init__(self):
self.stylesheet = StylesheetMock()
self.bounding_box = bbox
self.paper_width_mm = papers[0][1]
self.paper_height_mm = papers[0][2]
self.rtl = False
config = RenderingConfigurationMock()
plain = renderer_cls(config, '/tmp')
surface = cairo.PDFSurface('/tmp/plain.pdf',
Renderer.convert_mm_to_pt(config.paper_width_mm),
Renderer.convert_mm_to_pt(config.paper_height_mm))
plain.create_map_canvas()
plain.canvas.render()
plain.render(surface, None)
surface.finish()