Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
3697d45
feat(fpv-drone): freeze the ids, layout, scale, theme and assembly co…
veelenga Oct 2, 2026
3af65bb
feat(fpv-drone): add the sortie clock, flight path and figures model
veelenga Oct 2, 2026
20980eb
feat(fpv-drone): add the motor mix, tilt, power, payload and battery …
veelenga Oct 2, 2026
4e0de5a
feat(fpv-drone): add the link, flight mode and speed comparison model
veelenga Oct 2, 2026
c561d8c
feat(fpv-drone): add the timeline, number formats and phase palette
veelenga Oct 2, 2026
bfe96f6
feat(fpv-drone): add the presets, store and derived sortie readings
veelenga Oct 2, 2026
30ca0b4
feat(fpv-drone): add the dock, gauge readouts, chapter actions and wi…
veelenga Oct 2, 2026
294fd74
feat(fpv-drone): add the camera views, scene controller, bindings and…
veelenga Oct 2, 2026
aafa7d3
feat(fpv-drone): add the explainer entry, manifest, styles and placeh…
veelenga Oct 2, 2026
9a367fc
feat(fpv-drone): add the social card template
veelenga Oct 2, 2026
5430913
feat(fpv-drone): write the English chapters and locale
veelenga Oct 2, 2026
5c1c5cf
feat(fpv-drone): translate the copy into seven languages
veelenga Oct 2, 2026
60a3a29
feat(fpv-drone): ship the explainer in all eight languages
veelenga Oct 2, 2026
236cea0
feat(fpv-drone): add the scene constants and finishes
veelenga Oct 2, 2026
9b7aee4
feat(fpv-drone): add the geometry helpers and the part context
veelenga Oct 2, 2026
eca9860
feat(fpv-drone): build the drone with its frame, motors, props, batte…
veelenga Oct 2, 2026
f2cc610
feat(fpv-drone): add the field, roads, treeline, shrubs, sky and cros…
veelenga Oct 2, 2026
f1232ee
feat(fpv-drone): add the pilot's station and the launch pad
veelenga Oct 2, 2026
896e6cb
feat(fpv-drone): add the link beams and the flight track
veelenga Oct 2, 2026
e7f936c
feat(fpv-drone): assemble the scene with regions, options and lighting
veelenga Oct 2, 2026
667aa13
test(fpv-drone): cover the assembly anchors, regions, spin and view f…
veelenga Oct 2, 2026
b094e6a
feat(fpv-drone): start the fog beyond the crossroads
veelenga Oct 2, 2026
71fae00
test(fpv-drone): cover the blade, trail, textures, world scatter, reg…
veelenga Oct 2, 2026
ed2e4ca
feat(fpv-drone): draw the radio links as thin fading paths
veelenga Oct 2, 2026
32d196e
feat(fpv-drone): stand the drone low on short printed feet
veelenga Oct 2, 2026
9e6c9ec
feat(fpv-drone): paint the dirt roads in earth tones with wheel ruts
veelenga Oct 2, 2026
6358da7
feat(fpv-drone): give the field mown bands, tufts and bare earth spots
veelenga Oct 2, 2026
246412a
feat(fpv-drone): add a pillow geometry for sandbags
veelenga Oct 2, 2026
03846fb
feat(fpv-drone): stack flat sandbags in three rows on the dugout logs
veelenga Oct 2, 2026
91fdf90
feat(fpv-drone): shape the wreck as a pickup and give the shed a door…
veelenga Oct 2, 2026
55299fa
feat(fpv-drone): vary the conifers and branch the bare poplars
veelenga Oct 2, 2026
7c648c6
feat(fpv-drone): switch to the real scene with its lighting and options
veelenga Oct 2, 2026
e398fb2
fix(fpv-drone): match the power readouts to what the model renders
veelenga Oct 2, 2026
f70955e
fix(fpv-drone): say what the tilt thrust is a share of
veelenga Oct 2, 2026
b794e0a
fix(fpv-drone): print the receiver sensitivity with a true minus sign
veelenga Oct 2, 2026
aa74228
fix(fpv-drone): take the acro rate in the mode limit from the model
veelenga Oct 2, 2026
1b9c239
refactor(fpv-drone): give minutes per hour its own constant
veelenga Oct 2, 2026
8a610cd
fix(fpv-drone): lighten the flight mode chip tone to match its siblings
veelenga Oct 2, 2026
cce5177
fix(fpv-drone): copy fixes from review
veelenga Oct 2, 2026
6b52064
fix(fpv-drone): review fixes for the uk, zh and ja copy
veelenga Oct 2, 2026
f87ff07
fix(fpv-drone): review fixes for the de, fr, es and pt copy
veelenga Oct 2, 2026
6cb1505
feat(core): let an explainer set the camera target's floor margin
veelenga Oct 2, 2026
e90d8b3
fix(fpv-drone): frame the whole drone and keep it clear of the dock
veelenga Oct 2, 2026
4c8ce3e
fix(fpv-drone): fit follow views to the tighter stage dimension
veelenga Oct 2, 2026
b106869
feat(core): let a follow view turn with its anchor's heading or attitude
veelenga Oct 2, 2026
76510c0
fix(fpv-drone): turn the follow views with the drone and bank the gog…
veelenga Oct 2, 2026
2175600
fix(fpv-drone): keep the flight going when the flight chapter opens
veelenga Oct 2, 2026
636e645
feat(fpv-drone): draw the downwash under each propeller
veelenga Oct 2, 2026
68d210c
feat(fpv-drone): blow a dust wash over the ground at takeoff and landing
veelenga Oct 2, 2026
3152f90
feat(fpv-drone): deepen the sky to blue overhead with soft clouds ove…
veelenga Oct 2, 2026
6a2244c
feat(fpv-drone): stream thin wind streaks past the fpv camera at cruise
veelenga Oct 2, 2026
ab0aa13
fix(fpv-drone): keep only the gauge column out of the frame and bring…
veelenga Oct 2, 2026
2c344ac
feat(fpv-drone): add the cover and the link preview card
veelenga Oct 2, 2026
a263e85
feat(fpv-drone): mark the goggles view on the stage and in the copy
veelenga Oct 3, 2026
39fa9e6
fix(fpv-drone): frame the goggles view as a visor and drop it when th…
veelenga Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion explainers/black-hole/src/scene/cameraViews.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('camera views', () => {

it('sits the probe camera 2.6 units from the probe and follows it', () => {
const { spec, pose } = customPose('probe', ANCHORS);
expect(spec.follow).toBe(true);
expect(spec.follow).toBe('position');
expect(spec.distance).toEqual(PROBE_DISTANCE);
expect(pose?.target).toEqual(ANCHORS.probe);
expect(pose?.position.distanceTo(ANCHORS.probe)).toBeCloseTo(2.6, 5);
Expand Down
2 changes: 1 addition & 1 deletion explainers/black-hole/src/scene/cameraViews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function cameraViews(
): Readonly<Record<CameraView, ViewSpec<RegionId>>> {
return {
...FRAMED_VIEWS,
probe: customView(anchors, probePose, { follow: true, distance: PROBE_DISTANCE }),
probe: customView(anchors, probePose, { follow: 'position', distance: PROBE_DISTANCE }),
ship: customView(anchors, shipPose, { distance: SHIP_DISTANCE }),
};
}
3 changes: 2 additions & 1 deletion explainers/black-hole/src/scene/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,11 @@ describe('black hole controller', () => {
controller.views.frame('probe', false);
expect(rig.follow).toHaveBeenLastCalledWith(
expect.objectContaining({ position: expect.anything() }),
'position',
);
expect(rig.jumpTo.mock.lastCall?.[0]?.target).toEqual(new Vector3(4, 3, 0));
controller.views.frame('ship', false);
expect(rig.follow).toHaveBeenLastCalledWith(null);
expect(rig.follow).toHaveBeenLastCalledWith(null, undefined);
expect(rig.jumpTo.mock.lastCall?.[0]?.position).toEqual(new Vector3(16, 12, 0));
});

Expand Down
632 changes: 632 additions & 0 deletions explainers/fpv-drone/chapters.html

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions explainers/fpv-drone/explainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"slug": "fpv-drone",
"tags": ["aircraft", "flight", "weapons"],
"cover": "cover.webp",
"entry": "src/index.ts",
"chapters": "chapters.html",
"locales": ["en", "zh", "es", "uk", "pt", "fr", "de", "ja"],
"social": {
"image": "social/og-image.png",
"alt": "A camouflaged FPV quadcopter banking low over a field under a grey sky, its four propellers blurred and a radio beam reaching back to a pilot's dugout, beside the title How an FPV drone flies"
}
}
290 changes: 290 additions & 0 deletions explainers/fpv-drone/locales/de.json

Large diffs are not rendered by default.

290 changes: 290 additions & 0 deletions explainers/fpv-drone/locales/en.json

Large diffs are not rendered by default.

290 changes: 290 additions & 0 deletions explainers/fpv-drone/locales/es.json

Large diffs are not rendered by default.

290 changes: 290 additions & 0 deletions explainers/fpv-drone/locales/fr.json

Large diffs are not rendered by default.

290 changes: 290 additions & 0 deletions explainers/fpv-drone/locales/ja.json

Large diffs are not rendered by default.

290 changes: 290 additions & 0 deletions explainers/fpv-drone/locales/pt.json

Large diffs are not rendered by default.

290 changes: 290 additions & 0 deletions explainers/fpv-drone/locales/uk.json

Large diffs are not rendered by default.

290 changes: 290 additions & 0 deletions explainers/fpv-drone/locales/zh.json

Large diffs are not rendered by default.

Binary file added explainers/fpv-drone/public/cover.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added explainers/fpv-drone/public/social/og-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
123 changes: 123 additions & 0 deletions explainers/fpv-drone/src/ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
export const PART_IDS = [
'frame',
'motorFrontLeft',
'motorFrontRight',
'motorRearLeft',
'motorRearRight',
'propellers',
'battery',
'stack',
'camera',
'videoAntenna',
'receiverAntenna',
'gpsModule',
'groundStation',
'controlLink',
'videoLink',
'spinArrows',
'crossroads',
'launchPad',
] as const;

export type PartId = (typeof PART_IDS)[number];

export const MOTOR_PART_IDS = [
'motorRearRight',
'motorFrontRight',
'motorRearLeft',
'motorFrontLeft',
] as const satisfies readonly PartId[];

export type MotorPartId = (typeof MOTOR_PART_IDS)[number];

export const PHASE_IDS = ['takeoff', 'climb', 'transit', 'orbit', 'return', 'landing'] as const;

export type PhaseId = (typeof PHASE_IDS)[number];

export const MOMENT_IDS = ['liftoff', 'cruise', 'onStation', 'turnHome', 'touchdown'] as const;

export type MomentId = (typeof MOMENT_IDS)[number];

export const VIDEO_IDS = ['analogue', 'digital'] as const;

export type VideoId = (typeof VIDEO_IDS)[number];

export const MOVE_IDS = ['hover', 'forward', 'roll', 'yaw', 'climb'] as const;

export type MoveId = (typeof MOVE_IDS)[number];

export const FLIGHT_MODE_IDS = ['acro', 'angle', 'horizon'] as const;

export type FlightModeId = (typeof FLIGHT_MODE_IDS)[number];

export const PACKET_RATES = [50, 150, 250, 500] as const;

export type PacketRate = (typeof PACKET_RATES)[number];

export const SPEEDSTER_IDS = ['longRange', 'racer', 'record'] as const;

export type SpeedsterId = (typeof SPEEDSTER_IDS)[number];

export const PRESET_IDS = ['overview', 'flight', 'controller', 'link', 'power', 'limits'] as const;

export type PresetId = (typeof PRESET_IDS)[number];

export type CameraView = 'chase' | 'top' | 'close' | 'side' | 'pilot' | 'fpv';

export type RegionId = 'scene' | 'station' | 'drone' | 'crossroads' | 'route';

export type AnchorId = 'drone' | 'camera' | 'station' | 'crossroads';

export type SpinDirection = 'clockwise' | 'counterClockwise';

export type Point = readonly [x: number, y: number, z: number];

export type MotorShares = readonly [m1: number, m2: number, m3: number, m4: number];

export interface FlightReading {
position: Point;
heading: number;
pitch: number;
roll: number;
speedKmh: number;
height: number;
verticalSpeed: number;
onGround: boolean;
armed: boolean;
propRate: number;
}

export interface MotorReading {
shares: MotorShares;
}

export interface BatteryReading {
share: number;
volts: number;
amps: number;
usedMah: number;
}

export interface LinkReading {
distance: number;
signal: number;
}

export interface ViewOptions {
links: boolean;
track: boolean;
arrows: boolean;
labels: boolean;
}

export interface AssemblyState {
phase: number;
flight: FlightReading;
motors: MotorReading;
battery: BatteryReading;
link: LinkReading;
video: VideoId;
move: MoveId;
playing: boolean;
view: ViewOptions;
}
22 changes: 22 additions & 0 deletions explainers/fpv-drone/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import './style.css';
import { defineExplainer } from '@core/explainer';
import { SCENE_OPTIONS, mountFpvScene } from './scene';
import { PART_INFO } from './scene/partInfo';
import { PRESETS, createFpvStore } from './state';
import type { FpvStoreState } from './state';
import { FPV_TIMELINE } from './timeline';
import { CHAPTER_ACTIONS, FPV_CHOICES, FPV_READOUTS, VIEW_TOGGLES, mountFpvUi } from './ui';

export default defineExplainer<FpvStoreState>({
id: 'fpv-drone',
timeline: FPV_TIMELINE,
presets: PRESETS,
parts: PART_INFO,
createStore: () => createFpvStore(),
dock: { choices: FPV_CHOICES, toggles: VIEW_TOGGLES },
readouts: FPV_READOUTS,
actions: CHAPTER_ACTIONS,
scene: SCENE_OPTIONS,
mountScene: mountFpvScene,
mountUi: mountFpvUi,
});
75 changes: 75 additions & 0 deletions explainers/fpv-drone/src/model/battery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import {
BATTERY,
BATTERY_GRID_S,
batteryAt,
loadedVolts,
openCircuitVolts,
usedMahAt,
wattsAt,
} from './battery';
import { MASS_G } from './figures';
import { flightAt } from './flight';
import { allUpG } from './payload';
import { cruisePowerW, hoverPowerW } from './power';
import { SORTIE_SECONDS } from './sortie';

const PAYLOAD = MASS_G.defaultPayload;

describe('battery', () => {
it('is a 6S Li-ion pack with the sheet figures', () => {
expect(BATTERY.cells).toBe(6);
expect(BATTERY.nominalVolts).toBeCloseTo(21.6, 9);
expect(BATTERY.fullVolts).toBeCloseTo(25.2, 9);
expect(BATTERY.emptyVolts).toBe(18);
expect(BATTERY.capacityMah).toBe(4200);
expect(BATTERY.resistanceOhm).toBe(0.06);
expect(BATTERY_GRID_S).toBe(0.5);
});

it('sags under load and rests at the open circuit voltage', () => {
expect(openCircuitVolts(1)).toBeCloseTo(BATTERY.fullVolts, 9);
expect(openCircuitVolts(0)).toBe(BATTERY.emptyVolts);
expect(loadedVolts(25.2, 0)).toBeCloseTo(25.2, 9);
const sagged = loadedVolts(25.2, 400);
expect(sagged).toBeLessThan(25.2);
expect(sagged).toBeGreaterThan(24);
expect((400 / sagged) * BATTERY.resistanceOhm).toBeCloseTo(25.2 - sagged, 6);
});

it('draws the hover power at rest and a little less in cruise at the default load', () => {
const hover = wattsAt(flightAt(5), PAYLOAD);
expect(hover).toBeCloseTo(hoverPowerW(allUpG(PAYLOAD)), 6);
const cruise = wattsAt(flightAt(20), PAYLOAD);
expect(cruise).toBeCloseTo(cruisePowerW(allUpG(PAYLOAD)), 1);
expect(cruise).toBeLessThan(hover);
expect(wattsAt(flightAt(SORTIE_SECONDS), PAYLOAD)).toBe(0);
});

it('starts full and only ever drains', () => {
expect(batteryAt(0, PAYLOAD)).toMatchObject({ share: 1, usedMah: 0 });
expect(batteryAt(0, PAYLOAD).volts).toBeCloseTo(BATTERY.fullVolts, 9);
let previous = 0;
for (let seconds = 0; seconds <= SORTIE_SECONDS; seconds += 0.25) {
const used = usedMahAt(seconds, PAYLOAD);
expect(used).toBeGreaterThanOrEqual(previous);
previous = used;
}
});

it('lands the short sortie with most of the pack left', () => {
const { share, volts, amps } = batteryAt(SORTIE_SECONDS, PAYLOAD);
expect(share).toBeGreaterThan(0.85);
expect(share).toBeLessThan(0.93);
expect(volts).toBeLessThan(BATTERY.fullVolts);
expect(amps).toBe(0);
expect(batteryAt(40, PAYLOAD).amps).toBeGreaterThan(5);
expect(batteryAt(40, PAYLOAD).amps).toBeLessThan(30);
});

it('uses more with a heavier payload and reuses its integration', () => {
const heavy = batteryAt(SORTIE_SECONDS, 1500);
expect(heavy.share).toBeLessThan(batteryAt(SORTIE_SECONDS, PAYLOAD).share);
expect(batteryAt(30, 1500).usedMah).toBe(batteryAt(30, 1500).usedMah);
});
});
81 changes: 81 additions & 0 deletions explainers/fpv-drone/src/model/battery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { clamp, lerp } from '@core/math';
import type { BatteryReading } from '../ids';
import { PACK } from './figures';
import { flightAt } from './flight';
import type { FlightMotion } from './flight';
import { totalThrustG } from './motors';
import { allUpG } from './payload';
import { powerAt } from './power';
import { MINUTES_PER_HOUR, SECONDS_PER_MINUTE, SORTIE_SECONDS, clampSeconds } from './sortie';

export const BATTERY = {
cells: PACK.cells,
nominalVolts: PACK.cells * PACK.cellNominalV,
fullVolts: PACK.cells * PACK.cellFullV,
emptyVolts: PACK.cells * PACK.cellEmptyV,
capacityMah: PACK.capacityMah,
massG: PACK.massG,
resistanceOhm: PACK.resistanceOhm,
} as const;

export const BATTERY_GRID_S = 0.5;

const SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR;
const MILLI = 1000;
const GRID_STEPS = Math.ceil(SORTIE_SECONDS / BATTERY_GRID_S);
const HALF = 0.5;

const usedMahGrids = new Map<number, Float64Array>();

export function openCircuitVolts(share: number): number {
return lerp(BATTERY.emptyVolts, BATTERY.fullVolts, clamp(share, 0, 1));
}

export function loadedVolts(openVolts: number, watts: number): number {
const discriminant = openVolts * openVolts - 4 * watts * BATTERY.resistanceOhm;
if (discriminant <= 0) return openVolts * HALF;
return (openVolts + Math.sqrt(discriminant)) * HALF;
}

export function wattsAt(flight: FlightMotion, payloadG: number): number {
return powerAt(totalThrustG(flight, allUpG(payloadG)), flight.speedKmh);
}

function shareLeft(usedMah: number): number {
return clamp(1 - usedMah / BATTERY.capacityMah, 0, 1);
}

function integrate(payloadG: number): Float64Array {
const grid = new Float64Array(GRID_STEPS + 1);
let used = 0;
for (let step = 0; step < GRID_STEPS; step += 1) {
const watts = wattsAt(flightAt(step * BATTERY_GRID_S), payloadG);
const volts = loadedVolts(openCircuitVolts(shareLeft(used)), watts);
used += ((watts / volts) * BATTERY_GRID_S * MILLI) / SECONDS_PER_HOUR;
grid[step + 1] = used;
}
return grid;
}

function usedMahGrid(payloadG: number): Float64Array {
const remembered = usedMahGrids.get(payloadG);
if (remembered) return remembered;
const grid = integrate(payloadG);
usedMahGrids.set(payloadG, grid);
return grid;
}

export function usedMahAt(seconds: number, payloadG: number): number {
const grid = usedMahGrid(payloadG);
const position = clampSeconds(seconds) / BATTERY_GRID_S;
const index = Math.min(Math.floor(position), GRID_STEPS - 1);
return lerp(grid[index], grid[index + 1], position - index);
}

export function batteryAt(seconds: number, payloadG: number): BatteryReading {
const usedMah = usedMahAt(seconds, payloadG);
const share = shareLeft(usedMah);
const watts = wattsAt(flightAt(seconds), payloadG);
const volts = loadedVolts(openCircuitVolts(share), watts);
return { share, volts, amps: watts / volts, usedMah };
}
46 changes: 46 additions & 0 deletions explainers/fpv-drone/src/model/figures.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
HOVER_POWER_REF,
MASS_G,
PACK,
SENSITIVITY_DBM,
THRUST_PER_MOTOR_G,
TOP_SPEED_KMH,
VIDEO_FIGURES,
} from './figures';

describe('figures', () => {
it('takes the masses and the thrust from the facts sheet', () => {
expect(MASS_G).toEqual({ dry: 650, battery: 450, defaultPayload: 300, maxPayload: 1500 });
expect(THRUST_PER_MOTOR_G).toEqual({ flight: 2000, bench: 2200 });
});

it('describes the 6S Li-ion pack', () => {
expect(PACK.cells).toBe(6);
expect(PACK.cells * PACK.cellNominalV).toBeCloseTo(21.6, 9);
expect(PACK.cells * PACK.cellFullV).toBeCloseTo(25.2, 9);
expect(PACK.cells * PACK.cellEmptyV).toBe(18);
expect(PACK.capacityMah).toBe(4200);
expect(PACK.energyWh).toBe(91);
expect(PACK.massG).toBe(450);
});

it('references the hover power at the all-up weight without payload', () => {
expect(HOVER_POWER_REF.allUpG).toBe(MASS_G.dry + MASS_G.battery);
});

it('lists the receiver sensitivity per packet rate and the video systems', () => {
expect(SENSITIVITY_DBM).toEqual({ 50: -115, 150: -112, 250: -108, 500: -105 });
expect(VIDEO_FIGURES.analogue).toEqual({ latencyMinMs: 20, latencyMaxMs: 30, channels: 40 });
expect(VIDEO_FIGURES.digital).toEqual({
latencyMs: 20,
rangeKm: 10,
lines: 1080,
framesPerSecond: 100,
});
});

it('quotes the three top speeds', () => {
expect(TOP_SPEED_KMH).toEqual({ longRange: 140, racer: 263, record: 657.59 });
});
});
Loading
Loading