@@ -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');
+ });
+});