/target/namespace}} * {{geoplot>:absolute:namespace}} * {{geoplot>namespace?800}} (800px tall map) * {{geoplot>:absolute:namespace?800}} * * Media files are located with DokuWiki's own media helpers: * - mediaFN() resolves a media ID to a filesystem path * - media_resize_image() produces a cached thumbnail via the same * GD/ImageMagick path that fetch.php uses, * so subsequent visitors hit the cache * * The page render emits only marker data; thumbnails and video * previews are lazy-loaded from the plugin's AJAX endpoint when a * popup first opens. * * (c) Wizardry and Steamworks 2026 * SPDX-License-Identifier: MIT */ if (!defined('DOKU_INC')) die(); require_once __DIR__ . '/geohash.php'; require_once __DIR__ . '/xmp.php'; class syntax_plugin_geoplot extends DokuWiki_Syntax_Plugin { const DEFAULT_HEIGHT = 480; const THUMB_WIDTH = 640; public function getType() { return 'substition'; } public function getSort() { return 32; } public function getPType() { return 'block'; } public function connectTo($mode) { $this->Lexer->addSpecialPattern('\{\{geoplot>[^\}]+\}\}', $mode, 'plugin_geoplot'); } public function handle($match, $state, $pos, Doku_Handler $handler) { $inner = substr($match, strlen('{{geoplot>'), -2); $inner = trim($inner); $height = self::DEFAULT_HEIGHT; if (($q = strrpos($inner, '?')) !== false) { $tail = substr($inner, $q + 1); if (ctype_digit($tail)) { $height = (int) $tail; if ($height < 100) { $height = 100; } if ($height > 4000) { $height = 4000; } $inner = substr($inner, 0, $q); } } $ns = ltrim($inner, ':'); $ns = trim($ns, " \t/"); return array( 'ns' => $ns, 'height' => $height, ); } public function render($mode, Doku_Renderer $renderer, $data) { if ($mode !== 'xhtml') { return false; } $this->emitAssets($renderer); $ns = $data['ns']; $height = $data['height']; $files = $this->collectFiles($ns); if (empty($files)) { $renderer->doc .= $this->renderEmpty($ns); return true; } $dates = array(); foreach ($files as $f) { $dates[] = $f['date']; } $minDate = min($dates); $maxDate = max($dates); $span = max(1, $maxDate - $minDate); // Minimum pin opacity, from the plugin configuration. Stored // as an integer percentage (default 33) and converted here to // a fraction in [0.0, 1.0]. $minOpacity = ((int) $this->getConf('min_opacity')) / 100.0; if ($minOpacity < 0.0) { $minOpacity = 0.0; } if ($minOpacity > 1.0) { $minOpacity = 1.0; } global $ID; $pageId = $ID; $linkEnabled = (bool) $this->getConf('link_subnamespace'); $payload = array(); foreach ($files as $f) { if ($maxDate === $minDate) { $opacity = 1.0; } else { $opacity = $minOpacity + (1.0 - $minOpacity) * (($f['date'] - $minDate) / $span); } $linkId = $pageId . ':' . $f['hash']; $linkUrl = $linkEnabled ? wl($linkId) : null; $payload[] = array( 'hash' => $f['hash'], 'filename' => $f['filename'], 'mediaId' => $f['mediaId'], 'mediaType' => $f['mediaType'], 'lat' => $f['lat'], 'lon' => $f['lon'], 'date' => $f['date'], 'date_raw' => $f['date_raw'], 'timecode' => $f['timecode'], 'creator' => $f['creator'], 'linkUrl' => $linkUrl, 'linkId' => $linkId, 'opacity' => round($opacity, 4), ); } $mapId = 'geoplot-' . substr(md5($ns . microtime(true)), 0, 10); $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $renderer->doc .= $this->renderMap($mapId, $json, $height, self::THUMB_WIDTH); return true; } private static $assetsEmitted = false; private function emitAssets(Doku_Renderer $renderer) { if (self::$assetsEmitted) return; self::$assetsEmitted = true; $base = DOKU_BASE . 'lib/plugins/geoplot/'; $leaflet = filemtime(__DIR__ . '/leaflet/leaflet.js'); $script = filemtime(__DIR__ . '/script.js'); $style = filemtime(__DIR__ . '/style.css'); $renderer->doc .= ''; $renderer->doc .= ''; $renderer->doc .= ''; $renderer->doc .= ''; } private function renderEmpty($ns) { $doc = new DOMDocument('1.0', 'UTF-8'); $div = $doc->createElement('div'); $div->setAttribute('class', 'geoplot-empty'); $div->appendChild($doc->createTextNode('No geotagged media found in ')); $code = $doc->createElement('code'); $code->appendChild($doc->createTextNode($ns)); $div->appendChild($code); $div->appendChild($doc->createTextNode('.')); $doc->appendChild($div); return $doc->saveHTML($div); } private function renderMap($mapId, $json, $height, $thumbWidth) { $doc = new DOMDocument('1.0', 'UTF-8'); $div = $doc->createElement('div'); $div->setAttribute('class', 'geoplot-map'); $div->setAttribute('id', $mapId); $div->setAttribute('data-geoplot', $json); $div->setAttribute('data-thumb-width', (string) $thumbWidth); $div->setAttribute('data-endpoint', DOKU_BASE . 'lib/exe/ajax.php?call=plugin_geoplot_thumb'); if ($height !== self::DEFAULT_HEIGHT) { $div->setAttribute('style', 'height: ' . (int) $height . 'px;'); } $doc->appendChild($div); return $doc->saveHTML($div); } private function collectFiles($ns) { global $conf; $cleanNs = cleanID($ns); $diskPath = $conf['mediadir'] . '/' . str_replace(':', '/', $cleanNs); if (!is_dir($diskPath)) { return array(); } $files = array(); $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($diskPath, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); foreach ($it as $file) { if (!$file->isFile()) { continue; } $ext = strtolower($file->getExtension()); // Image and video types accepted by the plugin. The XMP // reader knows how to extract metadata from all of these. $isImage = in_array($ext, array('png', 'jpg', 'jpeg', 'gif', 'webp', 'tif', 'tiff')); $isVideo = in_array($ext, array('mp4', 'mov', 'm4v', 'webm', 'mkv', 'avi', 'ogg', 'ogv')); if (!$isImage && !$isVideo) { continue; } $path = $file->getPathname(); $meta = Geoplot_XMP::read($path); if ($meta === null) { continue; } $hash = Geoplot_Geohash::generate($meta['lat'], $meta['lon'], $meta['date']); $rel = substr($path, strlen($conf['mediadir']) + 1); $mediaId = str_replace('/', ':', $rel); $files[] = array( 'hash' => $hash, 'filename' => $file->getFilename(), 'mediaId' => $mediaId, 'mediaType' => $isVideo ? 'video' : 'image', 'lat' => $meta['lat'], 'lon' => $meta['lon'], 'date' => $meta['date'], 'date_raw' => $meta['date_raw'], 'timecode' => $meta['timecode'], 'creator' => $meta['creator'], ); } return $files; } }