diff --git a/weather-voodoo/src/app.css b/weather-voodoo/src/app.css
index 594030d..87b18b8 100644
--- a/weather-voodoo/src/app.css
+++ b/weather-voodoo/src/app.css
@@ -194,9 +194,18 @@ input[type='search'] {
min-height: 280px;
border-radius: 0;
}
-/* Hide controls card in fullscreen — map only */
+/* Show controls card in fullscreen but compact — needed for waypoint
+ editing, route info, search fields */
.map-stage.fullscreen > .card:not(.map-card) {
- display: none;
+ flex: 0 0 auto;
+ margin: 0;
+ border-radius: 0;
+ border-left: none;
+ border-right: none;
+ border-top: none;
+ padding: 0.4rem 0.6rem;
+ font-size: 0.9em;
+ overflow-x: auto;
}
.map-fs-btn {
position: absolute;
diff --git a/weather-voodoo/src/lib/components/MapView.svelte b/weather-voodoo/src/lib/components/MapView.svelte
index 3d1b6d6..937a548 100644
--- a/weather-voodoo/src/lib/components/MapView.svelte
+++ b/weather-voodoo/src/lib/components/MapView.svelte
@@ -366,7 +366,7 @@
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 });
+ map.fitBounds(bounds, { padding: 60, maxZoom: 16, duration: 400 });
} else if (markers.length === 1) {
map.flyTo({ center: [markers[0].lon, markers[0].lat], zoom: 11, duration: 400 });
}
@@ -448,7 +448,7 @@
id: 'route-line',
type: 'line',
source: 'route-src',
- paint: { 'line-color': polylineColor, 'line-width': 3, 'line-opacity': 0.85 }
+ paint: { 'line-color': polylineColor, 'line-width': 5, 'line-opacity': 0.9 }
});
}
}
diff --git a/weather-voodoo/src/lib/components/RouteView.svelte b/weather-voodoo/src/lib/components/RouteView.svelte
index 015f8e4..c78fdc5 100644
--- a/weather-voodoo/src/lib/components/RouteView.svelte
+++ b/weather-voodoo/src/lib/components/RouteView.svelte
@@ -20,7 +20,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;
@@ -212,6 +213,11 @@
{t('route.ferryPrefix')} {result.route.lengthKm.toFixed(0)} km
{t('route.waysSuffix', { n: result.route.wayCount })}
+ {: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 result?.route.kind === 'sea'}
{t('route.seaPrefix')} {result.route.lengthKm.toFixed(0)} km
@@ -219,7 +225,7 @@
{: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 77f3a78..e4299da 100644
--- a/weather-voodoo/src/lib/components/WaypointsView.svelte
+++ b/weather-voodoo/src/lib/components/WaypointsView.svelte
@@ -17,6 +17,7 @@
legCount: number;
ferryLegs: number;
seaLegs: number;
+ trailLegs: number;
straightLegs: number;
totalKm: number;
};
@@ -340,6 +341,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 51dfa38..9158f1f 100644
--- a/weather-voodoo/src/lib/i18n/en.ts
+++ b/weather-voodoo/src/lib/i18n/en.ts
@@ -98,6 +98,7 @@ export type Dict = {
legsMany: string;
ferryLegs: string;
seaLegs: string;
+ trailLegs: string;
straightLegs: string;
straightPreview: string;
editAriaLabel: string;
@@ -119,6 +120,9 @@ export type Dict = {
straight: string;
straightHint: string;
ferryLabel: string;
+ trailPrefix: string;
+ trailWaysSuffix: string;
+ trailLabel: string;
};
days: { today: string; tomorrow: string; d2: string };
share: {
@@ -431,6 +435,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',
@@ -451,7 +456,10 @@ export const en: Dict = {
greatCircleSuffix: 'the great-circle line',
straight: "📐 Straight-line route — couldn't snap to a sea-route network.",
straightHint: 'Sample points may cross land.',
- ferryLabel: 'ferry'
+ ferryLabel: 'ferry',
+ trailPrefix: '🥾 Trail route via OpenStreetMap:',
+ trailWaysSuffix: '({hike} hike · {bike} bike ways)',
+ trailLabel: 'trail'
},
days: {
today: 'Today',
diff --git a/weather-voodoo/src/lib/i18n/ro.ts b/weather-voodoo/src/lib/i18n/ro.ts
index 588693c..6971e96 100644
--- a/weather-voodoo/src/lib/i18n/ro.ts
+++ b/weather-voodoo/src/lib/i18n/ro.ts
@@ -107,6 +107,7 @@ export const ro: Dict = {
legsMany: '{n} segmente',
ferryLegs: '{n} feribot',
seaLegs: '{n} larg',
+ trailLegs: '{n} traseu',
straightLegs: '{n} drept',
straightPreview: '📐 Previzualizare în linie dreaptă în timpul editării — apasă Gata pentru a calcula ruta reală.',
editAriaLabel: 'Editează punctul de trecere',
@@ -127,7 +128,10 @@ export const ro: Dict = {
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.',
- ferryLabel: 'feribot'
+ ferryLabel: 'feribot',
+ trailPrefix: '🥾 Traseu drumeție via OpenStreetMap:',
+ trailWaysSuffix: '({hike} drumeție · {bike} bicicletă)',
+ trailLabel: 'traseu'
},
days: {
today: 'Azi',
diff --git a/weather-voodoo/src/lib/i18n/th.ts b/weather-voodoo/src/lib/i18n/th.ts
index 5b7102a..68b09f7 100644
--- a/weather-voodoo/src/lib/i18n/th.ts
+++ b/weather-voodoo/src/lib/i18n/th.ts
@@ -107,6 +107,7 @@ export const th: Dict = {
legsMany: '{n} ช่วง',
ferryLegs: '{n} เฟอร์รี',
seaLegs: '{n} ทะเลเปิด',
+ trailLegs: '{n} เส้นทาง',
straightLegs: '{n} เส้นตรง',
straightPreview: '📐 เส้นตรงตัวอย่างขณะแก้ไข — กดเสร็จเพื่อคำนวณเส้นทางจริง',
editAriaLabel: 'แก้ไขจุดผ่าน',
@@ -127,7 +128,10 @@ export const th: Dict = {
greatCircleSuffix: 'ของเส้น great-circle',
straight: '📐 เส้นทางตรง — ไม่สามารถ snap เข้ากับเครือข่ายเส้นทางทะเลได้',
straightHint: 'จุดสุ่มอาจข้ามแผ่นดิน',
- ferryLabel: 'เฟอร์รี'
+ ferryLabel: 'เฟอร์รี',
+ trailPrefix: '🥾 เส้นทางเดินป่าผ่าน OpenStreetMap:',
+ trailWaysSuffix: '({hike} เดินเท้า · {bike} จักรยาน)',
+ trailLabel: 'เส้นทาง'
},
days: {
today: 'วันนี้',
diff --git a/weather-voodoo/src/lib/server/openmeteo.ts b/weather-voodoo/src/lib/server/openmeteo.ts
index 82886d9..f9ed1b3 100644
--- a/weather-voodoo/src/lib/server/openmeteo.ts
+++ b/weather-voodoo/src/lib/server/openmeteo.ts
@@ -174,25 +174,64 @@ export async function fetchMarine(lat: number, lon: number, days = 3): Promise {
if (!query.trim()) return [];
+ const results = await geocodeOpenMeteo(query, limit);
+ if (results.length > 0) return results;
+ return geocodeNominatim(query, limit);
+}
+
+async function geocodeOpenMeteo(query: string, limit: number): Promise {
const params = new URLSearchParams({
name: query,
count: limit.toString(),
language: 'en',
format: 'json'
});
- const res = await fetch(`${GEOCODE_URL}?${params.toString()}`);
- if (!res.ok) return [];
- const data = (await res.json()) as { results?: { name: string; latitude: number; longitude: number; country?: string; admin1?: string }[] };
- return (data.results ?? []).map((r) => ({
- name: r.name,
- lat: r.latitude,
- lon: r.longitude,
- country: r.country,
- admin1: r.admin1
- }));
+ try {
+ const res = await fetch(`${GEOCODE_URL}?${params.toString()}`);
+ if (!res.ok) return [];
+ const data = (await res.json()) as { results?: { name: string; latitude: number; longitude: number; country?: string; admin1?: string }[] };
+ return (data.results ?? []).map((r) => ({
+ name: r.name,
+ lat: r.latitude,
+ lon: r.longitude,
+ country: r.country,
+ admin1: r.admin1
+ }));
+ } catch {
+ return [];
+ }
+}
+
+async function geocodeNominatim(query: string, limit: number): Promise {
+ const params = new URLSearchParams({
+ q: query,
+ format: 'jsonv2',
+ limit: limit.toString(),
+ addressdetails: '1'
+ });
+ try {
+ const res = await fetch(`${NOMINATIM_URL}?${params.toString()}`, {
+ headers: { 'user-agent': 'weather-voodoo (https://weather-voodoo.vercel.app)' }
+ });
+ if (!res.ok) return [];
+ const data = (await res.json()) as { display_name: string; lat: string; lon: string; address?: { country?: string; state?: string } }[];
+ return data.map((r) => {
+ const parts = r.display_name.split(',').map((s) => s.trim());
+ return {
+ name: parts[0] ?? r.display_name,
+ lat: Number(r.lat),
+ lon: Number(r.lon),
+ country: r.address?.country,
+ admin1: r.address?.state ?? parts[1]
+ };
+ });
+ } catch {
+ return [];
+ }
}
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 5589eac..3246a7e 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 { bearing, sampleAlongPolylineWithHeadings, 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');
@@ -130,6 +136,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 3c56853..89e23da 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 { bearing, sampleAlongPolylineWithHeadings, sampleAlongRoute } from '$lib/geo';
import type { LatLng } from '$lib/types';
@@ -28,12 +29,29 @@ 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) {
+ if (land) {
+ 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 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.