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;