Skip to content

Latest commit

 

History

History
624 lines (556 loc) · 39.5 KB

File metadata and controls

624 lines (556 loc) · 39.5 KB

FliP - Flight Planner

A flight planning tool for skydivers that calculates wind-corrected landing patterns. It takes user input for pattern parameters and target location, fetches wind forecasts at multiple altitudes, and displays the flight plan on a map showing both the ideal path and wind-adjusted path.

A redesign toward a "next generation" architecture is in progress. Start at docs/redesign/HANDOFF.md — it says where things stand, what's next, the hard rules, and the environment gotchas. It points on to ARCHITECTURE.md (target design + phase plan), BACKLOG.md (outstanding work), NOTES.md (running log / why), and UIUX.md.

Project Structure

All source is TypeScript.

/src/
├── App.tsx                   # Main orchestrator: routing, derivation wiring, panels
├── index.tsx                 # React DOM entry point
├── core/                     # PURE logic. No React, no DOM, no fetch, no map.
│                             #   geometry, pattern, manoeuvre, wind, flocking,
│                             #   flockingSolve, courses, pathStats, units,
│                             #   validation, model (versioned doc schemas)
├── data/                     # I/O: data/wind/ (WindSource plugins — openmeteo,
│                             #   soundings, stations/, compose, elevation)
├── map/                      # Map abstraction: MapAdapter + dispatch (primitives),
│                             #   google/, maplibre/ (providers — the ONLY places
│                             #   google.maps / maplibre-gl may appear),
│                             #   layers/ (declarative, provider-agnostic)
├── modes/                    # Declarative mode profiles (pattern/swoop/flocking)
├── components/               # UI panels, toolbar, map composition
├── hooks/                    # useAppState (context + localStorage), useWinds,
│                             #   useFlightPaths, useFlockingPath, useMode,
│                             #   useSetups, useUnits, useNotifications, ...
├── app/                      # routing helpers (URL scheme)
├── types/                    # Shared type definitions (types/index.ts)
├── samples/                  # Sample GPS track files for manoeuvres
index.html                    # Vite HTML entry (repo root)
vite.config.ts                # Vite + Vitest configuration

Dependency rule (enforced by review, currently clean): app → components/hooks → { core, data, map, modes }, data → core, map → core, core → nothing. core/ must never import React, DOM, I/O or map code; map/layers/ must never import a concrete provider.

Tech Stack

  • Vite for dev server/build, Vitest for tests, TypeScript 5
  • React 19 with hooks for UI and state
  • Material-UI (MUI) 7 for component library
  • Toolpad Core for dashboard layout and localStorage state persistence
  • @react-google-maps/api for map display
  • Turf.js for geographic calculations (distance, bearing, rotation, translation)
  • D3 for vector math
  • fast-xml-parser for XML parsing (ground wind data)
  • socket.io-client for WebSocket communication (real-time wind data); pinned to 2.5.0 — the Spaceland server speaks the socket.io v2 protocol

Build & Run

npm start              # Vite dev server on localhost:3000 (PORT env overrides)
npm run build          # Typecheck (tsc --noEmit) + production build to /build
npm test               # Vitest, single run
npm run test:watch     # Vitest, watch mode
npm run lint           # ESLint over src/**/*.{ts,tsx} (0 errors required;
                       #   warnings are the known Phase-1 cleanup list)

Google Maps API key: set VITE_GOOGLE_MAPS_API_KEY (see .env.example). Deploy: GitHub Pages via .github/workflows/static.yml (lint + test + build).

Application Flow

  1. User defines a pattern (1, 2, or 3 legs with altitudes and descent rates)
  2. User defines/uploads a manoeuvre (turn to final approach) — swoopers
  3. User sets a target (landing zone location and final heading)
  4. User fetches or manually enters wind data at multiple altitudes
  5. App calculates repositioned paths and applies wind corrections
  6. Map displays two path lines:
    • Dashed line: Original path without wind
    • Solid line: Wind-corrected path

Nerd mode (src/modes/nerd.ts) is a global flag (settings.nerd, off by default), not a mode: a mode answers "what jump am I planning", nerd answers "how much UI do I want", and they cross. It is applied as a transform over the active mode — withNerd() widens features/nav, so hasFeature, the nav, the map layers and the keymap gate on it for free — plus applyNerdGate(), which forces the nerd-only settings to their everyday values at App's modeSettings choke point. That value is DEFAULT_SETTINGS unless listed in NERD_OFF_OVERRIDES, so hiding a switch never silently changes the path math; unlike mode defaults the gate ignores flip.settings.touched. Behind it: manual wind entry (Unlock / Invert / Reset), both exports, pattern-point hover tooltips (pointTooltips — POM hover used to ignore its setting), the Location panel's numeric final-heading field (headingField), and nine settings including interpolation, leg straightening and the map provider. The wind source/model dropdowns are nerd-only too, but their values are deliberately not masked — the comparison table lets any user pick the active source, and a setting may only be masked while every control that writes it is behind the same gate. Toggle at the top of Settings; a NERD chip in the toolbar while it is on. Adding an item is one line in NERD_FEATURES or NERD_SETTING_KEYS.

The manoeuvre is a turn onto final, described in the final heading's frame — not relative to the target. The final heading is fixed by the target, so the parameters are what a turn is actually free to choose: turnDirection (which way you rotate), rotationDeg (90 / 135 / 270 / 450 / custom), depthFt (how far back you start, along the final heading, positive away from the target) and offsetFt (how far to the side, on the side you turn FROM). The entry heading is derived, so odd rotations work and changing a sign moves a point instead of rotating the whole manoeuvre — which the previous offsetX/offsetY/left model did, because it folded the offset's sign into the final bearing. Measuring the offset on the turn side is what makes flipping left/right mirror the turn; the absolute convention is not merely unintuitive but unflyable, since a turn cannot start across its own final line and still arrive on it.

The drawn curve is an ILLUSTRATION, and the panel says so. The numbers fix where the turn starts and how far it goes; they say nothing about its shape, and a real canopy turn is not a circle. solveManoeuvre() draws it at a nominal 200 ft radius and takes up the slack with straight legs: the turn is cut at every heading at a right angle to final, and a straight may be inserted at each joint as well as at both ends. Those directions are the four axes of the final-approach frame, so between them they absorb any displacement — a deep setup stretches the final approach, a negative offset stretches the entry, a 270 starting past the target becomes turn-straight- turn. Two straights always suffice, and the solver takes the LATEST valid pair (length only breaks ties), so slack lands on the final approach rather than partway round. Pairs must be at least 5 degrees apart and the total is capped: near a half turn the entry heading is the reverse of the final one, and solving that pair used to run away past the edge of the globe. The radius is tightened (by bisection) only when the setup is smaller than the nominal. Some setups cannot be drawn at all — a 90 can neither start past the target nor across the final line — and reaches reports that for the panel to warn about. Past 270 the radius shrinks along the sweep so big turns spiral instead of crossing themselves.

Two invariants worth keeping: the entry and final headings read back exactly (each end carries a straight longer than one track sample — reposition builds the pattern's final leg on the entry heading), and the wind drift over the turn depends only on altitude and duration, not on the shape. The second is why the track is resampled at uniform TIME steps: altitude is linear in time, so the drift integral is shape-independent, but addWind sums over the segments it is handed and geometric sampling put those at shape-dependent times.

Not every setup can be drawn, and the fields say so rather than letting the map disagree with them: manoeuvreBounds bisects on reaches to find the feasible depth and offset range (each with the rest of the turn held where it is), those bounds reach the input element so the spinner stops at the edge, and the binding edge is spelled out under the field.

correctPatternHeading applies only to tracks and samples, which are often a few degrees off; a parametric turn knows its entry heading exactly.

The initiation point is a drag handle (ManoeuvreEditLayer), and is the primary way to set a turn up — depth and offset are a position, and a position is a thing you point at. Always live, parametric turns only. It rides the still-air path — the dashed pre-wind line — because that is the frame the turn is described in: you set the turn up in still air and the wind correction is what FliP hands back. On the corrected path it was an input dressed as a result, and the drop had to have the drift taken back out of it before placeInitiation could read the numbers off. (Harmless arithmetic, as it happens: the drift over the turn depends only on altitude and duration, so it is the same vector wherever the handle is dropped.) placeInitiation projects the drop onto the final-approach axis and clamps to the same feasible bounds the fields use, one axis at a time. The handle withdraws within 26 px of the target's so it never eats its drags.

describeManoeuvrePath measures all of this back OFF a path, so recorded tracks and samples are described the same way. ManoeuvreHintLayer draws the entry arrow and rotation (showManoeuvreHint) and the final approach line (showFinalApproachLine), each separately switchable.

Modes (src/modes/) decide which of this is exposed — panels, map layers, coarse features, setting defaults. The three are Standard Pattern, High Performance Landing (adds manoeuvre + CP courses) and Flocking (its own panel/derivation; see below). Features gate the rest: e.g. only swoop has patternLegCount, so Standard Pattern hides the leg-count selector and always flies the full three-leg pattern. A first-run picker chooses one; it is remembered per device and switchable from the toolbar. Each mode keeps its own target position within a place, but the place itself is shared: choosing one in the picker (or loading a setup) moves the target in every mode, while dragging it, shift-clicking and the heading input affect only the current mode. Places also remember: a dropzone's stored coordinates are only a starting point, so the spot you shift-click to is recorded against that place (flip.targets.byPlace, keyed by Place.id, with the active one in flip.place.active) and restored next time you pick it. Flocking's pinned Spot Reference and its jumprun corridors ride along in the same record: the reference is the only other absolute coordinate in the app, so it unpins on a move (one left at the old DZ produced spots thousands of miles out) and comes back with its place. Targets belonging to no place — a setup, a geocoder hit — pass no place id and are not remembered.

The wordmark is the flocking switch (components/Wordmark.tsx + hooks/useFlipFlop.ts). The name was always a setup for a joke — FliP is the FLIght Planner, FloP the FLOcking Planner the README promised years before flocking mode existed — so now that it does, clicking the wordmark switches between flocking and the planner you were in before it. That "before" is stored (flip.mode.beforeFlocking) rather than derived: flocking is one mode and there are two it can return to, and a reload must not forget which — a Standard Pattern jumper coming back tomorrow must not land in swoop just because swoop is FALLBACK_MODE_ID. It is recorded on ARRIVAL in a non-flocking mode, not on the way out, because the wordmark is not the only door into flocking (the mode menu, a shortcut, a setup and ?mode= are the others). The mark is the logo's own artwork, which was always the joke drawn rather than told: an F, and the same F turned 180 degrees in the other colour, interlocking to fill the square. That is why FloP is a colour SWAP and not a redraw — the two halves are already each other's inversion, so exchanging them turns the F over. Wordmark.ART is the 14x14 transcription and a test pins the property (all 196 cells obey the rotation-with-colours-exchanged rule); an agent who "cleans up" the mark into an abstract pattern has thrown the letterforms away, which is exactly what happened once. Only the rendering changed: vector rather than a 16x16 base64 PNG, so it is crisp at any size, plus rounded corners.

Only the two colours change between the marks — the cells drawn are always the same ones. Swapping both (background AND which cells are painted) cancels out and yields a mark identical in both states, which also shipped for a moment; a test pins that the geometry is untouched while every colour is exchanged.

FliP and FloP are the same width, with F, l and P in the same places, so the switch reads as one letter turning over rather than the name being re-set. Both letters occupy ONE grid cell and the inactive one is hidden rather than removed, so the slot is as wide as the wider of the two in whatever font is resolved — no measured constant to go stale — and the narrow i gets the difference as air on either side. visibility is set as an inline style, not through sx, so the state is legible to a test without resolving emotion's classes. The lettering drops on a phone, as it always did; the mark stays, and still switches.

"No place" is stored explicitly (NO_PLACE, the empty string). It cannot be stored as null: useLocalStorageState encodes null as delete the key, and a missing key reads back as that key's DEFAULT — which for flip.place.active is ZHills. So picking a geocoder hit used to leave the app believing it was at ZHills, and every later edit (a target drag, a pinned Spot Reference, a new course) was recorded against that dropzone; choosing it later handed the foreign coordinates back, which is what a "1652 mi prior" spot was. flip.place.active is the only key with a non-null default that is ever set to null, so it is the only one with this hole. Reading a place's memory is also bounded (nearbyMemory): a remembered target or Spot Reference further than 25 mi from the place it belongs to is treated as damage and dropped, which heals storage already written by the bug.

A dropzone can also declare where each mode starts (Dropzone.modes, keyed by mode id): a swoop pond away from the student LZ, a flocking end point out in the big field, and for flocking the DZ's jumprun corridors and canonical Spot Reference (the landmark a spot is quoted against to the pilot). Anything omitted falls back to the dropzone's own coordinates and heading, and what the user did at a place always outranks what the dropzone declares, and "Reset to default" in the Corridors section throws those edits away. Corridors never travel: a place that declares none and has no edits has none. Speeds, window altitudes and the ring radii stay out of the DZ data on purpose — they describe the flock, not the place.

Pattern params are per-mode too (flip.pattern.byMode, falling back to the shared legacy flip.pattern.params): a swooper's descent rate and long legs describe their canopy, not the student pattern next to it.

So is the map zoom (hooks/useModeZoom.ts, flip.map.zoomByMode). A mode's defaultZoom says how wide its picture is — flocking spans miles of jumprun, a pattern spans hundreds of feet — but it is where the mode STARTS, not a preference to be re-imposed: a flocker who zooms in to read the spot against the ground finds that zoom again after a trip through another mode. The zoom travels up from the provider (MapContainerProps.onZoomChange, reported on the settled value — MapLibre fires zoom every frame for label thinning and zoomend for this) and back down as initialZoom, which is a loop: both containers therefore skip re-applying a zoom the map is already at, or setZoom and the zoom event bounce off each other. recordZoom also drops a value it already holds, and that check must be in the CALLER rather than in a state updater returning the previous object — useLocalStorageState encodes and writes whatever it is handed, so a no-op update still writes to localStorage and still notifies every subscriber of the key.

Courses belong to a place. A course is a fixed set of buoys in one pond, so CourseParams.placeId (a Place.id) scopes both the shipped courses in core/courses.BUILT_IN_PARAMS and the user's own — one field, one filter, no special-casing. The Courses panel lists only what is at the active place, grouped under its name, as a radio list rather than a dropdown (there are only ever a handful, and Duplicate/Delete then live on the rows instead of inside an open menu). "New" is a type menu — Distance / Zone Accuracy / Speed — which creates the course already named for its type (defaultCourseName, numbered if taken) and pointed along the target's final heading, because the type is the first real decision and used to be two levels down inside Edit. The selected course's own controls render inline under its row, not in a section further down. Positioning a course on the map is an explicit mode ("Position on map"): a course centre is usually within metres of where you land, so its drag handles sit on top of the target's and one set has to yield — while it is on the target is not draggable. It is off by default and resets whenever the selection changes. Below the list, Relative Position (depth / offset / approach angle, one field per line) places the turn against the course. Depth and offset share a row, depth first, the same pairing the Manoeuvre panel uses for the same two numbers. All three are MEASURED off the target on every render rather than held in local state: they describe the target, so held state went stale the moment it was dragged on the map, and since each field writes BOTH coordinates, stepping one then wrote the other's stale value back and the target jumped sideways. There is no feedback loop to fear — NumberField keeps its own text while typing and only re-syncs when the value it is handed changes. Choosing another dropzone drops a selection that belongs to the one being left (selectPlaceTarget), since it is meaningless there and the map camera would chase it. Two escapes keep that lossless: a course with no placeId — every custom course saved before this existed — belongs nowhere and is offered everywhere, and a setup records the place it was saved at (Setup.site.placeId), restoring dropzone and course together with PlaceSelection.useGivenTarget so the setup's own target still wins over what that place remembers.

Additional features: setups, canopy-piloting courses (distance / zone accuracy / speed, plus custom courses), observed ground-wind stations, forecast time selection + hour scrubber, model/sounding comparison (which follows the selected hour), persisted winds, KMZ and FlySight 2 export. On the map: a compact by-altitude winds indicator (WindMiniIndicator, corner overlay, ground-wind detail on hover), a wind-trust banner (WindTrustBanner + core/windTrust — none/manual/stale/fresh, hidden when fresh), and an always-draggable target handle (no edit mode; drag to move, hover for the heading-rotate handle, shift-click the map to jump it). Winds auto-fetch on load and again whenever the target moves to a new place.

The Wind panel reads top-down as: when, what the wind is, what the air is. It had drifted into the opposite — its subject was fourth, behind 279px of preamble — so three things were pulled apart:

  • The forecast time is one line, on the section heading: a stepper whose label IS the selection (Now, +3h · 2:00 PM, weekday once the day rolls over), the scrubber beneath, and the exact date/time fields folded away behind it. They are the precise path and the least used, and they were the heaviest thing in the panel.
  • The table draws the direction (WindArrow) instead of printing three columns of digits, and the Speed column gave up its Beaufort DOT — the arrow already carries that colour, and two coloured glyphs per row is what the panel was asked to have less of.
  • Ground conditions are one section, after the wind rather than above it, with the observed stations that feed them underneath and everything past wind/gusts behind a "Full report" disclosure. The section renders even when the stations do not: a future forecast hour has no observations but still has a temperature and a density altitude.

And the map's winds indicator takes its chip form while the Wind panel is open — both draw sampleWindBands over the same band list, so the card would be the same ten rows twice on one screen.

The Location panel chooses a place; the map edits the target. The panel (still PanelId target, route /target) used to carry a final-heading field and an "Upwind" button under a paragraph explaining that you could drag the target instead. Both are gone: the target is always draggable and its heading has a rotate handle, so the panel opens with a hero card naming the active place — town/region/country, coordinates, star, website, and how far the target has been dragged off the dropzone with a way back. Below it, Your places is ONE list, saved first then recent: the two overlap (a favorite you just used is both), so separate headed lists would show it twice, and the star on each row is both the marker and the way to move a place between them. Recents live in flip.places.recent (six, and snapshots rather than references — a geocoder hit is in no database); only the picker writes them, since a setup load also selects a place and that is not somewhere you went. The dropzones appear when searched, or under an All dropzones disclosure grouping all 274 by country — 41 countries is a list, 274 dropzones is not, and rendering them all is what used to make this panel's tests need a 15-second timeout. The numeric final-heading field comes back between the card and the search under nerd mode only (headingField) — it edits the same value the handle does, so gating it changes no path math, and flocking suppresses it regardless.

A setup is a document, and it splits on what travels. (core/setups.ts

  • hooks/useSetups.ts + components/SetupSelector.tsx; storage keys are still flip.presets — nobody but the hook reads them.) The pattern and the turn are always in a Setup; the site half is optional as a GROUP (site: place, target, course), because a target without the place it belongs to means nothing — the same coordinates at another dropzone are a field two states away. Bound, it groups under its dropzone in the menu and loading moves you there; portable (site: null), it is a canopy and a turn and applies wherever you are. Null rather than absent, because a preset saved before any of this carries NEITHER key and was always site-bound, and the migration has to tell the two apart.

A setup also carries its modeId and switches to it on load — pattern params are per-mode, so without that a swoop setup loaded from Standard Pattern files its numbers in the wrong slot. Loading therefore applies the pattern through setPatternParamsForMode(setup.modeId, …): React has not re-rendered by then, so a setter bound to the mode that WAS active would write to the wrong one. Carrying the mode also settles what a flocking setup stores, which is flockingParams.

A setup is live only where it applies — its mode, and its dropzone. Elsewhere it is DORMANT (awaySetup): still what you were working on, still there when you come back, but it cannot be dirty or saved. Both halves earn their keep, and the second was a real bug: standing at another dropzone read as "unsaved place, target and course", so Save would have quietly moved the ZHills setup to Eloy.

setupDiff(snapshot, stored) reports WHICH parts differ rather than a boolean, so the toolbar's amber dot has a tooltip ("unsaved pattern and target") and Save changes / Discard changes appear only when there is something to save. Discard is also "reload the setup I wandered off", which re-selecting the active one never did. Loading discards unsaved changes silently — a confirm on every switch would tax the one thing setups are for — with an Undo in the snackbar. Comparison of the target is to 1e-7 deg, so a storage round-trip cannot strand a setup as modified while a drag (metres) or a one-degree heading step still counts.

Copy to <dropzone> takes a setup somewhere else keeping where it sits relative to a COURSE. The relative position is not stored anywhere — the Courses panel derives it from the absolute target on every render — so planSetupCopy measures depth, offset and approach angle off the original and lays them out again against the course at the destination, which is why the copy is turned the way the new course is turned. The destination course is chosen silently: the one selected there, else one of the source's type, else the first there, else none. One switch in the dialog falls back to the target as it stands. (Owner's call: no course picker — the offset is visible on the map the moment the copy loads.)

The canopy is a label on the setup ("SAW 75"), typed rather than derived, because nothing in the app models a canopy yet and the glide ratio and descent rate that describe one are numbers you cannot read a name off. It can go stale against them; BACKLOG's canopy + wing-loading entry is what eventually replaces it. Everything else in the row's second line IS derived and cannot disagree with the setup: the DROPZONE, the turn (turnLabel, measured off the path for tracks and samples), the course, and the mode when it is not the one you are in.

The dropzone is named only where the list does not already say it — the menu's "Other dropzones" group and the manage dialog, which has no groups at all; under "At <place>" it would repeat that heading on every row. It comes first, since it is what tells two setups called "ZoneAcc" apart, and it is why the NAME does not have to carry the place. Those two lists also shorten the course (courseChipLabel(course, true) → "ZoneAcc"), which shortens the TYPE: a built-in course is named after its type, while a custom one keeps the name its owner gave it.

Keyboard + help. core/keymap.ts is one table driving both the key handler (hooks/useKeyboardShortcuts) and the ? overlay, gated per mode; it also documents mouse gestures. With no heading field left, four bindings ARE the heading's typed interface: < > turn five degrees, , . turn one, and u lands into wind — as does CLICKING the map's rotate handle, which had no meaning before. Settings gave up , for the fine step and took shift+s. F hides all chrome but the map. X flips the pattern's turns; Shift+X mirrors the MANOEUVRE — core/manoeuvre.mirrorManoeuvre decides what that means per type: flip turnDirection for a parametric turn (the offset is measured on the turn side, so nothing else moves), flip sampleLeft for a sample, and mirror the points of a recorded track, which carries no handedness. A shifted letter is its own combo (shift+x), keyed off shiftKey so caps lock still types plain letters — the cost is that Shift+P no longer opens the Pattern panel. core/help.ts holds the in-app reference as data — a topic per panel, reached from each panel header's ? (/help?topic=<id>) or the Help nav item, which replaced About. The old measure tool and average-wind arrow were removed (measure to be reimplemented — see BACKLOG).

UI conventions (one way to do each thing)

The panels had drifted into three numeric fields, five section headings, two disclosure styles, three reset idioms and thirteen map-label styles. The shared pieces below are the whole vocabulary; reach for one before writing a new look.

Thing Use Notes
A number components/NumberField Floating label, unit inside, helper text free for a bound. Bounds required (limits) unless the value is cyclic (wrap), and the type enforces the choice. 220px alone; fullWidth when sharing a row.
A prefilled field components/selectOnFocus onFocus handler selecting the value, so the next keystroke replaces it. NumberField applies it already; use it directly on what cannot be one — a coordinate, the current name in a rename dialog. Not on free text or a native date/time input.
A section components/PanelSection / SectionHeading Uppercase caption, optional action on the heading row. The Accordion (Flocking) is only for sections long enough to be worth collapsing.
A folded section components/DisclosureRow Chevron carries the state, so the label never renames itself.
A panel title the App panel header Panels never render their own — Courses used to, and said "Courses" twice. Panel-scoped actions (Wind's refresh, Courses' New) go in the header or on a section heading.
A button contained in dialogs only outlined for a panel's own action, text inline, size="small" throughout.
A reset text button, gated Shown only when there is something to restore, with a describeChild tooltip naming what — without that flag MUI makes the tooltip the button's accessible NAME.
A map label map/layers/labelStyles.mapLabel() One background, one radius, three sizes (sm annotates, md is default, lg is an answer). Colour is meaning; nothing else varies.
A wind direction components/WindArrow Downwind-pointing, Beaufort-coloured. degreesTooltip only where the arrow is the sole direction cue — beside a printed number it would repeat the screen.

Panels are left-aligned by their container (App.tsx), not by each component: the container used to centre text and every panel undid it by hand.

A panel's local state must not outlive the value it describes. This has bitten twice: the Courses panel's depth/offset (state synced by an effect that deliberately excluded the target, so a drag left it stale AND wrote the stale value back), and the manoeuvre's "the user asked for Custom" flag, which stayed on when a setup load put a preset rotation in. A value the map, a shortcut, or a setup can also change has to be derivedNumberField holds its own text while typing, so deriving does not fight the user — and a mode flag over such a value has to be reset when the value lands somewhere that contradicts it. PatternComponent's leg-altitude selector is the worked example, and every case above is now pinned by a component test.

Key Components

Component File Purpose
App App.tsx Main orchestrator, path derivation, navigation
MapComponent components/MapComponent.tsx Google Maps: paths, tooltips, drag handles, stations, winds indicator
PatternComponent components/PatternComponent.tsx Landing pattern parameters UI
ManoeuvreComponent components/ManoeuvreComponent.tsx Manoeuvre source selection (params/track/samples)
TargetComponent components/TargetComponent.tsx The Location panel: hero card + place picker
LocationHero components/LocationHero.tsx The active place, at the top of the Location panel
PlacePicker components/PlacePicker.tsx One searchable list: saved places, dropzones, geocoder hits
HelpComponent components/HelpComponent.tsx In-app reference: topic list, drill-in; absorbed About
WindsComponent components/WindsComponent.tsx Wind table, forecast time picker, stations
CoursesComponent components/CoursesComponent.tsx Course selection and editing
SettingsComponent components/SettingsComponent.tsx App preferences
SetupSelector components/SetupSelector.tsx The setups menu: switch, save, discard, copy
FlockingComponent components/FlockingComponent.tsx Flocking panel: classic/free/solve sub-modes
Wordmark components/Wordmark.tsx The FliP/FloP mark and name, and the switch between them

State Management

hooks/useAppState.tsx — React context. Configs (patternParams, manoeuvreConfig, flockingParams, per-mode targets, settings) are the source of truth, persisted via Toolpad's useLocalStorageState behind versioned codecs (util/storage.ts + migrate* in core/model.ts, which must never throw on bad data); flight paths are derived with useMemo. Setups snapshot/restore these configs (hooks/useSetups.ts).

Settings resolution: mode defaults apply only to settings the user has never changed — flip.settings.touched records explicit edits, so a user can force a mode-overridden setting back to the global default.

Core Modules (src/core/, all pure + unit-tested)

File Purpose
core/geometry.ts translate(), reposition(), addWind(), averageWind(), straightenLegs(), mirror()
core/pattern.ts makePatternByType() — landing pattern from parameters
core/manoeuvre.ts solveManoeuvre() + createManoeuvrePath() (the turn), describeManoeuvrePath() (measure one back), initiation-altitude scaling
core/wind.ts WindProfile data + pure helpers (getWindAt, vector interpolation, Beaufort, row provenance, sampleWindBands for the shared by-altitude summary, forecastHourOffset)
core/flocking.ts Flocking math: path, into-wind, drift vectors, FWC spot description, jumprun line helpers
core/flockingSolve.ts Analytic corridor solver: tiers + into-wind preference
core/courses.ts Course geometry (buoys, gates, lines); BUILT_IN_PARAMS, the per-place filter (coursesForPlace, courseIsAtPlace) and naming (courseTypeLabel, defaultCourseName)
core/pathStats.ts Per-leg/manoeuvre stats, driftAngle, groundSpeedKts, cumulativeTurnDeg
core/units.ts Unit conversions + preferences (incl. mi/nm/km distances)
core/validation.ts LIMITS, clamping, direction normalization (normalizeDirection absolute, normalizeRelativeAngle signed)
core/model.ts Versioned document defaults + migrate* loaders
core/places.ts Place list assembly + search ranking (buildPlaces, rankPlaces); place ids (dropzonePlaceId, placeNameFromId)
core/regions.ts State/country short forms, so "az" finds Arizona
core/setups.ts Setups: setupDiff (which parts are unsaved), describeSetup/turnLabel (the chip line), groupSetups, planSetupCopy (the course-relative copy)
core/courses.ts (labels) courseTypeLabel, courseTypeShortLabel, courseChipLabel — the last picks between a custom course's own name and the short type
core/keymap.ts Keyboard bindings + gestures; one table for handler and overlay
core/help.ts Help topics as data (HELP_TOPICS, topicForPanel)
modes/nerd.ts Nerd-mode flag: withNerd() mode transform, applyNerdGate() settings mask
util/dropzones.ts Dropzone database (274 entries; direction, website, town/region/country and per-mode config only where known)
util/exportKmz.ts, util/exportFlySight.ts Exports (DOM/download side effects, so not in core)

Wind System (src/data/wind/)

Sources implement a WindSource plugin interface (source.ts):

  • OpenMeteo (openmeteo.ts) — winds aloft at 27 pressure levels (to ~41k ft), selectable model, prefetches a ≥24 h window so hour-switching and the scrubber are local.
  • Soundings (soundings.ts) — Iowa Environmental Mesonet RAOB.
  • Observed stations (stations/) — NWS gridpoint discovery, CSC (GraphQL WS), Spaceland (Socket.IO); the nearest can be injected as ground wind (useDzGroundWind).
  • compose.ts merges aloft + observed ground into the effective profile. hooks/useWinds.ts is the app-facing facade over all of it.

Wind application algorithm

core/geometry.addWind(): the path is processed backward from the landing point (held fixed); each earlier point is offset by the cumulative drift, accumulated as a flat east/north vector (a polar accumulation used to wander and visibly curved paths whose drift nearly cancels the flown line — see NOTES).

Flocking mode

core/flocking.ts + hooks/useFlockingPath.ts + FlockingComponent + map/layers/FlockingLayer.tsx. Three sub-modes:

  • classic — the original Flocking Wind Calculator model: pick the canopy flight direction, the jumprun IS that direction, one unique exit.
  • free — you own the jumprun line (direction + lateral offset), the exit on it, and the canopy direction; the app reports where the jump ends and how far off target.
  • solve — describe allowed jumprun corridors (nameable, individually enabled) and let core/flockingSolve.ts pick. Selection is NOT plain miss minimization: misses are tiered by the green/yellow rings and corridors that both reach green are separated by which run is most into the wind, which keeps the answer stable as a forecast drifts.

The no-wind ghost is drawn from the exit (anchorAtExit in useFlockingPath). The model builds both paths sharing an END, because addWind holds the landing point and accumulates the drift backwards — right for measuring, wrong for drawing: the ghost then started at the exit you would have needed in still air, which is nobody's exit, and ended on the target, which is where the wind puts you. Translated onto the real exit it reads as the jump being planned — leave here, no wind, end up there — and the gap at the far end IS the drift. Only the drawn path moves; flockingVectors and averageWind keep the end-aligned pair, since both measure the gap between the two.

The spot is the output, so it is treated as one: "Jumprun 248˚ · 3.41 mi prior · 0.42 mi left" is written by core/spotText.formatSpot() and nothing else, and every surface that says it reads that one function — the panel's sticky SpotHero (display type, first thing in the panel, stays put while the inputs scroll), the top bar, which in flocking shows the spot INSTEAD of the wind summary (the map's winds indicator already carries GND and the bands; the pilot handoff outranks them), and the map's pill label at the exit. Two readouts rounding the same distance differently would be a bug, so they cannot be built separately. useCopySpot hands it on — clipboard, or the share sheet where the platform has one — from the top bar and the hero, and reports failure rather than pretending. The copy text is the spot alone: the reference point is agreed offline, and the corridor name and the forecast time are FliP's business, not the aircraft's. The map label is deliberately NOT clickable: map overlays live in Google's overlayLayer pane, which takes no mouse events, and the interactive panes sit above every marker — a clickable label would shadow the drag handles beside it. verdict ("MISSES by 0.80 mi") sits beside the spot on every surface but never inside the copied line; it describes the jumper's own setup, not where the plane should fly.

Coordinate Formats

  • Flip Format (internal): GeoJSON Feature<Point> with properties { alt, time, pom, phase } (FlightPoint in types/index.ts)
  • Google Maps: { lat, lng } literals / LatLng interface

Conversions handled in util/coords.ts and util/geo.ts.