feat(embeds): geo/gpx/3D/YouTube/media keyword system + docs, sanitized mirrors

- new snippet geo_map_render.js: [map] inline geomaps (tree order, tint,
  theme-following), [gpx] stats card + elevation profile with hover cursor
  (km from start, d-height, vertical guide), [yt] privacy click-to-load
  facade, [video]/[audio] inline players fed from the new geoData indexes
- 3d_model_viewer.js: [cad] keyword + title/ID resolution via geoData.cad,
  sizes/captions, real occt-wasm ESM pair (fixes 'STEP worker error'),
  code-span-safe keyword scan (docs can show examples)
- blog_generator.js: buildGeoData() v6 – maps (geolocation + geoShape
  point/line/polygon + child GPX tracks), gpx/cad/media indexes,
  branch-change rebuild for route reordering
- generated/geo_data.js placeholder; docs/embed-keywords.md (new) +
  embed-3d-model Option D; README layout/wiring/notes tables updated
- license: runtime components (MapLibre ISC, VersaTiles MIT/ODbL, YouTube)
- hygiene: removed leaked note IDs and instance domain from theme docs
This commit is contained in:
trilium-share-gruvbox
2026-09-28 14:53:51 +02:00
parent 9e456bbaaa
commit 2818c40f96
10 changed files with 933 additions and 28 deletions
+541
View File
@@ -0,0 +1,541 @@
<style>
.blog-geo-map { height: min(70vh, 640px); min-height: 360px; margin: 1.25em 0; border-radius: 10px; overflow: hidden; box-shadow: 0 2px 12px rgba(0,0,0,.18); background: #ddd; }
.blog-geo-map[data-box-size="small"] { height: 280px; min-height: 220px; }
.blog-geo-map[data-box-size="medium"] { height: 440px; min-height: 300px; }
.blog-geo-map .geo-marker { width: 14px; height: 14px; border-radius: 50%; background: #5b8a72; border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.4); cursor: pointer; }
.blog-geo-map .geo-marker-endpoint { width: 18px; height: 18px; background: #d9772f; }
.blog-geo-map .maplibregl-popup-content { font-family: inherit; font-size: .95em; border-radius: 8px; padding: 10px 14px; }
.blog-geo-map .geo-point-summary { margin-top: 4px; color: #555; font-size: .9em; }
.blog-geo-map .maplibregl-ctrl-attrib { font-size: .7em; }
.blog-geo-map::after { content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none; background: linear-gradient(158deg, rgba(150,172,148,.20), rgba(208,196,164,.16)); mix-blend-mode: multiply; }
.blog-gpx { margin: 1.25em 0; padding: 16px 18px 12px; border-radius: 10px; border: 1px solid rgba(61,122,95,.28); border-left: 3px solid #5b8a72; background: #faf8f2; box-shadow: 0 2px 12px rgba(0,0,0,.06); }
.blog-gpx .gpx-title { font-weight: 600; margin-bottom: 10px; }
.blog-gpx .gpx-stats { display: flex; flex-wrap: wrap; gap: 8px 26px; margin-bottom: 12px; }
.blog-gpx .gpx-stat b { display: block; font-size: 1.15em; }
.blog-gpx .gpx-stat i { font-style: normal; font-size: .72em; letter-spacing: .05em; text-transform: uppercase; color: #888; }
.blog-gpx .gpx-ele-range { text-align: right; font-size: .72em; color: #888; margin-bottom: 2px; }
.blog-gpx svg.gpx-profile { width: 100%; height: 140px; display: block; }
.blog-gpx .gpx-prof-wrap { position: relative; background: #eef3ec; border-radius: 8px; padding: 8px 10px 6px; border: 1px solid rgba(61,122,95,.22); }
.blog-gpx .gpx-cursor { stroke: rgba(0,0,0,.4); stroke-width: 1.5; stroke-dasharray: 3 3; vector-effect: non-scaling-stroke; display: none; }
.blog-gpx .gpx-tip { position: absolute; top: 4px; transform: translateX(-50%); background: rgba(255,255,255,.92); color: #333; border: 1px solid rgba(61,122,95,.35); border-radius: 6px; padding: 2px 8px; font-size: .75em; white-space: nowrap; pointer-events: none; opacity: 0; transition: opacity .12s; z-index: 2; }
.blog-gpx .gpx-profile { touch-action: pan-y; }
.blog-gpx .gpx-area { fill: rgba(61,122,95,.25); }
.blog-gpx .gpx-line { fill: none; stroke: #3d7a5f; stroke-width: 2; vector-effect: non-scaling-stroke; }
.blog-gpx .gpx-axis { display: flex; justify-content: space-between; font-size: .72em; color: #888; margin-top: 2px; }
.blog-yt { margin: 1.25em 0; }
.blog-yt iframe { display: block; width: 100%; aspect-ratio: 16 / 9; border: 0; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,.18); background: #000; }
.blog-yt .blog-yt-poster { position: relative; display: block; width: 100%; aspect-ratio: 16 / 9; padding: 0; cursor: pointer; border: 1px solid rgba(0,0,0,.15); border-radius: 10px; overflow: hidden; background: #000; box-shadow: 0 2px 12px rgba(0,0,0,.18); }
.blog-yt .blog-yt-poster img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.blog-yt .blog-yt-play { position: absolute; left: 50%; top: 50%; width: 68px; height: 48px; margin: -24px 0 0 -34px; background: rgba(204,0,0,.88); border-radius: 12px; transition: transform .12s ease, background .12s ease; }
.blog-yt .blog-yt-play:after { content: ""; position: absolute; left: 56%; top: 50%; transform: translate(-50%, -50%); border-style: solid; border-width: 10px 0 10px 17px; border-color: transparent transparent transparent #fff; }
.blog-yt .blog-yt-poster:hover .blog-yt-play { background: #c00; transform: scale(1.08); }
.blog-yt-caption { margin-top: 6px; font-size: .85em; color: #777; text-align: center; }
.blog-media { margin: 1.25em 0; }
.blog-media video { display: block; width: 100%; border-radius: 10px; background: #000; box-shadow: 0 2px 12px rgba(0,0,0,.18); }
.blog-media audio { display: block; width: 100%; }
.blog-media-caption { margin-top: 6px; font-size: .85em; color: #777; text-align: center; }
html.theme-dark .blog-yt-caption { color: #999; }
html.theme-dark .blog-media-caption { color: #999; }
@media (prefers-color-scheme: dark) {
html:not(.theme-light):not(.theme-dark) .blog-geo-map { background: #222; }
html:not(.theme-light):not(.theme-dark) .blog-geo-map .maplibregl-canvas { filter: invert(1) hue-rotate(180deg); }
html:not(.theme-light):not(.theme-dark) .blog-geo-map .geo-marker { border-color: #222; }
html:not(.theme-light):not(.theme-dark) .blog-geo-map .maplibregl-popup-content { background: #2a2a2a; color: #ddd; }
html:not(.theme-light):not(.theme-dark) .blog-geo-map .maplibregl-popup-content a { color: #e8a05c; }
html:not(.theme-light):not(.theme-dark) .blog-geo-map .geo-point-summary { color: #aaa; }
html:not(.theme-light):not(.theme-dark) .blog-geo-map .maplibregl-popup-anchor-top .maplibregl-popup-tip { border-bottom-color: #2a2a2a; }
html:not(.theme-light):not(.theme-dark) .blog-geo-map .maplibregl-popup-anchor-bottom .maplibregl-popup-tip { border-top-color: #2a2a2a; }
html:not(.theme-light):not(.theme-dark) .blog-geo-map .maplibregl-popup-anchor-left .maplibregl-popup-tip { border-right-color: #2a2a2a; }
html:not(.theme-light):not(.theme-dark) .blog-geo-map .maplibregl-popup-anchor-right .maplibregl-popup-tip { border-left-color: #2a2a2a; }
html:not(.theme-light):not(.theme-dark) .blog-geo-map .maplibregl-ctrl-attrib, html:not(.theme-light):not(.theme-dark) .blog-geo-map .maplibregl-ctrl-attrib a { background: rgba(34,34,34,.7); color: #999; }
html:not(.theme-light):not(.theme-dark) .blog-geo-map .maplibregl-ctrl button { background: rgba(34,34,34,.85); color: #ddd; }
html:not(.theme-light):not(.theme-dark) .blog-geo-map::after { background: none; }
}
html.theme-dark .blog-geo-map { background: #222; }
html.theme-dark .blog-geo-map .maplibregl-canvas { filter: invert(1) hue-rotate(180deg); }
html.theme-dark .blog-geo-map .geo-marker { border-color: #222; }
html.theme-dark .blog-geo-map .maplibregl-popup-content { background: #2a2a2a; color: #ddd; }
html.theme-dark .blog-geo-map .maplibregl-popup-content a { color: #e8a05c; }
html.theme-dark .blog-geo-map .geo-point-summary { color: #aaa; }
html.theme-dark .blog-geo-map .maplibregl-popup-anchor-top .maplibregl-popup-tip { border-bottom-color: #2a2a2a; }
html.theme-dark .blog-geo-map .maplibregl-popup-anchor-bottom .maplibregl-popup-tip { border-top-color: #2a2a2a; }
html.theme-dark .blog-geo-map .maplibregl-popup-anchor-left .maplibregl-popup-tip { border-right-color: #2a2a2a; }
html.theme-dark .blog-geo-map .maplibregl-popup-anchor-right .maplibregl-popup-tip { border-left-color: #2a2a2a; }
html.theme-dark .blog-geo-map .maplibregl-ctrl-attrib, html.theme-dark .blog-geo-map .maplibregl-ctrl-attrib a { background: rgba(34,34,34,.7); color: #999; }
html.theme-dark .blog-geo-map .maplibregl-ctrl button { background: rgba(34,34,34,.85); color: #ddd; }
html.theme-dark .blog-geo-map::after { background: none; }
@media (prefers-color-scheme: dark) {
html:not(.theme-light):not(.theme-dark) .blog-gpx { background: #2a2a2a; color: #ddd; border-color: rgba(255,255,255,.12); }
html:not(.theme-light):not(.theme-dark) .blog-gpx .gpx-stat i, html:not(.theme-light):not(.theme-dark) .blog-gpx .gpx-ele-range, html:not(.theme-light):not(.theme-dark) .blog-gpx .gpx-axis { color: #999; }
html:not(.theme-light):not(.theme-dark) .blog-gpx .gpx-area { fill: rgba(130,180,150,.22); }
html:not(.theme-light):not(.theme-dark) .blog-gpx .gpx-line { stroke: #82b496; }
}
html.theme-dark .blog-gpx .gpx-cursor { stroke: rgba(255,255,255,.5); }
html.theme-dark .blog-gpx .gpx-tip { background: rgba(42,42,42,.95); color: #ddd; border-color: rgba(255,255,255,.2); }
html.theme-dark .blog-gpx { background: #2a2a2a; color: #ddd; border-color: rgba(255,255,255,.12); border-left-color: #5b8a72; }
html.theme-dark .blog-gpx .gpx-prof-wrap { background: #222; border-color: rgba(255,255,255,.08); }
html.theme-dark .blog-gpx .gpx-stat i, html.theme-dark .blog-gpx .gpx-ele-range, html.theme-dark .blog-gpx .gpx-axis { color: #999; }
html.theme-dark .blog-gpx .gpx-area { fill: rgba(130,180,150,.22); }
html.theme-dark .blog-gpx .gpx-line { stroke: #82b496; }
</style>
<script>
function bootGeoMap() {
var contentEl = document.getElementById("content");
var VERSA = "https://tiles.versatiles.org/assets/styles/";
var STYLES = {
"auto": VERSA + "muted/style.json",
"muted": VERSA + "muted/style.json",
"neutrino": VERSA + "muted/style.json",
"neutron": VERSA + "muted/style.json",
"neutrolis": VERSA + "muted/style.json",
"osm-bright": "https://tiles.openfreemap.org/styles/bright",
"liberty": "https://tiles.openfreemap.org/styles/liberty"
};
var darkQ = window.matchMedia ? window.matchMedia("(prefers-color-scheme: dark)") : null;
function isDark() { return document.documentElement.classList.contains("theme-dark"); }
var themeWatchers = [];
var themeObs = null;
function watchTheme(fn) {
themeWatchers.push(fn);
if (!themeObs && window.MutationObserver) {
themeObs = new MutationObserver(function () {
themeWatchers.forEach(function (f) { try { f(); } catch (e) {} });
});
themeObs.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
}
}
function routeColor() { return isDark() ? "#333333" : "#d9772f"; }
function trackColor() { return isDark() ? "#c285a0" : "#3d7a5f"; }
function findDef(key) {
var def = null;
window.geoData.maps.forEach(function (m) {
if (m.id === key || m.title === key) def = m;
});
if (!def) return null;
var hasPts = def.points && def.points.length;
var hasTracks = def.tracks && def.tracks.length;
return (hasPts || hasTracks) ? def : null;
}
function makeDiv(key, size) {
var d = document.createElement("div");
d.className = "blog-geo-map";
d.setAttribute("data-map", key);
if (size) d.setAttribute("data-box-size", size);
return d;
}
// Block text WITHOUT content of inline <code>/<pre> - so keyword examples
// in code spans are shown literally instead of being rendered.
function textExcludingCode(block) {
var out = "";
var w = document.createTreeWalker(block, NodeFilter.SHOW_TEXT, {
acceptNode: function (n) {
var p = n.parentElement;
while (p && p !== block) {
if (p.tagName === "CODE" || p.tagName === "PRE") return NodeFilter.FILTER_REJECT;
p = p.parentElement;
}
return NodeFilter.FILTER_ACCEPT;
}
});
while (w.nextNode()) out += w.currentNode.nodeValue;
return out;
}
// Replace [map <key>] / [map <key>|size] / [gpx <id|title>] in block elements.
// Works even when the editor split the text across inline spans.
var RE_KEYWORD = /\[(map|gpx|yt|video|audio)\s+([^\]]+)\]/;
if (contentEl) {
var blocks = contentEl.querySelectorAll("p, h1, h2, h3, h4, h5, h6, li, blockquote");
blocks.forEach(function (block) {
var txt = textExcludingCode(block);
var m = txt.match(RE_KEYWORD);
if (!m) return;
var pipe = m[2].split("|");
var key = pipe[0].trim();
var size = "";
if (pipe.length > 1) {
var s = pipe[1].trim().toLowerCase();
if (s === "small" || s === "medium" || s === "large") size = s;
}
if (!key) return;
var d;
if (m[1] === "map") d = makeDiv(key, size);
else if (m[1] === "yt") {
var vid = (key.match(/(?:v=|youtu\.be\/|shorts\/)([A-Za-z0-9_-]{11})/) || [null, key])[1];
d = document.createElement("div");
d.className = "blog-yt";
d.setAttribute("data-yt", vid);
var ytCap = pipe.slice(1).join("|").trim();
if (ytCap) d.setAttribute("data-caption", ytCap);
}
else if (m[1] === "video" || m[1] === "audio") {
d = document.createElement("div");
d.className = "blog-media";
d.setAttribute("data-media", key);
d.setAttribute("data-mkind", m[1]);
var mCap = pipe.slice(1).join("|").trim();
if (mCap) d.setAttribute("data-caption", mCap);
}
else {
d = document.createElement("div");
d.className = "blog-gpx";
d.setAttribute("data-gpx", key);
var gpxTitle = pipe.slice(1).join("|").trim();
if (gpxTitle) d.setAttribute("data-title", gpxTitle);
}
block.parentNode.replaceChild(d, block);
});
}
// Build all [gpx] detail cards (independent from maplibre).
document.querySelectorAll("div.blog-gpx[data-gpx]").forEach(function (el) { loadGpxCard(el); });
// Build [yt] click-to-load facades (no YouTube requests until clicked).
function buildYtFacade(el) {
var id = el.getAttribute("data-yt");
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) { el.className = ""; el.innerHTML = "<p><em>Ungültige YouTube-ID.</em></p>"; return; }
var cap = el.getAttribute("data-caption") || "";
el.innerHTML = '<button type="button" class="blog-yt-poster" aria-label="Video abspielen">' +
'<img src="https://i.ytimg.com/vi/' + id + '/hqdefault.jpg" alt="" loading="lazy">' +
'<span class="blog-yt-play"></span></button>' +
(cap ? '<div class="blog-yt-caption">' + cap + "</div>" : "");
el.querySelector(".blog-yt-poster").addEventListener("click", function () {
var f = document.createElement("iframe");
f.src = "https://www.youtube-nocookie.com/embed/" + id + "?autoplay=1&rel=0";
f.title = cap || "YouTube-Video";
f.allow = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share";
f.referrerPolicy = "strict-origin-when-cross-origin";
f.allowFullscreen = true;
var capEl = el.querySelector(".blog-yt-caption");
el.innerHTML = "";
el.appendChild(f);
if (capEl) el.appendChild(capEl);
});
}
document.querySelectorAll("div.blog-yt[data-yt]").forEach(buildYtFacade);
if (!window.geoData || !window.geoData.maps) return;
// Build [video]/[audio] players from the geoData media index.
function buildMediaPlayer(el, list) {
var key = el.getAttribute("data-media");
var hit = null;
var kl = key.toLowerCase();
list.forEach(function (mm) {
if (mm.id === key || mm.title === key || mm.title.toLowerCase() === kl || (mm.title + "." + mm.format).toLowerCase() === kl) hit = mm;
});
if (!hit) { el.className = ""; el.innerHTML = '<p><em>Mediendatei \"' + key + '\" nicht gefunden.</em></p>'; return; }
var src = "api/notes/" + hit.id + "/download";
var cap = el.getAttribute("data-caption") || hit.title;
var tag = hit.kind === "video"
? '<video controls preload="metadata" playsinline src="' + src + '"></video>'
: '<audio controls preload="metadata" src="' + src + '"></audio>';
el.innerHTML = tag + (cap ? '<div class="blog-media-caption">' + cap + "</div>" : "");
}
document.querySelectorAll("div.blog-media[data-media]").forEach(function (el) { buildMediaPlayer(el, (window.geoData && window.geoData.media) || []); });
// Collect all map targets from keyword placeholders.
var targets = [];
document.querySelectorAll("div.blog-geo-map[data-map]").forEach(function (el) {
var def = findDef(el.getAttribute("data-map"));
if (def) targets.push({ el: el, def: def });
else el.innerHTML = "<p><em>Geo-Karte nicht gefunden.</em></p>";
});
// If no placeholders, render the map for this page's note id, before the footer nav.
if (!targets.length) {
var id = document.body.getAttribute("data-note-id");
var mapDef = id ? findDef(id) : null;
if (mapDef && contentEl) {
var el = makeDiv(id, "");
var nav = contentEl.querySelector("nav.blog-prevnext");
if (nav) contentEl.insertBefore(el, nav);
else contentEl.appendChild(el);
targets.push({ el: el, def: mapDef });
}
}
if (!targets.length) return;
// ---- GPX detail cards ----
function resolveGpx(key) {
var list = (window.geoData && window.geoData.gpx) || [];
var hit = null;
list.forEach(function (g) { if (g.id === key || g.title === key) hit = g; });
if (hit) return hit;
if (/^[A-Za-z0-9]{12}$/.test(key)) return { id: key, title: key };
return null;
}
function hav(lat1, lon1, lat2, lon2) {
var R = 6371000, rad = Math.PI / 180;
var dLat = (lat2 - lat1) * rad, dLon = (lon2 - lon1) * rad;
var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function nodeText(n, tag) {
var l = n.getElementsByTagName(tag);
return l.length ? String(l[0].textContent).trim() : "";
}
function gpxStat(label, value) { return '<div class="gpx-stat"><b>' + value + "</b><i>" + label + "</i></div>"; }
function fmtDist(m) { var km = m / 1000; return (km < 10 ? km.toFixed(2) : km.toFixed(1)) + " km"; }
function fmtDur(s) {
if (!isFinite(s) || s <= 0) return "\u2014";
var h = Math.floor(s / 3600), mn = Math.round((s % 3600) / 60);
if (!h) return mn + " min";
return h + " h " + (mn < 10 ? "0" : "") + mn + " min";
}
function fmtPace(secPerKm) {
if (!isFinite(secPerKm) || secPerKm <= 0) return "\u2014";
var mm = Math.floor(secPerKm / 60), ss = Math.round(secPerKm % 60);
if (ss === 60) { mm += 1; ss = 0; }
return mm + ":" + (ss < 10 ? "0" : "") + ss + " /km";
}
function profileSvg(ds, es, maxD) {
var min = Math.min.apply(null, es), max = Math.max.apply(null, es);
if (min === max) { min -= 1; max += 1; }
var w = 600, h = 160, pad = 6, line = "";
for (var i = 0; i < ds.length; i++) {
var x = (ds[i] / maxD * w).toFixed(1);
var y = (h - pad - (es[i] - min) / (max - min) * (h - pad * 2)).toFixed(1);
line += (i ? " L" : "M") + x + "," + y;
}
return '<div class="gpx-prof-wrap">' +
'<div class="gpx-ele-range">max ' + Math.round(max) + " m \u00b7 min " + Math.round(min) + " m</div>" +
'<svg class="gpx-profile" viewBox="0 0 ' + w + " " + h + '" preserveAspectRatio="none">' +
'<path class="gpx-area" d="' + line + " L" + w + "," + h + " L0," + h + ' Z"></path>' +
'<path class="gpx-line" d="' + line + '"></path>' +
'<line class="gpx-cursor" x1="0" x2="0" y1="0" y2="' + h + '"></line></svg>' +
'<div class="gpx-tip"></div>' +
'<div class="gpx-axis"><span>0 km</span><span>' + fmtDist(maxD) + "</span></div></div>";
}
function attachProfileHover(wrap, ds, es, maxD, w) {
var svg = wrap.querySelector("svg.gpx-profile");
var line = wrap.querySelector(".gpx-cursor");
var tip = wrap.querySelector(".gpx-tip");
if (!svg) return;
function move(clientX) {
var r = svg.getBoundingClientRect();
if (!r.width) return;
var ratio = Math.min(1, Math.max(0, (clientX - r.left) / r.width));
var d = ratio * maxD;
var i = 1;
while (i < ds.length && ds[i] < d) i++;
var d0 = ds[i - 1], d1 = i < ds.length ? ds[i] : ds[i - 1];
var e0 = es[i - 1], e1 = i < es.length ? es[i] : es[i - 1];
var e = d1 > d0 ? e0 + (e1 - e0) * (d - d0) / (d1 - d0) : e0;
var x = (ratio * w).toFixed(1);
line.setAttribute("x1", x); line.setAttribute("x2", x);
line.style.display = "inline";
tip.style.left = (ratio * 100) + "%";
var diff = e - es[0];
tip.textContent = (d / 1000).toFixed(2) + " km \u00b7 " + (diff >= 0 ? "+" : "") + Math.round(diff) + " m";
tip.style.opacity = "1";
}
svg.addEventListener("pointermove", function (ev) { move(ev.clientX); });
svg.addEventListener("pointerdown", function (ev) { move(ev.clientX); });
svg.addEventListener("pointerleave", function () { line.style.display = "none"; tip.style.opacity = "0"; });
}
function loadGpxCard(el) {
var g = resolveGpx(el.getAttribute("data-gpx"));
if (!g) { el.className = ""; el.innerHTML = "<p><em>GPX-Datei nicht gefunden.</em></p>"; return; }
var customTitle = (el.getAttribute("data-title") || "").trim();
if (customTitle) g = { id: g.id, title: customTitle };
el.innerHTML = "<p><em>GPX wird geladen\u2026</em></p>";
fetch("api/notes/" + g.id + "/download")
.then(function (r) { return r.ok ? r.text() : null; })
.then(function (txt) {
if (!txt || txt.indexOf("<gpx") === -1) { el.className = ""; el.innerHTML = "<p><em>Keine GPX-Datei.</em></p>"; return; }
var doc = new DOMParser().parseFromString(txt, "application/xml");
var nodes = doc.getElementsByTagName("trkpt");
if (!nodes.length) nodes = doc.getElementsByTagName("rtept");
if (!nodes.length) nodes = doc.getElementsByTagName("wpt");
if (!nodes.length) { el.className = ""; el.innerHTML = "<p><em>Keine Trackpunkte.</em></p>"; return; }
var pts = [], dist = 0, prev = null;
for (var i = 0; i < nodes.length; i++) {
var lat = parseFloat(nodes[i].getAttribute("lat"));
var lon = parseFloat(nodes[i].getAttribute("lon"));
if (isNaN(lat) || isNaN(lon)) continue;
if (prev) dist += hav(prev.lat, prev.lon, lat, lon);
prev = { lat: lat, lon: lon };
var eleS = nodeText(nodes[i], "ele");
var timeS = nodeText(nodes[i], "time");
pts.push({ d: dist, ele: eleS ? parseFloat(eleS) : null, t: timeS ? Date.parse(timeS) : null });
}
var ascent = 0, descent = 0, lastEle = null, maxEle = null, minEle = null;
var elePts = [];
for (var j = 0; j < pts.length; j++) {
var e = pts[j].ele;
if (e === null || isNaN(e)) continue;
if (lastEle !== null) {
var diff = e - lastEle;
if (diff > 0.5) ascent += diff; else if (diff < -0.5) descent -= diff;
}
lastEle = e;
if (maxEle === null || e > maxEle) maxEle = e;
if (minEle === null || e < minEle) minEle = e;
elePts.push(pts[j]);
}
var t0 = null, t1 = null;
for (var k = 0; k < pts.length; k++) {
var t = pts[k].t;
if (t && !isNaN(t)) { if (t0 === null) t0 = t; t1 = t; }
}
var durS = (t0 !== null && t1 > t0) ? (t1 - t0) / 1000 : null;
var kmh = dist / 1000;
var stats = '<div class="gpx-stats">' +
gpxStat("Distance", fmtDist(dist)) +
(ascent ? gpxStat("Ascent", Math.round(ascent) + " m") : "") +
(descent ? gpxStat("Descent", Math.round(descent) + " m") : "") +
(maxEle !== null ? gpxStat("Elevation", Math.round(minEle) + "\u2013" + Math.round(maxEle) + " m") : "") +
(durS !== null ? gpxStat("Duration", fmtDur(durS)) : "") +
(durS !== null && kmh ? gpxStat("Avg speed", (kmh / (durS / 3600)).toFixed(1) + " km/h") : "") +
(durS !== null && kmh ? gpxStat("Pace", fmtPace(durS / kmh)) : "") +
"</div>";
var profile = "";
var pd = null, pe = null, pMaxD = 0;
if (elePts.length >= 2) {
var step = Math.max(1, Math.ceil(elePts.length / 600));
var ds = [], es = [];
for (var q = 0; q < elePts.length; q += step) { ds.push(elePts[q].d); es.push(elePts[q].ele); }
var lastP = elePts[elePts.length - 1];
if (ds[ds.length - 1] !== lastP.d) { ds.push(lastP.d); es.push(lastP.ele); }
pMaxD = lastP.d || 1;
pd = ds; pe = es;
profile = profileSvg(ds, es, pMaxD);
}
el.innerHTML = '<div class="gpx-title">' + g.title + "</div>" + stats + profile;
if (pd) {
var wrap = el.querySelector(".gpx-prof-wrap");
if (wrap) attachProfileHover(wrap, pd, pe, pMaxD, 600);
}
})
.catch(function () { el.className = ""; el.innerHTML = "<p><em>GPX konnte nicht geladen werden.</em></p>"; });
}
function fetchGpx(url, cb) {
try {
fetch(url).then(function (r) { return r.ok ? r.text() : null; }).then(function (txt) {
if (!txt) { cb(null); return; }
var doc = new DOMParser().parseFromString(txt, "application/xml");
var nodes = doc.getElementsByTagName("trkpt");
if (!nodes.length) nodes = doc.getElementsByTagName("rtept");
if (!nodes.length) nodes = doc.getElementsByTagName("wpt");
var coords = [];
for (var i = 0; i < nodes.length; i++) {
var lat = parseFloat(nodes[i].getAttribute("lat"));
var lon = parseFloat(nodes[i].getAttribute("lon"));
if (!isNaN(lat) && !isNaN(lon)) coords.push([lon, lat]);
}
cb(coords.length ? coords : null);
}).catch(function () { cb(null); });
} catch (e) { cb(null); }
}
function buildMap(el, mapDef, trackLines) {
var styleKey = String(mapDef.style || "auto").toLowerCase().replace(/^versatiles-/, "");
var styleUrl = STYLES[styleKey] || STYLES["auto"];
var pts = mapDef.points || [];
var coords = pts.map(function (p) { return [p.lng, p.lat]; });
var all = coords.slice();
(trackLines || []).forEach(function (tr) { all = all.concat(tr.coords); });
if (!all.length) return;
var lngs = all.map(function (c) { return c[0]; });
var lats = all.map(function (c) { return c[1]; });
var minLng = Math.min.apply(null, lngs);
var maxLng = Math.max.apply(null, lngs);
var minLat = Math.min.apply(null, lats);
var maxLat = Math.max.apply(null, lats);
var map = new window.maplibregl.Map({
container: el,
style: styleUrl,
center: [(minLng + maxLng) / 2, (minLat + maxLat) / 2],
zoom: 1,
scrollZoom: false
});
map.addControl(new window.maplibregl.NavigationControl({ showCompass: false }), "top-right");
map.on("style.load", function () {
if (coords.length > 1 && !map.getSource("route")) {
map.addSource("route", {
type: "geojson",
data: { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: coords } }
});
map.addLayer({
id: "route-line", type: "line", source: "route",
paint: { "line-color": routeColor(), "line-width": 3, "line-opacity": 0.5 }
});
}
(trackLines || []).forEach(function (tr, i) {
if (tr.coords.length < 2 || map.getLayer("track-line-" + i)) return;
map.addSource("track-" + i, {
type: "geojson",
data: { type: "Feature", properties: { name: tr.title }, geometry: { type: "LineString", coordinates: tr.coords } }
});
map.addLayer({
id: "track-line-" + i, type: "line", source: "track-" + i,
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": trackColor(), "line-width": 3, "line-opacity": 0.85 }
});
});
if (all.length > 1) map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 40, maxZoom: 14 });
else map.setZoom(12);
});
pts.forEach(function (p, i) {
var first = i === 0, last = i === pts.length - 1;
var node = document.createElement("div");
node.className = "geo-marker" + (pts.length > 1 && (first || last) ? " geo-marker-endpoint" : "");
node.addEventListener("click", function () {
var html = p.url ? '<a href="' + p.url + '"><strong>' + p.title + "</strong></a>" : "<strong>" + p.title + "</strong>";
if (p.summary) html += '<div class="geo-point-summary">' + p.summary + "</div>";
new window.maplibregl.Popup({ offset: 12 }).setLngLat([p.lng, p.lat]).setHTML(html).addTo(map);
});
new window.maplibregl.Marker({ element: node }).setLngLat([p.lng, p.lat]).addTo(map);
});
watchTheme(function () {
if (map.getLayer("route-line")) map.setPaintProperty("route-line", "line-color", routeColor());
(trackLines || []).forEach(function (tr, i) {
if (map.getLayer("track-line-" + i)) map.setPaintProperty("track-line-" + i, "line-color", trackColor());
});
});
}
function boot() {
if (!window.maplibregl) return;
targets.forEach(function (t) {
var tracks = t.def.tracks || [];
if (!tracks.length) { buildMap(t.el, t.def, []); return; }
var resolved = tracks.map(function (tr) { return tr.coords ? { title: tr.title, coords: tr.coords } : null; });
var pending = [];
tracks.forEach(function (tr, i) { if (tr.url) pending.push({ i: i, url: tr.url, title: tr.title }); });
if (!pending.length) { buildMap(t.el, t.def, resolved.filter(Boolean)); return; }
var done = 0;
pending.forEach(function (p) {
fetchGpx(p.url, function (coords) {
if (coords) resolved[p.i] = { title: p.title, coords: coords };
if (++done === pending.length) buildMap(t.el, t.def, resolved.filter(Boolean));
});
});
});
}
var css = document.createElement("link");
css.rel = "stylesheet";
css.href = "https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css";
document.head.appendChild(css);
var s = document.createElement("script");
s.src = "https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js";
s.onload = boot;
document.head.appendChild(s);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bootGeoMap);
} else {
bootGeoMap();
}
</script>