Commit 5eb78ba
Next-generation architecture: TypeScript, Vite, modes, and the redesigned UI (#14)
* NOTES: mark Phase 0 complete
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* CI: pin node 24 (active LTS, supported to Apr 2028)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Tests: pin current behavior of the derive pipeline
Golden-value tests for reposition, addWind, straightenLegs, averageWind
and Winds.getWindAt with realistic inputs (parameter manoeuvre + 3-leg
pattern + multi-row winds). Safety net for the Phase 1 refactors
(interpolation fix, memoization, Winds -> plain data, core/ extraction).
The two known bugs are deliberately not pinned: interpolation is only
pinned without interpolation or with uniform-direction rows (so the pins
survive the step 2a vector-interpolation fix), and the manoeuvre uses
offsetXFt=300, far from the step 2b offset clamp.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix wind direction interpolation wrapping the wrong way across north
Winds.getWindAt() interpolated direction linearly, so 350->10 degrees
passed through 180 instead of 0. Interpolate the wind vector (u/v
components) between rows instead: direction takes the shortest arc and
speed is blended physically — opposing winds partially cancel, giving a
lower speed than the linear average, which is correct and desired.
Existing interpolation expectations updated accordingly (vector values
instead of independent linear direction/speed); the exact-row pin in
pipeline.test.ts is relaxed to 9 decimals because an exact altitude match
now round-trips through u/v at p=0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix manoeuvre offsetXFt clamp; support zero and negative offsets
createManoeuvrePath() clamped offsetXFt with Math.max(offsetXFt, 3),
silently forcing a 3 ft minimum, because setFinalHeading() derives the
final approach direction from the bearing between the last two points and
breaks down when they coincide (offsetXFt=0 put p2 exactly on p1).
Instead: translate by |offsetXFt| (flipping the bearing for negative
values, i.e. offset to the opposite side of the final approach line), and
for exactly 0 use a 0.01 ft epsilon segment — visually indistinguishable
from zero but keeping the final heading defined for the downstream
rotation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add input validation limits; clamp out-of-range values in the panels
Absurd inputs (e.g. a 1,000,000 ft pattern leg) effectively broke the
app: most numeric fields had no max, the wind table had no validation at
all, and the target heading normalization went negative below -360.
- validation.ts: LIMITS table (internal units) for pattern, manoeuvre,
wind and direction inputs, plus clampNumber() and normalizeDirection()
helpers that never let non-finite values through.
- NumberInput: out-of-range entries show an error helperText and are
clamped into range on blur; invalid values are never propagated raw.
- Pattern/Manoeuvre panels: min/max wired from LIMITS (converted to the
display unit). The manoeuvre "Back" field now accepts 0 and negative
values (matches the offsetXFt fix).
- Wind table: altitude/speed clamped, direction normalized on entry.
- Target heading: proper [0, 360 normalization.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EOF
)
* Memoize the derive pipeline; remove reposition mutation leaks
App.tsx recomputed reposition -> addWind -> straightenLegs -> averageWind
inline on every render (every hover reran the full turf pipeline).
Extract into hooks/useFlightPaths.ts, a useMemo keyed on the actual
inputs (manoeuvre, pattern, target, effective winds, and the three
relevant settings).
reposition() now produces new point objects instead of setting phase and
time/alt by mutating its (translated) points, so memoized inputs can
never be aliased. The c2[i].properties.phase copy loop in App.tsx is
dropped: addWind clones its input points, properties included, which the
step-1 pinning test 'preserves point properties' proves.
Behavior is identical per the pipeline golden tests; a new test pins
that reposition leaves its inputs untouched. Lint warnings 60 -> 57.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Replace Winds/WindRow classes with plain data + pure functions
Winds was a class instance stored in React state, with bound methods and
ad hoc serialization. It becomes a plain WindProfile interface (WindRow
is now just the IWindRow shape) plus pure functions: createWindRow,
createWindProfile, copyProfile, getWindAt, setGroundWind, prepWind
(moved from geo.ts) and interpolateWindRows.
The effectiveWinds clone-and-patch in App.tsx becomes composeWinds():
forecast profile + observed ground row -> effective profile with the
ground source marked as dropzone-observed. The wind summary also no
longer sets 'observed' by mutating the profile's ground row.
All call sites updated (useFetchForecast, useFlightPaths, App,
WindsComponent, OpenMeteo/CSC/Spaceland providers, tests). No behavior
change - pipeline golden tests unchanged and green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Extract pure logic into src/core/
Create src/core/ per ARCHITECTURE.md and move the pure modules out of
util/: geometry.ts (former geo.ts + reposition/averageWind/straightenLegs
from util.ts), pattern.ts, manoeuvre.ts (+ setManoeuvreAltitude), wind.ts,
units.ts (+ metersToFeet/ktsToFps/mphToFps conversion constants, formerly
in geo.ts) and coords.ts (needed by reposition; moving it keeps core
self-contained). Tests moved alongside; util.test.ts redistributed
(reposition/averageWind -> geometry.test.ts, setManoeuvreAltitude ->
manoeuvre.test.ts, CODEC_JSON -> new storage.test.ts against the copy in
util/storage.ts, which App code already used).
Dependency rule (documented in src/core/README.md): nothing in core/ may
import from react, components, hooks, forecast (I/O) or map code. The
wind source identifiers (SOURCE_MANUAL/DZ/OPEN_METEO, ForecastSource)
move into core/wind.ts because WindProfile carries them;
forecast/sources.ts re-exports them, keeping forecastSourceLabel (UI
text) in the forecast layer.
All imports updated directly; no re-export shims. util/ keeps the
impure/app-specific modules (courses, csv, exports, dropzones, storage,
migration, pathStats, validation). No behavior change; pipeline golden
tests unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add versioned storage schemas with validating loaders
Persisted state (target, pattern params, manoeuvre config, settings,
presets, custom courses) is now written as a { schemaVersion, doc }
envelope and read through validating migrate functions. Old or corrupt
localStorage data can no longer crash the app: every migrate*() accepts
unknown JSON and returns a valid document, defaulting missing/invalid
fields, clamping numbers into LIMITS, and dropping garbage array entries.
Legacy (pre-envelope) bare documents are handled as version 0, including
the old {lat, lng} flight-point format for stored manoeuvre tracks.
New pieces:
- src/core/model.ts: canonical defaults (moved from useAppState /
ManoeuvreParametersComponent, which re-export them) + migrate functions
per document, unit-tested against garbage and legacy shapes.
- util/storage.ts createVersionedCodec(): envelope parse/stringify with a
never-throw fallback to migrate(undefined).
- validation.ts and migration.ts move from util/ to core/ (model depends
on both; keeps the core dependency rule intact).
Decisions on ambiguous cases (documented in model.test.ts): presets
without an id get a generated one instead of being dropped (their content
is still recoverable); custom courses without a finite lat/lng ARE
dropped (a course without a location is meaningless); pattern legs are
always padded/truncated to exactly 3 because the UI addresses legs[0..2]
directly.
Note: flip.locations.custom (CustomLocationsComponent), stored manoeuvre
tracks (ManoeuvreTrackComponent) and simple string keys remain
unversioned - candidates for a follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Docs: Phase 1 complete; record implementation follow-ups in backlog
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Move pure map helpers into core (geometry, units, wind)
destinationPoint/bearingBetween join core/geometry; formatDegrees,
formatDistanceFeet and speedGustLabel join core/units; beaufortColor joins
core/wind. MapComponent's local haversineDistanceFt is replaced by the
existing core distanceFeet (same haversine, turf earth radius — sub-ppm
difference, invisible after rounding). Prep for the Phase 2 map
layerization.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Introduce src/map: provider-neutral adapter + Google implementation
MapAdapter.tsx defines the primitive contracts layers are written against:
MapContainer, MapPolyline, MapCircle, MapOverlay (DOM at a geo position),
MapDragHandle, plus useMapClick (priority dispatch), useMapCursor
(override stack), useMapZoom and MapControl (screen-anchored controls).
src/map/google/ is now the only place importing @react-google-maps/api or
referencing the google.maps namespace; src/map/index.ts binds the provider.
MapComponent consumes only adapter primitives (no structural split yet):
map plumbing (API loading, camera, click dispatch, cursor, zoom state)
moved into GoogleMapContainer. Google-specific constants moved from
src/constants to src/map/google/mapConfig.ts; POM/path styles re-expressed
as neutral primitive props. No behavior change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Extract FlightPathsLayer into src/map/layers
Pre-wind + corrected polylines, POM markers, altitude labels, hover
tooltips (leg/manoeuvre/point stats), cross-path highlighting and crab
arrows move into layers/FlightPathsLayer.tsx with their hover state.
Shared tooltip styles/helpers land in layers/tooltip.tsx. Path style
constants move with the layer; the now-empty src/constants/ is removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Extract CourseLayer, TargetEditLayer and CourseEditLayer
Course buoys/lines/markers (with the zoom>=20 marker gate) move to
layers/CourseLayer. Target and course edit handles move to their own
layers, each owning its live-drag preview state; TargetEditLayer registers
its own crosshair cursor and background-click handler (priority 0), while
the measure tool's click handler now registers at priority 10 — preserving
the measure-beats-target-edit behavior. The CourseEditTarget and
TargetEditTarget types now live with their layers (MapComponent re-exports
them for App).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Extract StationsLayer
Observed-station wind arrows, the target-anchored ground-wind arrow
(observed or forecast) and their tooltips move to layers/StationsLayer,
which owns the shared hover state + leave timer. The target-anchored
arrow lives here rather than a separate wind-arrows layer because it
shares the single-tooltip hover state with the station markers; the
arrow SVG is deduplicated into a WindArrowGlyph.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Move Places autocomplete into the google adapter
LocationComponent reached into window.google.maps.places directly, with
its own ad-hoc type declarations for the Google namespace. Per the
src/map import rule (only src/map/google may reference google.maps),
the autocomplete wiring moves to src/map/google/places.ts as
attachPlaceAutocomplete(input, onPlace), exposed through the src/map
index. Pure refactor: same no-op + console.log when the API is not yet
loaded, same place_changed handling; the component now receives plain
LatLng coordinates.
Browser-verified: Target panel > Search shows Google autocomplete
suggestions; selecting one moves the target and pans the map.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Extract MeasureLayer; MapComponent becomes a thin composition
The measure tool (ruler toggle, click-to-add points, cumulative
distance labels, click-to-remove, clear button) moves from
MapComponent into src/map/layers/MeasureLayer.tsx, which owns all
measure state. MapComponent is now a ~100-line composition of
MapContainer + feature layers; the CourseEditTarget/TargetEditTarget
re-exports are gone, so App imports them from src/map/layers directly.
Adds src/map/README.md documenting the adapter surface and the import
rule. Pure refactor: identical styles, z-indexes, click priority.
Browser-verified: enable "Show measure distance tool"; ruler toggles
measure mode with crosshair cursor; clicks add points with cumulative
labels (707 ft / 1108 ft); clicking a point removes it and labels
recalculate; the X button clears all points; exiting resets the tool.
No console errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Docs: Phase 2 complete; record map-layer follow-ups in backlog
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Backlog: MapLibre adapter as future idea
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Replace the fake demo router with react-router (real URLs)
Panels now live at real paths (/pattern, /manoeuvre, /target, /wind,
/courses, /settings, /about) with the map at the root; /map remains as
a redirecting legacy alias. Browser back/forward and deep links work.
Choices and rationale:
- react-router-dom@7 (current major), used via a small adapter that
satisfies the router interface of Toolpad's AppProvider. Toolpad
integrates cleanly this way, so DashboardLayout stays (visuals
unchanged).
- History routing (clean URLs) rather than hash routing, because share
links are owner-prioritized and clean paths are the target scheme
(ARCHITECTURE "App plumbing"). Static-hosting reload safety comes from
a build-time 404.html fallback: GitHub Pages serves 404.html for
unknown paths, and the build now ships it as a copy of index.html
(spa404Fallback plugin in vite.config.ts; site uses a custom domain
at base '/', so no subpath complications).
- The deliberate UX quirk is preserved cleanly in the adapter:
navigating to the already-open panel routes to the map, acting as the
panel-close toggle mobile relies on.
- Route guard redirects unknown paths to the map.
Pure URL-scheme helpers live in src/app/routing.ts with unit tests
(guard logic included, ready for mode gating).
Browser-verified (dev server): deep link to /wind opens the Wind panel;
clicking the active Wind nav item closes it (routes to /); back button
returns to /wind; /map redirects to /; no console errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add src/modes: declarative mode profiles (data, not code)
Mode = {id, label, description, enabled, nav, mapLayers, defaults,
features} per ARCHITECTURE "Modes". Ships two real modes — pattern
(no Manoeuvre/Courses; student-friendly wind-arrow default) and swoop
(today's full UI) — plus flocking and explore as disabled stubs that
prove the shape.
applyModeDefaults() resolves effective settings: a mode default applies
only while the user's stored value equals the global default, so user
config survives mode switches untouched. The accepted limitation (a
mode-defaulted setting can't be stored back at the global-default
value) is documented at the function.
migrateModeId() validates stored/URL mode ids; anything unknown or
disabled becomes null (= first-run picker). Unit tests cover definition
integrity (nav/layers/features/defaults reference real ids), codec
garbage-safety, and defaults resolution.
Not yet wired into the UI — next commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Wire modes into the app: picker, switcher, gating, ?mode= links
- First-run picker ("What are you planning?") shows until a mode is
chosen; enabled modes are cards, stubs are greyed with a coming-soon
chip. Behind the picker the app runs the fallback (swoop = full UI),
so pre-mode users see nothing disappear.
- Mode persisted at flip.mode via the Phase-1 versioned codec
(schemaVersion envelope + migrateModeId; garbage or disabled ids
degrade to "not chosen" = picker).
- Toolbar gets a compact ModeSwitcher menu (per-mode icons, stubs
disabled) ahead of the existing actions.
- Gates driven by the mode definition:
- sidebar nav + mobile bottom nav built from mode.nav (bottom nav =
nav minus settings/about)
- map layers via a new MapComponent `layers` prop (mode.mapLayers);
e.g. CourseLayer/CourseEditLayer only exist in swoop
- features: manoeuvre excluded from path derivation and course
selection ignored outside swoop; presets toolbar gated
- route guard: panels outside mode.nav redirect to the map
- Mode in URL: ?mode=swoop|pattern on any path applies immediately
(confirmation-free, per phase scope), persists, then the param is
stripped to keep URLs canonical. Invalid values are ignored.
- Effective settings via applyModeDefaults(): mode defaults apply only
where the user's stored value equals the global default, so stored
config survives mode switches untouched. The Settings panel edits
stored values; known limitation documented at applyModeDefaults.
Browser-verified (dev server + production preview):
1. Cleared site data -> picker appears; chose Pattern -> sidebar and
bottom nav lose Manoeuvre/Courses; map fine; wind-arrow mode
default active.
2. Toolbar switch to Swoop -> tabs reappear; selected a course and
created a custom one -> renders on map; switching to Pattern hides
the course layer and redirects /courses to the map while
flip.courses.* data stays intact.
3. Deep link /wind opens the Wind panel; back button walks history;
reload keeps state.
4. Active-item toggle closes the panel on desktop and via bottom nav
at 375px viewport.
5. Mode survives reload; pattern leg altitude changed in swoop (300 ->
400) survives switching to pattern mode and back; ?mode= links
apply + strip correctly.
6. npm run build && npm run preview: deep-link /wind reloads correctly
on the static preview; 404.html ships identical to index.html for
GitHub Pages.
7. No console errors in any of the above.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Docs: Phase 3 complete; record router/modes follow-ups in backlog
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Introduce src/data/wind: WindSource plugin layer replaces src/forecast
Phase 4 slice A. The wind data layer is now a set of source plugins
implementing the WindSource interface from ARCHITECTURE §2 (id, label,
kind: model-forecast | sounding | observed-station, capabilities,
fetch(location, opts)):
- src/data/wind/source.ts: the interface, split into AloftWindSource
(resolves to a WindProfile) and ObservedStationSource (resolves to
stations near a location) for type-safe returns.
- openmeteo.ts, stations/{nws,csc,spaceland}.ts: existing providers
ported onto the interface (git mv from src/forecast, logic intact).
Site-specific feeds (CSC, Spaceland) gate themselves by range inside
fetch(); NWS declares capabilities.discovery.
- stations/index.ts: location-based aggregation (was observedWind.ts),
now iterating OBSERVED_STATION_SOURCES; dropzones only contribute
supplemental AWOS station ids.
- compose.ts: composeWithObservedGround() — the observed station wind
becomes the ground row, tagged with station id + observation time.
- index.ts: WIND_SOURCES registry + fetchForecast(center, opts).
core/wind.ts types extended compatibly (winds are not persisted, so no
codec changes): WindRow gains optional tempC/source/validTime, profiles
gain meta { model, fetchedAt, location, elevationFt, station... }, and
SOURCE_SOUNDING joins the source ids. OpenMeteo now tags every row with
source + validTime. forecastSourceLabel moved into core/wind.
Deleted dead legacy providers src/forecast/{csc,spaceland}.ts (unused
since the station providers replaced them).
New tests: registry/source conformance, OpenMeteo profile building
(mocked recorded-shape responses, hour offset, below-ground level
filtering, row tagging), NWS gridpoint discovery parsing (range filter,
partial-observation fallback, by-id supplement), station aggregation
(dedupe, sorting, failure isolation). openmeteo.ts uses bare fetch (was
window.fetch) so node-env tests can stub it.
Browser-verified (flip-dev, ZHills): forecast fetch populates the wind
table, Zephyrhills NWS station discovered and injected as ground wind,
paths redraw; no console errors.
Tests 289 passing; lint 0 errors / 50 warnings (was 52); build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Cache ground elevation per location (it never changes)
Phase 4 slice B; backlog "Cache elevation". fetchElevation ran on every
forecast fetch even though elevation is immutable. New
src/data/wind/elevation.ts owns the lookup: locations round to 3
decimal places (~110 m, so target nudges keep hitting), results persist
in localStorage under 'flip.elevationCache' through the versioned-codec
mechanism (schemaVersion 1, validating migrate drops garbage entries),
bounded to 500 entries by evicting the oldest. No TTL — permanent by
design. Storage-less environments (node tests) just always fetch.
Tests: key rounding, hit/miss/nearby-vs-distant, envelope shape,
corrupt-storage recovery, eviction, no-localStorage fallback.
Browser-verified (flip-dev, ZHills) with an instrumented fetch counter:
clicking Fetch forecast issues 1 GFS request and 0 elevation requests
once the cache entry exists; localStorage holds
{"schemaVersion":1,"doc":{"28.219,-82.151":75.45932}}.
Tests 303 passing; lint 0 errors / 50 warnings; build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Prefetch a window of forecast hours; switch hours locally
Phase 4 slice C; backlog "Prefetch forecast for next few hours". One
OpenMeteo request already returns hourly arrays, so fetchOpenMeteo now
requests a window (24h minimum, whole days up to the requested hour,
capped at 168h to match the 7-day picker) and keeps it in a module-level
cache. The forecast-time picker's +/- hour buttons and time edits are
then served locally — no network — as long as the window is fresh
(30 min TTL), covers the hour, and the target hasn't moved beyond
hasTargetMovedTooFar. Indices are computed from timestamps, so a window
fetched before an hour boundary stays valid after it (just shifted).
Explicit refreshes bypass the window: the Fetch forecast button and the
toolbar refresh action pass force → WindFetchOpts.forceRefresh. Hour
switching (+/-, date/time edits, "now") does not.
Tests: window sizing (24/48/168), local hour switch without a second
request, hour-boundary re-alignment, refetch beyond window / after TTL /
on target move, nudge tolerance, forceRefresh bypass (fake timers +
resetOpenMeteoPrefetch between tests).
Browser-verified (flip-dev, ZHills) with an instrumented fetch counter:
Fetch forecast → 1 GFS request; three "+1 hour" clicks → 0 further
requests, console shows "OpenMeteo prefetch hit: hour offset 1/2/3",
wind table updates each time; no console errors.
Tests 312 passing; lint 0 errors / 49 warnings; build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Show and select the OpenMeteo forecast model
Phase 4 slice D; backlog "OpenMeteo model info". The Wind panel now
badges the aloft source ("OpenMeteo - <model> - valid <time>"), and a
Forecast model dropdown in Settings lets the user pick the model. The
selected model (settings.windModel, persisted via the versioned codec)
flows through useFetchForecast -> WindFetchOpts.model and is part of the
prefetch cache key, so switching models refetches while switching hours
still serves locally.
OpenMeteo access moves from the model-specific /v1/gfs endpoint to the
generic /v1/forecast?models=<id>. buildProfile now tolerates missing
variables (null values), so models that don't cover every pressure
level or the 80 m wind still produce a valid profile from the levels
they do provide.
Models offered (all verified 2026-07 against real /v1/forecast at
28.22,-82.15 to return usable wind at the pressure levels FliP uses):
best_match - full 17 levels + 80 m (default)
gfs_seamless - full 17 levels + 80 m
icon_seamless - 9 levels + 80 m
ecmwf_ifs025 - 5 levels, no 80 m (usable but coarse; null rows skipped)
None dropped - all four return data; the null-tolerant builder covers
the sparse ones. OPEN_METEO_MODELS + windModelLabel live in core/wind;
migrateSettings validates windModel against the known ids.
Browser-verified (flip-dev, ZHills, instrumented fetch): initial fetch
hit models=best_match, badge "OpenMeteo - Best match"; selecting ICON in
Settings persisted windModel=icon_seamless, and refetching hit
models=icon_seamless with badge "OpenMeteo - ICON"; no console errors.
Tests 318 passing (model-in-cache-key, meta.model, ECMWF null-row
skipping, migrateSettings model validation); lint 0 errors / 49
warnings; build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add radiosonde soundings as an alternative winds-aloft source
Phase 4 slice F; backlog "Soundings as wind source". Real radiosonde
soundings from the Iowa Environmental Mesonet (IEM), implemented as a
WindSource of kind 'sounding' (src/data/wind/soundings.ts).
CORS verified with real fetches before building (both endpoints send
Access-Control-Allow-Origin: *, so they are callable directly from the
static client - no proxy):
network: mesonet.agron.iastate.edu/geojson/network/RAOB.geojson
sounding: mesonet.agron.iastate.edu/json/raob.py?ts=<UTC>&station=<id>
The source discovers the nearest online RAOB station from the location
(module-cached network GeoJSON), then fetches the most recent synoptic
launch (00Z/12Z, newest-first with fallback since the latest may not be
uploaded yet), converting each valid-wind level to an AGL WindRow
(direction, knots, temperature) referenced to the surface geopotential
height. Rows carry source=sounding + validTime; meta records the station
id/name/distance and launch time.
Wiring: settings.windAloftSource ('forecast' | 'sounding') selects the
aloft source in Settings (the Forecast-model dropdown shows only for
'forecast'); useFetchForecast dispatches through fetchForecast's
aloftSource option; the Wind panel badges the sounding station, distance
and launch time. Observed ground wind still composes over the sounding
surface row when enabled.
Verdict: BUILT. IEM RAOB is CORS-viable and returns good data.
Browser-verified (flip-dev, ZHills, instrumented fetch): selecting
Radiosonde sounding fetched RAOB.geojson, tried 00Z (not yet uploaded)
then fell back to 12Z, discovered station _TBW (Tampa, 39 mi), badged
"Sounding - _TBW (39 mi) - launched Jul 14, 07:00 AM", and populated the
table with the real AGL profile; no console errors.
Tests 331 passing (network parsing, nearest-station, synoptic-time
selection, profile building + null-wind skipping, discovery + latest/
fallback fetch, network caching, settings migration); lint 0 errors /
49 warnings; build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Docs: Phase 4 complete; record wind-subsystem follow-ups in backlog
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Docs: retract phantom past-forecast-time bug (clamps already handle it)
Verified in-browser that fetch, model badge and NWS station discovery work;
the empty table seen earlier was an automation click miss, not app behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Phase 5: installable PWA with offline app shell + cached forecasts
vite-plugin-pwa (generateSW, autoUpdate). Manifest + icons (192/512, any +
maskable, upscaled nearest-neighbor from the existing pixel-art logo — swap
for higher-res art later). Service worker precaches the app shell and uses
navigateFallback so the installed app opens any route offline. Runtime
NetworkFirst caching for the weather APIs (OpenMeteo, NWS, IEM soundings)
so the last-fetched forecast survives offline. Google Maps tiles are
deliberately NOT cached (their terms restrict it — offline tiles are the
MapLibre backlog item).
The 404.html SPA fallback now runs enforce:'post' so it copies the final
index.html after PWA injects the manifest link + SW registration; verified
both index.html and 404.html register the SW (deep-link installs work).
Verified in a production preview: SW active + controlling, manifest + 4
icons load, app-shell precache (10 entries), navigateFallback serves the
shell for unknown deep routes, and the openmeteo runtime cache populates
and reads back from cache. 331 tests, 0 lint errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Docs: mark Phase 5 (PWA) complete
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Add MapLibre scaffolding + mapProvider setting (google default)
Foundation for a runtime-switchable MapLibre map provider, alongside
Google Maps. Nothing is wired yet — the default provider stays 'google'
and the app renders exactly as before; the MapLibre modules compile but
are not imported by the app.
- Add maplibre-gl dependency (^5.24.0).
- Settings.mapProvider: 'google' | 'maplibre' (MAP_PROVIDERS in types),
default 'google' in DEFAULT_SETTINGS; versioned settings migration
defaults a missing/invalid mapProvider to 'google' (never throws), with
a unit test.
- MapProviderContext + useMapProvider + MapDispatchContainerProps in the
adapter, for the coming primitive dispatchers.
- src/map/maplibre/: MapLibre container (satellite ESRI World Imagery
raster style, own map-instance context, shared adapter contexts,
click/cursor/zoom/center parity), a meter-radius circle-polygon helper
with tests, and the map config. maplibre-gl confined to src/map/maplibre.
Verified: npm test (335), npm run lint (0 errors), npm run build all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Make map primitives provider-polymorphic (Google/MapLibre dispatchers)
Turn the six adapter primitives into runtime dispatchers that render the
Google or MapLibre implementation based on the active provider:
- MapContainer takes a `provider` prop (from settings via MapComponent),
mounts the matching provider container, and publishes the provider on
MapProviderContext. MapPolyline/MapCircle/MapOverlay/MapDragHandle read
that context and delegate. attachPlaceAutocomplete dispatches by an
explicit provider argument (it is not a component).
- src/map/index.ts now binds the primitives to ./dispatch instead of
./google. Layers/components still import the same names from src/map and
are unchanged.
- Add the MapLibre primitives (polyline, circle, overlay, drag handle),
the geocoder, the z-order helper, and the MapLibre index; maplibre-gl
stays confined to src/map/maplibre.
- Code-split: the whole MapLibre subtree loads via dynamic import, so the
default Google build's main chunk is unchanged (maplibre-gl ships in a
separate async chunk only fetched when MapLibre is selected). This keeps
the PWA precache under its size limit.
Default provider stays 'google'; there is no UI to switch yet, so the app
renders exactly as before.
Browser-verified (Google, default): satellite tiles, green pattern with
POM altitude labels, average-wind arrow all render; zero console errors.
npm test (335), npm run lint (0 errors), npm run build all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Add map-provider Settings control + MapLibre-aware place search
Make the MapLibre provider user-selectable and keep Target search working
in both providers:
- SettingsComponent: a "Map provider" select in the Map group (Google Maps
/ MapLibre satellite), styled like the existing selects. Switching it
re-renders the map with the chosen provider without losing layer state
(paths/POMs/target/course/stations/winds all derive from app state).
- LocationComponent place search now passes the active provider to
attachPlaceAutocomplete, so under MapLibre it uses the key-free Photon
geocoder instead of Google Places (which needs the Google JS API).
- MapLibreMapContainer: resize() on 'load' so the map paints even when the
flex dashboard layout sizes the container a frame after creation.
Browser-verified on a clean dev server: switching Google->MapLibre in
Settings (no reload) renders ESRI satellite tiles with attribution and the
average-wind arrow; zero console errors. Google remains the default and is
unchanged. npm test (335), lint (0 errors), build all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Fix MapControl clickability + MapLibre container robustness
- MapControl: wrap portalled content in a `pointer-events: auto` element.
The MapLibre control host is transparent to pointer events (so the map
stays draggable underneath), which left buttons rendered through
MapControl — notably the measure-tool ruler — unclickable. The wrapper
generates no box of its own, and controls that must not block the map
(the wind arrow) still opt out with their own `pointerEvents: 'none'`.
Google is unaffected (its host was already pointer-interactive).
- MapLibreMapContainer: defer map creation until the container has a
non-zero size (a MapLibre map created at 0x0 never renders and does not
recover on its own), and reflow via a ResizeObserver on later size
changes (e.g. the side panel opening/closing), matching the Google
adapter's automatic reflow.
Browser-verified under MapLibre: measure tool now works end to end — ruler
toggles, click adds points (circles), the connecting line and the "308 ft"
distance label render; target drag/click, wind arrows, and satellite tiles
all render; zero console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Fix crash when unmounting the MapLibre map (provider switch)
Switching the map provider tears down the MapLibre map via map.remove(),
after which its layers and sources are gone and getLayer/removeSource/off
throw. The layer/source cleanup effects ran unconditionally on unmount, so
switching Google<->MapLibre threw inside a cleanup and blanked the React
tree. Guard cleanup with isMapRemoved() (MapLibre's internal _removed flag,
no public equivalent) and skip teardown on an already-removed map.
Completes an in-progress fix the implementing agent had browser-verified
but could not commit (tooling outage). Green: 335 tests, 0 lint errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Docs: MapLibre provider added + verified; record follow-ups
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Docs: MapLibre interaction spot-check passed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Wind table: Beaufort color dot next to each speed (read-only view)
Reuses core beaufortColor (already used for map wind arrows) so the table
and map share one scale. Shown in the locked/read-only table — the common
case; editing rows is unchanged. Browser-verified: green at 7 kts through
orange at 17+.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Rename crab angle -> drift angle in the UI (owner's term)
User-facing strings only: Settings 'Show drift angle arrows' + tooltip,
and the map point tooltip 'Drift angle:'. To avoid two 'Drift' meanings in
one tooltip, the existing wind-drift line is relabeled 'Wind drift:'.
Internal names (showCrabArrow, crabAngle) kept to skip a settings
migration. Backlog updated (rename done; NWS attribution already present;
ground speed deferred).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Backlog hygiene: mark items delivered in Phases 1-5
The list had gone stale — offset bug, input limits, interpolation wrap,
elevation cache, prefetch, model info, soundings, station discovery,
ground-wind de-coupling, modes and PWA all shipped but were still open.
Marked with commit refs and what actually landed. 'Initiation altitude
not saved' could not be reproduced (feature is live and persisted) —
flagged as needing a concrete repro or closure.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Fix place-autocomplete listener/DOM leak on re-attach
attachPlaceAutocomplete returned void and registered listeners with no way
to detach: Google left a live place_changed listener behind, and the
MapLibre/Photon geocoder appended a fresh dropdown <ul> plus input/blur
listeners on every call. MapSearchBox's effect had no cleanup and depended
on onPlaceSelected, which callers do not memoize (selectLocation is
recreated each render in useTargetContext) — so both stacked up on every
render.
Both implementations and the dispatcher now return a disposer (the lazy
MapLibre branch is cancel-safe if disposed before its chunk resolves), and
MapSearchBox ref-stabilizes the callback so it re-attaches only when the
provider changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Keep the heading handle a constant screen distance from the target
The heading handle sat at a fixed 15m from the target, which is only ~14px
at zoom 17 near 28N — smaller than the two handles' combined radii, and it
shrinks further as you zoom out. With the heading handle also above the
target in z-order, it swallowed the target's drags.
Add core/geometry metersPerPixel(lat, zoom) (Web Mercator, 256px tiles) and
place the handle at a fixed 40px offset converted to meters for the current
zoom, so the gap no longer depends on zoom. Swapping z-order alone would
only have inverted the problem.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Wind table: stop clipping typed values in the editable rows
Three number fields with spinners plus a remove button share the panel
width; MUI's default cell and input padding left too little room, so e.g. a
five-digit altitude was clipped. Trim cell/input padding (edit state only)
and give the fields a minWidth. Browser-verified: 13500 now fits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Drop the redundant KZPH supplement from ZHills
Verified against the live NWS API: gridpoints/TBW/82,110/stations returns
KZPH as the nearest of 51 stations, so listing it under the dropzone's
nearbyStations only forced a duplicate by-id fetch that discovery already
covered. Also confirmed KM08 (Bolivar) is still absent from its gridpoint
list, so the supplement mechanism itself stays.
The supplement tests used ZHills as their example; repointed them at
Bolivar/KM08 (which genuinely needs a supplement) and added a case pinning
that a dropzone without supplements does no by-id lookup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Wind panel: name the sounding station, badge manual winds
The sounding badge showed meta.station (the raw id, e.g. '_TBW') even
though the source already captures meta.stationName — prefer the name and
fall back to the id. Browser-verified: now reads 'Sounding · Tampa Bay Area
-- KTPA KTBW (39 mi) · launched Jul 16, 07:00 AM'.
Manually entered winds had no badge at all (only the OpenMeteo and sounding
branches rendered one), so the source line silently vanished; add a
'Manually entered' caption for consistency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Hide the forecast-time picker when soundings are selected
A radiosonde profile is whatever was last launched, so the sounding source
ignores hourOffset — the time picker sat above it doing nothing. Key the
picker off the selected aloft source (not the fetched profile's) so it
disappears as soon as soundings are chosen, before any fetch. The launch
time is already shown in the sounding badge.
Browser-verified: hidden for soundings, returns for model forecast.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Mode picker: give the cards accessible names
The card text is laid out as separate nodes, so each CardActionArea was an
unnamed button to a screen reader. Add aria-label from the mode's label +
description, marking the stub modes as coming soon.
Browser-verified: all four cards now expose names, disabled state included.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Wind panel: link out to Windy for the current target
A second opinion on the same spot, using Windy's ?lat,lng,zoom deep link
(format verified to resolve). Opens in a new tab with noopener/noreferrer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Select numeric field contents on focus
These fields are retyped wholesale rather than edited in place, so landing
in one with the caret appended to the old value meant clearing it first.
Select on focus in NumberInput (pattern/manoeuvre/settings) and in the
three editable wind-row fields.
Verified by spying on select(): focusing a field invokes it. (Number inputs
do not expose selectionStart, so the selection itself is not readable.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Link the sounding station to its IEM page
Soundings were the only fetched wind source with no way to see more about
where the data came from (observed stations already link out). Link the
station name to IEM's RAOB station page, keyed on the station id the source
already carries.
URL verified before wiring: networks.php?station=<id>&network=RAOB returns
200 for both the '_TBW' and 'KTBW' id forms. (Earlier candidates — IEM
/raob/ and the University of Wyoming cgi-bin sounding endpoint — 404.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Move initiation-altitude scaling into core; drop dead setManoeuvreAltitude
setManoeuvreAltitude had no production callers: the feature it once served
(ManoeuvreAltitudeControl -> initiationAltitudeOffset) is implemented by
different code that supersedes it — offset-based, clamped to +/-15%, and
immutable, where the old function mutated in place with no clamp.
That live logic sat inline in useAppState's computeManoeuvre, i.e. pure
domain maths inside a React hook, against the core/ dependency rule. Extract
it as core/manoeuvre applyInitiationAltitudeOffset (+ MAX_INITIATION_OFFSET_
FRACTION), have the hook call it, and delete the dead function. Also guard
the originalInitAlt === 0 divide-by-zero the inline version could hit.
Replaces the dead function's tests with six covering the real path: scaling
up/down, clamping, immutability, no-ops, zero-altitude. Verified in the
running app: +100 -> 1100, +9999 clamps to 1150, input unmutated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Backlog: record solo-batch progress and the codec coupling
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Versioned codecs for custom locations and stored tracks
Moves the last two unversioned localStorage documents onto
createVersionedCodec: `flip.custom_locations` (CustomLocationsComponent)
and `flip.manoeuvre.track.tracks` (ManoeuvreTrackComponent). Their
element types move to types/index.ts (CustomLocation, StoredTrack) so
core/model.ts can own the loaders without depending on components.
Loader decisions (mirroring migrateCustomCourses):
- Both key on `name`, which is the UI's identity for an entry, so entries
without a non-empty name are dropped — they could never be selected.
- Locations without finite lat/lng are dropped; coordinates are clamped
and `direction` defaults to 0 / is normalized.
- Tracks whose points migrate to an empty path are dropped rather than
kept as a selectable entry that draws nothing; `track` accepts the
legacy {lat,lng,...} point format via migrateToFlightPath, and
`description` defaults to ''.
Legacy bare arrays (what is in users' browsers today) still load: the
codec passes an envelope-less document to migrate() as version 0.
Verified: 369 tests pass (was 343), lint 0 errors / 50 warnings
(unchanged), build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Validate the stored Locations tab; audit the simple string keys
Audit of every remaining non-versioned useLocalStorageState key. Toolpad's
default codec for these is CODEC_STRING (identity), so the stored value is
a bare string and parsing can never throw — the only failure mode is a
value the UI doesn't expect.
- `flip.location.tab` — left a plain string, given a validating fallback.
Versioning it buys nothing (a 3-value enum of UI ephemera), and would
actively cost: the stored value is a bare `dropzones`, which JSON.parse
rejects, so wrapping it would reset every existing user's tab. The real
bug is that an unrecognized value matched no `selectedTab === ...` branch
and rendered an empty panel with every tab unselected; it now falls back
to Dropzones.
- `flip.courses.selected`, `flip.presets.active` — left alone. They are ids
whose validity is relative to a separate, user-mutable list, so no static
loader can judge them; a stale id already degrades to "nothing selected"
(consumers `.find()` and fall back to undefined/null), which is graceful
and self-correcting.
- `flip.mode` is already versioned (migrateModeId).
Verified: 369 tests pass, lint 0 errors / 50 warnings (unchanged), build
green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Remove the dead createSafeCodec and createSimpleCodec
Both are now callerless: createSimpleCodec's only two callers moved to
createVersionedCodec in 7f2de97, and createSafeCodec was already dead
(only its own doc comment named it). deepMerge goes with them — it was
createSafeCodec's private helper and has no other user.
createVersionedCodec and CODEC_JSON stay. Note that CODEC_JSON has no
production callers either, only its own tests; leaving it as instructed
rather than widening the cleanup.
Verified: 369 tests pass, lint 0 errors / 50 warnings (unchanged), build
green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Backlog: item 7 done (versioned codecs + codec cleanup); record follow-ups
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Docs: add HANDOFF.md as the redesign entry point
A new session needs one place that says where things stand, what is next,
which rules are non-negotiable, and which traps to avoid. NOTES.md had grown
into a per-phase log that answers 'why', not 'what now', so add HANDOFF.md
and point CLAUDE.md and NOTES.md at it. Records the hard rules (no push /
deploy / flip-next), the working agreements that earned their keep (commit
every green slice; verify before building; do not fix phantoms), the
environment gotchas (VITE_ env prefix, SW only in prod builds, flaky browser
automation, one driver at a time) and the settled decisions not to
re-litigate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Backlog: record 2026-07-16 architecture review findings and accepted features
Review verdict: architecture sound; weak spots recorded as a new
'Architecture-review follow-ups' section (error surface, useWinds
extraction, core/ layering leftovers, component tests, settings
layering, track-scale rendering).
Owner-accepted feature additions: wind time scrubber, persist winds +
staleness banner, model/sounding comparison view (extended per owner),
replay animation, side profile view (needs concept demo), live GPS
mode, DZ wind climatology, METAR/TAF (scope vs stations open). Notes
added to existing items: share-links encoding proposal, reach-before-
flocking ordering, flocking parked pending owner input.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Surface wind-fetch errors; keep previous profile on failure
A failed forecast fetch used to reset the wind table to an empty profile
with only a console.log. Now the previous profile is kept, the hook
exposes an error state, and a new app-wide NotificationsProvider (MUI
Snackbar + Alert) shows the failure to the user.
Verified via tests, lint, build (no browser in this environment).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Backlog: error-surface item done (cf21e1e)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Extract the useWinds facade from App.tsx
Move the wind orchestration out of App.tsx into hooks/useWinds:
forecast fetching, observed stations, their composition into the
effective profile, the forecastTime/observed-reset coupling, the
fetch-error notification, and the wind summary construction (now
memoized via makeWindSummary). Pure refactor, no behavior change.
Also render CustomAppTitle as an element instead of calling the
component as a plain function in the appTitle slot.
npm test (378), lint (0 errors, 50 known warnings) and build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Pin pathStats behavior with tests before moving it into core/
Covers per-leg stats (alt/time/heading/bearing/distance/glide), the
point-to-segment map, wind drift from pre/post-wind shifts, manoeuvre
stats (bearings, depth/offset projections), and getPointSegmentStats
lookups. Tests lead the refactor per the working agreement.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Move pathStats and courses from util/ into core/
Both are pure logic (turf + shared types only) that predated the core
extraction; this completes the layering for them. Mechanical move plus
import-path updates — no code changes. Tests, lint and build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Dedupe the drift-angle formula and share PointData from core
The heading-vs-bearing fold was written out twice in FlightPathsLayer
(tooltip row and arrow gate); both now use core/pathStats.driftAngle,
with tests covering the wrap across north. FlightPathsLayer's local
PointData copy is replaced by the one core/pathStats now exports.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Backlog: useWinds extraction and core layering done
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Add React Testing Library smoke tests for hooks and panels
First component/hook-level tests (everything so far was pure logic):
usePresets round-trip against localStorage (create/load/update/rename/
delete, persistence across a remount) and PatternComponent rendering +
input clamping (out-of-range values never propagate raw; clamped on
blur). Tests opt into jsdom per-file so the pure suites keep the node
environment.
New devDependencies (pre-approved for this item): @testing-library/react,
@testing-library/jest-dom, jsdom.
The route guard was already covered in app/routing.test.ts including
nav-list filtering, so no extension was needed there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Backlog: component/hook tests done (19bf4f5)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Settings layering: explicit touch tracking replaces the defaults heuristic
applyModeDefaults previously applied a mode default wherever the stored
value equaled the global default, which made it impossible to force a
mode-overridden setting back to the global default (documented trap).
Settings keys the user has explicitly changed are now tracked in a
persisted, versioned 'flip.settings.touched' document: setSettings marks
every key whose value changed (the Settings panel is the only writer),
resolution becomes touched-always-wins / untouched-takes-mode-default,
and resetAll clears the list. Pre-tracking users are seeded with every
key whose stored value differs from the global default, reproducing the
old behavior exactly until their next explicit change.
Covers: core migrate/seed tests, applyModeDefaults tests including the
force-back-to-default case, and hook-level tests for marking, seeding,
persistence across remount and resetAll (396 tests total).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Remove the dead CODEC_JSON
Its only references were its own tests (kept alive earlier only by
task scope). The versioned codec is the sole codec in use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Backlog: settings layering + minors done; review section complete
All architecture-review follow-ups are now closed except the two
deliberately deferred: track-scale path rendering (Phase 7) and the
accepted openmeteo prefetch singleton. Cross-referenced entries updated
(Phase-1 CODEC_JSON, Phase-3 mode-defaults heuristic, Phase-4 KZPH).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Jump to course: pan map to a course when selected
Built-in canopy-piloting courses are geographically anchored (e.g.
Skydive Arizona); selecting one far from the target used to render
nothing visible. The map camera now pans to the course center on
selection and back to the target on deselection.
Mechanism: App derives a mapCenter state (follows the target; jumps on
course-selection *changes*, so a persisted selection does not hijack
the initial view). MapComponent gained a separate cameraCenter prop
because its center prop also anchors the stations/ground-wind layer,
which must stay at the target. Both map providers already pan only when
the camera center changes, so free drag is unaffected.
Browser-verified (Google provider): selecting "Skydive Arizona:
Distance" from the default Florida target pans to Eloy, AZ
(32.808,-111.582); deselecting pans back to ZHills; drag unaffected.
Closes the Phase-2 "jump to course" and Phase-3 "selecting a course
doesn't pan" follow-ups.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Improve map tooltip contrast over satellite imagery
The shared tooltip style was 11px white on 85%-alpha black, which reads
poorly over dark satellite imagery (and the box itself melts into dark
terrain). Now: near-opaque background (rgba(10,10,10,0.92)), a hairline
white border so the box separates from dark imagery, 12px body text,
stronger shadow, and a brighter secondary style for the coordinate line
(new TOOLTIP_SECONDARY_STYLE, replacing the ad-hoc 10px #aaa).
Applies to all consumers of TOOLTIP_STYLE: point/leg/manoeuvre tooltips
in FlightPathsLayer and station tooltips in StationsLayer. Inline styles
render inside the map overlay portal, so both app UI themes see the same
(readable) result.
Browser-verified on the point tooltip (Google provider, satellite);
leg-stat tooltips share the same container/row styles but automation
hovers could not land precisely on a POM (known flakiness).
Closes the Phase-2 "leg tooltip body rows low-contrast" follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Wind table: per-row source indicators
Rows have carried source metadata since Phase 4 (open-meteo / sounding /
station id / none-for-manual) but the table never showed it — the ground
row injected from an observed station was indistinguishable from
forecast rows.
The read-only (locked) table now shows a subtle leading icon per row:
- green sensors icon for an observed-station row, tooltip with the
station id and observation age;
- muted cloud icon for forecast rows (OpenMeteo or sounding, per
tooltip);
- muted pencil icon for manually entered rows (possible in a locked
table when a manual aloft profile is composed with observed ground).
The edit-mode table is unchanged (everything there is editable/manual,
and the row is already cramped). Classification is a new pure helper,
core/wind.windRowSourceKind, covered by tests (+4).
Browser-verified: after a fetch at ZHills, the injected NWS ground row
shows the green sensors icon; the OpenMeteo rows show cloud icons.
Closes the ARCHITECTURE §2 per-row provenance ask (owner item 19).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Show ground speed in the map point tooltip
New core/pathStats.groundSpeedKts (tested): ground speed at a path
point from a centered difference over its two adjacent points (the
prev-to-next segment), falling back to the single available neighbor at
the path ends; null for degenerate inputs. Centered differencing was
chosen over per-segment speed so the value at a point is not biased
toward whichever side of it the segment boundary falls on.
FlightPathsLayer threads the full path plus the user's wind-speed unit
formatter (useUnits.formatWindSpeed) into the point tooltip, which now
shows "Gnd speed" between Time and the coordinates. Works on both the
wind-corrected and pre-wind paths.
Browser-verified: hovering a base-leg point showed 23.2 kts; switching
the wind-speed unit setting to m/s changed it to 11.9 m/s (same speed,
converted), confirming the unit preference is respected.
This was deferred twice because the tooltip only received an altitude
formatter; closes the "ground speed in point hover popup" backlog item
(owner item 14).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Show cumulative turn in the manoeuvre point tooltip
New core helpers (tested):
- headingDeltaDeg: signed shortest-arc heading change in [-180, 180),
positive = clockwise, 350->10 is +20;
- cumulativeTurnDeg: per-point cumulative signed turn along a path,
summing per-segment shortest-arc deltas so a full 270 turn reads ~270
at the end instead of its 360-wrapped remainder; duplicate points
(e.g. epsilon segments) carry the total forward instead of producing
garbage bearings.
FlightPathsLayer builds an original-index -> turn map over the
manoeuvre-phase points (ordered by time, i.e. from initiation) for both
paths, and the point tooltip shows "Rotated: N° left/right" on
manoeuvre points (hidden below 1°).
Browser-verified with the "Sample 90" manoeuvre: a point 2 s after
initiation showed "Rotated: 4° right" (initial GPS wobble), the end of
the turn "Rotated: 87° left" — consistent with a gentle 90° left turn.
Closes the "degrees rotated in map hover" backlog item (owner item 15),
the last of the five owner-agreed quick items.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Persist winds across reloads + staleness indicator
Winds lived in useState only, so a reload lost both manual edits and
the fetched forecast. The current WindProfile now persists under the
versioned localStorage key flip.winds:
- core/model.migrateStoredWinds (tested): validating loader that
revives the profile's Dates (validTime, per-row validTime,
meta.fetchedAt) from their JSON ISO-string form, clamps/defaults
invalid fields, drops unusable rows, never throws, and returns null
for garbage so "nothing stored" is distinguishable from a profile.
- useFetchForecast stores the profile via useLocalStorageState +
createVersionedCodec (same pattern as the other persisted docs);
resetWinds clears the key. Manual profiles persist too.
Staleness: the wind panel's source badge (OpenMeteo and sounding)
gains "· fetched N min ago", ticking once a minute; past 30 minutes it
turns warning-colored and shows an inline "refresh" link that forces a
fetch (bypassing the prefetch cache). Manual profiles show no age.
fetchedAt is the profile's meta.fetchedAt, i.e. when the data was
actually retrieved (the OpenMeteo prefetch cache can serve an
already-minutes-old window, which the age honestly reflects).
Browser-verified: fetch -> reload restores the full table with the
badge and age; unlock -> edit a row -> reload restores the manual
profile including the edit. The >30 min warning/refresh path is
logic-only verified (cannot wait 30 min in a session).
Observed-station ground injection is runtime composition and is
deliberately not persisted: after a reload the raw forecast ground row
shows until the next fetch discovers stations again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Wind time scrubber over the prefetched forecast window
A horizontal hour slider under the forecast-time picker: dragging it
steps through the hours already sitting in the OpenMeteo prefetch cache,
so the wind table and the map paths morph live with no network traffic.
- data: new openmeteo.prefetchedWindowHours(point, model) reports how
many hours from "now" the cached window still covers, applying exactly
the freshness rules the fetch itself uses (TTL, model, location) so
the scrubber never promises a local hour that a real fetch would
re-request. Tested (null before fetch/after reset/on TTL expiry/for
other model or distant location; shrinks as the clock advances).
- hooks: useWinds exposes scrubHours, re-derived after every fetch
(the cache is module state; the profile is the reactive proxy).
- UI: MUI Slider (0 .. scrubHours-1, "now"/"+Nh" value labels), shown
only when a fetch has filled the cache and the aloft source is an
hourly model. It composes with the picker as the fast path: each step
calls the same onForecastTimeChange + fetch pair the picker's +/-1h
buttons use, so a non-now hour clears observed-station injection
identically. Hidden for soundings together with the picker (the
Phase-4 "picker inert for soundings" follow-up was already handled by
hiding the picker; marked done).
Browser-verified with window.fetch instrumented: scrubbing 0 -> +12h
replaced the whole profile (valid time +12h, directions 280->010) with
zero fetch() calls; scrubbing back to "now" kept the wind fetch local
and re-fired only NWS station discovery, exactly like pressing "now".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Model/sounding comparison view — first pass
core/windCompare: sample every profile on a common 500 ft ladder using
the app's own vector interpolation (prepWind + getWindAt); below a
profile's lowest row the lowest row applies, above its highest row the
cell is null — sparse sources (ECMWF's 5 levels) compare on honest
terms. Disagreement thresholds are named, exported constants (>15° /
>5 kts; directions of <3 kt winds are noise), all tested.
Fetch side: fetchOpenMeteoComparison serves from the prefetch window
when it matches but never stores, so a comparison sweep cannot evict
the cached window that hour-switching and the scrubber rely on.
Soundings included when a station is available.
UI: a compare toggle in the wind panel opens a read-only side-by-side
(per-altitude arrows + speeds per source, disagreeing bands
highlighted); the active profile driving the flight path is untouched.
First pass — visualization design open for owner iteration (noted in
BACKLOG).
Committed on the agent's behalf after verifying: 440 tests, lint
0 errors/50 known warnings, build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Flocking core math: path, into-wind, drift vectors, FWC spot description
Port of the Flocking Wind Calculator's Drift.kt math into src/core/flocking.ts,
pure and tested (20 tests; 440 -> 460):
- makeFlockingPath: no-wind descent path at the origin, point[0] = end of
jump (windowBottomFt, time 0), later points backward in time up to the
exit at windowTopFt, 1 s steps like makePattern. Deviation from FWC
(which has no path concept): POM points are inserted exactly at round
altitude multiples so map labels read 5000/6000/... cleanly; the
interval is a parameter (1000 ft default, ~250 m for metric users).
- intoWindDirection: wind applied to a zero-horizontal-speed descent; the
bearing end -> corrected exit is the into-wind jumprun. Calm winds
return 0 (stable, arbitrary).
- flockingVectors: the FWC "Wind drift / Canopy flight / Combined" block
from the positioned ideal+corrected path endpoints.
- spotDescription: FWC's projection math line-for-line, including its
left/right convention (which inverts the geometric side for PAST exits
- kept for parity, noted in a doc comment).
Deviation (owner-approved): wind application reuses FliP's addWind
(vector-interpolating) rather than FWC's per-forecast-level stepwise sum;
the closed-form parity test asserts agreement within a few percent on the
FWC default window (12k -> 4k ft, 21 mph descent, 50 mph horizontal,
uniform 20 kt wind).
npm test 460 green, lint 0 errors / 50 known warnings, build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Flocking params document: FlockingParams + migration and limits
FlockingParams lives in core/flocking (like MakePatternParams in
core/pattern): altitude window top/bottom in plain feet (FWC uses kft),
descent + horizontal speed in mph, direction as cardinal degrees or
'into-wind' (replacing FWC's -1 sentinel), distance unit mi/nm/km (FWC
has mi/nm; km added), and an optional pinned reference point C.
- DEFAULT_FLOCKING_PARAMS in core/model: FWC's defaults (12000/4000 ft,
Flow 21/50 mph, into-wind, mi, no reference point).
- migrateFlockingParams: validating loader, never throws; clamps via new
LIMITS entries (flockingAltitudeFt 0..30000, flockingDescentRateMph
1..100, flockingHorizontalSpeedMph 0..150 - XRW's 40/70 fit), drops
invalid reference points, normalizes numeric directions.
- 13 new tests in core/model.test.ts (garbage table + field cases).
npm test 473 green, lint 0 errors / 50 known warnings, build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Flocking mode live: mode wiring, derivation hook, panel
- modes: 'flocking' enabled (picker card, toolbar switcher and ?mode=
links all work off enabled flags); nav [flocking, target, wind,
settings, about]; map layers [flocking, stations, targetEdit,
windArrow] (the flocking layer itself lands in the next commit).
'flocking' added to PANEL_IDS and MAP_LAYER_IDS; swoop deliberately
does not gain the flocking panel/layer (tests updated).
- state: flockingParams persisted under versioned key
flip.flocking.params in useAppState; included in resetAll.
- hooks/useFlockingPath: memoized derive pipeline - resolve into-wind
from the wind profile, makeFlockingPath (POM interval 82…1 parent 860302c commit 5eb78ba
244 files changed
Lines changed: 50482 additions & 21023 deletions
File tree
- .claude/skills
- add-persisted-field
- verify-flip
- .github/workflows
- docs
- redesign
- testing
- ux
- public
- icons
- scripts
- src
- app
- components
- constants
- core
- data/wind
- stations
- forecast
- hooks
- map
- google
- layers
- maplibre
- modes
- samples
- types
- util
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | | - | |
| 1 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
| 2 | + | |
2 | 3 | | |
3 | 4 | | |
4 | 5 | | |
| |||
8 | 9 | | |
9 | 10 | | |
10 | 11 | | |
11 | | - | |
12 | | - | |
13 | | - | |
| 12 | + | |
14 | 13 | | |
15 | 14 | | |
16 | 15 | | |
17 | | - | |
18 | 16 | | |
19 | 17 | | |
20 | | - | |
21 | | - | |
22 | | - | |
23 | 18 | | |
24 | 19 | | |
25 | 20 | | |
26 | | - | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
27 | 45 | | |
28 | 46 | | |
29 | 47 | | |
30 | 48 | | |
31 | 49 | | |
32 | | - | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
33 | 95 | | |
34 | 96 | | |
35 | 97 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
37 | | - | |
| 36 | + | |
| 37 | + | |
38 | 38 | | |
| 39 | + | |
39 | 40 | | |
40 | 41 | | |
41 | | - | |
| 42 | + | |
| 43 | + | |
42 | 44 | | |
43 | 45 | | |
44 | 46 | | |
45 | 47 | | |
46 | 48 | | |
47 | | - | |
| 49 | + | |
48 | 50 | | |
49 | 51 | | |
50 | 52 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
0 commit comments