#!/usr/bin/perl # vim:ts=8:sts=8:sw=8:noet # Copyright 2018 Martín Ferrari # Copyright 2011 Blars Blarson # Released under GPL version 2 package IkiWiki::Plugin::osm; use utf8; use strict; use warnings; use IkiWiki 3.0; use JSON; use constant OSM => "osm"; use constant OUTPUT_PATH => "/ikiwiki/" . OSM; use constant JS_IDENTIFIER_RE => qr/^[a-zA-Z_][0-9a-zA-Z_]*$/o; use constant DMS_RE => qr/ (\d+(?:\.\d*)?)[\s°]? # Degrees (?:\s*(\d+(?:\.\d*)?)[\s`´']? # Minutes (?:\s*(\d+(?:\.\d*)?)[\s"]?)? # Seconds )? /xo; sub import { add_underlay(OSM); hook(type => "getsetup", id => OSM, call => \&getsetup); hook(type => "checkconfig", id => OSM, call => \&checkconfig); hook(type => "needsbuild", id => OSM, call => \&needsbuild); hook(type => "preprocess", id => "osm", call => \&preprocess_osm); hook(type => "preprocess", id => "waypoint", scan => 1, call => \&preprocess_waypoint); hook(type => "format", id => OSM, call => \&format); hook(type => "changes", id => OSM, call => \&changes); } sub getsetup () { return plugin => { safe => 1, rebuild => 1, section => "special-purpose", }, osm_default_zoom => { type => "integer", example => "15", description => "default map zoom", safe => 1, rebuild => 1, }, osm_leafletjs_url => { type => "string", example => "https://unpkg.com/leaflet@1.3.1/dist/leaflet.js", description => "Url for the leaflet.js file", safe => 0, rebuild => 1, }, osm_leafletcss_url => { type => "string", example => "https://unpkg.com/leaflet@1.3.1/dist/leaflet.css", description => "Url for the leaflet.css file", safe => 0, rebuild => 1, }, osm_tile_source => { type => "string", example => "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", description => "URL pattern for tile layers. " + "See Leaflet documentation for tileLayer.", safe => 0, rebuild => 1, }, osm_attribution => { type => "string", example => q(© ) + q(OpenStreetMap contributors), description => "Text describing the tile source.", safe => 0, rebuild => 1, }; } sub checkconfig { $config{'osm_default_zoom'} = 15 unless (defined $config{'osm_default_zoom'}); $config{'osm_leafletjs_url'} = "https://unpkg.com/leaflet@1.3.1/dist/leaflet.js" unless (defined $config{'osm_leafletjs_url'}); $config{'osm_leafletcss_url'} = "https://unpkg.com/leaflet@1.3.1/dist/leaflet.css" unless (defined $config{'osm_leafletcss_url'}); $config{'osm_tile_source'} = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" unless (defined $config{'osm_tile_source'}); $config{'osm_attribution'} = q(© OpenStreetMap contributors) unless (defined $config{'osm_attribution'}); } # Index for ensuring waypoints have unique IDs. my %waypointidx = (); # Idea taken from meta.pm plugin. # Make sure cached state is cleaned before rebuilding (and after deleting) # pages. sub needsbuild { my $needsbuild = shift; my $deleted = shift; my %touched = map { $_ => 1 } (@$needsbuild, @$deleted); foreach my $page (keys %pagestate) { next unless (exists $pagestate{$page}{OSM} and exists $pagesources{$page}); if (exists $touched{$pagesources{$page}}) { delete $pagestate{$page}{OSM}; } # Rebuild index. foreach my $map (keys %{$pagestate{$page}{OSM}}) { my $wps = $pagestate{$page}{OSM}{$map}{'waypoints'}; foreach my $wp (keys %$wps) { $waypointidx{$map}{$wp} = $wps->{$wp}; } } } return $needsbuild; } sub preprocess_osm { my %params = @_; my $page = $params{'page'}; my $dest = $params{'destpage'}; my $map = $params{'map'} || 'map'; my $height = scrub($params{'height'} || '300px', $page, $dest); my $width = scrub($params{'width'} || '500px', $page, $dest); my $float = (defined($params{'right'}) && 'right') || ( defined($params{'left'}) && 'left'); my $autolines = defined($params{'autolines'}); my $nolinkpages = defined($params{'nolinkpages'}); my $highlight = $params{'highlight'} || ''; my $loc = $params{'loc'}; my $lat = $params{'lat'}; my $lon = $params{'lon'}; my $zoom = $params{'zoom'} // $config{'osm_default_zoom'}; ($lon, $lat) = scrub_lonlat($loc, $lon, $lat); error(sprintf(gettext("Invalid map name: %s"), $map)) if ( $map !~ JS_IDENTIFIER_RE); error(sprintf(gettext("Invalid zoom: %s"), $zoom)) if ( $zoom !~ /^\d\d?$/ || $zoom < 2 || $zoom > 18); will_render($page, OUTPUT_PATH . "/${map}.js"); return unless defined wantarray; # Scan mode. # Make sure the div ID is unique in this (dest) page. my $div_id = generate_unique_key( $pagestate{$dest}{OSM}{$map}{'displays'}, "map-$map"); # Register this page is rendering map $map inside div $id. my $map_opts = $pagestate{$dest}{OSM}{$map}{'displays'}{$div_id} = { height => $height, width => $width, float => $float, autolines => $autolines || 0, nolinkpages => $nolinkpages || 0, highlight => $highlight, zoom => $zoom, }; if (defined $lat and defined $lon) { $map_opts->{'lat'} = $lat; $map_opts->{'lon'} = $lon; } # Place a
shiv that is filled with parameters later. return qq(
\n); } our $waypoint_changed = 0; sub preprocess_waypoint { my %params = @_; my $page = $params{'page'}; my $dest = $params{'destpage'}; my $preview = $params{'preview'}; my $p = IkiWiki::basename($page); my $map = $params{'map'} || 'map'; my $id = $params{'id'}; my $name = scrub($params{'name'} || pagetitle($p), $page, $dest); my $desc = scrub($params{'desc'} || '', $page, $dest); my $embed = defined($params{'embed'}); # Passed verbatim to preprocess_osm. my $height = $params{'height'}; my $width = $params{'width'}; my $right = $params{'right'}; my $left = $params{'left'}; my $autolines = $params{'autolines'}; my $nolinkpages = $params{'nolinkpages'}; my $loc = $params{'loc'}; my $lat = $params{'lat'}; my $lon = $params{'lon'}; my $zoom = $params{'zoom'} // $config{'osm_default_zoom'}; ($lon, $lat) = scrub_lonlat($loc, $lon, $lat); error(sprintf(gettext("Invalid map name: %s"), $map)) if ( $map !~ JS_IDENTIFIER_RE); error(sprintf(gettext("Duplicate waypoint id: %s"), $id)) if ( $id and exists $waypointidx{$map}{$id}); error(gettext("Must specify lat and lon (or loc)")) unless ( defined $lat and defined $lon); error(sprintf(gettext("Invalid zoom: %s"), $zoom)) if ( $zoom !~ /^\d\d?$/ || $zoom < 2 || $zoom > 18); # Register json file that will be rendered. will_render($page, OUTPUT_PATH . "/${map}.js"); return unless defined wantarray; # Scan mode. $waypointidx{$map} ||= {}; $id = generate_unique_key($waypointidx{$map}, $page) unless($id); # Do not create waypoints from inlined or preview pages; and only # during scan mode. if ($page eq $dest and not $preview) { $waypoint_changed = 1; debug(sprintf(gettext("osm: found waypoint %s/%s"), $map, $id)); $waypointidx{$map}{$id} = $pagestate{$page}{OSM}{$map}{'waypoints'}{$id} = { id => $id, name => $name, desc => $desc, lat => $lat, lon => $lon, page => $page, # How to link back to the page from the map, must be # absolute. href => urlto($page), }; } my $output = ''; if ($embed) { $output .= preprocess_osm( page => $page, destpage => $dest, map => $map, height => $height, width => $width, right => $right, left => $left, autolines => $autolines, nolinkpages => $nolinkpages, lat => $lat, lon => $lon, zoom => $zoom, highlight => $id, ); } return $output; } # Given a HASH ref and an initial key, iterate until key is unique in that # HASH. sub generate_unique_key($$) { my ($hashref, $initial_key) = @_; my $num = 1; my $id = $initial_key; while (exists $hashref->{$id}) { $id = "${initial_key}_${num}"; $num++; } return $id; } sub scrub_lonlat($$$) { my ($loc, $lon, $lat) = @_; my $lat_re = qr/([+-]?)/ . DMS_RE . qr/\s*([ns]?)/i; my $lon_re = qr/([+-]?)/ . DMS_RE . qr/\s*([ew]?)/i; if ($loc) { if ($loc =~ / ^\s* ($lat_re) \s*[,;\s]\s* ($lon_re) \s*$ /x ) { $lat = $1; $lon = $7; } else { error(sprintf(gettext( "Bad value for loc parameter: %s"), $loc)); } } if (defined($lat)) { if ($lat =~ /^\s*$lat_re\s*$/) { $lat = $2 + ($3 // 0) / 60 + ($4 // 0) / 3600; if ($1 eq '-' or uc($5 // '') eq 'S') { $lat = - $lat; } } else { error(sprintf(gettext( "Bad value for lat parameter: %s"), $lat)); } } if (defined($lon)) { if ($lon =~ /^\s*$lon_re\s*$/) { $lon = $2 + ($3 // 0) / 60 + ($4 // 0) / 3600; if ($1 eq '-' or uc($5 // '') eq 'W') { $lon = - $lon; } } else { error(sprintf(gettext( "Bad value for lon parameter: %s"), $lon)); } } if (!defined($lon) || !defined($lat)) { return (undef, undef); } if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) { error(sprintf(gettext("Location out of range: (%s, %s)"), $lat, $lon)); } return ($lon + 0.0, $lat + 0.0); } sub find_waypoints() { my %waypoints = (); foreach my $page (keys %pagestate) { next unless (exists $pagestate{$page}{OSM}); my $maps = $pagestate{$page}{OSM}; foreach my $map (keys %$maps) { # Make sure a GeoJSON file is generated even if empty. $waypoints{$map} ||= {}; next unless (exists $maps->{$map}{'waypoints'} and $maps->{$map}{'waypoints'}); $waypoints{$map}{$page} = $maps->{$map}{'waypoints'}; } } return \%waypoints; } sub find_wplines($) { # Draw lines between waypoints whose pages link each other. my $mapwps = shift; my @lines; foreach my $page (sort keys %{$mapwps}) { next unless (exists $links{$page}); foreach my $otherpage (@{$links{$page}}) { my $bestlink = bestlink($page, $otherpage); next unless (exists $mapwps->{$bestlink}); foreach my $wpid (sort keys %{$mapwps->{$page}}) { my $wp = $mapwps->{$page}{$wpid}; foreach my $otherwpid ( sort keys %{$mapwps->{$bestlink}} ) { my $otherwp = $mapwps->{$bestlink}{$otherwpid}; push(@lines, [$wp, $otherwp]); } } } } return \@lines; } sub changes { return unless($waypoint_changed); my $waypoints = find_waypoints(); my %lines = (); foreach my $map (keys %$waypoints) { $lines{$map} = find_wplines($waypoints->{$map}); } writejson($waypoints, \%lines); $waypoint_changed = 0; } sub writejson($;$) { my %waypoints = %{$_[0]}; my %lines = %{$_[1]}; foreach my $map (keys %waypoints) { my %geojson = ( "type" => "FeatureCollection", "features" => [], ); foreach my $page (sort keys %{$waypoints{$map}}) { foreach my $wpid (sort keys %{$waypoints{$map}{$page}} ) { my $wp = $waypoints{$map}{$page}{$wpid}; my %marker = ( "type" => "Feature", "geometry" => { "type" => "Point", "coordinates" => [ $wp->{'lon'} + 0.0, $wp->{'lat'} + 0.0, ], }, "properties" => $wp, ); push @{$geojson{'features'}}, \%marker; } } foreach my $lines (@{$lines{$map}}) { my $coord = [ [ $lines->[0]->{'lon'} + 0.0, $lines->[0]->{'lat'} + 0.0, ], [ $lines->[1]->{'lon'} + 0.0, $lines->[1]->{'lat'} + 0.0, ] ]; my %json = ( "type" => "Feature", "geometry" => { "type" => "LineString", "coordinates" => $coord, }, ); push @{$geojson{'features'}}, \%json; } debug(sprintf(gettext("osm: building %s"), OUTPUT_PATH . "/$map.js")); writefile("$map.js", "$config{'destdir'}/" . OUTPUT_PATH, "var geojson_$map = " . to_json(\%geojson)); } } # pipe some data through the HTML scrubber # # code taken from the meta.pm plugin sub scrub($$$) { if (IkiWiki::Plugin::htmlscrubber->can('sanitize')) { return IkiWiki::Plugin::htmlscrubber::sanitize( content => shift, page => shift, destpage => shift); } else { return shift; } } sub format (@) { my %params = @_; my $page = $params{'page'}; my $content = $params{'content'}; return $content unless ( exists $pagestate{$page}{OSM} and grep { exists $_->{'displays'} } values(%{$pagestate{$page}{OSM}}) ); # Fill map
with attributes and javascript code. # Needs to be done here, so htmlscrubber does not remove the tags. my @maps_present = (); foreach my $map (keys %{$pagestate{$page}{OSM}}) { my $displays = $pagestate{$page}{OSM}{$map}{'displays'}; next unless($displays and %$displays); foreach my $div_id (keys %$displays) { my $map_opts = $displays->{$div_id}; my $height = $map_opts->{'height'}; my $orig = qq(
\n); my $repl = (qq(
\n)); $repl .= display_map_js($map, $div_id, %$map_opts); $content =~ s(\Q$orig\E)($repl); } push @maps_present, $map; } # Add Leaflet setup code and load GeoJSON files. my $js = map_setup_js($page); foreach my $map (@maps_present) { $js .= load_geojson_js($map, $page); } my ($before, $after) = split(m(), $content, 2); if (defined $after) { return $before . $js . '' . $after; } return $js . $content; } sub map_setup_js($) { my $page = shift; my $cssurl = $config{'osm_leafletcss_url'}; my $olurl = $config{'osm_leafletjs_url'}; my $displaymap_link = bestlink($page, OUTPUT_PATH . "/display_map.js"); my $displaymap_url = urlto($displaymap_link, $page); return < END } our %json_embedded; sub load_geojson_js($$) { my $map = shift; my $dest = shift; return '' if ($json_embedded{$dest}{$map}); $json_embedded{$dest}{$map} = 1; my $jsonurl = urlto(OUTPUT_PATH . "/${map}.js", $dest); my $ret = qq(\n); return $ret; } sub display_map_js($$;@) { my $map = shift; my $div_id = shift; my %options = @_; $options{'tilesrc'} = $config{'osm_tile_source'}; $options{'attribution'} = $config{'osm_attribution'}; my $ret = qq(\n}; return $ret; } 1;