/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;
}
}