make layers completely customizable
[ikiwiki.git] / IkiWiki / Plugin / osm.pm
1 #!/usr/bin/perl
2 # Copyright 2011 Blars Blarson
3 # Released under GPL version 2
4
5 package IkiWiki::Plugin::osm;
6 use utf8;
7 use strict;
8 use warnings;
9 use IkiWiki 3.0;
10
11 sub import {
12         add_underlay("osm");
13         hook(type => "getsetup", id => "osm", call => \&getsetup);
14         hook(type => "format", id => "osm", call => \&format);
15         hook(type => "preprocess", id => "osm", call => \&preprocess);
16         hook(type => "preprocess", id => "waypoint", call => \&process_waypoint);
17         hook(type => "savestate", id => "waypoint", call => \&savestate);
18         hook(type => "cgi", id => "osm", call => \&cgi);
19 }
20
21 sub getsetup () {
22         return
23                 plugin => {
24                         safe => 1,
25                         rebuild => 1,
26                         section => "special-purpose",
27                 },
28                 osm_default_zoom => {
29                         type => "integer",
30                         example => "15",
31                         description => "the default zoom when you click on the map link",
32                         safe => 1,
33                         rebuild => 1,
34                 },
35                 osm_default_icon => {
36                         type => "string",
37                         example => "ikiwiki/images/osm.png",
38                         description => "the icon shown on links and on the main map",
39                         safe => 0,
40                         rebuild => 1,
41                 },
42                 osm_alt => {
43                         type => "string",
44                         example => "",
45                         description => "the alt tag of links, defaults to empty",
46                         safe => 0,
47                         rebuild => 1,
48                 },
49                 osm_format => {
50                         type => "string",
51                         example => "KML",
52                         description => "the output format for waypoints, can be KML, GeoJSON or CSV (one or many, comma-separated)",
53                         safe => 1,
54                         rebuild => 1,
55                 },
56                 osm_tag_default_icon => {
57                         type => "string",
58                         example => "icon.png",
59                         description => "the icon attached to a tag, displayed on the map for tagged pages",
60                         safe => 0,
61                         rebuild => 1,
62                 },
63                 osm_openlayers_url => {
64                         type => "string",
65                         example => "http://www.openlayers.org/api/OpenLayers.js",
66                         description => "Url for the OpenLayers.js file",
67                         safe => 0,
68                         rebuild => 1,
69                 },
70                 osm_layers => {
71                         type => "string",
72                         example => { OSM => 1,
73                                      Google => 'Hybrid',
74                         },
75                         description => "Layers to use in the map. If the value is 1, use the default for the map, otherwise the argument is a URL (for OSM layers, e.g. http://a.tile.stamen.com/toner/\${z}/\${x}/\${y}.png) or a type option for Google maps (Normal, Satellite, Hybrid or Physical).",
76                         safe => 0,
77                         rebuild => 1,
78                 },
79                 osm_layers_order => {
80                         type => "string",
81                         example => { 'OSM', 'Google' },
82                         description => "Display order for the layers. The first layer is the default layer, must match exactly the left side of the osm_layers hash.",
83                         safe => 0,
84                         rebuild => 1,
85                 },
86                 osm_google_apikey => {
87                         type => "string",
88                         example => "",
89                         description => "Google maps API key, Google layer not used if missing, see https://code.google.com/apis/console/ to get an API key",
90                         safe => 1,
91                         rebuild => 1,
92                 },
93 }
94
95 sub register_rendered_files {
96         my $map = shift;
97         my $page = shift;
98         my $dest = shift;
99
100         if ($page eq $dest) {
101                 my %formats = get_formats();
102                 if ($formats{'GeoJSON'}) {
103                         will_render($page, "$map/pois.json");
104                 }
105                 if ($formats{'CSV'}) {
106                         will_render($page, "$map/pois.txt");
107                 }
108                 if ($formats{'KML'}) {
109                         will_render($page, "$map/pois.kml");
110                 }
111         }
112 }
113
114 sub preprocess {
115         my %params=@_;
116         my $page = $params{page};
117         my $dest = $params{destpage};
118         my $loc = $params{loc}; # sanitized below
119         my $lat = $params{lat}; # sanitized below
120         my $lon = $params{lon}; # sanitized below
121         my $href = $params{href};
122
123         my ($width, $height, $float);
124         $height = scrub($params{'height'} || "300px", $page, $dest); # sanitized here
125         $width = scrub($params{'width'} || "500px", $page, $dest); # sanitized here
126         $float = (defined($params{'right'}) && 'right') || (defined($params{'left'}) && 'left'); # sanitized here
127         
128         my $zoom = scrub($params{'zoom'} // $config{'osm_default_zoom'} // 15, $page, $dest); # sanitized below
129         my $map;
130         $map = $params{'map'} || 'map';
131         
132         $map = scrub($map, $page, $dest); # sanitized here
133         my $name = scrub($params{'name'} || $map, $page, $dest);
134
135         if (defined($lon) || defined($lat) || defined($loc)) {
136                 ($lon, $lat) = scrub_lonlat($loc, $lon, $lat);
137         }
138
139         if ($zoom !~ /^\d\d?$/ || $zoom < 2 || $zoom > 18) {
140                 error("Bad zoom");
141         }
142
143         if (! defined $href || ! length $href) {
144                 $href=IkiWiki::cgiurl(
145                         do => "osm",
146                         map => $map,
147                 );
148         }
149
150         register_rendered_files($map, $page, $dest);
151
152         $pagestate{$page}{'osm'}{$map}{'displays'}{$name} = {
153                 height => $height,
154                 width => $width,
155                 float => $float,
156                 zoom => $zoom,
157                 fullscreen => 0,
158                 editable => defined($params{'editable'}),
159                 lat => $lat,
160                 lon => $lon,
161                 href => $href,
162                 google_apikey => $config{'osm_google_apikey'},
163         };
164         return "<div id=\"mapdiv-$name\"></div>";
165 }
166
167 sub process_waypoint {
168         my %params=@_;
169         my $loc = $params{'loc'}; # sanitized below
170         my $lat = $params{'lat'}; # sanitized below
171         my $lon = $params{'lon'}; # sanitized below
172         my $page = $params{'page'}; # not sanitized?
173         my $dest = $params{'destpage'}; # not sanitized?
174         my $hidden = defined($params{'hidden'}); # sanitized here
175         my ($p) = $page =~ /(?:^|\/)([^\/]+)\/?$/; # shorter page name
176         my $name = scrub($params{'name'} || $p, $page, $dest); # sanitized here
177         my $desc = scrub($params{'desc'} || '', $page, $dest); # sanitized here
178         my $zoom = scrub($params{'zoom'} // $config{'osm_default_zoom'} // 15, $page, $dest); # sanitized below
179         my $icon = $config{'osm_default_icon'} || "ikiwiki/images/osm.png"; # sanitized: we trust $config
180         my $map = scrub($params{'map'} || 'map', $page, $dest); # sanitized here
181         my $alt = $config{'osm_alt'} ? "alt=\"$config{'osm_alt'}\"" : ''; # sanitized: we trust $config
182         if ($zoom !~ /^\d\d?$/ || $zoom < 2 || $zoom > 18) {
183                 error("Bad zoom");
184         }
185
186         ($lon, $lat) = scrub_lonlat($loc, $lon, $lat);
187         if (!defined($lat) || !defined($lon)) {
188                 error("Must specify lat and lon");
189         }
190
191         my $tag = $params{'tag'};
192         foreach my $t (keys %{$typedlinks{$page}{'tag'}}) {
193                 if ($icon = get_tag_icon($t)) {
194                         $tag = $t;
195                         last;
196                 }
197                 $t =~ s!/$config{'tagbase'}/!!;
198                 if ($icon = get_tag_icon($t)) {
199                         $tag = $t;
200                         last;
201                 }
202         }
203         $icon = urlto($icon, $dest, 1);
204         $tag = '' unless $tag;
205         register_rendered_files($map, $page, $dest);
206         $pagestate{$page}{'osm'}{$map}{'waypoints'}{$name} = {
207                 page => $page,
208                 desc => $desc,
209                 icon => $icon,
210                 tag => $tag,
211                 lat => $lat,
212                 lon => $lon,
213                 # How to link back to the page from the map, not to be
214                 # confused with the URL of the map itself sent to the
215                 # embeded map below. Note: used in generated KML etc file,
216                 # so must be absolute.
217                 href => urlto($page),
218         };
219
220         my $mapurl = IkiWiki::cgiurl(
221                 do => "osm",
222                 map => $map,
223                 lat => $lat,
224                 lon => $lon,
225                 zoom => $zoom,
226         );
227         my $output = '';
228         if (defined($params{'embed'})) {
229                 $output .= preprocess(%params,
230                         href => $mapurl,
231                 );
232         }
233         if (!$hidden) {
234                 $output .= "<a href=\"$mapurl\"><img class=\"img\" src=\"$icon\" $alt /></a>";
235         }
236         return $output;
237 }
238
239 # get the icon from the given tag
240 sub get_tag_icon($) {
241         my $tag = shift;
242         # look for an icon attached to the tag
243         my $attached = $tag . '/' . $config{'osm_tag_default_icon'};
244         if (srcfile($attached)) {
245                 return $attached;
246         }
247         else {
248                 return undef;
249         }
250 }
251
252 sub scrub_lonlat($$$) {
253         my ($loc, $lon, $lat) = @_;
254         if ($loc) {
255                 if ($loc =~ /^\s*(\-?\d+(?:\.\d*°?|(?:°?|\s)\s*\d+(?:\.\d*\'?|(?:\'|\s)\s*\d+(?:\.\d*)?\"?|\'?)°?)[NS]?)\s*\,?\;?\s*(\-?\d+(?:\.\d*°?|(?:°?|\s)\s*\d+(?:\.\d*\'?|(?:\'|\s)\s*\d+(?:\.\d*)?\"?|\'?)°?)[EW]?)\s*$/) {
256                         $lat = $1;
257                         $lon = $2;
258                 }
259                 else {
260                         error("Bad loc");
261                 }
262         }
263         if (defined($lat)) {
264                 if ($lat =~ /^(\-?)(\d+)(?:(\.\d*)°?|(?:°|\s)\s*(\d+)(?:(\.\d*)\'?|(?:\'|\s)\s*(\d+(?:\.\d*)?\"?)|\'?)|°?)\s*([NS])?\s*$/) {
265                         $lat = $2 + ($3//0) + ((($4//0) + (($5//0) + (($6//0)/60.)))/60.);
266                         if (($1 eq '-') || (($7//'') eq 'S')) {
267                                 $lat = - $lat;
268                         }
269                 }
270                 else {
271                         error("Bad lat");
272                 }
273         }
274         if (defined($lon)) {
275                 if ($lon =~ /^(\-?)(\d+)(?:(\.\d*)°?|(?:°|\s)\s*(\d+)(?:(\.\d*)\'?|(?:\'|\s)\s*(\d+(?:\.\d*)?\"?)|\'?)|°?)\s*([EW])?$/) {
276                         $lon = $2 + ($3//0) + ((($4//0) + (($5//0) + (($6//0)/60.)))/60.);
277                         if (($1 eq '-') || (($7//'') eq 'W')) {
278                                 $lon = - $lon;
279                         }
280                 }
281                 else {
282                         error("Bad lon");
283                 }
284         }
285         if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
286                 error("Location out of range");
287         }
288         return ($lon, $lat);
289 }
290
291 sub savestate {
292         my %waypoints = ();
293         my %linestrings = ();
294
295         foreach my $page (keys %pagestate) {
296                 if (exists $pagestate{$page}{'osm'}) {
297                         foreach my $map (keys %{$pagestate{$page}{'osm'}}) {
298                                 foreach my $name (keys %{$pagestate{$page}{'osm'}{$map}{'waypoints'}}) {
299                                         debug("found waypoint $name");
300                                         $waypoints{$map}{$name} = $pagestate{$page}{'osm'}{$map}{'waypoints'}{$name};
301                                 }
302                         }
303                 }
304         }
305
306         foreach my $page (keys %pagestate) {
307                 if (exists $pagestate{$page}{'osm'}) {
308                         foreach my $map (keys %{$pagestate{$page}{'osm'}}) {
309                                 # examine the links on this page
310                                 foreach my $name (keys %{$pagestate{$page}{'osm'}{$map}{'waypoints'}}) {
311                                         if (exists $links{$page}) {
312                                                 foreach my $otherpage (@{$links{$page}}) {
313                                                         if (exists $waypoints{$map}{$otherpage}) {
314                                                                 push(@{$linestrings{$map}}, [
315                                                                         [ $waypoints{$map}{$name}{'lon'}, $waypoints{$map}{$name}{'lat'} ],
316                                                                         [ $waypoints{$map}{$otherpage}{'lon'}, $waypoints{$map}{$otherpage}{'lat'} ]
317                                                                 ]);
318                                                         }
319                                                 }
320                                         }
321                                 }
322                         }
323                         # clear the state, it will be regenerated on the next parse
324                         # the idea here is to clear up removed waypoints...
325                         $pagestate{$page}{'osm'} = ();
326                 }
327         }
328
329         my %formats = get_formats();
330         if ($formats{'GeoJSON'}) {
331                 writejson(\%waypoints, \%linestrings);
332         }
333         if ($formats{'CSV'}) {
334                 writecsvs(\%waypoints, \%linestrings);
335         }
336         if ($formats{'KML'}) {
337                 writekml(\%waypoints, \%linestrings);
338         }
339 }
340
341 sub writejson($;$) {
342         my %waypoints = %{$_[0]};
343         my %linestrings = %{$_[1]};
344         eval q{use JSON};
345         error $@ if $@;
346         foreach my $map (keys %waypoints) {
347                 my %geojson = ( "type" => "FeatureCollection", "features" => []);
348                 foreach my $name (keys %{$waypoints{$map}}) {
349                         my %marker = ( "type" => "Feature",
350                                 "geometry" => { "type" => "Point", "coordinates" => [ $waypoints{$map}{$name}{'lon'}, $waypoints{$map}{$name}{'lat'} ] },
351                                 "properties" => $waypoints{$map}{$name} );
352                         push @{$geojson{'features'}}, \%marker;
353                 }
354                 foreach my $linestring (@{$linestrings{$map}}) {
355                         my %json  = ( "type" => "Feature",
356                                 "geometry" => { "type" => "LineString", "coordinates" => $linestring });
357                         push @{$geojson{'features'}}, \%json;
358                 }
359                 writefile("pois.json", $config{destdir} . "/$map", to_json(\%geojson));
360         }
361 }
362
363 sub writekml($;$) {
364         my %waypoints = %{$_[0]};
365         my %linestrings = %{$_[1]};
366         eval q{use XML::Writer};
367         error $@ if $@;
368         foreach my $map (keys %waypoints) {
369                 my $output;
370                 my $writer = XML::Writer->new( OUTPUT => \$output,
371                         DATA_MODE => 1, ENCODING => 'UTF-8');
372                 $writer->xmlDecl();
373                 $writer->startTag("kml", "xmlns" => "http://www.opengis.net/kml/2.2");
374                 $writer->startTag("Document");
375
376                 # first pass: get the icons
377                 foreach my $name (keys %{$waypoints{$map}}) {
378                         my %options = %{$waypoints{$map}{$name}};
379                         $writer->startTag("Style", id => $options{tag});
380                         $writer->startTag("IconStyle");
381                         $writer->startTag("Icon");
382                         $writer->startTag("href");
383                         $writer->characters($options{icon});
384                         $writer->endTag();
385                         $writer->endTag();
386                         $writer->endTag();
387                         $writer->endTag();
388                 }
389         
390                 foreach my $name (keys %{$waypoints{$map}}) {
391                         my %options = %{$waypoints{$map}{$name}};
392                         $writer->startTag("Placemark");
393                         $writer->startTag("name");
394                         $writer->characters($name);
395                         $writer->endTag();
396                         $writer->startTag("styleUrl");
397                         $writer->characters('#' . $options{tag});
398                         $writer->endTag();
399                         #$writer->emptyTag('atom:link', href => $options{href});
400                         # to make it easier for us as the atom:link parameter is
401                         # hard to access from javascript
402                         $writer->startTag('href');
403                         $writer->characters($options{href});
404                         $writer->endTag();
405                         $writer->startTag("description");
406                         $writer->characters($options{desc});
407                         $writer->endTag();
408                         $writer->startTag("Point");
409                         $writer->startTag("coordinates");
410                         $writer->characters($options{lon} . "," . $options{lat});
411                         $writer->endTag();
412                         $writer->endTag();
413                         $writer->endTag();
414                 }
415                 
416                 my $i = 0;
417                 foreach my $linestring (@{$linestrings{$map}}) {
418                         $writer->startTag("Placemark");
419                         $writer->startTag("name");
420                         $writer->characters("linestring " . $i++);
421                         $writer->endTag();
422                         $writer->startTag("LineString");
423                         $writer->startTag("coordinates");
424                         my $str = '';
425                         foreach my $coord (@{$linestring}) {
426                                 $str .= join(',', @{$coord}) . " \n";
427                         }
428                         $writer->characters($str);
429                         $writer->endTag();
430                         $writer->endTag();
431                         $writer->endTag();
432                 }
433                 $writer->endTag();
434                 $writer->endTag();
435                 $writer->end();
436
437                 writefile("pois.kml", $config{destdir} . "/$map", $output);
438         }
439 }
440
441 sub writecsvs($;$) {
442         my %waypoints = %{$_[0]};
443         foreach my $map (keys %waypoints) {
444                 my $poisf = "lat\tlon\ttitle\tdescription\ticon\ticonSize\ticonOffset\n";
445                 foreach my $name (keys %{$waypoints{$map}}) {
446                         my %options = %{$waypoints{$map}{$name}};
447                         my $line = 
448                                 $options{'lat'} . "\t" .
449                                 $options{'lon'} . "\t" .
450                                 $name . "\t" .
451                                 $options{'desc'} . '<br /><a href="' . $options{'page'} . '">' . $name . "</a>\t" .
452                                 $options{'icon'} . "\n";
453                         $poisf .= $line;
454                 }
455                 writefile("pois.txt", $config{destdir} . "/$map", $poisf);
456         }
457 }
458
459 # pipe some data through the HTML scrubber
460 #
461 # code taken from the meta.pm plugin
462 sub scrub($$$) {
463         if (IkiWiki::Plugin::htmlscrubber->can("sanitize")) {
464                 return IkiWiki::Plugin::htmlscrubber::sanitize(
465                         content => shift, page => shift, destpage => shift);
466         }
467         else {
468                 return shift;
469         }
470 }
471
472 # taken from toggle.pm
473 sub format (@) {
474         my %params=@_;
475
476         if ($params{content}=~m!<div[^>]*id="mapdiv-[^"]*"[^>]*>!g) {
477                 if (! ($params{content}=~s!</body>!include_javascript($params{page})."</body>"!em)) {
478                         # no <body> tag, probably in preview mode
479                         $params{content}=$params{content} . include_javascript($params{page});
480                 }
481         }
482         return $params{content};
483 }
484
485 sub preferred_format() {
486         if (!defined($config{'osm_format'}) || !$config{'osm_format'}) {
487                 $config{'osm_format'} = 'KML';
488         }
489         my @spl = split(/, */, $config{'osm_format'});
490         return shift @spl;
491 }
492
493 sub get_formats() {
494         if (!defined($config{'osm_format'}) || !$config{'osm_format'}) {
495                 $config{'osm_format'} = 'KML';
496         }
497         map { $_ => 1 } split(/, */, $config{'osm_format'});
498 }
499
500 sub include_javascript ($) {
501         my $page=shift;
502         my $loader;
503
504         if (exists $pagestate{$page}{'osm'}) {
505                 foreach my $map (keys %{$pagestate{$page}{'osm'}}) {
506                         foreach my $name (keys %{$pagestate{$page}{'osm'}{$map}{'displays'}}) {
507                                 $loader .= map_setup_code($map, $name, %{$pagestate{$page}{'osm'}{$map}{'displays'}{$name}});
508                         }
509                 }
510         }
511         if ($loader) {
512                 return embed_map_code($page) . "<script type=\"text/javascript\" charset=\"utf-8\">$loader</script>";
513         }
514         else {
515                 return '';
516         }
517 }
518
519 sub cgi($) {
520         my $cgi=shift;
521
522         return unless defined $cgi->param('do') &&
523                 $cgi->param("do") eq "osm";
524         
525         IkiWiki::loadindex();
526
527         IkiWiki::decode_cgi_utf8($cgi);
528
529         my $map = $cgi->param('map');
530         if (!defined $map || $map !~ /^[a-z]*$/) {
531                 error("invalid map parameter");
532         }
533
534         print "Content-Type: text/html\r\n";
535         print ("\r\n");
536         print "<html><body>";
537         print "<div id=\"mapdiv-$map\"></div>";
538         print embed_map_code();
539         print "<script type=\"text/javascript\" charset=\"utf-8\">";
540         print map_setup_code($map, $map,
541                 lat => "urlParams['lat']",
542                 lon => "urlParams['lon']",
543                 zoom => "urlParams['zoom']",
544                 fullscreen => 1,
545                 editable => 1,
546                 google_apikey => $config{'osm_google_apikey'},
547         );
548         print "</script>";
549         print "</body></html>";
550
551         exit 0;
552 }
553
554 sub embed_map_code(;$) {
555         my $page=shift;
556         my $olurl = $config{osm_openlayers_url} || "http://www.openlayers.org/api/OpenLayers.js";
557         my $code = '<script src="'.$olurl.'" type="text/javascript" charset="utf-8"></script>'."\n".
558                 '<script src="'.urlto("ikiwiki/osm.js", $page).
559                 '" type="text/javascript" charset="utf-8"></script>'."\n";
560         if ($config{'osm_google_apikey'}) {
561             $code .= '<script src="http://maps.google.com/maps?file=api&amp;v=2&amp;key='.$config{'osm_google_apikey'}.'&sensor=false" type="text/javascript" charset="utf-8"></script>';
562         }
563         return $code;
564 }
565
566 sub map_setup_code($;@) {
567         my $map=shift;
568         my $name=shift;
569         my %options=@_;
570
571         my $mapurl = $config{osm_map_url};
572
573         eval q{use JSON};
574         error $@ if $@;
575                                 
576         $options{'format'} = preferred_format();
577
578         my %formats = get_formats();
579         if ($formats{'GeoJSON'}) {
580                 $options{'jsonurl'} = urlto($map."/pois.json");
581         }
582         if ($formats{'CSV'}) {
583                 $options{'csvurl'} = urlto($map."/pois.txt");
584         }
585         if ($formats{'KML'}) {
586                 $options{'kmlurl'} = urlto($map."/pois.kml");
587         }
588
589         if ($mapurl) {
590                 $options{'mapurl'} = $mapurl;
591         }
592         $options{'layers'} = $config{osm_layers};
593         $options{'layers_order'} = $config{osm_layers_order};
594
595         return "mapsetup('mapdiv-$name', " . to_json(\%options) . ");";
596 }
597
598 1;