Source code for clld.web.maps

"""Functionality to configure leaflet maps from python."""
from __future__ import unicode_literals, division, print_function, absolute_import
import requests
from six import string_types
from clldutils.misc import cached_property

from clld.interfaces import IDataTable, IMapMarker, IIcon
from clld.web.util import helpers
from clld.web.util.htmllib import HTML
from clld.web.util.component import Component
from clld.web.adapters.geojson import GeoJson, GeoJsonCombinationDomainElement, get_lonlat


[docs]class Layer(object): """Represents a layer in a leaflet map. A layer in our terminology is a `FeatureCollection <http://geojson.org/geojson-spec.html#feature-collection-objects>`_ in geojson and a `geoJson layer <http://leafletjs.com/reference.html#geojson>`_ in leaflet, i.e. a bunch of points on the map. """
[docs] def __init__(self, id_, name, data, **kw): """Initialize a layer object. :param id_: Map-wide unique string identifying the layer. :param name: Human readable name of the layer. :param data: A GeoJSON FeatureCollection either specified as corresponding Python\ dict or as URL which will serve the appropriate GeoJSON. :param kw: Additional keyword parameters are made available to the Layer as \ instance attributes. """ self.id = id_ self.name = name self.data = data for k, v in kw.items(): setattr(self, k, v)
class Legend(object): """Represents a navpill with a dropdown above a map.""" def __init__(self, map_, name, items, label=None, stay_open=False, item_attrs=None, pull_right=False): self.map = map_ self.name = name self.label = label or name.capitalize() self.items = items self.stay_open = stay_open self.item_attrs = item_attrs or {} self.pull_right = pull_right def format_id(self, suffix=None): suffix = suffix or '' if suffix: suffix = '-' + suffix return 'legend-%s%s' % (self.name, suffix) def render_item(self, item): if not isinstance(item, (tuple, list)): item = [item] attrs = self.item_attrs if self.stay_open: class_ = attrs.get('class', attrs.get('class_', '')) attrs['class'] = class_ + ' stay-open' return HTML.li(*item, **attrs) def render(self): a_attrs = { 'class': 'dropdown-toggle', 'data-toggle': "dropdown", 'href': "#", 'id': self.format_id('opener')} ul_class = 'dropdown-menu' if self.stay_open: ul_class += ' stay-open' return HTML.li( HTML.a(self.label, HTML.b(class_='caret'), **a_attrs), HTML.ul( *map(self.render_item, self.items), **dict(class_=ul_class, id=self.format_id('container'))), class_='dropdown' + (' pull-right' if self.pull_right else ''), id=self.format_id(), ) class FilterLegend(Legend): """Legend with actionable items. Legend rendering radio controls to filter languages on a map in sync with a column of an associated DataTable. """ def __init__(self, map_, value_getter, col=None, dt=None, **kw): """Initialize. @param value_getter: Name of a javascript object which will be called with the \ properties associated with a map marker to determine its filter value. """ kw.setdefault('stay_open', True) if col and dt: col = {c.name: c for c in dt.cols}.get(col) if col: kw.setdefault('label', col.js_args.get('sTitle')) Legend.__init__(self, map_, col.name if col else 'nocol', [], **kw) self.jsname = 'fl-' + self.name items = [self.li(col, '--any--', value_getter, checked=True)] for item in getattr(col, 'choices', []): items.append(self.li(col, item, value_getter)) self.items = items def li_label(self, item): return item[1] if isinstance(item, (tuple, list)) else item def li(self, col, item, value_getter, checked=False): input_attrs = dict( type='radio', class_='stay-open %s inline' % self.jsname, name=self.jsname, value=item[0] if isinstance(item, (tuple, list)) else item, onclick=helpers.JS("CLLD.mapLegendFilter")( self.map.eid, self.name, self.jsname, helpers.JS(value_getter), col.dt.eid if col else None)) if checked: input_attrs['checked'] = 'checked' return HTML.label( HTML.input(**input_attrs), ' ', self.li_label(item), class_="stay-open", style="margin-left:5px; margin-right:5px;", )
[docs]class Map(Component): """Represents the configuration for a leaflet map.""" __template__ = 'clld:web/templates/map.mako'
[docs] def __init__(self, ctx, req, eid='map'): """Initialize. :param ctx: context object of the current request. :param req: current pyramid request object. :param eid: Page-unique DOM-node ID. """ self.req = req self.ctx = ctx self.eid = eid self.map_marker = req.registry.getUtility(IMapMarker)
def get_options_from_req(self): params = self.req.params res = {} try: if 'lat' in params and 'lng' in params: res['center'] = list(map(float, [params['lat'], params['lng']])) if 'z' in params: res['zoom'] = int(params['z']) except (ValueError, TypeError): pass return res @cached_property() def layers(self): """The list of layers of the map. .. note:: Since layers may be costly to compute, we cache them per map instance. :return: list of :py:class:`clld.web.maps.Layer` instances. """ return list(self.get_layers())
[docs] def get_layers(self): """Generate the list of layers. :return: list or generator of :py:class:`clld.web.maps.Layer` instances. """ route_params = {'ext': 'geojson'} if not IDataTable.providedBy(self.ctx): route_params['id'] = self.ctx.id route_name = self.req.matched_route.name if not route_name.endswith('_alt'): route_name += '_alt' yield Layer( getattr(self.ctx, 'id', 'id'), '%s' % self.ctx, self.req.route_url(route_name, **route_params))
@cached_property() def legends(self): return list(self.get_legends()) def get_legends(self): if len(self.layers) > 1: items = [] total = 0 repr_attrs = dict(class_='pull-right stay-open', style="padding-right: 10px;") for layer in self.layers: representation = '' if hasattr(layer, 'representation'): total += layer.representation representation = HTML.span(str(layer.representation), **repr_attrs) items.append([ HTML.label( HTML.input( class_="stay-open", type="checkbox", checked="checked", onclick=helpers.JS_CLLD.mapToggleLayer( self.eid, layer.id, helpers.JS("this"))), getattr(layer, 'marker', ''), layer.name, class_="checkbox inline stay-open", style="margin-left: 5px; margin-right: 5px;", ), representation, ]) if total: items.append(HTML.span(HTML.b(str(total)), **repr_attrs)) yield Legend( self, 'layers', items, label='Legend', stay_open=True, item_attrs=dict(style='clear: right')) items = [] for size in [15, 20, 30, 40]: attrs = dict(name="iconsize", value=str(size), type="radio") if size == self.options.get('icon_size', 30): attrs['checked'] = 'checked' items.append(HTML.label( HTML.input(onclick=helpers.JS_CLLD.mapResizeIcons(self.eid), **attrs), HTML.img( height=str(size), width=str(size), src=self.req.registry.getUtility(IIcon, 'cff6600').url(self.req)), class_="radio", style="margin-left: 5px; margin-right: 5px;")) yield Legend( self, 'iconsize', items, label='Icon size') item = lambda layer: HTML.a( layer.name, onclick='return %s;' % helpers.JS_CLLD.mapShowGeojson(self.eid, layer.id), href=layer.data if isinstance(layer.data, string_types) else '#') yield Legend( self, 'geojson', map(item, self.layers), label='GeoJSON', pull_right=True)
class ParameterMap(Map): """Map displaying markers for valuesets associated with a parameter instance.""" def get_layers(self): if self.ctx.domain: for de in self.ctx.domain: yield Layer( de.id, de.name, self.req.resource_url( self.ctx, ext='geojson', _query=dict(domainelement=str(de.id), **self.req.query_params) ), marker=helpers.map_marker_img(self.req, de, marker=self.map_marker)) else: yield Layer( self.ctx.id, self.ctx.name, self.req.resource_url(self.ctx, ext='geojson')) def get_default_options(self): return {'info_query': {'parameter': self.ctx.pk}, 'hash': True} class GeoJsonMultiple(GeoJson): """Render a collection of languages as geojson feature collection.""" def feature_iterator(self, ctx, req): return ctx def feature_properties(self, ctx, req, language): return { 'icon': req.registry.getUtility(IIcon, 'tff0000').url(req), 'icon_size': 10, 'zindex': 1000} class CombinationMap(Map): """Map for a combination of parameters.""" def get_layers(self): for de in self.ctx.domain: if de.languages: yield Layer( de.id, de.name, GeoJsonCombinationDomainElement(de).render(de, self.req, dump=False), marker=HTML.img(src=de.icon.url(self.req), height='20', width='20')) if self.ctx.multiple: # yield another layer which can be used to mark languages with multiple # values, because this may not be visible when markers are stacked on top # of each other. icon_url = self.req.registry.getUtility(IIcon, 'tff0000').url(self.req) yield Layer( '__multiple__', 'Languages with multiple values', GeoJsonMultiple(None).render(self.ctx.multiple, self.req, dump=False), marker=HTML.img(src=icon_url, height='20', width='20')) def get_options(self): return {'icon_size': 25, 'hash': True} class LanguageMap(Map): """Map showing a single language.""" def get_layers(self): yield Layer( self.ctx.id, self.ctx.name, GeoJson(self.ctx).render(self.ctx, self.req, dump=False)) def get_default_options(self): return { 'center': list(reversed(get_lonlat(self.ctx) or [0, 0])), 'zoom': 3, 'no_popup': True, 'no_link': True, 'sidebar': True} class GeoJsonSelectedLanguages(GeoJson): """Represents the geo-data of an iterable selection of languages. The iterable is assumed to be passed into the adapter upon initialization. """ def feature_iterator(self, ctx, req): return self.obj class SelectedLanguagesMap(Map): """Map showing an arbitrary selection of languages.""" def __init__(self, ctx, req, languages, geojson_impl=None, **kw): """Initialize. :param languages: Iterable collection of languages. :param geojson_impl: GeoJson implementation to use. """ self.geojson_impl = geojson_impl or GeoJsonSelectedLanguages self.languages = languages Map.__init__(self, ctx, req, **kw) def get_options(self): return {'icon_size': 20, 'hash': True, 'show_labels': len(self.languages) < 100} def get_layers(self): yield Layer( 'languages', 'Languages', self.geojson_impl(self.languages).render(self.ctx, self.req, dump=False)) # # The following code implements a map to overlay geojson for parameters from distinct # datasets. It may be used by CrossGram at some point. # def layers(spec, size, zindex=0): # pragma: no cover app, pid, url = spec def normalize(geojson): for f in geojson['features']: f['properties']['icon_size'] = size f['properties']['zindex'] = zindex if f['geometry']['coordinates'][0] > 180: f['geometry']['coordinates'][0] = f['geometry']['coordinates'][0] - 360 return geojson geojson = requests.get(url + '.geojson').json() id_ = '-'.join([app, pid]) if geojson['properties']['domain'] and not geojson['features']: for de in geojson['properties']['domain']: yield Layer( '-'.join([id_, de['id']]), '%s: %s - %s' % (app, geojson['properties']['name'], de['name']), normalize( requests.get(url + '.geojson?domainelement=' + de['id']).json()), size=size, link=url, marker=HTML.img(src=de['icon'], width=size, height=size)) else: yield Layer( id_, '%s: %s' % (app, geojson['properties']['name']), normalize(geojson), size=size, link=url, domain=geojson['properties']['domain']) class CombinedMap(Map): # pragma: no cover """Map for combination of parameters from different clld apps.""" def get_layers(self): for i, spec in enumerate(self.ctx): for layer in layers(spec, (i + 1) * 10 + 10, (i + 1) * (-1000)): yield layer def get_options(self): return {'no_popup': True, 'no_link': True}