xref: /plugin/openlayersmap/syntax/olmap.php (revision e299f0b739dd8395dd450353bd7e5c1cec99aa5c)
1<?php
2
3/*
4 * Copyright (c) 2008-2023 Mark C. Prins <mprins@users.sf.net>
5 *
6 * Permission to use, copy, modify, and distribute this software for any
7 * purpose with or without fee is hereby granted, provided that the above
8 * copyright notice and this permission notice appear in all copies.
9 *
10 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
11 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
12 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
13 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
14 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
15 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
16 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
17 *
18 * @phpcs:disable Squiz.Classes.ValidClassName.NotCamelCaps
19 */
20use dokuwiki\Extension\SyntaxPlugin;
21use geoPHP\Geometry\Point;
22use dokuwiki\Logger;
23
24/**
25 * DokuWiki Plugin openlayersmap (Syntax Component).
26 * Provides for display of an OpenLayers based map in a wiki page.
27 *
28 * @author Mark Prins
29 */
30class syntax_plugin_openlayersmap_olmap extends SyntaxPlugin
31{
32    /**
33     * defaults of the known attributes of the olmap tag.
34     */
35    private $dflt = ['id'            => 'olmap', 'width'         => '550px', 'height'        => '450px', 'lat'           => 50.0, 'lon'           => 5.1, 'zoom'          => 12, 'autozoom'      => 1, 'controls'      => true, 'baselyr'       => 'OpenStreetMap', 'gpxfile'       => '', 'kmlfile'       => '', 'geojsonfile'   => '', 'summary'       => ''];
36
37    /**
38     *
39     * @see DokuWiki_Syntax_Plugin::getType()
40     */
41    public function getType(): string
42    {
43        return 'substition';
44    }
45
46    /**
47     *
48     * @see DokuWiki_Syntax_Plugin::getPType()
49     */
50    public function getPType(): string
51    {
52        return 'block';
53    }
54
55    /**
56     *
57     * @see Doku_Parser_Mode::getSort()
58     */
59    public function getSort(): int
60    {
61        return 901;
62    }
63
64    /**
65     *
66     * @see Doku_Parser_Mode::connectTo()
67     */
68    public function connectTo($mode)
69    {
70        $this->Lexer->addSpecialPattern(
71            '<olmap ?[^>\n]*>.*?</olmap>',
72            $mode,
73            'plugin_openlayersmap_olmap'
74        );
75    }
76
77    /**
78     *
79     * @see DokuWiki_Syntax_Plugin::handle()
80     */
81    public function handle($match, $state, $pos, Doku_Handler $handler): array
82    {
83        // break matched data into its components
84        $_tag       = explode('>', substr($match, 7, -9), 2);
85        $str_params = $_tag[0];
86        if (array_key_exists(1, $_tag)) {
87            $str_points = $_tag[1];
88        } else {
89            $str_points = '';
90        }
91        // get the lat/lon for adding them to the metadata (used by geotag)
92        preg_match('(lat[:|=]\"-?\d*\.?\d*\")', $match, $mainLat);
93        preg_match('(lon[:|=]\"-?\d*\.?\d*\")', $match, $mainLon);
94        $mainLat = substr($mainLat [0], 5, -1);
95        $mainLon = substr($mainLon [0], 5, -1);
96        if (!is_numeric($mainLat)) {
97            $mainLat = $this->dflt ['lat'];
98        }
99        if (!is_numeric($mainLon)) {
100            $mainLon = $this->dflt ['lon'];
101        }
102
103        $gmap          = $this->extractParams($str_params);
104        $overlay = $this->extractPoints($str_points);
105        $_firstimageID = '';
106
107        $_nocache = false;
108        // choose maptype based on the specified tag
109        $imgUrl = "{{";
110        if (stripos($gmap ['baselyr'], 'google') !== false) {
111            // Google
112            $imgUrl .= $this->getGoogle($gmap, $overlay);
113            $imgUrl .= "&.png";
114        } elseif (stripos($gmap ['baselyr'], 'azure') !== false) {
115            if (!$this->getConf('azureAPIKey')) {
116                // in case there is no Azure api key we'll use OSM
117                $_firstimageID = $this->getStaticOSM($gmap, $overlay);
118                $imgUrl        .= $_firstimageID;
119                if ($this->getConf('optionStaticMapGenerator') == 'remote') {
120                    $imgUrl .= "&.png";
121                }
122            } else {
123                $_nocache = true;
124                // NOTE the azure API key must be transmitted in the request header as `x-ms-client-id` as well as an
125                // Accept header with 'image/png'.
126                // So ultimately this will fail if we try to use the Azure maps API without local static map generator,
127                // but we'll do our best to generate a map URL for the user
128                $imgUrl .= $this->getAzure($gmap, $overlay) . "&.png";
129            }
130         /* elseif (stripos ( $gmap ['baselyr'], 'mapquest' ) !== false) {
131            // MapQuest
132            if (! $this->getConf ( 'mapquestAPIKey' )) {
133                // no API key for MapQuest, use OSM
134                $_firstimageID = $this->getStaticOSM ( $gmap, $overlay );
135                $imgUrl .= $_firstimageID;
136                if ($this->getConf ( 'optionStaticMapGenerator' ) == 'remote') {
137                    $imgUrl .= "&.png";
138                }
139            } else {
140                $imgUrl .= $this->_getMapQuest ( $gmap, $overlay );
141                $imgUrl .= "&.png";
142            }
143        } */
144        } else {
145            // default OSM
146            $_firstimageID = $this->getStaticOSM($gmap, $overlay);
147            $imgUrl        .= $_firstimageID;
148            if ($this->getConf('optionStaticMapGenerator') == 'remote') {
149                $imgUrl .= "&.png";
150            }
151        }
152
153        // append dw p_render specific params and render
154        $imgUrl .= "?" . str_replace("px", "", $gmap ['width']) . "x"
155            . str_replace("px", "", $gmap ['height']);
156        $imgUrl .= "&nolink";
157
158        // add nocache option for selected services
159        if ($_nocache) {
160            $imgUrl .= "&nocache";
161        }
162
163        $imgUrl .= " |" . $gmap ['summary'] . " }}";
164
165        $mapid = $gmap ['id'];
166        // create a javascript parameter string for the map
167        $param = '';
168        foreach ($gmap as $key => $val) {
169            $param .= is_numeric($val) ? "$key: $val, " : "$key: '" . hsc($val) . "', ";
170        }
171        if (!empty($param)) {
172            $param = substr($param, 0, -2);
173        }
174        unset($gmap ['id']);
175
176        // create a javascript serialisation of the point data
177        $poi      = '';
178        $poitable = '';
179        $rowId    = 0;
180        if ($overlay !== []) {
181            foreach ($overlay as $data) {
182                [$lat, $lon, $text, $angle, $opacity, $img] = $data;
183                $rowId++;
184                $poi .= ", {lat:$lat,lon:$lon,txt:'$text',angle:$angle,opacity:$opacity,img:'$img',rowId: $rowId}";
185
186                if ($this->getConf('displayformat') === 'DMS') {
187                    $lat = $this->convertLat($lat);
188                    $lon = $this->convertLon($lon);
189                } else {
190                    $lat .= 'º';
191                    $lon .= 'º';
192                }
193
194                $poitable .= '
195                    <tr>
196                    <td class="rowId">' . $rowId . '</td>
197                    <td class="icon"><img src="' . DOKU_BASE . 'lib/plugins/openlayersmap/icons/' . $img . '" alt="'
198                    . substr($img, 0, -4) . $this->getlang('alt_legend_poi') . '" /></td>
199                    <td class="lat" title="' . $this->getLang('olmapPOIlatTitle') . '">' . $lat . '</td>
200                    <td class="lon" title="' . $this->getLang('olmapPOIlonTitle') . '">' . $lon . '</td>
201                    <td class="txt">' . $text . '</td>
202                    </tr>';
203            }
204            $poi = substr($poi, 2);
205        }
206        if (!empty($gmap ['kmlfile'])) {
207            $poitable .= '
208                    <tr>
209                    <td class="rowId"><img src="' . DOKU_BASE
210                . 'lib/plugins/openlayersmap/toolbar/kml_file.png" alt="KML file" /></td>
211                    <td class="icon"><img src="' . DOKU_BASE . 'lib/plugins/openlayersmap/toolbar/kml_line.png" alt="'
212                . $this->getlang('alt_legend_kml') . '" /></td>
213                    <td class="txt" colspan="3">KML track: ' . $this->getFileName($gmap ['kmlfile']) . '</td>
214                    </tr>';
215        }
216        if (!empty($gmap ['gpxfile'])) {
217            $poitable .= '
218                    <tr>
219                    <td class="rowId"><img src="' . DOKU_BASE
220                . 'lib/plugins/openlayersmap/toolbar/gpx_file.png" alt="GPX file" /></td>
221                    <td class="icon"><img src="' . DOKU_BASE
222                . 'lib/plugins/openlayersmap/toolbar/gpx_line.png" alt="'
223                . $this->getlang('alt_legend_gpx') . '" /></td>
224                    <td class="txt" colspan="3">GPX track: ' . $this->getFileName($gmap ['gpxfile']) . '</td>
225                    </tr>';
226        }
227        if (!empty($gmap ['geojsonfile'])) {
228            $poitable .= '
229                    <tr>
230                    <td class="rowId"><img src="' . DOKU_BASE
231                . 'lib/plugins/openlayersmap/toolbar/geojson_file.png" alt="GeoJSON file" /></td>
232                    <td class="icon"><img src="' . DOKU_BASE
233                . 'lib/plugins/openlayersmap/toolbar/geojson_line.png" alt="'
234                . $this->getlang('alt_legend_geojson') . '" /></td>
235                    <td class="txt" colspan="3">GeoJSON track: ' . $this->getFileName($gmap ['geojsonfile']) . '</td>
236                    </tr>';
237        }
238
239        $autozoom = empty($gmap ['autozoom']) ? $this->getConf('autoZoomMap') : $gmap ['autozoom'];
240        $js       = "{mapOpts: {" . $param . ", displayformat: '" . $this->getConf('displayformat')
241            . "', autozoom: " . $autozoom . "}, poi: [$poi]};";
242        // unescape the json
243        $poitable = stripslashes($poitable);
244
245        return [$mapid, $js, $mainLat, $mainLon, $poitable, $gmap ['summary'], $imgUrl, $_firstimageID];
246    }
247
248    /**
249     * extract parameters for the map from the parameter string
250     *
251     * @param string $str_params
252     *            string of key="value" pairs
253     * @return array associative array of parameters key=>value
254     */
255    private function extractParams(string $str_params): array
256    {
257        $param = [];
258        preg_match_all('/(\w*)="(.*?)"/us', $str_params, $param, PREG_SET_ORDER);
259        // parse match for instructions, break into key value pairs
260        $gmap = $this->dflt;
261        foreach ($gmap as $key => &$value) {
262            $defval = $this->getConf('default_' . $key);
263            if ($defval !== '') {
264                $value = $defval;
265            }
266        }
267        unset($value);
268        foreach ($param as $kvpair) {
269            [$match, $key, $val] = $kvpair;
270            $key = strtolower($key);
271            if (isset($gmap [$key])) {
272                if ($key == 'summary') {
273                    // preserve case for summary field
274                    $gmap [$key] = $val;
275                } elseif ($key == 'id') {
276                    // preserve case for id field
277                    $gmap [$key] = $val;
278                } else {
279                    $gmap [$key] = strtolower($val);
280                }
281            }
282        }
283        return $gmap;
284    }
285
286    /**
287     * extract overlay points for the map from the wiki syntax data
288     *
289     * @param string $str_points
290     *            multi-line string of lat,lon,text triplets
291     * @return array multi-dimensional array of lat,lon,text triplets
292     */
293    private function extractPoints(string $str_points): array
294    {
295        $point = [];
296        // preg_match_all('/^([+-]?[0-9].*?),\s*([+-]?[0-9].*?),(.*?),(.*?),(.*?),(.*)$/um',
297        //      $str_points, $point, PREG_SET_ORDER);
298        /*
299         * group 1: ([+-]?[0-9]+(?:\.[0-9]*)?)
300         * group 2: ([+-]?[0-9]+(?:\.[0-9]*)?)
301         * group 3: (.*?)
302         * group 4: (.*?)
303         * group 5: (.*?)
304         * group 6: (.*)
305         */
306        preg_match_all(
307            '/^([+-]?[0-9]+(?:\.[0-9]*)?),\s*([+-]?[0-9]+(?:\.[0-9]*)?),(.*?),(.*?),(.*?),(.*)$/um',
308            $str_points,
309            $point,
310            PREG_SET_ORDER
311        );
312        // create poi array
313        $overlay = [];
314        foreach ($point as $pt) {
315            [$match, $lat, $lon, $angle, $opacity, $img, $text] = $pt;
316            $lat     = is_numeric($lat) ? $lat : 0;
317            $lon     = is_numeric($lon) ? $lon : 0;
318            $angle   = is_numeric($angle) ? $angle : 0;
319            $opacity = is_numeric($opacity) ? $opacity : 0.8;
320            // TODO validate using exist & set up default img?
321            $img  = trim($img);
322            $text = p_get_instructions($text);
323            // dbg ( $text );
324            $text = p_render("xhtml", $text, $info);
325            // dbg ( $text );
326            $text       = addslashes(str_replace("\n", "", $text));
327            $overlay [] = [$lat, $lon, $text, $angle, $opacity, $img];
328        }
329        return $overlay;
330    }
331
332    /**
333     * Create a Google maps static image url w/ the poi.
334     *
335     * @param array $gmap
336     * @param array $overlay
337     */
338    private function getGoogle(array $gmap, array $overlay): string
339    {
340        $sUrl = $this->getConf('iconUrlOverload');
341        if (!$sUrl) {
342            $sUrl = DOKU_URL;
343        }
344        $maptype = match ($gmap ['baselyr']) {
345            'google hybrid' => 'hybrid',
346            'google sat' => 'satellite',
347            'terrain', 'google relief' => 'terrain',
348            default => 'roadmap',
349        };
350        // TODO maybe use viewport / visible instead of center/zoom,
351        // see: https://developers.google.com/maps/documentation/staticmaps/index#Viewports
352        // http://maps.google.com/maps/api/staticmap?center=51.565690,5.456756&zoom=16&size=600x400&markers=icon:http://wild-water.nl/dokuwiki/lib/plugins/openlayersmap/icons/marker.png|label:1|51.565690,5.456756&markers=icon:http://wild-water.nl/dokuwiki/lib/plugins/openlayersmap/icons/marker-blue.png|51.566197,5.458966|label:2&markers=icon:http://wild-water.nl/dokuwiki/lib/plugins/openlayersmap/icons/parking.png|51.567177,5.457909|label:3&markers=icon:http://wild-water.nl/dokuwiki/lib/plugins/openlayersmap/icons/parking.png|51.566283,5.457330|label:4&markers=icon:http://wild-water.nl/dokuwiki/lib/plugins/openlayersmap/icons/parking.png|51.565630,5.457695|label:5&sensor=false&format=png&maptype=roadmap
353        $imgUrl = "https://maps.googleapis.com/maps/api/staticmap?";
354        $imgUrl .= "&size=" . str_replace("px", "", $gmap ['width']) . "x"
355            . str_replace("px", "", $gmap ['height']);
356        //if (!$this->getConf( 'autoZoomMap')) { // no need for center & zoom params }
357        $imgUrl .= "&center=" . $gmap ['lat'] . "," . $gmap ['lon'];
358        // max is 21 (== building scale), but that's overkill..
359        if ($gmap ['zoom'] > 17) {
360            $imgUrl .= "&zoom=17";
361        } else {
362            $imgUrl .= "&zoom=" . $gmap ['zoom'];
363        }
364        if ($overlay !== []) {
365            $rowId = 0;
366            foreach ($overlay as $data) {
367                [$lat, $lon, $text, $angle, $opacity, $img] = $data;
368                $imgUrl .= "&markers=icon%3a" . $sUrl . "lib/plugins/openlayersmap/icons/" . $img . "%7c"
369                    . $lat . "," . $lon . "%7clabel%3a" . ++$rowId;
370            }
371        }
372        $imgUrl .= "&format=png&maptype=" . $maptype;
373        global $conf;
374        $imgUrl .= "&language=" . $conf ['lang'];
375        if ($this->getConf('googleAPIkey')) {
376            $imgUrl .= "&key=" . $this->getConf('googleAPIkey');
377        }
378        return $imgUrl;
379    }
380
381    /**
382     * Create a MapQuest static map API image url.
383     *
384     * @param array $gmap
385     * @param array $overlay
386     */
387    /*
388   private function _getMapQuest($gmap, $overlay) {
389       $sUrl = $this->getConf ( 'iconUrlOverload' );
390       if (! $sUrl) {
391           $sUrl = DOKU_URL;
392       }
393       switch ($gmap ['baselyr']) {
394           case 'mapquest hybrid' :
395               $maptype = 'hyb';
396               break;
397           case 'mapquest sat' :
398               // because sat coverage is very limited use 'hyb' instead of 'sat' so we don't get a blank map
399               $maptype = 'hyb';
400               break;
401           case 'mapquest road' :
402           default :
403               $maptype = 'map';
404               break;
405       }
406       $imgUrl = "http://open.mapquestapi.com/staticmap/v4/getmap?declutter=true&";
407       if (count ( $overlay ) < 1) {
408           $imgUrl .= "?center=" . $gmap ['lat'] . "," . $gmap ['lon'];
409           // max level for mapquest is 16
410           if ($gmap ['zoom'] > 16) {
411               $imgUrl .= "&zoom=16";
412           } else {
413               $imgUrl .= "&zoom=" . $gmap ['zoom'];
414           }
415       }
416       // use bestfit instead of center/zoom, needs upperleft/lowerright corners
417       // $bbox=$this->calcBBOX($overlay, $gmap['lat'], $gmap['lon']);
418       // $imgUrl .= "bestfit=".$bbox['minlat'].",".$bbox['maxlon'].",".$bbox['maxlat'].",".$bbox['minlon'];
419
420       // TODO declutter option works well for square maps but not for rectangular, maybe compensate for that
421       //       or compensate the mbr..
422
423       $imgUrl .= "&size=" . str_replace ( "px", "", $gmap ['width'] ) . "," . str_replace ("px","",$gmap['height']);
424
425       // TODO mapquest allows using one image url with a multiplier $NUMBER eg:
426       // $NUMBER = 2
427       // $imgUrl .= DOKU_URL."/".DOKU_PLUGIN."/".getPluginName()."/icons/".$img.",$NUMBER,C,"
428        //  .$lat1.",".$lon1.",0,0,0,0,C,".$lat2.",".$lon2.",0,0,0,0";
429       if (! empty ( $overlay )) {
430           $imgUrl .= "&xis=";
431           foreach ( $overlay as $data ) {
432               list ( $lat, $lon, $text, $angle, $opacity, $img ) = $data;
433               // $imgUrl .= $sUrl."lib/plugins/openlayersmap/icons/".$img.",1,C,".$lat.",".$lon.",0,0,0,0,";
434               $imgUrl .= $sUrl . "lib/plugins/openlayersmap/icons/" . $img . ",1,C," . $lat . "," . $lon . ",";
435           }
436           $imgUrl = substr ( $imgUrl, 0, - 1 );
437       }
438       $imgUrl .= "&imageType=png&type=" . $maptype;
439       $imgUrl .= "&key=".$this->getConf ( 'mapquestAPIKey' );
440       return $imgUrl;
441   }
442   */
443
444    /**
445     * Create a static OSM map image url w/ the poi from http://staticmap.openstreetmap.de (staticMapLite)
446     * use http://staticmap.openstreetmap.de "staticMapLite" or a local version
447     *
448     * @param array $gmap
449     * @param array $overlay
450     *
451     * @return false|string
452     * @todo implementation for http://ojw.dev.openstreetmap.org/StaticMapDev/
453     */
454    private function getStaticOSM(array $gmap, array $overlay)
455    {
456        global $conf;
457
458        if ($this->getConf('optionStaticMapGenerator') === 'local') {
459            // using local basemap composer
460            if (($myMap = plugin_load('helper', 'openlayersmap_staticmap')) === null) {
461                Logger::error(
462                    'openlayersmap_staticmap plugin is not available for use.',
463                    $myMap
464                );
465            }
466            if (($geophp = plugin_load('helper', 'geophp')) === null) {
467                Logger::debug('geophp plugin is not available for use.', $geophp);
468            }
469            $size = str_replace("px", "", $gmap ['width']) . "x"
470                . str_replace("px", "", $gmap ['height']);
471
472            $markers = [];
473            if ($overlay !== []) {
474                foreach ($overlay as $data) {
475                    [$lat, $lon, $text, $angle, $opacity, $img] = $data;
476                    $iconStyle  = substr($img, 0, -4);
477                    $markers [] = ['lat'  => $lat, 'lon'  => $lon, 'type' => $iconStyle];
478                }
479            }
480
481            $apikey = '';
482            switch ($gmap ['baselyr']) {
483                case 'mapnik':
484                case 'openstreetmap':
485                    $maptype = 'openstreetmap';
486                    break;
487                case 'transport':
488                    $maptype = 'transport';
489                    $apikey  = '?apikey=' . $this->getConf('tfApiKey');
490                    break;
491                case 'landscape':
492                    $maptype = 'landscape';
493                    $apikey  = '?apikey=' . $this->getConf('tfApiKey');
494                    break;
495                case 'outdoors':
496                    $maptype = 'outdoors';
497                    $apikey  = '?apikey=' . $this->getConf('tfApiKey');
498                    break;
499                case 'cycle map':
500                    $maptype = 'cycle';
501                    $apikey  = '?apikey=' . $this->getConf('tfApiKey');
502                    break;
503                case 'hike and bike map':
504                    $maptype = 'hikeandbike';
505                    break;
506                case 'mapquest hybrid':
507                case 'mapquest road':
508                case 'mapquest sat':
509                    $maptype = 'mapquest';
510                    break;
511                default:
512                    $maptype = '';
513                    break;
514            }
515
516            $result = $myMap->getMap(
517                $gmap ['lat'],
518                $gmap ['lon'],
519                $gmap ['zoom'],
520                $size,
521                $maptype,
522                $markers,
523                $gmap ['gpxfile'],
524                $gmap ['kmlfile'],
525                $gmap ['geojsonfile'],
526                $apikey
527            );
528        } else {
529            // using external basemap composer
530
531            // https://staticmap.openstreetmap.de/staticmap.php?center=47.000622235634,10
532            //.117187497601&zoom=5&size=500x350
533            // &markers=48.999812532766,8.3593749976708,lightblue1|43.154850037315,17.499999997306,
534            //  lightblue1|49.487527053077,10.820312497573,ltblu-pushpin|47.951071133739,15.917968747369,
535            //  ol-marker|47.921629720114,18.027343747285,ol-marker-gold|47.951071133739,19.257812497236,
536            //  ol-marker-blue|47.180141361692,19.257812497236,ol-marker-green
537            $imgUrl = "https://staticmap.openstreetmap.de/staticmap.php";
538            $imgUrl .= "?center=" . $gmap ['lat'] . "," . $gmap ['lon'];
539            $imgUrl .= "&size=" . str_replace("px", "", $gmap ['width']) . "x"
540                . str_replace("px", "", $gmap ['height']);
541
542            if ($gmap ['zoom'] > 16) {
543                // actually this could even be 18, but that seems overkill
544                $imgUrl .= "&zoom=16";
545            } else {
546                $imgUrl .= "&zoom=" . $gmap ['zoom'];
547            }
548
549            if ($overlay !== []) {
550                $rowId  = 0;
551                $imgUrl .= "&markers=";
552                foreach ($overlay as $data) {
553                    [$lat, $lon, $text, $angle, $opacity, $img] = $data;
554                    $rowId++;
555                    $iconStyle = "lightblue$rowId";
556                    $imgUrl    .= "$lat,$lon,$iconStyle%7c";
557                }
558                $imgUrl = substr($imgUrl, 0, -3);
559            }
560
561            $result = $imgUrl;
562        }
563        return $result;
564    }
565
566    /**
567     * Create an Azure maps static image url w/ the poi.
568     *
569     * @param array $gmap
570     * @param array $overlay
571     * @return string
572     *
573     * @see https://learn.microsoft.com/en-us/rest/api/maps/render/get-map-static-image?view=rest-maps-2026-01-01&tabs=HTTP
574     */
575    private function getAzure(array $gmap, array $overlay): string
576    {
577        $maptype = match ($gmap ['baselyr']) {
578            've sat', 'azure sat' => 'microsoft.imagery',
579            default => 'microsoft.base.road',
580        };
581        $imgUrl = "https://atlas.microsoft.com/map/static?api-version=2024-04-01&tilesetId=" . $maptype;
582        // unlikely to work as it should be in the req-header, but we'll do our best to generate a map URL for the user
583        $imgUrl .= "&subscription-key=" . $this->getConf('azureAPIKey');
584        if ($this->getConf('autoZoomMap')) {
585            $bbox = $this->calcBBOX($overlay, $gmap ['lat'], $gmap ['lon']);
586            $imgUrl .= "&bbox=" . $bbox ['minlon'] . "%2C" . $bbox ['minlat'] . "%2C" . $bbox ['maxlon'] . "%2C" . $bbox ['maxlat'];
587        } else {
588            $imgUrl .= "&center=" . $gmap ['lon'] . "%2C" . $gmap ['lat'];
589            $imgUrl .= "&zoom=" . $gmap ['zoom'];
590        }
591        $imgUrl .= "&width=" . str_replace("px", "", $gmap ['width']);
592        $imgUrl .= "&height=" . str_replace("px", "", $gmap ['height']);
593        if ($overlay !== []) {
594            $rowId = 0;
595            $imgUrl .= "&pins=default%7C";
596            foreach ($overlay as $data) {
597                [$lat, $lon, $text, $angle, $opacity, $img] = $data;
598                $rowId++;
599                // The Azure Maps account S0 SKU only supports a single instance of the pins parameter and the number
600                // of locations is limited to 5 per pin. Other SKUs allow up to 25 instances of the pins parameter
601                // to specify multiple pin styles, and the number of locations is limited to 50 per pin.
602                if ($rowId == 6) {
603                    break;
604                }
605                $imgUrl .="%7C'$rowId'$lon%20$lat";
606            }
607        }
608        global $conf;
609        $imgUrl .= "&language=" . $conf ['lang'];
610        return $imgUrl;
611    }
612
613    /**
614     * Calculate the minimum bbox for a start location + poi.
615     *
616     * @param array $overlay
617     *            multi-dimensional array of array($lat, $lon, $text, $angle, $opacity, $img)
618     * @param float $lat
619     *            latitude for map center
620     * @param float $lon
621     *            longitude for map center
622     * @return array :float array describing the mbr and center point
623     */
624    private function calcBBOX(array $overlay, float $lat, float $lon): array
625    {
626        $lats = [$lat];
627        $lons = [$lon];
628        foreach ($overlay as $data) {
629            [$lat, $lon, $text, $angle, $opacity, $img] = $data;
630            $lats [] = $lat;
631            $lons [] = $lon;
632        }
633        sort($lats);
634        sort($lons);
635        // TODO: make edge/wrap around cases work
636        $centerlat = $lats [0] + ($lats [count($lats) - 1] - $lats [0]);
637        $centerlon = $lons [0] + ($lons [count($lats) - 1] - $lons [0]);
638        return ['minlat'    => $lats [0], 'minlon'    => $lons [0], 'maxlat'    => $lats [count($lats) - 1], 'maxlon'    => $lons [count($lats) - 1], 'centerlat' => $centerlat, 'centerlon' => $centerlon];
639    }
640
641    /**
642     * convert latitude in decimal degrees to DMS+hemisphere.
643     *
644     * @param float $decimaldegrees
645     * @todo move this into a shared library
646     */
647    private function convertLat(float $decimaldegrees): string
648    {
649        if (str_contains($decimaldegrees, '-')) {
650            $latPos = "S";
651        } else {
652            $latPos = "N";
653        }
654        $dms = $this->convertDDtoDMS(abs($decimaldegrees));
655        return hsc($dms . $latPos);
656    }
657
658    /**
659     * Convert decimal degrees to degrees, minutes, seconds format
660     *
661     * @param float $decimaldegrees
662     * @return string dms
663     * @todo move this into a shared library
664     */
665    private function convertDDtoDMS(float $decimaldegrees): string
666    {
667        $dms  = floor($decimaldegrees);
668        $secs = ($decimaldegrees - $dms) * 3600;
669        $min  = floor($secs / 60);
670        $sec  = round($secs - ($min * 60), 3);
671        $dms  .= 'º' . $min . '\'' . $sec . '"';
672        return $dms;
673    }
674
675    /**
676     * convert longitude in decimal degrees to DMS+hemisphere.
677     *
678     * @param float $decimaldegrees
679     * @todo move this into a shared library
680     */
681    private function convertLon(float $decimaldegrees): string
682    {
683        if (str_contains($decimaldegrees, '-')) {
684            $lonPos = "W";
685        } else {
686            $lonPos = "E";
687        }
688        $dms = $this->convertDDtoDMS(abs($decimaldegrees));
689        return hsc($dms . $lonPos);
690    }
691
692    /**
693     * Figures out the base filename of a media path.
694     *
695     * @param string $mediaLink
696     */
697    private function getFileName(string $mediaLink): string
698    {
699        $mediaLink = str_replace('[[', '', $mediaLink);
700        $mediaLink = str_replace(']]', '', $mediaLink);
701        $mediaLink = substr($mediaLink, 0, -4);
702
703        $parts     = explode(':', $mediaLink);
704        $mediaLink = end($parts);
705        return str_replace('_', ' ', $mediaLink);
706    }
707
708    /**
709     *
710     * @see DokuWiki_Syntax_Plugin::render()
711     */
712    public function render($format, Doku_Renderer $renderer, $data): bool
713    {
714        // set to true after external scripts tags are written
715        static $initialised = false;
716        // incremented for each map tag in the page source so we can keep track of each map in this page
717        static $mapnumber = 0;
718
719        [$mapid, $param, $mainLat, $mainLon, $poitable, $poitabledesc, $staticImgUrl, $_firstimage] = $data;
720
721        if ($format === 'xhtml') {
722            $olscript     = '';
723            $stadiaEnable = $this->getConf('enableStadia');
724            $osmEnable    = $this->getConf('enableOSM');
725            $enableAzure   = $this->getConf('enableAzure');
726
727            $scriptEnable = '';
728            if (!$initialised) {
729                $initialised = true;
730                // render necessary script tags only once
731                $olscript = '<script defer="defer" src="' . DOKU_BASE . 'lib/plugins/openlayersmap/ol/ol.js"></script>
732<script defer="defer" src="' . DOKU_BASE . 'lib/plugins/openlayersmap/ol/ol-layerswitcher.js"></script>';
733
734                $scriptEnable = '<script defer="defer" src="data:text/javascript;base64,';
735                $scriptSrc    = $olscript ? 'const olEnable=true;' : 'const olEnable=false;';
736                $scriptSrc    .= 'const osmEnable=' . ($osmEnable ? 'true' : 'false') . ';';
737                $scriptSrc    .= 'const stadiaEnable=' . ($stadiaEnable ? 'true' : 'false') . ';';
738                $scriptSrc    .= 'const aEnable=' . ($enableAzure ? 'true' : 'false') . ';';
739                $scriptSrc    .= 'const aApiKey="' . $this->getConf('azureAPIKey') . '";';
740                $scriptSrc    .= 'const tfApiKey="' . $this->getConf('tfApiKey') . '";';
741                $scriptSrc    .= 'const gApiKey="' . $this->getConf('googleAPIkey') . '";';
742                $scriptSrc    .= 'olMapData = []; let olMaps = {}; let olMapOverlays = {};';
743                $scriptEnable .= base64_encode($scriptSrc);
744                $scriptEnable .= '"></script>';
745            }
746            $renderer->doc .= "$olscript\n$scriptEnable";
747            $renderer->doc .= '<div class="olMapHelp">' . $this->locale_xhtml("help") . '</div>';
748            if ($this->getConf('enableA11y')) {
749                $renderer->doc .= '<div id="' . $mapid . '-static" class="olStaticMap">'
750                    . p_render($format, p_get_instructions($staticImgUrl), $info) . '</div>';
751            }
752            $renderer->doc .= '<div id="' . $mapid . '-clearer" class="clearer"><p>&nbsp;</p></div>';
753            if ($this->getConf('enableA11y')) {
754                // render a table of the POI for the print and a11y presentation, it is hidden using javascript
755                $renderer->doc .= '
756                <div id="' . $mapid . '-table-span" class="olPOItableSpan">
757                    <table id="' . $mapid . '-table" class="olPOItable">
758                    <caption class="olPOITblCaption">' . $this->getLang('olmapPOItitle') . '</caption>
759                    <thead class="olPOITblHeader">
760                    <tr>
761                    <th class="rowId" scope="col">id</th>
762                    <th class="icon" scope="col">' . $this->getLang('olmapPOIicon') . '</th>
763                    <th class="lat" scope="col" title="' . $this->getLang('olmapPOIlatTitle') . '">'
764                    . $this->getLang('olmapPOIlat') . '</th>
765                    <th class="lon" scope="col" title="' . $this->getLang('olmapPOIlonTitle') . '">'
766                    . $this->getLang('olmapPOIlon') . '</th>
767                    <th class="txt" scope="col">' . $this->getLang('olmapPOItxt') . '</th>
768                    </tr>
769                    </thead>';
770                if ($poitabledesc != '') {
771                    $renderer->doc .= '<tfoot class="olPOITblFooter"><tr><td colspan="5">' . $poitabledesc
772                        . '</td></tr></tfoot>';
773                }
774                $renderer->doc .= '<tbody class="olPOITblBody">' . $poitable . '</tbody>
775                    </table>
776                </div>';
777                $renderer->doc .= "\n";
778            }
779            // render inline mapscript parts
780            $renderer->doc .= '<script defer="defer" src="data:text/javascript;base64,';
781            $renderer->doc .= base64_encode("olMapData[$mapnumber] = $param");
782            $renderer->doc .= '"></script>';
783            $mapnumber++;
784            return true;
785        } elseif ($format === 'metadata') {
786            if (!(($this->dflt ['lat'] == $mainLat) && ($this->dflt ['lon'] == $mainLon))) {
787                // render geo metadata, unless they are the default
788                $renderer->meta ['geo'] ['lat'] = $mainLat;
789                $renderer->meta ['geo'] ['lon'] = $mainLon;
790                if (($geophp = plugin_load('helper', 'geophp')) !== null) {
791                    // if we have the geoPHP helper, add the geohash
792                    try {
793                        $renderer->meta['geo']['geohash'] = (new Point($mainLon, $mainLat))->out('geohash');
794                    } catch (Exception) {
795                        Logger::error("Failed to create geohash for: $mainLat, $mainLon");
796                    }
797                }
798            }
799
800            if (($this->getConf('enableA11y')) && (!empty($_firstimage))) {
801                // add map local image into relation/firstimage if not already filled and when it is a local image
802
803                global $ID;
804                $rel = p_get_metadata($ID, 'relation', METADATA_RENDER_USING_CACHE);
805                // $img = $rel ['firstimage'];
806                if (empty($rel ['firstimage']) /* || $img == $_firstimage*/) {
807                    //Logger::debug(
808                    // 'olmap::render#rendering image relation metadata for _firstimage as $img was empty or same.',
809                    // $_firstimage);
810
811                    // This seems to never work; the firstimage entry in the .meta file is empty
812                    // $renderer->meta['relation']['firstimage'] = $_firstimage;
813                    // ... and neither does this; the firstimage entry in the .meta file is empty
814                    // $relation = array('relation'=>array('firstimage'=>$_firstimage));
815                    // p_set_metadata($ID, $relation, false, false);
816                    // ... this works
817                    $renderer->internalmedia($_firstimage, $poitabledesc);
818                }
819            }
820            return true;
821        }
822        return false;
823    }
824}
825