diff --git a/.gitignore b/.gitignore index 38b8f288..35a2f799 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,21 @@ data/usda/ # Loyalty-card barcodes (regenerated by scripts/generate-loyalty-cards.ts from env) static/shopping/supercard.svg static/shopping/cumulus.svg +# Hikes build outputs (regenerated by scripts/build-hikes.ts at prebuild) +static/hikes/ +hikes-assets/ +src/lib/data/hikes.generated.ts +# Build-script disk caches (Swisstopo identify, BRouter responses, ...) +scripts/.cache/ +# Loose working-tree scratch files (notes, photos, prototypes) that aren't +# part of the committed source. +/HIKES_PLAN.md +/additional_apologetics.md +/header_jellyfin.html +/person-hiking.svg +/PXL_*.jpg +/PXL_*.MP.jpg +src-tauri/icons/_safezone_template_*.png src-tauri/target/ src-tauri/*.keystore # Android: ignore build output and caches, track source files diff --git a/TODO.md b/TODO.md index 01338a1d..8d4ae085 100644 --- a/TODO.md +++ b/TODO.md @@ -24,10 +24,18 @@ Order = impact. Font items + app.html preload intentionally skipped. [x] BF graph (with trend line like weight graph) on /fitness/stats page. Emphasize relative changes, not absolute numbers in design (as we cannot trust those) (e.g., use start day of overview as 0% and then show +/- x % on the graph) [x] Workshop better names than "Measure" for the /fitness/measure route. It's about body data points (i.e., non-food related). What's a better, short name than "Measure" to capture the logging of weight, body composition, body part measurements, and period tracking? [x] on /fitness/stats/histoy/ for body measurement graphs, make the range reasonable. e.g., if we have 1 cm change, do not fill the entire y-height with 1 cm. Use reasonable padding for low ranges (i think we do something like htis already on the weight graph?) -[ ] on /fitness/check-in, Make the Period ended button a lot more prominent in the period tracker component. -[ ] swap heart emoji on recipe favorites to lucide icon -[ ] coop and migros cards on shopping list for scanning -[ ] login icon from lucide in header +[x] on /fitness/check-in, Make the Period ended button a lot more prominent in the period tracker component. +[x] swap heart emoji on recipe favorites to lucide icon +[x] coop and migros cards on shopping list for scanning +[x] login icon from lucide in header +[ ] Investigate self-hosting BRouter +[ ] Use the same color swisstopo map both for light and dark mode (currentyl only light mode) +[ ] pre-compute required map tiles for all tiles on the route (and adjacent enough to be visibile by default on sane screen sizes) and create a fetch instruction for the server. (separate step: create a swiss-topo caching service which smoothly interpolates with non-switzerland service tiles for spots outside of switzerland) +[ ] expand compatibility outside of switzerland with non-swiss topo map +[ ] align design better with swizterland mobility +[ ] allow for difficulty cardio, difficulty technique and T1-T6 labelling +[ ] allow for Switzerland Mobility like hike icons (with alpine blue white blue, red white red, and yellow hiking shields as a fallback alternative) +[ ] Add smoothing distance for elevation calculations on GPS-tracled workouts (3 meters? more?) ## Refactor Recipe Search Component @@ -39,3 +47,45 @@ Refactor `src/lib/components/Search.svelte` to use the new `SearchInput.svelte` Files involved: - `src/lib/components/Search.svelte` - refactor to use SearchInput - `src/lib/components/SearchInput.svelte` - the reusable input component + + + + + + + + + + + + + 1. $app/stores → $app/state (biggest, most mechanical) + Old: import { page } from '$app/stores' + $page.url.pathname + New: import { page } from '$app/state' + page.url.pathname (no $, it's a rune now). + Runes-based, smaller bundle (no store wrapper), cleaner SSR. Codebase has dozens of $app/stores imports — same kind + of codemod-able migration as hrefs. Available since 2.12. $app/stores is deprecated. + + 2. Convert legacy stores to .svelte.ts rune state + Files like $lib/stores/recipeTranslation.ts, $lib/stores/language.ts use writable(). Modern pattern: .svelte.ts files + with $state() + exported getters/setters. Better TS inference, no $ prefix, no auto-subscription gotchas. + + 3. Remote functions for new API code ($app/server, since 2.27) + Replaces hand-rolled +server.ts + client fetch with type-safe server functions called like normal funcs. Major + refactor for existing /api/** (lots of files), so probably only adopt for new endpoints — not worth churning the + existing ~80 API routes. + + 4. prerender = true audit + Static-ish pages (faith catechesis, latin prayers, apologetics arguments) are great candidates. Skip-SSR for static + content = faster cold loads + cheaper hosting. Currently nothing's prerendered — quick win where applicable. + + 5. @sveltejs/enhanced-img + Transparent image optimization (responsive srcset, AVIF/WebP, blur placeholders) at build time. Recipe hero images + and saint-day cards would benefit visibly. Drop-in via . + + 6. {@attach} over use: (Svelte 5 attachments) + Newer API for DOM-lifecycle hooks. Supports spread + library composition use: can't. Low urgency; only matters when + writing new lifecycle code. + + 7. Shallow routing for modals/galleries + pushState + flow lets modals participate in history without full navigation. Useful if you ever add a + recipe-image lightbox or apologetics-arg overlay. Net-new feature, not a migration. diff --git a/package.json b/package.json index a4ee7266..6ef666fc 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "homepage", - "version": "1.70.2", + "version": "1.71.0", "private": true, "type": "module", "scripts": { "dev": "vite dev", - "prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts && pnpm exec vite-node scripts/generate-loyalty-cards.ts && pnpm exec vite-node scripts/generate-error-quotes.ts", + "prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts && pnpm exec vite-node scripts/generate-loyalty-cards.ts && pnpm exec vite-node scripts/generate-error-quotes.ts && pnpm exec vite-node scripts/build-hikes.ts", "build": "vite build", "postbuild": "pnpm exec vite-node scripts/build-error-page.ts", "preview": "vite preview", @@ -40,6 +40,7 @@ "@vitest/ui": "^4.1.2", "bwip-js": "^4.10.1", "jsdom": "^27.2.0", + "mdsvex": "^0.12.7", "svelte": "^5.55.1", "svelte-check": "^4.4.6", "tslib": "^2.8.1", @@ -59,6 +60,7 @@ "chart.js": "^4.5.1", "chartjs-adapter-date-fns": "^3.0.0", "date-fns": "^4.1.0", + "exifr": "^7.1.3", "file-type": "^19.0.0", "leaflet": "^1.9.4", "mongoose": "^9.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa8efce3..a93c92e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + exifr: + specifier: ^7.1.3 + version: 7.1.3 file-type: specifier: ^19.0.0 version: 19.6.0 @@ -99,6 +102,9 @@ importers: jsdom: specifier: ^27.2.0 version: 27.2.0 + mdsvex: + specifier: ^0.12.7 + version: 0.12.7(svelte@5.55.1) svelte: specifier: ^5.55.1 version: 5.55.1 @@ -1079,6 +1085,9 @@ packages: '@types/leaflet@1.9.21': resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/node-cron@3.0.11': resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} @@ -1091,6 +1100,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + '@types/webidl-conversions@7.0.0': resolution: {integrity: sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==} @@ -1341,6 +1353,9 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + exifr@7.1.3: + resolution: {integrity: sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1585,6 +1600,11 @@ packages: mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + mdsvex@0.12.7: + resolution: {integrity: sha512-gx4bReLCUvq+MPErHXYeyX+TEq1hsS2KfiZtEOMNTcbibSouFy8AHc5h04KbGCl+g5tLuo4/lbgRVYRnc7bJZw==} + peerDependencies: + svelte: ^3.56.0 || ^4.0.0 || ^5.0.0-next.120 + memory-pager@1.5.0: resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} @@ -1738,6 +1758,13 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prism-svelte@0.4.7: + resolution: {integrity: sha512-yABh19CYbM24V7aS7TuPYRNMqthxwbvx6FF/Rw920YbyBWO3tnyPIqRMgHuSVsLmuHkkBS1Akyof463FVdkeDQ==} + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + protobufjs@7.5.4: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} @@ -1968,6 +1995,21 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unist-util-is@4.1.0: + resolution: {integrity: sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==} + + unist-util-stringify-position@2.0.3: + resolution: {integrity: sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==} + + unist-util-visit-parents@3.1.1: + resolution: {integrity: sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==} + + unist-util-visit@2.0.3: + resolution: {integrity: sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==} + + vfile-message@2.0.4: + resolution: {integrity: sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==} + vite-node@6.0.0: resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2847,6 +2889,10 @@ snapshots: dependencies: '@types/geojson': 7946.0.16 + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 2.0.11 + '@types/node-cron@3.0.11': {} '@types/node@22.18.0': @@ -2857,6 +2903,8 @@ snapshots: '@types/trusted-types@2.0.7': {} + '@types/unist@2.0.11': {} + '@types/webidl-conversions@7.0.0': {} '@types/whatwg-url@13.0.0': @@ -3096,6 +3144,8 @@ snapshots: dependencies: '@types/estree': 1.0.8 + exifr@7.1.3: {} + expect-type@1.3.0: {} fdir@6.5.0(picomatch@4.0.3): @@ -3321,6 +3371,16 @@ snapshots: mdn-data@2.12.2: {} + mdsvex@0.12.7(svelte@5.55.1): + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 2.0.11 + prism-svelte: 0.4.7 + prismjs: 1.30.0 + svelte: 5.55.1 + unist-util-visit: 2.0.3 + vfile-message: 2.0.4 + memory-pager@1.5.0: {} min-indent@1.0.1: {} @@ -3442,6 +3502,10 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + prism-svelte@0.4.7: {} + + prismjs@1.30.0: {} + protobufjs@7.5.4: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -3752,6 +3816,28 @@ snapshots: undici-types@6.21.0: {} + unist-util-is@4.1.0: {} + + unist-util-stringify-position@2.0.3: + dependencies: + '@types/unist': 2.0.11 + + unist-util-visit-parents@3.1.1: + dependencies: + '@types/unist': 2.0.11 + unist-util-is: 4.1.0 + + unist-util-visit@2.0.3: + dependencies: + '@types/unist': 2.0.11 + unist-util-is: 4.1.0 + unist-util-visit-parents: 3.1.1 + + vfile-message@2.0.4: + dependencies: + '@types/unist': 2.0.11 + unist-util-stringify-position: 2.0.3 + vite-node@6.0.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0): dependencies: cac: 7.0.0 diff --git a/scripts/build-hikes.ts b/scripts/build-hikes.ts new file mode 100644 index 00000000..d628360f --- /dev/null +++ b/scripts/build-hikes.ts @@ -0,0 +1,846 @@ +/** + * Build script for the /hikes route. + * + * For each directory under `src/content/hikes//`: + * 1. Parse `index.svx` frontmatter (lightweight in-house parser, schema is small). + * 2. Parse `track.gpx` and derive distance / elevation gain / loss / bbox / + * centroid / duration / preview polyline. + * 3. Reverse-geocode the centroid via Swisstopo (cached on disk). + * 4. Process every image in `images/` with sharp into AVIF + WebP at 3 widths + * and emit srcset strings. Only encode images whose hash is referenced + * and collect them as `imagePoints` for on-map markers. + * 5. Write `static/hikes//track..json` (compact tuple format). + * Emits `src/lib/data/hikes.generated.ts` containing the typed manifest used + * by the `/hikes` overview page. + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import os from 'node:os'; +import sharp from 'sharp'; +import { + parseGpx, + parseGpxImageRefs, + trackDistance, + type GpxImageRef, + type GpxPoint +} from '../src/lib/server/gpx.js'; +import { simplifyTrack } from '../src/lib/server/simplifyTrack.js'; +import type { + Difficulty, + HikeManifestEntry, + ImagePoint, + ImageVariant +} from '../src/types/hikes.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const ROOT = path.resolve(process.cwd()); +const CONTENT_DIR = path.join(ROOT, 'src', 'content', 'hikes'); +// Track JSON stays under /static — it's public preview data and SvelteKit +// serves it directly with the rest of the site. URL: /hikes//track.*.json +const STATIC_DIR = path.join(ROOT, 'static', 'hikes'); +// Image binaries live outside /static so they aren't bundled into the Node +// build or served by SvelteKit. The deploy step rsyncs this tree to +// /var/www/static/hikes/ on the server, where nginx serves public images +// directly and gates `/private/` images through Node + X-Accel-Redirect. +const HIKES_ASSETS_DIR = path.join(ROOT, 'hikes-assets'); +const CACHE_DIR = path.join(ROOT, 'scripts', '.cache'); +const GEOCODE_CACHE_FILE = path.join(CACHE_DIR, 'hikes-geocode.json'); +const MANIFEST_OUT = path.join(ROOT, 'src', 'lib', 'data', 'hikes.generated.ts'); + +const ELEV_SMOOTH_WINDOW = 5; // moving-average window for altitude denoising +const ELEV_MIN_STEP_M = 3; // discard altitude deltas below this +const PREVIEW_POLYLINE_MAX_POINTS = 30; +const IMAGE_WIDTHS = [480, 960, 1600] as const; +const IMAGE_THUMBNAIL_WIDTH = 240; // popup thumbnail for map markers +const MANIFEST_WARN_BYTES = 200_000; + +const VALID_DIFFICULTIES: readonly Difficulty[] = ['T1', 'T2', 'T3', 'T4', 'T5', 'T6']; + +// Sharp pipelines are CPU-heavy but release the JS thread while libvips runs, +// so a small concurrency pool gives a near-linear speed-up. Cap at 4 to avoid +// thrashing on smaller boxes (a single AVIF encode can saturate one core). +const IMAGE_CONCURRENCY = Math.max(2, Math.min(os.cpus().length, 4)); + +async function pathExists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +async function runWithConcurrency( + items: readonly T[], + limit: number, + worker: (item: T, index: number) => Promise +): Promise { + const results = new Array(items.length); + let next = 0; + const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (true) { + const i = next++; + if (i >= items.length) return; + results[i] = await worker(items[i], i); + } + }); + await Promise.all(runners); + return results; +} + +// --------------------------------------------------------------------------- +// Tiny frontmatter parser (no deps). +// Supports: strings, numbers, booleans, ISO dates (kept as string), +// and bracketed arrays of strings: `[a, b, "c d"]`. +// --------------------------------------------------------------------------- + +type Frontmatter = Record; + +function parseFrontmatter(source: string): { data: Frontmatter; body: string } { + const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!match) return { data: {}, body: source }; + + const data: Frontmatter = {}; + for (const rawLine of match[1].split(/\r?\n/)) { + const line = rawLine.replace(/\s+#.*$/, '').trim(); + if (!line || line.startsWith('#')) continue; + const sep = line.indexOf(':'); + if (sep < 0) continue; + const key = line.slice(0, sep).trim(); + const raw = line.slice(sep + 1).trim(); + data[key] = parseScalar(raw); + } + return { data, body: match[2] }; +} + +function parseScalar(raw: string): string | number | boolean | string[] { + if (raw === '') return ''; + if (raw === 'true') return true; + if (raw === 'false') return false; + if (raw.startsWith('[') && raw.endsWith(']')) { + return raw + .slice(1, -1) + .split(',') + .map(s => stripQuotes(s.trim())) + .filter(s => s.length > 0); + } + if (/^-?\d+(\.\d+)?$/.test(raw)) return parseFloat(raw); + return stripQuotes(raw); +} + +function stripQuotes(s: string): string { + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { + return s.slice(1, -1); + } + return s; +} + +/** Parse a `seasons` frontmatter value into `{ seasonStart, seasonEnd }`. + * Accepts: + * - a numeric range string `"4-9"` (April through September) + * - a 3-letter / full English month range `"apr-sep"` or `"april-september"` + * - an array of two numbers `[4, 9]` + * Returns `{ seasonStart: null, seasonEnd: null }` when absent or malformed. */ +function parseSeasonRange(raw: unknown): { seasonStart: number | null; seasonEnd: number | null } { + const empty = { seasonStart: null, seasonEnd: null }; + if (raw == null || raw === '') return empty; + + const MONTHS: Record = { + jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, + apr: 4, april: 4, may: 5, jun: 6, june: 6, jul: 7, july: 7, + aug: 8, august: 8, sep: 9, september: 9, sept: 9, oct: 10, october: 10, + nov: 11, november: 11, dec: 12, december: 12 + }; + const toMonth = (v: string | number): number | null => { + if (typeof v === 'number') return v >= 1 && v <= 12 ? v : null; + const s = String(v).trim().toLowerCase(); + if (/^\d+$/.test(s)) { + const n = parseInt(s, 10); + return n >= 1 && n <= 12 ? n : null; + } + return MONTHS[s] ?? null; + }; + + let parts: Array | null = null; + if (Array.isArray(raw) && raw.length === 2) { + parts = raw as Array; + } else if (typeof raw === 'string' && raw.includes('-')) { + parts = raw.split('-').map((s) => s.trim()); + } + if (!parts) return empty; + + const a = toMonth(parts[0]); + const b = toMonth(parts[1]); + if (a == null || b == null) return empty; + return { seasonStart: a, seasonEnd: b }; +} + +// --------------------------------------------------------------------------- +// Elevation helpers +// --------------------------------------------------------------------------- + +// Returns `null` for indices where no defined altitude exists in the ±half +// window. The previous behaviour (defaulting to 0) silently turned missing +// `` tags into huge synthetic gain spikes against the next real altitude. +function smoothAltitudes(track: GpxPoint[]): (number | null)[] { + const n = track.length; + const out = new Array(n); + const half = Math.floor(ELEV_SMOOTH_WINDOW / 2); + for (let i = 0; i < n; i++) { + let sum = 0; + let count = 0; + for (let j = Math.max(0, i - half); j <= Math.min(n - 1, i + half); j++) { + const a = track[j].altitude; + if (typeof a === 'number') { + sum += a; + count++; + } + } + out[i] = count > 0 ? sum / count : null; + } + return out; +} + +function computeElevationStats(track: GpxPoint[]): { gain: number; loss: number } { + if (track.length < 2) return { gain: 0, loss: 0 }; + const altitudes = smoothAltitudes(track); + let gain = 0; + let loss = 0; + let prev: number | null = null; + for (const a of altitudes) { + if (a === null) continue; + if (prev === null) { + prev = a; + continue; + } + const diff = a - prev; + if (diff >= ELEV_MIN_STEP_M) { + gain += diff; + prev = a; + } else if (diff <= -ELEV_MIN_STEP_M) { + loss += -diff; + prev = a; + } + } + return { gain: Math.round(gain), loss: Math.round(loss) }; +} + +function computeElevationRange(track: GpxPoint[]): { min: number | null; max: number | null } { + let min = Infinity; + let max = -Infinity; + for (const p of track) { + if (typeof p.altitude !== 'number') continue; + if (p.altitude < min) min = p.altitude; + if (p.altitude > max) max = p.altitude; + } + if (!Number.isFinite(min) || !Number.isFinite(max)) return { min: null, max: null }; + return { min: Math.round(min), max: Math.round(max) }; +} + +function computeBboxAndCentroid(track: GpxPoint[]): { + bbox: [number, number, number, number]; + centroid: [number, number]; +} { + let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; + let sumLat = 0, sumLng = 0; + for (const p of track) { + if (p.lat < minLat) minLat = p.lat; + if (p.lat > maxLat) maxLat = p.lat; + if (p.lng < minLng) minLng = p.lng; + if (p.lng > maxLng) maxLng = p.lng; + sumLat += p.lat; + sumLng += p.lng; + } + const n = track.length || 1; + return { + bbox: [minLat, minLng, maxLat, maxLng], + centroid: [sumLat / n, sumLng / n] + }; +} + +// --------------------------------------------------------------------------- +// Swisstopo reverse-geocode with disk cache +// --------------------------------------------------------------------------- + +type GeocodeResult = { + canton: string | null; + municipality: string | null; + region: string | null; +}; + +type GeocodeCache = Record; + +async function loadGeocodeCache(): Promise { + try { + const raw = await fs.readFile(GEOCODE_CACHE_FILE, 'utf-8'); + return JSON.parse(raw); + } catch { + return {}; + } +} + +async function saveGeocodeCache(cache: GeocodeCache): Promise { + await fs.mkdir(CACHE_DIR, { recursive: true }); + await fs.writeFile(GEOCODE_CACHE_FILE, JSON.stringify(cache, null, 2)); +} + +const SWISSTOPO_UA = 'bocken-homepage build-hikes'; + +async function fetchFeatureName(layerBodId: string, featureId: number | string): Promise { + const url = `https://api3.geo.admin.ch/rest/services/api/MapServer/${layerBodId}/${featureId}/htmlPopup?lang=de`; + try { + const res = await fetch(url, { headers: { 'User-Agent': SWISSTOPO_UA } }); + if (!res.ok) return null; + const html = await res.text(); + // htmlPopup label is "Name" for cantons and "Amtlicher Gemeindename" for municipalities. + const m = + html.match(/]*>(?:Amtlicher\s+Gemeindename|Name)<\/td>\s*]*>([^<]+)<\/td>/i); + return m ? m[1].trim() : null; + } catch { + return null; + } +} + +async function reverseGeocode( + lat: number, + lng: number, + cache: GeocodeCache +): Promise { + const key = `${lat.toFixed(5)},${lng.toFixed(5)}`; + if (cache[key]) return cache[key]; + + const layers = + 'all:ch.swisstopo.swissboundaries3d-kanton-flaeche.fill,' + + 'ch.swisstopo.swissboundaries3d-gemeinde-flaeche.fill'; + // Tight 1000x1000 imageDisplay over a 0.0002 deg mapExtent with 1px tolerance + // gives ~2 cm of effective tolerance around the centroid — enough to land in + // the correct kanton/gemeinde without picking up neighbours. + const eps = 0.0001; + const url = + `https://api3.geo.admin.ch/rest/services/api/MapServer/identify` + + `?geometry=${lng},${lat}` + + `&geometryType=esriGeometryPoint&geometryFormat=geojson&returnGeometry=false` + + `&imageDisplay=1000,1000,96` + + `&mapExtent=${lng - eps},${lat - eps},${lng + eps},${lat + eps}` + + `&tolerance=1&layers=${layers}&sr=4326`; + + const result: GeocodeResult = { canton: null, municipality: null, region: null }; + try { + const res = await fetch(url, { headers: { 'User-Agent': SWISSTOPO_UA } }); + if (res.ok) { + type IdentifyRow = { layerBodId?: string; layerName?: string; featureId?: number | string; id?: number | string }; + const json = (await res.json()) as { results?: IdentifyRow[] }; + // Identify returns historical boundary records too, so we only need the + // first hit per layer. + for (const r of json.results ?? []) { + const layerBodId = r.layerBodId; + const featureId = r.featureId ?? r.id; + if (!layerBodId || featureId === undefined) continue; + if (layerBodId.includes('kanton') && result.canton) continue; + if (layerBodId.includes('gemeinde') && result.municipality) continue; + const name = await fetchFeatureName(layerBodId, featureId); + if (!name) continue; + if (layerBodId.includes('kanton')) result.canton = name; + else if (layerBodId.includes('gemeinde')) result.municipality = name; + } + result.region = result.municipality ?? result.canton; + } else { + console.warn(`[build-hikes] Swisstopo identify failed (${res.status}) for ${key}`); + } + } catch (err) { + console.warn(`[build-hikes] Swisstopo identify error for ${key}:`, err); + } + + cache[key] = result; + return result; +} + +// --------------------------------------------------------------------------- +// Image processing (sharp -> AVIF + WebP at multiple widths) +// --------------------------------------------------------------------------- + +function shortHashOfBuffer(buf: Buffer): string { + return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 8); +} + +async function processImage( + srcPath: string, + slug: string, + alt: string, + gpxImageRefs: Record +): Promise< + | { variant: ImageVariant; thumbnailRelUrl: string; largestRelUrl: string; hash: string; visibility: 'public' | 'private'; cached: boolean; outNames: string[] } + | { skipped: true; hash: string } +> { + const buffer = await fs.readFile(srcPath); + const hash = shortHashOfBuffer(buffer); + const ref = gpxImageRefs[hash]; + if (!ref) { + // Not referenced by any waypoint in track.gpx — drop it entirely (no + // encode, no manifest entry, no static output). Authors who want an + // image published must place it on the route via the route-builder + // (which writes a `` waypoint into track.gpx). + return { skipped: true, hash }; + } + const visibility: 'public' | 'private' = ref.visibility === 'private' ? 'private' : 'public'; + // Public images go under `images/` (served directly by nginx). + // Private images go under `private/` (proxied through Node for auth check; + // nginx hands them off via X-Accel-Redirect once the session is valid). + const segment = visibility === 'private' ? 'private' : 'images'; + // Filenames are content-hash only — the source basename (which usually + // encodes a date + camera ID) is intentionally dropped so it doesn't leak + // into the published URLs. + const outDir = path.join(HIKES_ASSETS_DIR, slug, segment); + await fs.mkdir(outDir, { recursive: true }); + + const meta = await sharp(buffer).metadata(); + const intrinsicW = meta.width ?? IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1]; + const intrinsicH = meta.height ?? 0; + + const widths = IMAGE_WIDTHS.filter(w => w <= intrinsicW); + if (widths.length === 0) widths.push(intrinsicW); + + type EncodeJob = { + w: number; + format: 'avif' | 'webp'; + filePath: string; + quality: number; + }; + + const jobs: EncodeJob[] = []; + const avifEntries: string[] = []; + const webpEntries: string[] = []; + let largestWebp = ''; + + for (const w of widths) { + const avifName = `${hash}.${w}.avif`; + const webpName = `${hash}.${w}.webp`; + jobs.push({ w, format: 'avif', filePath: path.join(outDir, avifName), quality: 55 }); + jobs.push({ w, format: 'webp', filePath: path.join(outDir, webpName), quality: 82 }); + const avifUrl = `/hikes/${slug}/${segment}/${avifName}`; + const webpUrl = `/hikes/${slug}/${segment}/${webpName}`; + avifEntries.push(`${avifUrl} ${w}w`); + webpEntries.push(`${webpUrl} ${w}w`); + largestWebp = webpUrl; + } + + const thumbName = `${hash}.${IMAGE_THUMBNAIL_WIDTH}.webp`; + const thumbPath = path.join(outDir, thumbName); + const thumbUrl = `/hikes/${slug}/${segment}/${thumbName}`; + const thumbJob: EncodeJob = { + w: IMAGE_THUMBNAIL_WIDTH, + format: 'webp', + filePath: thumbPath, + quality: 78 + }; + + // Filter out jobs whose output already exists — the hash is in the filename, + // so an existing file is guaranteed to be the same encoded bytes. + const allJobs = [...jobs, thumbJob]; + const presence = await Promise.all(allJobs.map(j => pathExists(j.filePath))); + const pending = allJobs.filter((_, i) => !presence[i]); + const cached = pending.length === 0; + + await Promise.all( + pending.map(async (job) => { + const pipeline = sharp(buffer).rotate().resize({ width: job.w, withoutEnlargement: true }); + if (job.format === 'avif') { + await pipeline.avif({ quality: job.quality }).toFile(job.filePath); + } else { + await pipeline.webp({ quality: job.quality }).toFile(job.filePath); + } + }) + ); + + const largestW = widths[widths.length - 1]; + const scale = largestW / intrinsicW; + const largestH = Math.round((intrinsicH || largestW) * scale); + + // Names of every output file this image owns — used by the per-hike + // cleanup pass to drop orphaned encodes from previous builds. + const outNames = allJobs.map((j) => path.basename(j.filePath)); + + return { + variant: { + src: largestWebp, + srcsetAvif: avifEntries.join(', '), + srcsetWebp: webpEntries.join(', '), + width: largestW, + height: largestH, + alt + }, + thumbnailRelUrl: thumbUrl, + largestRelUrl: largestWebp, + hash, + visibility, + cached, + outNames + }; +} + +// --------------------------------------------------------------------------- +// Per-hike icon (icon.svg / icon.png / icon.jpg / icon.jpeg / icon.webp). +// SVG passes through verbatim; raster sources are re-encoded to a single +// 256-square WebP so /hikes// stays small. Filenames carry the +// source content hash so the URL changes when the icon does, side-stepping +// CDN cache concerns. +// --------------------------------------------------------------------------- + +const ICON_SOURCES: ReadonlyArray<{ filename: string; isSvg: boolean }> = [ + { filename: 'icon.svg', isSvg: true }, + { filename: 'icon.png', isSvg: false }, + { filename: 'icon.jpg', isSvg: false }, + { filename: 'icon.jpeg', isSvg: false }, + { filename: 'icon.webp', isSvg: false } +]; + +const ICON_RASTER_SIZE = 256; + +async function processIcon(slug: string, hikeDir: string): Promise<{ url: string; outName: string } | undefined> { + let srcPath: string | undefined; + let isSvg = false; + for (const candidate of ICON_SOURCES) { + const p = path.join(hikeDir, candidate.filename); + if (await pathExists(p)) { + srcPath = p; + isSvg = candidate.isSvg; + break; + } + } + if (!srcPath) return undefined; + + const buf = await fs.readFile(srcPath); + const hash = shortHashOfBuffer(buf); + const outExt = isSvg ? 'svg' : 'webp'; + const outName = `icon.${hash}.${outExt}`; + // Icons live under the `images/` namespace (alongside encoded photos) so + // they piggy-back on the same dev-server plugin and nginx public-serve + // rules. The naming prefix `icon.` keeps them clearly distinct from + // hash-named photo outputs. + const outDir = path.join(HIKES_ASSETS_DIR, slug, 'images'); + await fs.mkdir(outDir, { recursive: true }); + const outPath = path.join(outDir, outName); + + if (!(await pathExists(outPath))) { + if (isSvg) { + await fs.writeFile(outPath, buf); + } else { + await sharp(buf) + .rotate() + .resize({ width: ICON_RASTER_SIZE, height: ICON_RASTER_SIZE, fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 88 }) + .toFile(outPath); + } + } + + return { url: `/hikes/${slug}/images/${outName}`, outName }; +} + +// --------------------------------------------------------------------------- +// Image EXIF -> ImagePoint +// --------------------------------------------------------------------------- + +function extractImagePoint( + processed: { thumbnailRelUrl: string; largestRelUrl: string; hash: string }, + alt: string, + gpxImageRef: GpxImageRef +): ImagePoint { + // The GPX `` waypoint is the single source of truth + // for an image's position. Authors place images on the route via the + // route-builder (or by hand in the GPX); EXIF GPS is no longer trusted + // as a fallback because phone GPS noise produced visible spikes and + // because users sometimes want to publish an image at a corrected + // location. + return { + src: processed.largestRelUrl, + thumbnail: processed.thumbnailRelUrl, + lat: gpxImageRef.lat, + lng: gpxImageRef.lng, + altitude: gpxImageRef.altitude, + timestamp: gpxImageRef.timestamp, + alt, + visibility: gpxImageRef.visibility ?? 'public' + }; +} + +// --------------------------------------------------------------------------- +// Per-hike build +// --------------------------------------------------------------------------- + +async function buildHike(slug: string, cache: GeocodeCache): Promise { + const hikeStart = Date.now(); + const hikeDir = path.join(CONTENT_DIR, slug); + const svxPath = path.join(hikeDir, 'index.svx'); + const gpxPath = path.join(hikeDir, 'track.gpx'); + const imagesDir = path.join(hikeDir, 'images'); + + let svxSource: string; + try { + svxSource = await fs.readFile(svxPath, 'utf-8'); + } catch { + console.warn(`[build-hikes] Skipping ${slug}: no index.svx`); + return null; + } + + let gpxSource: string; + try { + gpxSource = await fs.readFile(gpxPath, 'utf-8'); + } catch { + console.warn(`[build-hikes] Skipping ${slug}: no track.gpx`); + return null; + } + + const { data: fm } = parseFrontmatter(svxSource); + const track = parseGpx(gpxSource); + if (track.length === 0) { + console.warn(`[build-hikes] Skipping ${slug}: empty GPX`); + return null; + } + const gpxImageRefs = parseGpxImageRefs(gpxSource); + const gpxImageCount = Object.keys(gpxImageRefs).length; + console.log(`[build-hikes:${slug}] parsed GPX (${track.length} track pts, ${gpxImageCount} image refs)`); + + const distanceKm = trackDistance(track); + const { gain, loss } = computeElevationStats(track); + const { min: elevationMinM, max: elevationMaxM } = computeElevationRange(track); + const { bbox, centroid } = computeBboxAndCentroid(track); + const previewPolyline = simplifyTrack(track, PREVIEW_POLYLINE_MAX_POINTS) as [number, number][]; + const dtMs = track[track.length - 1].timestamp - track[0].timestamp; + const durationMin = dtMs > 0 ? Math.round(dtMs / 60000) : null; + console.log(`[build-hikes:${slug}] metrics: ${distanceKm.toFixed(2)} km · ↑${gain}m / ↓${loss}m · ${elevationMinM ?? '?'}–${elevationMaxM ?? '?'}m · ${durationMin ?? '?'} min`); + + const geoT0 = Date.now(); + const geo = await reverseGeocode(centroid[0], centroid[1], cache); + console.log(`[build-hikes:${slug}] geocode: ${geo.municipality ?? '–'}, ${geo.canton ?? '–'} (${Date.now() - geoT0}ms)`); + + // Process images + const imageFiles: string[] = []; + try { + const entries = await fs.readdir(imagesDir); + for (const e of entries.sort()) { + if (/\.(jpe?g|png|webp|heic|heif)$/i.test(e)) imageFiles.push(path.join(imagesDir, e)); + } + } catch { + // no images dir is fine + } + // Images whose content hash isn't in gpxImageRefs are dropped before + // encoding (see processImage). Count for the log line below. + if (imageFiles.length > 0) { + console.log( + `[build-hikes:${slug}] processing ${imageFiles.length} image(s) — ${Object.keys(gpxImageRefs).length} referenced in track.gpx (concurrency=${IMAGE_CONCURRENCY})…` + ); + } + + let cover: ImageVariant | null = null; + const imagePoints: ImagePoint[] = []; + // Filenames produced by this build, keyed by segment dir (`images` / + // `private`). Used to delete leftover encoded files from previous runs + // (images that have since been unreferenced or moved between visibilities). + const keepFiles: Record<'images' | 'private', Set> = { + images: new Set(), + private: new Set() + }; + + type ImageResult = { + variant: ImageVariant | null; + point: ImagePoint | null; + outNames: string[]; + visibility: 'public' | 'private'; + }; + + const results = await runWithConcurrency( + imageFiles, + IMAGE_CONCURRENCY, + async (imgPath, i) => { + const imgT0 = Date.now(); + // Hero alt only applies to the first image; later ones get a generic + // label (image basenames usually encode date/camera info that we don't + // want to leak into alt text or hover tooltips). + const alt = i === 0 && typeof fm.heroAlt === 'string' + ? fm.heroAlt + : `Bild ${i + 1}`; + const processed = await processImage(imgPath, slug, alt, gpxImageRefs); + if ('skipped' in processed) { + console.log( + `[build-hikes:${slug}] [${i + 1}/${imageFiles.length}] ${path.basename(imgPath)} · ${processed.hash} · skipped (not in track.gpx)` + ); + return { variant: null, point: null, outNames: [], visibility: 'public' as const }; + } + const point = extractImagePoint(processed, alt, gpxImageRefs[processed.hash]); + const cacheTag = processed.cached ? ' · cached' : ''; + console.log( + `[build-hikes:${slug}] [${i + 1}/${imageFiles.length}] ${path.basename(imgPath)} · ${processed.hash} · ${processed.visibility}${cacheTag} (${Date.now() - imgT0}ms)` + ); + return { + variant: processed.variant, + point, + outNames: processed.outNames, + visibility: processed.visibility + }; + } + ); + + for (const r of results) { + if (r.variant !== null) { + // Use the first PUBLIC image as the cover. Private images must not + // surface on the listing page (which is prerendered and served to + // anonymous viewers). + if (cover === null && r.visibility === 'public') cover = r.variant; + const segment = r.visibility === 'private' ? 'private' : 'images'; + for (const name of r.outNames) keepFiles[segment].add(name); + } + if (r.point) imagePoints.push(r.point); + } + + // Per-route icon — handled here (before cleanup) so its outName joins + // `keepFiles.images` and survives the orphan sweep, while previous-build + // `icon..*` files (different hash, not in keepFiles) get removed. + const iconResult = await processIcon(slug, hikeDir); + if (iconResult) keepFiles.images.add(iconResult.outName); + + // Cleanup pass: drop any encoded files in either segment dir that don't + // belong to a current image. Catches both stale hashes (deleted source + // images) and visibility flips (a hash that's now public still has its + // old `private/` encodes lying around, and vice versa). + for (const segment of ['images', 'private'] as const) { + const dir = path.join(HIKES_ASSETS_DIR, slug, segment); + try { + const existing = await fs.readdir(dir); + const keep = keepFiles[segment]; + const orphans = existing.filter((f) => !keep.has(f)); + if (orphans.length > 0) { + await Promise.all( + orphans.map((f) => fs.unlink(path.join(dir, f)).catch(() => {})) + ); + console.log( + `[build-hikes:${slug}] removed ${orphans.length} orphaned ${segment}/ file(s) from prior builds` + ); + } + } catch { + // Dir may not exist when a hike has no images of this visibility — nothing to clean. + } + } + + if (!cover) { + // Synthetic 1x1 placeholder so the manifest type stays satisfied even + // when a hike directory has no images yet. + cover = { + src: '', + srcsetAvif: '', + srcsetWebp: '', + width: 0, + height: 0, + alt: '' + }; + } + + // Per-hike full track JSON in compact tuple format + const tuples = track.map(p => [ + Number(p.lng.toFixed(6)), + Number(p.lat.toFixed(6)), + typeof p.altitude === 'number' ? Number(p.altitude.toFixed(1)) : null, + p.timestamp + ]); + const trackJson = JSON.stringify(tuples); + const trackHash = crypto.createHash('sha256').update(trackJson).digest('hex').slice(0, 8); + const trackFile = path.join(STATIC_DIR, slug, `track.${trackHash}.json`); + await fs.mkdir(path.dirname(trackFile), { recursive: true }); + await fs.writeFile(trackFile, trackJson); + console.log(`[build-hikes:${slug}] wrote track.${trackHash}.json (${trackJson.length} bytes)`); + + const difficulty = (typeof fm.difficulty === 'string' && VALID_DIFFICULTIES.includes(fm.difficulty as Difficulty)) + ? (fm.difficulty as Difficulty) + : 'T1'; + + const date = typeof fm.date === 'string' + ? fm.date + : (typeof fm.date === 'number' ? new Date(fm.date).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10)); + + const tags = Array.isArray(fm.tags) ? fm.tags : []; + + const iconUrl = iconResult?.url; + + const entry: HikeManifestEntry = { + slug, + title: typeof fm.title === 'string' ? fm.title : slug, + date, + summary: typeof fm.summary === 'string' ? fm.summary : '', + author: typeof fm.author === 'string' ? fm.author : undefined, + tags, + difficulty, + hidden: fm.hidden === true, + ...parseSeasonRange(fm.seasons), + distanceKm: Math.round(distanceKm * 100) / 100, + durationMin, + elevationGainM: gain, + elevationLossM: loss, + elevationMaxM, + elevationMinM, + bbox, + centroid, + previewPolyline, + region: geo.region, + canton: geo.canton, + municipality: geo.municipality, + trackUrl: `/hikes/${slug}/track.${trackHash}.json`, + pointCount: track.length, + cover, + icon: iconUrl, + imagePoints + }; + + console.log(`[build-hikes:${slug}] done in ${((Date.now() - hikeStart) / 1000).toFixed(1)}s`); + return entry; +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +async function main() { + let slugs: string[] = []; + try { + const entries = await fs.readdir(CONTENT_DIR, { withFileTypes: true }); + slugs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); + } catch { + console.warn(`[build-hikes] No content dir at ${CONTENT_DIR}; emitting empty manifest.`); + } + + const cache = await loadGeocodeCache(); + const hikes: HikeManifestEntry[] = []; + + for (const slug of slugs) { + console.log(`[build-hikes] Building ${slug}`); + const entry = await buildHike(slug, cache); + if (entry) hikes.push(entry); + } + + await saveGeocodeCache(cache); + + hikes.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); + + await fs.mkdir(path.dirname(MANIFEST_OUT), { recursive: true }); + const banner = + '// AUTO-GENERATED by scripts/build-hikes.ts — do not edit by hand.\n' + + "import type { HikeManifestEntry } from '$types/hikes';\n\n"; + const body = `export const HIKES: HikeManifestEntry[] = ${JSON.stringify(hikes, null, 2)} as const;\n`; + const manifestSrc = banner + body; + await fs.writeFile(MANIFEST_OUT, manifestSrc); + + const bytes = Buffer.byteLength(manifestSrc, 'utf-8'); + if (bytes > MANIFEST_WARN_BYTES) { + console.warn(`[build-hikes] Manifest ${bytes} bytes exceeds soft cap ${MANIFEST_WARN_BYTES} — consider trimming previewPolyline size.`); + } + + console.log(`[build-hikes] Wrote ${hikes.length} hikes to ${MANIFEST_OUT} (${bytes} bytes)`); +} + +main().catch(err => { + console.error('[build-hikes] Fatal:', err); + process.exit(1); +}); diff --git a/scripts/deploy.sh b/scripts/deploy.sh index c263ed7c..15cc9756 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -17,6 +17,12 @@ REMOTE_USER_GROUP="${REMOTE_USER_GROUP:-homepage:homepage}" SERVICE="${SERVICE:-homepage.service}" ERROR_PAGES_DIR="${ERROR_PAGES_DIR:-/var/www/errors}" ERROR_PAGES_OWNER="${ERROR_PAGES_OWNER:-http:http}" +# Hike images live outside the Node app: nginx serves /hikes//images/ +# directly from disk and gates /hikes//private/ through Node via +# X-Accel-Redirect. The build pipeline writes them to ./hikes-assets/ and we +# rsync that tree to the path nginx serves from. +HIKES_ASSETS_DIR="${HIKES_ASSETS_DIR:-/var/www/static/hikes}" +HIKES_ASSETS_OWNER="${HIKES_ASSETS_OWNER:-http:http}" DRY="" if [[ "${1:-}" == "--dry-run" ]]; then @@ -74,13 +80,22 @@ ssh "$REMOTE" "mkdir -p $ERROR_PAGES_DIR" rsync -az --delete $DRY --info=progress2 \ build/client/errors/ "$REMOTE:$ERROR_PAGES_DIR/" +if [[ -d hikes-assets ]]; then + echo ":: Syncing hikes-assets/ → $REMOTE:$HIKES_ASSETS_DIR/" + ssh "$REMOTE" "mkdir -p $HIKES_ASSETS_DIR" + rsync -az --delete $DRY --info=progress2 \ + hikes-assets/ "$REMOTE:$HIKES_ASSETS_DIR/" +else + echo ":: No hikes-assets/ dir — skipping nginx-served hike images sync" +fi + if [[ -n "$DRY" ]]; then echo ":: Dry run complete — no service restart" exit 0 fi echo ":: Fixing ownership on server" -ssh "$REMOTE" "chown -R $REMOTE_USER_GROUP $REMOTE_DIR/dist $REMOTE_DIR/node_modules $REMOTE_DIR/static $REMOTE_DIR/package.json $REMOTE_DIR/pnpm-lock.yaml && chown -R $ERROR_PAGES_OWNER $ERROR_PAGES_DIR" +ssh "$REMOTE" "chown -R $REMOTE_USER_GROUP $REMOTE_DIR/dist $REMOTE_DIR/node_modules $REMOTE_DIR/static $REMOTE_DIR/package.json $REMOTE_DIR/pnpm-lock.yaml && chown -R $ERROR_PAGES_OWNER $ERROR_PAGES_DIR && if [[ -d $HIKES_ASSETS_DIR ]]; then chown -R $HIKES_ASSETS_OWNER $HIKES_ASSETS_DIR; fi" echo ":: Restarting $SERVICE" ssh "$REMOTE" "systemctl restart $SERVICE" diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 426d9b9e..ea3b05c3 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -25,6 +25,29 @@ async function htmlLang({ event, resolve }: Parameters[0]) { }); } +/** Apply headers to a response, transparently cloning it if the original + * has immutable headers. Auth.js (and certain fetch error/redirect responses) + * hand back frozen Headers, and a direct `.set()` on those throws + * `TypeError: immutable` — which would mask the underlying error and 500 + * the request. Cloning preserves the body stream and status. */ +function applyHeaders(response: Response, entries: Array<[string, string]>): Response { + try { + for (const [k, v] of entries) response.headers.set(k, v); + return response; + } catch (err) { + if (err instanceof TypeError) { + const headers = new Headers(response.headers); + for (const [k, v] of entries) headers.set(k, v); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers + }); + } + throw err; + } +} + /** Routes that must never appear in search-engine indexes. Search-results pages * are thin/duplicate content; admin/edit/auth-walled pages have no public value * and shouldn't burn crawl budget. Sets X-Robots-Tag rather than per-page meta @@ -46,11 +69,34 @@ const NOINDEX_PATTERNS: RegExp[] = [ async function noindex({ event, resolve }: Parameters[0]) { const response = await resolve(event); if (NOINDEX_PATTERNS.some((p) => p.test(event.url.pathname))) { - response.headers.set('X-Robots-Tag', 'noindex, nofollow'); + return applyHeaders(response, [['X-Robots-Tag', 'noindex, nofollow']]); } return response; } +/** Baseline security headers, set on every response. + * + * - X-Frame-Options + CSP frame-ancestors block this site from being + * iframed onto attacker pages (clickjacking on /login, /cospend, + * /fitness, etc.). Both directives are sent: modern browsers honour + * frame-ancestors and ignore the legacy header; older ones (IE11) only + * understand X-Frame-Options. + * - Strict-Transport-Security tells browsers to refuse plain-HTTP for + * bocken.org and any subdomain for one year, preventing protocol + * downgrade. Browsers ignore the header on http:// loads, so dev on + * localhost is unaffected. `preload` deliberately omitted — the HSTS + * preload list is hard to leave; revisit only after a stable production + * deployment. + */ +async function securityHeaders({ event, resolve }: Parameters[0]) { + const response = await resolve(event); + return applyHeaders(response, [ + ['X-Frame-Options', 'DENY'], + ['Content-Security-Policy', "frame-ancestors 'none'"], + ['Strict-Transport-Security', 'max-age=31536000; includeSubDomains'] + ]); +} + async function timing({ event, resolve }: Parameters[0]) { const marks: Record = {}; event.locals.timing = { @@ -72,8 +118,7 @@ async function timing({ event, resolve }: Parameters[0]) { const header = Object.entries(marks) .map(([k, v]) => `${k};dur=${v.toFixed(1)}`) .join(', '); - response.headers.set('Server-Timing', header); - return response; + return applyHeaders(response, [['Server-Timing', header]]); } export const init: ServerInit = async () => { @@ -172,8 +217,14 @@ async function authorization({ event, resolve }: Parameters[0]) { return resolve(event); } +/** Browser/crawler probes for these paths are routine 404s — not bugs. + * Skip the noisy console.error so real errors stay visible. */ +const SILENT_404_PATHS = new Set(['/favicon.ico', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png']); + export const handleError: HandleServerError = async ({ error, event, status, message }) => { - console.error('Error occurred:', { error, status, message, url: event.url.pathname }); + if (!(status === 404 && SILENT_404_PATHS.has(event.url.pathname))) { + console.error('Error occurred:', { error, status, message, url: event.url.pathname }); + } const bibleQuote = await getRandomVerse(event.fetch, event.url.pathname); const isEnglish = event.url.pathname.startsWith('/faith/') || event.url.pathname.startsWith('/recipes/'); @@ -189,6 +240,7 @@ export const handle: Handle = sequence( timing, htmlLang, noindex, + securityHeaders, auth.handle, authorization ); diff --git a/src/lib/components/DateTimePicker.svelte b/src/lib/components/DateTimePicker.svelte new file mode 100644 index 00000000..70050af3 --- /dev/null +++ b/src/lib/components/DateTimePicker.svelte @@ -0,0 +1,656 @@ + + +
+
+ + + + {#if mode === 'datetime'} + {#if showNudge && negativeNudges.length > 0} +
+ {#each negativeNudges as delta (delta)} + + {/each} +
+ {/if} + + {#if showNudge && positiveNudges.length > 0} +
+ {#each positiveNudges as delta (delta)} + + {/each} +
+ {/if} + {/if} +
+ + {#if open} + + {/if} + + {#if inheritedActive} + + {/if} + + {#if showClear} + + {/if} +
+ + diff --git a/src/lib/components/fitness/WorkoutFocusCard.svelte b/src/lib/components/fitness/WorkoutFocusCard.svelte index c955c3dd..5c613f69 100644 --- a/src/lib/components/fitness/WorkoutFocusCard.svelte +++ b/src/lib/components/fitness/WorkoutFocusCard.svelte @@ -79,6 +79,7 @@ background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-card); + view-transition-name: workout-focus-card; } /* Eyebrow row: step counter + bodypart + equipment, controls on the right */ @@ -120,7 +121,6 @@ line-height: 1.15; color: var(--color-text-primary); min-width: 0; - view-transition-name: workout-focus-name; } .focus-details { flex-shrink: 0; @@ -147,7 +147,6 @@ justify-content: space-between; gap: 1rem; flex-wrap: wrap; - view-transition-name: workout-focus-progress; } .focus-set-label { font-size: 0.78rem; diff --git a/src/lib/components/hikes/ElevationProfile.svelte b/src/lib/components/hikes/ElevationProfile.svelte new file mode 100644 index 00000000..da8a3807 --- /dev/null +++ b/src/lib/components/hikes/ElevationProfile.svelte @@ -0,0 +1,250 @@ + + +
+ +
+ + diff --git a/src/lib/components/hikes/HikeCard.svelte b/src/lib/components/hikes/HikeCard.svelte new file mode 100644 index 00000000..76d9fe9b --- /dev/null +++ b/src/lib/components/hikes/HikeCard.svelte @@ -0,0 +1,326 @@ + + +
+
+ {#if hike.cover.src} + + + + {hike.cover.alt} + + {:else} +
+ {/if} + + {#if hike.icon} + + {/if} + + + {hike.difficulty} + + + {#if isRecent} + Neu + {/if} +
+ +
+
+

{hike.title}

+ {#if hike.region} +

{hike.region}{hike.canton && hike.canton !== hike.region ? `, ${hike.canton}` : ''}

+ {/if} +
+ +
+ + + + +
+ + {#if (hike.elevationMinM !== null && hike.elevationMaxM !== null) || seasonLabel} +
+ {#if hike.elevationMinM !== null && hike.elevationMaxM !== null} + + + {/if} + {#if seasonLabel} + + + {/if} +
+ {/if} +
+
+ + diff --git a/src/lib/components/hikes/HikeImage.svelte b/src/lib/components/hikes/HikeImage.svelte new file mode 100644 index 00000000..88116aa1 --- /dev/null +++ b/src/lib/components/hikes/HikeImage.svelte @@ -0,0 +1,137 @@ + + +{#if ip && visible} +
+ {ip.alt} + {#if ip.visibility === 'private'} + + + {/if} + {#if caption} +
{caption}
+ {/if} +
+{/if} + + diff --git a/src/lib/components/hikes/HikeMap.svelte b/src/lib/components/hikes/HikeMap.svelte new file mode 100644 index 00000000..f4a27cc3 --- /dev/null +++ b/src/lib/components/hikes/HikeMap.svelte @@ -0,0 +1,810 @@ + + +
+
+ +
+
+ + {#if layerMenuOpen} + + {/if} +
+ + {#if recenterMap} + + {/if} + + {#if imagePoints.length > 0} + + {/if} + + +
+ + {#if locationError} +

{locationError}

+ {/if} +
+ + diff --git a/src/lib/components/hikes/HikeMdxLayout.svelte b/src/lib/components/hikes/HikeMdxLayout.svelte new file mode 100644 index 00000000..772faa55 --- /dev/null +++ b/src/lib/components/hikes/HikeMdxLayout.svelte @@ -0,0 +1,116 @@ + + +
+ {@render children?.()} +
+ + diff --git a/src/lib/components/hikes/HikePhotoStrip.svelte b/src/lib/components/hikes/HikePhotoStrip.svelte new file mode 100644 index 00000000..290da468 --- /dev/null +++ b/src/lib/components/hikes/HikePhotoStrip.svelte @@ -0,0 +1,396 @@ + + +{#if images.length > 0} +
+
+

Bildstrecke

+ + +
+ +
+ + +
+ {#each images as ip, i (ip.src)} + {@const elapsed = + ip.timestamp != null && startTimestamp != null + ? formatElapsed(ip.timestamp - startTimestamp) + : null} + {@const active = focused.index === i} + + {/each} +
+ + +
+
+{/if} + + diff --git a/src/lib/components/hikes/HikesFilterBar.svelte b/src/lib/components/hikes/HikesFilterBar.svelte new file mode 100644 index 00000000..0aebba3c --- /dev/null +++ b/src/lib/components/hikes/HikesFilterBar.svelte @@ -0,0 +1,257 @@ + + + + + diff --git a/src/lib/components/hikes/HikesOverviewMap.svelte b/src/lib/components/hikes/HikesOverviewMap.svelte new file mode 100644 index 00000000..b06c8836 --- /dev/null +++ b/src/lib/components/hikes/HikesOverviewMap.svelte @@ -0,0 +1,513 @@ + + + + + + + +
+
+ +
+
+ + {#if layerMenuOpen} + + {/if} +
+ + {#if recenterMap} + + {/if} + + +
+ + {#if locationError} +

{locationError}

+ {/if} +
+ + diff --git a/src/lib/components/hikes/UserLocation.svelte b/src/lib/components/hikes/UserLocation.svelte new file mode 100644 index 00000000..19f0af9f --- /dev/null +++ b/src/lib/components/hikes/UserLocation.svelte @@ -0,0 +1,74 @@ + + +
+ +

+ Dein Standort wird auf deinem Gerät berechnet und nicht an Dritte gesendet. +

+ {#if permissionError} +

{permissionError}

+ {/if} +
+ + diff --git a/src/lib/components/hikes/focusedImageStore.svelte.ts b/src/lib/components/hikes/focusedImageStore.svelte.ts new file mode 100644 index 00000000..60253a73 --- /dev/null +++ b/src/lib/components/hikes/focusedImageStore.svelte.ts @@ -0,0 +1,42 @@ +/** + * Shared focus state for a hike detail page's photo strip + map. + * + * Writing to `focused.index` from the strip (source='strip') makes the map fly + * to that photo and pulse a focus ring; writing from the map (source='map') + * makes the strip scroll the matching card into view and highlight it. Each + * side ignores its own writes via the `source` field so the two never feed + * back into each other. + * + * Indexes are positions in the visibility-filtered `ImagePoint[]` that both + * components share — the page filters once and hands the same array down. + */ + +/** + * Sources of focus-store writes: + * - `'strip'`: the user clicked a thumbnail or used a chevron / arrow key. + * Full sync: map flies to the marker, strip centres the card. + * - `'map'`: the user clicked a map marker. Strip scrolls + highlights, + * but the map doesn't fly to itself. + * - `'map-hover'`: the user is hovering a map marker. Strip skips scroll + * (would jerk across dense clusters), and the map skips its own flyTo + + * focus ring (the user is already looking at it). + * - `'inline'`: an inline `` scrolled into the viewport's middle + * band. Full sync: map flies to the marker, strip centres the card. This + * is the desktop scrollytelling driver. + */ +export type FocusSource = 'map' | 'map-hover' | 'strip' | 'inline' | null; + +export const focused = $state<{ index: number | null; source: FocusSource }>({ + index: null, + source: null +}); + +export function setFocused(index: number | null, source: FocusSource): void { + focused.index = index; + focused.source = source; +} + +export function clearFocused(): void { + focused.index = null; + focused.source = null; +} diff --git a/src/lib/components/hikes/hikeContext.svelte.ts b/src/lib/components/hikes/hikeContext.svelte.ts new file mode 100644 index 00000000..d7cb91b4 --- /dev/null +++ b/src/lib/components/hikes/hikeContext.svelte.ts @@ -0,0 +1,43 @@ +/** + * Provides the hike detail page's ImagePoints arrays to descendants — + * specifically, to inline `` components used inside `.svx` + * content. The page sets the context; HikeImage reads it. + * + * Two arrays are exposed because they serve different needs: + * + * - `images` is the full chronological list (including private images). + * `` indexes into this list, so the author's + * indices stay stable regardless of the viewer's login state. + * + * - `visibleImages` is the same list with private entries filtered out + * for the current viewer. The strip, map, and stage all operate against + * it, and the focus store's `index` field is a position in this array. + * `HikeImage` translates its own idx → position-in-visibleImages so the + * focus sync works. + */ + +import { getContext, setContext } from 'svelte'; +import type { HikeTrackPoint, ImagePoint } from '$types/hikes'; + +const KEY = Symbol('hike-context'); + +interface HikeContext { + readonly images: ImagePoint[]; + readonly visibleImages: ImagePoint[]; + /** GPX track points — null until the JSON fetch resolves. Used by + * inline `` to compute the nearest-track-index for the + * scroll-progress pin on the map. */ + readonly track: HikeTrackPoint[] | null; +} + +export function setHikeContext(ctx: () => HikeContext): void { + setContext(KEY, ctx); +} + +export function getHikeContext(): () => HikeContext { + const ctx = getContext<() => HikeContext>(KEY); + if (!ctx) { + throw new Error('HikeImage used outside a hike detail page (no context found).'); + } + return ctx; +} diff --git a/src/lib/components/hikes/hoverStore.svelte.ts b/src/lib/components/hikes/hoverStore.svelte.ts new file mode 100644 index 00000000..00c6a9b6 --- /dev/null +++ b/src/lib/components/hikes/hoverStore.svelte.ts @@ -0,0 +1,28 @@ +/** + * Shared cursor state for a hike detail page. + * + * The map and the elevation chart each push into `hover.index` when the + * pointer moves over them; both observe the rune via `$effect` to draw the + * corresponding marker on their own side. A single shared rune avoids the + * map↔chart hover-loop bookkeeping that prop wiring would require. + * + * `source` records which side wrote the last update so the receiver can skip + * redrawing on its own write and prevent feedback loops. + */ + +export type HoverSource = 'map' | 'chart' | 'image' | 'scroll' | null; + +export const hover = $state<{ index: number | null; source: HoverSource }>({ + index: null, + source: null +}); + +export function setHover(index: number | null, source: HoverSource): void { + hover.index = index; + hover.source = source; +} + +export function clearHover(): void { + hover.index = null; + hover.source = null; +} diff --git a/src/lib/components/hikes/route-builder/EditMap.svelte b/src/lib/components/hikes/route-builder/EditMap.svelte new file mode 100644 index 00000000..ca5ce678 --- /dev/null +++ b/src/lib/components/hikes/route-builder/EditMap.svelte @@ -0,0 +1,338 @@ + + +
+
+ {#if pendingWaypoint} +
+ Klicke auf die Karte, um das Bild zu platzieren. + +
+ {/if} +
+ + diff --git a/src/lib/components/hikes/route-builder/ImageDropzone.svelte b/src/lib/components/hikes/route-builder/ImageDropzone.svelte new file mode 100644 index 00000000..ba470015 --- /dev/null +++ b/src/lib/components/hikes/route-builder/ImageDropzone.svelte @@ -0,0 +1,301 @@ + + +
{ + e.preventDefault(); + isDragging = true; + }} + ondragover={(e) => { + e.preventDefault(); + }} + ondragleave={() => { + isDragging = false; + }} + ondrop={onDrop} +> +
+

Bilder

+

+ Bilder mit GPS-EXIF werden chronologisch platziert. Bilder ohne GPS + erscheinen in der Wegpunkt-Liste und können dort auf der Karte platziert + werden. Die Bilder verlassen dein Gerät nicht. +

+
+ + + + {#if entries.length > 0} +
    + {#each entries as e (e.id)} +
  • + + {e.name} + + {#if e.status === 'pending'}wird gelesen… + {:else if e.status === 'placed'}✓ chronologisch platziert + {:else if e.status === 'unplaced'}⚠ Position fehlt — in Liste platzieren + {:else if e.status === 'error'}Fehler: {e.message ?? 'unbekannt'} + {/if} + + +
  • + {/each} +
+ {/if} +
+ + diff --git a/src/lib/components/hikes/route-builder/WaypointTable.svelte b/src/lib/components/hikes/route-builder/WaypointTable.svelte new file mode 100644 index 00000000..e61e8376 --- /dev/null +++ b/src/lib/components/hikes/route-builder/WaypointTable.svelte @@ -0,0 +1,633 @@ + + +
+
+

Wegpunkte ({builder.waypoints.length})

+
+ + {#if builder.waypoints.length === 0} +

Klicke auf die Karte oder lade Bilder, um Wegpunkte zu setzen.

+ {:else} +

* Erster und letzter platzierter Wegpunkt brauchen einen Zeitstempel.

+
    + {#each builder.waypoints as wp, idx (wp.id)} +
  1. + {#if wp.thumbnail || getFullImageUrl(wp.id)} +
    + + {#if wp.unplaced} + 📍 noch nicht platziert + {/if} +
    + {/if} + +
    + + {wp.unplaced ? '?' : idx + 1} + + + {#if wp.unplaced} + Bild ohne Position + {:else if wp.imageHash} + Bild {idx + 1} + {:else} + Wegpunkt {idx + 1} + {/if} + +
    + + + +
    +
    + + {#if wp.unplaced} +
    + {#if wp.id === pendingPlacementId} + Klicke auf die Karte… + + {:else} + + {/if} +
    + {:else} +
    + updateLat(idx, e.currentTarget.value)} + aria-label="Breitengrad" + /> + updateLng(idx, e.currentTarget.value)} + aria-label="Längengrad" + /> +
    + {/if} + + {#if !wp.unplaced} + {@const requiresTime = idx === firstPlacedIdx || idx === lastPlacedIdx} + {@const isImage = !!wp.imageHash} + {@const hasTimestamp = wp.timestamp != null} + {@const inheritedTs = !hasTimestamp ? nearestTimestamp(idx) ?? null : null} + {@const showTime = isImage || requiresTime || hasTimestamp} +
    + + {showTime ? 'Zeit' : 'Datum'}{requiresTime ? ' *' : ''} + + +
    + {/if} + + {#if wp.imageHash} +
    + Sichtbarkeit +
    + + +
    +
    + {:else if !wp.unplaced} + + {/if} +
  2. + {/each} +
+ {/if} +
+ + diff --git a/src/lib/components/hikes/route-builder/builderStore.svelte.ts b/src/lib/components/hikes/route-builder/builderStore.svelte.ts new file mode 100644 index 00000000..1f94575e --- /dev/null +++ b/src/lib/components/hikes/route-builder/builderStore.svelte.ts @@ -0,0 +1,246 @@ +/** + * State for the route-builder editor. + * + * The whole store is a single $state object so any field — waypoints, + * routed segments, profile — automatically reactivates dependent UI + * (table rows, map markers, polyline). Draft state is mirrored to + * `localStorage` so accidental tab close doesn't lose work. + */ + +import { browser } from '$app/environment'; + +export type RoutingProfile = 'hiking-mountain' | 'trekking' | 'road'; + +export type ImageVisibility = 'public' | 'private'; + +export type Waypoint = { + id: string; + lat: number; + lng: number; + altitude?: number; + timestamp?: number | null; + thumbnail?: string; // optional base64 preview for marker badge + table row + /** First 8 hex chars of the source image's sha256 content hash. Matches + * the same scheme used by the build script's output filenames so the + * build can re-attach the image to this user-corrected position. */ + imageHash?: string; + /** Whether the image should be visible to anonymous viewers. Both values + * embed the image in the GPX export — private images are simply hidden + * from the public map unless the viewer is logged in. Defaults to + * `'public'`; only meaningful when `imageHash` is set. */ + imageVisibility?: ImageVisibility; + /** When true, this waypoint represents an image with a known timestamp + * but unknown location — the user still needs to drop it on the map. + * Lat/lng are placeholders (0/0) and the waypoint is hidden from the map + * and excluded from GPX export until placed. */ + unplaced?: boolean; +}; + +export type BuilderState = { + name: string; + profile: RoutingProfile; + /** When true, newly created segments are snapped to the trail network via + * the routing API. When false, new segments use a direct straight line. + * Existing (already-snapped) segments are preserved across toggle. */ + autoSnap: boolean; + waypoints: Waypoint[]; + /** One coordinate run per consecutive-waypoint pair (snapped or linear). */ + routedSegments: Array>; // [lng, lat, ele?] + /** Parallel record of which waypoint pair each `routedSegments[i]` was + * built for — by id AND by coordinate. Both must match for the segment to + * be considered still valid, so a drag (same id, new coords) correctly + * invalidates the adjacent segments. */ + segmentSources: Array; +}; + +export type SegmentSource = { + startId: string; + endId: string; + startLat: number; + startLng: number; + endLat: number; + endLng: number; +}; + +const STORAGE_KEY = 'hikes:route-builder:draft'; + +function loadDraft(): BuilderState { + if (!browser) return defaultState(); + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return defaultState(); + const parsed = JSON.parse(raw) as BuilderState; + if (!parsed || !Array.isArray(parsed.waypoints)) return defaultState(); + // Migrate older drafts that used `showImageOnMap` (boolean) instead of + // the new `imageVisibility` enum: false → private, anything else → public. + const waypoints = parsed.waypoints.map((w) => { + const legacy = w as Waypoint & { showImageOnMap?: boolean }; + if (legacy.imageVisibility === undefined && legacy.showImageOnMap === false) { + return { ...legacy, imageVisibility: 'private' as const, showImageOnMap: undefined }; + } + return legacy; + }); + return { + name: parsed.name ?? '', + profile: parsed.profile ?? 'hiking-mountain', + autoSnap: parsed.autoSnap !== false, + waypoints, + routedSegments: Array.isArray(parsed.routedSegments) ? parsed.routedSegments : [], + segmentSources: Array.isArray(parsed.segmentSources) ? parsed.segmentSources : [] + }; + } catch { + return defaultState(); + } +} + +function defaultState(): BuilderState { + return { + name: '', + profile: 'hiking-mountain', + autoSnap: true, + waypoints: [], + routedSegments: [], + segmentSources: [] + }; +} + +export const builder = $state(loadDraft()); + +let saveTimer: ReturnType | null = null; +export function scheduleSave(): void { + if (!browser) return; + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(builder)); + } catch { + /* localStorage may be unavailable in private mode */ + } + }, 300); +} + +export function clearDraft(): void { + builder.name = ''; + builder.profile = 'hiking-mountain'; + builder.autoSnap = true; + builder.waypoints.splice(0, builder.waypoints.length); + builder.routedSegments.splice(0, builder.routedSegments.length); + builder.segmentSources.splice(0, builder.segmentSources.length); + if (browser) { + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { /* ignored */ } + } +} + +export function nextWaypointId(): string { + return Math.random().toString(36).slice(2, 10); +} + +/** + * Insert `wp` into `builder.waypoints` so that timestamped waypoints stay in + * chronological order. Waypoints without a timestamp (map-click clicks, + * draft scribbles) act as transparent neighbours — they don't affect sorting. + * Without a timestamp on the new waypoint, falls back to a plain append. + */ +export function insertWaypointChronologically(wp: Waypoint): void { + if (typeof wp.timestamp !== 'number') { + builder.waypoints.push(wp); + scheduleSave(); + return; + } + const t = wp.timestamp; + let insertIdx = builder.waypoints.length; + for (let i = 0; i < builder.waypoints.length; i++) { + const other = builder.waypoints[i].timestamp; + if (typeof other === 'number' && other > t) { + insertIdx = i; + break; + } + } + builder.waypoints.splice(insertIdx, 0, wp); + scheduleSave(); +} + +function makeSource(a: Waypoint, b: Waypoint): SegmentSource { + return { + startId: a.id, + endId: b.id, + startLat: a.lat, + startLng: a.lng, + endLat: b.lat, + endLng: b.lng + }; +} + +function sourcesMatch(s: SegmentSource, a: Waypoint, b: Waypoint): boolean { + return ( + s.startId === a.id && + s.endId === b.id && + s.startLat === a.lat && + s.startLng === a.lng && + s.endLat === b.lat && + s.endLng === b.lng + ); +} + +export function setRoutedSegments(segments: Array>): void { + builder.routedSegments.splice(0, builder.routedSegments.length, ...segments); + const sources: SegmentSource[] = []; + for (let i = 0; i < builder.waypoints.length - 1 && i < segments.length; i++) { + sources.push(makeSource(builder.waypoints[i], builder.waypoints[i + 1])); + } + builder.segmentSources.splice(0, builder.segmentSources.length, ...sources); +} + +/** + * Walk the current waypoint pairs and rebuild `routedSegments` so it aligns + * 1:1 with consecutive waypoint pairs. A segment is preserved verbatim only + * when both endpoints match (same id AND same lat/lng) — a waypoint drag + * keeps the id but changes coords, which is exactly when the snapped geometry + * goes stale. Stale pairs are replaced with a straight two-point linear + * placeholder; if autoSnap is on, the page's snapToRoute call will overwrite + * them shortly after. + */ +export function reconcileSegments(): void { + const newSegs: Array> = []; + const newSources: SegmentSource[] = []; + // Walk only placed waypoints — unplaced ones (image without location) sit + // in the table but don't participate in the track until the user drops + // them on the map. + const placed: Waypoint[] = []; + for (const w of builder.waypoints) { + if (!w.unplaced) placed.push(w); + } + for (let i = 0; i < placed.length - 1; i++) { + const a = placed[i]; + const b = placed[i + 1]; + const oldIdx = builder.segmentSources.findIndex((s) => sourcesMatch(s, a, b)); + if (oldIdx >= 0 && builder.routedSegments[oldIdx]) { + newSegs.push(builder.routedSegments[oldIdx]); + newSources.push(builder.segmentSources[oldIdx]); + } else { + newSegs.push([ + [a.lng, a.lat, a.altitude], + [b.lng, b.lat, b.altitude] + ]); + newSources.push(makeSource(a, b)); + } + } + builder.routedSegments.splice(0, builder.routedSegments.length, ...newSegs); + builder.segmentSources.splice(0, builder.segmentSources.length, ...newSources); +} + +export function setElevations(elevations: (number | null)[]): void { + // elevations are aligned with the flattened routedSegments points; fold them + // back into the per-segment arrays. + let idx = 0; + for (const seg of builder.routedSegments) { + for (let i = 0; i < seg.length; i++) { + const e = elevations[idx++]; + if (typeof e === 'number') { + seg[i] = [seg[i][0], seg[i][1], e]; + } + } + } +} diff --git a/src/lib/components/hikes/route-builder/elevationClient.ts b/src/lib/components/hikes/route-builder/elevationClient.ts new file mode 100644 index 00000000..29896735 --- /dev/null +++ b/src/lib/components/hikes/route-builder/elevationClient.ts @@ -0,0 +1,16 @@ +/** + * Single-point Swisstopo elevation lookup for the route-builder. + * Image GPS altitude (EXIF GPSAltitude) is unreliable and causes spikes in the + * elevation profile, so all waypoints — including image-derived ones — should + * source their altitude from the terrain model instead. + */ +export async function fetchElevationAt(lat: number, lng: number): Promise { + try { + const res = await fetch(`/api/hikes/route-builder/elevation?lat=${lat}&lng=${lng}`); + if (!res.ok) return null; + const { elevation } = (await res.json()) as { elevation: number | null }; + return typeof elevation === 'number' ? elevation : null; + } catch { + return null; + } +} diff --git a/src/lib/components/hikes/route-builder/fullImageCache.svelte.ts b/src/lib/components/hikes/route-builder/fullImageCache.svelte.ts new file mode 100644 index 00000000..ee6e4b0a --- /dev/null +++ b/src/lib/components/hikes/route-builder/fullImageCache.svelte.ts @@ -0,0 +1,33 @@ +/** + * In-memory cache of full-resolution image Blob URLs keyed by waypoint id. + * + * Storing the original File as a base64 data URL would blow past the + * localStorage quota almost immediately, so the persisted draft only carries + * a 360w WebP thumbnail. The full-resolution preview lives here only for the + * lifetime of the page — on reload, callers fall back to the thumbnail. + * + * Backed by a Svelte 5 `$state` proxy so the table re-renders the moment a + * fresh Blob URL is registered (e.g. after image upload or re-attach). + */ + +import { browser } from '$app/environment'; + +const urls = $state>({}); + +export function setFullImage(waypointId: string, file: Blob): void { + if (!browser) return; + const old = urls[waypointId]; + if (old) URL.revokeObjectURL(old); + urls[waypointId] = URL.createObjectURL(file); +} + +export function getFullImageUrl(waypointId: string): string | undefined { + return urls[waypointId]; +} + +export function dropFullImage(waypointId: string): void { + if (!browser) return; + const url = urls[waypointId]; + if (url) URL.revokeObjectURL(url); + delete urls[waypointId]; +} diff --git a/src/lib/components/hikes/route-builder/imageThumbnail.ts b/src/lib/components/hikes/route-builder/imageThumbnail.ts new file mode 100644 index 00000000..8ecf44e6 --- /dev/null +++ b/src/lib/components/hikes/route-builder/imageThumbnail.ts @@ -0,0 +1,35 @@ +/** + * Render a WebP data-URL preview of an image file using the browser's canvas. + * 360px wide — large enough to serve as a full-width preview in the waypoint + * table while still being a sane base64 payload (~30 KB) for localStorage. + * The marker badge on the map (56×56) downsamples it cleanly. + */ +const PREVIEW_WIDTH = 360; + +export async function readThumbnail(file: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const img = new Image(); + img.onload = () => { + const w = Math.min(PREVIEW_WIDTH, img.width || PREVIEW_WIDTH); + const ratio = img.width / img.height || 1; + const h = Math.round(w / ratio); + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (!ctx) { + reject(new Error('canvas 2d unavailable')); + return; + } + ctx.drawImage(img, 0, 0, w, h); + resolve(canvas.toDataURL('image/webp', 0.7)); + }; + img.onerror = () => reject(new Error('Konnte Bild nicht laden')); + img.src = reader.result as string; + }; + reader.onerror = () => reject(new Error('Konnte Datei nicht lesen')); + reader.readAsDataURL(file); + }); +} diff --git a/src/lib/components/hikes/scrollAnchors.ts b/src/lib/components/hikes/scrollAnchors.ts new file mode 100644 index 00000000..dc9fff3a --- /dev/null +++ b/src/lib/components/hikes/scrollAnchors.ts @@ -0,0 +1,37 @@ +/** + * Module-scoped registry of "scroll anchors" — DOM elements rendered by + * inline `` components whose viewport positions are sampled on + * every scroll frame to compute a continuous trail-position indicator. + * + * Each anchor carries: + * - `element` — the DOM node we read `getBoundingClientRect()` from. + * - `trackIdx` — the index in the GPX track points array nearest to the + * image's timestamp. The "current trail position" is interpolated between + * adjacent anchors' `trackIdx` based on scroll progress. + * - `visibleIdx` — index in the visibility-filtered ImagePoints. Used to + * drive the focused store (strip highlighting) when the nearest-image + * changes. + * + * The registry is a singleton because there's only ever one hike detail + * page open at a time, and a Svelte context would otherwise force every + * read site (the page's scroll listener) to be inside the component tree. + */ + +export interface ScrollAnchor { + element: HTMLElement; + trackIdx: number; + visibleIdx: number; +} + +const anchors = new Set(); + +export function addScrollAnchor(a: ScrollAnchor): () => void { + anchors.add(a); + return () => { + anchors.delete(a); + }; +} + +export function listScrollAnchors(): ScrollAnchor[] { + return Array.from(anchors); +} diff --git a/src/lib/gpx.ts b/src/lib/gpx.ts new file mode 100644 index 00000000..a0060a0c --- /dev/null +++ b/src/lib/gpx.ts @@ -0,0 +1,240 @@ +/** + * Pure GPX serializer usable from both server scripts and the browser. + * Kept dependency-free so the route-builder can bundle it for client-side + * GPX export without dragging in Node-only helpers. + */ + +export interface GpxWritePoint { + lat: number; + lng: number; + altitude?: number; + /** Unix milliseconds. Pass null/undefined to omit `