From f5c9a20e1a50e55305b3751095fb0e574149bb1a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 20:08:52 +0000 Subject: [PATCH 1/5] OSM hiking & cycling trail routing for Land mode (closes #33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-implements #33 from scratch on current main (the original PR had heavy merge conflicts from the wind/compass/GPS work). The trail routing logic (`osm-land.ts`) is identical to the original. When Land mode is active, each route leg now snaps to OpenStreetMap hiking/cycling trails (path, footway, track, bridleway, cycleway, pedestrian, steps, foot/bicycle=designated) instead of drawing a straight line. Pipeline: * `osm-land.ts` — queries Overpass for trail ways within a graduated bbox, builds a geojson-path-finder graph, snaps endpoints to the nearest vertex (≤5 km), and returns the routed polyline + length + hike/bike way counts. * `/api/route?land=1` — calls computeLandTrailRoute first, falls back to straight-line on failure. * `/api/multi-route?land=1` — each leg attempts trail routing; trailLegs count in the response. * RouteView shows "🥾 Trail route via OpenStreetMap: X km (Y hike · Z bike ways)". Straight-line fallback shows the trail failure reason. * WaypointsView shows "🥾 N trail" in the track summary. Constraints: MAX_SPAN_DEG=1.5 (~165 km), MAX_SNAP_KM=5, 24h Overpass cache. Trails are dense — tight bbox keeps payloads bounded. i18n: route.trailPrefix/trailWaysSuffix/trailLabel, waypoints.trailLegs in en/th/ro. 91/91 tests pass, 0 type errors, build succeeds. https://claude.ai/code/session_01B9DMrLPT7hYfdCTsJgFF9q --- .../src/lib/components/RouteView.svelte | 10 +- .../src/lib/components/WaypointsView.svelte | 2 + weather-voodoo/src/lib/i18n/en.ts | 10 +- weather-voodoo/src/lib/i18n/ro.ts | 6 +- weather-voodoo/src/lib/i18n/th.ts | 6 +- weather-voodoo/src/lib/server/osm-land.ts | 286 ++++++++++++++++++ .../src/routes/api/multi-route/+server.ts | 13 +- .../src/routes/api/route/+server.ts | 22 +- 8 files changed, 345 insertions(+), 10 deletions(-) create mode 100644 weather-voodoo/src/lib/server/osm-land.ts 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/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. From 85394a3ac26c77082cad1383ecd11f103ee0f3a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 20:32:24 +0000 Subject: [PATCH 2/5] Add Nominatim fallback for geocoding (parks, landmarks, POIs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open-Meteo's geocoding API only indexes cities and populated places — searching for "Lumpini Park", "Hyde Park London", or any trail/POI returned empty results. Now if Open-Meteo returns no results, the geocoder falls back to Nominatim (OpenStreetMap's geocoder), which knows parks, mountains, beaches, trails, buildings — everything tagged in OSM. The flow: Open-Meteo first (fast, good for cities) → Nominatim fallback (slower, but finds POIs). Both are free and unkeyed. https://claude.ai/code/session_01B9DMrLPT7hYfdCTsJgFF9q --- weather-voodoo/src/lib/server/openmeteo.ts | 59 ++++++++++++++++++---- 1 file changed, 49 insertions(+), 10 deletions(-) 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 []; + } } From c19631254ab2ad985fd5a8b627fc3de3e896d751 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 22:02:57 +0000 Subject: [PATCH 3/5] Show controls card in fullscreen map mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controls card (waypoint chips + Done button, route meta-line, search fields) was hidden in fullscreen via display:none. This broke waypoint editing — you couldn't see/tap chips or press Done. Now the controls card stays visible but compact in fullscreen: reduced padding, no rounded corners, horizontally scrollable if needed. The map fills the remaining space below. https://claude.ai/code/session_01B9DMrLPT7hYfdCTsJgFF9q --- weather-voodoo/src/app.css | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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; From 9dc78c401899bc6d7939d83cda7bc7fb21065d9c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 22:07:37 +0000 Subject: [PATCH 4/5] Thicker route polyline (5px, was 3px) https://claude.ai/code/session_01B9DMrLPT7hYfdCTsJgFF9q --- weather-voodoo/src/lib/components/MapView.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/weather-voodoo/src/lib/components/MapView.svelte b/weather-voodoo/src/lib/components/MapView.svelte index 3d1b6d6..28caffe 100644 --- a/weather-voodoo/src/lib/components/MapView.svelte +++ b/weather-voodoo/src/lib/components/MapView.svelte @@ -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 } }); } } From 8f1174939173296b391ae0e4d2f884b84d0c6bee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 22:16:44 +0000 Subject: [PATCH 5/5] Zoom to fit waypoints tightly after Done (maxZoom 16, was 11) maxZoom: 11 forced the map to zoom out far even when all waypoints were within a few blocks. Now caps at 16 so nearby points stay zoomed in with just enough padding (60px) to see all markers. https://claude.ai/code/session_01B9DMrLPT7hYfdCTsJgFF9q --- weather-voodoo/src/lib/components/MapView.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/weather-voodoo/src/lib/components/MapView.svelte b/weather-voodoo/src/lib/components/MapView.svelte index 28caffe..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 }); }