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
+3 -3
View File
@@ -26,7 +26,7 @@ CSS file (Gruvbox, light + dark, theme toggle without reload).
| `scripts/*.js` | one code note each (`.blog`), attached via `~shareHtml` | frontend snippets (verbatim content incl. `<script>`) |
| `templates/blog_article.html` | `Blog Article` | article template with promoted labels |
| `examples/*` | ignis8 icons/script | sample category logo |
| `generated/*` | `feed.xml`, `sitemap.xml`, `blog_data`, `state_map` | regenerated by the generator (reference only) |
| `generated/*` | `feed.xml`, `sitemap.xml`, `blog_data`, `state_map`, `geo_data` | regenerated by the generator (reference only, sanitized) |
| `assets/palette.png` | – | Gruvbox palette (from morhetz/gruvbox, MIT) |
## How the generator works (keep this intact)
@@ -160,8 +160,8 @@ set the other labels, and set `publish=true` **last**.
the repo reference file.
3. Check licenses: `THIRD_PARTY_LICENSES.md` must list every vendored third-party file;
original code stays GPL-3.0. Update it if new libraries were added.
4. Update docs if behaviour changed: `docs/embed-3d-model.md` (3D viewer), `README.md`
(features/layout) and this file.
4. Update docs if behaviour changed: `docs/embed-3d-model.md` (3D viewer),
`docs/embed-keywords.md` (embed keywords), `README.md` (features/layout) and this file.
5. Commit (English message) and push to `main` with the neutral identity
`trilium-share-gruvbox <trilium-share-gruvbox@users.noreply.git.familie-zink.org>`.
+17 -7
View File
@@ -75,6 +75,11 @@ pointing the `shareCss` relation at the matching note – see
battery module). See [docs/embed-3d-model.md](docs/embed-3d-model.md).
- **3D viewer inside Trilium (app widget)**: the same viewer runs in the Trilium app itself
(independent of the blog) via a `note-detail-pane` widget – see `app/`.
- **Inline embed keywords**: plain-text keywords render interactive widgets in any shared note —
`[map]` (MapLibre + VersaTiles vector maps with route lines, GPX/geoShape tracks and popups),
`[gpx]` (track stats + elevation profile with hover measurement cursor), `[cad]` (3D viewer),
`[yt]` (privacy-friendly YouTube click-to-load facade), `[video]` / `[audio]` (stream media file
notes). Everything follows the blog theme. See [docs/embed-keywords.md](docs/embed-keywords.md).
- Code highlighting (highlight.js + line numbers) and copy buttons on all code boxes.
- Optional "Buy me a coffee" button with locally-hosted Cookie font.
- `blog_generator` (backend) builds index/feed/sitemap/search-data; `blog_publish_manager` moves
@@ -105,6 +110,7 @@ pointing the `shareCss` relation at the matching note – see
| `templates/share/*.ejs` | custom share template + partials (canonical, OG, JSON-LD, robots) | code, `application/x-ejs` |
| `assets/og-banner.png` | default Open Graph image (1200×630, replace with your own) | file |
| `docs/embed-3d-model.md` | how to embed STEP/STL in a note | – |
| `docs/embed-keywords.md` | reference for the inline embed keywords (map/gpx/cad/yt/video/audio) | – |
| `examples/*` | sample category logo + menu icon script (ignis8) | code / SVG |
| `generated/*` | reference output, **regenerated automatically** by `blog_generator.js` | code / xml |
@@ -126,7 +132,7 @@ pointing the `shareCss` relation at the matching note – see
```
root
└── Blog (book)
├── Richard's Blog (book) ← blog root, the shared note
├── Your Blog (book) ← blog root, the shared note
│ ├── .blog (book) ← system notes (scripts, css, data)
│ ├── Astrophotography (book) ← example category
│ ├── Electronic (book)
@@ -177,15 +183,19 @@ Under **`.blog`** create one code note per file, using the file name as the note
| `note fallback` | `scripts/note_fallback.js` | `text/html` |
| `menu date sort` | `scripts/menu_date_sort.js` | `text/html` |
| `3d model viewer` | `scripts/3d_model_viewer.js` | `text/html` |
| `geo map render` | `scripts/geo_map_render.js` | `text/html` |
| `three.js` | `lib/3d/three.min.js` | `application/javascript` |
| `three orbit controls` | `lib/3d/OrbitControls.js` | `application/javascript` |
| `three stl loader` | `lib/3d/STLLoader.js` | `application/javascript` |
| `occt-import-js` | `lib/3d/occt-import-js.js` | `application/javascript` |
| `occt-import-js.wasm` | `lib/3d/occt-import-js.wasm` | `application/wasm` (file note) |
| `occt-wasm bundle` | `lib/3d/occt-wasm-bundle.js` | `application/javascript` (ESM `OcctKernel`, used by the `[cad]` viewer worker) |
| `occt-wasm.wasm` | `lib/3d/occt-wasm.wasm` | `application/wasm` (file note) |
| `feed.xml` | `generated/feed.xml` | code (regenerated) |
| `sitemap.xml` | `generated/sitemap.xml` | code (regenerated) |
| `state_map` | `generated/state_map.js` | `text/html` (regenerated) |
| `blog_data` | `generated/blog_data.js` | `text/html` (regenerated) |
| `geo_data` | `generated/geo_data.js` | `text/html` (regenerated; auto-created **and** auto-attached as `shareHtml` by `buildGeoData()`) |
| `robots.txt` | `generated/robots.txt` | code (static) |
| `share template` | `templates/share/page.ejs` | `application/x-ejs` |
| `tree_item`, `prev_next`, `toc_item` (children of `share template`) | `templates/share/tree_item.ejs` etc. | `application/x-ejs` |
@@ -197,7 +207,7 @@ mime `application/javascript;env=backend`) and `blog_publish_manager` (from
### 3. Wire the attributes
**On the blog root note** (`Richard's Blog`), add:
**On the blog root note** (`Your Blog`), add:
| Attribute | Value | Inheritable |
|---|---|---|
@@ -212,9 +222,9 @@ mime `application/javascript;env=backend`) and `blog_publish_manager` (from
| `~shareFavicon` | → `favicon` | yes |
| `~shareTemplate` | → `share template` | yes |
| `~shareOpenGraphImage` | → `og-banner` | – |
| `~shareHtml` | → `table column hover`, `blog card click`, `garden row click`, `code note highlight`, `code copy & lines`, `state_map`, `title state badge`, `bmc button`, `logo share alias`, `blog_data`, `prev next`, `reading time`, `search header`, `ignis8 menu icon` (optional), `note fallback`, `menu date sort`, `3d model viewer` | yes |
| `~shareHtml` | → `table column hover`, `blog card click`, `garden row click`, `code note highlight`, `code copy & lines`, `state_map`, `title state badge`, `bmc button`, `logo share alias`, `blog_data`, `prev next`, `reading time`, `search header`, `ignis8 menu icon` (optional), `note fallback`, `menu date sort`, `3d model viewer`, `geo map render` | yes |
| `~shareHtml` | → `hide home title` | **no** (index only) |
| `~runOnNoteContentChange`, `~runOnAttributeChange`, `~runOnNoteCreation`, `~runOnNoteTitleChange`, `~runOnAttributeCreation`, `~runOnBranchDeletion`, `~runOnChildNoteCreation` | → `blog_generator` | yes |
| `~runOnNoteContentChange`, `~runOnAttributeChange`, `~runOnNoteCreation`, `~runOnNoteTitleChange`, `~runOnAttributeCreation`, `~runOnBranchDeletion`, `~runOnBranchChange`, `~runOnChildNoteCreation` | → `blog_generator` | yes |
| `~runOnAttributeChange`, `~runOnAttributeCreation` | → `blog_publish_manager` | yes |
**On the script notes** (under `.blog`), add:
@@ -257,7 +267,7 @@ placeholder tokens. Replace every `__TOKEN__` with the real note ID of the corre
| Token | Replace with note |
|---|---|
| `__BLOG_ROOT_ID__` | `Richard's Blog` |
| `__BLOG_ROOT_ID__` | `Your Blog` |
| `__BLOG_SYSTEM_ID__` | `.blog` |
| `__UNPUBLISHED_ROOT_ID__` | `Unpublished` |
| `__BLOG_GENERATOR_ID__` | `blog_generator` |
@@ -283,7 +293,7 @@ Then edit the constants at the top of `blog_generator.js` and `blog_publish_mana
### 5. Enable Public Share and generate
1. On `Richard's Blog` right-click → *Share* → enable sharing.
1. On `Your Blog` right-click → *Share* → enable sharing.
2. Run the generator once: execute the `blog_generator` script (e.g. press play in the script
editor, or trigger any attribute/content change). It is also wired to run automatically on
every relevant change (`runOn*`), and you can add `run=daily` to it for a nightly run.
@@ -293,7 +303,7 @@ Then edit the constants at the top of `blog_generator.js` and `blog_publish_mana
1. Create a note from the `templates/blog_article.html` content (or clone the template note).
It promotes the `publish`, `date`, `category`, `summary`, `shareAlias` and `tags` labels.
2. Put it under a category inside `Richard's Blog` and set `publish=true`.
2. Put it under a category inside `Your Blog` and set `publish=true`.
3. To keep it private, put it under `Unpublished` instead – `blog_publish_manager` clones it
into the matching category (and back) when you flip `publish`.
+11
View File
@@ -23,6 +23,17 @@ The following files in this repository are **not** covered by the GPL-3.0 and ke
| `lib/3d/occt-wasm-bundle.js`, `lib/3d/occt-wasm.wasm` | [occt-wasm](https://github.com/andymai/occt-wasm) (OpenCASCADE **OCCT V8**, WebAssembly, esbuild-bundled `OcctKernel`) | [MIT OR Apache-2.0](#mit-license) |
| `scripts/bmc_button.js` | embeds the Trilium logo (SVG) from [Trilium/TriliumNext](https://github.com/TriliumNext/Trilium) | [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) |
## Runtime components (loaded from CDNs at page runtime, not vendored)
These are referenced by `scripts/geo_map_render.js` and `scripts/3d_model_viewer.js`
and fetched by the browser when a page renders — they are **not** stored in this repository:
| Component | Source | License |
|---|---|---|
| MapLibre GL JS 4.7.1 (map rendering, from `unpkg.com`) | [maplibre/maplibre-gl-js](https://github.com/maplibre/maplibre-gl-js) | ISC |
| VersaTiles basemap style & vector tiles (`muted`, from `tiles.versatiles.org`) | [versamaps](https://github.com/versamaps) | style: MIT · tile data: © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors (ODbL) |
| YouTube click-to-load facade (thumbnail `i.ytimg.com`, player `youtube-nocookie.com` after user click) | YouTube | no vendored code; subject to YouTube ToS |
## BSD-3-Clause
```
+216 -3
View File
@@ -51,7 +51,8 @@ if (api.originEntity && isGeneratedNoteId(api.originEntity.noteId)) {
// (avoids full rebuild on every snippet/CSS edit, image upload, self-set shareAlias, ...)
const GEN_RELEVANT_LABELS = new Set([
"publish", "date", "category", "tags", "summary", "state",
"blogGarden", "blogCategory", "blogNoArticles", "iconClass"
"blogGarden", "blogCategory", "blogNoArticles", "blogArticleList", "iconClass",
"geolocation", "geo:position", "geoShape", "viewType", "map:style", "map:scale"
]);
const GEN_SKIP_RELATIONS = new Set([
"shareHtml", "shareCss", "shareTemplate", "shareFavicon", "shareOpenGraphImage",
@@ -71,7 +72,14 @@ function shouldRebuild() {
}
if (o.type === "label" && !GEN_RELEVANT_LABELS.has(o.name)) return false;
if (o.type === "relation" && GEN_SKIP_RELATIONS.has(o.name)) return false;
if (o.type === "branch") return false; // publish/unpublish covers clone+move via label events
if (o.type === "branch") {
// Reordering route points (drag in tree) must refresh geo_data
try {
const bn = o.getNote ? o.getNote() : null;
if (bn && (bn.hasLabel("geolocation") || bn.hasLabel("geo:position") || bn.hasLabel("geoShape"))) return true;
} catch (e) {}
return false; // publish/unpublish covers clone+move via label events
}
return true;
}
if (!shouldRebuild()) {
@@ -437,6 +445,19 @@ function run() {
} catch (e) { return ""; }
}
function isInGardenTree(note) {
try {
for (const p of note.getParentNotes()) {
let cur = p;
for (let d = 0; cur && d < 8; d++) {
if (cur.hasLabel && cur.hasLabel("blogGarden")) return true;
cur = cur.getParentNotes()[0];
}
}
} catch (e) {}
return false;
}
function buildBlogData() {
const systemNote = api.getNote(BLOG_SYSTEM_NOTE_ID);
if (!systemNote) return;
@@ -448,8 +469,9 @@ function run() {
url: SHARE_BASE + "/" + encodeURIComponent(a.getLabelValue("shareAlias") || a.noteId),
summary: a.getLabelValue("summary") || "",
tags: tagList(a),
category: a.getLabelValue("category") || "",
category: a.getLabelValue("category") || (isInGardenTree(a) ? "Digital Garden" : ""),
date: (a.getLabelValue("date") || a.dateCreated).slice(0, 10),
created: (a.dateCreated || "").slice(0, 10),
dateText: dateText(a.getLabelValue("date") || a.dateCreated),
text: plainText(a)
}));
@@ -461,6 +483,151 @@ function run() {
}
}
// Published notes with viewType=geoMap get their child geolocations exported
// as window.geoData; the "geo map render" snippet turns them into Leaflet maps.
function buildGeoData() {
const systemNote = api.getNote(BLOG_SYSTEM_NOTE_ID);
if (!systemNote) return;
const books = [];
const seen = new Set();
(function walk(note, depth) {
if (depth > 20) return;
for (const child of note.getChildNotes()) {
if (seen.has(child.noteId)) continue;
seen.add(child.noteId);
if (child.getLabelValue("viewType") === "geoMap" && child.getLabelValue("publish") === "true") books.push(child);
walk(child, depth + 1);
}
})(blogRoot, 0);
let dataNote = systemNote.getChildNotes().find(n => n.title === "geo_data");
if (!books.length) {
if (dataNote && dataNote.getContent() !== "") {
dataNote.setContent("");
dataNote.save();
api.log("blog_generator: geo_data cleared");
}
return;
}
if (!dataNote) {
const created = api.createTextNote(systemNote.noteId, "geo_data", "");
dataNote = created.note ? created.note : created;
}
if (!blogRoot.hasRelation("shareHtml", dataNote.noteId)) {
blogRoot.setInheritableRelation("shareHtml", dataNote.noteId);
blogRoot.save();
api.log("blog_generator: geo_data attached to blog root");
}
const maps = books.map(b => {
const pts = [];
let kids = b.getChildNotes();
try {
if (typeof api.getBranchFromChildAndParent === "function") {
kids = kids.slice().sort((c1, c2) => {
const br1 = api.getBranchFromChildAndParent(c1.noteId, b.noteId);
const br2 = api.getBranchFromChildAndParent(c2.noteId, b.noteId);
return (br1 ? br1.position : 0) - (br2 ? br2.position : 0);
});
} else {
api.log("blog_generator: getBranchFromChildAndParent unavailable, using natural order");
}
} catch (e) {
api.log("blog_generator: geo sort failed (" + e + "), using natural order");
}
function parsePairs(s) {
const out = [];
for (const pair of String(s).split(/\s+/)) {
const pm = pair.match(/^(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)$/);
if (pm) out.push({ lat: parseFloat(pm[1]), lng: parseFloat(pm[2]) });
}
return out;
}
const tracks = [];
for (const c of kids) {
const published = c.getLabelValue("publish") === "true";
const purl = published ? SHARE_BASE + "/" + encodeURIComponent(c.getLabelValue("shareAlias") || c.noteId) : "";
const summary = c.getLabelValue("summary") || "";
const markerCoords = [];
let shapes = [];
try { shapes = c.getLabels("geoShape"); } catch (e) {}
for (const s of shapes) {
const sm = String(s.value).match(/^\s*(point|line|polygon)\s*:\s*([\s\S]*)$/i);
if (!sm) continue;
const cs = parsePairs(sm[2]);
if (!cs.length) continue;
if (sm[1].toLowerCase() === "point") markerCoords.push(...cs);
else tracks.push({ title: c.title, coords: cs.map(p => [p.lng, p.lat]) });
}
const g = c.getLabelValue("geolocation") || c.getLabelValue("geo:position") || "";
markerCoords.push(...parsePairs(g));
markerCoords.forEach((p, i) => {
pts.push({ title: markerCoords.length > 1 ? c.title + " (" + (i + 1) + ")" : c.title, lat: p.lat, lng: p.lng, url: purl, summary: summary });
});
if (c.type === "file" && (/\.gpx$/i.test(c.title || "") || /\.gpx$/i.test(c.getLabelValue("originalFileName") || ""))) {
tracks.push({ title: c.title.replace(/\.gpx$/i, ""), url: "api/notes/" + c.noteId + "/download" });
}
}
return {
id: b.noteId,
title: b.title,
style: b.getLabelValue("map:style") || "",
points: pts,
tracks: tracks
};
});
const gpxIndex = [];
(function walkGpx(note, depth) {
if (depth > 20) return;
for (const child of note.getChildNotes()) {
if (child.type === "file" && (/\.gpx$/i.test(child.title || "") || /\.gpx$/i.test(child.getLabelValue("originalFileName") || ""))) {
gpxIndex.push({ id: child.noteId, title: child.title.replace(/\.gpx$/i, "") });
}
walkGpx(child, depth + 1);
}
})(blogRoot, 0);
const cadIndex = [];
(function walkCad(note, depth) {
if (depth > 20) return;
for (const child of note.getChildNotes()) {
if (child.type === "file") {
const cm = String(child.title || "").match(/\.(stl|step|stp)$/i)
|| String(child.getLabelValue("originalFileName") || "").match(/\.(stl|step|stp)$/i);
if (cm) {
cadIndex.push({ id: child.noteId, title: child.title.replace(/\.(stl|step|stp)$/i, ""), format: cm[1].toLowerCase() });
}
}
walkCad(child, depth + 1);
}
})(blogRoot, 0);
const mediaIndex = [];
(function walkMedia(note, depth) {
if (depth > 20) return;
for (const child of note.getChildNotes()) {
if (child.type === "file") {
const titleStr = String(child.title || "");
const origStr = String(child.getLabelValue("originalFileName") || "");
const wm = titleStr.match(/\.(mp4|webm|mov|m4v|mp3|m4a|ogg|oga|wav|flac|aac|opus)$/i)
|| origStr.match(/\.(mp4|webm|mov|m4v|mp3|m4a|ogg|oga|wav|flac|aac|opus)$/i);
if (wm) {
const fmt = wm[1].toLowerCase();
mediaIndex.push({
id: child.noteId,
title: titleStr.replace(/\.[a-z0-9]+$/i, "") || origStr.replace(/\.[a-z0-9]+$/i, ""),
format: fmt,
kind: ["mp4", "webm", "mov", "m4v"].indexOf(fmt) >= 0 ? "video" : "audio"
});
}
}
walkMedia(child, depth + 1);
}
})(blogRoot, 0);
const html = `<script>window.geoData = ${JSON.stringify({ maps, gpx: gpxIndex, cad: cadIndex, media: mediaIndex, v: 6 })};</script>`;
if (dataNote.getContent() !== html) {
dataNote.setContent(html);
dataNote.save();
api.log("blog_generator: geo_data updated (" + maps.length + " maps)");
}
}
function buildTags() {
const tagsNote = blogRoot.getChildNotes().find(n => n.title === TAGS_SECTION_TITLE);
if (!tagsNote) return;
@@ -634,6 +801,50 @@ function run() {
}
}
// Opt-in (label "blogArticleList"): append a generated list of the note's
// published child notes after the user's own content. Idempotent via marker;
// placing the marker yourself controls where the list appears. The label
// value (if any, not "true") becomes the list heading.
function buildChildArticleLists() {
const marker = "<!-- blog:child-articles -->";
const seen = new Set();
(function walk(note, depth) {
if (depth > 20) return;
for (const child of note.getChildNotes()) {
if (seen.has(child.noteId)) continue;
seen.add(child.noteId);
if (child.hasLabel("blogArticleList")) {
const kids = child.getChildNotes().filter(c => c.getLabelValue("publish") === "true");
kids.sort((a, b) => {
const da = a.getLabelValue("date") || a.dateCreated;
const db = b.getLabelValue("date") || b.dateCreated;
return (db < da) ? -1 : (db > da ? 1 : 0);
});
const current = child.getContent() || "";
const idx = current.indexOf(marker);
const userText = idx >= 0 ? current.slice(0, idx) : current;
let out;
if (kids.length) {
out = userText;
if (out && !out.endsWith("\n")) out += "\n";
out += marker + "\n";
const heading = child.getLabelValue("blogArticleList") || "";
if (heading && heading !== "true") out += `<h2>${escapeHtml(heading)}</h2>\n`;
out += renderArticles(kids);
} else {
out = idx >= 0 ? current.replace(marker + "\n", "").replace(marker, "") : current;
}
if (out !== current) {
child.setContent(out);
child.save();
api.log("blog_generator: child article list updated: " + child.title + " (" + kids.length + ")");
}
}
walk(child, depth + 1);
}
})(blogRoot, 0);
}
function removeEmptyCategories() {
const blogRoot = api.getNote(BLOG_ROOT_NOTE_ID);
if (!blogRoot) return;
@@ -683,10 +894,12 @@ function run() {
buildTags();
buildCategories();
buildChildArticleLists();
buildStateMap();
buildSitemap();
ensureSeoMeta();
buildBlogData();
buildGeoData();
hideThumbChildren();
removeEmptyCategories();
}
+17 -1
View File
@@ -7,7 +7,7 @@ available space instead of the readable content width. **Inline viewers embedded
articles** (Options B and C) are limited to the content width (`model-viewer-inline`). The caption below the model always shows
`<file name> · <size> · <triangle count>` (e.g. `Wrench.stl · 711 KB · 14.6k triangles`).
There are three ways to embed a model, depending on how you store the file.
There are four ways to embed a model, depending on how you store the file.
## Option A – Model as its own note (file note)
@@ -47,6 +47,22 @@ exists as its own (published) file note — the model stays in one place.
> STEP tessellation is set to `linearDeflection: 0.4, angularDeflection: 0.2`
> (≈480k triangles for the ignis battery module; tuned for mobile).
## Option D – `[cad]` keyword (editor-safe, no HTML or links)
Plain-text keyword, robust against editor re-saves (unlike raw HTML embeds, which CKEditor strips):
```
[cad <id|title>] # e.g. [cad base] or [cad __MODEL_FILE_NOTE_ID__]
[cad <id|title>|small] # small | medium | large (viewer height)
[cad <id|title>|Own caption] # free text as caption
```
The key resolves through the **`cad` index** in `window.geoData` (built by `blog_generator.js`,
scans the blog tree for `.stl` / `.step` / `.stp` file notes; the extension may live in the
note title **or** `#originalFileName`). The model file note does **not** need its own `publish`
label — it only has to be inside the blog subtree. See `docs/embed-keywords.md` for the whole
keyword family (`map`, `gpx`, `cad`, `yt`, `video`, `audio`).
## Requirements
- The `3d model viewer` script must be wired: `~shareHtml → 3d model viewer`
+51
View File
@@ -0,0 +1,51 @@
# Inline embed keywords (map / gpx / cad / yt / video / audio)
The share renders small **text keywords** into interactive widgets, client-side, from two
`shareHtml` snippets: `scripts/geo_map_render.js` (`map`, `gpx`, `yt`, `video`, `audio`) and
`scripts/3d_model_viewer.js` (`cad`). Keywords are plain text, so they survive editor re-saves —
no raw HTML needed. Put one keyword **on its own line** (recommended) or inline in a sentence.
A keyword is ignored when it sits inside `code`/`pre` (that is how this file shows examples).
## Syntax
```
[map <id|title>] # geomap, optional: |small|medium|large
[gpx <id|title>] # track stats card + elevation profile, optional: |Custom title
[cad <id|title>] # 3D model viewer, optional: |small|medium|large or |Caption
[yt <videoId|url>] # YouTube click-to-load facade, optional: |Caption
[video <id|title>] # inline video player, optional: |Caption
[audio <id|title>] # inline audio player, optional: |Caption
```
`<id|title>` is either the **note ID** (12 chars) or the **note title / file name**
(case-insensitive; file extension optional).
## Requirements per keyword
| Keyword | References | Notes |
|---|---|---|
| `map` | a geoMap book note (`#viewType=geoMap` + `#publish=true`) | points via child `#geolocation` / `#geo:position` / `#geoShape` or child `.gpx` files; tree order = route order; reorder children in Trilium → auto refresh (`~runOnBranchChange`) |
| `gpx` | any `.gpx` **file note** in the blog tree | stats: distance, ascent/descent (0.5 m deadband), elevation range, duration, avg speed, pace (needs `<time>` in track points) + hover cursor on the profile (km from start · Δheight from start) |
| `cad` | `.stl` / `.step` / `.stp` file note | extension may live in the note title **or** `#originalFileName`; STEP parsed by occt-wasm in a module worker (~22 MB on first load) |
| `yt` | YouTube video ID or URL (`youtu.be`, `watch?v=`, `/shorts/`) | only the thumbnail loads initially; player comes from `youtube-nocookie.com` on click |
| `video` | `.mp4` / `.webm` / `.mov` / `.m4v` file note | use **faststart** MP4 (moov first). Trilium ignores `Range` requests → seeking may re-buffer |
| `audio` | `.mp3` / `.ogg` / `.oga` / `.wav` / `.flac` / `.m4a` / `.aac` / `.opus` file note | streamed from `api/notes/<id>/download` |
`gpx` (as card), `cad`, `video` and `audio` resolve through the **`geoData` indexes**
(`maps`, `gpx`, `cad`, `media`) which `blog_generator.js` builds automatically — so the files
must live somewhere **inside the blog tree** (they do not need their own `publish` label).
## Theme
Maps, GPX cards, players and captions follow the blog's day/night toggle
(`theme-dark` / `theme-light` class on `<html>`), **not** the OS setting. Map basemaps are
VersaTiles `muted` ("neutron" look) in day mode, CSS-inverted at night; a subtle sage/bone
tint overlay sits on the map in day mode only.
## Styling knobs (geo map render)
- map height: `[map x|small]` ≈ 280 px, `medium` ≈ 440 px, default `min(70vh, 640px)`, `large` ≈ 620 px (Cad: same words via `CAD_HEIGHTS`)
- map style label on the book note: `#map:style=auto|muted|neutron|osm-bright|liberty`
- tint: `.blog-geo-map::after` gradient + `mix-blend-mode: multiply`
- 3D shading: append `?render=normal` to the page URL to disable the electron-microscope shader
+14 -14
View File
@@ -1,7 +1,7 @@
# Switching the blog theme
The blog theme is **not** selected by a file name – it is controlled by the **`~shareCss` relation**
on the blog root note (`Richard's Blog`) inside Trilium. The relation points at a `text/css` note
on the blog root note (`Your Blog`) inside Trilium. The relation points at a `text/css` note
that holds the theme stylesheet. Everything below uses the notes of this repository's live blog;
note IDs are given for reference.
@@ -9,12 +9,12 @@ note IDs are given for reference.
| Theme | Repo file | Live note | Dark / light |
|---|---|---|---|
| **Ash (active)** | `theme/blog_share_theme_ash.css` | `OCbcTyzbQJA2` | panel/bone · ember+steel / paper+ink |
| Coffee (default) | `theme/blog_share_theme_coffee.css` | `NqZXcFRGi3II` | espresso / latte-cream |
| Harbor | `theme/blog_share_theme_harbor.css` | `Ep54EDTwKxup` | harbordark / paper |
| Gruvbox | `theme/blog_share_theme_gruvbox.css` | `edhL5cByGwqA` | gruvbox dark / light |
| Solarized | `theme/blog_share_theme_solarized.css` | `UFxPR5IpTyfj` | base03 / base3 |
| Tokyo Night | `theme/blog_share_theme_tokyonight.css` | `bf1OKYo3msZm` | storm / day |
| **Ash (active)** | `theme/blog_share_theme_ash.css` | `__THEME_ASH_ID__` | panel/bone · ember+steel / paper+ink |
| Coffee (default) | `theme/blog_share_theme_coffee.css` | `__THEME_COFFEE_ID__` | espresso / latte-cream |
| Harbor | `theme/blog_share_theme_harbor.css` | `__THEME_HARBOR_ID__` | harbordark / paper |
| Gruvbox | `theme/blog_share_theme_gruvbox.css` | `__THEME_GRUVBOX_ID__` | gruvbox dark / light |
| Solarized | `theme/blog_share_theme_solarized.css` | `__THEME_SOLARIZED_ID__` | base03 / base3 |
| Tokyo Night | `theme/blog_share_theme_tokyonight.css` | `__THEME_TOKYONIGHT_ID__` | storm / day |
All six themes include a `html.theme-light` + `html.theme-dark` block, theme-aware **3D viewer**
overlays and **Mermaid** rendering. The 3D viewer reads the theme's CSS variables
@@ -23,7 +23,7 @@ automatically to whichever theme is active – no script changes needed.
## How the active theme is resolved
1. The blog root note (`Richard's Blog`, `OKjkpgmXZY1L`) carries `~shareCss` → `blog_share_theme_*.css`.
1. The blog root note (`Your Blog`, `__BLOG_ROOT_ID__`) carries `~shareCss` → `blog_share_theme_*.css`.
2. The target note is a `code` note with mime `text/css` living under `.blog` (the share system note).
3. The public share renders the page and loads that note's CSS.
@@ -35,26 +35,26 @@ table above), so only the relation needs to change.
### Option A – via the Trilium UI
1. Find the target theme note under `.blog` (e.g. `blog_share_theme_gruvbox.css`).
2. Open `Richard's Blog` → Attributes → edit the `~shareCss` relation to point at that note.
2. Open `Your Blog` → Attributes → edit the `~shareCss` relation to point at that note.
3. Hard-refresh the blog (Ctrl+F5). Done.
### Option B – via ETAPI (scriptable)
```bash
TOKEN="your-etapi-token"
SERVER="https://notes.familie-zink.org"
SERVER="https://YOUR-TRILIUM.example.com"
# 1. Delete the current shareCss relation on the blog root (OKjkpgmXZY1L)
# 1. Delete the current shareCss relation on the blog root (__BLOG_ROOT_ID__)
curl -X DELETE "$SERVER/etapi/attributes/<attributeId>" -H "Authorization: $TOKEN"
# 2. Point shareCss at the desired theme note (example: Gruvbox edhL5cByGwqA)
# 2. Point shareCss at the desired theme note (example: Gruvbox __THEME_GRUVBOX_ID__)
curl -X POST "$SERVER/etapi/attributes" \
-H "Authorization: $TOKEN" -H "Content-Type: application/json" \
-d '{"noteId":"OKjkpgmXZY1L","type":"relation","name":"shareCss","value":"edhL5cByGwqA","isInheritable":true}'
-d '{"noteId":"__BLOG_ROOT_ID__","type":"relation","name":"shareCss","value":"__THEME_GRUVBOX_ID__","isInheritable":true}'
```
The attribute ID of the old `shareCss` relation can be looked up via
`GET $SERVER/etapi/notes/OKjkpgmXZY1L/attributes` (or the Trilium attribute list on the note).
`GET $SERVER/etapi/notes/__BLOG_ROOT_ID__/attributes` (or the Trilium attribute list on the note).
### Option C – create a fresh theme note from this repo
+1
View File
@@ -0,0 +1 @@
<script>window.geoData = {"maps":[],"gpx":[],"cad":[],"media":[],"v":6};</script>
+62
View File
@@ -442,5 +442,67 @@
a.replaceWith(el);
initViewer(el, "api/notes/" + art.id + "/download", mm[0]);
});
// ---- [cad <key>] keyword: inline viewer, key = title or noteId of an .stl/.step/.stp file note.
// Optional pipe suffixes: |small|medium|large for height, free text for a custom caption.
var RE_CAD = /\[cad\s+([^\]]+)\]/i;
var CAD_HEIGHTS = { small: "280px", medium: "440px", large: "620px" };
function scanCadKeywords() {
var content = document.getElementById("content");
if (!content) return;
var list = (window.geoData && window.geoData.cad) || [];
if (!list.length) return;
// Block text WITHOUT inline <code>/<pre> content (keyword examples stay literal).
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;
}
content.querySelectorAll("p, h1, h2, h3, h4, h5, h6, li, blockquote").forEach(function (block) {
var m = textExcludingCode(block).match(RE_CAD);
if (!m) return;
var parts = m[1].split("|");
var key = parts[0].trim();
var size = "", title = "";
for (var i = 1; i < parts.length; i++) {
var p = parts[i].trim(), pl = p.toLowerCase();
if (pl === "small" || pl === "medium" || pl === "large") size = pl;
else if (p && !title) title = p;
}
if (!key) return;
var hit = null;
var kl = key.toLowerCase();
list.forEach(function (c) {
if (c.id === key || c.title === key || c.title.toLowerCase() === kl || (c.title + "." + c.format).toLowerCase() === kl) hit = c;
});
if (!hit) {
block.textContent = 'CAD-Modell \"' + key + '\" nicht gefunden.';
return;
}
var el = makeViewer(title || hit.title);
el.classList.add("model-viewer-inline");
if (size) {
var canvas = el.querySelector(".model-viewer-canvas");
if (canvas) canvas.style.height = CAD_HEIGHTS[size];
}
block.replaceWith(el);
initViewer(el, "api/notes/" + hit.id + "/download", "." + hit.format);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", scanCadKeywords);
} else {
scanCadKeywords();
}
})();
</script>
+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>