From 6e284418e1a15a7c8b35a18603c4743c3300f4b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 09:38:13 +0000 Subject: [PATCH 01/11] Wind chevron overlay on the route map (cyclist-first UX) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on #38. Puts the relative-wind read-out where you actually need it while riding/sailing: directly on the route map, glanceable at arm's length from a handlebar-mounted phone. What you see: * Big (48 px) chevrons at each route sample point. Colored ring (red head → orange head-cross → grey cross → lime tail-cross → green tail) carries the meaning; white arrow inside shows where the wind is going in absolute terms (so the geometry stays intuitive on the map). Dark backdrop + drop-shadow keeps them readable on any tile background — bright sun, water, forest. * Bold wind speed label below each chevron ("12kn") with tabular numerals. * "NOW · TUE 14:00" scrubber pill at the top of the map. ◀ ▶ buttons (48 px tap target) advance one hour at a time; the label is also a button — tap it to jump back to "now". A green "NOW" badge appears when the selected hour is the current one. * One-line verdict under the pill summarizing the worst-case class along the route ("Head wind along the route — tough going", "Tail wind — free speed", …). Wiring: * `/api/route` and `/api/multi-route` now return `windSamples` — per-sample-point, per-hour `{ time, windDirDeg, windKn, gustKn }`. * `WindSample` type added to types.ts. * `wind-map.ts` (new): `chevronsForHour()` builds the per-current- hour data shape MapView consumes; `pickNowHour()` picks the first forecast hour ≥ now; `worstClass()` feeds the scrubber verdict. * `MapView` gains a `windChevrons` prop and a separate render effect — chevrons re-render on hour scrub without touching markers or the polyline. * `WindMapOverlay.svelte` (new) — the scrubber pill + verdict line, absolutely positioned over the map. Big touch targets, backdrop-blur, hidden timezone label on narrow screens. * `RouteView` + `WaypointsView` wire it up; the chevron set is derived from the selected hour and re-renders reactively. Hidden while a Waypoints track is being edited (the polyline isn't real yet). i18n: new `windMap.{regionLabel, now, nowTitle, jumpNowTitle, prevHour, nextHour, verdict.*}` keys added to en / th / ro. Tests: 3 new for `chevronsForHour` and `worstClass`. 91/91 pass. Refs #37. https://claude.ai/code/session_01B9DMrLPT7hYfdCTsJgFF9q --- .../src/lib/components/MapView.svelte | 165 +++++++++++- .../src/lib/components/RouteView.svelte | 62 ++++- .../src/lib/components/WaypointsView.svelte | 55 +++- .../src/lib/components/WindMapOverlay.svelte | 245 ++++++++++++++++++ weather-voodoo/src/lib/i18n/en.ts | 30 +++ weather-voodoo/src/lib/i18n/ro.ts | 15 ++ weather-voodoo/src/lib/i18n/th.ts | 15 ++ weather-voodoo/src/lib/types.ts | 12 + weather-voodoo/src/lib/wind-map.ts | 70 +++++ .../src/routes/api/multi-route/+server.ts | 11 + .../src/routes/api/route/+server.ts | 13 +- weather-voodoo/tests/wind-map.test.ts | 82 ++++++ 12 files changed, 768 insertions(+), 7 deletions(-) create mode 100644 weather-voodoo/src/lib/components/WindMapOverlay.svelte create mode 100644 weather-voodoo/src/lib/wind-map.ts create mode 100644 weather-voodoo/tests/wind-map.test.ts diff --git a/weather-voodoo/src/lib/components/MapView.svelte b/weather-voodoo/src/lib/components/MapView.svelte index b4f252b..7243513 100644 --- a/weather-voodoo/src/lib/components/MapView.svelte +++ b/weather-voodoo/src/lib/components/MapView.svelte @@ -3,6 +3,22 @@ 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'; + + 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 +33,12 @@ 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[]; }; let { @@ -31,12 +53,14 @@ polylineColor = '#38bdf8', draggableMarkers = false, highlightIdx = null, - height = '440px' + height = '440px', + windChevrons }: Props = $props(); let el: HTMLDivElement | null = null; let map: MlMap | null = null; let drawn: Marker[] = []; + let drawnChevrons: Marker[] = []; const STYLE = { version: 8 as const, @@ -89,6 +113,7 @@ } renderMarkersAndLine(); + renderWindChevrons(); // Re-measure when the container itself resizes (e.g. fullscreen toggle, // orientation change). maplibre listens to window resize but not @@ -105,6 +130,7 @@ onDestroy(() => { drawn.forEach((m) => m.remove()); + drawnChevrons.forEach((m) => m.remove()); resizeObs?.disconnect(); resizeObs = null; map?.remove(); @@ -121,6 +147,11 @@ renderMarkersAndLine(); }); + $effect(() => { + void windChevrons; + renderWindChevrons(); + }); + function renderMarkersAndLine() { if (!map) return; drawn.forEach((m) => m.remove()); @@ -173,6 +204,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'); @@ -211,4 +286,92 @@ transform: scale(1.3); transform-origin: center bottom; } + + /* + * Wind chevrons — sized for handlebar-mounted glance use. Dark backdrop + * survives bright sun and water/land tile transitions; the colored ring + * carries the head/tail/cross meaning while the white arrow stays readable + * against any background. + */ + :global(.wind-chevron) { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + pointer-events: auto; + cursor: help; + user-select: none; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.55)); + transition: transform 160ms ease; + } + :global(.wind-chevron:hover) { + transform: scale(1.08); + z-index: 30; + } + :global(.wind-chevron .wc-ring) { + width: 48px; + height: 48px; + border-radius: 50%; + background: rgba(15, 23, 42, 0.88); + border: 4px solid var(--wc-color, #cbd5e1); + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + } + :global(.wind-chevron .wc-ring svg) { + width: 28px; + height: 28px; + transition: transform 220ms ease; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.7)); + } + :global(.wind-chevron .wc-label) { + font-size: 12px; + font-weight: 800; + color: #fff; + line-height: 1; + padding: 2px 6px; + background: rgba(15, 23, 42, 0.9); + border-radius: 6px; + border: 1.5px solid var(--wc-color, #cbd5e1); + font-variant-numeric: tabular-nums; + letter-spacing: 0.2px; + white-space: nowrap; + } + :global(.wind-chevron .wc-unit) { + font-size: 9px; + font-weight: 600; + opacity: 0.8; + margin-left: 1px; + } + :global(.wind-chevron[data-cls='head']) { + --wc-color: #f87171; + } + :global(.wind-chevron[data-cls='head-cross']) { + --wc-color: #fb923c; + } + :global(.wind-chevron[data-cls='cross']) { + --wc-color: #94a3b8; + } + :global(.wind-chevron[data-cls='tail-cross']) { + --wc-color: #a3e635; + } + :global(.wind-chevron[data-cls='tail']) { + --wc-color: #22c55e; + } + @media (max-width: 720px) { + :global(.wind-chevron .wc-ring) { + width: 42px; + height: 42px; + border-width: 3px; + } + :global(.wind-chevron .wc-ring svg) { + width: 24px; + height: 24px; + } + :global(.wind-chevron .wc-label) { + font-size: 11px; + padding: 1.5px 5px; + } + } diff --git a/weather-voodoo/src/lib/components/RouteView.svelte b/weather-voodoo/src/lib/components/RouteView.svelte index 766d372..00e7931 100644 --- a/weather-voodoo/src/lib/components/RouteView.svelte +++ b/weather-voodoo/src/lib/components/RouteView.svelte @@ -3,13 +3,16 @@ import PlaceSearch from './PlaceSearch.svelte'; import PlacesChips from './PlacesChips.svelte'; import MapView from './MapView.svelte'; + import WindMapOverlay from './WindMapOverlay.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 +26,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 +66,7 @@ daylight?: DaylightDay[]; polyline?: { lat: number; lon: number }[]; route?: RouteMeta; + windSamples?: WindSample[]; }; result = { hours: data.hours, @@ -67,8 +76,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 +99,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) { @@ -176,7 +216,23 @@
- + 0 ? chevrons : undefined} + /> + {#if chevrons.length > 0 && hourTimes.length > 0 && selectedTime} + (mapHour = t)} + /> + {/if} {#if loading && view.from && view.to}
diff --git a/weather-voodoo/src/lib/components/WaypointsView.svelte b/weather-voodoo/src/lib/components/WaypointsView.svelte index 5133e27..5d0257e 100644 --- a/weather-voodoo/src/lib/components/WaypointsView.svelte +++ b/weather-voodoo/src/lib/components/WaypointsView.svelte @@ -1,12 +1,15 @@ + +
+
+ + + +
+ {#if verdict} +
{verdict}
+ {/if} +
+ + diff --git a/weather-voodoo/src/lib/i18n/en.ts b/weather-voodoo/src/lib/i18n/en.ts index 8e7f276..630b5ab 100644 --- a/weather-voodoo/src/lib/i18n/en.ts +++ b/weather-voodoo/src/lib/i18n/en.ts @@ -302,6 +302,21 @@ export type Dict = { tail: string; relativeTooltip: string; }; + windMap: { + regionLabel: string; + now: string; + nowTitle: string; + jumpNowTitle: string; + prevHour: string; + nextHour: string; + verdict: { + head: string; + headCross: string; + cross: string; + tailCross: string; + tail: string; + }; + }; }; export const en: Dict = { @@ -623,5 +638,20 @@ 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', + 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..0fa7b4b 100644 --- a/weather-voodoo/src/lib/i18n/ro.ts +++ b/weather-voodoo/src/lib/i18n/ro.ts @@ -319,5 +319,20 @@ 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', + 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..de3f84d 100644 --- a/weather-voodoo/src/lib/i18n/th.ts +++ b/weather-voodoo/src/lib/i18n/th.ts @@ -319,5 +319,20 @@ export const th: Dict = { tailCross: 'ลมส่งเฉียง', tail: 'ลมส่ง', relativeTooltip: '{cls} ({deg}° เทียบกับทิศทางการเดินทาง)' + }, + windMap: { + regionLabel: 'ลมตามเส้นทาง — ใช้ปุ่มลูกศรเพื่อเลื่อนชั่วโมง', + now: 'ตอนนี้', + nowTitle: 'กำลังแสดงลมสำหรับชั่วโมงปัจจุบัน', + jumpNowTitle: 'กลับไปยังชั่วโมงปัจจุบัน', + prevHour: 'ชั่วโมงก่อนหน้า', + nextHour: 'ชั่วโมงถัดไป', + verdict: { + head: 'ลมต้านตลอดเส้นทาง — ปั่นยาก', + headCross: 'ลมต้านเฉียง — ฝืนพอตัว', + cross: 'ลมข้าง — ระวังการทรงตัว', + tailCross: 'ลมส่งเฉียง — ดันคุณไปข้างหน้า', + tail: 'ลมส่ง — ฟรีแรง' + } } }; 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'); + }); +}); From cefd1377ccbccb489d0c0208ce8df8a4892d19dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 12:08:57 +0000 Subject: [PATCH 02/11] Add wind compass rose overlay on the route map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live SVG compass widget in the bottom-left of the map showing the relative-wind classification zones as colored arcs (red head → green tail), a rotating white arrow pointing where the wind goes relative to heading, wind speed in the center, and the class label below. Zones that match the current wind class glow at full opacity; the others are dimmed to 35% — so the "active" zone pops out. Arrow smoothly transitions on hour scrub (CSS transition on the rotate). Positioned absolutely inside the map-card, survives fullscreen. Shows the first sample point's wind (where the cyclist currently is) for the selected hour. https://claude.ai/code/session_01B9DMrLPT7hYfdCTsJgFF9q --- .../src/lib/components/RouteView.svelte | 18 ++ .../src/lib/components/WaypointsView.svelte | 18 ++ .../src/lib/components/WindCompass.svelte | 154 ++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 weather-voodoo/src/lib/components/WindCompass.svelte diff --git a/weather-voodoo/src/lib/components/RouteView.svelte b/weather-voodoo/src/lib/components/RouteView.svelte index 00e7931..7c5a03c 100644 --- a/weather-voodoo/src/lib/components/RouteView.svelte +++ b/weather-voodoo/src/lib/components/RouteView.svelte @@ -4,6 +4,7 @@ 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'; @@ -233,6 +234,16 @@ onSelect={(t) => (mapHour = t)} /> {/if} + {#if chevrons.length > 0} +
+ +
+ {/if} {#if loading && view.from && view.to}
@@ -314,6 +325,13 @@ .map-card { position: relative; } + .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 5d0257e..b1d0c04 100644 --- a/weather-voodoo/src/lib/components/WaypointsView.svelte +++ b/weather-voodoo/src/lib/components/WaypointsView.svelte @@ -2,6 +2,7 @@ import { view, toggleExpanded, effectiveConfig, setDayOverride, resetDayOverride } from '$lib/state.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'; @@ -365,6 +366,16 @@ {t('route.computing')}
{/if} + {#if chevrons.length > 0} +
+ +
+ {/if} {#if editing && selectedIdx !== null && draft[selectedIdx]}