diff --git a/weather-voodoo/src/lib/components/MapView.svelte b/weather-voodoo/src/lib/components/MapView.svelte index b4f252b..3b43cb9 100644 --- a/weather-voodoo/src/lib/components/MapView.svelte +++ b/weather-voodoo/src/lib/components/MapView.svelte @@ -3,6 +3,23 @@ import maplibregl, { type Map as MlMap, type Marker } from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import type { LatLng } from '$lib/types'; + import type { RelativeWindClass } from '$lib/wind'; + import { mapViewport } from '$lib/map-viewport.svelte'; + + export type WindChevron = { + point: LatLng; + /** Bearing of travel at this point (deg from N). Used for tooltip context. */ + headingDeg: number; + /** Wind speed at the selected hour, knots. */ + windKn: number; + /** Meteorological wind direction (from), deg from N. Used to orient the arrow. */ + windDirDeg: number; + /** Wind angle relative to heading, in [-180, 180]. */ + relWindDeg: number; + cls: RelativeWindClass; + /** Localized class label for tooltip ("Head", "Tail-Cross", …). */ + classLabel: string; + }; type Props = { markers?: LatLng[]; @@ -17,6 +34,17 @@ draggableMarkers?: boolean; highlightIdx?: number | null; height?: string; + /** + * Wind chevrons rendered along the route — one per sample point, sized + * for handlebar/glance-able use on mobile. Color = relative-wind class, + * arrow direction = absolute wind direction (where the wind is going). + */ + windChevrons?: WindChevron[]; + /** Show user's live GPS position as a pulsing blue dot. */ + showUserLocation?: boolean; + /** Suppress auto-fitBounds. Use during editing when markers change + * incrementally and the user is positioning the map manually. */ + suppressAutoFit?: boolean; }; let { @@ -31,12 +59,19 @@ polylineColor = '#38bdf8', draggableMarkers = false, highlightIdx = null, - height = '440px' + height = '600px', + windChevrons, + showUserLocation = false, + suppressAutoFit = false }: Props = $props(); let el: HTMLDivElement | null = null; let map: MlMap | null = null; let drawn: Marker[] = []; + let drawnChevrons: Marker[] = []; + let userLocMarker: Marker | null = null; + let geoWatchId: number | null = null; + let lastFitKey = ''; const STYLE = { version: 8 as const, @@ -51,13 +86,51 @@ layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }] }; + let locating = $state(false); + + function locateMe() { + if (!map || !('geolocation' in navigator)) return; + locating = true; + navigator.geolocation.getCurrentPosition( + (pos) => { + if (!map) return; + const { latitude: lat, longitude: lon } = pos.coords; + map.flyTo({ center: [lon, lat], zoom: Math.max(map.getZoom(), 12), duration: 600 }); + mapViewport.center = { lat, lon }; + mapViewport.zoom = Math.max(map.getZoom(), 12); + locating = false; + }, + () => { locating = false; }, + { enableHighAccuracy: true, timeout: 10_000 } + ); + } + onMount(() => { if (!el) return; + + // Use shared viewport if available (persisted from another tab), else + // fall back to markers or the default view. + const initCenter: [number, number] = mapViewport.center + ? [mapViewport.center.lon, mapViewport.center.lat] + : markers.length + ? [markers[0].lon, markers[0].lat] + : [98.85, 7.9]; + const initZoom = mapViewport.zoom + ?? (markers.length ? 9 : 4); + map = new maplibregl.Map({ container: el, style: STYLE, - center: markers.length ? [markers[0].lon, markers[0].lat] : [98.85, 7.9], - zoom: markers.length ? 9 : 4 + center: initCenter, + zoom: initZoom + }); + + // Write viewport changes back to the shared store on every move/zoom. + map.on('moveend', () => { + if (!map) return; + const c = map.getCenter(); + mapViewport.center = { lat: c.lat, lon: c.lng }; + mapViewport.zoom = map.getZoom(); }); if (interactive) { @@ -89,6 +162,8 @@ } renderMarkersAndLine(); + renderWindChevrons(); + startGeolocation(); // Re-measure when the container itself resizes (e.g. fullscreen toggle, // orientation change). maplibre listens to window resize but not @@ -105,6 +180,9 @@ onDestroy(() => { drawn.forEach((m) => m.remove()); + drawnChevrons.forEach((m) => m.remove()); + userLocMarker?.remove(); + if (geoWatchId !== null) navigator.geolocation.clearWatch(geoWatchId); resizeObs?.disconnect(); resizeObs = null; map?.remove(); @@ -121,6 +199,39 @@ renderMarkersAndLine(); }); + $effect(() => { + void windChevrons; + renderWindChevrons(); + }); + + function buildUserDot(): HTMLElement { + const dot = document.createElement('div'); + dot.className = 'user-loc-dot'; + dot.setAttribute('aria-label', 'Your location'); + dot.innerHTML = '
'; + return dot; + } + + function startGeolocation() { + if (!showUserLocation || !map || !('geolocation' in navigator)) return; + geoWatchId = navigator.geolocation.watchPosition( + (pos) => { + if (!map) return; + const lng = pos.coords.longitude; + const lat = pos.coords.latitude; + if (!userLocMarker) { + userLocMarker = new maplibregl.Marker({ element: buildUserDot(), anchor: 'center' }) + .setLngLat([lng, lat]) + .addTo(map); + } else { + userLocMarker.setLngLat([lng, lat]); + } + }, + () => {}, + { enableHighAccuracy: true, maximumAge: 10_000, timeout: 15_000 } + ); + } + function renderMarkersAndLine() { if (!map) return; drawn.forEach((m) => m.remove()); @@ -150,17 +261,22 @@ 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 }); + // Only fit bounds when the markers/polyline actually change (new route). + // If the user panned or zoomed manually (or the shared viewport is set), + // don't override their position on every re-render. + if (!suppressAutoFit) { + const fitTarget = polyline && polyline.length >= 2 ? polyline : markers; + const fitKey = fitTarget.map((p) => `${p.lat.toFixed(4)},${p.lon.toFixed(4)}`).join('|'); + if (fitKey && fitKey !== lastFitKey) { + lastFitKey = fitKey; + 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 @@ -173,6 +289,50 @@ } } + function buildChevronElement(c: WindChevron): HTMLElement { + // Where the wind is going in absolute compass terms (meteo windDir is + // the direction the wind is FROM, so the arrow points to windDir + 180). + const arrowRot = c.windDirDeg + 180; + const speed = Math.round(c.windKn); + const wrap = document.createElement('div'); + wrap.className = 'wind-chevron'; + wrap.dataset.cls = c.cls; + const tooltipParts = [ + `${c.classLabel} · ${speed} kn`, + `wind from ${Math.round(((c.windDirDeg % 360) + 360) % 360)}°`, + `heading ${Math.round(((c.headingDeg % 360) + 360) % 360)}°`, + `relative ${Math.round(c.relWindDeg)}°` + ]; + const tooltip = tooltipParts.join(' · '); + wrap.setAttribute('title', tooltip); + wrap.setAttribute('aria-label', tooltip); + wrap.setAttribute('role', 'img'); + wrap.innerHTML = ` +
+ +
+
${speed}kn
+ `; + return wrap; + } + + function renderWindChevrons() { + if (!map) return; + drawnChevrons.forEach((m) => m.remove()); + drawnChevrons = []; + if (!windChevrons || windChevrons.length === 0) return; + for (const c of windChevrons) { + const element = buildChevronElement(c); + const marker = new maplibregl.Marker({ element, anchor: 'center' }) + .setLngLat([c.point.lon, c.point.lat]) + .addTo(map); + drawnChevrons.push(marker); + } + } + function renderRouteLayer() { if (!map) return; if (map.getLayer('route-line')) map.removeLayer('route-line'); @@ -200,9 +360,78 @@ } -
+
+
+ {#if interactive} + + {/if} +
diff --git a/weather-voodoo/src/lib/components/RouteView.svelte b/weather-voodoo/src/lib/components/RouteView.svelte index 766d372..015f8e4 100644 --- a/weather-voodoo/src/lib/components/RouteView.svelte +++ b/weather-voodoo/src/lib/components/RouteView.svelte @@ -3,13 +3,17 @@ import PlaceSearch from './PlaceSearch.svelte'; import PlacesChips from './PlacesChips.svelte'; import MapView from './MapView.svelte'; + import WindMapOverlay from './WindMapOverlay.svelte'; + import WindCompass from './WindCompass.svelte'; import DayTabs from './DayTabs.svelte'; import ForecastTable from './ForecastTable.svelte'; import TripFinder from './TripFinder.svelte'; import { filterHoursForDay, localIsoDate } from '$lib/time'; import { addRecent } from '$lib/client/recentPlaces.svelte'; import { t } from '$lib/i18n/index.svelte'; - import type { DaylightDay, FusedHour, LabeledPoint, DayKey } from '$lib/types'; + import { chevronsForHour, pickNowHour, worstClass } from '$lib/wind-map'; + import type { RelativeWindClass } from '$lib/wind'; + import type { DaylightDay, FusedHour, LabeledPoint, DayKey, WindSample } from '$lib/types'; let loading = $state(false); let error = $state(null); @@ -23,8 +27,13 @@ daylight: DaylightDay[]; polyline: { lat: number; lon: number }[]; route: RouteMeta; + windSamples: WindSample[]; } | null>(null); + // Currently-selected hour for the on-map wind chevron overlay. null = auto + // (use the first hour >= now). Resets when a new route loads. + let mapHour = $state(null); + const markers = $derived( [view.from, view.to].filter((m): m is LabeledPoint => m !== null) ); @@ -58,6 +67,7 @@ daylight?: DaylightDay[]; polyline?: { lat: number; lon: number }[]; route?: RouteMeta; + windSamples?: WindSample[]; }; result = { hours: data.hours, @@ -67,8 +77,11 @@ { lat: from.lat, lon: from.lon }, { lat: to.lat, lon: to.lon } ], - route: data.route ?? { kind: 'straight' } + route: data.route ?? { kind: 'straight' }, + windSamples: data.windSamples ?? [] }; + // Reset scrubber to "now" whenever a new route comes in. + mapHour = null; }) .catch((e: unknown) => { if (e instanceof DOMException && e.name === 'AbortError') return; @@ -87,6 +100,34 @@ ); const activeMode = $derived(eff.mode); + // Wind-overlay scrubber state. + const hourTimes = $derived(result?.windSamples?.[0]?.hours.map((h) => h.time) ?? []); + const nowTime = $derived(hourTimes.length > 0 ? pickNowHour(hourTimes) : null); + const selectedTime = $derived(mapHour ?? nowTime ?? hourTimes[0] ?? ''); + const classLabelMap = $derived>({ + head: t('wind.head'), + 'head-cross': t('wind.headCross'), + cross: t('wind.cross'), + 'tail-cross': t('wind.tailCross'), + tail: t('wind.tail') + }); + const chevrons = $derived( + result && selectedTime + ? chevronsForHour(result.windSamples, selectedTime, (c) => classLabelMap[c]) + : [] + ); + const verdictKeyMap = $derived>({ + head: 'windMap.verdict.head', + 'head-cross': 'windMap.verdict.headCross', + cross: 'windMap.verdict.cross', + 'tail-cross': 'windMap.verdict.tailCross', + tail: 'windMap.verdict.tail' + }); + const verdict = $derived.by(() => { + const w = worstClass(chevrons); + return w ? t(verdictKeyMap[w]) : undefined; + }); + let lastFocused: 'from' | 'to' | null = $state(null); function pickFrom(p: LabeledPoint) { @@ -119,6 +160,14 @@ view.day = d; } + let compassVisible = $state( + typeof localStorage !== 'undefined' ? localStorage.getItem('wx-compass') !== 'hidden' : true + ); + function setCompassVisible(v: boolean) { + compassVisible = v; + if (typeof localStorage !== 'undefined') localStorage.setItem('wx-compass', v ? 'visible' : 'hidden'); + } + let fullscreen = $state(false); function toggleFullscreen() { fullscreen = !fullscreen; @@ -176,7 +225,44 @@
- + 0} + /> + {#if chevrons.length > 0 && hourTimes.length > 0 && selectedTime} + (mapHour = t)} + /> + {/if} + {#if chevrons.length > 0} +
+ {#if compassVisible} + setCompassVisible(false)} + /> + {:else} + + {/if} +
+ {/if} {#if loading && view.from && view.to}
@@ -258,6 +344,31 @@ .map-card { position: relative; } + .wc-show-btn { + all: unset; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + background: rgba(15, 23, 42, 0.88); + border: 1.5px solid rgba(148, 163, 184, 0.25); + border-radius: 50%; + cursor: pointer; + filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5)); + } + .wc-show-btn:hover { + background: rgba(15, 23, 42, 0.95); + border-color: rgba(255, 255, 255, 0.3); + } + .wind-compass-anchor { + position: absolute; + bottom: 14px; + left: 14px; + z-index: 12; + pointer-events: auto; + } .map-loading { position: absolute; top: 0.6rem; diff --git a/weather-voodoo/src/lib/components/WaypointsView.svelte b/weather-voodoo/src/lib/components/WaypointsView.svelte index 5133e27..77f3a78 100644 --- a/weather-voodoo/src/lib/components/WaypointsView.svelte +++ b/weather-voodoo/src/lib/components/WaypointsView.svelte @@ -1,12 +1,16 @@ + +
+ + + +
(showDetail = !showDetail)} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') showDetail = !showDetail; }} + role="button" + tabindex="0" + aria-label="{classLabel} · {Math.round(windKn)} kn" + > + +
{classLabel}
+
+
+ +{#if showDetail} + +
(showDetail = false)} + onkeydown={(e) => { if (e.key === 'Escape') showDetail = false; }} + > + +
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}> +
+ {t('wind.head')}/{t('wind.tail')} + {classLabel} +
+
+ {t('table.windGust')} + {Math.round(windKn)} kn +
+
+ {t('windMap.verdict.relAngle')} + {Math.round(relWindDeg)}° +
+ +
+
+{/if} + + diff --git a/weather-voodoo/src/lib/components/WindIndicator.svelte b/weather-voodoo/src/lib/components/WindIndicator.svelte index 58aa140..4c12a5d 100644 --- a/weather-voodoo/src/lib/components/WindIndicator.svelte +++ b/weather-voodoo/src/lib/components/WindIndicator.svelte @@ -54,24 +54,24 @@ transform-origin: 50% 50%; } .rw[data-cls='head'] { - background: rgba(248, 113, 113, 0.18); - color: #fca5a5; + background: rgba(255, 85, 85, 0.22); + color: #ff8888; } .rw[data-cls='head-cross'] { - background: rgba(251, 146, 60, 0.18); - color: #fdba74; + background: rgba(180, 83, 9, 0.18); + color: #d97706; } .rw[data-cls='cross'] { - background: rgba(148, 163, 184, 0.18); - color: #cbd5e1; + background: rgba(100, 116, 139, 0.18); + color: #94a3b8; } .rw[data-cls='tail-cross'] { - background: rgba(132, 204, 22, 0.2); - color: #bef264; + background: rgba(101, 163, 13, 0.18); + color: #84cc16; } .rw[data-cls='tail'] { - background: rgba(34, 197, 94, 0.22); - color: #86efac; + background: rgba(74, 222, 128, 0.25); + color: #4ade80; } @media (max-width: 720px) { .rw { diff --git a/weather-voodoo/src/lib/components/WindMapOverlay.svelte b/weather-voodoo/src/lib/components/WindMapOverlay.svelte new file mode 100644 index 0000000..7697b8b --- /dev/null +++ b/weather-voodoo/src/lib/components/WindMapOverlay.svelte @@ -0,0 +1,245 @@ + + +
+
+ + + +
+ {#if verdict} +
{verdict}
+ {/if} +
+ + diff --git a/weather-voodoo/src/lib/i18n/en.ts b/weather-voodoo/src/lib/i18n/en.ts index 8e7f276..51dfa38 100644 --- a/weather-voodoo/src/lib/i18n/en.ts +++ b/weather-voodoo/src/lib/i18n/en.ts @@ -77,6 +77,8 @@ export type Dict = { }; waypoints: { editHelp: string; + editHelpShort: string; + editHelpTitle: string; committedHelp: string; cancel: string; done: string; @@ -302,6 +304,24 @@ export type Dict = { tail: string; relativeTooltip: string; }; + windMap: { + regionLabel: string; + now: string; + nowTitle: string; + jumpNowTitle: string; + prevHour: string; + nextHour: string; + hideCompass: string; + showCompass: string; + relAngle: string; + verdict: { + head: string; + headCross: string; + cross: string; + tailCross: string; + tail: string; + }; + }; }; export const en: Dict = { @@ -390,6 +410,8 @@ export const en: Dict = { }, waypoints: { editHelp: 'Tap the map to add a waypoint. To edit one, tap its red marker on the map or tap its chip in the list below — reorder, delete and move-to options open in a dialog. You can also drag a red marker directly (long-press on mobile). Straight-line preview shown while editing — press ✓ Done to compute the real route and forecast.', + editHelpShort: 'Tap the map to add waypoints. Press ✓ Done when ready.', + editHelpTitle: 'How to use waypoints', committedHelp: 'Track committed. Press ✎ Change waypoints to edit.', cancel: 'Cancel', done: '✓ Done', @@ -623,5 +645,23 @@ export const en: Dict = { tailCross: 'Tail-Cross', tail: 'Tail', relativeTooltip: '{cls} wind ({deg}° relative to direction of travel)' + }, + windMap: { + regionLabel: 'Wind along route — use arrow keys to scrub through hours', + now: 'NOW', + nowTitle: 'Showing wind for the current hour', + jumpNowTitle: 'Jump back to the current hour', + prevHour: 'Previous hour', + nextHour: 'Next hour', + hideCompass: 'Hide wind compass', + showCompass: 'Show wind compass', + relAngle: 'Relative angle', + verdict: { + head: 'Head wind along the route — tough going', + headCross: 'Head-cross wind — fighting it', + cross: 'Cross wind — watch your balance', + tailCross: 'Tail-cross wind — pushing you along', + tail: 'Tail wind — free speed' + } } }; diff --git a/weather-voodoo/src/lib/i18n/ro.ts b/weather-voodoo/src/lib/i18n/ro.ts index 9f21b87..588693c 100644 --- a/weather-voodoo/src/lib/i18n/ro.ts +++ b/weather-voodoo/src/lib/i18n/ro.ts @@ -86,6 +86,8 @@ export const ro: Dict = { }, waypoints: { editHelp: 'Apasă pe hartă pentru a adăuga un punct de trecere. Pentru a edita unul, apasă pinul roșu de pe hartă sau apasă chip-ul din lista de mai jos — opțiunile de reordonare, ștergere și mutare se deschid într-un dialog. Poți și trage direct un pin roșu (apăsare lungă pe mobil). Previzualizare în linie dreaptă în timpul editării — apasă ✓ Gata pentru a calcula ruta și prognoza reale.', + editHelpShort: 'Apasă pe hartă pentru a adăuga puncte. Apasă ✓ Gata când ești pregătit.', + editHelpTitle: 'Cum se folosesc punctele de trecere', committedHelp: 'Traseu confirmat. Apasă ✎ Modifică punctele pentru a edita.', cancel: 'Anulează', done: '✓ Gata', @@ -319,5 +321,23 @@ export const ro: Dict = { tailCross: 'Spate-lateral', tail: 'Spate', relativeTooltip: '{cls} ({deg}° față de direcția de deplasare)' + }, + windMap: { + regionLabel: 'Vânt de-a lungul rutei — folosește săgețile pentru a parcurge orele', + now: 'ACUM', + nowTitle: 'Se afișează vântul pentru ora curentă', + jumpNowTitle: 'Revino la ora curentă', + prevHour: 'Ora anterioară', + nextHour: 'Ora următoare', + hideCompass: 'Ascunde busola vânt', + showCompass: 'Arată busola vânt', + relAngle: 'Unghi relativ', + verdict: { + head: 'Vânt din față pe traseu — pedalat greu', + headCross: 'Vânt din față-lateral — te lupți cu el', + cross: 'Vânt lateral — atenție la echilibru', + tailCross: 'Vânt din spate-lateral — te împinge înainte', + tail: 'Vânt din spate — viteză gratuită' + } } }; diff --git a/weather-voodoo/src/lib/i18n/th.ts b/weather-voodoo/src/lib/i18n/th.ts index b653b39..5b7102a 100644 --- a/weather-voodoo/src/lib/i18n/th.ts +++ b/weather-voodoo/src/lib/i18n/th.ts @@ -86,6 +86,8 @@ export const th: Dict = { }, waypoints: { editHelp: 'แตะแผนที่เพื่อเพิ่มจุดผ่าน หากต้องการแก้ไข แตะหมุดสีแดงบนแผนที่หรือแตะชิปในรายการด้านล่าง — ตัวเลือกจัดลำดับ ลบ และย้ายจะเปิดในกล่องโต้ตอบ คุณสามารถลากหมุดสีแดงโดยตรงได้ (กดค้างบนมือถือ) แสดงเส้นตรงตัวอย่างขณะแก้ไข — กด ✓ เสร็จ เพื่อคำนวณเส้นทางและพยากรณ์จริง', + editHelpShort: 'แตะแผนที่เพื่อเพิ่มจุดผ่าน กด ✓ เสร็จ เมื่อพร้อม', + editHelpTitle: 'วิธีใช้จุดผ่าน', committedHelp: 'บันทึกเส้นทางแล้ว กด ✎ เปลี่ยนจุดผ่าน เพื่อแก้ไข', cancel: 'ยกเลิก', done: '✓ เสร็จ', @@ -319,5 +321,23 @@ export const th: Dict = { tailCross: 'ลมส่งเฉียง', tail: 'ลมส่ง', relativeTooltip: '{cls} ({deg}° เทียบกับทิศทางการเดินทาง)' + }, + windMap: { + regionLabel: 'ลมตามเส้นทาง — ใช้ปุ่มลูกศรเพื่อเลื่อนชั่วโมง', + now: 'ตอนนี้', + nowTitle: 'กำลังแสดงลมสำหรับชั่วโมงปัจจุบัน', + jumpNowTitle: 'กลับไปยังชั่วโมงปัจจุบัน', + prevHour: 'ชั่วโมงก่อนหน้า', + nextHour: 'ชั่วโมงถัดไป', + hideCompass: 'ซ่อนเข็มทิศลม', + showCompass: 'แสดงเข็มทิศลม', + relAngle: 'มุมสัมพัทธ์', + verdict: { + head: 'ลมต้านตลอดเส้นทาง — ปั่นยาก', + headCross: 'ลมต้านเฉียง — ฝืนพอตัว', + cross: 'ลมข้าง — ระวังการทรงตัว', + tailCross: 'ลมส่งเฉียง — ดันคุณไปข้างหน้า', + tail: 'ลมส่ง — ฟรีแรง' + } } }; diff --git a/weather-voodoo/src/lib/map-viewport.svelte.ts b/weather-voodoo/src/lib/map-viewport.svelte.ts new file mode 100644 index 0000000..3fc7fd6 --- /dev/null +++ b/weather-voodoo/src/lib/map-viewport.svelte.ts @@ -0,0 +1,15 @@ +/** + * Shared reactive map viewport — persists center + zoom across tab switches + * (Route / Fixed / Waypoints) so the user doesn't lose their position when + * switching views. Not URL-serialized (viewport is ephemeral). + */ + +let _center = $state<{ lat: number; lon: number } | null>(null); +let _zoom = $state(null); + +export const mapViewport = { + get center() { return _center; }, + set center(v) { _center = v; }, + get zoom() { return _zoom; }, + set zoom(v) { _zoom = v; }, +}; diff --git a/weather-voodoo/src/lib/types.ts b/weather-voodoo/src/lib/types.ts index 762dc7c..dae7519 100644 --- a/weather-voodoo/src/lib/types.ts +++ b/weather-voodoo/src/lib/types.ts @@ -73,6 +73,18 @@ export type FusedHour = ForecastHour & { relWindDeg?: number; }; +/** + * Per-sample-point wind timeseries returned by the route APIs. Powers the + * on-map wind chevron overlay (#37 sketch 3) — one chevron per sample point, + * oriented and colored by the wind at the currently-selected hour. + */ +export type WindSample = { + point: LatLng; + /** Direction of travel at this sample point, in degrees clockwise from north. */ + headingDeg: number; + hours: { time: string; windDirDeg: number; windKn: number; gustKn: number }[]; +}; + export type DaylightDay = { date: string; sunrise: string; sunset: string }; export type Activity = diff --git a/weather-voodoo/src/lib/wind-map.ts b/weather-voodoo/src/lib/wind-map.ts new file mode 100644 index 0000000..6957b26 --- /dev/null +++ b/weather-voodoo/src/lib/wind-map.ts @@ -0,0 +1,70 @@ +/** + * Helpers that turn the API's per-sample-point wind timeseries into the + * data shape MapView's chevron overlay consumes (and a small verdict string + * for the scrubber overlay). + */ +import type { WindChevron } from './components/MapView.svelte'; +import type { WindSample } from './types'; +import { classifyRelativeWind, relativeWindDeg, type RelativeWindClass } from './wind'; + +/** + * Pick the chevron data for a specific hour. Returns null if the hour is not + * present in any sample's timeseries (e.g. samples disagree on hour range). + */ +export function chevronsForHour( + samples: WindSample[], + time: string, + classLabel: (cls: RelativeWindClass) => string +): WindChevron[] { + const out: WindChevron[] = []; + for (const s of samples) { + const h = s.hours.find((x) => x.time === time); + if (!h) continue; + const rel = relativeWindDeg(h.windDirDeg, s.headingDeg); + const cls = classifyRelativeWind(rel); + out.push({ + point: s.point, + headingDeg: s.headingDeg, + windDirDeg: h.windDirDeg, + windKn: h.windKn, + relWindDeg: rel, + cls, + classLabel: classLabel(cls) + }); + } + return out; +} + +/** + * Pick the default scrubber hour: the first hour timestamp that is + * >= the current wall clock. Falls back to the first hour if all are past. + * Hour strings are local ISO ("YYYY-MM-DDTHH:00") in the destination's + * timezone; for picking "now" we compare to local UTC-ish wall clock which + * is good enough for the common case where the user is at/near the route. + */ +export function pickNowHour(hourTimes: string[]): string | null { + if (hourTimes.length === 0) return null; + const now = new Date(); + // Match local-ish format YYYY-MM-DDTHH:00 against `now`. + const pad = (n: number) => n.toString().padStart(2, '0'); + const nowIso = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:00`; + for (const t of hourTimes) { + if (t >= nowIso) return t; + } + return hourTimes[0]; +} + +/** + * Tiny natural-language verdict for the scrubber: the worst relative-wind + * class observed across all chevrons at the selected hour. Picks vocabulary + * keyed by class — caller maps to localized strings. + */ +export function worstClass(chevrons: WindChevron[]): RelativeWindClass | null { + if (chevrons.length === 0) return null; + const order: RelativeWindClass[] = ['head', 'head-cross', 'cross', 'tail-cross', 'tail']; + let worst: RelativeWindClass = 'tail'; + for (const c of chevrons) { + if (order.indexOf(c.cls) < order.indexOf(worst)) worst = c.cls; + } + return worst; +} diff --git a/weather-voodoo/src/routes/api/multi-route/+server.ts b/weather-voodoo/src/routes/api/multi-route/+server.ts index 129b33f..5589eac 100644 --- a/weather-voodoo/src/routes/api/multi-route/+server.ts +++ b/weather-voodoo/src/routes/api/multi-route/+server.ts @@ -107,6 +107,16 @@ export const GET: RequestHandler = async ({ url }) => { }) ); const hours = fuseRoute(results); + const windSamples = results.map((r, i) => ({ + point: samplePoints[i], + headingDeg: sampleHeadings[i], + hours: r.forecast.map((h) => ({ + time: h.time, + windDirDeg: h.windDirDeg, + windKn: h.windKn, + gustKn: h.gustKn + })) + })); return json( { timezone: results[0]?.timezone ?? 'UTC', @@ -114,6 +124,7 @@ export const GET: RequestHandler = async ({ url }) => { hours, daylight: results[0]?.daylight ?? [], polyline, + windSamples, route: { kind: 'waypoints', legCount: legs.length, diff --git a/weather-voodoo/src/routes/api/route/+server.ts b/weather-voodoo/src/routes/api/route/+server.ts index 9e26132..3c56853 100644 --- a/weather-voodoo/src/routes/api/route/+server.ts +++ b/weather-voodoo/src/routes/api/route/+server.ts @@ -94,6 +94,16 @@ export const GET: RequestHandler = async ({ url }) => { }) ); const hours = fuseRoute(results); + const windSamples = results.map((r, i) => ({ + point: points[i], + headingDeg: headings[i], + hours: r.forecast.map((h) => ({ + time: h.time, + windDirDeg: h.windDirDeg, + windKn: h.windKn, + gustKn: h.gustKn + })) + })); return json( { timezone: results[0]?.timezone ?? 'UTC', @@ -101,7 +111,8 @@ export const GET: RequestHandler = async ({ url }) => { hours, daylight: results[0]?.daylight ?? [], polyline: routePolyline, - route: routeMeta + route: routeMeta, + windSamples }, { headers: { 'cache-control': 'public, s-maxage=600, stale-while-revalidate=3600' } } ); diff --git a/weather-voodoo/tests/wind-map.test.ts b/weather-voodoo/tests/wind-map.test.ts new file mode 100644 index 0000000..a5b7c98 --- /dev/null +++ b/weather-voodoo/tests/wind-map.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { chevronsForHour, worstClass } from '../src/lib/wind-map'; +import type { WindSample } from '../src/lib/types'; +import type { RelativeWindClass } from '../src/lib/wind'; + +const labelOf = (c: RelativeWindClass): string => + ({ + head: 'Head', + 'head-cross': 'Head-Cross', + cross: 'Cross', + 'tail-cross': 'Tail-Cross', + tail: 'Tail' + })[c]; + +describe('wind-map', () => { + it('chevronsForHour pairs each sample with its hourly wind and classifies', () => { + const samples: WindSample[] = [ + { + point: { lat: 7.74, lon: 98.78 }, + headingDeg: 0, // heading north + hours: [ + { time: '2026-05-24T08:00', windDirDeg: 0, windKn: 12, gustKn: 18 }, // wind FROM north = head + { time: '2026-05-24T09:00', windDirDeg: 180, windKn: 10, gustKn: 14 } // tail + ] + }, + { + point: { lat: 8.05, lon: 98.81 }, + headingDeg: 0, + hours: [ + { time: '2026-05-24T08:00', windDirDeg: 90, windKn: 14, gustKn: 20 }, // cross (right) + { time: '2026-05-24T09:00', windDirDeg: 135, windKn: 8, gustKn: 12 } // tail-cross + ] + } + ]; + const chevrons = chevronsForHour(samples, '2026-05-24T08:00', labelOf); + expect(chevrons.length).toBe(2); + expect(chevrons[0].cls).toBe('head'); + expect(chevrons[0].classLabel).toBe('Head'); + expect(chevrons[1].cls).toBe('cross'); + }); + + it('chevronsForHour skips samples that have no matching hour', () => { + const samples: WindSample[] = [ + { + point: { lat: 0, lon: 0 }, + headingDeg: 0, + hours: [{ time: '2026-05-24T08:00', windDirDeg: 0, windKn: 10, gustKn: 15 }] + }, + { + point: { lat: 1, lon: 1 }, + headingDeg: 0, + hours: [] // empty + } + ]; + const chevrons = chevronsForHour(samples, '2026-05-24T08:00', labelOf); + expect(chevrons.length).toBe(1); + }); + + it('worstClass picks the most head-on class across chevrons', () => { + expect(worstClass([])).toBeNull(); + const samples: WindSample[] = [ + { + point: { lat: 0, lon: 0 }, + headingDeg: 0, + hours: [ + { time: '2026-05-24T08:00', windDirDeg: 180, windKn: 8, gustKn: 12 }, // tail + { time: '2026-05-24T09:00', windDirDeg: 45, windKn: 8, gustKn: 12 } // head-cross + ] + }, + { + point: { lat: 1, lon: 1 }, + headingDeg: 0, + hours: [ + { time: '2026-05-24T08:00', windDirDeg: 0, windKn: 10, gustKn: 15 }, // head + { time: '2026-05-24T09:00', windDirDeg: 170, windKn: 10, gustKn: 15 } // tail + ] + } + ]; + expect(worstClass(chevronsForHour(samples, '2026-05-24T08:00', labelOf))).toBe('head'); + expect(worstClass(chevronsForHour(samples, '2026-05-24T09:00', labelOf))).toBe('head-cross'); + }); +});