diff --git a/weather-voodoo/src/lib/components/MapView.svelte b/weather-voodoo/src/lib/components/MapView.svelte
index d83489e..b4f252b 100644
--- a/weather-voodoo/src/lib/components/MapView.svelte
+++ b/weather-voodoo/src/lib/components/MapView.svelte
@@ -150,10 +150,35 @@
drawn.push(marker);
}
+ // Camera fits don't require the style to be loaded — run them eagerly so
+ // shared links open already framed on the route. Prefer polyline bounds
+ // (a routed sea/trail polyline can detour well outside the markers' bbox)
+ // and fall back to marker bounds when no route line is available yet.
+ const fitTarget = polyline && polyline.length >= 2 ? polyline : markers;
+ if (fitTarget.length >= 2) {
+ const bounds = new maplibregl.LngLatBounds();
+ fitTarget.forEach((p) => bounds.extend([p.lon, p.lat]));
+ map.fitBounds(bounds, { padding: 60, maxZoom: 11, duration: 400 });
+ } else if (markers.length === 1) {
+ map.flyTo({ center: [markers[0].lon, markers[0].lat], zoom: 11, duration: 400 });
+ }
+
+ // The route source/layer needs the MapLibre style to be loaded first; on a
+ // cold page load (shared link) this often isn't ready yet, so defer until
+ // the 'load' event if necessary.
+ if (map.isStyleLoaded()) {
+ renderRouteLayer();
+ } else {
+ map.once('load', renderRouteLayer);
+ }
+ }
+
+ function renderRouteLayer() {
+ if (!map) return;
if (map.getLayer('route-line')) map.removeLayer('route-line');
if (map.getSource('route-src')) map.removeSource('route-src');
- if (polyline && polyline.length >= 2 && map.isStyleLoaded()) {
+ if (polyline && polyline.length >= 2) {
map.addSource('route-src', {
type: 'geojson',
data: {
@@ -172,14 +197,6 @@
paint: { 'line-color': polylineColor, 'line-width': 3, 'line-opacity': 0.85 }
});
}
-
- if (markers.length >= 2 && map.isStyleLoaded()) {
- const bounds = new maplibregl.LngLatBounds();
- markers.forEach((m) => bounds.extend([m.lon, m.lat]));
- map.fitBounds(bounds, { padding: 60, maxZoom: 11, duration: 400 });
- } else if (markers.length === 1) {
- map.flyTo({ center: [markers[0].lon, markers[0].lat], zoom: 11, duration: 400 });
- }
}
diff --git a/weather-voodoo/src/lib/components/RouteView.svelte b/weather-voodoo/src/lib/components/RouteView.svelte
index 766d372..b1bba4a 100644
--- a/weather-voodoo/src/lib/components/RouteView.svelte
+++ b/weather-voodoo/src/lib/components/RouteView.svelte
@@ -16,7 +16,8 @@
type RouteMeta =
| { kind: 'ferry'; lengthKm: number; wayCount: number; originSnapKm: number; destinationSnapKm: number }
| { kind: 'sea'; lengthKm: number; greatCircleKm: number; detourRatio: number }
- | { kind: 'straight'; ferryFallback?: string; ferryDetail?: string };
+ | { kind: 'trail'; lengthKm: number; wayCount: number; hikeWayCount: number; bikeWayCount: number; originSnapKm: number; destinationSnapKm: number }
+ | { kind: 'straight'; ferryFallback?: string; ferryDetail?: string; trailFallback?: string; trailDetail?: string };
let result = $state<{
hours: FusedHour[];
timezone: string;
@@ -168,9 +169,14 @@
{t('route.seaPrefix')} {result.route.lengthKm.toFixed(0)} km
(×{result.route.detourRatio.toFixed(2)} {t('route.greatCircleSuffix')})
+ {:else if result?.route.kind === 'trail'}
+
+ {t('route.trailPrefix')} {result.route.lengthKm.toFixed(0)} km
+ {t('route.trailWaysSuffix', { hike: result.route.hikeWayCount, bike: result.route.bikeWayCount })}
+
{:else if view.from && view.to && result}
- {t('route.straight')}{#if result.route.kind === 'straight' && result.route.ferryFallback} ({t('route.ferryLabel')}: {result.route.ferryFallback}{#if result.route.ferryDetail} · {result.route.ferryDetail}{/if}){/if} {t('route.straightHint')}
+ {t('route.straight')}{#if result.route.kind === 'straight' && result.route.ferryFallback} ({t('route.ferryLabel')}: {result.route.ferryFallback}{#if result.route.ferryDetail} · {result.route.ferryDetail}{/if}){/if}{#if result.route.kind === 'straight' && result.route.trailFallback} ({t('route.trailLabel')}: {result.route.trailFallback}{#if result.route.trailDetail} · {result.route.trailDetail}{/if}){/if} {t('route.straightHint')}
{/if}
diff --git a/weather-voodoo/src/lib/components/WaypointsView.svelte b/weather-voodoo/src/lib/components/WaypointsView.svelte
index 5133e27..bca9d56 100644
--- a/weather-voodoo/src/lib/components/WaypointsView.svelte
+++ b/weather-voodoo/src/lib/components/WaypointsView.svelte
@@ -13,6 +13,7 @@
legCount: number;
ferryLegs: number;
seaLegs: number;
+ trailLegs: number;
straightLegs: number;
totalKm: number;
};
@@ -282,6 +283,7 @@
· {t(result.route.legCount === 1 ? 'waypoints.legsOne' : 'waypoints.legsMany', { n: result.route.legCount })}
{#if result.route.ferryLegs > 0} · ⛴️ {t('waypoints.ferryLegs', { n: result.route.ferryLegs })}{/if}
{#if result.route.seaLegs > 0} · ⚓ {t('waypoints.seaLegs', { n: result.route.seaLegs })}{/if}
+ {#if result.route.trailLegs > 0} · 🥾 {t('waypoints.trailLegs', { n: result.route.trailLegs })}{/if}
{#if result.route.straightLegs > 0} · 📐 {t('waypoints.straightLegs', { n: result.route.straightLegs })}{/if}
{:else if editing && draft.length >= 2}
diff --git a/weather-voodoo/src/lib/i18n/en.ts b/weather-voodoo/src/lib/i18n/en.ts
index 7b5eee0..ae98d6c 100644
--- a/weather-voodoo/src/lib/i18n/en.ts
+++ b/weather-voodoo/src/lib/i18n/en.ts
@@ -96,6 +96,7 @@ export type Dict = {
legsMany: string;
ferryLegs: string;
seaLegs: string;
+ trailLegs: string;
straightLegs: string;
straightPreview: string;
editAriaLabel: string;
@@ -114,6 +115,10 @@ export type Dict = {
seaPrefix: string;
detourTitle: string;
greatCircleSuffix: string;
+ trailPrefix: string;
+ trailWaysSuffix: string;
+ trailWaysTitle: string;
+ trailLabel: string;
straight: string;
straightHint: string;
ferryLabel: string;
@@ -344,7 +349,7 @@ export const en: Dict = {
title: 'How Weather Voodoo works',
lede: 'It picks the best hours of the next 3 days for a specific outdoor or marine trip — by blending wind, gust, rain, wave height and visibility into a single 0–100 score, then surfacing the best contiguous windows that fit your time and duration.',
twoTabs: 'The tabs',
- routeDesc: 'Route — pick a From and To. The forecast is fused across 3 sample points along the line, taking the worst-case conditions hour-by-hour. Good for ferry/boat trips, drives, kayak crossings.',
+ routeDesc: 'Route — pick a From and To. The forecast is fused across 3 sample points along the line, taking the worst-case conditions hour-by-hour. In Sea mode the route follows OSM ferry ways or the open-ocean network; in Land mode it follows OSM hiking and cycling trails.',
fixedDesc: 'Fixed location — one place. Good for a beach day, hike, sunset session.',
score: 'The score',
scoreDesc: "Each hour is scored 0–100 based on the activity type. The window score is the worst hour in the range (chain-as-strong-as-weakest-link); average score is the tiebreaker.",
@@ -401,6 +406,7 @@ export const en: Dict = {
legsMany: '{n} legs',
ferryLegs: '{n} ferry',
seaLegs: '{n} open-ocean',
+ trailLegs: '{n} trail',
straightLegs: '{n} straight',
straightPreview: '📐 Straight-line preview while editing — press Done to compute the real route.',
editAriaLabel: 'Edit waypoint',
@@ -412,15 +418,19 @@ export const en: Dict = {
},
route: {
computing: 'Computing route…',
- computingLong: 'Computing best sea route & fetching forecasts… first request in a region can take up to 15 s.',
+ computingLong: 'Computing best route & fetching forecasts… first request in a region can take up to 15 s.',
ferryPrefix: '⛴️ Ferry route via OpenStreetMap:',
waysSuffix: '({n} ways in the area)',
waysTitle: 'number of distinct ferry ways considered',
seaPrefix: '⚓ Open-ocean route:',
detourTitle: 'route length / great-circle length',
greatCircleSuffix: 'the great-circle line',
- straight: "📐 Straight-line route — couldn't snap to a sea-route network.",
- straightHint: 'Sample points may cross land.',
+ trailPrefix: '🥾 Trail route via OpenStreetMap:',
+ trailWaysSuffix: '({hike} hiking · {bike} cycling ways nearby)',
+ trailWaysTitle: 'paths, footways, tracks, bridleways and cycleways within the search area',
+ trailLabel: 'trail',
+ straight: "📐 Straight-line route — couldn't snap to a routing network.",
+ straightHint: 'Sample points may cross land or sea.',
ferryLabel: 'ferry'
},
days: {
diff --git a/weather-voodoo/src/lib/i18n/ro.ts b/weather-voodoo/src/lib/i18n/ro.ts
index d90e1f3..b89f999 100644
--- a/weather-voodoo/src/lib/i18n/ro.ts
+++ b/weather-voodoo/src/lib/i18n/ro.ts
@@ -48,7 +48,7 @@ export const ro: Dict = {
title: 'Cum funcționează Weather Voodoo',
lede: 'Aplicația alege cele mai bune ore din următoarele 3 zile pentru o anumită călătorie în natură sau pe mare — combinând vântul, rafala, ploaia, înălțimea valului și vizibilitatea într-un singur scor 0–100, apoi afișând cele mai bune intervale contigue care se potrivesc cu timpul și durata ta.',
twoTabs: 'Tab-urile',
- routeDesc: 'Rută — alege un De la și un Până la. Prognoza este fuzionată din 3 puncte de eșantionare de-a lungul traseului, luând condițiile cele mai severe oră de oră. Bun pentru feribot/barcă, condus, traversări cu caiacul.',
+ routeDesc: 'Rută — alege un De la și un Până la. Prognoza este fuzionată din 3 puncte de eșantionare de-a lungul traseului, luând condițiile cele mai severe oră de oră. În modul Marin ruta urmează căile de feribot OSM sau rețeaua oceanică; în modul Terestru urmează potecile de drumeție și pistele de bicicletă din OSM.',
fixedDesc: 'Locație fixă — un singur loc. Bun pentru zi la plajă, drumeție, sesiune de apus.',
score: 'Scorul',
scoreDesc: 'Fiecare oră primește scor 0–100 în funcție de tipul activității. Scorul intervalului este cea mai slabă oră din interval (lanțul e la fel de rezistent ca veriga cea mai slabă); scorul mediu este criteriu de departajare.',
@@ -105,6 +105,7 @@ export const ro: Dict = {
legsMany: '{n} segmente',
ferryLegs: '{n} feribot',
seaLegs: '{n} larg',
+ trailLegs: '{n} potecă',
straightLegs: '{n} drept',
straightPreview: '📐 Previzualizare în linie dreaptă în timpul editării — apasă Gata pentru a calcula ruta reală.',
editAriaLabel: 'Editează punctul de trecere',
@@ -116,15 +117,19 @@ export const ro: Dict = {
},
route: {
computing: 'Se calculează ruta…',
- computingLong: 'Se calculează cea mai bună rută marină și se descarcă prognoza… prima cerere într-o regiune poate dura până la 15 s.',
+ computingLong: 'Se calculează cea mai bună rută și se descarcă prognoza… prima cerere într-o regiune poate dura până la 15 s.',
ferryPrefix: '⛴️ Rută cu feribot via OpenStreetMap:',
waysSuffix: '({n} căi în zonă)',
waysTitle: 'număr de căi de feribot distincte luate în calcul',
seaPrefix: '⚓ Rută în larg:',
detourTitle: 'lungimea rutei / lungimea great-circle',
greatCircleSuffix: 'din linia great-circle',
- straight: '📐 Rută în linie dreaptă — nu am putut potrivi cu o rețea de rute marine.',
- straightHint: 'Punctele de eșantionare pot traversa uscatul.',
+ trailPrefix: '🥾 Rută pe poteci via OpenStreetMap:',
+ trailWaysSuffix: '({hike} poteci de drumeție · {bike} piste de bicicletă în zonă)',
+ trailWaysTitle: 'poteci, trotuare, drumuri forestiere, alei călări și piste de bicicletă din zona de căutare',
+ trailLabel: 'potecă',
+ straight: '📐 Rută în linie dreaptă — nu am putut potrivi cu o rețea de rute.',
+ straightHint: 'Punctele de eșantionare pot traversa uscat sau mare.',
ferryLabel: 'feribot'
},
days: {
diff --git a/weather-voodoo/src/lib/i18n/th.ts b/weather-voodoo/src/lib/i18n/th.ts
index 1140c79..8c08732 100644
--- a/weather-voodoo/src/lib/i18n/th.ts
+++ b/weather-voodoo/src/lib/i18n/th.ts
@@ -48,7 +48,7 @@ export const th: Dict = {
title: 'Weather Voodoo ทำงานอย่างไร',
lede: 'แอปจะเลือกชั่วโมงที่ดีที่สุดในอีก 3 วันข้างหน้าสำหรับทริปกลางแจ้งหรือทะเลที่เฉพาะเจาะจง — โดยรวมลม กระโชก ฝน ความสูงของคลื่น และทัศนวิสัยเป็นคะแนน 0–100 แล้วแสดงช่วงที่ดีที่สุดที่เข้ากับเวลาและระยะเวลาของคุณ',
twoTabs: 'แท็บต่าง ๆ',
- routeDesc: 'เส้นทาง — เลือก จาก และ ถึง พยากรณ์ถูกรวมจาก 3 จุดตัวอย่างตามเส้นทาง โดยใช้สภาพที่แย่ที่สุดในแต่ละชั่วโมง เหมาะกับทริปเรือเฟอร์รี/เรือ ขับรถ ข้ามแม่น้ำด้วยคายัค',
+ routeDesc: 'เส้นทาง — เลือก จาก และ ถึง พยากรณ์ถูกรวมจาก 3 จุดตัวอย่างตามเส้นทาง โดยใช้สภาพที่แย่ที่สุดในแต่ละชั่วโมง โหมด ทะเล จะใช้เส้นทางเฟอร์รี OSM หรือเครือข่ายทะเลเปิด; โหมด บก จะใช้เส้นทางเดินป่าและจักรยานจาก OSM',
fixedDesc: 'ตำแหน่งคงที่ — สถานที่เดียว เหมาะกับวันชายหาด เดินป่า ดูพระอาทิตย์ตก',
score: 'คะแนน',
scoreDesc: 'แต่ละชั่วโมงถูกให้คะแนน 0–100 ตามประเภทกิจกรรม คะแนนช่วง = ชั่วโมงที่แย่ที่สุดในช่วงนั้น (โซ่แข็งแรงเท่าจุดที่อ่อนที่สุด) คะแนนเฉลี่ยเป็นตัวแบ่งกรณีเสมอ',
@@ -105,6 +105,7 @@ export const th: Dict = {
legsMany: '{n} ช่วง',
ferryLegs: '{n} เฟอร์รี',
seaLegs: '{n} ทะเลเปิด',
+ trailLegs: '{n} เส้นทางเดิน',
straightLegs: '{n} เส้นตรง',
straightPreview: '📐 เส้นตรงตัวอย่างขณะแก้ไข — กดเสร็จเพื่อคำนวณเส้นทางจริง',
editAriaLabel: 'แก้ไขจุดผ่าน',
@@ -116,15 +117,19 @@ export const th: Dict = {
},
route: {
computing: 'กำลังคำนวณเส้นทาง…',
- computingLong: 'กำลังคำนวณเส้นทางทะเลที่ดีที่สุด & ดึงข้อมูลพยากรณ์… ครั้งแรกในพื้นที่อาจใช้เวลาถึง 15 วินาที',
+ computingLong: 'กำลังคำนวณเส้นทางที่ดีที่สุด & ดึงข้อมูลพยากรณ์… ครั้งแรกในพื้นที่อาจใช้เวลาถึง 15 วินาที',
ferryPrefix: '⛴️ เส้นทางเฟอร์รีจาก OpenStreetMap:',
waysSuffix: '({n} เส้นทางในพื้นที่)',
waysTitle: 'จำนวนเส้นทางเฟอร์รีที่นำมาพิจารณา',
seaPrefix: '⚓ เส้นทางทะเลเปิด:',
detourTitle: 'ความยาวเส้นทาง / ความยาว great-circle',
greatCircleSuffix: 'ของเส้น great-circle',
- straight: '📐 เส้นทางตรง — ไม่สามารถ snap เข้ากับเครือข่ายเส้นทางทะเลได้',
- straightHint: 'จุดสุ่มอาจข้ามแผ่นดิน',
+ trailPrefix: '🥾 เส้นทางเดินเท้า/ปั่นจักรยานจาก OpenStreetMap:',
+ trailWaysSuffix: '({hike} เส้นเดินเท้า · {bike} เส้นจักรยาน ในบริเวณใกล้เคียง)',
+ trailWaysTitle: 'ทางเดิน ทางเท้า ทางลูกรัง ทางม้า และทางจักรยานในพื้นที่ค้นหา',
+ trailLabel: 'เส้นทางเดิน',
+ straight: '📐 เส้นทางตรง — ไม่สามารถ snap เข้ากับเครือข่ายเส้นทางได้',
+ straightHint: 'จุดสุ่มอาจข้ามแผ่นดินหรือทะเล',
ferryLabel: 'เฟอร์รี'
},
days: {
diff --git a/weather-voodoo/src/lib/server/osm-land.ts b/weather-voodoo/src/lib/server/osm-land.ts
new file mode 100644
index 0000000..5c932df
--- /dev/null
+++ b/weather-voodoo/src/lib/server/osm-land.ts
@@ -0,0 +1,286 @@
+import * as PathFinderMod from 'geojson-path-finder';
+
+type PathFinderCtor = new (
+ network: FeatureCollection
+) => {
+ findPath(a: Feature, b: Feature): { path: Position[]; weight: number } | undefined;
+};
+
+// geojson-path-finder's CJS-to-ESM interop varies by bundler — handle every layer.
+type MaybeWrapped = { default?: MaybeWrapped | PathFinderCtor } & Record;
+const _mod = PathFinderMod as unknown as MaybeWrapped;
+const _layer1 = (_mod.default ?? _mod) as MaybeWrapped;
+const _layer2 = ((_layer1 as MaybeWrapped).default ?? _layer1) as MaybeWrapped;
+const PathFinder = (typeof _layer2 === 'function' ? _layer2 : _layer1) as unknown as PathFinderCtor;
+import type { Feature, FeatureCollection, LineString, Point, Position } from 'geojson';
+import type { LatLng } from '$lib/types';
+import { cached, roundCoord } from './cache';
+import { haversineKm } from '$lib/geo';
+
+const OVERPASS_MIRRORS = [
+ 'https://overpass.kumi.systems/api/interpreter',
+ 'https://overpass-api.de/api/interpreter',
+ 'https://lz4.overpass-api.de/api/interpreter',
+ 'https://overpass.osm.ch/api/interpreter'
+];
+const OVERPASS_TIMEOUT_MS = 12_000;
+
+// Trail networks are dense — keep the search area tight so the Overpass payload
+// stays bounded and the path-finding graph fits in memory.
+const MAX_SPAN_DEG = 1.5;
+const MAX_SNAP_KM = 5;
+const TTL_MS = 24 * 60 * 60 * 1000;
+
+type TrailKind = 'hike' | 'bike';
+
+type OverpassWay = {
+ id: number;
+ type: 'way';
+ geometry: { lat: number; lon: number }[];
+ tags?: Record;
+};
+
+type OverpassResponse = { elements: OverpassWay[] };
+
+function bboxPadDeg(from: LatLng, to: LatLng): number {
+ const span = Math.max(Math.abs(from.lat - to.lat), Math.abs(from.lon - to.lon));
+ // Trail networks are dense — keep the pad tight, especially for short hops.
+ if (span < 0.05) return 0.02;
+ if (span < 0.1) return 0.03;
+ if (span < 0.3) return 0.06;
+ if (span < 0.8) return 0.12;
+ return 0.2;
+}
+
+function bbox(from: LatLng, to: LatLng): { south: number; west: number; north: number; east: number } {
+ const pad = bboxPadDeg(from, to);
+ const south = Math.min(from.lat, to.lat) - pad;
+ const north = Math.max(from.lat, to.lat) + pad;
+ const west = Math.min(from.lon, to.lon) - pad;
+ const east = Math.max(from.lon, to.lon) + pad;
+ return { south, west, north, east };
+}
+
+async function postOverpass(mirror: string, body: string): Promise {
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), OVERPASS_TIMEOUT_MS);
+ try {
+ return await fetch(mirror, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded',
+ 'user-agent': 'weather-voodoo (https://weather-voodoo.vercel.app)'
+ },
+ body,
+ signal: ctrl.signal
+ });
+ } finally {
+ clearTimeout(t);
+ }
+}
+
+function buildQuery(b: { south: number; west: number; north: number; east: number }): string {
+ const bb = `${b.south},${b.west},${b.north},${b.east}`;
+ // path / footway / track / bridleway — hiking-friendly
+ // cycleway — dedicated bike paths
+ // pedestrian / steps — urban walking
+ // foot=designated / bicycle=designated — explicit non-highway routing tags
+ return (
+ `[out:json][timeout:10];` +
+ `(` +
+ `way["highway"="path"](${bb});` +
+ `way["highway"="footway"](${bb});` +
+ `way["highway"="track"](${bb});` +
+ `way["highway"="bridleway"](${bb});` +
+ `way["highway"="cycleway"](${bb});` +
+ `way["highway"="pedestrian"](${bb});` +
+ `way["highway"="steps"](${bb});` +
+ `way["foot"="designated"](${bb});` +
+ `way["bicycle"="designated"](${bb});` +
+ `);` +
+ `out tags geom;`
+ );
+}
+
+async function fetchTrailWays(from: LatLng, to: LatLng): Promise {
+ const b = bbox(from, to);
+ const key = `osm-land:${roundCoord(b.south, 1)}:${roundCoord(b.west, 1)}:${roundCoord(b.north, 1)}:${roundCoord(b.east, 1)}`;
+ const ways = await cached(
+ key,
+ async () => {
+ const query = buildQuery(b);
+ const body = 'data=' + encodeURIComponent(query);
+ const failures: string[] = [];
+ for (const mirror of OVERPASS_MIRRORS) {
+ try {
+ const res = await postOverpass(mirror, body);
+ if (!res.ok) {
+ failures.push(`${new URL(mirror).host}: HTTP ${res.status}`);
+ continue;
+ }
+ const data = (await res.json()) as OverpassResponse;
+ const filtered = data.elements.filter(
+ (e) => e.type === 'way' && Array.isArray(e.geometry) && e.geometry.length >= 2
+ );
+ // An empty list is usually a transient mirror hiccup — try the next
+ // mirror before settling on it (otherwise we'd cache an empty result
+ // for the full TTL).
+ if (filtered.length === 0) {
+ failures.push(`${new URL(mirror).host}: empty result`);
+ continue;
+ }
+ return filtered;
+ } catch (e) {
+ failures.push(
+ `${new URL(mirror).host}: ${e instanceof Error ? e.name + ': ' + e.message : String(e)}`
+ );
+ }
+ }
+ throw new Error('All Overpass mirrors returned empty or failed — ' + failures.join(' | '));
+ },
+ TTL_MS
+ );
+ return ways;
+}
+
+function classifyWay(w: OverpassWay): TrailKind {
+ const t = w.tags ?? {};
+ const hw = t.highway;
+ if (hw === 'cycleway') return 'bike';
+ if (t.bicycle === 'designated' && t.foot !== 'designated') return 'bike';
+ // Everything else (path/footway/track/bridleway/pedestrian/steps + foot=designated)
+ // is hiking-friendly; mixed-use trails count as hiking by default.
+ return 'hike';
+}
+
+function nearestVertex(ways: OverpassWay[], p: LatLng): { vertex: LatLng; km: number } | null {
+ let best: { vertex: LatLng; km: number } | null = null;
+ for (const w of ways) {
+ for (const g of w.geometry) {
+ const km = haversineKm(p, { lat: g.lat, lon: g.lon });
+ if (!best || km < best.km) best = { vertex: { lat: g.lat, lon: g.lon }, km };
+ }
+ }
+ return best;
+}
+
+function waysToFeatureCollection(ways: OverpassWay[]): FeatureCollection {
+ const features = ways.map>((w) => ({
+ type: 'Feature',
+ properties: { id: w.id },
+ geometry: {
+ type: 'LineString',
+ coordinates: w.geometry.map(({ lat, lon }) => [lon, lat])
+ }
+ }));
+ return { type: 'FeatureCollection', features };
+}
+
+function pointFeature(p: LatLng): Feature {
+ return {
+ type: 'Feature',
+ properties: {},
+ geometry: { type: 'Point', coordinates: [p.lon, p.lat] }
+ };
+}
+
+export type TrailRouteResult = {
+ polyline: LatLng[];
+ lengthKm: number;
+ originSnapKm: number;
+ destinationSnapKm: number;
+ wayCount: number;
+ hikeWayCount: number;
+ bikeWayCount: number;
+};
+
+export type TrailRouteFailureReason =
+ | 'span-too-wide'
+ | 'overpass-failed'
+ | 'no-ways'
+ | 'snap-too-far'
+ | 'no-path';
+
+export type TrailRouteOutcome =
+ | { ok: true; result: TrailRouteResult }
+ | { ok: false; reason: TrailRouteFailureReason; detail?: string };
+
+/**
+ * Try to route from→to along OpenStreetMap-tagged hiking and cycling ways
+ * (path, footway, track, bridleway, cycleway, pedestrian, steps, plus ways
+ * tagged foot=designated or bicycle=designated). Mirrors the structure of
+ * the ferry resolver: snap each endpoint to the nearest vertex, run
+ * geojson-path-finder over the network, and stitch the real endpoints back on.
+ */
+export async function computeLandTrailRoute(
+ from: LatLng,
+ to: LatLng
+): Promise {
+ if (Math.abs(from.lat - to.lat) > MAX_SPAN_DEG || Math.abs(from.lon - to.lon) > MAX_SPAN_DEG) {
+ return { ok: false, reason: 'span-too-wide' };
+ }
+
+ let ways: OverpassWay[];
+ try {
+ ways = await fetchTrailWays(from, to);
+ } catch (e) {
+ const detail = e instanceof Error ? e.message : String(e);
+ console.warn('[osm-land] Overpass fetch failed:', detail);
+ return { ok: false, reason: 'overpass-failed', detail };
+ }
+ if (ways.length === 0) {
+ return { ok: false, reason: 'no-ways' };
+ }
+
+ const fromSnap = nearestVertex(ways, from);
+ const toSnap = nearestVertex(ways, to);
+ if (!fromSnap || !toSnap) return { ok: false, reason: 'no-ways' };
+ if (fromSnap.km > MAX_SNAP_KM || toSnap.km > MAX_SNAP_KM) {
+ return {
+ ok: false,
+ reason: 'snap-too-far',
+ detail: `from=${fromSnap.km.toFixed(1)}km, to=${toSnap.km.toFixed(1)}km`
+ };
+ }
+
+ const fc = waysToFeatureCollection(ways);
+ let pf: InstanceType;
+ try {
+ pf = new PathFinder(fc);
+ } catch (e) {
+ const detail = e instanceof Error ? e.message : String(e);
+ return { ok: false, reason: 'no-path', detail };
+ }
+ const path = pf.findPath(pointFeature(fromSnap.vertex), pointFeature(toSnap.vertex));
+ if (!path || !path.path || path.path.length < 2) {
+ return { ok: false, reason: 'no-path' };
+ }
+
+ const interior: LatLng[] = (path.path as Position[]).map(([lon, lat]) => ({ lat, lon }));
+ const polyline: LatLng[] = [from, ...interior, to];
+
+ let lengthKm = 0;
+ for (let i = 1; i < polyline.length; i++) {
+ lengthKm += haversineKm(polyline[i - 1], polyline[i]);
+ }
+
+ let hikeWayCount = 0;
+ let bikeWayCount = 0;
+ for (const w of ways) {
+ if (classifyWay(w) === 'bike') bikeWayCount++;
+ else hikeWayCount++;
+ }
+
+ return {
+ ok: true,
+ result: {
+ polyline,
+ lengthKm,
+ originSnapKm: fromSnap.km,
+ destinationSnapKm: toSnap.km,
+ wayCount: ways.length,
+ hikeWayCount,
+ bikeWayCount
+ }
+ };
+}
diff --git a/weather-voodoo/src/routes/api/multi-route/+server.ts b/weather-voodoo/src/routes/api/multi-route/+server.ts
index ffd44d7..cbc8a9b 100644
--- a/weather-voodoo/src/routes/api/multi-route/+server.ts
+++ b/weather-voodoo/src/routes/api/multi-route/+server.ts
@@ -2,6 +2,7 @@ import { error, json } from '@sveltejs/kit';
import { fetchForecast, fetchMarine } from '$lib/server/openmeteo';
import { computeSeaRoute } from '$lib/server/sea-routing';
import { computeFerryRoute } from '$lib/server/osm-ferry';
+import { computeLandTrailRoute } from '$lib/server/osm-land';
import { fuseRoute } from '$lib/fusion';
import { sampleAlongPolyline, sampleAlongRoute } from '$lib/geo';
import type { LatLng } from '$lib/types';
@@ -21,11 +22,16 @@ function parsePoints(raw: string | null): LatLng[] | null {
return out;
}
-type LegKind = 'ferry' | 'sea' | 'straight';
+type LegKind = 'ferry' | 'sea' | 'trail' | 'straight';
type Leg = { kind: LegKind; polyline: LatLng[]; lengthKm: number };
async function resolveLeg(from: LatLng, to: LatLng, land: boolean): Promise {
- if (!land) {
+ if (land) {
+ const trail = await computeLandTrailRoute(from, to);
+ if (trail.ok) {
+ return { kind: 'trail', polyline: trail.result.polyline, lengthKm: trail.result.lengthKm };
+ }
+ } else {
const ferry = await computeFerryRoute(from, to);
if (ferry.ok) {
return { kind: 'ferry', polyline: ferry.result.polyline, lengthKm: ferry.result.lengthKm };
@@ -35,7 +41,6 @@ async function resolveLeg(from: LatLng, to: LatLng, land: boolean): Promise
return { kind: 'sea', polyline: sea.polyline, lengthKm: sea.lengthKm };
}
}
- // Straight-line fallback (or always for land mode).
const segment = [from, to];
let lengthKm = 0;
for (let i = 1; i < segment.length; i++) {
@@ -75,6 +80,7 @@ export const GET: RequestHandler = async ({ url }) => {
const totalKm = legs.reduce((acc, l) => acc + l.lengthKm, 0);
const ferryLegs = legs.filter((l) => l.kind === 'ferry').length;
const seaLegs = legs.filter((l) => l.kind === 'sea').length;
+ const trailLegs = legs.filter((l) => l.kind === 'trail').length;
const straightLegs = legs.filter((l) => l.kind === 'straight').length;
const allStraight = legs.every((l) => l.kind === 'straight');
@@ -105,6 +111,7 @@ export const GET: RequestHandler = async ({ url }) => {
legCount: legs.length,
ferryLegs,
seaLegs,
+ trailLegs,
straightLegs,
totalKm
}
diff --git a/weather-voodoo/src/routes/api/route/+server.ts b/weather-voodoo/src/routes/api/route/+server.ts
index 920af1d..ac2f7be 100644
--- a/weather-voodoo/src/routes/api/route/+server.ts
+++ b/weather-voodoo/src/routes/api/route/+server.ts
@@ -2,6 +2,7 @@ import { error, json } from '@sveltejs/kit';
import { fetchForecast, fetchMarine } from '$lib/server/openmeteo';
import { computeSeaRoute } from '$lib/server/sea-routing';
import { computeFerryRoute } from '$lib/server/osm-ferry';
+import { computeLandTrailRoute } from '$lib/server/osm-land';
import { fuseRoute } from '$lib/fusion';
import { sampleAlongPolyline, sampleAlongRoute } from '$lib/geo';
import type { LatLng } from '$lib/types';
@@ -28,15 +29,45 @@ export const GET: RequestHandler = async ({ url }) => {
type RouteMeta =
| { kind: 'ferry'; lengthKm: number; wayCount: number; originSnapKm: number; destinationSnapKm: number }
| { kind: 'sea'; lengthKm: number; greatCircleKm: number; detourRatio: number }
- | { kind: 'straight'; ferryFallback?: string; ferryDetail?: string };
+ | {
+ kind: 'trail';
+ lengthKm: number;
+ wayCount: number;
+ hikeWayCount: number;
+ bikeWayCount: number;
+ originSnapKm: number;
+ destinationSnapKm: number;
+ }
+ | { kind: 'straight'; ferryFallback?: string; ferryDetail?: string; trailFallback?: string; trailDetail?: string };
let routePolyline: LatLng[] = [from, to];
let routeMeta: RouteMeta = { kind: 'straight' };
- if (!land) {
- // Prefer OSM ferry routing — uses real `route=ferry` tagging from
- // OpenStreetMap, which is dense enough for short coastal hops where
- // the Eurostat marnet is too coarse.
+ if (land) {
+ // Land mode → look for hiking + cycling trails via OSM. Falls back to
+ // straight-line if no connected network exists between the endpoints.
+ const trail = await computeLandTrailRoute(from, to);
+ if (trail.ok) {
+ routePolyline = trail.result.polyline;
+ routeMeta = {
+ kind: 'trail',
+ lengthKm: trail.result.lengthKm,
+ wayCount: trail.result.wayCount,
+ hikeWayCount: trail.result.hikeWayCount,
+ bikeWayCount: trail.result.bikeWayCount,
+ originSnapKm: trail.result.originSnapKm,
+ destinationSnapKm: trail.result.destinationSnapKm
+ };
+ } else {
+ routeMeta = {
+ kind: 'straight',
+ trailFallback: trail.reason,
+ trailDetail: trail.detail
+ };
+ }
+ } else {
+ // Sea mode → prefer OSM ferry routing for short coastal hops, then fall
+ // back to the open-ocean searoute network.
const ferry = await computeFerryRoute(from, to);
if (ferry.ok) {
routePolyline = ferry.result.polyline;