From 5c68a1ba9a46217ca4454e9628a63c855caade5b Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 09:43:32 +0200 Subject: [PATCH 01/99] feat(naval-drone): add the ids, run, hull and jet model, and assembly contract --- explainers/naval-drone/src/ids.ts | 161 ++++++++ explainers/naval-drone/src/model/hull.test.ts | 94 +++++ explainers/naval-drone/src/model/hull.ts | 114 ++++++ explainers/naval-drone/src/model/jet.test.ts | 77 ++++ explainers/naval-drone/src/model/jet.ts | 102 +++++ .../naval-drone/src/model/keyframes.test.ts | 33 ++ explainers/naval-drone/src/model/keyframes.ts | 45 +++ .../naval-drone/src/model/layout.test.ts | 121 ++++++ explainers/naval-drone/src/model/layout.ts | 256 +++++++++++++ explainers/naval-drone/src/model/run.test.ts | 134 +++++++ explainers/naval-drone/src/model/run.ts | 177 +++++++++ .../naval-drone/src/model/scale.test.ts | 18 + explainers/naval-drone/src/model/scale.ts | 27 ++ explainers/naval-drone/src/model/sea.test.ts | 24 ++ explainers/naval-drone/src/model/sea.ts | 25 ++ explainers/naval-drone/src/scene/assembly.ts | 33 ++ .../src/scene/placeholderAssembly.dom.test.ts | 106 ++++++ .../src/scene/placeholderAssembly.ts | 351 ++++++++++++++++++ explainers/naval-drone/src/scene/pose.test.ts | 38 ++ explainers/naval-drone/src/scene/pose.ts | 14 + explainers/naval-drone/src/theme.ts | 37 ++ 21 files changed, 1987 insertions(+) create mode 100644 explainers/naval-drone/src/ids.ts create mode 100644 explainers/naval-drone/src/model/hull.test.ts create mode 100644 explainers/naval-drone/src/model/hull.ts create mode 100644 explainers/naval-drone/src/model/jet.test.ts create mode 100644 explainers/naval-drone/src/model/jet.ts create mode 100644 explainers/naval-drone/src/model/keyframes.test.ts create mode 100644 explainers/naval-drone/src/model/keyframes.ts create mode 100644 explainers/naval-drone/src/model/layout.test.ts create mode 100644 explainers/naval-drone/src/model/layout.ts create mode 100644 explainers/naval-drone/src/model/run.test.ts create mode 100644 explainers/naval-drone/src/model/run.ts create mode 100644 explainers/naval-drone/src/model/scale.test.ts create mode 100644 explainers/naval-drone/src/model/scale.ts create mode 100644 explainers/naval-drone/src/model/sea.test.ts create mode 100644 explainers/naval-drone/src/model/sea.ts create mode 100644 explainers/naval-drone/src/scene/assembly.ts create mode 100644 explainers/naval-drone/src/scene/placeholderAssembly.dom.test.ts create mode 100644 explainers/naval-drone/src/scene/placeholderAssembly.ts create mode 100644 explainers/naval-drone/src/scene/pose.test.ts create mode 100644 explainers/naval-drone/src/scene/pose.ts create mode 100644 explainers/naval-drone/src/theme.ts diff --git a/explainers/naval-drone/src/ids.ts b/explainers/naval-drone/src/ids.ts new file mode 100644 index 00000000..e5afd423 --- /dev/null +++ b/explainers/naval-drone/src/ids.ts @@ -0,0 +1,161 @@ +export const PART_IDS = [ + 'hull', + 'chines', + 'sprayRails', + 'payloadBay', + 'fuelTanks', + 'engine', + 'electronicsBay', + 'starlinkPanels', + 'backupPanel', + 'cameraDome', + 'bowCamera', + 'intake', + 'duct', + 'driveShaft', + 'impeller', + 'waterjet', + 'stator', + 'nozzle', + 'steeringNozzle', + 'reverseBucket', + 'missileRails', + 'jetStream', + 'bowWave', + 'spray', + 'wake', + 'wettedLength', + 'videoGhost', + 'satLink', + 'backupLink', + 'satellite', + 'backupSatellite', + 'groundStation', + 'ship', + 'shipRadar', + 'companions', +] as const; + +export type PartId = (typeof PART_IDS)[number]; + +export const PHASE_IDS = ['launch', 'hump', 'cruise', 'sprint', 'arrival'] as const; + +export type PhaseId = (typeof PHASE_IDS)[number]; + +export const MOMENT_IDS = [ + 'hullSpeed', + 'humpPeak', + 'onPlane', + 'cruiseSpeed', + 'throttleUp', + 'topSpeed', + 'alongside', +] as const; + +export type MomentId = (typeof MOMENT_IDS)[number]; + +export const PRESET_IDS = ['overview', 'hull', 'jet', 'link', 'horizon', 'fleet'] as const; + +export type PresetId = (typeof PRESET_IDS)[number]; + +export const FIT_IDS = ['standard', 'missile'] as const; + +export type FitId = (typeof FIT_IDS)[number]; + +export const HELM_IDS = ['left', 'straight', 'right', 'reverse'] as const; + +export type HelmId = (typeof HELM_IDS)[number]; + +export const LINK_MODES = ['satellite', 'backup', 'lost'] as const; + +export type LinkMode = (typeof LINK_MODES)[number]; + +export const SEA_STATE_IDS = ['smooth', 'slight', 'moderate', 'rough'] as const; + +export type SeaStateId = (typeof SEA_STATE_IDS)[number]; + +export const SPEED_MARK_IDS = ['hullSpeed', 'hump', 'planing', 'cruise', 'top'] as const; + +export type SpeedMarkId = (typeof SPEED_MARK_IDS)[number]; + +export const HULL_MODES = ['floating', 'hump', 'planing'] as const; + +export type HullMode = (typeof HULL_MODES)[number]; + +export type CameraView = 'chase' | 'waterline' | 'stern' | 'sky' | 'eye' | 'group'; + +export type RegionId = 'scene' | 'boat' | 'ship' | 'shore'; + +export type AnchorId = + 'boat' | 'dome' | 'stern' | 'ship' | 'satellite' | 'backupSatellite' | 'groundStation'; + +export type Point = readonly [x: number, y: number, z: number]; + +export interface RoutePose { + position: Point; + heading: number; +} + +export type CompanionReading = RoutePose; + +export interface BoatReading extends RoutePose { + knots: number; + distance: number; + held: boolean; +} + +export interface PlaningReading { + knots: number; + mode: HullMode; + trim: number; + heave: number; + transomDepth: number; + wettedLength: number; + keelWettedLength: number; + chineWettedLength: number; + liftShare: number; + speedLengthRatio: number; +} + +export interface JetReading { + throttle: number; + flow: number; + jetSpeed: number; + thrust: number; + efficiency: number; + impellerShare: number; + nozzleAngle: number; + bucket: number; +} + +export interface SeaReading { + state: SeaStateId; + waveHeight: number; +} + +export interface LinkReading { + mode: LinkMode; + ghost: RoutePose | null; +} + +export interface ViewOptions { + cutaway: boolean; + flow: boolean; + links: boolean; + labels: boolean; +} + +export interface AssemblyState { + phase: number; + playing: boolean; + boat: BoatReading; + planing: PlaningReading; + jet: JetReading; + companions: readonly CompanionReading[]; + sea: SeaReading; + link: LinkReading; + fit: FitId; + view: ViewOptions; + waterSection: boolean; + wettedBar: boolean; +} diff --git a/explainers/naval-drone/src/model/hull.test.ts b/explainers/naval-drone/src/model/hull.test.ts new file mode 100644 index 00000000..24e4fdc9 --- /dev/null +++ b/explainers/naval-drone/src/model/hull.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { toDegrees } from '@core/math'; +import { + FLOATING_BELOW_KN, + HULL_SPEED_KN, + PLANING_FROM_KN, + TRANSOM_DEPTH_KEYS, + TRIM_KEYS_DEG, + WETTED_KEYS, + heaveFor, + liftShareAt, + modeAt, + planingAt, + speedLengthRatio, +} from './hull'; +import { BOAT } from './layout'; + +describe('hull model', () => { + it('gives the 5.7 kn hull speed of a 5.5 m boat', () => { + expect(HULL_SPEED_KN).toBe(5.7); + expect(speedLengthRatio(HULL_SPEED_KN)).toBeCloseTo(1.34, 2); + }); + + it('floats below 6 kn, climbs the hump and planes from 16 kn', () => { + expect(modeAt(0)).toBe('floating'); + expect(modeAt(FLOATING_BELOW_KN - 0.1)).toBe('floating'); + expect(modeAt(11)).toBe('hump'); + expect(modeAt(PLANING_FROM_KN)).toBe('planing'); + expect(modeAt(42)).toBe('planing'); + expect(PLANING_FROM_KN).toBeGreaterThanOrEqual(15); + expect(PLANING_FROM_KN).toBeLessThanOrEqual(18); + }); + + it('takes trim and wetted length from 15 kn up from the facts sheet', () => { + expect(toDegrees(planingAt(15).trim)).toBeCloseTo(4, 9); + expect(toDegrees(planingAt(22).trim)).toBeCloseTo(4, 9); + expect(toDegrees(planingAt(30).trim)).toBeCloseTo(3, 9); + expect(toDegrees(planingAt(42).trim)).toBeCloseTo(2.5, 9); + expect(planingAt(15).wettedLength).toBe(4.5); + expect(planingAt(22).wettedLength).toBe(3.2); + expect(planingAt(42).wettedLength).toBe(1.7); + }); + + it('lifts the bow most near the hump and settles to 3 to 4 degrees', () => { + const peak = Math.max(...TRIM_KEYS_DEG.map(([, trim]) => trim)); + expect(peak).toBe(6); + expect(toDegrees(planingAt(11).trim)).toBeCloseTo(peak, 9); + expect(peak).toBeLessThanOrEqual(15); + }); + + it('sits at its static draft at rest', () => { + const rest = planingAt(0); + expect(rest.trim).toBe(0); + expect(rest.heave).toBeCloseTo(0, 9); + expect(rest.transomDepth).toBe(BOAT.staticDraft); + expect(rest.wettedLength).toBe(BOAT.waterlineLength); + expect(rest.liftShare).toBe(0); + }); + + it('rises as it planes', () => { + expect(planingAt(11).heave).toBeCloseTo(0.146, 2); + expect(planingAt(22).heave).toBeCloseTo(0.2, 2); + expect(planingAt(42).heave).toBeCloseTo(0.28, 2); + expect(heaveFor(0, BOAT.staticDraft)).toBe(0); + }); + + it('puts the keel in the water ahead of the transom by depth over trim', () => { + const top = planingAt(42); + expect(top.keelWettedLength).toBeCloseTo(top.transomDepth / Math.sin(top.trim), 9); + expect(top.keelWettedLength).toBeCloseTo(3.67, 2); + expect(top.chineWettedLength).toBe(0); + const cruise = planingAt(22); + expect((cruise.keelWettedLength + cruise.chineWettedLength) / 2).toBeCloseTo(3.2, 9); + expect(planingAt(5.7).keelWettedLength).toBe(BOAT.waterlineLength); + }); + + it('splits the weight between speed and floating with Savitsky', () => { + expect(liftShareAt(5.7, 5.1)).toBeCloseTo(0.099, 2); + expect(planingAt(11).liftShare).toBeCloseTo(0.356, 2); + expect(planingAt(22).liftShare).toBeCloseTo(0.807, 2); + expect(planingAt(42).liftShare).toBeCloseTo(0.982, 2); + for (let knots = 1; knots <= 42; knots += 1) { + expect(planingAt(knots).liftShare).toBeGreaterThan(planingAt(knots - 1).liftShare); + } + }); + + it('clamps speeds to the boat range and keeps every key table ordered', () => { + expect(planingAt(60).knots).toBe(BOAT.topKnots); + expect(planingAt(-3).knots).toBe(0); + [TRIM_KEYS_DEG, WETTED_KEYS, TRANSOM_DEPTH_KEYS].forEach((keys) => { + keys.slice(1).forEach(([knots], index) => expect(knots).toBeGreaterThan(keys[index][0])); + }); + }); +}); diff --git a/explainers/naval-drone/src/model/hull.ts b/explainers/naval-drone/src/model/hull.ts new file mode 100644 index 00000000..68d8e20b --- /dev/null +++ b/explainers/naval-drone/src/model/hull.ts @@ -0,0 +1,114 @@ +import { clamp, toRadians } from '@core/math'; +import type { HullMode, PlaningReading } from '../ids'; +import { valueAt } from './keyframes'; +import type { Keyframes } from './keyframes'; +import { BOAT } from './layout'; +import { FEET_PER_METRE, GRAVITY, knotsToMs } from './scale'; + +export const HULL_SPEED_KN = 5.7; +export const FLOATING_BELOW_KN = 6; +export const PLANING_FROM_KN = 16; +export const HUMP_PEAK_KN = 11; +export const SAVITSKY_BEAM = 1.5; + +const SAVITSKY_SPEED_TERM = 0.012; +const SAVITSKY_BUOYANCY_TERM = 0.0055; +const SAVITSKY_SPEED_POWER = 0.5; +const SAVITSKY_BUOYANCY_POWER = 2.5; +const CV_VALID_FROM = 0.6; +const KEEL_TRIM_FROM = toRadians(1); +const MEAN_TO_SUM = 2; + +export const TRIM_KEYS_DEG: Keyframes = [ + [0, 0], + [4, 0.4], + [5.7, 1.2], + [8, 3.5], + [11, 6], + [13, 5.2], + [15, 4], + [22, 4], + [30, 3], + [42, 2.5], +]; + +export const WETTED_KEYS: Keyframes = [ + [0, 5.1], + [5.7, 5.1], + [8, 4.8], + [11, 4.4], + [13, 4.4], + [15, 4.5], + [22, 3.2], + [30, 2.8], + [42, 1.7], +]; + +export const TRANSOM_DEPTH_KEYS: Keyframes = [ + [0, 0.32], + [4, 0.33], + [5.7, 0.35], + [8, 0.42], + [11, 0.46], + [13, 0.42], + [15, 0.37], + [22, 0.31], + [30, 0.23], + [42, 0.16], +]; + +export function modeAt(knots: number): HullMode { + if (knots < FLOATING_BELOW_KN) return 'floating'; + if (knots < PLANING_FROM_KN) return 'hump'; + return 'planing'; +} + +export function beamFroude(knots: number): number { + return knotsToMs(knots) / Math.sqrt(GRAVITY * SAVITSKY_BEAM); +} + +function savitskySplit(lambda: number, cv: number): number { + const speedTerm = SAVITSKY_SPEED_TERM * lambda ** SAVITSKY_SPEED_POWER; + const buoyancyTerm = (SAVITSKY_BUOYANCY_TERM * lambda ** SAVITSKY_BUOYANCY_POWER) / (cv * cv); + return speedTerm / (speedTerm + buoyancyTerm); +} + +export function liftShareAt(knots: number, wettedLength: number): number { + const lambda = wettedLength / SAVITSKY_BEAM; + const cv = beamFroude(Math.max(knots, 0)); + if (cv >= CV_VALID_FROM) return savitskySplit(lambda, cv); + return savitskySplit(lambda, CV_VALID_FROM) * (cv / CV_VALID_FROM); +} + +export function heaveFor(trim: number, transomDepth: number): number { + return BOAT.halfLength * Math.sin(trim) + BOAT.staticDraft * Math.cos(trim) - transomDepth; +} + +export function keelWettedFor(trim: number, transomDepth: number): number { + if (trim < KEEL_TRIM_FROM) return BOAT.waterlineLength; + return Math.min(BOAT.waterlineLength, transomDepth / Math.sin(trim)); +} + +export function speedLengthRatio(knots: number): number { + return knots / Math.sqrt(BOAT.length * FEET_PER_METRE); +} + +export function planingAt(knots: number): PlaningReading { + const speed = clamp(knots, 0, BOAT.topKnots); + const trim = toRadians(valueAt(TRIM_KEYS_DEG, speed)); + const transomDepth = valueAt(TRANSOM_DEPTH_KEYS, speed); + const wettedLength = valueAt(WETTED_KEYS, speed); + const keelWettedLength = keelWettedFor(trim, transomDepth); + return { + knots: speed, + mode: modeAt(speed), + trim, + heave: heaveFor(trim, transomDepth), + transomDepth, + wettedLength, + keelWettedLength, + chineWettedLength: clamp(MEAN_TO_SUM * wettedLength - keelWettedLength, 0, keelWettedLength), + liftShare: liftShareAt(speed, wettedLength), + speedLengthRatio: speedLengthRatio(speed), + }; +} diff --git a/explainers/naval-drone/src/model/jet.test.ts b/explainers/naval-drone/src/model/jet.test.ts new file mode 100644 index 00000000..22254de4 --- /dev/null +++ b/explainers/naval-drone/src/model/jet.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { toDegrees } from '@core/math'; +import { + NOZZLE_AREA, + RESISTANCE_KEYS_KN, + STEER_MAX, + TOP_FLOW, + helmNozzle, + idealEfficiency, + jetAt, + knotsAtThrottle, + resistanceAt, + steadyFlowAt, + steadyJetSpeedAt, + throttleAt, +} from './jet'; +import { knotsToMs } from './scale'; + +describe('waterjet model', () => { + it('takes the drag at cruise and top speed from the facts sheet', () => { + expect(resistanceAt(22)).toBe(1650); + expect(resistanceAt(42)).toBe(2300); + RESISTANCE_KEYS_KN.slice(1).forEach(([, kn], index) => { + expect(kn).toBeGreaterThanOrEqual(RESISTANCE_KEYS_KN[index][1]); + }); + }); + + it('reproduces the facts sheet jet at 42 kn', () => { + expect(NOZZLE_AREA).toBe(0.0064); + expect(TOP_FLOW).toBeCloseTo(213, 0); + expect(steadyJetSpeedAt(42)).toBeCloseTo(32.4, 1); + const top = jetAt(1, 42, 0, false); + expect(top.thrust).toBeCloseTo(2300, 6); + expect(top.efficiency).toBeCloseTo(0.8, 2); + }); + + it('balances thrust and drag at every steady speed', () => { + [4, 5.7, 11, 15, 22, 30].forEach((knots) => { + const jet = jetAt(throttleAt(knots), knots, 0, false); + expect(jet.thrust).toBeCloseTo(resistanceAt(knots), 6); + expect(jet.flow).toBeCloseTo(steadyFlowAt(knots), 6); + }); + }); + + it('maps throttle to speed and back', () => { + expect(throttleAt(0)).toBe(0); + expect(throttleAt(42)).toBe(1); + expect(throttleAt(22)).toBeCloseTo(0.694, 2); + expect(throttleAt(11)).toBeCloseTo(0.554, 2); + [0.2, 0.5, 0.694, 0.9].forEach((share) => { + expect(throttleAt(knotsAtThrottle(share))).toBeCloseTo(share, 6); + }); + for (let knots = 1; knots <= 42; knots += 1) { + expect(throttleAt(knots)).toBeGreaterThan(throttleAt(knots - 1)); + } + }); + + it('is poor at low speed and good at high speed', () => { + expect(jetAt(throttleAt(5.7), 5.7, 0, false).efficiency).toBeLessThan(0.5); + expect(jetAt(throttleAt(22), 22, 0, false).efficiency).toBeCloseTo(0.67, 2); + expect(idealEfficiency(10, 0)).toBe(0); + expect(idealEfficiency(2 * knotsToMs(20), knotsToMs(20))).toBeCloseTo(2 / 3, 9); + }); + + it('steers 27 degrees each way and drops the bucket in reverse', () => { + expect(toDegrees(STEER_MAX)).toBeCloseTo(27, 9); + expect(helmNozzle('left')).toBe(-STEER_MAX); + expect(helmNozzle('right')).toBe(STEER_MAX); + expect(helmNozzle('straight')).toBe(0); + const reverse = jetAt(0.5, 0, helmNozzle('reverse'), true); + expect(reverse.bucket).toBe(1); + expect(reverse.thrust).toBe(0); + expect(reverse.efficiency).toBe(0); + expect(reverse.flow).toBeCloseTo(0.5 * TOP_FLOW, 9); + expect(jetAt(1, 42, 1, false).nozzleAngle).toBe(STEER_MAX); + }); +}); diff --git a/explainers/naval-drone/src/model/jet.ts b/explainers/naval-drone/src/model/jet.ts new file mode 100644 index 00000000..7307cf9e --- /dev/null +++ b/explainers/naval-drone/src/model/jet.ts @@ -0,0 +1,102 @@ +import { clamp, toRadians } from '@core/math'; +import type { HelmId, JetReading } from '../ids'; +import { valueAt } from './keyframes'; +import type { Keyframes } from './keyframes'; +import { BOAT } from './layout'; +import { SEAWATER_DENSITY, knotsToMs } from './scale'; + +export const NOZZLE_AREA = 0.0064; +export const NEWTONS_PER_KILONEWTON = 1000; +export const MAX_RPM = 8000; +export const STEER_MAX = toRadians(27); + +export const RESISTANCE_KEYS_KN: Keyframes = [ + [0, 0], + [4, 0.25], + [5.7, 0.5], + [8, 1], + [11, 1.45], + [15, 1.6], + [22, 1.65], + [30, 1.9], + [42, 2.3], +]; + +const FLOW_PER_JET_SPEED = SEAWATER_DENSITY * NOZZLE_AREA; +const QUADRATIC_FOUR = 4; +const IDEAL_EFFICIENCY_NUMERATOR = 2; +const BISECTION_STEPS = 48; + +const HELM_NOZZLE: Readonly> = { + left: -STEER_MAX, + straight: 0, + right: STEER_MAX, + reverse: 0, +}; + +function boatKnots(knots: number): number { + return clamp(knots, 0, BOAT.topKnots); +} + +export function resistanceAt(knots: number): number { + return valueAt(RESISTANCE_KEYS_KN, boatKnots(knots)) * NEWTONS_PER_KILONEWTON; +} + +export function steadyJetSpeedAt(knots: number): number { + const inflow = knotsToMs(boatKnots(knots)); + const pressure = (QUADRATIC_FOUR * resistanceAt(knots)) / FLOW_PER_JET_SPEED; + return (inflow + Math.sqrt(inflow * inflow + pressure)) / 2; +} + +export function steadyFlowAt(knots: number): number { + return FLOW_PER_JET_SPEED * steadyJetSpeedAt(knots); +} + +export const TOP_FLOW = steadyFlowAt(BOAT.topKnots); + +export function throttleAt(knots: number): number { + return clamp(steadyFlowAt(knots) / TOP_FLOW, 0, 1); +} + +export function knotsAtThrottle(share: number): number { + const target = clamp(share, 0, 1); + let low = 0; + let high: number = BOAT.topKnots; + for (let step = 0; step < BISECTION_STEPS; step += 1) { + const middle = (low + high) / 2; + if (throttleAt(middle) < target) low = middle; + else high = middle; + } + return (low + high) / 2; +} + +export function helmNozzle(helm: HelmId): number { + return HELM_NOZZLE[helm]; +} + +export function idealEfficiency(jetSpeed: number, inflow: number): number { + if (inflow <= 0 || jetSpeed <= 0) return 0; + return IDEAL_EFFICIENCY_NUMERATOR / (1 + jetSpeed / inflow); +} + +export function jetAt( + throttle: number, + knots: number, + nozzleAngle: number, + reverse: boolean, +): JetReading { + const share = clamp(throttle, 0, 1); + const flow = share * TOP_FLOW; + const jetSpeed = flow / FLOW_PER_JET_SPEED; + const inflow = knotsToMs(boatKnots(knots)); + return { + throttle: share, + flow, + jetSpeed, + thrust: reverse ? 0 : flow * (jetSpeed - inflow), + efficiency: reverse ? 0 : idealEfficiency(jetSpeed, inflow), + impellerShare: share, + nozzleAngle: clamp(nozzleAngle, -STEER_MAX, STEER_MAX), + bucket: reverse ? 1 : 0, + }; +} diff --git a/explainers/naval-drone/src/model/keyframes.test.ts b/explainers/naval-drone/src/model/keyframes.test.ts new file mode 100644 index 00000000..2790b889 --- /dev/null +++ b/explainers/naval-drone/src/model/keyframes.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { firstCrossing, integralTo, valueAt } from './keyframes'; +import type { Keyframes } from './keyframes'; + +const RAMP: Keyframes = [ + [0, 0], + [10, 10], + [20, 10], +]; + +describe('keyframes', () => { + it('interpolates between keys and holds the ends', () => { + expect(valueAt(RAMP, -5)).toBe(0); + expect(valueAt(RAMP, 5)).toBe(5); + expect(valueAt(RAMP, 15)).toBe(10); + expect(valueAt(RAMP, 25)).toBe(10); + }); + + it('integrates the area under the keys exactly', () => { + expect(integralTo(RAMP, 0)).toBe(0); + expect(integralTo(RAMP, 5)).toBeCloseTo(12.5, 9); + expect(integralTo(RAMP, 10)).toBeCloseTo(50, 9); + expect(integralTo(RAMP, 20)).toBeCloseTo(150, 9); + expect(integralTo(RAMP, 99)).toBeCloseTo(150, 9); + }); + + it('finds the first time a value is reached', () => { + expect(firstCrossing(RAMP, 0)).toBe(0); + expect(firstCrossing(RAMP, 4)).toBeCloseTo(4, 9); + expect(firstCrossing(RAMP, 10)).toBe(10); + expect(firstCrossing(RAMP, 11)).toBeNaN(); + }); +}); diff --git a/explainers/naval-drone/src/model/keyframes.ts b/explainers/naval-drone/src/model/keyframes.ts new file mode 100644 index 00000000..06fd3765 --- /dev/null +++ b/explainers/naval-drone/src/model/keyframes.ts @@ -0,0 +1,45 @@ +import { clamp } from '@core/math'; + +export type Keyframe = readonly [x: number, y: number]; +export type Keyframes = readonly Keyframe[]; + +function spanIndex(keys: Keyframes, x: number): number { + const index = keys.findIndex((key) => x <= key[0]); + if (index <= 0) return 0; + return index - 1; +} + +export function valueAt(keys: Keyframes, x: number): number { + const first = keys[0]; + const last = keys[keys.length - 1]; + if (x <= first[0]) return first[1]; + if (x >= last[0]) return last[1]; + const index = spanIndex(keys, x); + const [x0, y0] = keys[index]; + const [x1, y1] = keys[index + 1]; + return y0 + ((x - x0) / (x1 - x0)) * (y1 - y0); +} + +export function integralTo(keys: Keyframes, x: number): number { + const end = clamp(x, keys[0][0], keys[keys.length - 1][0]); + let total = 0; + for (let index = 0; index < keys.length - 1; index += 1) { + const [x0, y0] = keys[index]; + const x1 = keys[index + 1][0]; + if (end <= x0) break; + const to = Math.min(end, x1); + total += ((y0 + valueAt(keys, to)) / 2) * (to - x0); + } + return total; +} + +export function firstCrossing(keys: Keyframes, y: number): number { + for (let index = 0; index < keys.length - 1; index += 1) { + const [x0, y0] = keys[index]; + const [x1, y1] = keys[index + 1]; + if (y0 === y) return x0; + if ((y0 - y) * (y1 - y) < 0) return x0 + ((y - y0) / (y1 - y0)) * (x1 - x0); + } + const last = keys[keys.length - 1]; + return last[1] === y ? last[0] : Number.NaN; +} diff --git a/explainers/naval-drone/src/model/layout.test.ts b/explainers/naval-drone/src/model/layout.test.ts new file mode 100644 index 00000000..673f18b0 --- /dev/null +++ b/explainers/naval-drone/src/model/layout.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { toDegrees } from '@core/math'; +import { + BACKUP_PANEL_X, + BACKUP_SATELLITE_OFFSET, + BOAT, + BOAT_BOUNDS, + BOW_CAMERA, + DOME, + FAIRING, + FINAL_HEADING, + HULL_STATIONS, + JET, + PANEL, + SATELLITE_OFFSET, + STARLINK_PANEL_XS, + START, + STEM, + STUB, + TRANSOM_X, + VENT_BOX, + chineHeight, + skyPoint, + stemXAt, +} from './layout'; + +const SPEC_CHINE_HEIGHTS = [-0.083, -0.08, -0.078, -0.08, 0.002, 0.111, 0.22, 0.33]; + +describe('boat layout', () => { + it('takes the main dimensions from the facts sheet', () => { + expect(BOAT.length).toBe(5.5); + expect(BOAT.beam).toBe(1.5); + expect(BOAT.freeboard).toBe(0.5); + expect(BOAT.cruiseKnots).toBe(22); + expect(BOAT.topKnots).toBe(42); + expect(BOAT.halfLength * 2).toBe(BOAT.length); + expect(TRANSOM_X).toBe(-BOAT.halfLength); + }); + + it('runs the stations from the transom to the bow inside the beam', () => { + expect(HULL_STATIONS[0].x).toBe(TRANSOM_X); + expect(HULL_STATIONS[0].keel).toBe(-BOAT.staticDraft); + HULL_STATIONS.slice(1).forEach((station, index) => { + expect(station.x).toBeGreaterThan(HULL_STATIONS[index].x); + expect(station.deadrise).toBeGreaterThanOrEqual(HULL_STATIONS[index].deadrise); + }); + HULL_STATIONS.forEach((station) => { + expect(station.sheer[0]).toBeLessThanOrEqual(BOAT.beam / 2); + expect(station.knuckle[0]).toBeLessThanOrEqual(station.sheer[0]); + expect(station.chineHalfBreadth).toBeLessThan(station.knuckle[0]); + expect(station.deck).toBeGreaterThan(station.sheer[1]); + }); + expect(Math.max(...HULL_STATIONS.map((station) => station.sheer[0]))).toBe(BOAT.beam / 2); + }); + + it('keeps 20 degrees of deadrise aft and puts the chine where the spec says', () => { + expect(toDegrees(HULL_STATIONS[0].deadrise)).toBeCloseTo(20, 9); + HULL_STATIONS.forEach((station, index) => { + expect(chineHeight(station)).toBeCloseTo(SPEC_CHINE_HEIGHTS[index], 2); + }); + expect(chineHeight(HULL_STATIONS[0])).toBeLessThan(0); + }); + + it('rakes the stem so the static waterline is about 5.1 m long', () => { + expect(STEM.top[0]).toBe(BOAT.halfLength); + expect(stemXAt(0) - TRANSOM_X).toBeCloseTo(BOAT.waterlineLength, 1); + expect(stemXAt(STEM.top[1])).toBeCloseTo(STEM.top[0], 9); + }); + + it('orders the deck items from the stern forward on the centreline', () => { + const order = [ + VENT_BOX.x[0], + BACKUP_PANEL_X, + ...STARLINK_PANEL_XS, + STUB.x, + DOME.x, + BOW_CAMERA.x[0], + ]; + order.slice(1).forEach((x, index) => expect(x).toBeGreaterThan(order[index])); + expect(DOME.x).toBeCloseTo(BOAT.halfLength - BOAT.length / 3, 1); + }); + + it('keeps the fairing at the freeboard and the panels on top of it', () => { + expect(FAIRING.top).toBe(BOAT.freeboard); + expect(PANEL.top).toBeCloseTo(FAIRING.top + PANEL.thickness + PANEL.raise, 9); + expect(BACKUP_PANEL_X - PANEL.length / 2).toBeGreaterThan(VENT_BOX.x[1]); + expect(STARLINK_PANEL_XS[1] + PANEL.length / 2).toBeLessThan(FAIRING.frontTopX); + }); + + it('puts the dome lens 0.7 m above the water', () => { + expect(DOME.lens).toBe(0.7); + expect(DOME.lens).toBeLessThan(DOME.top); + expect(DOME.top).toBeCloseTo(DOME.base + DOME.height + DOME.radius, 9); + }); + + it('runs the jet on its axis just above the bottom of the transom', () => { + expect(JET.axisY).toBeGreaterThan(-BOAT.staticDraft); + expect(JET.axisY).toBeLessThan(0); + expect(JET.steeringNozzle.pivotX).toBe(JET.nozzle.x[0]); + expect(JET.nozzle.exitDiameter).toBe(0.09); + expect(BOAT_BOUNDS.x[0]).toBeLessThanOrEqual(JET.steeringNozzle.x[0]); + }); + + it('draws the satellites ahead and above in the frame of the final heading', () => { + expect(toDegrees(FINAL_HEADING)).toBeCloseTo(-20, 9); + expect(START.heading).toBe(0); + const main = skyPoint([0, 0, 0], SATELLITE_OFFSET); + expect(main[1]).toBe(SATELLITE_OFFSET.up); + expect(main[0] * Math.cos(FINAL_HEADING) + main[2] * Math.sin(FINAL_HEADING)).toBeCloseTo( + SATELLITE_OFFSET.ahead, + 9, + ); + const backup = skyPoint([10, 0, 5], BACKUP_SATELLITE_OFFSET); + const sideX = -Math.sin(FINAL_HEADING); + const sideZ = Math.cos(FINAL_HEADING); + expect((backup[0] - 10) * sideX + (backup[2] - 5) * sideZ).toBeCloseTo( + BACKUP_SATELLITE_OFFSET.side, + 9, + ); + }); +}); diff --git a/explainers/naval-drone/src/model/layout.ts b/explainers/naval-drone/src/model/layout.ts new file mode 100644 index 00000000..0ca125cd --- /dev/null +++ b/explainers/naval-drone/src/model/layout.ts @@ -0,0 +1,256 @@ +import { toRadians } from '@core/math'; +import type { Pose } from '@core/path'; +import type { Point } from '../ids'; + +export type Extent = readonly [min: number, max: number]; +export type SectionPoint = readonly [z: number, y: number]; + +export interface Box { + x: Extent; + y: Extent; + z: Extent; +} + +export interface SkyOffset { + ahead: number; + up: number; + side: number; +} + +export interface HullStation { + x: number; + keel: number; + deadrise: number; + chineHalfBreadth: number; + knuckle: SectionPoint; + sheer: SectionPoint; + deck: number; +} + +export const BOAT = { + length: 5.5, + beam: 1.5, + freeboard: 0.5, + halfLength: 2.75, + staticDraft: 0.32, + waterlineLength: 5.1, + chineBeam: 1.3, + deadriseAft: toRadians(20), + mass: 1000, + lensHeight: 0.7, + cruiseKnots: 22, + topKnots: 42, +} as const; + +export const TRANSOM_X = -BOAT.halfLength; + +function station( + x: number, + keel: number, + deadriseDegrees: number, + chineHalfBreadth: number, + knuckle: SectionPoint, + sheer: SectionPoint, + deck: number, +): HullStation { + return { x, keel, deadrise: toRadians(deadriseDegrees), chineHalfBreadth, knuckle, sheer, deck }; +} + +export const HULL_STATIONS: readonly HullStation[] = [ + station(-2.75, -0.32, 20, 0.65, [0.72, 0.08], [0.74, 0.26], 0.31), + station(-2, -0.32, 20, 0.66, [0.73, 0.09], [0.75, 0.27], 0.32), + station(-1, -0.32, 20, 0.665, [0.73, 0.1], [0.75, 0.29], 0.34), + station(0, -0.32, 20, 0.66, [0.73, 0.12], [0.75, 0.33], 0.38), + station(1, -0.3, 26, 0.62, [0.7, 0.17], [0.72, 0.38], 0.43), + station(1.6, -0.24, 33, 0.54, [0.62, 0.23], [0.64, 0.42], 0.47), + station(2.1, -0.14, 42, 0.4, [0.48, 0.32], [0.5, 0.46], 0.5), + station(2.4, 0.02, 50, 0.26, [0.3, 0.41], [0.32, 0.49], 0.52), +]; + +export function chineHeight(hullStation: HullStation): number { + return hullStation.keel + hullStation.chineHalfBreadth * Math.tan(hullStation.deadrise); +} + +export const STEM = { + forefoot: [2.35, -0.05] as const, + top: [BOAT.halfLength, 0.52] as const, + chineMeetsAt: 0.5, +} as const; + +export function stemXAt(y: number): number { + const [x0, y0] = STEM.forefoot; + const [x1, y1] = STEM.top; + return x0 + ((y - y0) / (y1 - y0)) * (x1 - x0); +} + +export const HULL_DETAIL = { + keelStraightTo: 0.5, + chineFlat: { width: 0.03, endX: 2.3 }, + sprayRail: { width: 0.025, x: [0.2, 2.45] as Extent }, + knuckleMergeX: 2.55, + deckCentreWidth: 0.6, + edgeBandDepth: 0.05, +} as const; + +export const FAIRING = { + x: [-2.62, -0.3] as Extent, + frontTopX: -0.62, + top: 0.5, + topHalfWidth: 0.5, + baseHalfWidth: 0.62, +} as const; + +export const VENT_BOX = { x: [-2.58, -2.32] as Extent, halfWidth: 0.18, top: 0.65 } as const; + +export const PANEL = { + length: 0.4, + width: 0.6, + thickness: 0.04, + raise: 0.01, + frameLength: 0.46, + frameWidth: 0.66, + top: 0.55, +} as const; + +export const BACKUP_PANEL_X = -2.05; +export const STARLINK_PANEL_XS = [-1.55, -1.05] as const; + +export const STUB = { x: -0.12, radius: 0.02, height: 0.14 } as const; + +export const DOME = { + x: 0.92, + base: 0.426, + ringRadius: 0.14, + radius: 0.11, + height: 0.2, + top: 0.736, + lens: BOAT.lensHeight, +} as const; + +export const HATCHES = { + forward: { x: [1.15, 1.75] as Extent, halfWidth: 0.26, handles: 8 }, + mid: { x: [-0.15, 0.55] as Extent, halfWidth: 0.3, handles: 6 }, + recess: 0.01, + handle: { bar: 0.04, stem: 0.02 }, +} as const; + +export const BOW_CAMERA = { + x: [2.12, 2.42] as Extent, + width: 0.16, + height: 0.1, + window: { width: 0.08, height: 0.05, y: 0.58 }, +} as const; + +export const PAYLOAD_BAY = { + bulkheads: [0.75, 2.05] as Extent, + box: { x: [0.85, 1.95] as Extent, halfWidth: 0.38, y: [-0.18, 0.36] as Extent }, +} as const; + +export const FUEL_TANKS = { + x: [-0.55, 0.55] as Extent, + centreZ: 0.27, + width: 0.42, + y: [-0.12, 0.2] as Extent, +} as const; + +export const ENGINE_TUB = { + x: [-1.85, -0.65] as Extent, + halfWidth: 0.42, + y: [-0.26, 0.22] as Extent, +} as const; + +export const ENGINE = { + x: [-1.6, -0.85] as Extent, + halfWidth: 0.22, + y: [-0.2, 0.16] as Extent, + couplerX: -1.65, +} as const; + +export const ELECTRONICS = { + x: [-1.55, -0.75] as Extent, + halfWidth: 0.35, + y: [0.24, 0.44] as Extent, +} as const; + +export const IMPELLER = { + x: -2.55, + diameter: 0.155, + hubRadius: 0.035, + blades: 3, + statorVanes: 7, +} as const; + +export const JET = { + axisY: -0.15, + intake: { x: [-2.25, -1.75] as Extent, halfWidth: 0.11, bars: 5, y: -0.3 }, + duct: { endX: -2.5, innerDiameter: 0.157 }, + shaft: { x: [-2.55, -1.65] as Extent, radius: 0.0125, entryX: -2.15 }, + housing: { x: [-2.9, -2.5] as Extent, outerDiameter: 0.24 }, + stator: { x: [-2.88, -2.75] as Extent }, + nozzle: { x: [-3, -2.9] as Extent, inletDiameter: 0.16, exitDiameter: 0.09 }, + steeringNozzle: { x: [-3.12, -3] as Extent, pivotX: -3 }, + bucket: { pivot: [-2.98, -0.02] as const, width: 0.22, depth: 0.18 }, +} as const; + +export const MISSILE_FIT = { + railZ: 0.52, + x: [-0.9, 0.9] as Extent, + railTop: 0.62, + raise: toRadians(8), + missile: { length: 2.9, diameter: 0.17 }, +} as const; + +export const BOAT_BOUNDS: Box = { x: [-3.25, 2.75], y: [-0.35, 0.8], z: [-0.75, 0.75] }; + +export const WATER_SECTION = { + x: [-3.4, 3.1] as Extent, + z: [-2.6, 0] as Extent, + depth: 1.2, +} as const; + +export const SHORELINE_X = 0; + +export const SLIPWAY = { + x: [-22, 3] as Extent, + z: [-2, 2] as Extent, + top: 1.5, + foot: -0.4, +} as const; + +export const START: Pose = { x: 6, z: 0, heading: 0 }; + +export const ROUTE_TURN = { lead: 180, radius: 350, turn: -toRadians(20) } as const; + +export const FINAL_HEADING = START.heading + ROUTE_TURN.turn; + +export const SHIP = { + length: 110, + beam: 14, + draft: 4, + deck: 6, + bridgeTop: 15, + mastTop: 30, + radarHeight: 20, + mastX: 12, + reach: 62, +} as const; + +export const GROUND_STATION: Point = [-70, 3, -60]; + +export const SATELLITE_OFFSET: SkyOffset = { ahead: 800, up: 360, side: -70 }; +export const BACKUP_SATELLITE_OFFSET: SkyOffset = { ahead: 860, up: 430, side: 60 }; + +export function skyPoint(position: Point, offset: SkyOffset): Point { + const aheadX = Math.cos(FINAL_HEADING); + const aheadZ = Math.sin(FINAL_HEADING); + return [ + position[0] + offset.ahead * aheadX - offset.side * aheadZ, + offset.up, + position[2] + offset.ahead * aheadZ + offset.side * aheadX, + ]; +} + +export const FORMATION = { back: 30, side: 18, joinSide: 120, joinStart: 70, joinEnd: 84 } as const; + +export const SCENE_BOUNDS: Box = { x: [-150, 1550], y: [-6, 40], z: [-550, 150] }; +export const SHORE_BOUNDS: Box = { x: [-80, 20], y: [-1, 8], z: [-70, 30] }; diff --git a/explainers/naval-drone/src/model/run.test.ts b/explainers/naval-drone/src/model/run.test.ts new file mode 100644 index 00000000..cc5d74b1 --- /dev/null +++ b/explainers/naval-drone/src/model/run.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { toDegrees } from '@core/math'; +import { MOMENT_IDS, PHASE_IDS } from '../ids'; +import { BOAT, FINAL_HEADING, FORMATION, ROUTE_TURN, SHIP, START } from './layout'; +import { + FINAL_LENGTH, + HELD_PHASE, + MOMENTS, + PHASE_RANGES, + ROUTE, + ROUTE_END, + RUN_DISTANCE, + RUN_SECONDS, + RUN_STEER, + SHIP_CENTRE, + SHIP_HEADING, + TURN_END_DISTANCE, + TURN_END_TIME, + TURN_START_TIME, + boatAt, + companionsAt, + distanceAt, + distanceToShip, + phaseAt, + phaseShareAt, + poseAtDistance, + speedAt, + steerAt, + timeAtDistance, +} from './run'; + +describe('run timeline', () => { + it('covers the two minute run with the five phases in order', () => { + expect(RUN_SECONDS).toBe(120); + expect(PHASE_RANGES[PHASE_IDS[0]].start).toBe(0); + expect(PHASE_RANGES[PHASE_IDS[PHASE_IDS.length - 1]].end).toBe(RUN_SECONDS); + PHASE_IDS.slice(1).forEach((id, index) => { + expect(PHASE_RANGES[id].start).toBe(PHASE_RANGES[PHASE_IDS[index]].end); + }); + expect(phaseAt(0)).toBe('launch'); + expect(phaseAt(20)).toBe('hump'); + expect(phaseAt(60)).toBe('cruise'); + expect(phaseAt(90)).toBe('sprint'); + expect(phaseAt(RUN_SECONDS)).toBe('arrival'); + expect(phaseShareAt(25.5)).toBeCloseTo(0.5, 9); + }); + + it('idles below hull speed at launch, cruises at 22 kn and sprints at 42 kn', () => { + expect(speedAt(0)).toBe(0); + expect(speedAt(PHASE_RANGES.launch.end)).toBeLessThan(5.7); + expect(speedAt(60)).toBe(BOAT.cruiseKnots); + expect(speedAt(100)).toBe(BOAT.topKnots); + expect(speedAt(RUN_SECONDS)).toBe(BOAT.topKnots); + }); + + it('places the moments on the speed profile', () => { + expect(MOMENTS.hullSpeed).toBeCloseTo(18.97, 2); + expect(MOMENTS.humpPeak).toBe(28); + expect(MOMENTS.onPlane).toBeCloseTo(33, 9); + expect(MOMENTS.cruiseSpeed).toBe(40); + expect(MOMENTS.throttleUp).toBe(PHASE_RANGES.sprint.start); + expect(MOMENTS.topSpeed).toBe(88); + expect(MOMENTS.alongside).toBe(RUN_SECONDS); + MOMENT_IDS.slice(1).forEach((id, index) => { + expect(MOMENTS[id]).toBeGreaterThan(MOMENTS[MOMENT_IDS[index]]); + }); + }); + + it('integrates the speed into about 1.46 km of sea', () => { + expect(RUN_DISTANCE).toBeCloseTo(1458.8, 0); + expect(distanceAt(40)).toBeCloseTo(172.85, 2); + expect(distanceAt(HELD_PHASE)).toBeCloseTo(399.2, 1); + expect(distanceAt(RUN_SECONDS) - distanceAt(PHASE_RANGES.arrival.start)).toBeCloseTo(172.9, 0); + expect(timeAtDistance(distanceAt(50))).toBeCloseTo(50, 6); + expect(distanceToShip(RUN_SECONDS)).toBe(0); + }); +}); + +describe('route and ship', () => { + it('turns 20 degrees to port and ends on the final heading', () => { + expect(ROUTE.length).toBeCloseTo(RUN_DISTANCE, 6); + expect(FINAL_LENGTH).toBeCloseTo(1156.7, 0); + expect(ROUTE.end.heading).toBeCloseTo(FINAL_HEADING, 9); + expect(ROUTE_END[0]).toBeCloseTo(1392.6, 0); + expect(ROUTE_END[2]).toBeCloseTo(-416.7, 0); + expect(toDegrees(poseAtDistance(ROUTE_TURN.lead / 2).heading)).toBeCloseTo(0, 9); + expect(poseAtDistance(0).position).toEqual([START.x, 0, START.z]); + expect(TURN_START_TIME).toBeCloseTo(40.63, 1); + expect(TURN_END_TIME).toBeCloseTo(51.43, 1); + expect(distanceAt(52)).toBeGreaterThan(TURN_END_DISTANCE); + }); + + it('holds the hull and jet chapters on the straight final leg', () => { + expect(boatAt(HELD_PHASE).heading).toBeCloseTo(FINAL_HEADING, 9); + expect(boatAt(HELD_PHASE).held).toBe(false); + expect(boatAt(HELD_PHASE).knots).toBe(BOAT.cruiseKnots); + }); + + it('puts the ship side square across the run, one bow length past the route end', () => { + const along = + (SHIP_CENTRE[0] - ROUTE_END[0]) * Math.cos(FINAL_HEADING) + + (SHIP_CENTRE[2] - ROUTE_END[2]) * Math.sin(FINAL_HEADING); + expect(along - SHIP.beam / 2).toBeCloseTo(BOAT.halfLength, 9); + expect(SHIP_HEADING - FINAL_HEADING).toBeCloseTo(Math.PI / 2, 9); + }); + + it('steers the nozzle to port only through the turn', () => { + expect(steerAt(20)).toBeCloseTo(0, 9); + expect(steerAt((TURN_START_TIME + TURN_END_TIME) / 2)).toBeCloseTo(-RUN_STEER, 9); + expect(steerAt(70)).toBeCloseTo(0, 9); + }); +}); + +describe('companions', () => { + it('stay away until 70 s and then close in from both sides', () => { + expect(companionsAt(FORMATION.joinStart - 1)).toEqual([]); + const joining = companionsAt(77); + expect(joining).toHaveLength(2); + const [port, starboard] = companionsAt(100); + const base = poseAtDistance(distanceAt(100) - FORMATION.back); + const side = (point: readonly number[]) => + (point[0] - base.position[0]) * -Math.sin(base.heading) + + (point[2] - base.position[2]) * Math.cos(base.heading); + expect(side(port.position)).toBeCloseTo(-FORMATION.side, 6); + expect(side(starboard.position)).toBeCloseTo(FORMATION.side, 6); + expect(port.heading).toBeCloseTo(base.heading, 9); + }); + + it('turn inward while they close in', () => { + const [port, starboard] = companionsAt(77); + expect(port.heading).toBeGreaterThan(FINAL_HEADING); + expect(starboard.heading).toBeLessThan(FINAL_HEADING); + }); +}); diff --git a/explainers/naval-drone/src/model/run.ts b/explainers/naval-drone/src/model/run.ts new file mode 100644 index 00000000..f378885a --- /dev/null +++ b/explainers/naval-drone/src/model/run.ts @@ -0,0 +1,177 @@ +import { clamp, lerp, smoothstep, toRadians } from '@core/math'; +import { Route, arc, line } from '@core/path'; +import type { BoatReading, CompanionReading, MomentId, PhaseId, Point, RoutePose } from '../ids'; +import { HULL_SPEED_KN, HUMP_PEAK_KN, PLANING_FROM_KN } from './hull'; +import { firstCrossing, integralTo, valueAt } from './keyframes'; +import type { Keyframes } from './keyframes'; +import { BOAT, FINAL_HEADING, FORMATION, ROUTE_TURN, SHIP, START } from './layout'; +import type { Box } from './layout'; +import { knotsToMs } from './scale'; + +export const RUN_SECONDS = 120; +export const HELD_PHASE = 60; + +export const PHASE_RANGES: Readonly> = { + launch: { start: 0, end: 15 }, + hump: { start: 15, end: 36 }, + cruise: { start: 36, end: 78 }, + sprint: { start: 78, end: 112 }, + arrival: { start: 112, end: RUN_SECONDS }, +}; + +export const SPEED_KEYS: Keyframes = [ + [0, 0], + [5, 3], + [15, 4], + [22, 7], + [28, 11], + [34, 17], + [40, 22], + [78, 22], + [88, 42], + [120, 42], +]; + +export const RUN_STEER = toRadians(8); +const STEER_EASE_SECONDS = 0.5; +const BISECTION_STEPS = 48; +const MIN_FORWARD_SPEED = 0.1; +const SIDES = [-1, 1] as const; +const SMOOTHSTEP_SLOPE = 6; + +function runTime(t: number): number { + return clamp(t, 0, RUN_SECONDS); +} + +export function speedAt(t: number): number { + return valueAt(SPEED_KEYS, runTime(t)); +} + +export function distanceAt(t: number): number { + return knotsToMs(integralTo(SPEED_KEYS, runTime(t))); +} + +export const RUN_DISTANCE = distanceAt(RUN_SECONDS); + +export function timeAtDistance(distance: number): number { + const target = clamp(distance, 0, RUN_DISTANCE); + let low = 0; + let high = RUN_SECONDS; + for (let step = 0; step < BISECTION_STEPS; step += 1) { + const middle = (low + high) / 2; + if (distanceAt(middle) < target) low = middle; + else high = middle; + } + return (low + high) / 2; +} + +export function timeAtKnots(knots: number): number { + return firstCrossing(SPEED_KEYS, knots); +} + +const ARC_LENGTH = ROUTE_TURN.radius * Math.abs(ROUTE_TURN.turn); + +export const TURN_START_DISTANCE = ROUTE_TURN.lead; +export const TURN_END_DISTANCE = ROUTE_TURN.lead + ARC_LENGTH; +export const FINAL_LENGTH = RUN_DISTANCE - TURN_END_DISTANCE; + +export const ROUTE = new Route(START, [ + line(ROUTE_TURN.lead), + arc(ROUTE_TURN.radius, ROUTE_TURN.turn), + line(FINAL_LENGTH), +]); + +export const TURN_START_TIME = timeAtDistance(TURN_START_DISTANCE); +export const TURN_END_TIME = timeAtDistance(TURN_END_DISTANCE); + +export const ROUTE_END: Point = [ROUTE.end.x, 0, ROUTE.end.z]; + +const SHIP_SIDE_GAP = BOAT.halfLength + SHIP.beam / 2; + +export const SHIP_CENTRE: Point = [ + ROUTE_END[0] + SHIP_SIDE_GAP * Math.cos(FINAL_HEADING), + 0, + ROUTE_END[2] + SHIP_SIDE_GAP * Math.sin(FINAL_HEADING), +]; + +export const SHIP_HEADING = FINAL_HEADING + Math.PI / 2; + +export const SHIP_BOUNDS: Box = { + x: [SHIP_CENTRE[0] - SHIP.reach, SHIP_CENTRE[0] + SHIP.reach], + y: [-SHIP.draft, SHIP.mastTop + 2], + z: [SHIP_CENTRE[2] - SHIP.reach, SHIP_CENTRE[2] + SHIP.reach], +}; + +export function poseAtDistance(distance: number): RoutePose { + const pose = ROUTE.poseAt(clamp(distance, 0, ROUTE.length) / ROUTE.length); + return { position: [pose.x, 0, pose.z], heading: pose.heading }; +} + +export function boatAt(t: number): BoatReading { + const distance = distanceAt(t); + return { ...poseAtDistance(distance), knots: speedAt(t), distance, held: false }; +} + +export function steerAt(t: number): number { + const into = smoothstep( + t, + TURN_START_TIME - STEER_EASE_SECONDS, + TURN_START_TIME + STEER_EASE_SECONDS, + ); + const out = smoothstep(t, TURN_END_TIME - STEER_EASE_SECONDS, TURN_END_TIME + STEER_EASE_SECONDS); + return Math.sign(ROUTE_TURN.turn) * RUN_STEER * into * (1 - out); +} + +export function distanceToShip(t: number): number { + return RUN_DISTANCE - distanceAt(t); +} + +export function phaseAt(t: number): PhaseId { + const value = runTime(t); + const found = (Object.keys(PHASE_RANGES) as PhaseId[]).find((id) => value < PHASE_RANGES[id].end); + return found ?? 'arrival'; +} + +export function phaseShareAt(t: number): number { + const { start, end } = PHASE_RANGES[phaseAt(t)]; + return clamp((runTime(t) - start) / (end - start), 0, 1); +} + +export const MOMENTS: Readonly> = { + hullSpeed: timeAtKnots(HULL_SPEED_KN), + humpPeak: timeAtKnots(HUMP_PEAK_KN), + onPlane: timeAtKnots(PLANING_FROM_KN), + cruiseSpeed: timeAtKnots(BOAT.cruiseKnots), + throttleUp: PHASE_RANGES.sprint.start, + topSpeed: timeAtKnots(BOAT.topKnots), + alongside: RUN_SECONDS, +}; + +function joinShare(t: number): number { + return smoothstep(t, FORMATION.joinStart, FORMATION.joinEnd); +} + +function joinShareRate(t: number): number { + const span = FORMATION.joinEnd - FORMATION.joinStart; + const share = clamp((t - FORMATION.joinStart) / span, 0, 1); + return (SMOOTHSTEP_SLOPE * share * (1 - share)) / span; +} + +export function companionsAt(t: number): readonly CompanionReading[] { + if (t < FORMATION.joinStart) return []; + const side = lerp(FORMATION.joinSide, FORMATION.side, joinShare(t)); + const inwardSpeed = (FORMATION.joinSide - FORMATION.side) * joinShareRate(t); + const forwardSpeed = Math.max(knotsToMs(speedAt(t)), MIN_FORWARD_SPEED); + const inward = Math.atan2(inwardSpeed, forwardSpeed); + const base = poseAtDistance(distanceAt(t) - FORMATION.back); + const starboardX = -Math.sin(base.heading); + const starboardZ = Math.cos(base.heading); + return SIDES.map((sign) => ({ + position: [ + base.position[0] + sign * side * starboardX, + 0, + base.position[2] + sign * side * starboardZ, + ] as Point, + heading: base.heading - sign * inward, + })); +} diff --git a/explainers/naval-drone/src/model/scale.test.ts b/explainers/naval-drone/src/model/scale.test.ts new file mode 100644 index 00000000..07d94a0b --- /dev/null +++ b/explainers/naval-drone/src/model/scale.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { METRES_PER_UNIT, knotsToKmh, knotsToMs, metresToUnits, msToKmh, msToKnots } from './scale'; + +describe('scale', () => { + it('draws everything at one metre per unit', () => { + expect(METRES_PER_UNIT).toBe(1); + expect(metresToUnits(5.5)).toBe(5.5); + }); + + it('converts the facts sheet speeds', () => { + expect(knotsToKmh(42)).toBeCloseTo(77.8, 1); + expect(knotsToKmh(22)).toBeCloseTo(40.7, 1); + expect(knotsToMs(42)).toBeCloseTo(21.6, 1); + expect(knotsToMs(22)).toBeCloseTo(11.3, 1); + expect(msToKnots(knotsToMs(5.7))).toBeCloseTo(5.7, 9); + expect(msToKmh(knotsToMs(42))).toBeCloseTo(knotsToKmh(42), 9); + }); +}); diff --git a/explainers/naval-drone/src/model/scale.ts b/explainers/naval-drone/src/model/scale.ts new file mode 100644 index 00000000..cb5968f4 --- /dev/null +++ b/explainers/naval-drone/src/model/scale.ts @@ -0,0 +1,27 @@ +export const METRES_PER_UNIT = 1; +export const KMH_PER_KNOT = 1.852; +export const KMH_PER_MS = 3.6; +export const MS_PER_KNOT = KMH_PER_KNOT / KMH_PER_MS; +export const GRAVITY = 9.81; +export const SEAWATER_DENSITY = 1025; +export const FEET_PER_METRE = 3.28084; + +export function metresToUnits(metres: number): number { + return metres / METRES_PER_UNIT; +} + +export function knotsToMs(knots: number): number { + return knots * MS_PER_KNOT; +} + +export function msToKnots(ms: number): number { + return ms / MS_PER_KNOT; +} + +export function knotsToKmh(knots: number): number { + return knots * KMH_PER_KNOT; +} + +export function msToKmh(ms: number): number { + return ms * KMH_PER_MS; +} diff --git a/explainers/naval-drone/src/model/sea.test.ts b/explainers/naval-drone/src/model/sea.test.ts new file mode 100644 index 00000000..346f9843 --- /dev/null +++ b/explainers/naval-drone/src/model/sea.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { SEA_STATE_IDS } from '../ids'; +import { DEFAULT_SEA_STATE, SEA_STATES, seaAt, waveHeightOf } from './sea'; + +describe('sea states', () => { + it('follows WMO code 3700 from smooth to rough', () => { + expect(SEA_STATE_IDS.map((id) => SEA_STATES[id].wmo)).toEqual([2, 3, 4, 5]); + expect(SEA_STATES.slight).toEqual({ wmo: 3, from: 0.5, to: 1.25 }); + expect(SEA_STATES.rough).toEqual({ wmo: 5, from: 2.5, to: 4 }); + }); + + it('joins the bands without gaps', () => { + SEA_STATE_IDS.slice(1).forEach((id, index) => { + expect(SEA_STATES[id].from).toBe(SEA_STATES[SEA_STATE_IDS[index]].to); + }); + }); + + it('draws the middle of each band and starts smooth', () => { + expect(DEFAULT_SEA_STATE).toBe('smooth'); + expect(waveHeightOf('smooth')).toBeCloseTo(0.3, 9); + expect(waveHeightOf('slight')).toBeCloseTo(0.875, 9); + expect(seaAt('moderate')).toEqual({ state: 'moderate', waveHeight: 1.875 }); + }); +}); diff --git a/explainers/naval-drone/src/model/sea.ts b/explainers/naval-drone/src/model/sea.ts new file mode 100644 index 00000000..b3256bd0 --- /dev/null +++ b/explainers/naval-drone/src/model/sea.ts @@ -0,0 +1,25 @@ +import type { SeaReading, SeaStateId } from '../ids'; + +export interface SeaState { + wmo: number; + from: number; + to: number; +} + +export const SEA_STATES: Readonly> = { + smooth: { wmo: 2, from: 0.1, to: 0.5 }, + slight: { wmo: 3, from: 0.5, to: 1.25 }, + moderate: { wmo: 4, from: 1.25, to: 2.5 }, + rough: { wmo: 5, from: 2.5, to: 4 }, +}; + +export const DEFAULT_SEA_STATE: SeaStateId = 'smooth'; + +export function waveHeightOf(state: SeaStateId): number { + const { from, to } = SEA_STATES[state]; + return (from + to) / 2; +} + +export function seaAt(state: SeaStateId): SeaReading { + return { state, waveHeight: waveHeightOf(state) }; +} diff --git a/explainers/naval-drone/src/scene/assembly.ts b/explainers/naval-drone/src/scene/assembly.ts new file mode 100644 index 00000000..d47d1d16 --- /dev/null +++ b/explainers/naval-drone/src/scene/assembly.ts @@ -0,0 +1,33 @@ +import type { Box3, Group, Object3D } from 'three'; +import type { MaterialLibrary } from '@core/scene/materials'; +import type { SceneTextures } from '@core/scene/textures'; +import type { AnchorId, AssemblyState, PartId, Point, RegionId } from '../ids'; +import { createPlaceholderAssembly } from './placeholderAssembly'; + +export interface AssemblyResources { + materials: MaterialLibrary; + textures: SceneTextures; +} + +export interface ChaseTarget { + position: Point; + heading: number; + length: number; + ship: Point; +} + +export interface Assembly { + readonly root: Group; + setState(state: AssemblyState): void; + update(deltaSeconds: number, cameraDistance: number): boolean; + labelAnchors(): ReadonlyMap; + anchor(id: AnchorId): Object3D; + region(id: RegionId): Box3; + chaseTarget(): ChaseTarget; + warmUp?(compile: (object: Object3D) => void): void; + dispose(): void; +} + +export function createAssembly(resources: AssemblyResources, state: AssemblyState): Assembly { + return createPlaceholderAssembly(resources, state); +} diff --git a/explainers/naval-drone/src/scene/placeholderAssembly.dom.test.ts b/explainers/naval-drone/src/scene/placeholderAssembly.dom.test.ts new file mode 100644 index 00000000..221b8f91 --- /dev/null +++ b/explainers/naval-drone/src/scene/placeholderAssembly.dom.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { MaterialLibrary } from '@core/scene/materials'; +import { isShown } from '@core/scene/parts'; +import { createSceneTextures } from '@core/scene/textures'; +import { PART_IDS } from '../ids'; +import type { AnchorId, AssemblyState, RegionId } from '../ids'; +import { jetAt, throttleAt } from '../model/jet'; +import { planingAt } from '../model/hull'; +import { boatAt, companionsAt, poseAtDistance } from '../model/run'; +import { seaAt } from '../model/sea'; +import { createAssembly } from './assembly'; + +const ANCHORS: readonly AnchorId[] = [ + 'boat', + 'dome', + 'stern', + 'ship', + 'satellite', + 'backupSatellite', + 'groundStation', +]; +const REGIONS: readonly RegionId[] = ['scene', 'boat', 'ship', 'shore']; +const VIEW = { cutaway: false, flow: false, links: true, labels: true }; + +function stateAt(phase: number, patch: Partial = {}): AssemblyState { + const boat = boatAt(phase); + return { + phase, + playing: false, + boat, + planing: planingAt(boat.knots), + jet: jetAt(throttleAt(boat.knots), boat.knots, 0, false), + companions: companionsAt(phase), + sea: seaAt('smooth'), + link: { mode: 'satellite', ghost: null }, + fit: 'standard', + view: VIEW, + waterSection: false, + wettedBar: false, + ...patch, + }; +} + +function build(state: AssemblyState) { + return createAssembly( + { materials: new MaterialLibrary(), textures: createSceneTextures() }, + state, + ); +} + +describe('placeholder assembly', () => { + it('has an anchor for every part, every named anchor and every region', () => { + const assembly = build(stateAt(60)); + const anchors = assembly.labelAnchors(); + PART_IDS.forEach((id) => expect(anchors.has(id)).toBe(true)); + ANCHORS.forEach((id) => expect(assembly.anchor(id)).toBeDefined()); + REGIONS.forEach((id) => expect(assembly.region(id).isEmpty()).toBe(false)); + assembly.dispose(); + }); + + it('follows the boat and reports the chase target with its heave', () => { + const state = stateAt(60); + const assembly = build(state); + const target = assembly.chaseTarget(); + expect(target.position[0]).toBeCloseTo(state.boat.position[0], 9); + expect(target.position[1]).toBeCloseTo(state.planing.heave, 9); + expect(target.heading).toBe(state.boat.heading); + expect(target.length).toBe(5.5); + expect(assembly.region('boat').containsPoint(assembly.anchor('boat').position)).toBe(true); + assembly.dispose(); + }); + + it('hides what is not drawn: rails, ghost, companions and beams', () => { + const assembly = build(stateAt(60)); + const anchors = assembly.labelAnchors(); + const shown = (id: (typeof PART_IDS)[number]) => isShown(anchors.get(id)!); + expect(shown('missileRails')).toBe(false); + expect(shown('videoGhost')).toBe(false); + expect(shown('companions')).toBe(false); + expect(shown('satLink')).toBe(true); + expect(shown('backupLink')).toBe(false); + assembly.setState( + stateAt(100, { + fit: 'missile', + link: { mode: 'backup', ghost: poseAtDistance(900) }, + }), + ); + expect(shown('missileRails')).toBe(true); + expect(shown('videoGhost')).toBe(true); + expect(shown('companions')).toBe(true); + expect(shown('satLink')).toBe(false); + expect(shown('backupLink')).toBe(true); + assembly.dispose(); + }); + + it('keeps drawing only while the run plays or a trial holds the boat', () => { + const assembly = build(stateAt(60)); + expect(assembly.update(1 / 60, 10)).toBe(false); + assembly.setState(stateAt(60, { playing: true })); + expect(assembly.update(1 / 60, 10)).toBe(true); + const held = stateAt(60); + assembly.setState({ ...held, boat: { ...held.boat, held: true } }); + expect(assembly.update(1 / 60, 10)).toBe(true); + assembly.dispose(); + }); +}); diff --git a/explainers/naval-drone/src/scene/placeholderAssembly.ts b/explainers/naval-drone/src/scene/placeholderAssembly.ts new file mode 100644 index 00000000..1819296b --- /dev/null +++ b/explainers/naval-drone/src/scene/placeholderAssembly.ts @@ -0,0 +1,351 @@ +import { Box3, Group, Mesh, PlaneGeometry } from 'three'; +import type { Object3D } from 'three'; +import { box } from '@core/scene/geometry/box'; +import type { BoxBounds } from '@core/scene/geometry/box'; +import { STRUCTURE_GROUP, UNDIMMED_GROUP } from '@core/scene/materials'; +import { anchorAt } from '@core/scene/parts'; +import { regionFromSpec } from '@core/scene/regions'; +import type { AnchorId, AssemblyState, PartId, Point, RegionId } from '../ids'; +import { PART_IDS } from '../ids'; +import { + BACKUP_PANEL_X, + BACKUP_SATELLITE_OFFSET, + BOAT, + BOW_CAMERA, + DOME, + ELECTRONICS, + ENGINE, + FAIRING, + FUEL_TANKS, + GROUND_STATION, + HULL_DETAIL, + HULL_STATIONS, + IMPELLER, + JET, + MISSILE_FIT, + PANEL, + PAYLOAD_BAY, + SATELLITE_OFFSET, + SCENE_BOUNDS, + SHIP, + SHORE_BOUNDS, + SLIPWAY, + STARLINK_PANEL_XS, + TRANSOM_X, + chineHeight, + skyPoint, + stemXAt, +} from '../model/layout'; +import type { Extent } from '../model/layout'; +import { SHIP_BOUNDS, SHIP_CENTRE, SHIP_HEADING } from '../model/run'; +import { THEME } from '../theme'; +import type { Assembly, AssemblyResources, ChaseTarget } from './assembly'; +import { applyBoatPose } from './pose'; + +const SEA_SIZE = 12000; +const SATELLITE_SIZE = 30; +const STATION_SIZE = 8; +const MARKER_SIZE = 0.2; +const EFFECT_REACH = { jet: 1, spray: 0.8, wake: 4, wetted: 1 } as const; +const MIDSHIP = HULL_STATIONS[3]; +const BOW_SECTION = HULL_STATIONS[4]; +const RAIL_SECTION = HULL_STATIONS[5]; + +type Children = readonly Object3D[]; + +function middle([from, to]: Extent): number { + return (from + to) / 2; +} + +function centredBox(x: Extent, y: Extent, halfWidth: number): BoxBounds { + return { minX: x[0], maxX: x[1], minY: y[0], maxY: y[1], minZ: -halfWidth, maxZ: halfWidth }; +} + +function cube(at: Point, size: number): BoxBounds { + const half = size / 2; + return { + minX: at[0] - half, + maxX: at[0] + half, + minY: at[1] - half, + maxY: at[1] + half, + minZ: at[2] - half, + maxZ: at[2] + half, + }; +} + +const ORIGIN: Point = [0, 0, 0]; +const BOW_CAMERA_AT: Point = [middle(BOW_CAMERA.x), BOW_CAMERA.window.y, 0]; +const WATERJET_AT: Point = [middle(JET.housing.x), JET.axisY, 0]; + +const BOAT_ANCHORS: Readonly>> = { + hull: [0, MIDSHIP.deck, 0], + chines: [BOW_SECTION.x, chineHeight(BOW_SECTION), -BOW_SECTION.chineHalfBreadth], + sprayRails: [middle(HULL_DETAIL.sprayRail.x), RAIL_SECTION.knuckle[1], -RAIL_SECTION.knuckle[0]], + payloadBay: [middle(PAYLOAD_BAY.box.x), middle(PAYLOAD_BAY.box.y), 0], + fuelTanks: [middle(FUEL_TANKS.x), middle(FUEL_TANKS.y), -FUEL_TANKS.centreZ], + engine: [middle(ENGINE.x), middle(ENGINE.y), 0], + electronicsBay: [middle(ELECTRONICS.x), middle(ELECTRONICS.y), 0], + starlinkPanels: [middle(STARLINK_PANEL_XS), PANEL.top, 0], + backupPanel: [BACKUP_PANEL_X, PANEL.top, 0], + cameraDome: [DOME.x, DOME.top, 0], + bowCamera: BOW_CAMERA_AT, + intake: [middle(JET.intake.x), JET.intake.y, 0], + duct: [(JET.intake.x[0] + JET.duct.endX) / 2, (JET.intake.y + JET.axisY) / 2, 0], + driveShaft: [middle(JET.shaft.x), JET.axisY, 0], + impeller: [IMPELLER.x, JET.axisY, 0], + waterjet: WATERJET_AT, + stator: [middle(JET.stator.x), JET.axisY, 0], + nozzle: [middle(JET.nozzle.x), JET.axisY, 0], + steeringNozzle: [middle(JET.steeringNozzle.x), JET.axisY, 0], + reverseBucket: [JET.bucket.pivot[0], JET.bucket.pivot[1], 0], + jetStream: [JET.steeringNozzle.x[0] - EFFECT_REACH.jet, JET.axisY, 0], + bowWave: [stemXAt(0), 0, 0], + spray: [0, 0, -BOAT.beam / 2 - EFFECT_REACH.spray], + wake: [TRANSOM_X - EFFECT_REACH.wake, 0, 0], +}; + +class PlaceholderAssembly implements Assembly { + readonly root = new Group(); + private readonly resources: AssemblyResources; + private readonly boat = new Group(); + private readonly rails = new Group(); + private readonly wettedBar = new Group(); + private readonly ship = new Group(); + private readonly ghost = new Group(); + private readonly satLink = new Group(); + private readonly backupLink = new Group(); + private readonly satellite: Mesh; + private readonly backupSatellite: Mesh; + private readonly groundStation: Mesh; + private readonly companions: readonly Mesh[]; + private readonly anchors = new Map(); + private readonly named: Readonly>; + private readonly regions: Readonly>; + private state: AssemblyState; + + constructor(resources: AssemblyResources, state: AssemblyState) { + this.resources = resources; + this.state = state; + this.satellite = this.mesh('satellite', THEME.panel, cube(ORIGIN, SATELLITE_SIZE)); + this.backupSatellite = this.mesh( + 'backupSatellite', + THEME.backupLink, + cube(ORIGIN, SATELLITE_SIZE), + ); + this.groundStation = this.mesh('groundStation', THEME.radome, cube(ORIGIN, STATION_SIZE)); + this.groundStation.position.set(...GROUND_STATION); + this.companions = [this.boatBody('companions'), this.boatBody('companions')]; + this.buildWorld(); + this.buildBoat(); + this.buildEffects(); + this.named = { + boat: this.boat, + dome: anchorAt(this.boat, DOME.x, DOME.lens, 0), + stern: anchorAt(this.boat, JET.steeringNozzle.x[0], JET.axisY, 0), + ship: this.ship, + satellite: this.satellite, + backupSatellite: this.backupSatellite, + groundStation: this.groundStation, + }; + this.regions = { + scene: regionFromSpec(SCENE_BOUNDS), + boat: new Box3(), + ship: regionFromSpec(SHIP_BOUNDS), + shore: regionFromSpec(SHORE_BOUNDS), + }; + this.attachAnchors(); + this.setState(state); + } + + private mesh(group: string, color: string, bounds: BoxBounds): Mesh { + return new Mesh(box(bounds), this.resources.materials.get(group, { color })); + } + + private boatBody(group: string): Mesh { + return this.mesh( + group, + THEME.hull, + centredBox([TRANSOM_X, BOAT.halfLength], [-BOAT.staticDraft, MIDSHIP.deck], BOAT.beam / 2), + ); + } + + private add(parent: Object3D, children: Children): void { + children.forEach((child) => parent.add(child)); + } + + private buildWorld(): void { + const sea = new Mesh( + new PlaneGeometry(SEA_SIZE, SEA_SIZE), + this.resources.materials.get(UNDIMMED_GROUP, { color: THEME.seaDeep }), + ); + sea.rotation.x = -Math.PI / 2; + const slipway = this.mesh( + STRUCTURE_GROUP, + THEME.concrete, + centredBox(SLIPWAY.x, [SLIPWAY.foot, SLIPWAY.top], SLIPWAY.z[1]), + ); + this.ship.position.set(SHIP_CENTRE[0], 0, SHIP_CENTRE[2]); + this.ship.rotation.y = -SHIP_HEADING; + this.add(this.ship, [ + this.mesh( + 'ship', + THEME.ship, + centredBox( + [-SHIP.length / 2, SHIP.length / 2], + [-SHIP.draft, SHIP.bridgeTop], + SHIP.beam / 2, + ), + ), + this.mesh('shipRadar', THEME.ship, cube([SHIP.mastX, SHIP.radarHeight, 0], 1)), + ]); + this.add(this.root, [ + sea, + slipway, + this.ship, + this.satellite, + this.backupSatellite, + this.groundStation, + ...this.companions, + ]); + } + + private buildBoat(): void { + const panel = (x: number): BoxBounds => + centredBox( + [x - PANEL.length / 2, x + PANEL.length / 2], + [FAIRING.top, PANEL.top], + PANEL.width / 2, + ); + this.rails.add( + this.mesh( + 'missileRails', + THEME.metal, + centredBox( + MISSILE_FIT.x, + [MISSILE_FIT.railTop - MARKER_SIZE, MISSILE_FIT.railTop], + MISSILE_FIT.railZ, + ), + ), + ); + this.add(this.boat, [ + this.boatBody('hull'), + ...STARLINK_PANEL_XS.map((x) => this.mesh('starlinkPanels', THEME.panel, panel(x))), + this.mesh('backupPanel', THEME.panel, panel(BACKUP_PANEL_X)), + this.mesh( + 'cameraDome', + THEME.glass, + cube([DOME.x, DOME.base + DOME.height, 0], DOME.radius * 2), + ), + this.mesh('bowCamera', THEME.glass, cube(BOW_CAMERA_AT, BOW_CAMERA.width)), + this.mesh('waterjet', THEME.jetBlack, cube(WATERJET_AT, JET.housing.outerDiameter)), + this.rails, + this.wettedBar, + ]); + this.root.add(this.boat); + } + + private buildEffects(): void { + this.ghost.add(this.boatBody('videoGhost')); + this.add(this.root, [this.ghost, this.satLink, this.backupLink]); + } + + private attachAnchors(): void { + for (const id of PART_IDS) { + const at = BOAT_ANCHORS[id]; + if (at) this.anchors.set(id, anchorAt(this.boat, at[0], at[1], at[2])); + } + const wetted: Point = [TRANSOM_X + EFFECT_REACH.wetted, 0, -BOAT.beam / 2]; + this.anchors.set('wettedLength', anchorAt(this.wettedBar, ...wetted)); + this.anchors.set( + 'missileRails', + anchorAt(this.rails, 0, MISSILE_FIT.railTop, -MISSILE_FIT.railZ), + ); + this.anchors.set('videoGhost', anchorAt(this.ghost, 0, MIDSHIP.deck, 0)); + this.anchors.set('satLink', anchorAt(this.satLink, ...ORIGIN)); + this.anchors.set('backupLink', anchorAt(this.backupLink, ...ORIGIN)); + this.anchors.set('satellite', anchorAt(this.satellite, ...ORIGIN)); + this.anchors.set('backupSatellite', anchorAt(this.backupSatellite, ...ORIGIN)); + this.anchors.set('groundStation', anchorAt(this.groundStation, ...ORIGIN)); + this.anchors.set('ship', anchorAt(this.ship, 0, SHIP.deck, 0)); + this.anchors.set('shipRadar', anchorAt(this.ship, SHIP.mastX, SHIP.radarHeight, 0)); + this.anchors.set('companions', anchorAt(this.companions[0], 0, MIDSHIP.deck, 0)); + } + + setState(state: AssemblyState): void { + this.state = state; + const { boat, planing, link, view } = state; + applyBoatPose(this.boat, boat, planing); + this.boat.updateMatrixWorld(true); + this.regions.boat.setFromObject(this.boat); + this.rails.visible = state.fit === 'missile'; + this.wettedBar.visible = state.wettedBar; + this.placeSky(boat.position, view.links ? link.mode : 'lost'); + this.ghost.visible = link.ghost !== null; + if (link.ghost) applyBoatPose(this.ghost, link.ghost, planing); + this.companions.forEach((companion, index) => { + const reading = state.companions[index]; + companion.visible = reading !== undefined; + if (reading) applyBoatPose(companion, reading, planing); + }); + } + + private placeSky(position: Point, beam: AssemblyState['link']['mode']): void { + const main = skyPoint(position, SATELLITE_OFFSET); + const backup = skyPoint(position, BACKUP_SATELLITE_OFFSET); + this.satellite.position.set(...main); + this.backupSatellite.position.set(...backup); + this.satLink.position.set( + (position[0] + main[0]) / 2, + main[1] / 2, + (position[2] + main[2]) / 2, + ); + this.backupLink.position.set( + (position[0] + backup[0]) / 2, + backup[1] / 2, + (position[2] + backup[2]) / 2, + ); + this.satLink.visible = beam === 'satellite'; + this.backupLink.visible = beam === 'backup'; + } + + update(): boolean { + return this.state.playing || this.state.boat.held; + } + + labelAnchors(): ReadonlyMap { + return this.anchors; + } + + anchor(id: AnchorId): Object3D { + return this.named[id]; + } + + region(id: RegionId): Box3 { + return this.regions[id]; + } + + chaseTarget(): ChaseTarget { + const { boat, planing } = this.state; + const [x, y, z] = boat.position; + return { + position: [x, y + planing.heave, z], + heading: boat.heading, + length: BOAT.length, + ship: SHIP_CENTRE, + }; + } + + dispose(): void { + this.root.traverse((object) => { + if (object instanceof Mesh) object.geometry.dispose(); + }); + this.root.clear(); + } +} + +export function createPlaceholderAssembly( + resources: AssemblyResources, + state: AssemblyState, +): Assembly { + return new PlaceholderAssembly(resources, state); +} diff --git a/explainers/naval-drone/src/scene/pose.test.ts b/explainers/naval-drone/src/scene/pose.test.ts new file mode 100644 index 00000000..02f96f12 --- /dev/null +++ b/explainers/naval-drone/src/scene/pose.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { Object3D, Vector3 } from 'three'; +import { toRadians } from '@core/math'; +import { applyBoatPose } from './pose'; + +function bowDirection(object: Object3D): Vector3 { + object.updateMatrixWorld(true); + return new Vector3(1, 0, 0).transformDirection(object.matrixWorld); +} + +describe('boat pose', () => { + it('places the boat at its route point raised by the heave', () => { + const boat = new Object3D(); + applyBoatPose(boat, { position: [10, 0, -4], heading: 0 }, { heave: 0.2, trim: 0 }); + expect(boat.position.toArray()).toEqual([10, 0.2, -4]); + }); + + it('points the bow along the heading, turning toward +z as the heading grows', () => { + const boat = new Object3D(); + const heading = toRadians(30); + applyBoatPose(boat, { position: [0, 0, 0], heading }, { heave: 0, trim: 0 }); + const bow = bowDirection(boat); + expect(bow.x).toBeCloseTo(Math.cos(heading), 9); + expect(bow.z).toBeCloseTo(Math.sin(heading), 9); + const starboard = new Vector3(0, 0, 1).transformDirection(boat.matrixWorld); + expect(starboard.x).toBeCloseTo(-Math.sin(heading), 9); + expect(starboard.z).toBeCloseTo(Math.cos(heading), 9); + }); + + it('lifts the bow with a positive trim whatever the heading', () => { + const boat = new Object3D(); + const trim = toRadians(6); + applyBoatPose(boat, { position: [0, 0, 0], heading: toRadians(-20) }, { heave: 0, trim }); + const bow = bowDirection(boat); + expect(bow.y).toBeCloseTo(Math.sin(trim), 9); + expect(Math.atan2(bow.z, bow.x)).toBeCloseTo(toRadians(-20), 9); + }); +}); diff --git a/explainers/naval-drone/src/scene/pose.ts b/explainers/naval-drone/src/scene/pose.ts new file mode 100644 index 00000000..9efb5b7a --- /dev/null +++ b/explainers/naval-drone/src/scene/pose.ts @@ -0,0 +1,14 @@ +import type { Object3D } from 'three'; +import type { BoatReading, PlaningReading } from '../ids'; + +export const BOAT_EULER_ORDER = 'YZX'; + +export function applyBoatPose( + object: Object3D, + boat: Pick, + planing: Pick, +): void { + const [x, y, z] = boat.position; + object.position.set(x, y + planing.heave, z); + object.rotation.set(0, -boat.heading, planing.trim, BOAT_EULER_ORDER); +} diff --git a/explainers/naval-drone/src/theme.ts b/explainers/naval-drone/src/theme.ts new file mode 100644 index 00000000..711e388a --- /dev/null +++ b/explainers/naval-drone/src/theme.ts @@ -0,0 +1,37 @@ +import { THEME as CORE_THEME } from '@core/theme'; + +export const THEME = { + ...CORE_THEME, + launch: '#7fb3d5', + hump: '#ffb347', + cruise: '#6fd38a', + sprint: '#ff7a59', + arrival: '#b48cff', + floating: '#5fa8ff', + planing: '#4fd1c5', + hull: '#4b5259', + hullDark: '#30353a', + deck: '#6c747c', + panel: '#c8cdd2', + jetBlack: '#1b1d20', + glass: '#16222e', + section: '#e6c27a', + foam: '#eef4f6', + seaDeep: '#0e2433', + seaLit: '#1f3d4f', + waterWall: '#2b6f8f', + flowSlow: '#5fa8ff', + flowFast: '#ffffff', + satLink: '#5fa8ff', + backupLink: '#ffd24c', + ghost: '#ffffff', + ship: '#7a8590', + shore: '#4a4a3f', + beach: '#b8a98a', + concrete: '#9a9a94', + radome: '#e8ecef', + sun: '#ffc58a', + skyTop: '#1a2740', + skyHorizon: '#f0a868', + haze: '#8d9aa6', +} as const; From f2cad59bb7754db10d4383e74c90fc3fae6bd276 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:25:33 +0200 Subject: [PATCH 02/99] feat(naval-drone): add the playback, speed trial, link, horizon and fleet model --- .../naval-drone/src/model/fleet.test.ts | 13 +++++ explainers/naval-drone/src/model/fleet.ts | 3 ++ .../naval-drone/src/model/horizon.test.ts | 49 +++++++++++++++++++ explainers/naval-drone/src/model/horizon.ts | 45 +++++++++++++++++ explainers/naval-drone/src/model/index.ts | 12 +++++ explainers/naval-drone/src/model/link.test.ts | 19 +++++++ explainers/naval-drone/src/model/link.ts | 13 +++++ .../naval-drone/src/model/playback.test.ts | 16 ++++++ explainers/naval-drone/src/model/playback.ts | 7 +++ .../naval-drone/src/model/trial.test.ts | 46 +++++++++++++++++ explainers/naval-drone/src/model/trial.ts | 41 ++++++++++++++++ 11 files changed, 264 insertions(+) create mode 100644 explainers/naval-drone/src/model/fleet.test.ts create mode 100644 explainers/naval-drone/src/model/fleet.ts create mode 100644 explainers/naval-drone/src/model/horizon.test.ts create mode 100644 explainers/naval-drone/src/model/horizon.ts create mode 100644 explainers/naval-drone/src/model/index.ts create mode 100644 explainers/naval-drone/src/model/link.test.ts create mode 100644 explainers/naval-drone/src/model/link.ts create mode 100644 explainers/naval-drone/src/model/playback.test.ts create mode 100644 explainers/naval-drone/src/model/playback.ts create mode 100644 explainers/naval-drone/src/model/trial.test.ts create mode 100644 explainers/naval-drone/src/model/trial.ts diff --git a/explainers/naval-drone/src/model/fleet.test.ts b/explainers/naval-drone/src/model/fleet.test.ts new file mode 100644 index 00000000..baaedc03 --- /dev/null +++ b/explainers/naval-drone/src/model/fleet.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { BOAT_COST_USD, KOTOV_VALUE_MUSD, PAYLOAD_MAX_KG } from './fleet'; + +describe('fleet figures', () => { + it('costs about a quarter of a million dollars and carries up to 320 kg', () => { + expect(BOAT_COST_USD).toEqual([250000, 273000]); + expect(PAYLOAD_MAX_KG).toBe(320); + }); + + it('quotes the patrol ship at the value GUR gave', () => { + expect(KOTOV_VALUE_MUSD).toBe(65); + }); +}); diff --git a/explainers/naval-drone/src/model/fleet.ts b/explainers/naval-drone/src/model/fleet.ts new file mode 100644 index 00000000..75c1f386 --- /dev/null +++ b/explainers/naval-drone/src/model/fleet.ts @@ -0,0 +1,3 @@ +export const BOAT_COST_USD = [250000, 273000] as const; +export const PAYLOAD_MAX_KG = 320; +export const KOTOV_VALUE_MUSD = 65; diff --git a/explainers/naval-drone/src/model/horizon.test.ts b/explainers/naval-drone/src/model/horizon.test.ts new file mode 100644 index 00000000..a68ccae8 --- /dev/null +++ b/explainers/naval-drone/src/model/horizon.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { + DETECTION_KM, + LENS_HEIGHT_M, + RADAR_HEIGHT_M, + detectionCovered, + hiddenUpToHalf, + minutesAtTopSpeed, + radarLineOfSightKm, + surfaceDropM, + visualHorizonKm, +} from './horizon'; + +describe('horizon', () => { + it('sees a 0.5 m boat from a 20 m radar out to about 21 km', () => { + expect(radarLineOfSightKm(20)).toBeCloseTo(21.3, 1); + expect(RADAR_HEIGHT_M).toEqual({ min: 5, max: 50, step: 1, default: 20 }); + }); + + it('puts the horizon about 3 km from the 0.7 m camera', () => { + expect(LENS_HEIGHT_M).toBe(0.7); + expect(visualHorizonKm(LENS_HEIGHT_M)).toBeCloseTo(3.2, 1); + }); + + it('crosses 21 km at 42 kn in about 16 minutes and the 9 km detection range in about 7', () => { + expect(minutesAtTopSpeed(radarLineOfSightKm(20))).toBeCloseTo(16.5, 1); + expect(DETECTION_KM).toBe(9.26); + expect(minutesAtTopSpeed(DETECTION_KM)).toBeCloseTo(7.1, 1); + }); + + it('bends the sea so the line of sight grazes it at the radar horizon', () => { + const radarHeight = 20; + const horizonKm = radarLineOfSightKm(radarHeight) - radarLineOfSightKm(0); + expect(surfaceDropM(horizonKm)).toBeCloseTo(radarHeight, 9); + expect(surfaceDropM(0)).toBe(0); + }); + + it('expects detection only up to a moderate sea, sea state 4', () => { + expect(detectionCovered('smooth')).toBe(true); + expect(detectionCovered('moderate')).toBe(true); + expect(detectionCovered('rough')).toBe(false); + }); + + it('hides the boat behind waves up to half the time from a slight sea up', () => { + expect(hiddenUpToHalf('smooth')).toBe(false); + expect(hiddenUpToHalf('slight')).toBe(true); + expect(hiddenUpToHalf('rough')).toBe(true); + }); +}); diff --git a/explainers/naval-drone/src/model/horizon.ts b/explainers/naval-drone/src/model/horizon.ts new file mode 100644 index 00000000..d54b0cd9 --- /dev/null +++ b/explainers/naval-drone/src/model/horizon.ts @@ -0,0 +1,45 @@ +import { SEA_STATE_IDS } from '../ids'; +import type { SeaStateId } from '../ids'; +import { BOAT } from './layout'; +import { knotsToMs } from './scale'; + +export const RADAR_K = 4.12; +export const VISUAL_K = 3.86; +export const TARGET_HEIGHT_M = 0.5; +export const LENS_HEIGHT_M = BOAT.lensHeight; +export const RADAR_HEIGHT_M = { min: 5, max: 50, step: 1, default: 20 } as const; +export const DETECTION_KM = 9.26; + +const METRES_PER_KM = 1000; +const SECONDS_PER_MINUTE = 60; +const DETECTION_COVERED_UP_TO: SeaStateId = 'moderate'; +const HIDDEN_FROM: SeaStateId = 'slight'; + +export function radarLineOfSightKm(radarHeight: number): number { + return RADAR_K * (Math.sqrt(radarHeight) + Math.sqrt(TARGET_HEIGHT_M)); +} + +export function visualHorizonKm(eyeHeight: number): number { + return VISUAL_K * Math.sqrt(eyeHeight); +} + +export function minutesAtTopSpeed(km: number): number { + const seconds = (km * METRES_PER_KM) / knotsToMs(BOAT.topKnots); + return seconds / SECONDS_PER_MINUTE; +} + +export function surfaceDropM(km: number): number { + return (km / RADAR_K) ** 2; +} + +function seaRank(state: SeaStateId): number { + return SEA_STATE_IDS.indexOf(state); +} + +export function detectionCovered(state: SeaStateId): boolean { + return seaRank(state) <= seaRank(DETECTION_COVERED_UP_TO); +} + +export function hiddenUpToHalf(state: SeaStateId): boolean { + return seaRank(state) >= seaRank(HIDDEN_FROM); +} diff --git a/explainers/naval-drone/src/model/index.ts b/explainers/naval-drone/src/model/index.ts new file mode 100644 index 00000000..ea28f415 --- /dev/null +++ b/explainers/naval-drone/src/model/index.ts @@ -0,0 +1,12 @@ +export * from './fleet'; +export * from './horizon'; +export * from './hull'; +export * from './jet'; +export * from './keyframes'; +export * from './layout'; +export * from './link'; +export * from './playback'; +export * from './run'; +export * from './scale'; +export * from './sea'; +export * from './trial'; diff --git a/explainers/naval-drone/src/model/link.test.ts b/explainers/naval-drone/src/model/link.test.ts new file mode 100644 index 00000000..4400dd18 --- /dev/null +++ b/explainers/naval-drone/src/model/link.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { VIDEO_DELAY_MS, boatLengths, lagMetres } from './link'; + +describe('video delay', () => { + it('lets the reader set the delay from 50 ms to a full second, 250 ms at first', () => { + expect(VIDEO_DELAY_MS).toEqual({ min: 50, max: 1000, step: 10, default: 250 }); + }); + + it('turns a quarter second at 42 kn into 5.4 m, about one boat length', () => { + expect(lagMetres(250, 42)).toBeCloseTo(5.4, 1); + expect(boatLengths(lagMetres(250, 42))).toBeCloseTo(1, 1); + }); + + it('grows with the delay and the speed and vanishes at rest', () => { + expect(lagMetres(1000, 42)).toBeCloseTo(21.6, 1); + expect(lagMetres(250, 22)).toBeCloseTo(2.83, 2); + expect(lagMetres(250, 0)).toBe(0); + }); +}); diff --git a/explainers/naval-drone/src/model/link.ts b/explainers/naval-drone/src/model/link.ts new file mode 100644 index 00000000..b5fe8d71 --- /dev/null +++ b/explainers/naval-drone/src/model/link.ts @@ -0,0 +1,13 @@ +import { BOAT } from './layout'; +import { knotsToMs } from './scale'; + +export const VIDEO_DELAY_MS = { min: 50, max: 1000, step: 10, default: 250 } as const; +export const MS_PER_SECOND = 1000; + +export function lagMetres(delayMs: number, knots: number): number { + return (knotsToMs(knots) * delayMs) / MS_PER_SECOND; +} + +export function boatLengths(metres: number): number { + return metres / BOAT.length; +} diff --git a/explainers/naval-drone/src/model/playback.test.ts b/explainers/naval-drone/src/model/playback.test.ts new file mode 100644 index 00000000..e92b16fe --- /dev/null +++ b/explainers/naval-drone/src/model/playback.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { SPEED_RANGE, rate } from './playback'; +import { RUN_SECONDS } from './run'; + +describe('playback', () => { + it('plays the run in two minutes at normal speed, one model second a second', () => { + expect(rate(1)).toBe(1); + expect(RUN_SECONDS / rate(1)).toBe(120); + }); + + it('runs from a quarter to four times normal speed', () => { + expect(SPEED_RANGE).toEqual({ min: 0.25, max: 4, step: 0.25, default: 1 }); + expect(RUN_SECONDS / rate(SPEED_RANGE.max)).toBe(30); + expect(RUN_SECONDS / rate(SPEED_RANGE.min)).toBe(480); + }); +}); diff --git a/explainers/naval-drone/src/model/playback.ts b/explainers/naval-drone/src/model/playback.ts new file mode 100644 index 00000000..6915faa5 --- /dev/null +++ b/explainers/naval-drone/src/model/playback.ts @@ -0,0 +1,7 @@ +export const SPEED_RANGE = { min: 0.25, max: 4, step: 0.25, default: 1 } as const; + +const MODEL_SECONDS_PER_SECOND = 1; + +export function rate(speed: number): number { + return speed * MODEL_SECONDS_PER_SECOND; +} diff --git a/explainers/naval-drone/src/model/trial.test.ts b/explainers/naval-drone/src/model/trial.test.ts new file mode 100644 index 00000000..66193f13 --- /dev/null +++ b/explainers/naval-drone/src/model/trial.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { SPEED_MARK_IDS } from '../ids'; +import { + MARK_TOLERANCE_KN, + SPEED_MARKS, + THROTTLE_PERCENT, + TRIAL_KNOTS, + speedMarkAt, + throttlePercentOf, + throttleShareOf, + trialKnotsOf, +} from './trial'; + +describe('speed trial', () => { + it('lets the reader set any speed from rest to the 42 kn top speed in tenths', () => { + expect(TRIAL_KNOTS).toEqual({ min: 0, max: 42, step: 0.1 }); + expect(THROTTLE_PERCENT).toEqual({ min: 0, max: 100, step: 1 }); + }); + + it('marks hull speed, the hump, planing, cruise and top speed', () => { + expect(SPEED_MARK_IDS.map((mark) => SPEED_MARKS[mark])).toEqual([5.7, 11, 16, 22, 42]); + expect(MARK_TOLERANCE_KN).toBe(0.25); + }); + + it('keeps a trial speed inside the slider and on its tenths', () => { + expect(trialKnotsOf(-3)).toBe(0); + expect(trialKnotsOf(50)).toBe(42); + expect(trialKnotsOf(11.04)).toBe(11); + expect(trialKnotsOf(5.66)).toBe(5.7); + }); + + it('turns throttle percents into a share of full flow and back', () => { + expect(throttleShareOf(55)).toBeCloseTo(0.55, 12); + expect(throttleShareOf(140)).toBe(1); + expect(throttleShareOf(-5)).toBe(0); + expect(throttlePercentOf(0.694)).toBe(69); + }); + + it('finds the mark within a quarter knot and none between marks', () => { + expect(speedMarkAt(5.9)).toBe('hullSpeed'); + expect(speedMarkAt(21.8)).toBe('cruise'); + expect(speedMarkAt(42)).toBe('top'); + expect(speedMarkAt(5.4)).toBeUndefined(); + expect(speedMarkAt(30)).toBeUndefined(); + }); +}); diff --git a/explainers/naval-drone/src/model/trial.ts b/explainers/naval-drone/src/model/trial.ts new file mode 100644 index 00000000..57c98c78 --- /dev/null +++ b/explainers/naval-drone/src/model/trial.ts @@ -0,0 +1,41 @@ +import { clamp } from '@core/math'; +import type { SpeedMarkId } from '../ids'; +import { HULL_SPEED_KN, HUMP_PEAK_KN, PLANING_FROM_KN } from './hull'; +import { BOAT } from './layout'; + +export const TRIAL_KNOTS = { min: 0, max: BOAT.topKnots, step: 0.1 } as const; +export const THROTTLE_PERCENT = { min: 0, max: 100, step: 1 } as const; +export const MARK_TOLERANCE_KN = 0.25; + +export const SPEED_MARKS: Readonly> = { + hullSpeed: HULL_SPEED_KN, + hump: HUMP_PEAK_KN, + planing: PLANING_FROM_KN, + cruise: BOAT.cruiseKnots, + top: BOAT.topKnots, +}; + +const PERCENT = 100; + +export function roundToStep(value: number, step: number): number { + return Math.round(value / step) * step; +} + +export function trialKnotsOf(knots: number): number { + const stepped = roundToStep(clamp(knots, TRIAL_KNOTS.min, TRIAL_KNOTS.max), TRIAL_KNOTS.step); + return Number(stepped.toFixed(1)); +} + +export function throttleShareOf(percent: number): number { + return clamp(percent, THROTTLE_PERCENT.min, THROTTLE_PERCENT.max) / PERCENT; +} + +export function throttlePercentOf(share: number): number { + return roundToStep(share * PERCENT, THROTTLE_PERCENT.step); +} + +export function speedMarkAt(knots: number): SpeedMarkId | undefined { + return (Object.keys(SPEED_MARKS) as SpeedMarkId[]).find( + (mark) => Math.abs(SPEED_MARKS[mark] - knots) <= MARK_TOLERANCE_KN, + ); +} From d0cd4732f752d98819c58bc243c2ecc2ef1f9570 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:25:33 +0200 Subject: [PATCH 03/99] feat(naval-drone): add the timeline and the store with the speed trial hold --- .../naval-drone/src/state/derived.test.ts | 111 ++++++++ explainers/naval-drone/src/state/derived.ts | 127 +++++++++ explainers/naval-drone/src/state/index.ts | 3 + .../naval-drone/src/state/presets.test.ts | 94 +++++++ explainers/naval-drone/src/state/presets.ts | 143 +++++++++++ .../naval-drone/src/state/store.test.ts | 242 ++++++++++++++++++ explainers/naval-drone/src/state/store.ts | 142 ++++++++++ explainers/naval-drone/src/timeline.test.ts | 43 ++++ explainers/naval-drone/src/timeline.ts | 48 ++++ .../naval-drone/src/ui/format.dom.test.ts | 153 +++++++++++ explainers/naval-drone/src/ui/format.ts | 240 +++++++++++++++++ explainers/naval-drone/src/ui/palette.ts | 16 ++ explainers/naval-drone/src/ui/testing.ts | 9 + 13 files changed, 1371 insertions(+) create mode 100644 explainers/naval-drone/src/state/derived.test.ts create mode 100644 explainers/naval-drone/src/state/derived.ts create mode 100644 explainers/naval-drone/src/state/index.ts create mode 100644 explainers/naval-drone/src/state/presets.test.ts create mode 100644 explainers/naval-drone/src/state/presets.ts create mode 100644 explainers/naval-drone/src/state/store.test.ts create mode 100644 explainers/naval-drone/src/state/store.ts create mode 100644 explainers/naval-drone/src/timeline.test.ts create mode 100644 explainers/naval-drone/src/timeline.ts create mode 100644 explainers/naval-drone/src/ui/format.dom.test.ts create mode 100644 explainers/naval-drone/src/ui/format.ts create mode 100644 explainers/naval-drone/src/ui/palette.ts create mode 100644 explainers/naval-drone/src/ui/testing.ts diff --git a/explainers/naval-drone/src/state/derived.test.ts b/explainers/naval-drone/src/state/derived.test.ts new file mode 100644 index 00000000..8abe23f9 --- /dev/null +++ b/explainers/naval-drone/src/state/derived.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { toRadians } from '@core/math'; +import { + HELD_PHASE, + boatAt, + companionsAt, + distanceToShip, + jetAt, + lagMetres, + planingAt, + poseAtDistance, + seaAt, + steerAt, + throttleAt, +} from '../model'; +import { assemblyFlags, followedKnots, runAt } from './derived'; +import type { RunSource } from './derived'; +import { DEFAULT_VIEW } from './store'; + +const RUNNING: RunSource = { + phase: 45, + trialKnots: null, + helm: 'straight', + linkMode: 'satellite', + videoDelayMs: 250, + seaState: 'smooth', + preset: 'overview', +}; + +function held(knots: number, helm: RunSource['helm'] = 'straight'): RunSource { + return { ...RUNNING, phase: HELD_PHASE, trialKnots: knots, helm, preset: 'jet' }; +} + +describe('run readings', () => { + it('follows the run under the scrubber while nobody holds the boat', () => { + const reading = runAt(RUNNING); + const boat = boatAt(45); + expect(reading.boat).toEqual(boat); + expect(reading.planing).toEqual(planingAt(boat.knots)); + expect(reading.jet).toEqual(jetAt(throttleAt(boat.knots), boat.knots, steerAt(45), false)); + expect(reading.jet.nozzleAngle).toBeCloseTo(-toRadians(8), 9); + expect(reading.companions).toEqual(companionsAt(45)); + expect(reading.distanceToShip).toBeCloseTo(distanceToShip(45), 9); + expect(reading.sea).toEqual(seaAt('smooth')); + }); + + it('brings the companions in for the sprint', () => { + expect(runAt({ ...RUNNING, phase: 100 }).companions).toHaveLength(2); + }); + + it('holds the boat in place at the trial speed', () => { + const reading = runAt(held(42)); + const place = boatAt(HELD_PHASE); + expect(reading.boat).toEqual({ ...place, knots: 42, held: true }); + expect(reading.planing).toEqual(planingAt(42)); + expect(reading.jet).toEqual(jetAt(throttleAt(42), 42, 0, false)); + expect(reading.companions).toEqual([]); + }); + + it('swings the nozzle full over for each helm', () => { + expect(runAt(held(22, 'left')).jet.nozzleAngle).toBeCloseTo(-toRadians(27), 9); + expect(runAt(held(22, 'right')).jet.nozzleAngle).toBeCloseTo(toRadians(27), 9); + }); + + it('stops the boat with the bucket down in reverse but keeps the pump running', () => { + const reading = runAt(held(22, 'reverse')); + expect(reading.boat.knots).toBe(0); + expect(reading.planing).toEqual(planingAt(0)); + expect(reading.jet).toMatchObject({ throttle: throttleAt(22), bucket: 1, thrust: 0 }); + }); + + it('draws the delayed video ghost only in the link chapter while the link is up', () => { + const link: RunSource = { ...RUNNING, phase: 100, preset: 'link', videoDelayMs: 500 }; + const boat = boatAt(100); + expect(runAt(link).link).toEqual({ + mode: 'satellite', + ghost: poseAtDistance(boat.distance - lagMetres(500, boat.knots)), + }); + expect(runAt({ ...link, linkMode: 'backup' }).link.ghost).not.toBeNull(); + expect(runAt({ ...link, linkMode: 'lost' }).link).toEqual({ mode: 'lost', ghost: null }); + expect(runAt({ ...link, preset: 'overview' }).link.ghost).toBeNull(); + }); + + it('reads the sea state the reader picks', () => { + expect(runAt({ ...RUNNING, seaState: 'rough' }).sea).toEqual(seaAt('rough')); + }); + + it('remembers the last reading until a source changes', () => { + const first = runAt({ ...RUNNING, phase: 70 }); + expect(runAt({ ...RUNNING, phase: 70 })).toBe(first); + expect(runAt({ ...RUNNING, phase: 70, seaState: 'slight' })).not.toBe(first); + }); + + it('follows the run speed in the sliders until the reader holds the boat', () => { + expect(followedKnots({ phase: 28, trialKnots: null })).toBe(11); + expect(followedKnots({ phase: 28, trialKnots: 30 })).toBe(30); + }); + + it('opens the water section with the cutaway or in the hull chapter, the bar only there', () => { + expect(assemblyFlags({ view: DEFAULT_VIEW, preset: 'overview' })).toEqual({ + waterSection: true, + wettedBar: false, + }); + const closed = { ...DEFAULT_VIEW, cutaway: false }; + expect(assemblyFlags({ view: closed, preset: 'link' }).waterSection).toBe(false); + expect(assemblyFlags({ view: closed, preset: 'hull' })).toEqual({ + waterSection: true, + wettedBar: true, + }); + }); +}); diff --git a/explainers/naval-drone/src/state/derived.ts b/explainers/naval-drone/src/state/derived.ts new file mode 100644 index 00000000..c3c6ac2d --- /dev/null +++ b/explainers/naval-drone/src/state/derived.ts @@ -0,0 +1,127 @@ +import type { + BoatReading, + CompanionReading, + HelmId, + JetReading, + LinkReading, + PlaningReading, + SeaReading, +} from '../ids'; +import { + boatAt, + companionsAt, + distanceToShip, + helmNozzle, + jetAt, + lagMetres, + planingAt, + poseAtDistance, + seaAt, + speedAt, + steerAt, + throttleAt, +} from '../model'; +import type { NavalDroneState } from './store'; + +const RUN_SOURCE_KEYS = [ + 'phase', + 'trialKnots', + 'helm', + 'linkMode', + 'videoDelayMs', + 'seaState', + 'preset', +] as const; + +export type RunSource = Pick; +export type FlagSource = Pick; + +export interface RunReading { + boat: BoatReading; + planing: PlaningReading; + jet: JetReading; + companions: readonly CompanionReading[]; + distanceToShip: number; + sea: SeaReading; + link: LinkReading; +} + +export interface AssemblyFlags { + waterSection: boolean; + wettedBar: boolean; +} + +type Motion = Pick; + +const NO_COMPANIONS: readonly CompanionReading[] = []; +const STOPPED_KNOTS = 0; + +function runningAt(phase: number): Motion { + const boat = boatAt(phase); + return { + boat, + planing: planingAt(boat.knots), + jet: jetAt(throttleAt(boat.knots), boat.knots, steerAt(phase), false), + companions: companionsAt(phase), + }; +} + +function heldAt(phase: number, trialKnots: number, helm: HelmId): Motion { + const reverse = helm === 'reverse'; + const knots = reverse ? STOPPED_KNOTS : trialKnots; + return { + boat: { ...boatAt(phase), knots, held: true }, + planing: planingAt(knots), + jet: jetAt(throttleAt(trialKnots), knots, helmNozzle(helm), reverse), + companions: NO_COMPANIONS, + }; +} + +function linkOf(source: RunSource, boat: BoatReading): LinkReading { + const { linkMode: mode, videoDelayMs, preset } = source; + if (preset !== 'link' || mode === 'lost') return { mode, ghost: null }; + return { mode, ghost: poseAtDistance(boat.distance - lagMetres(videoDelayMs, boat.knots)) }; +} + +function readingOf(source: RunSource): RunReading { + const { phase, trialKnots, helm, seaState } = source; + const motion = trialKnots === null ? runningAt(phase) : heldAt(phase, trialKnots, helm); + return { + ...motion, + distanceToShip: distanceToShip(phase), + sea: seaAt(seaState), + link: linkOf(source, motion.boat), + }; +} + +interface Remembered { + source: RunSource; + reading: RunReading; +} + +let last: Remembered | null = null; + +function sourceOf(state: RunSource): RunSource { + const { phase, trialKnots, helm, linkMode, videoDelayMs, seaState, preset } = state; + return { phase, trialKnots, helm, linkMode, videoDelayMs, seaState, preset }; +} + +function isRemembered(source: RunSource): boolean { + return last !== null && RUN_SOURCE_KEYS.every((key) => last?.source[key] === source[key]); +} + +export function runAt(state: RunSource): Readonly { + if (!last || !isRemembered(state)) { + const source = sourceOf(state); + last = { source, reading: readingOf(source) }; + } + return last.reading; +} + +export function followedKnots(state: Pick): number { + return state.trialKnots ?? speedAt(state.phase); +} + +export function assemblyFlags({ view, preset }: FlagSource): AssemblyFlags { + return { waterSection: view.cutaway || preset === 'hull', wettedBar: preset === 'hull' }; +} diff --git a/explainers/naval-drone/src/state/index.ts b/explainers/naval-drone/src/state/index.ts new file mode 100644 index 00000000..029bc932 --- /dev/null +++ b/explainers/naval-drone/src/state/index.ts @@ -0,0 +1,3 @@ +export * from './derived'; +export * from './presets'; +export * from './store'; diff --git a/explainers/naval-drone/src/state/presets.test.ts b/explainers/naval-drone/src/state/presets.test.ts new file mode 100644 index 00000000..bc3b5586 --- /dev/null +++ b/explainers/naval-drone/src/state/presets.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { PART_IDS, PRESET_IDS } from '../ids'; +import { HELD_PHASE, SPEED_RANGE } from '../model'; +import { PRESETS } from './presets'; + +describe('chapter presets', () => { + it('has the six chapters in reading order', () => { + expect(Object.keys(PRESETS)).toEqual([...PRESET_IDS]); + }); + + it('frames each chapter with its own camera view', () => { + expect(PRESET_IDS.map((id) => PRESETS[id].camera)).toEqual([ + 'chase', + 'waterline', + 'stern', + 'sky', + 'eye', + 'group', + ]); + }); + + it('labels and highlights only known parts', () => { + Object.entries(PRESETS).forEach(([id, preset]) => + [...preset.labels, ...preset.highlight].forEach((part) => + expect(PART_IDS, `${id}: ${part}`).toContain(part), + ), + ); + }); + + it('highlights the labelled parts of the hull, jet and link chapters', () => { + expect(PRESETS.hull.highlight).toEqual(PRESETS.hull.labels); + expect(PRESETS.link.highlight).toEqual(PRESETS.link.labels); + expect(PRESETS.jet.highlight).toEqual([...PRESETS.jet.labels, 'waterjet', 'engine']); + expect(PRESETS.horizon.highlight).toEqual(['cameraDome', 'bowCamera', 'ship', 'shipRadar']); + expect(PRESETS.overview.highlight).toEqual([]); + expect(PRESETS.fleet.highlight).toEqual([]); + }); + + it('plays every chapter at normal speed', () => { + Object.values(PRESETS).forEach((preset) => { + expect(preset.speed).toBe(1); + expect(preset.speed).toBeGreaterThanOrEqual(SPEED_RANGE.min); + }); + }); + + it('holds the boat on the final leg for the hull and the jet, and seeks the others', () => { + expect(PRESET_IDS.map((id) => PRESETS[id].pauseAt)).toEqual([ + undefined, + HELD_PHASE, + HELD_PHASE, + undefined, + undefined, + undefined, + ]); + expect(PRESET_IDS.map((id) => PRESETS[id].startAt)).toEqual([ + 0, + undefined, + undefined, + 52, + 62, + 96, + ]); + }); + + it('sets the cutaway, the water flow and the links for each chapter, never the labels', () => { + expect(PRESET_IDS.map((id) => PRESETS[id].view)).toEqual([ + { cutaway: true, flow: false, links: false }, + { cutaway: false, flow: true, links: false }, + { cutaway: true, flow: true, links: false }, + { cutaway: false, flow: false, links: true }, + { cutaway: false, flow: false, links: false }, + { cutaway: false, flow: false, links: false }, + ]); + }); + + it('starts each chapter control only in the chapter that owns it', () => { + expect(PRESET_IDS.map((id) => PRESETS[id].controls)).toEqual([ + undefined, + ['trialKnots'], + ['trialKnots', 'helm'], + ['linkMode', 'videoDelayMs'], + ['radarHeight', 'seaState'], + undefined, + ]); + Object.entries(PRESETS).forEach(([id, preset]) => + Object.keys(preset.start ?? {}).forEach((control) => + expect(preset.controls, `${id}: ${control}`).toContain(control), + ), + ); + expect(PRESETS.hull.start).toEqual({ trialKnots: 11 }); + expect(PRESETS.jet.start).toEqual({ trialKnots: 22, helm: 'straight' }); + expect(PRESETS.horizon.start).toEqual({ seaState: 'slight' }); + }); +}); diff --git a/explainers/naval-drone/src/state/presets.ts b/explainers/naval-drone/src/state/presets.ts new file mode 100644 index 00000000..e9b1b148 --- /dev/null +++ b/explainers/naval-drone/src/state/presets.ts @@ -0,0 +1,143 @@ +import type { ScenePreset } from '@core/scene/presetBinder'; +import type { + CameraView, + FitId, + HelmId, + LinkMode, + PartId, + PresetId, + SeaStateId, + ViewOptions, +} from '../ids'; +import { HELD_PHASE } from '../model'; + +export interface ChapterControls { + trialKnots: number | null; + helm: HelmId; + linkMode: LinkMode; + videoDelayMs: number; + radarHeight: number; + seaState: SeaStateId; +} + +export type ChapterControl = keyof ChapterControls; + +export interface Preset extends ScenePreset { + view: Partial; + controls?: readonly ChapterControl[]; + start?: Partial; +} + +export const DEFAULT_FIT: FitId = 'standard'; + +const NORMAL_SPEED = 1; +const HULL_TRIAL_KNOTS = 11; +const JET_TRIAL_KNOTS = 22; + +const CHAPTER_START_SECONDS = { + overview: 0, + link: 52, + horizon: 62, + fleet: 96, +} as const; + +const HULL_PARTS: readonly PartId[] = [ + 'hull', + 'chines', + 'sprayRails', + 'bowWave', + 'spray', + 'wake', + 'wettedLength', +]; + +const JET_PARTS: readonly PartId[] = [ + 'intake', + 'duct', + 'driveShaft', + 'impeller', + 'stator', + 'nozzle', + 'steeringNozzle', + 'reverseBucket', + 'jetStream', +]; + +const LINK_PARTS: readonly PartId[] = [ + 'starlinkPanels', + 'backupPanel', + 'satLink', + 'backupLink', + 'satellite', + 'backupSatellite', + 'groundStation', + 'videoGhost', +]; + +export const PRESETS: Record = { + overview: { + camera: 'chase', + speed: NORMAL_SPEED, + view: { cutaway: true, flow: false, links: false }, + startAt: CHAPTER_START_SECONDS.overview, + labels: [ + 'hull', + 'payloadBay', + 'fuelTanks', + 'engine', + 'electronicsBay', + 'waterjet', + 'starlinkPanels', + 'cameraDome', + 'bowCamera', + ], + highlight: [], + }, + hull: { + camera: 'waterline', + speed: NORMAL_SPEED, + view: { cutaway: false, flow: true, links: false }, + pauseAt: HELD_PHASE, + controls: ['trialKnots'], + start: { trialKnots: HULL_TRIAL_KNOTS }, + labels: HULL_PARTS, + highlight: HULL_PARTS, + }, + jet: { + camera: 'stern', + speed: NORMAL_SPEED, + view: { cutaway: true, flow: true, links: false }, + pauseAt: HELD_PHASE, + controls: ['trialKnots', 'helm'], + start: { trialKnots: JET_TRIAL_KNOTS, helm: 'straight' }, + labels: JET_PARTS, + highlight: [...JET_PARTS, 'waterjet', 'engine'], + }, + link: { + camera: 'sky', + speed: NORMAL_SPEED, + view: { cutaway: false, flow: false, links: true }, + startAt: CHAPTER_START_SECONDS.link, + controls: ['linkMode', 'videoDelayMs'], + labels: LINK_PARTS, + highlight: LINK_PARTS, + }, + horizon: { + camera: 'eye', + speed: NORMAL_SPEED, + view: { cutaway: false, flow: false, links: false }, + startAt: CHAPTER_START_SECONDS.horizon, + controls: ['radarHeight', 'seaState'], + start: { seaState: 'slight' }, + labels: ['ship', 'shipRadar', 'bowCamera', 'wake'], + highlight: ['cameraDome', 'bowCamera', 'ship', 'shipRadar'], + }, + fleet: { + camera: 'group', + speed: NORMAL_SPEED, + view: { cutaway: false, flow: false, links: false }, + startAt: CHAPTER_START_SECONDS.fleet, + labels: ['companions', 'ship', 'missileRails', 'starlinkPanels'], + highlight: [], + }, +}; diff --git a/explainers/naval-drone/src/state/store.test.ts b/explainers/naval-drone/src/state/store.test.ts new file mode 100644 index 00000000..9915d57e --- /dev/null +++ b/explainers/naval-drone/src/state/store.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; +import { MOMENT_IDS, PRESET_IDS } from '../ids'; +import { HELD_PHASE, MOMENTS, RUN_SECONDS, knotsAtThrottle, speedAt, throttleAt } from '../model'; +import { PRESETS } from './presets'; +import { CHAPTER_CONTROL_DEFAULTS, DEFAULT_VIEW, createNavalDroneStore } from './store'; +import type { NavalDroneStore } from './store'; + +function heldStore(knots = 30): NavalDroneStore { + const store = createNavalDroneStore({ phase: 50, playing: true }); + store.getState().setTrialKnots(knots); + return store; +} + +describe('naval drone store', () => { + it('starts on the overview at the slipway with every chapter control at rest', () => { + expect(createNavalDroneStore().getState()).toMatchObject({ + phase: 0, + speed: 1, + preset: 'overview', + fit: 'standard', + trialKnots: null, + helm: 'straight', + linkMode: 'satellite', + videoDelayMs: 250, + radarHeight: 20, + seaState: 'smooth', + view: { cutaway: true, flow: false, links: false, labels: true }, + }); + }); + + it('opens with the view the overview chapter asks for', () => { + expect(DEFAULT_VIEW).toMatchObject(PRESETS.overview.view); + }); + + it('runs the whole route in two minutes and stops at the ship', () => { + const store = createNavalDroneStore({ phase: 0, playing: true }); + store.getState().tick(60); + expect(store.getState().phase).toBeCloseTo(60, 9); + store.getState().tick(90); + expect(store.getState().phase).toBe(RUN_SECONDS); + expect(store.getState().playing).toBe(false); + }); + + it('pauses at each moment', () => { + const store = createNavalDroneStore({ playing: true }); + MOMENT_IDS.forEach((moment) => { + store.getState().play(); + store.getState().seekMoment(moment); + expect(store.getState().playing, moment).toBe(false); + expect(store.getState().phase, moment).toBeCloseTo(MOMENTS[moment], 9); + }); + }); + + it('keeps the delay and the radar height on their sliders', () => { + const store = createNavalDroneStore(); + store.getState().setVideoDelay(2000); + expect(store.getState().videoDelayMs).toBe(1000); + store.getState().setVideoDelay(10); + expect(store.getState().videoDelayMs).toBe(50); + store.getState().setVideoDelay(333); + expect(store.getState().videoDelayMs).toBe(330); + store.getState().setRadarHeight(80); + expect(store.getState().radarHeight).toBe(50); + store.getState().setRadarHeight(1); + expect(store.getState().radarHeight).toBe(5); + }); +}); + +describe('speed trial', () => { + it('takes the boat over and pauses the run when the reader sets a speed', () => { + const store = heldStore(27.04); + expect(store.getState()).toMatchObject({ trialKnots: 27, playing: false, phase: 50 }); + store.getState().setTrialKnots(60); + expect(store.getState().trialKnots).toBe(42); + store.getState().setTrialKnots(-1); + expect(store.getState().trialKnots).toBe(0); + }); + + it('sets the speed that a throttle setting holds', () => { + const store = createNavalDroneStore({ playing: true }); + store.getState().setThrottle(55); + expect(store.getState().playing).toBe(false); + expect(store.getState().trialKnots).toBeCloseTo(knotsAtThrottle(0.55), 9); + expect(throttleAt(store.getState().trialKnots ?? 0)).toBeCloseTo(0.55, 6); + store.getState().setThrottle(150); + expect(store.getState().trialKnots).toBeCloseTo(42, 6); + }); + + it('jumps to each speed mark', () => { + const store = createNavalDroneStore({ playing: true }); + store.getState().setSpeedMark('hullSpeed'); + expect(store.getState()).toMatchObject({ trialKnots: 5.7, playing: false }); + store.getState().setSpeedMark('planing'); + expect(store.getState().trialKnots).toBe(16); + }); + + it('holds the run speed of the moment when the helm moves first', () => { + const store = createNavalDroneStore({ phase: 30, playing: true }); + store.getState().setHelm('left'); + expect(store.getState()).toMatchObject({ helm: 'left', playing: false }); + expect(store.getState().trialKnots).toBeCloseTo(speedAt(30), 1); + store.getState().setTrialKnots(35); + store.getState().setHelm('reverse'); + expect(store.getState()).toMatchObject({ helm: 'reverse', trialKnots: 35 }); + }); + + it('lets go of the boat and centres the helm on release', () => { + const store = heldStore(); + store.getState().setHelm('right'); + store.getState().releaseTrial(); + expect(store.getState()).toMatchObject({ trialKnots: null, helm: 'straight' }); + }); + + it('lets go of the boat when the run plays again', () => { + const store = heldStore(); + store.getState().setHelm('reverse'); + store.getState().play(); + expect(store.getState()).toMatchObject({ trialKnots: null, helm: 'straight', playing: true }); + }); + + it.each([ + ['the scrubber', (store: NavalDroneStore) => store.getState().setPhase(70)], + ['a step', (store: NavalDroneStore) => store.getState().step(0.5)], + ['a phase chip', (store: NavalDroneStore) => store.getState().jumpToPhase('sprint')], + ['a moment chip', (store: NavalDroneStore) => store.getState().seekMoment('topSpeed')], + ])('lets go of the boat on %s', (_, seek) => { + const store = heldStore(); + seek(store); + expect(store.getState().trialKnots).toBeNull(); + expect(store.getState().playing).toBe(false); + }); + + it('keeps holding the boat while the phase stays put', () => { + const store = heldStore(); + store.getState().setPhase(50); + store.getState().setFit('missile'); + store.getState().toggleView('cutaway'); + store.getState().setSpeed(2); + expect(store.getState().trialKnots).toBe(30); + }); + + it('holds the boat at the hull and jet chapter speeds when the chapter seeks', () => { + const store = createNavalDroneStore({ phase: 10, playing: true }); + store.getState().applyPreset('hull'); + expect(store.getState()).toMatchObject({ + phase: HELD_PHASE, + playing: false, + trialKnots: 11, + helm: 'straight', + }); + store.getState().setHelm('left'); + store.getState().applyPreset('jet'); + expect(store.getState()).toMatchObject({ phase: HELD_PHASE, trialKnots: 22, helm: 'straight' }); + }); + + it('lets go of the boat and plays on in the chapters without a trial', () => { + const store = createNavalDroneStore({ phase: 10, playing: true }); + store.getState().applyPreset('jet'); + store.getState().applyPreset('link'); + expect(store.getState()).toMatchObject({ + trialKnots: null, + helm: 'straight', + phase: 52, + playing: true, + }); + }); + + it('plays on after the reader takes the helm in a chapter that paused the run itself', () => { + const store = createNavalDroneStore({ phase: 10, playing: true }); + store.getState().applyPreset('jet'); + store.getState().setHelm('right'); + store.getState().setTrialKnots(30); + store.getState().applyPreset('link'); + expect(store.getState()).toMatchObject({ trialKnots: null, helm: 'straight', playing: true }); + }); + + it('keeps the pause a trial makes while the run plays', () => { + const store = createNavalDroneStore({ phase: 10, playing: true }); + store.getState().applyPreset('jet'); + store.getState().play(); + store.getState().setTrialKnots(30); + store.getState().applyPreset('link'); + expect(store.getState()).toMatchObject({ trialKnots: null, playing: false }); + }); +}); + +describe('chapter controls', () => { + it.each([ + ['hull', 'trialKnots', 30], + ['jet', 'helm', 'reverse'], + ['link', 'linkMode', 'lost'], + ['link', 'videoDelayMs', 600], + ['horizon', 'radarHeight', 40], + ['horizon', 'seaState', 'rough'], + ] as const)( + 'resets the %s chapter control %s in every other chapter', + (chapter, field, value) => { + const store = createNavalDroneStore(); + PRESET_IDS.forEach((id) => { + store.getState().applyPreset(chapter); + store.setState({ [field]: value }); + store.getState().applyPreset(id); + const expected = + id === chapter ? value : (PRESETS[id].start?.[field] ?? CHAPTER_CONTROL_DEFAULTS[field]); + expect(store.getState()[field], id).toBe(expected); + }); + }, + ); + + it('keeps every control when the same chapter is applied again', () => { + const store = createNavalDroneStore(); + store.getState().applyPreset('horizon'); + store.getState().setSeaState('moderate'); + store.getState().setRadarHeight(35); + store.getState().applyPreset('horizon'); + expect(store.getState()).toMatchObject({ seaState: 'moderate', radarHeight: 35 }); + }); + + it('opens the horizon chapter on a slight sea', () => { + const store = createNavalDroneStore(); + store.getState().applyPreset('horizon'); + expect(store.getState()).toMatchObject({ seaState: 'slight', phase: 62 }); + }); + + it('keeps the deck fit and the labels through every chapter', () => { + const store = createNavalDroneStore(); + store.getState().setFit('missile'); + PRESET_IDS.forEach((id) => { + store.getState().applyPreset(id); + expect(store.getState().fit, id).toBe('missile'); + expect(store.getState().view.labels, id).toBe(true); + }); + }); + + it('seeks the chapters without a trial and keeps playing', () => { + const store = createNavalDroneStore({ phase: 80, playing: true }); + store.getState().applyPreset('fleet'); + expect(store.getState()).toMatchObject({ phase: 96, playing: true, speed: 1 }); + store.getState().applyPreset('horizon'); + expect(store.getState()).toMatchObject({ phase: 62, playing: true }); + }); +}); diff --git a/explainers/naval-drone/src/state/store.ts b/explainers/naval-drone/src/state/store.ts new file mode 100644 index 00000000..07584f0e --- /dev/null +++ b/explainers/naval-drone/src/state/store.ts @@ -0,0 +1,142 @@ +import type { ExplainerStore, Playback, PlaybackState } from '@core/explainer'; +import { clamp } from '@core/math'; +import { createExplainerStore } from '@core/store'; +import { shallow } from 'zustand/vanilla/shallow'; +import type { + FitId, + HelmId, + LinkMode, + MomentId, + PresetId, + SeaStateId, + SpeedMarkId, + ViewOptions, +} from '../ids'; +import { + DEFAULT_SEA_STATE, + MOMENTS, + RADAR_HEIGHT_M, + SPEED_MARKS, + SPEED_RANGE, + VIDEO_DELAY_MS, + knotsAtThrottle, + speedAt, + throttleShareOf, + trialKnotsOf, +} from '../model'; +import { NAVAL_DRONE_TIMELINE } from '../timeline'; +import { DEFAULT_FIT, PRESETS } from './presets'; +import type { ChapterControls, Preset } from './presets'; + +export type ViewState = { [Key in keyof ViewOptions]: ViewOptions[Key] }; + +export interface NavalDroneFields extends ChapterControls { + fit: FitId; + view: ViewState; + preset: PresetId; +} + +export interface NavalDroneOwnActions { + setFit(fit: FitId): void; + setTrialKnots(knots: number): void; + setThrottle(percent: number): void; + setSpeedMark(mark: SpeedMarkId): void; + setHelm(helm: HelmId): void; + releaseTrial(): void; + setLinkMode(mode: LinkMode): void; + setVideoDelay(delayMs: number): void; + setRadarHeight(metres: number): void; + setSeaState(sea: SeaStateId): void; + seekMoment(moment: MomentId): void; +} + +export type NavalDroneState = PlaybackState & NavalDroneFields; +export type NavalDroneStoreState = Playback & NavalDroneFields & NavalDroneOwnActions; +export type NavalDroneStore = ExplainerStore; + +export const DEFAULT_VIEW: ViewState = { cutaway: true, flow: false, links: false, labels: true }; +export const DEFAULT_HELM: HelmId = 'straight'; +const START_SPEED = PRESETS.overview.speed ?? SPEED_RANGE.default; + +export const CHAPTER_CONTROL_DEFAULTS: ChapterControls = { + trialKnots: null, + helm: DEFAULT_HELM, + linkMode: 'satellite', + videoDelayMs: VIDEO_DELAY_MS.default, + radarHeight: RADAR_HEIGHT_M.default, + seaState: DEFAULT_SEA_STATE, +}; + +function stepTo(value: number, range: { min: number; max: number; step: number }): number { + return Math.round(clamp(value, range.min, range.max) / range.step) * range.step; +} + +function presetFields(preset: Preset, state: NavalDroneFields): Partial { + if (PRESETS[state.preset] === preset) return {}; + return { ...CHAPTER_CONTROL_DEFAULTS, ...preset.start }; +} + +type TrialWatch = readonly [phase: number, playing: boolean, preset: PresetId]; + +function shouldRelease(next: TrialWatch, previous: TrialWatch): boolean { + const [phase, playing, preset] = next; + const [lastPhase, wasPlaying, lastPreset] = previous; + return (playing && !wasPlaying) || (phase !== lastPhase && preset === lastPreset); +} + +function releaseTrialOnRun(store: NavalDroneStore): NavalDroneStore { + store.subscribe( + (state): TrialWatch => [state.phase, state.playing, state.preset], + (next, previous) => { + if (shouldRelease(next, previous)) store.getState().releaseTrial(); + }, + { equalityFn: shallow }, + ); + return store; +} + +export function createNavalDroneStore( + overrides: Partial = {}, +): NavalDroneStore { + const store = createExplainerStore( + { + timeline: NAVAL_DRONE_TIMELINE, + presets: PRESETS, + defaults: { preset: 'overview', speed: START_SPEED, view: DEFAULT_VIEW }, + extend: (set, get) => { + const hold = (fields: Pick & Partial) => { + if (get().playing) get().pause(); + set(fields); + }; + return { + ...CHAPTER_CONTROL_DEFAULTS, + fit: DEFAULT_FIT, + setFit: (fit) => set({ fit }), + setTrialKnots: (knots) => hold({ trialKnots: trialKnotsOf(knots) }), + setThrottle: (percent) => hold({ trialKnots: knotsAtThrottle(throttleShareOf(percent)) }), + setSpeedMark: (mark) => hold({ trialKnots: SPEED_MARKS[mark] }), + setHelm: (helm) => { + const { trialKnots, phase } = get(); + hold({ trialKnots: trialKnots ?? trialKnotsOf(speedAt(phase)), helm }); + }, + releaseTrial: () => { + const { trialKnots, helm } = get(); + if (trialKnots === null && helm === DEFAULT_HELM) return; + set({ trialKnots: null, helm: DEFAULT_HELM }); + }, + setLinkMode: (linkMode) => set({ linkMode }), + setVideoDelay: (delayMs) => set({ videoDelayMs: stepTo(delayMs, VIDEO_DELAY_MS) }), + setRadarHeight: (metres) => set({ radarHeight: stepTo(metres, RADAR_HEIGHT_M) }), + setSeaState: (seaState) => set({ seaState }), + seekMoment: (moment) => { + get().pause(); + get().setPhase(MOMENTS[moment]); + }, + }; + }, + presetState: presetFields, + }, + overrides, + ); + return releaseTrialOnRun(store); +} diff --git a/explainers/naval-drone/src/timeline.test.ts b/explainers/naval-drone/src/timeline.test.ts new file mode 100644 index 00000000..9b91d63b --- /dev/null +++ b/explainers/naval-drone/src/timeline.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { PHASE_IDS } from './ids'; +import { PHASE_RANGES, RUN_SECONDS } from './model'; +import { THEME } from './theme'; +import { NAVAL_DRONE_TIMELINE, SPEED_RANGE } from './timeline'; + +describe('naval drone timeline', () => { + it('runs the route once through the five phases and stops at the ship', () => { + const { phases, cycle, loop } = NAVAL_DRONE_TIMELINE; + expect(cycle).toBe(RUN_SECONDS); + expect(loop).toBe(false); + expect(phases.map((phase) => phase.id)).toEqual([...PHASE_IDS]); + phases.forEach((phase) => expect(phase).toMatchObject(PHASE_RANGES[phase.id as never])); + expect(phases[0].start).toBe(0); + expect(phases.at(-1)?.end).toBe(cycle); + phases.slice(1).forEach((phase, index) => expect(phase.start).toBe(phases[index].end)); + }); + + it('colours each phase with its theme tone and names it from the locale', () => { + expect(NAVAL_DRONE_TIMELINE.phases.map((phase) => phase.tone)).toEqual( + PHASE_IDS.map((id) => THEME[id]), + ); + NAVAL_DRONE_TIMELINE.phases.forEach((phase) => { + expect(phase.labelKey).toBe(`timeline.phase.${phase.id}`); + expect(phase.jumpLabelKey).toBe(`timeline.jump.${phase.id}`); + }); + }); + + it('scrubs in tenths of a second and nudges half a second, or five with shift', () => { + expect(NAVAL_DRONE_TIMELINE.step).toBe(0.1); + expect(NAVAL_DRONE_TIMELINE.nudge).toEqual({ fine: 0.5, coarse: 5 }); + expect(NAVAL_DRONE_TIMELINE.labelKey).toBe('timeline.label'); + expect(NAVAL_DRONE_TIMELINE.phasesLabelKey).toBe('timeline.phases'); + }); + + it('plays the run in two minutes at normal speed, from a quarter to four times as fast', () => { + const { rate, speed } = NAVAL_DRONE_TIMELINE; + expect(speed).toMatchObject({ min: 0.25, max: 4, step: 0.25, labelKey: 'timeline.speed' }); + expect(RUN_SECONDS / rate(1)).toBe(120); + expect(rate(2)).toBe(2); + expect(SPEED_RANGE.default).toBe(1); + }); +}); diff --git a/explainers/naval-drone/src/timeline.ts b/explainers/naval-drone/src/timeline.ts new file mode 100644 index 00000000..a2479220 --- /dev/null +++ b/explainers/naval-drone/src/timeline.ts @@ -0,0 +1,48 @@ +import type { Phase, Timeline } from '@core/explainer'; +import { PHASE_IDS } from './ids'; +import { PHASE_RANGES, RUN_SECONDS, SPEED_RANGE, rate } from './model'; +import { + JUMP_KEYS, + PHASE_KEYS, + describePhase, + describeSpeed, + formatPhase, + formatSpeed, +} from './ui/format'; +import { PHASE_TONES } from './ui/palette'; + +const SCRUBBER_STEP_SECONDS = 0.1; +const FINE_STEP_SECONDS = 0.5; +const COARSE_STEP_SECONDS = 5; + +const RUN_PHASES: readonly Phase[] = PHASE_IDS.map((id) => ({ + id, + start: PHASE_RANGES[id].start, + end: PHASE_RANGES[id].end, + labelKey: PHASE_KEYS[id], + jumpLabelKey: JUMP_KEYS[id], + tone: PHASE_TONES[id], +})); + +export const NAVAL_DRONE_TIMELINE: Timeline = { + cycle: RUN_SECONDS, + loop: false, + step: SCRUBBER_STEP_SECONDS, + nudge: { fine: FINE_STEP_SECONDS, coarse: COARSE_STEP_SECONDS }, + labelKey: 'timeline.label', + phasesLabelKey: 'timeline.phases', + formatPhase, + describePhase, + rate, + phases: RUN_PHASES, + speed: { + min: SPEED_RANGE.min, + max: SPEED_RANGE.max, + step: SPEED_RANGE.step, + labelKey: 'timeline.speed', + format: formatSpeed, + describe: describeSpeed, + }, +}; + +export { SPEED_RANGE }; diff --git a/explainers/naval-drone/src/ui/format.dom.test.ts b/explainers/naval-drone/src/ui/format.dom.test.ts new file mode 100644 index 00000000..26f440df --- /dev/null +++ b/explainers/naval-drone/src/ui/format.dom.test.ts @@ -0,0 +1,153 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { toRadians } from '@core/math'; +import { initI18n } from '@core/i18n'; +import en from '../../locales/en.json'; +import { SEA_STATE_IDS } from '../ids'; +import { + clockText, + describePhase, + describeSpeed, + formatBoatOrBacking, + formatBoatSpeed, + formatCameraHorizon, + formatCanvasKm, + formatCarries, + formatCost, + formatDegrees, + formatDelay, + formatDetection, + formatDistance, + formatEfficiency, + formatFlow, + formatHidden, + formatHullRatio, + formatJetSpeed, + formatKm, + formatLift, + formatLinkBoat, + formatLinkCarrier, + formatLinkNow, + formatLinkTop, + formatMetres, + formatMinutes, + formatMode, + formatPercent, + formatPhase, + formatPush, + formatShipValue, + formatSpeed, + formatThrust, + formatTrialSpeed, + formatWaves, + formatWetted, +} from './format'; +import { fill, isFilled } from './testing'; + +const { units, timeline, hull, jet, link, horizon, fleet } = en; + +describe('naval drone formatting', () => { + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + it('shows the run clock as minutes and seconds of model time', () => { + expect(clockText(0)).toBe('0:00'); + expect(clockText(65.9)).toBe('1:05'); + expect(clockText(120)).toBe('2:00'); + expect(formatPhase(65)).toBe(fill(timeline.clock, { clock: '1:05' })); + }); + + it('describes the scrubber with what happens in each phase', () => { + expect(describePhase(100)).toBe( + fill(timeline.value, { time: formatPhase(100), phase: timeline.during.sprint }), + ); + expect(describePhase(120)).toContain(timeline.during.arrival); + }); + + it('names the playback factor', () => { + expect(formatSpeed(0.25)).toBe(fill(timeline.speedFormat, { factor: '0.25' })); + expect(describeSpeed()).toBe(timeline.speedValue); + }); + + it('shows knots in tenths below 10 and whole above, with km/h', () => { + expect(formatBoatSpeed(5.7)).toBe(fill(units.speed, { kn: '5.7', kmh: '11' })); + expect(formatBoatSpeed(22)).toBe(fill(units.speed, { kn: '22', kmh: '41' })); + expect(formatBoatSpeed(42)).toBe(fill(units.speed, { kn: '42', kmh: '78' })); + expect(formatTrialSpeed(11)).toBe(fill(units.speed, { kn: '11.0', kmh: '20' })); + }); + + it('names the hull mode', () => { + expect(formatMode('planing')).toBe(en.mode.planing); + }); + + it('shows the distance to the ship in tens of metres, in km from 1,000 m', () => { + expect(formatDistance(1458.8)).toBe(fill(units.km, { value: '1.5' })); + expect(formatDistance(518.4)).toBe(fill(units.m, { value: '520' })); + expect(formatDistance(997)).toBe(fill(units.km, { value: '1.0' })); + expect(formatDistance(0)).toBe(fill(units.m, { value: '0' })); + }); + + it('shows percents, trim and the lift split in whole numbers that add up', () => { + expect(formatPercent(0.694)).toBe(fill(units.percent, { value: '69' })); + expect(formatDegrees(toRadians(6))).toBe(fill(units.degrees, { value: '6.0' })); + expect(formatLift(0.364)).toBe(fill(hull.lift, { dynamic: '36', buoyant: '64' })); + }); + + it('compares the wetted length with the length at rest', () => { + expect(formatWetted(1.7)).toBe(fill(hull.wetted, { m: '1.7', rest: '5.1' })); + }); + + it('compares the speed with the 5.7 kn hull speed, or says the boat stands still', () => { + expect(formatHullRatio(11)).toBe(fill(hull.ratio, { times: '1.9', hullSpeed: '5.7' })); + expect(formatHullRatio(42)).toBe(fill(hull.ratio, { times: '7.4', hullSpeed: '5.7' })); + expect(formatHullRatio(0)).toBe(fill(hull.ratioStill, { hullSpeed: '5.7' })); + }); + + it('reads the pump in kg and litres a second, m/s and kN, or says it backs', () => { + expect(formatFlow(212.7)).toBe(fill(jet.flow, { kg: '213', litres: '208' })); + expect(formatJetSpeed(32.4)).toBe(fill(units.mps, { ms: '32', kmh: '117' })); + expect(formatThrust(2300, 'straight')).toBe(fill(units.kilonewtons, { value: '2.30' })); + expect(formatThrust(2300, 'reverse')).toBe(jet.astern); + expect(formatBoatOrBacking(0, 'reverse')).toBe(jet.backing); + expect(formatBoatOrBacking(22, 'left')).toBe(formatBoatSpeed(22)); + expect(formatEfficiency(0.8, 42)).toBe(fill(units.percent, { value: '80' })); + expect(formatEfficiency(0, 0)).toBe(jet.noEfficiency); + }); + + it('says where the nozzle pushes the stern', () => { + expect(formatPush('left', -toRadians(27))).toBe(fill(jet.push.left, { angle: '27' })); + expect(formatPush('right', toRadians(27))).toBe(fill(jet.push.right, { angle: '27' })); + expect(formatPush('straight', -toRadians(8))).toBe(fill(jet.push.left, { angle: '8' })); + expect(formatPush('straight', 0)).toBe(jet.push.straight); + expect(formatPush('reverse', 0)).toBe(jet.push.reverse); + }); + + it('turns the video delay into metres now and at top speed', () => { + expect(formatDelay(250)).toBe(fill(units.ms, { value: '250' })); + expect(formatLinkNow('satellite', 250, 22)).toBe(fill(link.now, { m: '2.8', kn: '22' })); + expect(formatLinkNow('lost', 250, 22)).toBe(link.nowLost); + expect(formatLinkTop(250)).toBe(fill(link.top, { m: '5.4', lengths: '1.0' })); + expect(formatLinkCarrier('backup')).toBe(link.carrier.backup); + expect(formatLinkBoat('lost')).toBe(link.boat.lost); + }); + + it('reads the horizons, the detection range and the waves', () => { + expect(formatMetres(20)).toBe(fill(units.m, { value: '20' })); + expect(formatKm(21.34)).toBe(fill(units.km, { value: '21.3' })); + expect(formatMinutes(16.46)).toBe(fill(units.minutes, { value: '16.5' })); + expect(formatCameraHorizon()).toBe(fill(horizon.camera, { km: '3.2', m: '0.7' })); + expect(formatDetection('moderate')).toBe(fill(horizon.detect, { km: '9.3', min: '7.1' })); + expect(formatDetection('rough')).toBe(horizon.detectBeyond); + expect(formatWaves('slight')).toBe(fill(horizon.waves.slight, { from: '0.5', to: '1.25' })); + expect(formatCanvasKm(30)).toBe(fill(horizon.canvas.km, { value: '30' })); + SEA_STATE_IDS.forEach((sea) => { + expect(isFilled(formatWaves(sea)), sea).toBe(true); + expect(formatHidden(sea)).toBe(horizon.hidden[sea]); + }); + }); + + it('quotes the payload, the cost and the ship value', () => { + expect(formatCarries('standard')).toBe(fill(fleet.carries.standard, { kg: '320' })); + expect(formatCarries('missile')).toBe(fleet.carries.missile); + expect(formatCost()).toBe(fill(fleet.cost, { from: '250,000', to: '273,000' })); + expect(formatShipValue()).toBe(fill(fleet.ship, { value: '65' })); + }); +}); diff --git a/explainers/naval-drone/src/ui/format.ts b/explainers/naval-drone/src/ui/format.ts new file mode 100644 index 00000000..af16a450 --- /dev/null +++ b/explainers/naval-drone/src/ui/format.ts @@ -0,0 +1,240 @@ +import { formatFixed, formatNumber } from '@core/format'; +import { t } from '@core/i18n'; +import { toDegrees } from '@core/math'; +import { PHASE_IDS } from '../ids'; +import type { FitId, HelmId, HullMode, LinkMode, PhaseId, SeaStateId } from '../ids'; +import { + BOAT, + BOAT_COST_USD, + DETECTION_KM, + HULL_SPEED_KN, + KOTOV_VALUE_MUSD, + LENS_HEIGHT_M, + NEWTONS_PER_KILONEWTON, + PAYLOAD_MAX_KG, + SEAWATER_DENSITY, + SEA_STATES, + boatLengths, + detectionCovered, + knotsToKmh, + lagMetres, + minutesAtTopSpeed, + msToKmh, + phaseAt, + visualHorizonKm, +} from '../model'; + +const WHOLE = 0; +const TENTHS = 1; +const HUNDREDTHS = 2; +const PERCENT = 100; +const SECONDS_PER_MINUTE = 60; +const CLOCK_DIGITS = 2; +const CLOCK_PAD = '0'; +const CLOCK_SEPARATOR = ':'; +const WHOLE_KNOTS_FROM = 10; +const METRES_STEP = 10; +const METRES_PER_KM = 1000; +const KG_PER_LITRE = SEAWATER_DENSITY / METRES_PER_KM; +const STRAIGHT_NOZZLE = 0.5; + +function phaseKeys(group: string): Readonly> { + return Object.fromEntries(PHASE_IDS.map((id) => [id, `timeline.${group}.${id}`])) as Record< + PhaseId, + string + >; +} + +export const PHASE_KEYS = phaseKeys('phase'); +export const JUMP_KEYS = phaseKeys('jump'); +const DURING_KEYS = phaseKeys('during'); + +function whole(value: number): string { + return formatFixed(value, WHOLE); +} + +function tenths(value: number): string { + return formatFixed(value, TENTHS); +} + +function percentOf(share: number): number { + return Math.round(share * PERCENT); +} + +export function clockText(seconds: number): string { + const total = Math.floor(seconds); + const minutes = Math.floor(total / SECONDS_PER_MINUTE); + const rest = total % SECONDS_PER_MINUTE; + return [String(minutes), String(rest).padStart(CLOCK_DIGITS, CLOCK_PAD)].join(CLOCK_SEPARATOR); +} + +export function formatPhase(seconds: number): string { + return t('timeline.clock', { clock: clockText(seconds) }); +} + +export function describePhase(seconds: number): string { + return t('timeline.value', { + time: formatPhase(seconds), + phase: t(DURING_KEYS[phaseAt(seconds)]), + }); +} + +export function formatSpeed(speed: number): string { + return t('timeline.speedFormat', { factor: formatNumber(speed) }); +} + +export function describeSpeed(): string { + return t('timeline.speedValue'); +} + +function knotsText(knots: number): string { + return knots < WHOLE_KNOTS_FROM ? tenths(knots) : whole(knots); +} + +export function formatBoatSpeed(knots: number): string { + return t('units.speed', { kn: knotsText(knots), kmh: whole(knotsToKmh(knots)) }); +} + +export function formatTrialSpeed(knots: number): string { + return t('units.speed', { kn: tenths(knots), kmh: whole(knotsToKmh(knots)) }); +} + +export function formatMode(mode: HullMode): string { + return t(`mode.${mode}`); +} + +export function formatDistance(metres: number): string { + const rounded = Math.round(metres / METRES_STEP) * METRES_STEP; + if (rounded < METRES_PER_KM) return t('units.m', { value: whole(rounded) }); + return t('units.km', { value: tenths(metres / METRES_PER_KM) }); +} + +export function formatPercent(share: number): string { + return t('units.percent', { value: whole(percentOf(share)) }); +} + +export function formatDegrees(radians: number): string { + return t('units.degrees', { value: tenths(toDegrees(radians)) }); +} + +export function formatLift(liftShare: number): string { + const dynamic = percentOf(liftShare); + return t('hull.lift', { dynamic: whole(dynamic), buoyant: whole(PERCENT - dynamic) }); +} + +export function formatWetted(metres: number): string { + return t('hull.wetted', { m: tenths(metres), rest: tenths(BOAT.waterlineLength) }); +} + +export function formatHullRatio(knots: number): string { + const hullSpeed = tenths(HULL_SPEED_KN); + if (knots <= 0) return t('hull.ratioStill', { hullSpeed }); + return t('hull.ratio', { times: tenths(knots / HULL_SPEED_KN), hullSpeed }); +} + +export function formatBoatOrBacking(knots: number, helm: HelmId): string { + return helm === 'reverse' ? t('jet.backing') : formatBoatSpeed(knots); +} + +export function formatFlow(kgPerSecond: number): string { + return t('jet.flow', { kg: whole(kgPerSecond), litres: whole(kgPerSecond / KG_PER_LITRE) }); +} + +export function formatJetSpeed(metresPerSecond: number): string { + return t('units.mps', { ms: whole(metresPerSecond), kmh: whole(msToKmh(metresPerSecond)) }); +} + +export function formatThrust(newtons: number, helm: HelmId): string { + if (helm === 'reverse') return t('jet.astern'); + return t('units.kilonewtons', { + value: formatFixed(newtons / NEWTONS_PER_KILONEWTON, HUNDREDTHS), + }); +} + +export function formatEfficiency(efficiency: number, knots: number): string { + return knots <= 0 ? t('jet.noEfficiency') : formatPercent(efficiency); +} + +export function formatPush(helm: HelmId, nozzleAngle: number): string { + if (helm === 'reverse') return t('jet.push.reverse'); + const degrees = toDegrees(nozzleAngle); + if (Math.abs(degrees) < STRAIGHT_NOZZLE) return t('jet.push.straight'); + const side = degrees < 0 ? 'left' : 'right'; + return t(`jet.push.${side}`, { angle: whole(Math.abs(degrees)) }); +} + +export function formatDelay(delayMs: number): string { + return t('units.ms', { value: whole(delayMs) }); +} + +export function formatLinkNow(mode: LinkMode, delayMs: number, knots: number): string { + if (mode === 'lost') return t('link.nowLost'); + return t('link.now', { m: tenths(lagMetres(delayMs, knots)), kn: knotsText(knots) }); +} + +export function formatLinkTop(delayMs: number): string { + const metres = lagMetres(delayMs, BOAT.topKnots); + return t('link.top', { m: tenths(metres), lengths: tenths(boatLengths(metres)) }); +} + +export function formatLinkCarrier(mode: LinkMode): string { + return t(`link.carrier.${mode}`); +} + +export function formatLinkBoat(mode: LinkMode): string { + return t(`link.boat.${mode}`); +} + +export function formatMetres(metres: number): string { + return t('units.m', { value: whole(metres) }); +} + +export function formatKm(km: number): string { + return t('units.km', { value: tenths(km) }); +} + +export function formatMinutes(minutes: number): string { + return t('units.minutes', { value: tenths(minutes) }); +} + +export function formatCameraHorizon(): string { + return t('horizon.camera', { + km: tenths(visualHorizonKm(LENS_HEIGHT_M)), + m: tenths(LENS_HEIGHT_M), + }); +} + +export function formatDetection(sea: SeaStateId): string { + if (!detectionCovered(sea)) return t('horizon.detectBeyond'); + return t('horizon.detect', { + km: tenths(DETECTION_KM), + min: tenths(minutesAtTopSpeed(DETECTION_KM)), + }); +} + +export function formatWaves(sea: SeaStateId): string { + const { from, to } = SEA_STATES[sea]; + return t(`horizon.waves.${sea}`, { from: formatNumber(from), to: formatNumber(to) }); +} + +export function formatHidden(sea: SeaStateId): string { + return t(`horizon.hidden.${sea}`); +} + +export function formatCanvasKm(km: number): string { + return t('horizon.canvas.km', { value: whole(km) }); +} + +export function formatCarries(fit: FitId): string { + if (fit === 'missile') return t('fleet.carries.missile'); + return t('fleet.carries.standard', { kg: whole(PAYLOAD_MAX_KG) }); +} + +export function formatCost(): string { + const [from, to] = BOAT_COST_USD; + return t('fleet.cost', { from: whole(from), to: whole(to) }); +} + +export function formatShipValue(): string { + return t('fleet.ship', { value: whole(KOTOV_VALUE_MUSD) }); +} diff --git a/explainers/naval-drone/src/ui/palette.ts b/explainers/naval-drone/src/ui/palette.ts new file mode 100644 index 00000000..7d5a2ddd --- /dev/null +++ b/explainers/naval-drone/src/ui/palette.ts @@ -0,0 +1,16 @@ +import type { HullMode, PhaseId } from '../ids'; +import { THEME } from '../theme'; + +export const PHASE_TONES: Readonly> = { + launch: THEME.launch, + hump: THEME.hump, + cruise: THEME.cruise, + sprint: THEME.sprint, + arrival: THEME.arrival, +}; + +export const MODE_TONES: Readonly> = { + floating: THEME.floating, + hump: THEME.hump, + planing: THEME.planing, +}; diff --git a/explainers/naval-drone/src/ui/testing.ts b/explainers/naval-drone/src/ui/testing.ts new file mode 100644 index 00000000..b8e1cc66 --- /dev/null +++ b/explainers/naval-drone/src/ui/testing.ts @@ -0,0 +1,9 @@ +export function fill(template: string, values: Readonly>): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => values[name] ?? ''); +} + +const PLACEHOLDER = /\{\{|\}\}/; + +export function isFilled(text: string | null | undefined): boolean { + return Boolean(text) && !PLACEHOLDER.test(text ?? ''); +} From 7a145b85b4262a1ed8fb12169e899f17fcfb0280 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:25:33 +0200 Subject: [PATCH 04/99] feat(naval-drone): add the dock, gauge, chapter widgets and horizon view --- explainers/naval-drone/src/ui/actions.test.ts | 72 +++ explainers/naval-drone/src/ui/actions.ts | 43 ++ explainers/naval-drone/src/ui/dock.ts | 42 ++ .../src/ui/horizonView.dom.test.ts | 162 +++++++ explainers/naval-drone/src/ui/horizonView.ts | 453 ++++++++++++++++++ explainers/naval-drone/src/ui/index.ts | 24 + .../naval-drone/src/ui/mountFleetReadouts.ts | 12 + .../naval-drone/src/ui/mountHorizonControl.ts | 50 ++ .../naval-drone/src/ui/mountHullControl.ts | 37 ++ .../naval-drone/src/ui/mountJetControl.ts | 53 ++ .../naval-drone/src/ui/mountLinkControl.ts | 42 ++ .../src/ui/mountOverviewReadouts.ts | 14 + .../naval-drone/src/ui/mountUi.dom.test.ts | 209 ++++++++ .../naval-drone/src/ui/readouts.test.ts | 37 ++ explainers/naval-drone/src/ui/readouts.ts | 33 ++ 15 files changed, 1283 insertions(+) create mode 100644 explainers/naval-drone/src/ui/actions.test.ts create mode 100644 explainers/naval-drone/src/ui/actions.ts create mode 100644 explainers/naval-drone/src/ui/dock.ts create mode 100644 explainers/naval-drone/src/ui/horizonView.dom.test.ts create mode 100644 explainers/naval-drone/src/ui/horizonView.ts create mode 100644 explainers/naval-drone/src/ui/index.ts create mode 100644 explainers/naval-drone/src/ui/mountFleetReadouts.ts create mode 100644 explainers/naval-drone/src/ui/mountHorizonControl.ts create mode 100644 explainers/naval-drone/src/ui/mountHullControl.ts create mode 100644 explainers/naval-drone/src/ui/mountJetControl.ts create mode 100644 explainers/naval-drone/src/ui/mountLinkControl.ts create mode 100644 explainers/naval-drone/src/ui/mountOverviewReadouts.ts create mode 100644 explainers/naval-drone/src/ui/mountUi.dom.test.ts create mode 100644 explainers/naval-drone/src/ui/readouts.test.ts create mode 100644 explainers/naval-drone/src/ui/readouts.ts diff --git a/explainers/naval-drone/src/ui/actions.test.ts b/explainers/naval-drone/src/ui/actions.test.ts new file mode 100644 index 00000000..35d1df13 --- /dev/null +++ b/explainers/naval-drone/src/ui/actions.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { MOMENT_IDS, SPEED_MARK_IDS } from '../ids'; +import { MOMENTS, SPEED_MARKS } from '../model'; +import { createNavalDroneStore } from '../state'; +import { CHAPTER_ACTIONS, MOMENT_TOLERANCE_SECONDS } from './actions'; + +describe('chapter actions', () => { + it('pauses at each moment and marks it as current', () => { + const store = createNavalDroneStore({ playing: true }); + MOMENT_IDS.forEach((moment) => { + store.getState().play(); + CHAPTER_ACTIONS.moment.run(store.getState(), moment); + expect(store.getState().playing).toBe(false); + expect(store.getState().phase).toBeCloseTo(MOMENTS[moment], 9); + expect(CHAPTER_ACTIONS.moment.current?.(store.getState())).toBe(moment); + }); + }); + + it('keeps a moment current within its tolerance and marks none between them', () => { + const near = createNavalDroneStore({ phase: MOMENTS.topSpeed + 0.25 }).getState(); + expect(CHAPTER_ACTIONS.moment.current?.(near)).toBe('topSpeed'); + const between = createNavalDroneStore({ phase: 60 }).getState(); + expect(CHAPTER_ACTIONS.moment.current?.(between)).toBe(''); + expect(() => CHAPTER_ACTIONS.moment.run(between, 'launch')).toThrow(); + }); + + it('never marks two moments at once', () => { + const seconds = MOMENT_IDS.map((moment) => MOMENTS[moment]); + seconds + .slice(1) + .forEach((next, index) => + expect(next - seconds[index], MOMENT_IDS[index + 1]).toBeGreaterThan( + 2 * MOMENT_TOLERANCE_SECONDS, + ), + ); + }); + + it('holds the boat at each speed mark and marks it as current', () => { + const store = createNavalDroneStore({ phase: 60, playing: true }); + SPEED_MARK_IDS.forEach((mark) => { + CHAPTER_ACTIONS.speedMark.run(store.getState(), mark); + expect(store.getState()).toMatchObject({ trialKnots: SPEED_MARKS[mark], playing: false }); + expect(CHAPTER_ACTIONS.speedMark.current?.(store.getState())).toBe(mark); + }); + }); + + it('marks the speed mark the run passes while nobody holds the boat', () => { + const atHump = createNavalDroneStore({ phase: MOMENTS.humpPeak }).getState(); + expect(CHAPTER_ACTIONS.speedMark.current?.(atHump)).toBe('hump'); + const between = createNavalDroneStore({ phase: 31 }).getState(); + expect(CHAPTER_ACTIONS.speedMark.current?.(between)).toBe(''); + }); + + it('switches the helm, the link, the sea and the deck fit', () => { + const store = createNavalDroneStore(); + CHAPTER_ACTIONS.helm.run(store.getState(), 'reverse'); + CHAPTER_ACTIONS.linkMode.run(store.getState(), 'backup'); + CHAPTER_ACTIONS.seaState.run(store.getState(), 'rough'); + CHAPTER_ACTIONS.fit.run(store.getState(), 'missile'); + expect(store.getState()).toMatchObject({ + helm: 'reverse', + linkMode: 'backup', + seaState: 'rough', + fit: 'missile', + }); + expect(CHAPTER_ACTIONS.helm.current?.(store.getState())).toBe('reverse'); + expect(CHAPTER_ACTIONS.linkMode.current?.(store.getState())).toBe('backup'); + expect(CHAPTER_ACTIONS.seaState.current?.(store.getState())).toBe('rough'); + expect(CHAPTER_ACTIONS.fit.current?.(store.getState())).toBe('missile'); + expect(() => CHAPTER_ACTIONS.seaState.run(store.getState(), 'storm')).toThrow(); + }); +}); diff --git a/explainers/naval-drone/src/ui/actions.ts b/explainers/naval-drone/src/ui/actions.ts new file mode 100644 index 00000000..717e54bd --- /dev/null +++ b/explainers/naval-drone/src/ui/actions.ts @@ -0,0 +1,43 @@ +import type { ChapterAction } from '@core/explainer'; +import { parseOption } from '@core/ui/parse'; +import { FIT_IDS, HELM_IDS, LINK_MODES, MOMENT_IDS, SEA_STATE_IDS, SPEED_MARK_IDS } from '../ids'; +import type { MomentId } from '../ids'; +import { MOMENTS, speedMarkAt } from '../model'; +import { followedKnots } from '../state'; +import type { NavalDroneStoreState } from '../state'; + +const NOTHING_CURRENT = ''; +export const MOMENT_TOLERANCE_SECONDS = 0.3; + +function momentAt(state: NavalDroneStoreState): MomentId | undefined { + return MOMENT_IDS.find( + (moment) => Math.abs(MOMENTS[moment] - state.phase) <= MOMENT_TOLERANCE_SECONDS, + ); +} + +export const CHAPTER_ACTIONS: Record> = { + moment: { + run: (state, value) => state.seekMoment(parseOption(value, MOMENT_IDS)), + current: (state) => momentAt(state) ?? NOTHING_CURRENT, + }, + speedMark: { + run: (state, value) => state.setSpeedMark(parseOption(value, SPEED_MARK_IDS)), + current: (state) => speedMarkAt(followedKnots(state)) ?? NOTHING_CURRENT, + }, + helm: { + run: (state, value) => state.setHelm(parseOption(value, HELM_IDS)), + current: (state) => state.helm, + }, + linkMode: { + run: (state, value) => state.setLinkMode(parseOption(value, LINK_MODES)), + current: (state) => state.linkMode, + }, + seaState: { + run: (state, value) => state.setSeaState(parseOption(value, SEA_STATE_IDS)), + current: (state) => state.seaState, + }, + fit: { + run: (state, value) => state.setFit(parseOption(value, FIT_IDS)), + current: (state) => state.fit, + }, +}; diff --git a/explainers/naval-drone/src/ui/dock.ts b/explainers/naval-drone/src/ui/dock.ts new file mode 100644 index 00000000..f31a2754 --- /dev/null +++ b/explainers/naval-drone/src/ui/dock.ts @@ -0,0 +1,42 @@ +import type { Choice, ViewToggle } from '@core/explainer'; +import { parseOption } from '@core/ui/parse'; +import { FIT_IDS } from '../ids'; +import type { NavalDroneStoreState } from '../state'; + +const CUTAWAY_ICON = + ''; +const FLOW_ICON = + ''; +const LINKS_ICON = + ''; +const LABELS_ICON = + ''; + +export const NAVAL_DRONE_CHOICES: readonly Choice[] = [ + { + id: 'fit', + labelKey: 'controls.fit.label', + shortcut: 'F', + options: FIT_IDS.map((fit) => ({ value: fit, labelKey: `controls.fitOptions.${fit}` })), + select: (state) => state.fit, + apply: (state, value) => state.setFit(parseOption(value, FIT_IDS)), + }, +]; + +function viewToggle(view: string, shortcut: string, icon: string): ViewToggle { + return { + view, + shortcut, + nameKey: `controls.views.${view}.name`, + shortKey: `controls.views.${view}.short`, + hintKey: `controls.views.${view}.hint`, + icon, + }; +} + +export const VIEW_TOGGLES: readonly ViewToggle[] = [ + viewToggle('cutaway', 'C', CUTAWAY_ICON), + viewToggle('flow', 'W', FLOW_ICON), + viewToggle('links', 'K', LINKS_ICON), + viewToggle('labels', 'L', LABELS_ICON), +]; diff --git a/explainers/naval-drone/src/ui/horizonView.dom.test.ts b/explainers/naval-drone/src/ui/horizonView.dom.test.ts new file mode 100644 index 00000000..5205ce9d --- /dev/null +++ b/explainers/naval-drone/src/ui/horizonView.dom.test.ts @@ -0,0 +1,162 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { initI18n } from '@core/i18n'; +import type { CanvasFrame } from '@core/ui/canvasSurface'; +import en from '../../locales/en.json'; +import { RADAR_HEIGHT_M, radarLineOfSightKm, surfaceDropM } from '../model'; +import { + AXIS_KM, + TICKS_KM, + horizonLayout, + intersects, + paintHorizon, + placeLabel, + sightHeightM, + surfaceY, + xOfKm, + yOfMetres, +} from './horizonView'; +import type { Box, HorizonScene } from './horizonView'; +import { fill } from './testing'; + +const PHONE: CanvasFrame = { width: 358, height: 168, ratio: 1, fontFamily: 'sans-serif' }; +const DESKTOP: CanvasFrame = { width: 640, height: 300, ratio: 1, fontFamily: 'sans-serif' }; +const CHAR_WIDTH = 6; +const { canvas } = en.horizon; + +type Align = 'left' | 'center' | 'right'; + +interface Drawn { + text: string; + x: number; + y: number; + align: Align; +} + +function recordingContext() { + const texts: Drawn[] = []; + const noop = () => {}; + const context = { + font: '', + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + textAlign: 'left' as Align, + textBaseline: 'alphabetic', + measureText: (text: string) => ({ width: text.length * CHAR_WIDTH }), + fillText: (text: string, x: number, y: number) => + texts.push({ text, x, y, align: context.textAlign }), + beginPath: noop, + moveTo: noop, + lineTo: noop, + closePath: noop, + fill: noop, + stroke: noop, + arc: noop, + setLineDash: noop, + }; + return { texts, context: context as unknown as CanvasRenderingContext2D }; +} + +function paint(scene: HorizonScene, frame = PHONE): Drawn[] { + const { texts, context } = recordingContext(); + paintHorizon(context, frame, scene); + return texts; +} + +describe('horizon view layout', () => { + it('fits the 35 km axis, the tallest mast and the sea drop inside the canvas', () => { + [PHONE, DESKTOP].forEach(({ width, height }) => { + const layout = horizonLayout(width, height); + expect(xOfKm(layout, 0)).toBeGreaterThan(0); + expect(xOfKm(layout, AXIS_KM)).toBeLessThanOrEqual(width); + expect(yOfMetres(layout, RADAR_HEIGHT_M.max)).toBeGreaterThan(layout.plot.top); + expect(surfaceY(layout, AXIS_KM)).toBeLessThan(layout.plot.bottom); + expect(layout.tickBaseline).toBeLessThan(height); + expect(layout.font).toBeGreaterThanOrEqual(11); + }); + }); + + it('grazes the sea with the line of sight and reaches the top of the boat', () => { + [5, 20, 50].forEach((radarHeight) => { + const tangentKm = radarLineOfSightKm(radarHeight) - radarLineOfSightKm(0); + expect(sightHeightM(radarHeight, tangentKm)).toBeCloseTo(-surfaceDropM(tangentKm), 9); + const boatKm = radarLineOfSightKm(radarHeight); + expect(sightHeightM(radarHeight, boatKm)).toBeCloseTo(-surfaceDropM(boatKm) + 0.5, 9); + }); + }); +}); + +describe('horizon view labels', () => { + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + it('takes the first free spot, inside the canvas', () => { + const { context } = recordingContext(); + const layout = horizonLayout(PHONE.width, PHONE.height); + const taken: Box[] = [{ left: 0, top: 40, right: 100, bottom: 60 }]; + const spot = placeLabel( + context, + 'Label', + [ + { x: 50, baseline: 55, align: 'center' }, + { x: 50, baseline: 90, align: 'center' }, + ], + taken, + layout, + ); + expect(spot.baseline).toBe(90); + expect(taken).toHaveLength(2); + const edge = placeLabel( + context, + 'Label', + [{ x: 0, baseline: 120, align: 'center' }], + [], + layout, + ); + expect(edge.box.left).toBeGreaterThanOrEqual(layout.bounds.left); + }); + + it('draws every text from the locale, ticks at 0, 10, 20 and 30 km', () => { + const texts = paint({ radarHeight: 20, seaState: 'slight' }).map((drawn) => drawn.text); + expect(texts).toEqual( + expect.arrayContaining([ + canvas.radar, + canvas.boat, + canvas.lineOfSight, + canvas.detection, + canvas.note, + ...TICKS_KM.map((km) => fill(canvas.km, { value: String(km) })), + ]), + ); + }); + + it('marks the expected detection only up to a moderate sea', () => { + const rough = paint({ radarHeight: 20, seaState: 'rough' }).map((drawn) => drawn.text); + expect(rough).not.toContain(canvas.detection); + expect(rough).toContain(canvas.boat); + }); + + it('keeps the labels apart at every radar height on a phone and a desktop', () => { + [PHONE, DESKTOP].forEach((frame) => + [5, 20, 35, 50].forEach((radarHeight) => { + const { context, texts } = recordingContext(); + paintHorizon(context, frame, { radarHeight, seaState: 'smooth' }); + const layout = horizonLayout(frame.width, frame.height); + const boxes = texts + .filter((drawn) => !drawn.text.endsWith(' km')) + .map((drawn) => labelBox(drawn, layout.font)); + boxes.forEach((box, index) => + boxes + .slice(index + 1) + .forEach((other) => expect(intersects(box, other), `${radarHeight} m`).toBe(false)), + ); + }), + ); + }); +}); + +function labelBox(drawn: Drawn, font: number): Box { + const width = drawn.text.length * CHAR_WIDTH; + const offset = { left: 0, center: width / 2, right: width }[drawn.align]; + const left = drawn.x - offset; + return { left, right: left + width, top: drawn.y - font * 0.78, bottom: drawn.y }; +} diff --git a/explainers/naval-drone/src/ui/horizonView.ts b/explainers/naval-drone/src/ui/horizonView.ts new file mode 100644 index 00000000..df6031c3 --- /dev/null +++ b/explainers/naval-drone/src/ui/horizonView.ts @@ -0,0 +1,453 @@ +import { withAlpha } from '@core/color'; +import { currentLanguage, t } from '@core/i18n'; +import { FULL_TURN, clamp } from '@core/math'; +import { CanvasSurface, canvasFont } from '@core/ui/canvasSurface'; +import type { CanvasFrame } from '@core/ui/canvasSurface'; +import type { SeaStateId } from '../ids'; +import { + DETECTION_KM, + RADAR_HEIGHT_M, + RADAR_K, + detectionCovered, + radarLineOfSightKm, + seaAt, + surfaceDropM, +} from '../model'; +import { THEME } from '../theme'; +import { formatCanvasKm } from './format'; + +export interface HorizonScene { + radarHeight: number; + seaState: SeaStateId; +} + +export interface Box { + left: number; + top: number; + right: number; + bottom: number; +} + +export interface HorizonLayout { + font: number; + bounds: Box; + plot: Box; + pxPerKm: number; + pxPerMetre: number; + tickBaseline: number; +} + +interface Anchor { + x: number; + baseline: number; + align: 'left' | 'right' | 'center'; +} + +interface Placed extends Anchor { + box: Box; +} + +export const AXIS_KM = 35; +export const TICKS_KM = [0, 10, 20, 30] as const; + +const MAST_HEADROOM_M = 6; +const SEA_MARGIN_M = 4; +const SKY_TOP_M = RADAR_HEIGHT_M.max + MAST_HEADROOM_M; +const SEA_FLOOR_M = -surfaceDropM(AXIS_KM) - SEA_MARGIN_M; +const FONT = { min: 11, max: 13, perWidth: 0.02 } as const; +const SPACE = { pad: 6, gap: 4, shipGutter: 18, tick: 3 } as const; +const TEXT = { ascent: 0.78, descent: 0.24 } as const; +const SHIP = { aft: 14, bow: 10, deckM: 4, minHullPx: 5, antenna: 5 } as const; +const BOAT_ICON = { length: 10, height: 4 } as const; +const WAVE = { pxPerMetre: 1.5, periodPx: 9, stepPx: 1.5 } as const; +const LINE = { surface: 1.5, sight: 1.8, mast: 1.5, tick: 1, dash: [4, 4] } as const; +const SEA_FILL_ALPHA = 0.32; +const LOS_START_SHARES = [0, 0.18, 0.36, 0.5, 0.62, 0.74] as const; +const DETECTION_LINES = [0, 1, 2] as const; +const NOTE_ROWS = [0, 1, 2] as const; + +const COLORS = { + sea: withAlpha(THEME.waterWall, SEA_FILL_ALPHA), + surface: THEME.waterWall, + ship: THEME.ship, + antenna: THEME.text, + sight: THEME.sprint, + detection: THEME.hump, + boat: THEME.panel, + muted: THEME.muted, +} as const; + +export function horizonLayout(width: number, height: number): HorizonLayout { + const font = clamp(Math.round(width * FONT.perWidth), FONT.min, FONT.max); + const bounds = { left: SPACE.pad, top: SPACE.pad, right: width - SPACE.pad, bottom: height }; + const tickBaseline = height - SPACE.pad - font * TEXT.descent; + const plot = { + left: SPACE.pad + SPACE.shipGutter, + top: SPACE.pad + font + SPACE.gap, + right: width - SPACE.pad, + bottom: tickBaseline - font * TEXT.ascent - SPACE.gap - SPACE.tick, + }; + return { + font, + bounds, + plot, + pxPerKm: (plot.right - plot.left) / AXIS_KM, + pxPerMetre: (plot.bottom - plot.top) / (SKY_TOP_M - SEA_FLOOR_M), + tickBaseline, + }; +} + +export function xOfKm(layout: HorizonLayout, km: number): number { + return layout.plot.left + km * layout.pxPerKm; +} + +export function yOfMetres(layout: HorizonLayout, metres: number): number { + return layout.plot.top + (SKY_TOP_M - metres) * layout.pxPerMetre; +} + +export function surfaceY(layout: HorizonLayout, km: number): number { + return yOfMetres(layout, -surfaceDropM(km)); +} + +export function sightHeightM(radarHeight: number, km: number): number { + return radarHeight - ((2 * Math.sqrt(radarHeight)) / RADAR_K) * km; +} + +function kmOfX(layout: HorizonLayout, x: number): number { + return (x - layout.plot.left) / layout.pxPerKm; +} + +function sightY(layout: HorizonLayout, radarHeight: number, x: number): number { + return yOfMetres(layout, sightHeightM(radarHeight, kmOfX(layout, x))); +} + +export function intersects(a: Box, b: Box): boolean { + return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom; +} + +function isInside(box: Box, bounds: Box): boolean { + return box.top >= bounds.top && box.bottom <= bounds.bottom; +} + +function textBox( + context: CanvasRenderingContext2D, + text: string, + anchor: Anchor, + font: number, +): Box { + const width = context.measureText(text).width; + const offset = { left: 0, center: width / 2, right: width }[anchor.align]; + const left = anchor.x - offset; + return { + left, + right: left + width, + top: anchor.baseline - font * TEXT.ascent, + bottom: anchor.baseline + font * TEXT.descent, + }; +} + +function shiftInto(box: Box, anchor: Anchor, bounds: Box): Placed { + const shift = clamp(0, bounds.left - box.left, bounds.right - box.right); + return { + ...anchor, + x: anchor.x + shift, + box: { ...box, left: box.left + shift, right: box.right + shift }, + }; +} + +export function placeLabel( + context: CanvasRenderingContext2D, + text: string, + candidates: readonly Anchor[], + taken: Box[], + layout: HorizonLayout, +): Placed { + const placed = candidates.map((anchor) => + shiftInto(textBox(context, text, anchor, layout.font), anchor, layout.bounds), + ); + const free = placed.find( + (spot) => isInside(spot.box, layout.bounds) && !taken.some((box) => intersects(box, spot.box)), + ); + const chosen = free ?? placed[0]; + taken.push(chosen.box); + return chosen; +} + +function drawText( + context: CanvasRenderingContext2D, + text: string, + placed: Placed, + color: string, +): void { + context.fillStyle = color; + context.textAlign = placed.align; + context.fillText(text, placed.x, placed.baseline); +} + +function waveOffset(x: number, amplitude: number): number { + return -amplitude * Math.abs(Math.sin((Math.PI * x) / WAVE.periodPx)); +} + +function traceSurface(context: CanvasRenderingContext2D, layout: HorizonLayout, amplitude: number) { + const from = layout.bounds.left - SPACE.pad; + const to = layout.plot.right + SPACE.pad; + context.beginPath(); + for (let x = from; x <= to; x += WAVE.stepPx) { + const y = surfaceY(layout, kmOfX(layout, x)) + waveOffset(x, amplitude); + if (x === from) context.moveTo(x, y); + else context.lineTo(x, y); + } +} + +function paintSea(context: CanvasRenderingContext2D, layout: HorizonLayout, scene: HorizonScene) { + const amplitude = seaAt(scene.seaState).waveHeight * WAVE.pxPerMetre; + traceSurface(context, layout, amplitude); + context.lineTo(layout.plot.right + SPACE.pad, layout.plot.bottom); + context.lineTo(layout.bounds.left - SPACE.pad, layout.plot.bottom); + context.closePath(); + context.fillStyle = COLORS.sea; + context.fill(); + traceSurface(context, layout, amplitude); + context.strokeStyle = COLORS.surface; + context.lineWidth = LINE.surface; + context.stroke(); +} + +function paintTicks(context: CanvasRenderingContext2D, layout: HorizonLayout): void { + context.strokeStyle = COLORS.muted; + context.fillStyle = COLORS.muted; + context.lineWidth = LINE.tick; + TICKS_KM.forEach((km) => { + const x = xOfKm(layout, km); + context.beginPath(); + context.moveTo(x, layout.plot.bottom); + context.lineTo(x, layout.plot.bottom + SPACE.tick); + context.stroke(); + const text = formatCanvasKm(km); + const anchor: Anchor = { x, baseline: layout.tickBaseline, align: 'center' }; + const placed = shiftInto(textBox(context, text, anchor, layout.font), anchor, layout.bounds); + drawText(context, text, placed, COLORS.muted); + }); +} + +function shipBox(layout: HorizonLayout, radarHeight: number): Box { + const mastX = xOfKm(layout, 0); + return { + left: mastX - SHIP.aft, + right: mastX + SHIP.bow, + top: yOfMetres(layout, radarHeight) - SHIP.antenna, + bottom: yOfMetres(layout, 0), + }; +} + +function paintShip(context: CanvasRenderingContext2D, layout: HorizonLayout, radarHeight: number) { + const mastX = xOfKm(layout, 0); + const water = yOfMetres(layout, 0); + const deck = Math.min(yOfMetres(layout, SHIP.deckM), water - SHIP.minHullPx); + context.fillStyle = COLORS.ship; + context.beginPath(); + context.moveTo(mastX - SHIP.aft, deck); + context.lineTo(mastX + SHIP.bow, deck); + context.lineTo(mastX + SHIP.bow - SHIP.minHullPx, water); + context.lineTo(mastX - SHIP.aft, water); + context.closePath(); + context.fill(); + const antenna = yOfMetres(layout, radarHeight); + context.strokeStyle = COLORS.ship; + context.lineWidth = LINE.mast; + context.beginPath(); + context.moveTo(mastX, deck); + context.lineTo(mastX, antenna); + context.stroke(); + context.strokeStyle = COLORS.antenna; + context.beginPath(); + context.moveTo(mastX - SHIP.antenna, antenna); + context.lineTo(mastX + SHIP.antenna, antenna); + context.stroke(); +} + +function boatBox(layout: HorizonLayout, km: number): Box { + const x = xOfKm(layout, km); + const water = surfaceY(layout, km); + const half = BOAT_ICON.length / 2; + return { left: x - half, right: x + half, top: water - BOAT_ICON.height, bottom: water }; +} + +function paintBoat(context: CanvasRenderingContext2D, box: Box): void { + context.fillStyle = COLORS.boat; + context.beginPath(); + context.moveTo(box.left, box.top); + context.lineTo(box.right, box.top + BOAT_ICON.height / 2); + context.lineTo(box.right - BOAT_ICON.height, box.bottom); + context.lineTo(box.left, box.bottom); + context.closePath(); + context.fill(); +} + +function detectionBox(layout: HorizonLayout): Box { + const x = xOfKm(layout, DETECTION_KM); + return { + left: x - LINE.tick, + right: x + LINE.tick, + top: layout.plot.top, + bottom: layout.plot.bottom, + }; +} + +function paintDetection(context: CanvasRenderingContext2D, box: Box): void { + const x = (box.left + box.right) / 2; + context.strokeStyle = COLORS.detection; + context.lineWidth = LINE.tick; + context.setLineDash(LINE.dash); + context.beginPath(); + context.moveTo(x, box.top); + context.lineTo(x, box.bottom); + context.stroke(); + context.setLineDash([]); +} + +function paintSight( + context: CanvasRenderingContext2D, + layout: HorizonLayout, + radarHeight: number, + boatKm: number, +): void { + context.strokeStyle = COLORS.sight; + context.lineWidth = LINE.sight; + context.beginPath(); + context.moveTo(xOfKm(layout, 0), yOfMetres(layout, radarHeight)); + context.lineTo(xOfKm(layout, boatKm), yOfMetres(layout, sightHeightM(radarHeight, boatKm))); + context.stroke(); + context.beginPath(); + context.arc(xOfKm(layout, 0), yOfMetres(layout, radarHeight), LINE.sight, 0, FULL_TURN); + context.fillStyle = COLORS.sight; + context.fill(); +} + +function below(box: Box, layout: HorizonLayout, lines: number): number { + return box.bottom + SPACE.gap + layout.font * TEXT.ascent + lines * (layout.font + SPACE.gap); +} + +function above(box: Box, layout: HorizonLayout): number { + return box.top - SPACE.gap - layout.font * TEXT.descent; +} + +function sightAnchors(layout: HorizonLayout, scene: HorizonScene, boatKm: number): Anchor[] { + return LOS_START_SHARES.map((share) => { + const x = xOfKm(layout, share * boatKm) + SHIP.bow + SPACE.gap; + const baseline = sightY(layout, scene.radarHeight, x) - SPACE.gap - layout.font * TEXT.descent; + return { x, baseline, align: 'left' as const }; + }); +} + +interface LabelPainter { + context: CanvasRenderingContext2D; + layout: HorizonLayout; + taken: Box[]; +} + +function label( + painter: LabelPainter, + key: string, + candidates: readonly Anchor[], + color: string, +): void { + const { context, layout, taken } = painter; + const text = t(key); + drawText(context, text, placeLabel(context, text, candidates, taken, layout), color); +} + +function radarAnchors(layout: HorizonLayout, radarHeight: number): Anchor[] { + const ship = shipBox(layout, radarHeight); + return [ + { x: xOfKm(layout, 0), baseline: above(ship, layout), align: 'center' }, + { x: ship.right + SPACE.gap, baseline: above(ship, layout) + layout.font, align: 'left' }, + ]; +} + +function boatAnchors(layout: HorizonLayout, boat: Box): Anchor[] { + return [ + { x: (boat.left + boat.right) / 2, baseline: below(boat, layout, 0), align: 'center' }, + { x: boat.right, baseline: above(boat, layout), align: 'right' }, + ]; +} + +function detectionAnchors(layout: HorizonLayout): Anchor[] { + const x = xOfKm(layout, DETECTION_KM) + SPACE.gap; + const water = surfaceY(layout, DETECTION_KM); + const surface = { left: x, right: x, top: water, bottom: water }; + return DETECTION_LINES.map((lines) => ({ + x, + baseline: below(surface, layout, lines), + align: 'left', + })); +} + +function noteAnchors(layout: HorizonLayout): Anchor[] { + return NOTE_ROWS.map((row) => ({ + x: layout.bounds.right, + baseline: layout.plot.top - SPACE.gap + row * (layout.font + SPACE.gap), + align: 'right', + })); +} + +function paintLabels(painter: LabelPainter, scene: HorizonScene, boatKm: number): void { + const { layout } = painter; + label(painter, 'horizon.canvas.radar', radarAnchors(layout, scene.radarHeight), COLORS.antenna); + label(painter, 'horizon.canvas.boat', boatAnchors(layout, boatBox(layout, boatKm)), COLORS.boat); + label(painter, 'horizon.canvas.lineOfSight', sightAnchors(layout, scene, boatKm), COLORS.sight); + if (detectionCovered(scene.seaState)) { + label(painter, 'horizon.canvas.detection', detectionAnchors(layout), COLORS.detection); + } + label(painter, 'horizon.canvas.note', noteAnchors(layout), COLORS.muted); +} + +export function paintHorizon( + context: CanvasRenderingContext2D, + frame: CanvasFrame, + scene: HorizonScene, +): void { + const layout = horizonLayout(frame.width, frame.height); + const boatKm = radarLineOfSightKm(scene.radarHeight); + const covered = detectionCovered(scene.seaState); + context.font = canvasFont(frame, layout.font); + context.textBaseline = 'alphabetic'; + paintSea(context, layout, scene); + paintTicks(context, layout); + if (covered) paintDetection(context, detectionBox(layout)); + paintShip(context, layout, scene.radarHeight); + paintSight(context, layout, scene.radarHeight, boatKm); + const boat = boatBox(layout, boatKm); + paintBoat(context, boat); + const taken = [shipBox(layout, scene.radarHeight), boat]; + if (covered) taken.push(detectionBox(layout)); + paintLabels({ context, layout, taken }, scene, boatKm); +} + +export class HorizonView { + private readonly surface: CanvasSurface; + private painted: HorizonScene | null = null; + private language = currentLanguage(); + + constructor(canvas: HTMLCanvasElement) { + this.surface = new CanvasSurface(canvas); + } + + draw(scene: HorizonScene): void { + const language = currentLanguage(); + if (this.isPainted(scene) && language === this.language) return; + this.painted = { ...scene }; + this.language = language; + this.surface.paint((context, frame) => paintHorizon(context, frame, scene)); + } + + dispose(): void { + this.surface.dispose(); + } + + private isPainted(scene: HorizonScene): boolean { + return ( + this.painted?.radarHeight === scene.radarHeight && this.painted.seaState === scene.seaState + ); + } +} diff --git a/explainers/naval-drone/src/ui/index.ts b/explainers/naval-drone/src/ui/index.ts new file mode 100644 index 00000000..fc255f1e --- /dev/null +++ b/explainers/naval-drone/src/ui/index.ts @@ -0,0 +1,24 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import type { NavalDroneStore } from '../state'; +import { mountFleetReadouts } from './mountFleetReadouts'; +import { mountHorizonControl } from './mountHorizonControl'; +import { mountHullControl } from './mountHullControl'; +import { mountJetControl } from './mountJetControl'; +import { mountLinkControl } from './mountLinkControl'; +import { mountOverviewReadouts } from './mountOverviewReadouts'; + +export { CHAPTER_ACTIONS } from './actions'; +export { NAVAL_DRONE_CHOICES, VIEW_TOGGLES } from './dock'; +export { NAVAL_DRONE_READOUTS } from './readouts'; + +export function mountNavalDroneUi(root: Document, store: NavalDroneStore): Disposer { + return disposeAll([ + mountOverviewReadouts(root, store), + mountHullControl(root, store), + mountJetControl(root, store), + mountLinkControl(root, store), + mountHorizonControl(root, store), + mountFleetReadouts(root, store), + ]); +} diff --git a/explainers/naval-drone/src/ui/mountFleetReadouts.ts b/explainers/naval-drone/src/ui/mountFleetReadouts.ts new file mode 100644 index 00000000..df351fe7 --- /dev/null +++ b/explainers/naval-drone/src/ui/mountFleetReadouts.ts @@ -0,0 +1,12 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import type { NavalDroneStore } from '../state'; +import { formatCarries, formatCost, formatShipValue } from './format'; + +export function mountFleetReadouts(root: Document, store: NavalDroneStore): Disposer { + return mountLiveReadouts(root, store, { + 'fleet-carries': (state) => formatCarries(state.fit), + 'fleet-cost': () => formatCost(), + 'fleet-ship': () => formatShipValue(), + }); +} diff --git a/explainers/naval-drone/src/ui/mountHorizonControl.ts b/explainers/naval-drone/src/ui/mountHorizonControl.ts new file mode 100644 index 00000000..511a48f8 --- /dev/null +++ b/explainers/naval-drone/src/ui/mountHorizonControl.ts @@ -0,0 +1,50 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import { requireElement } from '@core/ui/dom'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { mountRangeWidget } from '@core/ui/rangeWidget'; +import type { SeaStateId } from '../ids'; +import { RADAR_HEIGHT_M, minutesAtTopSpeed, radarLineOfSightKm } from '../model'; +import type { NavalDroneStore } from '../state'; +import { + formatCameraHorizon, + formatDetection, + formatHidden, + formatKm, + formatMetres, + formatMinutes, + formatWaves, +} from './format'; +import { HorizonView } from './horizonView'; + +type SelectedRadar = readonly [radarHeight: number, seaState: SeaStateId]; + +export function mountHorizonControl(root: Document, store: NavalDroneStore): Disposer { + const view = new HorizonView(requireElement(root, '[data-view="horizon"]')); + const unmount = disposeAll([ + mountRangeWidget(root, store, { + control: 'radar-height', + range: RADAR_HEIGHT_M, + select: (state): SelectedRadar => [state.radarHeight, state.seaState], + value: ([radarHeight]) => radarHeight, + format: ([radarHeight]) => formatMetres(radarHeight), + set: (state, radarHeight) => state.setRadarHeight(radarHeight), + readouts: { + 'horizon-radar': ([radarHeight]) => formatKm(radarLineOfSightKm(radarHeight)), + 'horizon-minutes': ([radarHeight]) => + formatMinutes(minutesAtTopSpeed(radarLineOfSightKm(radarHeight))), + }, + after: ([radarHeight, seaState]) => view.draw({ radarHeight, seaState }), + }), + mountLiveReadouts(root, store, { + 'horizon-camera': () => formatCameraHorizon(), + 'horizon-detect': (state) => formatDetection(state.seaState), + 'horizon-waves': (state) => formatWaves(state.seaState), + 'horizon-hidden': (state) => formatHidden(state.seaState), + }), + ]); + return () => { + unmount(); + view.dispose(); + }; +} diff --git a/explainers/naval-drone/src/ui/mountHullControl.ts b/explainers/naval-drone/src/ui/mountHullControl.ts new file mode 100644 index 00000000..f77965a6 --- /dev/null +++ b/explainers/naval-drone/src/ui/mountHullControl.ts @@ -0,0 +1,37 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountRangeWidget } from '@core/ui/rangeWidget'; +import { TRIAL_KNOTS, planingAt, trialKnotsOf } from '../model'; +import { followedKnots } from '../state'; +import type { NavalDroneStore } from '../state'; +import { + formatDegrees, + formatHullRatio, + formatLift, + formatMode, + formatTrialSpeed, + formatWetted, +} from './format'; + +type SelectedSpeed = readonly [knots: number]; + +function planingOf([knots]: SelectedSpeed) { + return planingAt(knots); +} + +export function mountHullControl(root: Document, store: NavalDroneStore): Disposer { + return mountRangeWidget(root, store, { + control: 'trial-knots', + range: TRIAL_KNOTS, + select: (state): SelectedSpeed => [trialKnotsOf(followedKnots(state))], + value: ([knots]) => knots, + format: ([knots]) => formatTrialSpeed(knots), + set: (state, knots) => state.setTrialKnots(knots), + readouts: { + 'hull-mode': (selected) => formatMode(planingOf(selected).mode), + 'hull-trim': (selected) => formatDegrees(planingOf(selected).trim), + 'hull-lift': (selected) => formatLift(planingOf(selected).liftShare), + 'hull-wetted': (selected) => formatWetted(planingOf(selected).wettedLength), + 'hull-ratio': ([knots]) => formatHullRatio(knots), + }, + }); +} diff --git a/explainers/naval-drone/src/ui/mountJetControl.ts b/explainers/naval-drone/src/ui/mountJetControl.ts new file mode 100644 index 00000000..2a1066f6 --- /dev/null +++ b/explainers/naval-drone/src/ui/mountJetControl.ts @@ -0,0 +1,53 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { mountRangeWidget } from '@core/ui/rangeWidget'; +import type { HelmId } from '../ids'; +import { THROTTLE_PERCENT, throttleAt, throttlePercentOf } from '../model'; +import { followedKnots, runAt } from '../state'; +import type { NavalDroneStore, NavalDroneStoreState } from '../state'; +import { + formatBoatOrBacking, + formatEfficiency, + formatFlow, + formatJetSpeed, + formatPercent, + formatPush, + formatThrust, +} from './format'; + +type SelectedThrottle = readonly [percent: number, helm: HelmId, trialKnots: number | null]; + +const PERCENT = 100; + +function jetOf(state: NavalDroneStoreState) { + return runAt(state).jet; +} + +export function mountJetControl(root: Document, store: NavalDroneStore): Disposer { + return disposeAll([ + mountRangeWidget(root, store, { + control: 'throttle', + range: THROTTLE_PERCENT, + select: (state): SelectedThrottle => [ + throttlePercentOf(throttleAt(followedKnots(state))), + state.helm, + state.trialKnots, + ], + value: ([percent]) => percent, + format: ([percent]) => formatPercent(percent / PERCENT), + set: (state, percent) => state.setThrottle(percent), + readouts: { + 'jet-boat': (_, state) => formatBoatOrBacking(runAt(state).boat.knots, state.helm), + 'jet-flow': (_, state) => formatFlow(jetOf(state).flow), + 'jet-velocity': (_, state) => formatJetSpeed(jetOf(state).jetSpeed), + 'jet-thrust': (_, state) => formatThrust(jetOf(state).thrust, state.helm), + 'jet-efficiency': (_, state) => + formatEfficiency(jetOf(state).efficiency, runAt(state).boat.knots), + }, + }), + mountLiveReadouts(root, store, { + 'jet-push': (state) => formatPush(state.helm, jetOf(state).nozzleAngle), + }), + ]); +} diff --git a/explainers/naval-drone/src/ui/mountLinkControl.ts b/explainers/naval-drone/src/ui/mountLinkControl.ts new file mode 100644 index 00000000..f4e8e350 --- /dev/null +++ b/explainers/naval-drone/src/ui/mountLinkControl.ts @@ -0,0 +1,42 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { mountRangeWidget } from '@core/ui/rangeWidget'; +import type { LinkMode } from '../ids'; +import { VIDEO_DELAY_MS } from '../model'; +import { runAt } from '../state'; +import type { NavalDroneStore } from '../state'; +import { + formatDelay, + formatLinkBoat, + formatLinkCarrier, + formatLinkNow, + formatLinkTop, +} from './format'; + +type SelectedDelay = readonly [delayMs: number, mode: LinkMode, knots: number]; + +export function mountLinkControl(root: Document, store: NavalDroneStore): Disposer { + return disposeAll([ + mountRangeWidget(root, store, { + control: 'video-delay', + range: VIDEO_DELAY_MS, + select: (state): SelectedDelay => [ + state.videoDelayMs, + state.linkMode, + runAt(state).boat.knots, + ], + value: ([delayMs]) => delayMs, + format: ([delayMs]) => formatDelay(delayMs), + set: (state, delayMs) => state.setVideoDelay(delayMs), + readouts: { + 'link-now': ([delayMs, mode, knots]) => formatLinkNow(mode, delayMs, knots), + 'link-top': ([delayMs]) => formatLinkTop(delayMs), + }, + }), + mountLiveReadouts(root, store, { + 'link-carrier': (state) => formatLinkCarrier(state.linkMode), + 'link-boat': (state) => formatLinkBoat(state.linkMode), + }), + ]); +} diff --git a/explainers/naval-drone/src/ui/mountOverviewReadouts.ts b/explainers/naval-drone/src/ui/mountOverviewReadouts.ts new file mode 100644 index 00000000..0d331234 --- /dev/null +++ b/explainers/naval-drone/src/ui/mountOverviewReadouts.ts @@ -0,0 +1,14 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { RUN_SECONDS, distanceAt } from '../model'; +import { runAt } from '../state'; +import type { NavalDroneStore } from '../state'; +import { formatDistance, formatMode, formatPercent } from './format'; + +export function mountOverviewReadouts(root: Document, store: NavalDroneStore): Disposer { + return mountLiveReadouts(root, store, { + 'overview-mode': (state) => formatMode(runAt(state).planing.mode), + 'overview-covered': (state) => formatDistance(distanceAt(state.phase)), + 'overview-done': (state) => formatPercent(state.phase / RUN_SECONDS), + }); +} diff --git a/explainers/naval-drone/src/ui/mountUi.dom.test.ts b/explainers/naval-drone/src/ui/mountUi.dom.test.ts new file mode 100644 index 00000000..bfdb73c5 --- /dev/null +++ b/explainers/naval-drone/src/ui/mountUi.dom.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { initI18n } from '@core/i18n'; +import { mountActions } from '@core/ui/actions'; +import chapters from '../../chapters.html?raw'; +import en from '../../locales/en.json'; +import { FIT_IDS, HELM_IDS, LINK_MODES, SEA_STATE_IDS, SPEED_MARK_IDS } from '../ids'; +import { HELD_PHASE, MOMENTS, distanceAt, knotsAtThrottle, speedAt, throttleAt } from '../model'; +import { createNavalDroneStore } from '../state'; +import type { NavalDroneStore } from '../state'; +import { CHAPTER_ACTIONS } from './actions'; +import { + formatBoatSpeed, + formatDistance, + formatHullRatio, + formatPercent, + formatTrialSpeed, +} from './format'; +import { fill, isFilled } from './testing'; +import { mountNavalDroneUi } from '.'; + +const { units, hull, jet, link, horizon, fleet } = en; +const PERCENT = 100; + +function readout(id: string): string | null | undefined { + return document.querySelector(`[data-readout="${id}"]`)?.textContent; +} + +function allReadouts(): string[] { + return [...document.querySelectorAll('[data-readout]')].map((node) => node.textContent ?? ''); +} + +function click(action: string, value: string): void { + document.querySelector(`[data-action="${action}"][data-value="${value}"]`)?.click(); +} + +function pressed(action: string, value: string): string | null | undefined { + return document + .querySelector(`[data-action="${action}"][data-value="${value}"]`) + ?.getAttribute('aria-pressed'); +} + +function input(control: string): HTMLInputElement { + const found = document.querySelector(`[data-control="${control}"]`); + if (!found) throw new Error(`No control ${control}`); + return found; +} + +function slide(control: string, value: number): void { + const range = input(control); + range.value = String(value); + range.dispatchEvent(new Event('input')); +} + +function output(control: string): string | null | undefined { + return document.querySelector(`output[for="${control}"]`)?.textContent; +} + +describe('chapter widgets', () => { + let store: NavalDroneStore; + let dispose: () => void; + + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + beforeEach(() => { + document.body.innerHTML = chapters; + store = createNavalDroneStore({ playing: false, phase: 0 }); + dispose = mountNavalDroneUi(document, store); + mountActions(document, store, CHAPTER_ACTIONS); + }); + + afterEach(() => dispose()); + + it('offers a chip for every option the chapters switch', () => { + const values = (action: string) => + [...document.querySelectorAll(`[data-action="${action}"]`)].map((node) => + node.getAttribute('data-value'), + ); + expect(values('speedMark')).toEqual([...SPEED_MARK_IDS]); + expect(values('helm')).toEqual([...HELM_IDS]); + expect(values('linkMode')).toEqual([...LINK_MODES]); + expect(values('seaState')).toEqual([...SEA_STATE_IDS]); + expect(values('fit')).toEqual([...FIT_IDS]); + }); + + it('follows the hull mode, the distance covered and the share of the run in the overview', () => { + expect(readout('overview-mode')).toBe(en.mode.floating); + expect(readout('overview-covered')).toBe(fill(units.m, { value: '0' })); + expect(readout('overview-done')).toBe(fill(units.percent, { value: '0' })); + click('moment', 'topSpeed'); + expect(pressed('moment', 'topSpeed')).toBe('true'); + expect(readout('overview-mode')).toBe(en.mode.planing); + expect(readout('overview-covered')).toBe(formatDistance(distanceAt(MOMENTS.topSpeed))); + expect(readout('overview-done')).toBe(formatPercent(MOMENTS.topSpeed / 120)); + click('moment', 'alongside'); + expect(readout('overview-done')).toBe(fill(units.percent, { value: '100' })); + }); + + it('follows the run on the hull slider until the reader takes the boat over', () => { + store.getState().setPhase(MOMENTS.humpPeak); + expect(Number(input('trial-knots').value)).toBeCloseTo(speedAt(MOMENTS.humpPeak), 6); + expect(output('trial-knots')).toBe(formatTrialSpeed(11)); + expect(readout('hull-mode')).toBe(en.mode.hump); + expect(pressed('speedMark', 'hump')).toBe('true'); + store.getState().play(); + slide('trial-knots', 42); + expect(store.getState()).toMatchObject({ trialKnots: 42, playing: false }); + expect(readout('hull-mode')).toBe(en.mode.planing); + expect(readout('hull-trim')).toBe(fill(units.degrees, { value: '2.5' })); + expect(readout('hull-wetted')).toBe(fill(hull.wetted, { m: '1.7', rest: '5.1' })); + expect(readout('hull-ratio')).toBe(formatHullRatio(42)); + expect(readout('hull-lift')).toContain('98'); + expect(pressed('speedMark', 'top')).toBe('true'); + store.getState().play(); + expect(store.getState().trialKnots).toBeNull(); + expect(output('trial-knots')).toBe(formatTrialSpeed(speedAt(store.getState().phase))); + }); + + it('holds the boat at each speed mark', () => { + click('speedMark', 'hullSpeed'); + expect(output('trial-knots')).toBe(formatTrialSpeed(5.7)); + expect(readout('hull-ratio')).toBe(fill(hull.ratio, { times: '1.0', hullSpeed: '5.7' })); + click('speedMark', 'planing'); + expect(readout('hull-mode')).toBe(en.mode.planing); + slide('trial-knots', 0); + expect(readout('hull-ratio')).toBe(fill(hull.ratioStill, { hullSpeed: '5.7' })); + }); + + it('follows the run throttle on the jet slider and holds the boat when it moves', () => { + store.getState().setPhase(HELD_PHASE); + expect(Number(input('throttle').value)).toBe(Math.round(throttleAt(22) * PERCENT)); + expect(readout('jet-boat')).toBe(formatBoatSpeed(22)); + slide('throttle', 100); + expect(store.getState().trialKnots).toBeCloseTo(knotsAtThrottle(1), 6); + expect(output('throttle')).toBe(fill(units.percent, { value: '100' })); + expect(readout('jet-flow')).toBe(fill(jet.flow, { kg: '213', litres: '207' })); + expect(readout('jet-velocity')).toBe(fill(units.mps, { ms: '32', kmh: '117' })); + expect(readout('jet-thrust')).toBe(fill(units.kilonewtons, { value: '2.30' })); + expect(readout('jet-efficiency')).toBe(fill(units.percent, { value: '80' })); + }); + + it('swings the nozzle and backs the boat with the helm chips', () => { + store.getState().setPhase(HELD_PHASE); + expect(readout('jet-push')).toBe(jet.push.straight); + click('helm', 'left'); + expect(pressed('helm', 'left')).toBe('true'); + expect(store.getState().trialKnots).toBe(22); + expect(readout('jet-push')).toBe(fill(jet.push.left, { angle: '27' })); + click('helm', 'right'); + expect(readout('jet-push')).toBe(fill(jet.push.right, { angle: '27' })); + click('helm', 'reverse'); + expect(readout('jet-push')).toBe(jet.push.reverse); + expect(readout('jet-boat')).toBe(jet.backing); + expect(readout('jet-thrust')).toBe(jet.astern); + expect(readout('jet-efficiency')).toBe(jet.noEfficiency); + expect(output('throttle')).toBe(formatPercent(throttleAt(22))); + }); + + it('turns the video delay into distance and follows the link mode', () => { + store.getState().setPhase(MOMENTS.topSpeed); + expect(output('video-delay')).toBe(fill(units.ms, { value: '250' })); + expect(readout('link-now')).toBe(fill(link.now, { m: '5.4', kn: '42' })); + expect(readout('link-top')).toBe(fill(link.top, { m: '5.4', lengths: '1.0' })); + slide('video-delay', 1000); + expect(readout('link-top')).toBe(fill(link.top, { m: '21.6', lengths: '3.9' })); + expect(readout('link-carrier')).toBe(link.carrier.satellite); + click('linkMode', 'backup'); + expect(pressed('linkMode', 'backup')).toBe('true'); + expect(readout('link-boat')).toBe(link.boat.backup); + click('linkMode', 'lost'); + expect(readout('link-now')).toBe(link.nowLost); + expect(readout('link-carrier')).toBe(link.carrier.lost); + }); + + it('reads the radar line of sight and the sea state', () => { + expect(output('radar-height')).toBe(fill(units.m, { value: '20' })); + expect(readout('horizon-radar')).toBe(fill(units.km, { value: '21.3' })); + expect(readout('horizon-minutes')).toBe(fill(units.minutes, { value: '16.5' })); + slide('radar-height', 5); + expect(readout('horizon-radar')).toBe(fill(units.km, { value: '12.1' })); + expect(readout('horizon-camera')).toBe(fill(horizon.camera, { km: '3.2', m: '0.7' })); + expect(readout('horizon-detect')).toBe(fill(horizon.detect, { km: '9.3', min: '7.1' })); + expect(readout('horizon-hidden')).toBe(horizon.hidden.smooth); + click('seaState', 'rough'); + expect(pressed('seaState', 'rough')).toBe('true'); + expect(readout('horizon-detect')).toBe(horizon.detectBeyond); + expect(readout('horizon-waves')).toBe(fill(horizon.waves.rough, { from: '2.5', to: '4' })); + expect(readout('horizon-hidden')).toBe(horizon.hidden.rough); + }); + + it('quotes the payload for the deck fit, the cost and the ship value', () => { + expect(readout('fleet-carries')).toBe(fill(fleet.carries.standard, { kg: '320' })); + expect(readout('fleet-cost')).toBe(fill(fleet.cost, { from: '250,000', to: '273,000' })); + expect(readout('fleet-ship')).toBe(fill(fleet.ship, { value: '65' })); + click('fit', 'missile'); + expect(pressed('fit', 'missile')).toBe('true'); + expect(readout('fleet-carries')).toBe(fleet.carries.missile); + }); + + it('fills every readout in every chapter state', () => { + [0, 28, 60, 100, 120].forEach((phase) => { + store.getState().setPhase(phase); + allReadouts().forEach((text) => expect(isFilled(text), `${phase}: ${text}`).toBe(true)); + }); + click('helm', 'reverse'); + click('linkMode', 'lost'); + click('seaState', 'rough'); + allReadouts().forEach((text) => expect(isFilled(text), text).toBe(true)); + }); +}); diff --git a/explainers/naval-drone/src/ui/readouts.test.ts b/explainers/naval-drone/src/ui/readouts.test.ts new file mode 100644 index 00000000..51350c9f --- /dev/null +++ b/explainers/naval-drone/src/ui/readouts.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { createNavalDroneStore } from '../state'; +import { THEME } from '../theme'; +import { NAVAL_DRONE_READOUTS } from './readouts'; + +function readout(id: string) { + const found = NAVAL_DRONE_READOUTS.find((candidate) => candidate.id === id); + if (!found) throw new Error(`No readout ${id}`); + return found; +} + +describe('gauge readouts', () => { + it('shows the clock, the speed, the hull mode and the distance to the ship', () => { + expect(NAVAL_DRONE_READOUTS.map((row) => row.id)).toEqual([ + 'clock', + 'speed', + 'mode', + 'distance', + ]); + NAVAL_DRONE_READOUTS.forEach((row) => expect(row.labelKey).toBe(`readouts.${row.id}`)); + expect(NAVAL_DRONE_READOUTS.map((row) => row.numeric)).toEqual([true, true, false, true]); + }); + + it('tints the hull mode with its tone', () => { + const tone = readout('mode').tone; + expect(tone?.(createNavalDroneStore({ phase: 0 }).getState())).toBe(THEME.floating); + expect(tone?.(createNavalDroneStore({ phase: 28 }).getState())).toBe(THEME.hump); + expect(tone?.(createNavalDroneStore({ phase: 60 }).getState())).toBe(THEME.planing); + expect(tone?.(createNavalDroneStore({ phase: 60, trialKnots: 4 }).getState())).toBe( + THEME.floating, + ); + }); + + it('meters nothing', () => { + NAVAL_DRONE_READOUTS.forEach((row) => expect(row.meter, row.id).toBeUndefined()); + }); +}); diff --git a/explainers/naval-drone/src/ui/readouts.ts b/explainers/naval-drone/src/ui/readouts.ts new file mode 100644 index 00000000..06e70bd5 --- /dev/null +++ b/explainers/naval-drone/src/ui/readouts.ts @@ -0,0 +1,33 @@ +import type { Readout } from '@core/explainer'; +import { runAt } from '../state'; +import type { NavalDroneStoreState } from '../state'; +import { formatBoatSpeed, formatDistance, formatMode, formatPhase } from './format'; +import { MODE_TONES } from './palette'; + +export const NAVAL_DRONE_READOUTS: readonly Readout[] = [ + { + id: 'clock', + labelKey: 'readouts.clock', + numeric: true, + value: (state) => formatPhase(state.phase), + }, + { + id: 'speed', + labelKey: 'readouts.speed', + numeric: true, + value: (state) => formatBoatSpeed(runAt(state).boat.knots), + }, + { + id: 'mode', + labelKey: 'readouts.mode', + numeric: false, + value: (state) => formatMode(runAt(state).planing.mode), + tone: (state) => MODE_TONES[runAt(state).planing.mode], + }, + { + id: 'distance', + labelKey: 'readouts.distance', + numeric: true, + value: (state) => formatDistance(runAt(state).distanceToShip), + }, +]; From 68a462d1641c2ac886cd0a66ed420867edb52df0 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:25:33 +0200 Subject: [PATCH 05/99] feat(naval-drone): add the camera views, controller and scene bindings --- .../naval-drone/src/scene/bindings.test.ts | 134 ++++++++++ explainers/naval-drone/src/scene/bindings.ts | 92 +++++++ .../naval-drone/src/scene/cameraViews.test.ts | 143 +++++++++++ .../naval-drone/src/scene/cameraViews.ts | 228 ++++++++++++++++++ .../naval-drone/src/scene/controller.test.ts | 106 ++++++++ .../naval-drone/src/scene/controller.ts | 102 ++++++++ .../naval-drone/src/scene/drawnLabels.test.ts | 92 +++++++ .../naval-drone/src/scene/drawnLabels.ts | 67 +++++ explainers/naval-drone/src/scene/index.ts | 50 ++++ .../naval-drone/src/scene/partInfo.test.ts | 53 ++++ explainers/naval-drone/src/scene/partInfo.ts | 71 ++++++ .../naval-drone/src/scene/viewFit.test.ts | 73 ++++++ explainers/naval-drone/src/scene/viewFit.ts | 93 +++++++ 13 files changed, 1304 insertions(+) create mode 100644 explainers/naval-drone/src/scene/bindings.test.ts create mode 100644 explainers/naval-drone/src/scene/bindings.ts create mode 100644 explainers/naval-drone/src/scene/cameraViews.test.ts create mode 100644 explainers/naval-drone/src/scene/cameraViews.ts create mode 100644 explainers/naval-drone/src/scene/controller.test.ts create mode 100644 explainers/naval-drone/src/scene/controller.ts create mode 100644 explainers/naval-drone/src/scene/drawnLabels.test.ts create mode 100644 explainers/naval-drone/src/scene/drawnLabels.ts create mode 100644 explainers/naval-drone/src/scene/index.ts create mode 100644 explainers/naval-drone/src/scene/partInfo.test.ts create mode 100644 explainers/naval-drone/src/scene/partInfo.ts create mode 100644 explainers/naval-drone/src/scene/viewFit.test.ts create mode 100644 explainers/naval-drone/src/scene/viewFit.ts diff --git a/explainers/naval-drone/src/scene/bindings.test.ts b/explainers/naval-drone/src/scene/bindings.test.ts new file mode 100644 index 00000000..65f556b4 --- /dev/null +++ b/explainers/naval-drone/src/scene/bindings.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AssemblyState } from '../ids'; +import { HELD_PHASE, boatAt, companionsAt, planingAt, seaAt } from '../model'; +import { createNavalDroneStore } from '../state'; +import { bindStore } from './bindings'; +import type { NavalDroneController } from './controller'; + +function fakeTargets() { + const received: AssemblyState[] = []; + const snapshots: AssemblyState[] = []; + const record = (state: AssemblyState) => { + received.push(state); + snapshots.push({ ...state }); + }; + const frame = vi.fn(); + const navalDrone = { build: record, setState: record, views: { frame } }; + return { + received, + snapshots, + frame, + targets: { + navalDrone: navalDrone as unknown as NavalDroneController, + labelVisibility: { setWanted: vi.fn() }, + highlighter: { setHighlight: vi.fn() }, + labels: { show: vi.fn() }, + }, + }; +} + +describe('scene bindings', () => { + it('builds the scene at the store moment and frames the first chapter', () => { + const { received, frame, targets } = fakeTargets(); + bindStore(createNavalDroneStore({ phase: 100 }), targets); + expect(received[0]).toMatchObject({ + phase: 100, + playing: true, + boat: boatAt(100), + planing: planingAt(boatAt(100).knots), + companions: companionsAt(100), + sea: seaAt('smooth'), + link: { mode: 'satellite', ghost: null }, + fit: 'standard', + view: { cutaway: true, flow: false, links: false, labels: true }, + waterSection: true, + wettedBar: false, + }); + expect(frame).toHaveBeenCalledWith('chase', false, undefined); + }); + + it('hands the scene the held boat, the jet and the hull chapter flags', () => { + const { received, frame, targets } = fakeTargets(); + const store = createNavalDroneStore(); + bindStore(store, targets); + store.getState().applyPreset('hull'); + expect(frame).toHaveBeenLastCalledWith('waterline', true, undefined); + expect(received.at(-1)).toMatchObject({ + phase: HELD_PHASE, + playing: false, + boat: { knots: 11, held: true }, + waterSection: true, + wettedBar: true, + view: { cutaway: false, flow: true }, + }); + store.getState().applyPreset('jet'); + store.getState().setHelm('reverse'); + expect(received.at(-1)).toMatchObject({ boat: { knots: 0, held: true }, jet: { bucket: 1 } }); + store.getState().play(); + expect(received.at(-1)?.boat.held).toBe(false); + }); + + it('hands the scene the sea, the link ghost and the deck fit', () => { + const { received, targets } = fakeTargets(); + const store = createNavalDroneStore(); + bindStore(store, targets); + store.getState().applyPreset('link'); + expect(received.at(-1)?.link.ghost).not.toBeNull(); + store.getState().setLinkMode('lost'); + expect(received.at(-1)?.link).toEqual({ mode: 'lost', ghost: null }); + store.getState().applyPreset('horizon'); + expect(received.at(-1)?.sea).toEqual(seaAt('slight')); + store.getState().setFit('missile'); + expect(received.at(-1)?.fit).toBe('missile'); + }); + + it('hands the scene each view change once', () => { + const { received, targets } = fakeTargets(); + const store = createNavalDroneStore(); + bindStore(store, targets); + const calls = received.length; + store.getState().toggleView('links'); + expect(received).toHaveLength(calls + 1); + expect(received.at(-1)?.view.links).toBe(true); + }); + + it('never changes the state object the assembly got last', () => { + const { received, snapshots, targets } = fakeTargets(); + const store = createNavalDroneStore(); + bindStore(store, targets); + store.getState().setPhase(10); + store.getState().setPhase(90); + const last = received.length - 1; + expect(received[last]).not.toBe(received[last - 1]); + expect(received[last - 1]).toEqual(snapshots[last - 1]); + }); + + it('labels only the parts the scene draws', () => { + const { targets } = fakeTargets(); + const store = createNavalDroneStore({ phase: 0 }); + bindStore(store, targets); + const wanted = () => + targets.labelVisibility.setWanted.mock.lastCall?.[0] as ReadonlySet; + store.getState().applyPreset('hull'); + expect(wanted()).toContain('bowWave'); + expect(wanted()).toContain('wettedLength'); + store.getState().setTrialKnots(30); + expect(wanted()).not.toContain('bowWave'); + expect(wanted()).toContain('spray'); + store.getState().applyPreset('fleet'); + expect(wanted()).toContain('companions'); + expect(wanted()).not.toContain('missileRails'); + store.getState().setFit('missile'); + expect(wanted()).toContain('missileRails'); + }); + + it('stops listening once unbound', () => { + const { received, targets } = fakeTargets(); + const store = createNavalDroneStore(); + const unbind = bindStore(store, targets); + unbind(); + const calls = received.length; + store.getState().setPhase(50); + expect(received).toHaveLength(calls); + }); +}); diff --git a/explainers/naval-drone/src/scene/bindings.ts b/explainers/naval-drone/src/scene/bindings.ts new file mode 100644 index 00000000..1700568a --- /dev/null +++ b/explainers/naval-drone/src/scene/bindings.ts @@ -0,0 +1,92 @@ +import { bindPresets } from '@core/scene/presetBinder'; +import type { LabelPolicy, PresetTargets } from '@core/scene/presetBinder'; +import type { AssemblyState } from '../ids'; +import { + CHAPTER_CONTROL_DEFAULTS, + DEFAULT_FIT, + DEFAULT_VIEW, + PRESETS, + assemblyFlags, + runAt, +} from '../state'; +import type { + NavalDroneState, + NavalDroneStore, + NavalDroneStoreState, + Preset, + RunSource, +} from '../state'; +import type { NavalDroneController } from './controller'; +import { DrawnLabels } from './drawnLabels'; + +export interface SceneTargets extends PresetTargets { + navalDrone: NavalDroneController; + labelVisibility: LabelPolicy; +} + +type AssemblySource = RunSource & Pick; + +const BLANK_SOURCE: AssemblySource = { + ...CHAPTER_CONTROL_DEFAULTS, + phase: 0, + preset: 'overview', + playing: false, + fit: DEFAULT_FIT, + view: DEFAULT_VIEW, +}; + +function readingOf( + source: AssemblySource, +): Omit { + const { boat, planing, jet, companions, sea, link } = runAt(source); + return { boat, planing, jet, companions, sea, link, ...assemblyFlags(source) }; +} + +function copyInto(target: AssemblyState, source: AssemblySource): AssemblyState { + Object.assign(target, readingOf(source)); + target.phase = source.phase; + target.playing = source.playing; + target.fit = source.fit; + target.view = source.view; + return target; +} + +function blankState(): AssemblyState { + const { phase, playing, fit, view } = BLANK_SOURCE; + return { phase, playing, fit, view, ...readingOf(BLANK_SOURCE) }; +} + +class DoubleBufferedAssemblyState { + private heldByAssembly = blankState(); + private free = blankState(); + + handOver(source: AssemblySource): AssemblyState { + const next = copyInto(this.free, source); + this.free = this.heldByAssembly; + this.heldByAssembly = next; + return next; + } +} + +export function bindStore(store: NavalDroneStore, targets: SceneTargets): () => void { + const { navalDrone, labelVisibility } = targets; + const assemblyState = new DoubleBufferedAssemblyState(); + const drawnLabels = new DrawnLabels(labelVisibility); + const handOver = (state: AssemblySource) => { + const next = assemblyState.handOver(state); + drawnLabels.follow(next); + return next; + }; + const push = (state: AssemblySource) => navalDrone.setState(handOver(state)); + navalDrone.build(handOver(store.getState())); + const unsubscribers = [ + store.subscribe(push), + bindPresets(targets, store, { + presets: PRESETS, + views: navalDrone.views, + labels: drawnLabels, + prepare: push, + }), + ]; + return () => unsubscribers.forEach((unsubscribe) => unsubscribe()); +} diff --git a/explainers/naval-drone/src/scene/cameraViews.test.ts b/explainers/naval-drone/src/scene/cameraViews.test.ts new file mode 100644 index 00000000..27939df3 --- /dev/null +++ b/explainers/naval-drone/src/scene/cameraViews.test.ts @@ -0,0 +1,143 @@ +import { Vector3 } from 'three'; +import { describe, expect, it } from 'vitest'; +import { toRadians } from '@core/math'; +import { PRESETS } from '../state'; +import { BACKUP_SATELLITE_OFFSET, BOAT, SATELLITE_OFFSET, TRANSOM_X, skyPoint } from '../model'; +import { + EYE_VIEW, + GROUP_VIEW, + ORBIT_VIEWS, + SKY_VIEW, + VIEW_DISTANCE, + cameraViews, + eyeAim, + eyePose, + groupPose, + orbitPose, + skyPitch, + skyPose, +} from './cameraViews'; +import type { FollowTarget } from './cameraViews'; +import { fitsView, vectorOf } from './viewFit'; + +const PHONE = { vertical: 0.2, horizontal: 0.27 }; +const DESKTOP = { vertical: 0.25, horizontal: 0.42 }; + +const TARGET: FollowTarget = { + position: [400, 0.2, -100], + heading: 0, + length: BOAT.length, + ship: [1400, 0, -420], + trim: 0, +}; + +function offsetOf(pose: { position: Vector3; target: Vector3 }): Vector3 { + return pose.position.clone().sub(pose.target); +} + +describe('camera views', () => { + const views = cameraViews(() => TARGET); + + it('offers every view the chapters ask for, all following the boat', () => { + Object.values(PRESETS).forEach((preset) => { + expect(views[preset.camera]).toBeDefined(); + expect(views[preset.camera].follow, preset.camera).toBe(true); + }); + }); + + it('leaves the camera alone until the boat exists', () => { + const empty = cameraViews(() => null); + Object.values(empty).forEach((view) => expect(view.pose(PHONE)).toBeNull()); + }); + + it('limits the zoom of the close views', () => { + expect(views.stern.distance).toEqual({ min: 1.2, max: 30 }); + expect(views.waterline.distance).toEqual({ min: 4, max: 80 }); + expect(views.eye.distance).toEqual({ min: 5, max: 120 }); + expect(views.chase.distance).toBeUndefined(); + expect(VIEW_DISTANCE.sky).toBeUndefined(); + }); + + it('chases from behind on the port quarter, 18 degrees up, 2.2 lengths wide', () => { + const pose = orbitPose(TARGET, ORBIT_VIEWS.chase, PHONE); + expect(pose.target.toArray()).toEqual([400, 0.4, -100]); + const offset = offsetOf(pose); + expect(offset.x).toBeLessThan(0); + expect(offset.z).toBeLessThan(0); + expect(Math.asin(offset.y / offset.length())).toBeCloseTo(toRadians(18), 9); + expect(Math.atan2(offset.z, offset.x)).toBeCloseTo(toRadians(-150), 9); + expect(offset.length()).toBeCloseTo((2.2 * BOAT.length) / (2 * PHONE.horizontal), 9); + }); + + it('turns the views with the heading', () => { + const turned = orbitPose({ ...TARGET, heading: Math.PI / 2 }, ORBIT_VIEWS.waterline, PHONE); + const offset = offsetOf(turned); + expect(offset.x).toBeGreaterThan(0); + expect(Math.abs(offset.z)).toBeLessThan(1e-9); + }); + + it('sits abeam to port just above the water for the waterline', () => { + const pose = orbitPose(TARGET, ORBIT_VIEWS.waterline, DESKTOP); + const offset = offsetOf(pose); + expect(offset.z).toBeLessThan(0); + expect(Math.abs(offset.x)).toBeLessThan(1e-9); + expect(pose.position.y).toBeGreaterThan(TARGET.position[1]); + expect(pose.position.y).toBeLessThan(TARGET.position[1] + 1); + }); + + it('aims at the pump axis behind the transom, dropping with the trim', () => { + const level = orbitPose(TARGET, ORBIT_VIEWS.stern, PHONE); + expect(level.target.x).toBeCloseTo(400 + TRANSOM_X - 0.15, 9); + expect(level.target.y).toBeCloseTo(0.2 - 0.15, 9); + const trimmed = orbitPose({ ...TARGET, trim: toRadians(4) }, ORBIT_VIEWS.stern, PHONE); + expect(trimmed.target.y).toBeLessThan(level.target.y); + expect(offsetOf(level).length()).toBeCloseTo(2.4 / (2 * PHONE.horizontal), 9); + }); + + it('looks from just behind the dome along the heading with the bow fairing low in the frame', () => { + const pose = eyePose(TARGET, PHONE); + expect(pose.position.toArray()).toEqual([400.75, 1.12, -100]); + expect(pose.target.x).toBeCloseTo(440, 9); + const window = vectorOf(EYE_VIEW.window).add(vectorOf(TARGET.position)); + const margin = 0.01; + expect(fitsView(pose, [window], PHONE, EYE_VIEW.windowShare + margin)).toBe(true); + expect(fitsView(pose, [window], PHONE, EYE_VIEW.windowShare - margin)).toBe(false); + expect(eyeAim(DESKTOP)[1]).toBeGreaterThan(eyeAim(PHONE)[1]); + }); + + it('frames the boat and the formation behind it on the starboard quarter, looking ahead', () => { + [PHONE, DESKTOP].forEach((slopes) => { + const pose = groupPose(TARGET, slopes); + expect(pose.target.x).toBeCloseTo(400 + GROUP_VIEW.aimAhead, 9); + const offset = pose.position.clone().sub(vectorOf(TARGET.position)); + expect(offset.x).toBeLessThan(0); + expect(offset.z).toBeGreaterThan(0); + const companions = [-1, 1].map((side) => new Vector3(370, 0, -100 + side * 18)); + expect(fitsView(pose, [vectorOf(TARGET.position), ...companions], slopes, 0.9)).toBe(true); + }); + }); + + it('keeps the boat low in the sky view with both satellites up toward the top', () => { + [PHONE, DESKTOP].forEach((slopes) => { + const pose = skyPose(TARGET, slopes); + const behind = pose.position.clone().sub(vectorOf(TARGET.position)); + expect(behind.x).toBeLessThan(-SKY_VIEW.back.min + 1); + expect(-behind.x).toBeLessThanOrEqual(SKY_VIEW.back.max + 1); + expect(pose.target.y).toBeGreaterThan(pose.position.y); + expect(fitsView(pose, [vectorOf(TARGET.position)], slopes, 1)).toBe(true); + }); + const satellites = [SATELLITE_OFFSET, BACKUP_SATELLITE_OFFSET].map((offset) => + vectorOf(skyPoint(TARGET.position, offset)), + ); + expect(satellites.every((point) => point.y > 100)).toBe(true); + }); + + it('centres the sky view when everything fits, else puts the boat at the bottom', () => { + expect(skyPitch([-0.05, 0.1], PHONE)).toBeCloseTo(0.025, 9); + const low = -0.1; + expect(skyPitch([low, 0.5], PHONE)).toBeCloseTo( + low + Math.atan(PHONE.vertical * SKY_VIEW.fill), + 9, + ); + }); +}); diff --git a/explainers/naval-drone/src/scene/cameraViews.ts b/explainers/naval-drone/src/scene/cameraViews.ts new file mode 100644 index 00000000..e2466dab --- /dev/null +++ b/explainers/naval-drone/src/scene/cameraViews.ts @@ -0,0 +1,228 @@ +import type { Vector3 } from 'three'; +import { toRadians } from '@core/math'; +import type { CameraDistance } from '@core/scene/camera'; +import type { CustomView } from '@core/scene/cameraViews'; +import type { CameraPose } from '@core/scene/frameBox'; +import type { FramingSlopes } from '@core/scene/lens'; +import type { CameraView, Point } from '../ids'; +import { + BACKUP_SATELLITE_OFFSET, + BOW_CAMERA, + FORMATION, + JET, + SATELLITE_OFFSET, + TRANSOM_X, + skyPoint, +} from '../model'; +import type { ChaseTarget } from './assembly'; +import { boatToWorld, direction, fitDistance, fitsView, nearestFit, vectorOf } from './viewFit'; +import type { BoatFrame } from './viewFit'; + +export interface FollowTarget extends ChaseTarget { + trim: number; +} + +export type FollowSource = () => FollowTarget | null; + +type OrbitView = Extract; + +interface OrbitSpec { + bearing: number; + elevation: number; + width(target: FollowTarget): number; + aim: Point; +} + +const STERN_AIM_AFT = 0.15; +const STERN_WIDTH_M = 2.4; +const CHASE_WIDTH_LENGTHS = 2.2; +const WATERLINE_WIDTH_LENGTHS = 1.4; +const FIT_FILL = 0.86; +const SIDES = [-1, 1] as const; + +export const ORBIT_VIEWS: Readonly> = { + chase: { + bearing: toRadians(-150), + elevation: toRadians(18), + width: (target) => CHASE_WIDTH_LENGTHS * target.length, + aim: [0, 0.2, 0], + }, + waterline: { + bearing: toRadians(-90), + elevation: toRadians(1.5), + width: (target) => WATERLINE_WIDTH_LENGTHS * target.length, + aim: [0, 0.1, 0], + }, + stern: { + bearing: toRadians(-145), + elevation: toRadians(12), + width: () => STERN_WIDTH_M, + aim: [TRANSOM_X - STERN_AIM_AFT, JET.axisY, 0], + }, +}; + +export const GROUP_VIEW = { + bearing: toRadians(165), + elevation: toRadians(16), + width: 60, + aimAhead: 120, + maxDistance: 900, +} as const; + +export const SKY_VIEW = { + bearing: toRadians(175), + height: 4, + back: { min: 45, max: 160 }, + fill: 0.72, + aimAhead: 45, +} as const; + +export const EYE_VIEW = { + camera: [0.75, 0.92, 0] as Point, + window: [BOW_CAMERA.x[1], BOW_CAMERA.window.y, 0] as Point, + ahead: 40, + windowShare: 0.65, +} as const; + +export const VIEW_DISTANCE: Readonly>> = { + stern: { min: 1.2, max: 30 }, + waterline: { min: 4, max: 80 }, + eye: { min: 5, max: 120 }, +}; + +function frameOf(target: FollowTarget): BoatFrame { + return { position: target.position, heading: target.heading, trim: target.trim }; +} + +function level(target: FollowTarget): BoatFrame { + return { ...frameOf(target), trim: 0 }; +} + +export function orbitPose( + target: FollowTarget, + spec: OrbitSpec, + slopes: FramingSlopes, +): CameraPose { + const aim = boatToWorld(frameOf(target), spec.aim, true); + const distance = fitDistance(spec.width(target), slopes); + const offset = direction(target.heading + spec.bearing, spec.elevation).multiplyScalar(distance); + return { position: aim.clone().add(offset), target: aim }; +} + +function formationPoints(target: FollowTarget): Vector3[] { + const frame = level(target); + const slots = SIDES.map((side) => + boatToWorld(frame, [-FORMATION.back, 0, side * FORMATION.side], false), + ); + return [vectorOf(target.position), ...slots]; +} + +export function groupPose(target: FollowTarget, slopes: FramingSlopes): CameraPose { + const boat = vectorOf(target.position); + const aim = boatToWorld(level(target), [GROUP_VIEW.aimAhead, 0, 0], false); + const away = direction(target.heading + GROUP_VIEW.bearing, GROUP_VIEW.elevation); + const points = formationPoints(target); + return nearestFit( + (distance) => ({ position: boat.clone().addScaledVector(away, distance), target: aim.clone() }), + (pose) => fitsView(pose, points, slopes, FIT_FILL), + { min: fitDistance(GROUP_VIEW.width, slopes), max: GROUP_VIEW.maxDistance }, + ); +} + +function skyPoints(target: FollowTarget): Vector3[] { + return [ + vectorOf(target.position), + vectorOf(skyPoint(target.position, SATELLITE_OFFSET)), + vectorOf(skyPoint(target.position, BACKUP_SATELLITE_OFFSET)), + ]; +} + +function elevationFrom(from: Vector3, point: Vector3): number { + const rise = point.y - from.y; + return Math.atan2(rise, Math.hypot(point.x - from.x, point.z - from.z)); +} + +export function skyPitch(elevations: readonly number[], slopes: FramingSlopes): number { + const low = Math.min(...elevations); + const high = Math.max(...elevations); + const reach = Math.atan(slopes.vertical * SKY_VIEW.fill); + return Math.min((low + high) / 2, low + reach); +} + +function skyPoseAt( + target: FollowTarget, + back: number, + points: readonly Vector3[], + slopes: FramingSlopes, +): CameraPose { + const behind = direction(target.heading + SKY_VIEW.bearing, 0).multiplyScalar(back); + const position = vectorOf(target.position).add(behind).setY(SKY_VIEW.height); + const ahead = boatToWorld(level(target), [SKY_VIEW.aimAhead, 0, 0], false); + const azimuth = Math.atan2(ahead.z - position.z, ahead.x - position.x); + const pitch = skyPitch( + points.map((point) => elevationFrom(position, point)), + slopes, + ); + const reach = back + SKY_VIEW.aimAhead; + return { position, target: position.clone().addScaledVector(direction(azimuth, pitch), reach) }; +} + +export function skyPose(target: FollowTarget, slopes: FramingSlopes): CameraPose { + const points = skyPoints(target); + return nearestFit( + (back) => skyPoseAt(target, back, points, slopes), + (pose) => fitsView(pose, points, slopes, SKY_VIEW.fill), + SKY_VIEW.back, + ); +} + +export function eyeAim(slopes: FramingSlopes): Point { + const [x, y] = EYE_VIEW.camera; + const [windowX, windowY] = EYE_VIEW.window; + const pitch = + Math.atan2(windowY - y, windowX - x) + Math.atan(slopes.vertical * EYE_VIEW.windowShare); + const reach = EYE_VIEW.ahead - x; + return [EYE_VIEW.ahead, y + reach * Math.tan(pitch), 0]; +} + +export function eyePose(target: FollowTarget, slopes: FramingSlopes): CameraPose { + const frame = frameOf(target); + return { + position: boatToWorld(frame, EYE_VIEW.camera, true), + target: boatToWorld(frame, eyeAim(slopes), true), + }; +} + +function followView( + source: FollowSource, + pose: (target: FollowTarget, slopes: FramingSlopes) => CameraPose, + distance?: CameraDistance, +): CustomView { + return { + pose: (slopes) => { + const target = source(); + return target ? pose(target, slopes) : null; + }, + follow: true, + distance, + }; +} + +function orbitView(source: FollowSource, view: OrbitView): CustomView { + return followView( + source, + (target, slopes) => orbitPose(target, ORBIT_VIEWS[view], slopes), + VIEW_DISTANCE[view], + ); +} + +export function cameraViews(source: FollowSource): Record { + return { + chase: orbitView(source, 'chase'), + waterline: orbitView(source, 'waterline'), + stern: orbitView(source, 'stern'), + sky: followView(source, skyPose, VIEW_DISTANCE.sky), + eye: followView(source, eyePose, VIEW_DISTANCE.eye), + group: followView(source, groupPose, VIEW_DISTANCE.group), + }; +} diff --git a/explainers/naval-drone/src/scene/controller.test.ts b/explainers/naval-drone/src/scene/controller.test.ts new file mode 100644 index 00000000..40da1997 --- /dev/null +++ b/explainers/naval-drone/src/scene/controller.test.ts @@ -0,0 +1,106 @@ +import { Box3, MeshStandardMaterial, Vector3 } from 'three'; +import { describe, expect, it, vi } from 'vitest'; +import type { AssemblyState } from '../ids'; +import { HELD_PHASE } from '../model'; +import { CHAPTER_CONTROL_DEFAULTS, DEFAULT_VIEW, runAt } from '../state'; +import { NavalDroneController } from './controller'; +import type { NavalDroneControllerDependencies } from './controller'; + +const PHONE = { vertical: 0.2, horizontal: 0.27 }; +const LEVEL_LIMIT = Math.PI / 2; + +function assemblyState(changes: Partial = {}): AssemblyState { + const reading = runAt({ ...CHAPTER_CONTROL_DEFAULTS, phase: HELD_PHASE, preset: 'overview' }); + return { + phase: HELD_PHASE, + playing: false, + boat: reading.boat, + planing: reading.planing, + jet: reading.jet, + companions: reading.companions, + sea: reading.sea, + link: reading.link, + fit: 'standard', + view: DEFAULT_VIEW, + waterSection: true, + wettedBar: false, + ...changes, + }; +} + +function fakeShell() { + const rig = { + framing: () => PHONE, + follow: vi.fn(), + setDistanceLimits: vi.fn(), + jumpTo: vi.fn(), + tweenTo: vi.fn(), + setBounds: vi.fn(), + camera: { position: new Vector3(0, 0, 30) }, + controls: { target: new Vector3(), maxPolarAngle: LEVEL_LIMIT }, + }; + const shell = { + scene: { add: vi.fn() }, + materials: { get: () => new MeshStandardMaterial() }, + textures: {}, + labels: { attach: vi.fn(), show: vi.fn() }, + rig, + }; + return { rig, shell: shell as unknown as NavalDroneControllerDependencies }; +} + +describe('naval drone controller', () => { + it('adds the scene and keeps the camera target above the sea floor of the bounds', () => { + const { rig, shell } = fakeShell(); + const navalDrone = new NavalDroneController(shell); + navalDrone.build(assemblyState()); + expect(shell.scene.add).toHaveBeenCalledTimes(1); + expect(shell.labels.attach).toHaveBeenCalledTimes(1); + const [bounds, floor] = rig.setBounds.mock.lastCall ?? []; + expect(bounds).toBeInstanceOf(Box3); + expect(floor).toBe((bounds as Box3).min.y); + expect(floor).toBeLessThan(0); + }); + + it('frames a view around the boat and follows it once the scene is built', () => { + const { rig, shell } = fakeShell(); + const navalDrone = new NavalDroneController(shell); + navalDrone.views.frame('chase', true); + expect(rig.tweenTo).not.toHaveBeenCalled(); + const state = assemblyState(); + navalDrone.build(state); + navalDrone.views.frame('chase', true); + const target = rig.tweenTo.mock.lastCall?.[0]?.target as Vector3 | undefined; + expect(target?.x).toBeCloseTo(state.boat.position[0], 1); + expect(rig.follow).toHaveBeenLastCalledWith(expect.anything()); + expect(rig.setDistanceLimits).toHaveBeenLastCalledWith({}); + }); + + it('lets the sky view look up without the camera reaching the sea, then levels again', () => { + const { rig, shell } = fakeShell(); + const navalDrone = new NavalDroneController(shell); + navalDrone.build(assemblyState()); + navalDrone.views.frame('sky', false); + expect(rig.controls.maxPolarAngle).toBeGreaterThan(LEVEL_LIMIT); + const limits = rig.setDistanceLimits.mock.lastCall?.[0] as { max: number }; + expect(limits.max).toBeGreaterThan(0); + navalDrone.views.frame('stern', true); + expect(rig.controls.maxPolarAngle).toBe(LEVEL_LIMIT); + expect(rig.setDistanceLimits).toHaveBeenLastCalledWith({ min: 1.2, max: 30 }); + }); + + it('keeps drawing frames while the run plays or the reader holds the boat', () => { + const { shell } = fakeShell(); + const navalDrone = new NavalDroneController(shell); + expect(navalDrone.update(0.016)).toBe(false); + navalDrone.build(assemblyState()); + expect(navalDrone.update(0.016)).toBe(false); + navalDrone.setState(assemblyState({ playing: true })); + expect(navalDrone.update(0.016)).toBe(true); + const held = assemblyState(); + navalDrone.setState({ ...held, boat: { ...held.boat, held: true } }); + expect(navalDrone.update(0.016)).toBe(true); + navalDrone.dispose(); + expect(navalDrone.update(0.016)).toBe(false); + }); +}); diff --git a/explainers/naval-drone/src/scene/controller.ts b/explainers/naval-drone/src/scene/controller.ts new file mode 100644 index 00000000..a08ae525 --- /dev/null +++ b/explainers/naval-drone/src/scene/controller.ts @@ -0,0 +1,102 @@ +import type { Object3D } from 'three'; +import { CameraViews } from '@core/scene/cameraViews'; +import type { SceneShell } from '@core/scene/shell'; +import { whenIdle } from '@core/scene/warmUp'; +import type { AssemblyState, CameraView, RegionId } from '../ids'; +import { createAssembly } from './assembly'; +import type { Assembly } from './assembly'; +import { VIEW_DISTANCE, cameraViews } from './cameraViews'; +import type { FollowTarget } from './cameraViews'; +import { lookUpLimits } from './viewFit'; + +export type NavalDroneControllerDependencies = Pick< + SceneShell, + 'scene' | 'materials' | 'textures' | 'labels' | 'rig' | 'viewport' +>; + +export type ViewFramer = Pick, 'frame'>; + +const SEA_CLEARANCE_M = 1; + +export class NavalDroneController { + readonly views: ViewFramer; + private readonly dependencies: NavalDroneControllerDependencies; + private readonly cameraViews: CameraViews; + private readonly defaultMaxPolar: number; + private assembly: Assembly | null = null; + private state: AssemblyState | null = null; + private cancelWarmUp: () => void = () => {}; + + constructor(dependencies: NavalDroneControllerDependencies) { + this.dependencies = dependencies; + this.defaultMaxPolar = dependencies.rig.controls.maxPolarAngle; + this.cameraViews = new CameraViews(dependencies.rig, { + views: cameraViews(() => this.followTarget()), + region: (id) => this.assembly?.region(id) ?? null, + anchor: () => this.assembly?.anchor('boat') ?? null, + }); + this.views = { frame: (view, animate, variant) => this.frame(view, animate, variant) }; + } + + build(state: AssemblyState): void { + const { scene, materials, textures, labels, rig } = this.dependencies; + this.dispose(); + const assembly = createAssembly({ materials, textures }, state); + this.assembly = assembly; + this.state = state; + scene.add(assembly.root); + labels.attach(assembly.labelAnchors()); + const bounds = assembly.region('scene'); + rig.setBounds(bounds, bounds.min.y); + this.cancelWarmUp = whenIdle(() => assembly.warmUp?.((object) => this.compileHidden(object))); + } + + setState(state: AssemblyState): void { + this.state = state; + this.assembly?.setState(state); + } + + update(deltaSeconds: number): boolean { + const { camera, controls } = this.dependencies.rig; + return ( + this.assembly?.update(deltaSeconds, camera.position.distanceTo(controls.target)) ?? false + ); + } + + dispose(): void { + this.cancelWarmUp(); + this.assembly?.root.removeFromParent(); + this.assembly?.dispose(); + this.assembly = null; + } + + private followTarget(): FollowTarget | null { + const chase = this.assembly?.chaseTarget(); + if (!chase || !this.state) return null; + return { ...chase, trim: this.state.planing.trim }; + } + + private frame(view: CameraView, animate: boolean, variant?: string): void { + this.cameraViews.frame(view, animate, variant); + this.limitLookUp(view); + } + + private limitLookUp(view: CameraView): void { + const { rig } = this.dependencies; + const pose = this.cameraViews.pose(view); + if (!pose) return; + const limits = lookUpLimits(pose, this.defaultMaxPolar, SEA_CLEARANCE_M); + rig.controls.maxPolarAngle = limits.maxPolarAngle; + if (limits.maxDistance !== null) { + rig.setDistanceLimits({ ...VIEW_DISTANCE[view], max: limits.maxDistance }); + } + } + + private compileHidden(object: Object3D): void { + const { viewport, rig, scene } = this.dependencies; + const shown = object.visible; + object.visible = true; + void viewport.renderer.compileAsync(object, rig.camera, scene); + object.visible = shown; + } +} diff --git a/explainers/naval-drone/src/scene/drawnLabels.test.ts b/explainers/naval-drone/src/scene/drawnLabels.test.ts new file mode 100644 index 00000000..8c11adf4 --- /dev/null +++ b/explainers/naval-drone/src/scene/drawnLabels.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; +import { boatAt, jetAt, planingAt, seaAt } from '../model'; +import { DEFAULT_VIEW } from '../state'; +import { DrawnLabels } from './drawnLabels'; +import type { DrawnSource } from './drawnLabels'; + +const ALL = new Set([ + 'satLink', + 'backupLink', + 'videoGhost', + 'missileRails', + 'companions', + 'bowWave', + 'spray', + 'wettedLength', + 'jetStream', + 'hull', +]); + +function source(knots: number, changes: Partial = {}): DrawnSource { + return { + view: DEFAULT_VIEW, + link: { mode: 'satellite', ghost: null }, + fit: 'standard', + companions: [], + planing: planingAt(knots), + boat: { ...boatAt(60), knots }, + wettedBar: false, + jet: jetAt(knots / 42, knots, 0, false), + ...changes, + }; +} + +function shown(changes: DrawnSource): ReadonlySet { + const policy = { setWanted: vi.fn() }; + const labels = new DrawnLabels(policy); + labels.setWanted(ALL, ALL); + labels.follow(changes); + return policy.setWanted.mock.lastCall?.[0] as ReadonlySet; +} + +describe('drawn labels', () => { + it('hides the labels of what the scene does not draw at rest', () => { + expect([...shown(source(0))]).toEqual(['hull']); + }); + + it('shows the bow wave below planing and the spray from the hump on', () => { + expect(shown(source(11))).toContain('bowWave'); + expect(shown(source(11))).toContain('spray'); + expect(shown(source(22))).not.toContain('bowWave'); + expect(shown(source(22))).toContain('spray'); + expect(shown(source(22))).toContain('jetStream'); + }); + + it('labels the beam of the link in use while the links are shown', () => { + const links = { ...DEFAULT_VIEW, links: true }; + expect(shown(source(22, { view: links }))).toContain('satLink'); + const backup = shown(source(22, { view: links, link: { mode: 'backup', ghost: null } })); + expect(backup).toContain('backupLink'); + expect(backup).not.toContain('satLink'); + expect(shown(source(22, { link: { mode: 'satellite', ghost: null } }))).not.toContain( + 'satLink', + ); + }); + + it('labels the ghost, the rails, the companions and the wetted bar when drawn', () => { + const drawn = shown( + source(42, { + link: { mode: 'satellite', ghost: { position: [0, 0, 0], heading: 0 } }, + fit: 'missile', + companions: [{ position: [0, 0, 0], heading: 0 }], + wettedBar: true, + sea: seaAt('smooth'), + } as Partial), + ); + ['videoGhost', 'missileRails', 'companions', 'wettedLength'].forEach((part) => + expect(drawn).toContain(part), + ); + }); + + it('publishes again only when a drawn part changes', () => { + const policy = { setWanted: vi.fn() }; + const labels = new DrawnLabels(policy); + labels.setWanted(ALL, ALL); + labels.follow(source(22)); + const calls = policy.setWanted.mock.calls.length; + labels.follow(source(23)); + expect(policy.setWanted.mock.calls).toHaveLength(calls); + labels.follow(source(10)); + expect(policy.setWanted.mock.calls).toHaveLength(calls + 1); + }); +}); diff --git a/explainers/naval-drone/src/scene/drawnLabels.ts b/explainers/naval-drone/src/scene/drawnLabels.ts new file mode 100644 index 00000000..9faf0da6 --- /dev/null +++ b/explainers/naval-drone/src/scene/drawnLabels.ts @@ -0,0 +1,67 @@ +import type { LabelPolicy } from '@core/scene/presetBinder'; +import type { AssemblyState, PartId } from '../ids'; + +export type DrawnSource = Pick< + AssemblyState, + 'view' | 'link' | 'fit' | 'companions' | 'planing' | 'boat' | 'wettedBar' | 'jet' +>; + +type DrawnTest = (state: DrawnSource) => boolean; + +const NO_PARTS: ReadonlySet = new Set(); +const MOVING_KNOTS = 0.5; + +const DRAWN_WHEN: Readonly>> = { + satLink: ({ view, link }) => view.links && link.mode === 'satellite', + backupLink: ({ view, link }) => view.links && link.mode === 'backup', + videoGhost: ({ link }) => link.ghost !== null, + missileRails: ({ fit }) => fit === 'missile', + companions: ({ companions }) => companions.length > 0, + bowWave: ({ planing, boat }) => planing.mode !== 'planing' && boat.knots > MOVING_KNOTS, + spray: ({ planing }) => planing.mode !== 'floating', + wettedLength: ({ wettedBar }) => wettedBar, + jetStream: ({ jet }) => jet.flow > 0, +}; + +const DRAWN_TESTS = Object.entries(DRAWN_WHEN) as readonly [PartId, DrawnTest][]; + +export class DrawnLabels implements LabelPolicy { + private readonly policy: LabelPolicy; + private readonly hidden = new Set(); + private wanted = NO_PARTS; + private pinned = NO_PARTS; + + constructor(policy: LabelPolicy) { + this.policy = policy; + } + + setWanted(wanted: ReadonlySet, pinned: ReadonlySet): void { + this.wanted = wanted; + this.pinned = pinned; + this.publish(); + } + + follow(state: DrawnSource): void { + if (this.hideUndrawn(state)) this.publish(); + } + + private hideUndrawn(state: DrawnSource): boolean { + let changed = false; + for (const [part, isDrawn] of DRAWN_TESTS) { + const hidden = !isDrawn(state); + if (hidden === this.hidden.has(part)) continue; + if (hidden) this.hidden.add(part); + else this.hidden.delete(part); + changed = true; + } + return changed; + } + + private publish(): void { + this.policy.setWanted(this.drawn(this.wanted), this.drawn(this.pinned)); + } + + private drawn(parts: ReadonlySet): ReadonlySet { + return new Set([...parts].filter((part) => !this.hidden.has(part))); + } +} diff --git a/explainers/naval-drone/src/scene/index.ts b/explainers/naval-drone/src/scene/index.ts new file mode 100644 index 00000000..11d06b2f --- /dev/null +++ b/explainers/naval-drone/src/scene/index.ts @@ -0,0 +1,50 @@ +import { createLabelVisibility } from '@core/scene/presetBinder'; +import type { SceneOptions, SceneShell } from '@core/scene/shell'; +import type { PartId } from '../ids'; +import type { NavalDroneStore } from '../state'; +import { THEME } from '../theme'; +import { bindStore } from './bindings'; +import { NavalDroneController } from './controller'; +import { LABEL_PRIORITY } from './partInfo'; + +const UNDIMMED: readonly PartId[] = [ + 'jetStream', + 'bowWave', + 'spray', + 'wake', + 'wettedLength', + 'videoGhost', + 'satLink', + 'backupLink', +]; + +const HAZE = { near: 400, far: 5000 } as const; +const CAMERA = { near: 0.05, far: 9000, minDistance: 1, maxDistance: 2500 } as const; +const HIGHLIGHT_DIM = { saturation: 0.6, brightness: 0.72, emissive: 0.5 } as const; + +export const SCENE_OPTIONS: SceneOptions = { + background: THEME.haze, + fog: { color: THEME.haze, near: HAZE.near, far: HAZE.far }, + stage: false, + camera: { + near: CAMERA.near, + far: CAMERA.far, + maxPolarAngle: Math.PI / 2, + distance: { min: CAMERA.minDistance, max: CAMERA.maxDistance }, + }, + gaugeSide: 'top', + highlight: { dim: HIGHLIGHT_DIM, undimmed: UNDIMMED }, +}; + +export function mountNavalDroneScene(shell: SceneShell, store: NavalDroneStore): () => void { + const navalDrone = new NavalDroneController(shell); + const removeFrame = shell.onFrame((deltaSeconds) => navalDrone.update(deltaSeconds)); + const labelVisibility = createLabelVisibility(shell, LABEL_PRIORITY); + const unbind = bindStore(store, { navalDrone, labelVisibility, ...shell }); + return () => { + removeFrame(); + labelVisibility.dispose(); + unbind(); + navalDrone.dispose(); + }; +} diff --git a/explainers/naval-drone/src/scene/partInfo.test.ts b/explainers/naval-drone/src/scene/partInfo.test.ts new file mode 100644 index 00000000..e1501df4 --- /dev/null +++ b/explainers/naval-drone/src/scene/partInfo.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { PART_IDS } from '../ids'; +import { PRESETS } from '../state'; +import { LABEL_PRIORITY, PART_INFO } from './partInfo'; + +describe('part labels', () => { + it('names every part from its locale key', () => { + PART_IDS.forEach((id) => expect(PART_INFO[id].labelKey).toBe(`parts.${id}`)); + expect(Object.keys(PART_INFO)).toHaveLength(PART_IDS.length); + }); + + it('ranks every part exactly once, the impeller first and the hull last', () => { + expect([...LABEL_PRIORITY].sort()).toEqual([...PART_IDS].sort()); + expect(LABEL_PRIORITY[0]).toBe('impeller'); + expect(LABEL_PRIORITY.at(-1)).toBe('hull'); + }); + + it('labels the pump, the insides and the backup link on the left', () => { + ( + [ + 'waterjet', + 'engine', + 'fuelTanks', + 'electronicsBay', + 'intake', + 'duct', + 'driveShaft', + 'impeller', + 'stator', + 'wake', + 'jetStream', + 'wettedLength', + 'backupPanel', + 'backupLink', + 'groundStation', + 'companions', + 'shipRadar', + 'hull', + ] as const + ).forEach((id) => expect(PART_INFO[id].side, id).toBe('left')); + (['nozzle', 'steeringNozzle', 'satLink', 'satellite', 'ship', 'cameraDome'] as const).forEach( + (id) => expect(PART_INFO[id].side, id).toBe('right'), + ); + }); + + it('splits the labels of the busy chapters across both sides', () => { + (['overview', 'jet', 'link'] as const).forEach((id) => { + const sides = PRESETS[id].labels.map((part) => PART_INFO[part].side); + expect(sides, id).toContain('left'); + expect(sides, id).toContain('right'); + }); + }); +}); diff --git a/explainers/naval-drone/src/scene/partInfo.ts b/explainers/naval-drone/src/scene/partInfo.ts new file mode 100644 index 00000000..cee0331c --- /dev/null +++ b/explainers/naval-drone/src/scene/partInfo.ts @@ -0,0 +1,71 @@ +import type { PartInfo } from '@core/explainer'; +import type { LabelSide } from '@core/scene/labelLayout'; +import { PART_IDS } from '../ids'; +import type { PartId } from '../ids'; + +const LEFT_PARTS: ReadonlySet = new Set([ + 'waterjet', + 'engine', + 'fuelTanks', + 'electronicsBay', + 'intake', + 'duct', + 'driveShaft', + 'impeller', + 'stator', + 'wake', + 'jetStream', + 'wettedLength', + 'backupPanel', + 'backupLink', + 'groundStation', + 'companions', + 'shipRadar', + 'hull', +]); + +function labelSide(id: PartId): LabelSide { + return LEFT_PARTS.has(id) ? 'left' : 'right'; +} + +export const PART_INFO: Record = Object.fromEntries( + PART_IDS.map((id) => [id, { labelKey: `parts.${id}`, side: labelSide(id) }]), +) as Record; + +export const LABEL_PRIORITY: readonly PartId[] = [ + 'impeller', + 'steeringNozzle', + 'reverseBucket', + 'nozzle', + 'stator', + 'intake', + 'duct', + 'driveShaft', + 'jetStream', + 'wettedLength', + 'spray', + 'bowWave', + 'wake', + 'chines', + 'sprayRails', + 'cameraDome', + 'bowCamera', + 'starlinkPanels', + 'backupPanel', + 'satLink', + 'backupLink', + 'videoGhost', + 'satellite', + 'backupSatellite', + 'groundStation', + 'payloadBay', + 'engine', + 'fuelTanks', + 'electronicsBay', + 'waterjet', + 'missileRails', + 'ship', + 'shipRadar', + 'companions', + 'hull', +]; diff --git a/explainers/naval-drone/src/scene/viewFit.test.ts b/explainers/naval-drone/src/scene/viewFit.test.ts new file mode 100644 index 00000000..b7425365 --- /dev/null +++ b/explainers/naval-drone/src/scene/viewFit.test.ts @@ -0,0 +1,73 @@ +import { Vector3 } from 'three'; +import { describe, expect, it } from 'vitest'; +import { toRadians } from '@core/math'; +import { + boatToWorld, + direction, + fitDistance, + fitsView, + lookUpLimits, + nearestFit, + polarOf, +} from './viewFit'; + +const SLOPES = { vertical: 0.2, horizontal: 0.34 }; +const LEVEL = { position: [10, 0.5, -4] as const, heading: 0, trim: 0 }; + +describe('view fitting', () => { + it('turns boat points into the world with the heading, the trim only when pitched', () => { + expect(boatToWorld(LEVEL, [1, 0, 0], true).toArray()).toEqual([11, 0.5, -4]); + const turned = boatToWorld({ ...LEVEL, heading: Math.PI / 2 }, [1, 0, 0], false); + expect(turned.x).toBeCloseTo(10, 9); + expect(turned.z).toBeCloseTo(-3, 9); + const trimmed = { ...LEVEL, trim: toRadians(10) }; + expect(boatToWorld(trimmed, [-2, 0, 0], true).y).toBeLessThan(0.5); + expect(boatToWorld(trimmed, [-2, 0, 0], false).y).toBe(0.5); + }); + + it('points a direction from its azimuth and elevation', () => { + const up = direction(0, toRadians(30)); + expect(up.y).toBeCloseTo(0.5, 9); + expect(direction(Math.PI / 2, 0).z).toBeCloseTo(1, 9); + }); + + it('fits a width across the stage', () => { + expect(fitDistance(6.8, SLOPES)).toBeCloseTo(10, 9); + }); + + it('keeps points in front of the camera and inside the frame', () => { + const pose = { position: new Vector3(0, 0, 0), target: new Vector3(10, 0, 0) }; + expect(fitsView(pose, [new Vector3(10, 1, 2)], SLOPES, 1)).toBe(true); + expect(fitsView(pose, [new Vector3(10, 3, 0)], SLOPES, 1)).toBe(false); + expect(fitsView(pose, [new Vector3(-10, 0, 0)], SLOPES, 1)).toBe(false); + }); + + it('finds the nearest distance that fits, or the farthest allowed', () => { + const poseAt = (distance: number) => ({ + position: new Vector3(-distance, 0, 0), + target: new Vector3(0, 0, 0), + }); + const point = [new Vector3(0, 2, 0)]; + const near = nearestFit(poseAt, (pose) => fitsView(pose, point, SLOPES, 1), { + min: 1, + max: 100, + }); + expect(near.position.x).toBeCloseTo(-10, 3); + const far = nearestFit(poseAt, () => false, { min: 1, max: 100 }); + expect(far.position.x).toBeCloseTo(-100, 6); + }); + + it('lets a camera look up only as far as its view asks, and never under the sea', () => { + const level = { position: new Vector3(-10, 5, 0), target: new Vector3(0, 0, 0) }; + expect(lookUpLimits(level, Math.PI / 2, 1)).toEqual({ + maxPolarAngle: Math.PI / 2, + maxDistance: null, + }); + const upward = { position: new Vector3(-40, 4, 0), target: new Vector3(0, 12, 0) }; + const limits = lookUpLimits(upward, Math.PI / 2, 1); + expect(limits.maxPolarAngle).toBeGreaterThan(polarOf(upward)); + expect(limits.maxPolarAngle).toBeLessThan(polarOf(upward) + 0.01); + const lowest = 12 + (limits.maxDistance ?? 0) * Math.cos(limits.maxPolarAngle); + expect(lowest).toBeCloseTo(1, 9); + }); +}); diff --git a/explainers/naval-drone/src/scene/viewFit.ts b/explainers/naval-drone/src/scene/viewFit.ts new file mode 100644 index 00000000..092571de --- /dev/null +++ b/explainers/naval-drone/src/scene/viewFit.ts @@ -0,0 +1,93 @@ +import { Euler, Vector3 } from 'three'; +import type { CameraPose } from '@core/scene/frameBox'; +import type { FramingSlopes } from '@core/scene/lens'; +import type { Point } from '../ids'; +import { BOAT_EULER_ORDER } from './pose'; + +export interface BoatFrame { + position: Point; + heading: number; + trim: number; +} + +export interface DistanceRange { + min: number; + max: number; +} + +export interface PolarLimits { + maxPolarAngle: number; + maxDistance: number | null; +} + +const UP = new Vector3(0, 1, 0); +const SOLVE_STEPS = 32; +const POLAR_SLACK = 0.005; + +export function vectorOf(point: Point): Vector3 { + return new Vector3(point[0], point[1], point[2]); +} + +export function direction(azimuth: number, elevation: number): Vector3 { + const level = Math.cos(elevation); + return new Vector3(level * Math.cos(azimuth), Math.sin(elevation), level * Math.sin(azimuth)); +} + +export function boatToWorld(frame: BoatFrame, point: Point, pitched: boolean): Vector3 { + const rotation = new Euler(0, -frame.heading, pitched ? frame.trim : 0, BOAT_EULER_ORDER); + return vectorOf(point).applyEuler(rotation).add(vectorOf(frame.position)); +} + +export function fitDistance(width: number, slopes: FramingSlopes): number { + return width / (2 * slopes.horizontal); +} + +export function fitsView( + pose: CameraPose, + points: readonly Vector3[], + slopes: FramingSlopes, + fill: number, +): boolean { + const forward = pose.target.clone().sub(pose.position).normalize(); + const right = forward.clone().cross(UP).normalize(); + const up = right.clone().cross(forward); + return points.every((point) => { + const offset = point.clone().sub(pose.position); + const depth = offset.dot(forward); + if (depth <= 0) return false; + const across = Math.abs(offset.dot(right)) / depth; + const rise = Math.abs(offset.dot(up)) / depth; + return across <= slopes.horizontal * fill && rise <= slopes.vertical * fill; + }); +} + +export function nearestFit( + poseAt: (distance: number) => CameraPose, + fits: (pose: CameraPose) => boolean, + range: DistanceRange, +): CameraPose { + if (fits(poseAt(range.min))) return poseAt(range.min); + let low = range.min; + let high = range.max; + for (let step = 0; step < SOLVE_STEPS; step += 1) { + const middle = (low + high) / 2; + if (fits(poseAt(middle))) high = middle; + else low = middle; + } + return poseAt(high); +} + +export function polarOf(pose: CameraPose): number { + return pose.position.clone().sub(pose.target).angleTo(UP); +} + +export function lookUpLimits( + pose: CameraPose, + defaultMaxPolar: number, + seaClearance: number, +): PolarLimits { + const polar = polarOf(pose); + if (polar <= defaultMaxPolar) return { maxPolarAngle: defaultMaxPolar, maxDistance: null }; + const limit = polar + POLAR_SLACK; + return { maxPolarAngle: limit, maxDistance: (pose.target.y - seaClearance) / -Math.cos(limit) }; +} From 4a93c9eb6b30e2128a569c6676b1a62b04c59bbc Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:25:33 +0200 Subject: [PATCH 06/99] feat(naval-drone): add the entry, styles, manifest and placeholder images --- explainers/naval-drone/explainer.json | 12 ++ explainers/naval-drone/public/cover.webp | Bin 0 -> 2680 bytes .../naval-drone/public/social/og-image.png | Bin 0 -> 113360 bytes explainers/naval-drone/src/index.ts | 28 +++ explainers/naval-drone/src/style.css | 196 ++++++++++++++++++ scripts/cards/naval-drone.html | 163 +++++++++++++++ 6 files changed, 399 insertions(+) create mode 100644 explainers/naval-drone/explainer.json create mode 100644 explainers/naval-drone/public/cover.webp create mode 100644 explainers/naval-drone/public/social/og-image.png create mode 100644 explainers/naval-drone/src/index.ts create mode 100644 explainers/naval-drone/src/style.css create mode 100644 scripts/cards/naval-drone.html diff --git a/explainers/naval-drone/explainer.json b/explainers/naval-drone/explainer.json new file mode 100644 index 00000000..0ff9f1e7 --- /dev/null +++ b/explainers/naval-drone/explainer.json @@ -0,0 +1,12 @@ +{ + "slug": "naval-drone", + "tags": ["vehicles", "physics", "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 grey uncrewed boat skimming the sea at dawn, beside the title How a naval drone works" + } +} diff --git a/explainers/naval-drone/public/cover.webp b/explainers/naval-drone/public/cover.webp new file mode 100644 index 0000000000000000000000000000000000000000..70662e8d09c918b42f3adb9214beae20103af9e4 GIT binary patch literal 2680 zcma)62|Sd07k;hR8nR4$cd|5mb`!EQ_fA>EwI+%ol1PJ}owCHW?;-mj#_lqz@5+8d zNhXF|`YJ>7kuAwGwwXJ2ihkdDfB*NKcRS~Qp7XqqiLREG8xsInXu^#y87T=kP-6B@ zAcso)AsOq!!`Jo8A&f0el8avR1Io-BnbDjfftHg~gfb7% z+Bq&t>gog8MzIzMDbvK}p^|(hP60g) zhGqgsQn_z}hFN~i8)ngQGPQpDc42y{$xSc1E@p-vA}>}oGqdU)fKU?Fw9>?n^N<=g zBnejjM2lQKyQ;W?c!!2XLzTz(p3_%`df|j?gj4#6MDAlc-P)Up-SM50UTHG6G>9@C z=>vu&8^n2SjZRAh$~9SAuWmgPxz-0-#wN-GmA1->E1p;5N|`8_eV z;M(N{G2_S0d@<}ctI%~M1-BAP%<3=1i^*ZU79~zvHlv_^&fL;TXQK6|-`&xB<_F&; zPexz9l8q{e@BQHgstaRUje%rKJPhC4KzUX24i}4|pf-ex=O-N6Lbbj1#qeMqn_A@O zJ?^Vq92?mIRY(%wV$8XI8BMG)1b+HJMjH~B&F&|)J&QcSC7@)Kh`)A$E}H#kNpfBz zH;v8RG>=N({uQb)mIoT`9Z%v9u9j;_&UXn6<c_?WD&=*{M0}QD{>1lJvO- z?<1CZ$-vR~tK>g=iw-oHAL0Fac6vO>kpsVSR;R+PA&ldztnZ!EuXvfJoIt%HUmn^N zA*0!tdYy>~?piYcru{tZDiKYhk6q`)Imf9p;M#mtPRr#ZEl{E9(~PGT_fbCT_ZRtc zRK3kjxH6~-FXO7CuECFrEcQr=m^Z6Q%pYQOL znp|Kv*$5F%J-aU+2MNEYOMSk6yrmppv8OAQFV(*LQ`Rv1E&tfxk&8%`b+vm#A&}?y zRSRa5RRr5_HFq91g)F_%=8!@d?ZR;3Z*cj!oSul-i7NvW0!3@o+Hv@8aR~R=Qa;iv zg&{@<=I zDr-wckC|#V^JvqyiXI8Xra+S_|G1taqMsq_@ZuXMoCuJ}wJEExab#|jzdZSq25u>N ztP21Djz9@00MIh8uZ* z6D3<&zOM1pJrK+dvLC_F%3-Gid2E08+eirQX2-&6<}5>bColoNR{C%kv;KV>)kSht zm<@|g7WZnKD@3c5))@~N6vUUga(S6L6_&*YYH6I|=SYa`d}co?3-e(hT;8m_ZQ3HG z!2rN0YPoobfyg`li&KNuHjNaglrEu2pSfl&M=||wioJ?SJ#l(OpD_d~LfgusBKlxA z8315SSYKBFR1&nv-#I|_9gvckc{9xwaiR58j=Or1&B7OKXaTAPLrO_N_I@Xdf*os# z8|C66(eO=s43FgHnrq)VwZ+b0$_J@)8PuvhM6{TUx&WhMQ(Xso-f9q=U=T=LIP8WJ zupNM?bXQ?oj!>oDb)W%9`BEVf6ZM(JgJ-IRKcGghO4zrO*S^^@M)g1X^)R6}I%EH7 zZ{MHRAK4K&IM|C~1;a@%JT;hym-=+!lZkF9d1VrzWH8&_60CL0pc$NFbmk4UD$gt= zgyw7*aK5CvVi`eLU!-|*_xcj6MxB>d9XS`}{kglT75ygOW)$q`64tTS=)ercFzcSq z-M}UF56N*k+o#o1y<}cHS1KAA2^%rPc! zGHZ3k+8B5*fax{hE$`{)6hj(VN09WKp(|;6S7$|_ULGe+#@)}}D^E;LSB;7VUm2av zjKx!sr9h0-eyeM~Z$`7UEe|-UZS@($li665oi-1RXPZ;lF}sAza$RNnRw44zp2DLn zP4Mv~JK|wc{_XRPq77mqUDkX`ySV{?p%GvIsIc5h8zkQby_E26@|I~}w33DHn+uuWq?=_CLR8QRB_fAA37)*(H P6|B4V-YYA!1%Q75k$5Lr literal 0 HcmV?d00001 diff --git a/explainers/naval-drone/public/social/og-image.png b/explainers/naval-drone/public/social/og-image.png new file mode 100644 index 0000000000000000000000000000000000000000..e7d815edc9a992aaf064574eb98f4d326ff0b9a9 GIT binary patch literal 113360 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 j6_o^hcjxb9>L%XtmxZ#spcYHuCwPi-s({ + id: 'naval-drone', + timeline: NAVAL_DRONE_TIMELINE, + presets: PRESETS, + parts: PART_INFO, + createStore: () => createNavalDroneStore(), + dock: { choices: NAVAL_DRONE_CHOICES, toggles: VIEW_TOGGLES }, + readouts: NAVAL_DRONE_READOUTS, + actions: CHAPTER_ACTIONS, + scene: SCENE_OPTIONS, + mountScene: mountNavalDroneScene, + mountUi: mountNavalDroneUi, +}); diff --git a/explainers/naval-drone/src/style.css b/explainers/naval-drone/src/style.css new file mode 100644 index 00000000..390044a9 --- /dev/null +++ b/explainers/naval-drone/src/style.css @@ -0,0 +1,196 @@ +:root { + --launch: #7fb3d5; + --hump: #ffb347; + --cruise: #6fd38a; + --sprint: #ff7a59; + --arrival: #b48cff; + --floating: #5fa8ff; + --planing: #4fd1c5; + --sat-link: #5fa8ff; + --backup-link: #ffd24c; + --sea: #2b6f8f; + --hull: #9aa3ab; +} + +.section-readouts, +.range-widget:has([data-control='trial-knots']), +.range-widget:has([data-control='throttle']), +.range-widget:has([data-control='video-delay']), +.range-widget:has([data-control='radar-height']) { + --readout-min-width: 8rem; +} + +.section-readouts { + margin-block: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.section-readouts:has([data-readout='overview-mode']) { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.section-readouts:has([data-readout='jet-push']), +.section-readouts:has([data-readout='fleet-carries']), +.section-readouts > div:has([data-readout='link-boat']) { + grid-column: 1 / -1; +} + +.section-readouts:has([data-readout='jet-push']), +.section-readouts:has([data-readout='fleet-carries']) { + grid-template-columns: minmax(0, 1fr); +} + +[data-control='trial-knots'] { + --range-color: var(--planing); +} + +[data-control='throttle'] { + --range-color: var(--hump); +} + +[data-control='video-delay'] { + --range-color: var(--sat-link); +} + +[data-control='radar-height'] { + --range-color: var(--sprint); +} + +[data-readout='horizon-radar'], +[data-readout='horizon-minutes'] { + color: var(--sprint); +} + +[data-readout='link-now'], +[data-readout='link-top'] { + color: var(--sat-link); +} + +.horizon-view { + display: grid; + justify-items: center; + gap: 8px; + margin: 4px 0 16px; +} + +.horizon-view canvas { + display: block; + width: 100%; + height: auto; + border-radius: var(--radius-small); +} + +.horizon-view figcaption { + color: var(--muted); + font-size: 0.8125rem; + text-align: center; +} + +.signal-path__title { + margin: 24px 0 8px; + color: var(--text); + font-weight: 600; +} + +.signal-path { + display: grid; + gap: 8px; + margin: 0 0 24px; + padding: 0; + list-style: none; + counter-reset: signal-step; +} + +.signal-path li { + position: relative; + padding: 10px 14px 10px 44px; + border: 1px solid var(--line); + border-radius: var(--radius-small); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--sat-link) 8%, transparent), + transparent + ); + counter-increment: signal-step; +} + +.signal-path li::before { + content: counter(signal-step); + position: absolute; + top: 10px; + left: 14px; + width: 20px; + color: var(--sat-link); + font-family: var(--font-mono); + font-weight: 500; + text-align: center; +} + +.chip[data-action='moment'][data-value='hullSpeed'], +.chip[data-action='moment'][data-value='humpPeak'], +.chip[data-action='moment'][data-value='onPlane'] { + --tone: var(--hump); +} + +.chip[data-action='moment'][data-value='cruiseSpeed'] { + --tone: var(--cruise); +} + +.chip[data-action='moment'][data-value='throttleUp'], +.chip[data-action='moment'][data-value='topSpeed'] { + --tone: var(--sprint); +} + +.chip[data-action='moment'][data-value='alongside'] { + --tone: var(--arrival); +} + +.chip[data-action='speedMark'][data-value='hullSpeed'] { + --tone: var(--floating); +} + +.chip[data-action='speedMark'][data-value='hump'] { + --tone: var(--hump); +} + +.chip[data-action='speedMark'][data-value='planing'], +.chip[data-action='speedMark'][data-value='cruise'], +.chip[data-action='speedMark'][data-value='top'] { + --tone: var(--planing); +} + +.chip[data-action='helm'] { + --tone: var(--hump); +} + +.chip[data-action='linkMode'][data-value='satellite'] { + --tone: var(--sat-link); +} + +.chip[data-action='linkMode'][data-value='backup'] { + --tone: var(--backup-link); +} + +.chip[data-action='linkMode'][data-value='lost'] { + --tone: var(--muted); +} + +.chip[data-action='seaState'] { + --tone: var(--sea); +} + +.chip[data-action='fit'] { + --tone: var(--hull); +} + +@media (pointer: coarse) { + .chip[data-action] { + height: 44px; + } +} + +@media (max-width: 480px) { + .section-readouts:has([data-readout='overview-mode']) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/scripts/cards/naval-drone.html b/scripts/cards/naval-drone.html new file mode 100644 index 00000000..b161646d --- /dev/null +++ b/scripts/cards/naval-drone.html @@ -0,0 +1,163 @@ + + + + + How a naval drone works: social card + + + + + + +
+ +
+
+

+ + Naval drone +

+

How a naval drone works

+

+ A small grey boat with no one aboard, skimming the sea and steered from far away over a + satellite link. +

+
    +
  1. Boat
  2. +
  3. Hull
  4. +
  5. Jet
  6. +
  7. Link
  8. +
  9. Horizon
  10. +
  11. Fleet
  12. +
+
+

whatmakesittick.github.io/naval-drone

+ + + From 06b13a6df8541765add99365380e516953e8f2b4 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:40:20 +0200 Subject: [PATCH 07/99] fix(naval-drone): keep the horizon labels clear of the line of sight, the sea and each other --- .../naval-drone/src/ui/canvasLabels.test.ts | 56 +++ explainers/naval-drone/src/ui/canvasLabels.ts | 92 +++++ .../src/ui/horizonView.dom.test.ts | 106 +++--- explainers/naval-drone/src/ui/horizonView.ts | 359 ++++++++++-------- 4 files changed, 411 insertions(+), 202 deletions(-) create mode 100644 explainers/naval-drone/src/ui/canvasLabels.test.ts create mode 100644 explainers/naval-drone/src/ui/canvasLabels.ts diff --git a/explainers/naval-drone/src/ui/canvasLabels.test.ts b/explainers/naval-drone/src/ui/canvasLabels.test.ts new file mode 100644 index 00000000..c7f0d92e --- /dev/null +++ b/explainers/naval-drone/src/ui/canvasLabels.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { intersects, placeText, segmentBoxes, shiftInto, textBox } from './canvasLabels'; +import type { Box } from './canvasLabels'; + +const METRICS = { font: 10, ascent: 0.8, descent: 0.2 }; +const BOUNDS: Box = { left: 0, top: 0, right: 200, bottom: 100 }; + +describe('canvas labels', () => { + it('boxes a text around its anchor for each alignment', () => { + expect(textBox(40, { x: 50, baseline: 30, align: 'left' }, METRICS)).toEqual({ + left: 50, + right: 90, + top: 22, + bottom: 32, + }); + expect(textBox(40, { x: 50, baseline: 30, align: 'center' }, METRICS).left).toBe(30); + expect(textBox(40, { x: 50, baseline: 30, align: 'right' }, METRICS).left).toBe(10); + }); + + it('slides a label back inside the canvas sideways', () => { + const anchor = { x: 190, baseline: 30, align: 'left' as const }; + const placed = shiftInto(textBox(40, anchor, METRICS), anchor, BOUNDS); + expect(placed.box.right).toBe(200); + expect(placed.x).toBe(160); + }); + + it('takes the first free spot inside the canvas and marks it taken', () => { + const taken: Box[] = [{ left: 0, top: 20, right: 100, bottom: 40 }]; + const spot = placeText( + 40, + [ + { x: 50, baseline: 30, align: 'center' }, + { x: 50, baseline: 4, align: 'center' }, + { x: 50, baseline: 60, align: 'center' }, + ], + taken, + BOUNDS, + METRICS, + ); + expect(spot.baseline).toBe(60); + expect(taken).toHaveLength(2); + }); + + it('falls back to the first spot when none is free', () => { + const spot = placeText(40, [{ x: 50, baseline: 30, align: 'left' }], [BOUNDS], BOUNDS, METRICS); + expect(spot.baseline).toBe(30); + }); + + it('covers a sloping line with small boxes', () => { + const boxes = segmentBoxes(0, 100, 25, (x) => x / 2, 1); + expect(boxes).toHaveLength(4); + expect(boxes[1]).toEqual({ left: 25, right: 50, top: 11.5, bottom: 26 }); + expect(intersects(boxes[0], { left: 10, right: 12, top: 5, bottom: 6 })).toBe(true); + expect(intersects(boxes[0], { left: 10, right: 12, top: 40, bottom: 50 })).toBe(false); + }); +}); diff --git a/explainers/naval-drone/src/ui/canvasLabels.ts b/explainers/naval-drone/src/ui/canvasLabels.ts new file mode 100644 index 00000000..cf1b692b --- /dev/null +++ b/explainers/naval-drone/src/ui/canvasLabels.ts @@ -0,0 +1,92 @@ +import { clamp } from '@core/math'; + +export interface Box { + left: number; + top: number; + right: number; + bottom: number; +} + +export type TextAlign = 'left' | 'right' | 'center'; + +export interface Anchor { + x: number; + baseline: number; + align: TextAlign; +} + +export interface Placed extends Anchor { + box: Box; +} + +export interface TextMetrics { + font: number; + ascent: number; + descent: number; +} + +export function intersects(a: Box, b: Box): boolean { + return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom; +} + +function isInside(box: Box, bounds: Box): boolean { + return box.top >= bounds.top && box.bottom <= bounds.bottom; +} + +function alignOffset(width: number, align: TextAlign): number { + return { left: 0, center: width / 2, right: width }[align]; +} + +export function textBox(width: number, anchor: Anchor, metrics: TextMetrics): Box { + const left = anchor.x - alignOffset(width, anchor.align); + return { + left, + right: left + width, + top: anchor.baseline - metrics.font * metrics.ascent, + bottom: anchor.baseline + metrics.font * metrics.descent, + }; +} + +export function shiftInto(box: Box, anchor: Anchor, bounds: Box): Placed { + const shift = clamp(0, bounds.left - box.left, bounds.right - box.right); + return { + ...anchor, + x: anchor.x + shift, + box: { ...box, left: box.left + shift, right: box.right + shift }, + }; +} + +export function placeText( + width: number, + candidates: readonly Anchor[], + taken: Box[], + bounds: Box, + metrics: TextMetrics, +): Placed { + const placed = candidates.map((anchor) => + shiftInto(textBox(width, anchor, metrics), anchor, bounds), + ); + const free = placed.find( + (spot) => isInside(spot.box, bounds) && !taken.some((box) => intersects(box, spot.box)), + ); + const chosen = free ?? placed[0]; + taken.push(chosen.box); + return chosen; +} + +export function segmentBoxes( + from: number, + to: number, + step: number, + yAt: (x: number) => number, + slack: number, +): Box[] { + const boxes: Box[] = []; + for (let left = from; left < to; left += step) { + const right = Math.min(left + step, to); + const top = Math.min(yAt(left), yAt(right)); + const bottom = Math.max(yAt(left), yAt(right)); + boxes.push({ left, right, top: top - slack, bottom: bottom + slack }); + } + return boxes; +} diff --git a/explainers/naval-drone/src/ui/horizonView.dom.test.ts b/explainers/naval-drone/src/ui/horizonView.dom.test.ts index 5205ce9d..e6e82ced 100644 --- a/explainers/naval-drone/src/ui/horizonView.dom.test.ts +++ b/explainers/naval-drone/src/ui/horizonView.dom.test.ts @@ -2,25 +2,33 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { initI18n } from '@core/i18n'; import type { CanvasFrame } from '@core/ui/canvasSurface'; import en from '../../locales/en.json'; -import { RADAR_HEIGHT_M, radarLineOfSightKm, surfaceDropM } from '../model'; +import { + DETECTION_KM, + RADAR_HEIGHT_M, + detectionCovered, + radarLineOfSightKm, + surfaceDropM, +} from '../model'; +import { intersects } from './canvasLabels'; +import type { Box } from './canvasLabels'; import { AXIS_KM, TICKS_KM, horizonLayout, - intersects, + blockedBoxes, paintHorizon, - placeLabel, sightHeightM, surfaceY, xOfKm, yOfMetres, } from './horizonView'; -import type { Box, HorizonScene } from './horizonView'; +import type { HorizonScene } from './horizonView'; import { fill } from './testing'; const PHONE: CanvasFrame = { width: 358, height: 168, ratio: 1, fontFamily: 'sans-serif' }; const DESKTOP: CanvasFrame = { width: 640, height: 300, ratio: 1, fontFamily: 'sans-serif' }; -const CHAR_WIDTH = 6; +const CHAR_WIDTHS = [6, 8] as const; +let charWidth: number = CHAR_WIDTHS[0]; const { canvas } = en.horizon; type Align = 'left' | 'center' | 'right'; @@ -42,7 +50,9 @@ function recordingContext() { lineWidth: 1, textAlign: 'left' as Align, textBaseline: 'alphabetic', - measureText: (text: string) => ({ width: text.length * CHAR_WIDTH }), + measureText: (text: string) => ({ width: text.length * charWidth }), + strokeText: noop, + lineJoin: 'round', fillText: (text: string, x: number, y: number) => texts.push({ text, x, y, align: context.textAlign }), beginPath: noop, @@ -72,7 +82,7 @@ describe('horizon view layout', () => { expect(yOfMetres(layout, RADAR_HEIGHT_M.max)).toBeGreaterThan(layout.plot.top); expect(surfaceY(layout, AXIS_KM)).toBeLessThan(layout.plot.bottom); expect(layout.tickBaseline).toBeLessThan(height); - expect(layout.font).toBeGreaterThanOrEqual(11); + expect(layout.metrics.font).toBeGreaterThanOrEqual(11); }); }); @@ -89,30 +99,11 @@ describe('horizon view layout', () => { describe('horizon view labels', () => { beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); - it('takes the first free spot, inside the canvas', () => { - const { context } = recordingContext(); - const layout = horizonLayout(PHONE.width, PHONE.height); - const taken: Box[] = [{ left: 0, top: 40, right: 100, bottom: 60 }]; - const spot = placeLabel( - context, - 'Label', - [ - { x: 50, baseline: 55, align: 'center' }, - { x: 50, baseline: 90, align: 'center' }, - ], - taken, - layout, - ); - expect(spot.baseline).toBe(90); - expect(taken).toHaveLength(2); - const edge = placeLabel( - context, - 'Label', - [{ x: 0, baseline: 120, align: 'center' }], - [], - layout, + it('places the radar label first, the only one that may cross the detection mark', () => { + const labels = paint({ radarHeight: 20, seaState: 'slight' }).filter( + (drawn) => !drawn.text.endsWith(' km'), ); - expect(edge.box.left).toBeGreaterThanOrEqual(layout.bounds.left); + expect(labels[0].text).toBe(canvas.radar); }); it('draws every text from the locale, ticks at 0, 10, 20 and 30 km', () => { @@ -135,28 +126,45 @@ describe('horizon view labels', () => { expect(rough).toContain(canvas.boat); }); - it('keeps the labels apart at every radar height on a phone and a desktop', () => { - [PHONE, DESKTOP].forEach((frame) => - [5, 20, 35, 50].forEach((radarHeight) => { - const { context, texts } = recordingContext(); - paintHorizon(context, frame, { radarHeight, seaState: 'smooth' }); - const layout = horizonLayout(frame.width, frame.height); - const boxes = texts - .filter((drawn) => !drawn.text.endsWith(' km')) - .map((drawn) => labelBox(drawn, layout.font)); - boxes.forEach((box, index) => - boxes - .slice(index + 1) - .forEach((other) => expect(intersects(box, other), `${radarHeight} m`).toBe(false)), - ); - }), - ); - }); + it.each(CHAR_WIDTHS)( + 'keeps labels of %d px a letter apart and off the line and the sea', + (width) => { + charWidth = width; + [PHONE, DESKTOP].forEach((frame) => + [5, 8, 12, 20, 35, 50].forEach((radarHeight) => + (['smooth', 'moderate', 'rough'] as const).forEach((seaState) => { + const scene = { radarHeight, seaState }; + const { context, texts } = recordingContext(); + paintHorizon(context, frame, scene); + const layout = horizonLayout(frame.width, frame.height); + const blocked = blockedBoxes(layout, scene); + const labels = texts.filter((drawn) => !drawn.text.endsWith(' km')); + const boxes = labels.map((drawn) => labelBox(drawn, layout.metrics.font)); + boxes.forEach((box, index) => { + const where = `${frame.width} px, ${radarHeight} m, ${seaState}: ${labels[index].text}`; + expect(box.bottom, where).toBeLessThanOrEqual(layout.plot.bottom); + const marks = index === 0 ? blocked : [...blocked, detectionMark(layout, scene)]; + [...boxes.slice(index + 1), ...marks].forEach((other) => + expect(intersects(box, other), where).toBe(false), + ); + }); + }), + ), + ); + charWidth = CHAR_WIDTHS[0]; + }, + ); }); +function detectionMark(layout: ReturnType, scene: HorizonScene): Box { + if (!detectionCovered(scene.seaState)) return { left: 0, right: 0, top: 0, bottom: 0 }; + const x = xOfKm(layout, DETECTION_KM); + return { left: x - 1, right: x + 1, top: layout.plot.top, bottom: layout.plot.bottom }; +} + function labelBox(drawn: Drawn, font: number): Box { - const width = drawn.text.length * CHAR_WIDTH; + const width = drawn.text.length * charWidth; const offset = { left: 0, center: width / 2, right: width }[drawn.align]; const left = drawn.x - offset; - return { left, right: left + width, top: drawn.y - font * 0.78, bottom: drawn.y }; + return { left, right: left + width, top: drawn.y - font * 0.78, bottom: drawn.y + font * 0.24 }; } diff --git a/explainers/naval-drone/src/ui/horizonView.ts b/explainers/naval-drone/src/ui/horizonView.ts index df6031c3..14c3499a 100644 --- a/explainers/naval-drone/src/ui/horizonView.ts +++ b/explainers/naval-drone/src/ui/horizonView.ts @@ -14,6 +14,8 @@ import { surfaceDropM, } from '../model'; import { THEME } from '../theme'; +import { placeText, segmentBoxes, shiftInto, textBox } from './canvasLabels'; +import type { Anchor, Box, Placed, TextAlign, TextMetrics } from './canvasLabels'; import { formatCanvasKm } from './format'; export interface HorizonScene { @@ -21,15 +23,8 @@ export interface HorizonScene { seaState: SeaStateId; } -export interface Box { - left: number; - top: number; - right: number; - bottom: number; -} - export interface HorizonLayout { - font: number; + metrics: TextMetrics; bounds: Box; plot: Box; pxPerKm: number; @@ -37,15 +32,13 @@ export interface HorizonLayout { tickBaseline: number; } -interface Anchor { - x: number; - baseline: number; - align: 'left' | 'right' | 'center'; +interface LabelPainter { + context: CanvasRenderingContext2D; + layout: HorizonLayout; + taken: Box[]; } -interface Placed extends Anchor { - box: Box; -} +type Candidates = (width: number) => readonly Anchor[]; export const AXIS_KM = 35; export const TICKS_KM = [0, 10, 20, 30] as const; @@ -55,16 +48,19 @@ const SEA_MARGIN_M = 4; const SKY_TOP_M = RADAR_HEIGHT_M.max + MAST_HEADROOM_M; const SEA_FLOOR_M = -surfaceDropM(AXIS_KM) - SEA_MARGIN_M; const FONT = { min: 11, max: 13, perWidth: 0.02 } as const; -const SPACE = { pad: 6, gap: 4, shipGutter: 18, tick: 3 } as const; const TEXT = { ascent: 0.78, descent: 0.24 } as const; +const SPACE = { pad: 6, gap: 4, shipGutter: 18, tick: 3 } as const; const SHIP = { aft: 14, bow: 10, deckM: 4, minHullPx: 5, antenna: 5 } as const; const BOAT_ICON = { length: 10, height: 4 } as const; const WAVE = { pxPerMetre: 1.5, periodPx: 9, stepPx: 1.5 } as const; -const LINE = { surface: 1.5, sight: 1.8, mast: 1.5, tick: 1, dash: [4, 4] } as const; +const LINE = { surface: 1.5, sight: 1.8, mast: 1.5, tick: 1, halo: 3, dash: [4, 4] } as const; +const OBSTACLE = { stepPx: 12, slackPx: 2 } as const; const SEA_FILL_ALPHA = 0.32; -const LOS_START_SHARES = [0, 0.18, 0.36, 0.5, 0.62, 0.74] as const; -const DETECTION_LINES = [0, 1, 2] as const; +const SIGHT_SHARES = [0, 0.18, 0.36, 0.5, 0.62, 0.74] as const; +const SEA_ROWS = [0, 1, 2, 3] as const; const NOTE_ROWS = [0, 1, 2] as const; +const SKY_ROWS = [0, 1, 2] as const; +const TANGENT_SLOPE = 2 / RADAR_K; const COLORS = { sea: withAlpha(THEME.waterWall, SEA_FILL_ALPHA), @@ -75,11 +71,11 @@ const COLORS = { detection: THEME.hump, boat: THEME.panel, muted: THEME.muted, + halo: THEME.background, } as const; export function horizonLayout(width: number, height: number): HorizonLayout { const font = clamp(Math.round(width * FONT.perWidth), FONT.min, FONT.max); - const bounds = { left: SPACE.pad, top: SPACE.pad, right: width - SPACE.pad, bottom: height }; const tickBaseline = height - SPACE.pad - font * TEXT.descent; const plot = { left: SPACE.pad + SPACE.shipGutter, @@ -88,8 +84,8 @@ export function horizonLayout(width: number, height: number): HorizonLayout { bottom: tickBaseline - font * TEXT.ascent - SPACE.gap - SPACE.tick, }; return { - font, - bounds, + metrics: { font, ...TEXT }, + bounds: { left: SPACE.pad, top: SPACE.pad, right: width - SPACE.pad, bottom: plot.bottom }, plot, pxPerKm: (plot.right - plot.left) / AXIS_KM, pxPerMetre: (plot.bottom - plot.top) / (SKY_TOP_M - SEA_FLOOR_M), @@ -110,100 +106,55 @@ export function surfaceY(layout: HorizonLayout, km: number): number { } export function sightHeightM(radarHeight: number, km: number): number { - return radarHeight - ((2 * Math.sqrt(radarHeight)) / RADAR_K) * km; + return radarHeight - TANGENT_SLOPE * Math.sqrt(radarHeight) * km; } function kmOfX(layout: HorizonLayout, x: number): number { return (x - layout.plot.left) / layout.pxPerKm; } -function sightY(layout: HorizonLayout, radarHeight: number, x: number): number { +export function sightY(layout: HorizonLayout, radarHeight: number, x: number): number { return yOfMetres(layout, sightHeightM(radarHeight, kmOfX(layout, x))); } -export function intersects(a: Box, b: Box): boolean { - return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom; +function surfaceYAtX(layout: HorizonLayout, x: number): number { + return surfaceY(layout, kmOfX(layout, x)); } -function isInside(box: Box, bounds: Box): boolean { - return box.top >= bounds.top && box.bottom <= bounds.bottom; +function waveAmplitude(scene: HorizonScene): number { + return seaAt(scene.seaState).waveHeight * WAVE.pxPerMetre; } -function textBox( - context: CanvasRenderingContext2D, - text: string, - anchor: Anchor, - font: number, -): Box { - const width = context.measureText(text).width; - const offset = { left: 0, center: width / 2, right: width }[anchor.align]; - const left = anchor.x - offset; - return { - left, - right: left + width, - top: anchor.baseline - font * TEXT.ascent, - bottom: anchor.baseline + font * TEXT.descent, - }; +function seaClearance(scene: HorizonScene): number { + return OBSTACLE.slackPx + waveAmplitude(scene); } -function shiftInto(box: Box, anchor: Anchor, bounds: Box): Placed { - const shift = clamp(0, bounds.left - box.left, bounds.right - box.right); - return { - ...anchor, - x: anchor.x + shift, - box: { ...box, left: box.left + shift, right: box.right + shift }, - }; +function waveOffset(x: number, amplitude: number): number { + return -amplitude * Math.abs(Math.sin((Math.PI * x) / WAVE.periodPx)); } -export function placeLabel( +function traceSurface( context: CanvasRenderingContext2D, - text: string, - candidates: readonly Anchor[], - taken: Box[], layout: HorizonLayout, -): Placed { - const placed = candidates.map((anchor) => - shiftInto(textBox(context, text, anchor, layout.font), anchor, layout.bounds), - ); - const free = placed.find( - (spot) => isInside(spot.box, layout.bounds) && !taken.some((box) => intersects(box, spot.box)), - ); - const chosen = free ?? placed[0]; - taken.push(chosen.box); - return chosen; -} - -function drawText( - context: CanvasRenderingContext2D, - text: string, - placed: Placed, - color: string, + amplitude: number, ): void { - context.fillStyle = color; - context.textAlign = placed.align; - context.fillText(text, placed.x, placed.baseline); -} - -function waveOffset(x: number, amplitude: number): number { - return -amplitude * Math.abs(Math.sin((Math.PI * x) / WAVE.periodPx)); -} - -function traceSurface(context: CanvasRenderingContext2D, layout: HorizonLayout, amplitude: number) { - const from = layout.bounds.left - SPACE.pad; - const to = layout.plot.right + SPACE.pad; + const end = layout.plot.right + SPACE.pad; context.beginPath(); - for (let x = from; x <= to; x += WAVE.stepPx) { - const y = surfaceY(layout, kmOfX(layout, x)) + waveOffset(x, amplitude); - if (x === from) context.moveTo(x, y); - else context.lineTo(x, y); + context.moveTo(0, surfaceYAtX(layout, 0)); + for (let x = WAVE.stepPx; x <= end; x += WAVE.stepPx) { + context.lineTo(x, surfaceYAtX(layout, x) + waveOffset(x, amplitude)); } } -function paintSea(context: CanvasRenderingContext2D, layout: HorizonLayout, scene: HorizonScene) { - const amplitude = seaAt(scene.seaState).waveHeight * WAVE.pxPerMetre; +function paintSea( + context: CanvasRenderingContext2D, + layout: HorizonLayout, + scene: HorizonScene, +): void { + const amplitude = waveAmplitude(scene); traceSurface(context, layout, amplitude); context.lineTo(layout.plot.right + SPACE.pad, layout.plot.bottom); - context.lineTo(layout.bounds.left - SPACE.pad, layout.plot.bottom); + context.lineTo(0, layout.plot.bottom); context.closePath(); context.fillStyle = COLORS.sea; context.fill(); @@ -213,9 +164,23 @@ function paintSea(context: CanvasRenderingContext2D, layout: HorizonLayout, scen context.stroke(); } +function drawText( + context: CanvasRenderingContext2D, + text: string, + placed: Placed, + color: string, +): void { + context.textAlign = placed.align; + context.lineJoin = 'round'; + context.lineWidth = LINE.halo; + context.strokeStyle = COLORS.halo; + context.strokeText(text, placed.x, placed.baseline); + context.fillStyle = color; + context.fillText(text, placed.x, placed.baseline); +} + function paintTicks(context: CanvasRenderingContext2D, layout: HorizonLayout): void { context.strokeStyle = COLORS.muted; - context.fillStyle = COLORS.muted; context.lineWidth = LINE.tick; TICKS_KM.forEach((km) => { const x = xOfKm(layout, km); @@ -225,8 +190,8 @@ function paintTicks(context: CanvasRenderingContext2D, layout: HorizonLayout): v context.stroke(); const text = formatCanvasKm(km); const anchor: Anchor = { x, baseline: layout.tickBaseline, align: 'center' }; - const placed = shiftInto(textBox(context, text, anchor, layout.font), anchor, layout.bounds); - drawText(context, text, placed, COLORS.muted); + const box = textBox(context.measureText(text).width, anchor, layout.metrics); + drawText(context, text, shiftInto(box, anchor, layout.bounds), COLORS.muted); }); } @@ -240,7 +205,11 @@ function shipBox(layout: HorizonLayout, radarHeight: number): Box { }; } -function paintShip(context: CanvasRenderingContext2D, layout: HorizonLayout, radarHeight: number) { +function paintShip( + context: CanvasRenderingContext2D, + layout: HorizonLayout, + radarHeight: number, +): void { const mastX = xOfKm(layout, 0); const water = yOfMetres(layout, 0); const deck = Math.min(yOfMetres(layout, SHIP.deckM), water - SHIP.minHullPx); @@ -312,94 +281,180 @@ function paintSight( radarHeight: number, boatKm: number, ): void { + const startX = xOfKm(layout, 0); + const startY = yOfMetres(layout, radarHeight); context.strokeStyle = COLORS.sight; context.lineWidth = LINE.sight; context.beginPath(); - context.moveTo(xOfKm(layout, 0), yOfMetres(layout, radarHeight)); + context.moveTo(startX, startY); context.lineTo(xOfKm(layout, boatKm), yOfMetres(layout, sightHeightM(radarHeight, boatKm))); context.stroke(); context.beginPath(); - context.arc(xOfKm(layout, 0), yOfMetres(layout, radarHeight), LINE.sight, 0, FULL_TURN); + context.arc(startX, startY, LINE.sight, 0, FULL_TURN); context.fillStyle = COLORS.sight; context.fill(); } -function below(box: Box, layout: HorizonLayout, lines: number): number { - return box.bottom + SPACE.gap + layout.font * TEXT.ascent + lines * (layout.font + SPACE.gap); +export function blockedBoxes(layout: HorizonLayout, scene: HorizonScene): Box[] { + const { stepPx, slackPx } = OBSTACLE; + const boatKm = radarLineOfSightKm(scene.radarHeight); + const sight = segmentBoxes( + xOfKm(layout, 0), + xOfKm(layout, boatKm), + stepPx, + (x) => sightY(layout, scene.radarHeight, x), + slackPx, + ); + const surface = segmentBoxes( + layout.bounds.left, + layout.bounds.right, + stepPx, + (x) => surfaceYAtX(layout, x), + seaClearance(scene), + ); + const marks = [shipBox(layout, scene.radarHeight), boatBox(layout, boatKm)]; + return [...marks, ...sight, ...surface]; } -function above(box: Box, layout: HorizonLayout): number { - return box.top - SPACE.gap - layout.font * TEXT.descent; +function lineHeight(layout: HorizonLayout): number { + return layout.metrics.font + SPACE.gap; } -function sightAnchors(layout: HorizonLayout, scene: HorizonScene, boatKm: number): Anchor[] { - return LOS_START_SHARES.map((share) => { - const x = xOfKm(layout, share * boatKm) + SHIP.bow + SPACE.gap; - const baseline = sightY(layout, scene.radarHeight, x) - SPACE.gap - layout.font * TEXT.descent; - return { x, baseline, align: 'left' as const }; - }); +function baselineAbove(layout: HorizonLayout, y: number): number { + return y - SPACE.gap - layout.metrics.font * TEXT.descent; } -interface LabelPainter { - context: CanvasRenderingContext2D; - layout: HorizonLayout; - taken: Box[]; +function baselineBelow(layout: HorizonLayout, y: number, row: number): number { + return y + SPACE.gap + layout.metrics.font * TEXT.ascent + row * lineHeight(layout); } -function label( - painter: LabelPainter, - key: string, - candidates: readonly Anchor[], - color: string, -): void { - const { context, layout, taken } = painter; - const text = t(key); - drawText(context, text, placeLabel(context, text, candidates, taken, layout), color); -} - -function radarAnchors(layout: HorizonLayout, radarHeight: number): Anchor[] { +function radarCandidates(layout: HorizonLayout, radarHeight: number): Candidates { const ship = shipBox(layout, radarHeight); - return [ - { x: xOfKm(layout, 0), baseline: above(ship, layout), align: 'center' }, - { x: ship.right + SPACE.gap, baseline: above(ship, layout) + layout.font, align: 'left' }, + const baseline = baselineAbove(layout, ship.top); + return () => [ + { x: xOfKm(layout, 0), baseline, align: 'center' }, + { x: ship.right + SPACE.gap, baseline, align: 'left' }, ]; } -function boatAnchors(layout: HorizonLayout, boat: Box): Anchor[] { - return [ - { x: (boat.left + boat.right) / 2, baseline: below(boat, layout, 0), align: 'center' }, - { x: boat.right, baseline: above(boat, layout), align: 'right' }, - ]; +function skyAbove(layout: HorizonLayout, scene: HorizonScene, x: number): number { + const from = x - OBSTACLE.stepPx; + const crest = surfaceYAtX(layout, from) - seaClearance(scene); + return Math.min(sightY(layout, scene.radarHeight, from) - OBSTACLE.slackPx, crest); } -function detectionAnchors(layout: HorizonLayout): Anchor[] { - const x = xOfKm(layout, DETECTION_KM) + SPACE.gap; - const water = surfaceY(layout, DETECTION_KM); - const surface = { left: x, right: x, top: water, bottom: water }; - return DETECTION_LINES.map((lines) => ({ +function skyRows(layout: HorizonLayout, x: number, align: TextAlign): Anchor[] { + return SKY_ROWS.map((row) => ({ x, - baseline: below(surface, layout, lines), - align: 'left', + baseline: layout.plot.top + layout.metrics.font + row * lineHeight(layout), + align, })); } -function noteAnchors(layout: HorizonLayout): Anchor[] { - return NOTE_ROWS.map((row) => ({ - x: layout.bounds.right, - baseline: layout.plot.top - SPACE.gap + row * (layout.font + SPACE.gap), - align: 'right', - })); +function boatCandidates(layout: HorizonLayout, scene: HorizonScene, boat: Box): Candidates { + const x = (boat.left + boat.right) / 2; + const clearance = seaClearance(scene); + return (width) => [ + ...SEA_ROWS.map((row) => ({ + x, + baseline: baselineBelow(layout, surfaceYAtX(layout, x + width / 2) + clearance, row), + align: 'center' as const, + })), + ...SEA_ROWS.map((row) => ({ + x: boat.left, + baseline: baselineBelow(layout, surfaceYAtX(layout, boat.left + width) + clearance, row), + align: 'left' as const, + })), + { + x: boat.right, + baseline: baselineAbove(layout, skyAbove(layout, scene, boat.right - width)), + align: 'right' as const, + }, + ]; +} + +function sightCandidates(layout: HorizonLayout, scene: HorizonScene, boatKm: number): Candidates { + return () => + SIGHT_SHARES.map((share) => { + const x = xOfKm(layout, share * boatKm) + SHIP.bow + SPACE.gap; + return { + x, + baseline: baselineAbove(layout, skyAbove(layout, scene, x)), + align: 'left' as const, + }; + }); +} + +function detectionCandidates(layout: HorizonLayout, scene: HorizonScene): Candidates { + const line = xOfKm(layout, DETECTION_KM); + const right = line + SPACE.gap; + const left = line - SPACE.gap; + const clearance = seaClearance(scene); + return (width) => [ + ...SEA_ROWS.flatMap((row) => [ + { + x: right, + baseline: baselineBelow(layout, surfaceYAtX(layout, right + width) + clearance, row), + align: 'left' as const, + }, + { + x: left, + baseline: baselineBelow(layout, surfaceYAtX(layout, left) + clearance, row), + align: 'right' as const, + }, + ]), + ...skyRows(layout, right, 'left'), + ...skyRows(layout, left, 'right'), + ]; +} + +function noteCandidates(layout: HorizonLayout): Candidates { + return () => + NOTE_ROWS.map((row) => ({ + x: layout.bounds.right, + baseline: layout.plot.top - SPACE.gap + row * lineHeight(layout), + align: 'right' as const, + })); +} + +function label(painter: LabelPainter, key: string, candidates: Candidates, color: string): void { + const { context, layout, taken } = painter; + const text = t(key); + const width = context.measureText(text).width; + drawText( + context, + text, + placeText(width, candidates(width), taken, layout.bounds, layout.metrics), + color, + ); } function paintLabels(painter: LabelPainter, scene: HorizonScene, boatKm: number): void { const { layout } = painter; - label(painter, 'horizon.canvas.radar', radarAnchors(layout, scene.radarHeight), COLORS.antenna); - label(painter, 'horizon.canvas.boat', boatAnchors(layout, boatBox(layout, boatKm)), COLORS.boat); - label(painter, 'horizon.canvas.lineOfSight', sightAnchors(layout, scene, boatKm), COLORS.sight); + const boat = boatBox(layout, boatKm); + label( + painter, + 'horizon.canvas.radar', + radarCandidates(layout, scene.radarHeight), + COLORS.antenna, + ); + if (detectionCovered(scene.seaState)) painter.taken.push(detectionBox(layout)); + label(painter, 'horizon.canvas.boat', boatCandidates(layout, scene, boat), COLORS.boat); + label( + painter, + 'horizon.canvas.lineOfSight', + sightCandidates(layout, scene, boatKm), + COLORS.sight, + ); if (detectionCovered(scene.seaState)) { - label(painter, 'horizon.canvas.detection', detectionAnchors(layout), COLORS.detection); + label( + painter, + 'horizon.canvas.detection', + detectionCandidates(layout, scene), + COLORS.detection, + ); } - label(painter, 'horizon.canvas.note', noteAnchors(layout), COLORS.muted); + label(painter, 'horizon.canvas.note', noteCandidates(layout), COLORS.muted); } export function paintHorizon( @@ -410,18 +465,16 @@ export function paintHorizon( const layout = horizonLayout(frame.width, frame.height); const boatKm = radarLineOfSightKm(scene.radarHeight); const covered = detectionCovered(scene.seaState); - context.font = canvasFont(frame, layout.font); + const boat = boatBox(layout, boatKm); + context.font = canvasFont(frame, layout.metrics.font); context.textBaseline = 'alphabetic'; paintSea(context, layout, scene); paintTicks(context, layout); if (covered) paintDetection(context, detectionBox(layout)); paintShip(context, layout, scene.radarHeight); paintSight(context, layout, scene.radarHeight, boatKm); - const boat = boatBox(layout, boatKm); paintBoat(context, boat); - const taken = [shipBox(layout, scene.radarHeight), boat]; - if (covered) taken.push(detectionBox(layout)); - paintLabels({ context, layout, taken }, scene, boatKm); + paintLabels({ context, layout, taken: blockedBoxes(layout, scene) }, scene, boatKm); } export class HorizonView { From 01102d2bc3e87edaaf06e44e0a9d382f6140d05c Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:43:43 +0200 Subject: [PATCH 08/99] fix(naval-drone): lower the satellites so the sky view fits them --- explainers/naval-drone/src/model/layout.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/explainers/naval-drone/src/model/layout.ts b/explainers/naval-drone/src/model/layout.ts index 0ca125cd..e00f1a3c 100644 --- a/explainers/naval-drone/src/model/layout.ts +++ b/explainers/naval-drone/src/model/layout.ts @@ -237,8 +237,8 @@ export const SHIP = { export const GROUND_STATION: Point = [-70, 3, -60]; -export const SATELLITE_OFFSET: SkyOffset = { ahead: 800, up: 360, side: -70 }; -export const BACKUP_SATELLITE_OFFSET: SkyOffset = { ahead: 860, up: 430, side: 60 }; +export const SATELLITE_OFFSET: SkyOffset = { ahead: 800, up: 160, side: -70 }; +export const BACKUP_SATELLITE_OFFSET: SkyOffset = { ahead: 860, up: 175, side: 60 }; export function skyPoint(position: Point, offset: SkyOffset): Point { const aheadX = Math.cos(FINAL_HEADING); From 0bf01c8d12871e6ac95366f425db4b385d9ff90e Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 09:54:21 +0200 Subject: [PATCH 09/99] feat(naval-drone): write the English chapters and locale --- explainers/naval-drone/chapters.html | 642 +++++++++++++++++++++++++ explainers/naval-drone/locales/en.json | 322 +++++++++++++ 2 files changed, 964 insertions(+) create mode 100644 explainers/naval-drone/chapters.html create mode 100644 explainers/naval-drone/locales/en.json diff --git a/explainers/naval-drone/chapters.html b/explainers/naval-drone/chapters.html new file mode 100644 index 00000000..bbb543c9 --- /dev/null +++ b/explainers/naval-drone/chapters.html @@ -0,0 +1,642 @@ +
+

01

+

A speedboat with no one aboard

+

+ You are looking at a Magura V5, a naval drone from Ukraine: a fast boat with + nobody on board. The state firm SpetsTechnoExport builds it for GUR, Ukraine's defence + intelligence. It was first shown at the IDEF fair in Istanbul in July 2023. The boat is 5.5 m + long and 1.5 m wide, about the size of a small speedboat. Its deck sits only 0.5 m above the + water, and it weighs under one tonne. A pilot far away steers it over a satellite link. +

+

+ The cutaway lets you look inside. The hull is composite: layers of fibre cloth + set in hard resin. The bow holds a closed payload bay for up to 320 kg. Behind + it come the fuel tanks, then the engine in an aluminium tub bedded in foam, with the electronics + above it. The engine drives a waterjet at the stern. The maker has not named + the engine, so the model assumes a jet-ski-type petrol engine, like the ones in the early boats. + On deck sit two Starlink antennas, a backup satellite antenna, a low dome with a thermal camera + and a fixed camera in the bow. +

+

+ The model follows one run. The boat idles off a slipway, climbs onto the plane + and cruises at 22 knots. A knot is one nautical mile an hour, so that is about 41 km/h. Then it + speeds up to its top speed of 42 knots, about 78 km/h, and two more boats join it. The run ends + as its bow reaches the side of a large grey ship at anchor. +

+

+ At normal speed the boat moves as fast as a real one, but the trip is cut short to fit into two + minutes. A real boat would cruise for many hours: about 20 hours to cover its full range of 450 + nautical miles, about 800 km. Here the cruise lasts 42 seconds, and the sprint is shortened too. + The ship lies just 1.5 km from the slipway, while a real target would be hundreds of kilometres + away. The clock at the top counts model time, so T+1:05 means one minute and five seconds after + the start. Drag the timeline, or tap a chip below to jump to a moment. +

+

+ + + + + +

+
+
+
Hull
+
Floating
+
+
+
Distance run
+
0 m
+
+
+
Run done
+
0 %
+
+
+
+ +
+

02

+

It stops floating and starts skimming

+

+ At rest the boat floats like any other. The water it pushes aside holds it up, and this is + called buoyancy. As the boat speeds up, a second force joins in. Water striking + the slanted bottom pushes up on it, and the faster the boat goes, the harder the push. Once this + push carries most of the weight, the hull rises and skims over the water. That is + planing. Even then, the back of the bottom always stays in the water. +

+

+ Every boat that pushes through the water makes a bow wave. At about 5.7 knots, that wave is as + long as this 5.5 m hull. This is the hull speed, a rule of thumb for boats that + only float. It is not a speed limit: at 42 knots the Magura runs at about 7 times it. To get + there, it has to climb the resistance hump, where the boat makes big waves and + the drag, the force of the water holding it back, climbs steeply. The sharp + edges along the bottom, the chines, add drag here too. They pay off only once + the boat planes. +

+

+ Move the slider, or tap a chip, and watch the bow. Near the hump it rises the most, then it + settles as the boat planes. This angle is the trim. Drag is lowest at about 3 + to 4 degrees, and the boat runs a little flatter at top speed. On this boat planing starts at + about 15 to 18 knots, and the model's readout switches at 16. Then the wet part of the bottom + shrinks fast, from about 4.5 m at 15 knots to about 1.5 to 2 m at 42 knots. No hump data is + published for this boat, so the 6 degree peak and the split of the weight in the readouts are + the model's own numbers. +

+

+ The bottom is a deep V, which softens the blows from waves. Its angle is not + published: the model gives it 20 degrees at the stern, growing sharper toward the bow. The + square stern has a sharp lower edge, so at speed the water breaks away cleanly and the stern + runs dry. Along each side, a thin spray rail throws spray outward; it is the + model's guess. Lifting strakes, ridges lower down that help a hull rise onto the plane, are left + off, because no photo shows them. Too much trim brings its own trouble: above a certain angle, a + planing boat starts to bounce in a steady rhythm, which is called porpoising. +

+
+
+ + 11 kn, 20 km/h +
+ +
+
+
Hull
+
Climbing the hump
+
+
+
Bow angle (trim)
+
6.0°
+
+
+
Weight carried
+
36 % by speed, 64 % by floating
+
+
+
Wet length of the bottom
+
4.4 m, against 5.1 m at rest
+
+
+
Against hull speed
+
1.9 times the hull speed of 5.7 kn
+
+
+
+

+ + + + + +

+
+ +
+

03

+

A pump instead of a propeller

+

+ At the stern sits a waterjet, a pump that does the job of a propeller. Water + comes in through an opening in the bottom, behind a grate of steel bars. A spinning + impeller, a small fan-like rotor, speeds it up. Fixed vanes behind it, the + stator, take the swirl out of the flow. Then a nozzle narrows + the stream and shoots it out behind the boat. The water flow view shows its path. +

+

+ The push comes from speeding up the water that passes through the pump. The + thrust equals the mass of water going through each second, times how much + faster the jet leaves than the boat moves. So the jet must always be faster than the boat. In an + ideal pump, the share of the work that moves the boat is 2 / (1 + jet speed / boat speed). When + the jet leaves at one and a half times the boat's speed, that share is 80 %. At low speed the + jet is far faster than the boat, and most of the work is lost. +

+

+ Above about 25 to 30 knots, a jet matches or beats a propeller, and it has other strengths. + Nothing hangs under the hull to add drag or hit things, so the boat can run in shallow water. + The grate mostly clears itself at planing speed, but weed and rope can still block an intake. + The first boats in 2022 used jet-ski parts: a three-cylinder Rotax petrol engine and a Sea-Doo + pump. The Magura's own engine is not disclosed. The model's pump is jet-ski sized, 155 mm + across, and at 42 knots it moves roughly 100 to 200 litres of seawater a second. +

+

+ There is no rudder. To turn, the steering nozzle swings the stream up to 27 + degrees to either side. Swing it right and the stern is pushed left, so the bow turns right, + just as on a jet ski. To stop or back up, a reverse bucket drops over the + nozzle and turns the stream forward and down. The slider sets the throttle, which the model + takes as how much water the pump moves. Tap the chips to steer or reverse, and press play to + hand the boat back to the run. +

+
+
+ + 69 % +
+ +
+
+
Boat speed
+
22 kn, 41 km/h
+
+
+
Water through the pump
+
148 kg a second, about 144 litres
+
+
+
Jet speed
+
22 m/s, 81 km/h
+
+
+
Thrust
+
1.65 kN
+
+
+
Ideal efficiency
+
67 %
+
+
+
+

+ + + + +

+
+
+
Where the jet pushes
+
Straight back: the boat holds its course
+
+
+
+ +
+

04

+ +

+ With nobody on board, everything depends on the radio link. In November 2023 a Magura was + captured in western Crimea, and Russian sources later showed what was inside. They found two + Starlink satellite terminals, a backup satellite antenna made by Kymeta, a GPS + receiver and a small computer running Linux. A mobile router with two SIM cards gave a link near + the shore, within about 40 km. The maker also lists a mesh radio network, autonomous control and + up to three video streams, but those are its own claims. +

+

+ The pilot works at a console that travels in a hard case, with screens, levers and joysticks. + Long trips across the sea can be programmed in advance, but a pilot watches the whole way, and + the last stretch is often steered by hand. So the boat is not fully autonomous. Tap the chips to + switch between Starlink, the backup satellite and no link at all. What a real boat does when its + link drops is not published, so the model simply keeps it on its set course. +

+

+ Starlink is quick for a satellite service. Its typical delay on land is about 25 to 35 + milliseconds, and at sea it can be over 100. The tighter limit is the upload, 10 to 30 megabits + a second, which is about what three video streams need. So the upload, not the delay, sets how + sharp the pilot's picture is. Still, the picture always arrives a little late, and nobody has + published how late. Drag the slider to try a delay: at 42 knots, a quarter of a second is 5.4 m, + about one boat length. The ghost boat shows where the pilot sees it. +

+

+ Finding the way needs care too. Since mid-2024, jammed and faked GPS signals over the Black Sea + have been well documented, with ships showing up inland on tracking maps. That is why the maker + also lists inertial and visual navigation, which work from the boat's own motion sensors and + cameras. Starlink has a story here as well. In September 2022, Ukraine expected coverage near + Crimea that had never been turned on. A 2023 book about Elon Musk first told it differently, and + its author corrected the account after Musk said SpaceX did not deactivate anything. The model + draws the satellites small, low and close, moving with the boat, so that you can see them. +

+

+ + + +

+
+
+ + 250 ms +
+ +
+
+
Where the pilot sees it
+
2.8 m behind the boat at 22 kn
+
+
+
Same delay at top speed
+
5.4 m at 42 kn, 1.0 boat lengths
+
+
+
+
+
+
Link
+
Starlink, two terminals
+
+
+
What the boat does
+
The pilot steers by the video
+
+
+

How a command reaches the boat

+
    +
  1. + Pilot: moves a lever or a joystick on the console. +
  2. +
  3. + Ground station: a Starlink station on land sends the command up by radio. +
  4. +
  5. + Satellite: a Starlink satellite passes it down to the boat. +
  6. +
  7. + Boat: an antenna on deck picks it up, and the computer moves the throttle or + the steering nozzle. +
  8. +
  9. + Video: the camera pictures travel back the same way and reach the pilot a + little late. +
  10. +
+
+ +
+

05

+

+ Low enough to hide behind the curve of the sea +

+

+ The sea curves away, so you can only see so far across it. From a height of h metres, the + horizon lies about 3.86 × √h kilometres away. The boat's thermal camera sits about 0.7 m above + the water, so its horizon is only about 3 km off. The 3D sea in the model is flat, because over + 1.5 km the real curve drops only about 0.1 m. The drawing below makes the curve much steeper so + that you can see it. +

+

+ A ship's radar has the same problem. Its antenna is high, but the boat is only about 0.5 m tall. + Radio waves bend a little around the Earth, so the radar's + line of sight reaches about 4.12 × (√h1 + √h2) kilometres, with both heights in + metres. For a radar 20 m up, that is about 21 km, and at 42 knots the boat covers it in about 16 + minutes. But this is only the farthest the radar could ever see the boat, not where it actually + spots it. Drag the slider to raise or lower the radar. +

+

+ Real detection comes much later. A small boat sends back a weak echo. Its + radar cross-section, the size of target it looks like to a radar, is about 0.5 + to 5 m², and no figure has been published for the Magura. A standard for coastal radar expects a + target of 1 m², 1 m above the sea, to be found at about 9 km, and only by an advanced radar in + seas up to state 4. At 42 knots the boat covers that in about 7 minutes. Waves make it harder + still: a small boat may be hidden behind them up to half the time, and the echoes from the waves + themselves, called sea clutter, can drown out its own. +

+

+ The people who run these boats say they are hard to spot. A commander of Group 13, GUR's naval + drone unit, said radar has a hard time seeing the plastic hull and that it gives off little + heat. These are claims, and no measurement has been published. One thing is on record: the + engine sits in a foam-lined aluminium tub with the electronics above it, which keeps heat away + from the outer skin. The boats are painted grey, and the best-known attacks came at night or at + dawn. Tap the chips to change the sea state, a scale of wave height from smooth + to rough. +

+
+ +
+ Side view of the ship's radar, the curve of the sea and the boat. The curve and the heights + are exaggerated. +
+
+
+
+ + 20 m +
+ +
+
+
Radar line of sight
+
21.3 km
+
+
+
Time to cover it at 42 kn
+
16.5 min
+
+
+
+

+ + + + +

+
+
+
Boat camera's horizon
+
3.2 km, from a lens 0.7 m above the water
+
+
+
Expected detection
+
About 9.3 km, or 7.1 min at 42 kn
+
+
+
Wave height
+
0.5 to 1.25 m, sea state 3
+
+
+
Hidden behind waves
+
Up to half the time: the waves are as tall as its deck
+
+
+
+ +
+

06

+

Cheap, small and changing fast

+

+ A Magura V5 costs about a quarter of a million dollars. The maker quotes about USD 273,000, and + other estimates say about USD 250,000. Sea Baby, a separate design by the SBU, Ukraine's + security service, costs about USD 221,000. GUR values the patrol ship Sergey Kotov, which sank + in March 2024 after a Magura attack, at about USD 65 million. These are public figures, shown + side by side in the readouts below. +

+

+ These boats rarely work alone. Reported attacks used groups of about five to ten, and the + developers say each boat in a group has its own role. The ships that were reported sunk took + several hits, not one. In the run, two companion boats join for the sprint; how they line up is + the model's choice. +

+

+ The design keeps changing. In December 2024, GUR fitted V5 boats with two R-73 air-to-air + missiles on rails, renamed Sea Dragon. Tap the chips to see this fit. Only the deck changes: the + model's boat runs the same route and still ends beside the ship. In 2025 GUR showed the larger + Magura V7, 7.2 m long, with a payload of up to 650 kg. Sea Baby is the SBU's own line, not a + Magura. +

+

+ The record below keeps to what each side has said, and many of the claims are disputed. The UK + Ministry of Defence counts at least 26 Russian navy ships damaged or destroyed between February + 2022 and June 2024, about a third of the Black Sea Fleet. That count covers every kind of + weapon, not only drone boats. By October 2023, satellite images showed about 14 ships moved from + Sevastopol to Novorossiysk. +

+

+ + +

+
+
+
On board
+
A payload of up to 320 kg in the bow
+
+
+
Cost per boat
+
About USD 250,000 to 273,000
+
+
+
Patrol ship, as valued by GUR
+
About USD 65 million
+
+
+

Public record, as reported

+
    +
  1. + 29 Oct 2022, Sevastopol: sea and air drones hit the frigate Admiral Makarov + and a minesweeper. Russia admitted only minor damage. +
  2. +
  3. + Night of 31 Jan to 1 Feb 2024, Lake Donuzlav: Magura V5 boats sank the + missile corvette Ivanovets. GUR claims six hits; video shows about three. +
  4. +
  5. + Night of 4 to 5 Mar 2024, near the Kerch Strait: Magura V5 boats sank the + patrol ship Sergey Kotov. Ukrainian officials said five boats took part. +
  6. +
  7. + 31 Dec 2024, Cape Tarkhankut: GUR says Magura V5 boats shot down Mi-8 + helicopters with R-73 missiles, the first aircraft downed from a sea drone. It first claimed + one, later two. +
  8. +
  9. + 2 May 2025, near Novorossiysk: GUR says Magura V7 boats downed two Su-30SM + jets with Sidewinder missiles. Russia has not confirmed it. +
  10. +
+
diff --git a/explainers/naval-drone/locales/en.json b/explainers/naval-drone/locales/en.json new file mode 100644 index 00000000..83968fc0 --- /dev/null +++ b/explainers/naval-drone/locales/en.json @@ -0,0 +1,322 @@ +{ + "meta": { + "title": "How a naval drone works", + "eyebrow": "Naval drone", + "tagline": "A small boat with nobody on board: it skims on its hull, drives with a water pump and is steered over a satellite link.", + "description": "Follow a Magura V5 naval drone in 3D: the hull that rises onto the plane, the waterjet, the satellite link and why a low boat is hard to see.", + "summary": "The planing hull, the waterjet that drives and steers it, the satellite link and its delay, the curve of the sea, and a design that keeps changing.", + "socialAlt": "A grey uncrewed boat skimming over the sea at dawn with spray flying from its chines, beside the title How a naval drone works" + }, + "stage": { + "label": "Interactive naval drone model", + "gauge": "Live run readings" + }, + "controls": { + "label": "Naval drone controls", + "views": { + "cutaway": { + "name": "Cutaway", + "short": "Cutaway", + "hint": "Cutaway (C)" + }, + "flow": { + "name": "Water flow", + "short": "Flow", + "hint": "Water flow (W)" + }, + "links": { + "name": "Radio links", + "short": "Links", + "hint": "Radio links (K)" + }, + "labels": { + "name": "Labels", + "short": "Labels", + "hint": "Labels (L)" + } + }, + "fit": { + "label": "Deck fit" + }, + "fitOptions": { + "standard": "Standard", + "missile": "Missile rails" + }, + "momentOptions": { + "hullSpeed": "Hull speed", + "humpPeak": "Top of the hump", + "onPlane": "On the plane", + "cruiseSpeed": "Cruising", + "throttleUp": "Sprint starts", + "topSpeed": "Top speed", + "alongside": "At the ship" + }, + "speedMarkOptions": { + "hullSpeed": "Hull speed", + "hump": "Hump", + "planing": "Planing", + "cruise": "Cruise", + "top": "Top speed" + }, + "helmOptions": { + "left": "Left", + "straight": "Straight", + "right": "Right", + "reverse": "Reverse" + }, + "linkOptions": { + "satellite": "Starlink", + "backup": "Backup satellite", + "lost": "Link lost" + }, + "seaStateOptions": { + "smooth": "Smooth", + "slight": "Slight", + "moderate": "Moderate", + "rough": "Rough" + } + }, + "timeline": { + "label": "Run time", + "phases": "Jump to a part of the run", + "speed": "Playback speed", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "compared with normal playback", + "phase": { + "launch": "Launch", + "hump": "Hump", + "cruise": "Cruise", + "sprint": "Sprint", + "arrival": "Arrival" + }, + "during": { + "launch": "the boat idles off the slipway, slower than its hull speed", + "hump": "full throttle drives the boat over the hump and onto the plane", + "cruise": "the boat cruises at 22 knots, a trip of many hours cut short", + "sprint": "the boat speeds up to 42 knots as two more boats join it", + "arrival": "the boat runs the last 170 m to the side of the ship" + }, + "jump": { + "launch": "Jump to the launch", + "hump": "Jump to the hump", + "cruise": "Jump to the cruise", + "sprint": "Jump to the sprint", + "arrival": "Jump to the arrival" + } + }, + "readouts": { + "clock": "Run clock", + "speed": "Speed", + "mode": "Hull", + "distance": "To the ship" + }, + "mode": { + "floating": "Floating", + "hump": "Climbing the hump", + "planing": "Planing" + }, + "units": { + "speed": "{{kn}} kn, {{kmh}} km/h", + "m": "{{value}} m", + "km": "{{value}} km", + "percent": "{{value}} %", + "degrees": "{{value}}°", + "kilonewtons": "{{value}} kN", + "mps": "{{ms}} m/s, {{kmh}} km/h", + "ms": "{{value}} ms", + "minutes": "{{value}} min" + }, + "parts": { + "hull": "Composite hull", + "chines": "Chines", + "sprayRails": "Spray rails", + "payloadBay": "Payload bay", + "fuelTanks": "Fuel tanks", + "engine": "Engine in its tub", + "electronicsBay": "Electronics", + "starlinkPanels": "Starlink antennas", + "backupPanel": "Backup satellite antenna", + "cameraDome": "Thermal camera dome", + "bowCamera": "Bow camera", + "intake": "Intake and grate", + "duct": "Intake duct", + "driveShaft": "Drive shaft", + "impeller": "Impeller", + "waterjet": "Waterjet", + "stator": "Stator vanes", + "nozzle": "Nozzle", + "steeringNozzle": "Steering nozzle", + "reverseBucket": "Reverse bucket", + "missileRails": "Missile rails", + "jetStream": "Jet stream", + "bowWave": "Bow wave", + "spray": "Spray", + "wake": "Wake", + "wettedLength": "Wet length of the bottom", + "videoGhost": "Where the pilot sees it", + "satLink": "Starlink link", + "backupLink": "Backup link", + "satellite": "Starlink satellite", + "backupSatellite": "Backup satellite", + "groundStation": "Ground station", + "ship": "Ship at anchor", + "shipRadar": "Ship's radar", + "companions": "Companion boats" + }, + "hull": { + "lift": "{{dynamic}} % by speed, {{buoyant}} % by floating", + "wetted": "{{m}} m, against {{rest}} m at rest", + "ratio": "{{times}} times the hull speed of {{hullSpeed}} kn", + "ratioStill": "Standing still. Hull speed is {{hullSpeed}} kn" + }, + "jet": { + "flow": "{{kg}} kg a second, about {{litres}} litres", + "backing": "Stopping, with the bucket down", + "astern": "Turned forward by the bucket", + "noEfficiency": "None while the boat is not moving ahead", + "push": { + "left": "Nozzle {{angle}}° to the left: the bow turns left", + "right": "Nozzle {{angle}}° to the right: the bow turns right", + "straight": "Straight back: the boat holds its course", + "reverse": "Bucket down: the stream goes forward and down" + } + }, + "link": { + "now": "{{m}} m behind the boat at {{kn}} kn", + "nowLost": "No picture while the link is down", + "top": "{{m}} m at 42 kn, {{lengths}} boat lengths", + "carrier": { + "satellite": "Starlink, two terminals", + "backup": "Backup satellite antenna", + "lost": "None" + }, + "boat": { + "satellite": "The pilot steers by the video", + "backup": "The pilot steers over the backup link", + "lost": "In the model it keeps its set course; what a real boat does is not published" + } + }, + "horizon": { + "camera": "{{km}} km, from a lens {{m}} m above the water", + "detect": "About {{km}} km, or {{min}} min at 42 kn", + "detectBeyond": "Not covered: the standard stops at sea state 4", + "waves": { + "smooth": "{{from}} to {{to}} m, sea state 2", + "slight": "{{from}} to {{to}} m, sea state 3", + "moderate": "{{from}} to {{to}} m, sea state 4", + "rough": "{{from}} to {{to}} m, sea state 5" + }, + "hidden": { + "smooth": "Seldom: the waves are lower than its 0.5 m deck", + "slight": "Up to half the time: the waves are as tall as its deck", + "moderate": "Up to half the time: the waves are taller than its deck", + "rough": "Up to half the time: the waves tower over its deck" + }, + "canvas": { + "radar": "Ship's radar", + "boat": "Boat, 0.5 m high", + "lineOfSight": "Line of sight", + "detection": "Expected detection", + "km": "{{value}} km", + "note": "Curve of the sea exaggerated" + } + }, + "fleet": { + "carries": { + "standard": "A payload of up to {{kg}} kg in the bow", + "missile": "Two R-73 missiles on rails; the model keeps the bow bay" + }, + "cost": "About USD {{from}} to {{to}}", + "ship": "About USD {{value}} million" + }, + "sections": { + "overview": { + "title": "A speedboat with no one aboard", + "p1": "You are looking at a Magura V5, a naval drone from Ukraine: a fast boat with nobody on board. The state firm SpetsTechnoExport builds it for GUR, Ukraine's defence intelligence. It was first shown at the IDEF fair in Istanbul in July 2023. The boat is 5.5 m long and 1.5 m wide, about the size of a small speedboat. Its deck sits only 0.5 m above the water, and it weighs under one tonne. A pilot far away steers it over a satellite link.", + "p2": "The cutaway lets you look inside. The hull is composite: layers of fibre cloth set in hard resin. The bow holds a closed payload bay for up to 320 kg. Behind it come the fuel tanks, then the engine in an aluminium tub bedded in foam, with the electronics above it. The engine drives a waterjet at the stern. The maker has not named the engine, so the model assumes a jet-ski-type petrol engine, like the ones in the early boats. On deck sit two Starlink antennas, a backup satellite antenna, a low dome with a thermal camera and a fixed camera in the bow.", + "p3": "The model follows one run. The boat idles off a slipway, climbs onto the plane and cruises at 22 knots. A knot is one nautical mile an hour, so that is about 41 km/h. Then it speeds up to its top speed of 42 knots, about 78 km/h, and two more boats join it. The run ends as its bow reaches the side of a large grey ship at anchor.", + "p4": "At normal speed the boat moves as fast as a real one, but the trip is cut short to fit into two minutes. A real boat would cruise for many hours: about 20 hours to cover its full range of 450 nautical miles, about 800 km. Here the cruise lasts 42 seconds, and the sprint is shortened too. The ship lies just 1.5 km from the slipway, while a real target would be hundreds of kilometres away. The clock at the top counts model time, so T+1:05 means one minute and five seconds after the start. Drag the timeline, or tap a chip below to jump to a moment.", + "mode": "Hull", + "covered": "Distance run", + "done": "Run done" + }, + "hull": { + "title": "It stops floating and starts skimming", + "p1": "At rest the boat floats like any other. The water it pushes aside holds it up, and this is called buoyancy. As the boat speeds up, a second force joins in. Water striking the slanted bottom pushes up on it, and the faster the boat goes, the harder the push. Once this push carries most of the weight, the hull rises and skims over the water. That is planing. Even then, the back of the bottom always stays in the water.", + "p2": "Every boat that pushes through the water makes a bow wave. At about 5.7 knots, that wave is as long as this 5.5 m hull. This is the hull speed, a rule of thumb for boats that only float. It is not a speed limit: at 42 knots the Magura runs at about 7 times it. To get there, it has to climb the resistance hump, where the boat makes big waves and the drag, the force of the water holding it back, climbs steeply. The sharp edges along the bottom, the chines, add drag here too. They pay off only once the boat planes.", + "p3": "Move the slider, or tap a chip, and watch the bow. Near the hump it rises the most, then it settles as the boat planes. This angle is the trim. Drag is lowest at about 3 to 4 degrees, and the boat runs a little flatter at top speed. On this boat planing starts at about 15 to 18 knots, and the model's readout switches at 16. Then the wet part of the bottom shrinks fast, from about 4.5 m at 15 knots to about 1.5 to 2 m at 42 knots. No hump data is published for this boat, so the 6 degree peak and the split of the weight in the readouts are the model's own numbers.", + "p4": "The bottom is a deep V, which softens the blows from waves. Its angle is not published: the model gives it 20 degrees at the stern, growing sharper toward the bow. The square stern has a sharp lower edge, so at speed the water breaks away cleanly and the stern runs dry. Along each side, a thin spray rail throws spray outward; it is the model's guess. Lifting strakes, ridges lower down that help a hull rise onto the plane, are left off, because no photo shows them. Too much trim brings its own trouble: above a certain angle, a planing boat starts to bounce in a steady rhythm, which is called porpoising.", + "speedLabel": "Boat speed", + "mode": "Hull", + "trim": "Bow angle (trim)", + "lift": "Weight carried", + "wetted": "Wet length of the bottom", + "ratio": "Against hull speed" + }, + "jet": { + "title": "A pump instead of a propeller", + "p1": "At the stern sits a waterjet, a pump that does the job of a propeller. Water comes in through an opening in the bottom, behind a grate of steel bars. A spinning impeller, a small fan-like rotor, speeds it up. Fixed vanes behind it, the stator, take the swirl out of the flow. Then a nozzle narrows the stream and shoots it out behind the boat. The water flow view shows its path.", + "p2": "The push comes from speeding up the water that passes through the pump. The thrust equals the mass of water going through each second, times how much faster the jet leaves than the boat moves. So the jet must always be faster than the boat. In an ideal pump, the share of the work that moves the boat is 2 / (1 + jet speed / boat speed). When the jet leaves at one and a half times the boat's speed, that share is 80 %. At low speed the jet is far faster than the boat, and most of the work is lost.", + "p3": "Above about 25 to 30 knots, a jet matches or beats a propeller, and it has other strengths. Nothing hangs under the hull to add drag or hit things, so the boat can run in shallow water. The grate mostly clears itself at planing speed, but weed and rope can still block an intake. The first boats in 2022 used jet-ski parts: a three-cylinder Rotax petrol engine and a Sea-Doo pump. The Magura's own engine is not disclosed. The model's pump is jet-ski sized, 155 mm across, and at 42 knots it moves roughly 100 to 200 litres of seawater a second.", + "p4": "There is no rudder. To turn, the steering nozzle swings the stream up to 27 degrees to either side. Swing it right and the stern is pushed left, so the bow turns right, just as on a jet ski. To stop or back up, a reverse bucket drops over the nozzle and turns the stream forward and down. The slider sets the throttle, which the model takes as how much water the pump moves. Tap the chips to steer or reverse, and press play to hand the boat back to the run.", + "throttleLabel": "Throttle", + "boat": "Boat speed", + "flow": "Water through the pump", + "velocity": "Jet speed", + "thrust": "Thrust", + "efficiency": "Ideal efficiency", + "push": "Where the jet pushes" + }, + "link": { + "title": "Steered from hundreds of kilometres away", + "p1": "With nobody on board, everything depends on the radio link. In November 2023 a Magura was captured in western Crimea, and Russian sources later showed what was inside. They found two Starlink satellite terminals, a backup satellite antenna made by Kymeta, a GPS receiver and a small computer running Linux. A mobile router with two SIM cards gave a link near the shore, within about 40 km. The maker also lists a mesh radio network, autonomous control and up to three video streams, but those are its own claims.", + "p2": "The pilot works at a console that travels in a hard case, with screens, levers and joysticks. Long trips across the sea can be programmed in advance, but a pilot watches the whole way, and the last stretch is often steered by hand. So the boat is not fully autonomous. Tap the chips to switch between Starlink, the backup satellite and no link at all. What a real boat does when its link drops is not published, so the model simply keeps it on its set course.", + "p3": "Starlink is quick for a satellite service. Its typical delay on land is about 25 to 35 milliseconds, and at sea it can be over 100. The tighter limit is the upload, 10 to 30 megabits a second, which is about what three video streams need. So the upload, not the delay, sets how sharp the pilot's picture is. Still, the picture always arrives a little late, and nobody has published how late. Drag the slider to try a delay: at 42 knots, a quarter of a second is 5.4 m, about one boat length. The ghost boat shows where the pilot sees it.", + "p4": "Finding the way needs care too. Since mid-2024, jammed and faked GPS signals over the Black Sea have been well documented, with ships showing up inland on tracking maps. That is why the maker also lists inertial and visual navigation, which work from the boat's own motion sensors and cameras. Starlink has a story here as well. In September 2022, Ukraine expected coverage near Crimea that had never been turned on. A 2023 book about Elon Musk first told it differently, and its author corrected the account after Musk said SpaceX did not deactivate anything. The model draws the satellites small, low and close, moving with the boat, so that you can see them.", + "delayLabel": "Video delay", + "now": "Where the pilot sees it", + "top": "Same delay at top speed", + "carrier": "Link", + "boat": "What the boat does", + "path": "How a command reaches the boat", + "step1": "Pilot: moves a lever or a joystick on the console.", + "step2": "Ground station: a Starlink station on land sends the command up by radio.", + "step3": "Satellite: a Starlink satellite passes it down to the boat.", + "step4": "Boat: an antenna on deck picks it up, and the computer moves the throttle or the steering nozzle.", + "step5": "Video: the camera pictures travel back the same way and reach the pilot a little late." + }, + "horizon": { + "title": "Low enough to hide behind the curve of the sea", + "p1": "The sea curves away, so you can only see so far across it. From a height of h metres, the horizon lies about 3.86 × √h kilometres away. The boat's thermal camera sits about 0.7 m above the water, so its horizon is only about 3 km off. The 3D sea in the model is flat, because over 1.5 km the real curve drops only about 0.1 m. The drawing below makes the curve much steeper so that you can see it.", + "p2": "A ship's radar has the same problem. Its antenna is high, but the boat is only about 0.5 m tall. Radio waves bend a little around the Earth, so the radar's line of sight reaches about 4.12 × (√h1 + √h2) kilometres, with both heights in metres. For a radar 20 m up, that is about 21 km, and at 42 knots the boat covers it in about 16 minutes. But this is only the farthest the radar could ever see the boat, not where it actually spots it. Drag the slider to raise or lower the radar.", + "p3": "Real detection comes much later. A small boat sends back a weak echo. Its radar cross-section, the size of target it looks like to a radar, is about 0.5 to 5 m², and no figure has been published for the Magura. A standard for coastal radar expects a target of 1 m², 1 m above the sea, to be found at about 9 km, and only by an advanced radar in seas up to state 4. At 42 knots the boat covers that in about 7 minutes. Waves make it harder still: a small boat may be hidden behind them up to half the time, and the echoes from the waves themselves, called sea clutter, can drown out its own.", + "p4": "The people who run these boats say they are hard to spot. A commander of Group 13, GUR's naval drone unit, said radar has a hard time seeing the plastic hull and that it gives off little heat. These are claims, and no measurement has been published. One thing is on record: the engine sits in a foam-lined aluminium tub with the electronics above it, which keeps heat away from the outer skin. The boats are painted grey, and the best-known attacks came at night or at dawn. Tap the chips to change the sea state, a scale of wave height from smooth to rough.", + "caption": "Side view of the ship's radar, the curve of the sea and the boat. The curve and the heights are exaggerated.", + "heightLabel": "Radar height", + "radar": "Radar line of sight", + "minutes": "Time to cover it at 42 kn", + "camera": "Boat camera's horizon", + "detect": "Expected detection", + "waves": "Wave height", + "hidden": "Hidden behind waves" + }, + "fleet": { + "title": "Cheap, small and changing fast", + "p1": "A Magura V5 costs about a quarter of a million dollars. The maker quotes about USD 273,000, and other estimates say about USD 250,000. Sea Baby, a separate design by the SBU, Ukraine's security service, costs about USD 221,000. GUR values the patrol ship Sergey Kotov, which sank in March 2024 after a Magura attack, at about USD 65 million. These are public figures, shown side by side in the readouts below.", + "p2": "These boats rarely work alone. Reported attacks used groups of about five to ten, and the developers say each boat in a group has its own role. The ships that were reported sunk took several hits, not one. In the run, two companion boats join for the sprint; how they line up is the model's choice.", + "p3": "The design keeps changing. In December 2024, GUR fitted V5 boats with two R-73 air-to-air missiles on rails, renamed Sea Dragon. Tap the chips to see this fit. Only the deck changes: the model's boat runs the same route and still ends beside the ship. In 2025 GUR showed the larger Magura V7, 7.2 m long, with a payload of up to 650 kg. Sea Baby is the SBU's own line, not a Magura.", + "p4": "The record below keeps to what each side has said, and many of the claims are disputed. The UK Ministry of Defence counts at least 26 Russian navy ships damaged or destroyed between February 2022 and June 2024, about a third of the Black Sea Fleet. That count covers every kind of weapon, not only drone boats. By October 2023, satellite images showed about 14 ships moved from Sevastopol to Novorossiysk.", + "carries": "On board", + "cost": "Cost per boat", + "ship": "Patrol ship, as valued by GUR", + "events": "Public record, as reported", + "event1": "29 Oct 2022, Sevastopol: sea and air drones hit the frigate Admiral Makarov and a minesweeper. Russia admitted only minor damage.", + "event2": "Night of 31 Jan to 1 Feb 2024, Lake Donuzlav: Magura V5 boats sank the missile corvette Ivanovets. GUR claims six hits; video shows about three.", + "event3": "Night of 4 to 5 Mar 2024, near the Kerch Strait: Magura V5 boats sank the patrol ship Sergey Kotov. Ukrainian officials said five boats took part.", + "event4": "31 Dec 2024, Cape Tarkhankut: GUR says Magura V5 boats shot down Mi-8 helicopters with R-73 missiles, the first aircraft downed from a sea drone. It first claimed one, later two.", + "event5": "2 May 2025, near Novorossiysk: GUR says Magura V7 boats downed two Su-30SM jets with Sidewinder missiles. Russia has not confirmed it." + } + } +} From 78ae1832aedbdb444b8f22b00bbb11b52f2b6756 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:01:21 +0200 Subject: [PATCH 10/99] feat(naval-drone): add the Portuguese copy --- explainers/naval-drone/locales/pt.json | 322 +++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 explainers/naval-drone/locales/pt.json diff --git a/explainers/naval-drone/locales/pt.json b/explainers/naval-drone/locales/pt.json new file mode 100644 index 00000000..81c2c192 --- /dev/null +++ b/explainers/naval-drone/locales/pt.json @@ -0,0 +1,322 @@ +{ + "meta": { + "title": "Como funciona um drone naval", + "eyebrow": "Drone naval", + "tagline": "Um barco pequeno sem ninguém a bordo: ele desliza sobre o casco, é movido por uma bomba d'água e é pilotado por satélite.", + "description": "Siga em 3D um drone naval Magura V5: o casco que entra em planeio, o jato d'água, a conexão por satélite e por que um barco baixo é difícil de ver.", + "summary": "O casco de planeio, o jato d'água que impulsiona e dirige o barco, a conexão por satélite e seu atraso, a curvatura do mar e um projeto que não para de mudar.", + "socialAlt": "Um barco cinza não tripulado deslizando sobre o mar ao amanhecer, com respingos saindo das quinas. Ao lado, o título “Como funciona um drone naval”" + }, + "stage": { + "label": "Modelo interativo de um drone naval", + "gauge": "Dados do percurso em tempo real" + }, + "controls": { + "label": "Controles do drone naval", + "views": { + "cutaway": { + "name": "Vista em corte", + "short": "Corte", + "hint": "Vista em corte (C)" + }, + "flow": { + "name": "Fluxo de água", + "short": "Fluxo", + "hint": "Fluxo de água (W)" + }, + "links": { + "name": "Conexões de rádio", + "short": "Conexões", + "hint": "Conexões de rádio (K)" + }, + "labels": { + "name": "Rótulos", + "short": "Rótulos", + "hint": "Rótulos (L)" + } + }, + "fit": { + "label": "Equipamento do convés" + }, + "fitOptions": { + "standard": "Padrão", + "missile": "Trilhos de mísseis" + }, + "momentOptions": { + "hullSpeed": "Velocidade de casco", + "humpPeak": "Alto da corcova", + "onPlane": "Em planeio", + "cruiseSpeed": "Em cruzeiro", + "throttleUp": "Início da arrancada", + "topSpeed": "Velocidade máxima", + "alongside": "Junto ao navio" + }, + "speedMarkOptions": { + "hullSpeed": "Velocidade de casco", + "hump": "Corcova", + "planing": "Planeio", + "cruise": "Cruzeiro", + "top": "Velocidade máxima" + }, + "helmOptions": { + "left": "Esquerda", + "straight": "Em frente", + "right": "Direita", + "reverse": "Ré" + }, + "linkOptions": { + "satellite": "Starlink", + "backup": "Satélite reserva", + "lost": "Conexão perdida" + }, + "seaStateOptions": { + "smooth": "Calmo", + "slight": "Pouco agitado", + "moderate": "Moderado", + "rough": "Agitado" + } + }, + "timeline": { + "label": "Tempo de percurso", + "phases": "Ir para uma parte do percurso", + "speed": "Velocidade de reprodução", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "em relação à velocidade normal", + "phase": { + "launch": "Lançamento", + "hump": "Corcova", + "cruise": "Cruzeiro", + "sprint": "Arrancada", + "arrival": "Chegada" + }, + "during": { + "launch": "o barco sai da rampa em marcha lenta, abaixo da sua velocidade de casco", + "hump": "com o acelerador no máximo, o barco vence a corcova e entra em planeio", + "cruise": "o barco segue a 22 nós, numa viagem de muitas horas que aqui foi encurtada", + "sprint": "o barco acelera até 42 nós, e mais dois barcos se juntam a ele", + "arrival": "o barco percorre os últimos 170 m até a lateral do navio" + }, + "jump": { + "launch": "Ir para o lançamento", + "hump": "Ir para a corcova", + "cruise": "Ir para o cruzeiro", + "sprint": "Ir para a arrancada", + "arrival": "Ir para a chegada" + } + }, + "readouts": { + "clock": "Relógio do percurso", + "speed": "Velocidade", + "mode": "Casco", + "distance": "Até o navio" + }, + "mode": { + "floating": "Flutuando", + "hump": "Subindo a corcova", + "planing": "Em planeio" + }, + "units": { + "speed": "{{kn}} nós, {{kmh}} km/h", + "m": "{{value}} m", + "km": "{{value}} km", + "percent": "{{value}} %", + "degrees": "{{value}}°", + "kilonewtons": "{{value}} kN", + "mps": "{{ms}} m/s, {{kmh}} km/h", + "ms": "{{value}} ms", + "minutes": "{{value}} min" + }, + "parts": { + "hull": "Casco de compósito", + "chines": "Quinas", + "sprayRails": "Defletores de respingos", + "payloadBay": "Compartimento de carga", + "fuelTanks": "Tanques de combustível", + "engine": "Motor em sua cuba", + "electronicsBay": "Eletrônica", + "starlinkPanels": "Antenas Starlink", + "backupPanel": "Antena de satélite reserva", + "cameraDome": "Cúpula da câmera térmica", + "bowCamera": "Câmera de proa", + "intake": "Entrada e grade", + "duct": "Duto de entrada", + "driveShaft": "Eixo de transmissão", + "impeller": "Rotor", + "waterjet": "Propulsor a jato d'água", + "stator": "Aletas do estator", + "nozzle": "Bocal", + "steeringNozzle": "Bocal de direção", + "reverseBucket": "Concha de ré", + "missileRails": "Trilhos de mísseis", + "jetStream": "Jato de saída", + "bowWave": "Onda de proa", + "spray": "Respingos", + "wake": "Esteira", + "wettedLength": "Comprimento molhado do fundo", + "videoGhost": "Onde o piloto o vê", + "satLink": "Conexão Starlink", + "backupLink": "Conexão reserva", + "satellite": "Satélite Starlink", + "backupSatellite": "Satélite reserva", + "groundStation": "Estação terrestre", + "ship": "Navio ancorado", + "shipRadar": "Radar do navio", + "companions": "Barcos acompanhantes" + }, + "hull": { + "lift": "{{dynamic}} % pela velocidade, {{buoyant}} % pela flutuação", + "wetted": "{{m}} m, contra {{rest}} m parado", + "ratio": "{{times}} vezes a velocidade de casco, de {{hullSpeed}} nós", + "ratioStill": "Parado. A velocidade de casco é de {{hullSpeed}} nós" + }, + "jet": { + "flow": "{{kg}} kg por segundo, cerca de {{litres}} litros", + "backing": "Parando, com a concha abaixada", + "astern": "A concha joga o jato para a frente", + "noEfficiency": "Nenhuma enquanto o barco não anda para a frente", + "push": { + "left": "Bocal {{angle}}° para a esquerda: a proa vira para a esquerda", + "right": "Bocal {{angle}}° para a direita: a proa vira para a direita", + "straight": "Direto para trás: o barco mantém o rumo", + "reverse": "Concha abaixada: o jato vai para a frente e para baixo" + } + }, + "link": { + "now": "{{m}} m atrás do barco a {{kn}} nós", + "nowLost": "Sem imagem enquanto a conexão está fora", + "top": "{{m}} m a 42 nós, {{lengths}} comprimentos do barco", + "carrier": { + "satellite": "Starlink, dois terminais", + "backup": "Antena de satélite reserva", + "lost": "Nenhuma" + }, + "boat": { + "satellite": "O piloto conduz pelo vídeo", + "backup": "O piloto conduz pela conexão reserva", + "lost": "No modelo, o barco mantém o rumo definido; o que um barco real faz não foi divulgado" + } + }, + "horizon": { + "camera": "{{km}} km, de uma lente a {{m}} m acima da água", + "detect": "Cerca de {{km}} km, ou {{min}} min a 42 nós", + "detectBeyond": "Fora da norma: ela só vai até o estado do mar 4", + "waves": { + "smooth": "{{from}} a {{to}} m, estado do mar 2", + "slight": "{{from}} a {{to}} m, estado do mar 3", + "moderate": "{{from}} a {{to}} m, estado do mar 4", + "rough": "{{from}} a {{to}} m, estado do mar 5" + }, + "hidden": { + "smooth": "Raramente: as ondas são mais baixas que o convés, a 0,5 m da água", + "slight": "Até metade do tempo: as ondas têm a altura do convés", + "moderate": "Até metade do tempo: as ondas são mais altas que o convés", + "rough": "Até metade do tempo: as ondas ficam bem acima do convés" + }, + "canvas": { + "radar": "Radar do navio", + "boat": "Barco, 0,5 m de altura", + "lineOfSight": "Linha de visada", + "detection": "Detecção esperada", + "km": "{{value}} km", + "note": "Curvatura do mar exagerada" + } + }, + "fleet": { + "carries": { + "standard": "Uma carga de até {{kg}} kg na proa", + "missile": "Dois mísseis R-73 em trilhos; o modelo mantém o compartimento da proa" + }, + "cost": "Cerca de US$ {{from}} a {{to}}", + "ship": "Cerca de US$ {{value}} milhões" + }, + "sections": { + "overview": { + "title": "Uma lancha sem ninguém a bordo", + "p1": "Você está vendo um Magura V5, um drone naval da Ucrânia: um barco rápido sem ninguém a bordo. Quem o fabrica é a estatal SpetsTechnoExport, para o GUR, o serviço de inteligência militar da Ucrânia. Ele foi apresentado pela primeira vez em julho de 2023, na feira IDEF, em Istambul. O barco tem 5,5 m de comprimento e 1,5 m de largura, mais ou menos o tamanho de uma lancha pequena. O convés fica a apenas 0,5 m acima da água, e o barco pesa menos de uma tonelada. Um piloto lá longe o conduz por uma conexão via satélite.", + "p2": "A vista em corte deixa você olhar lá dentro. O casco é de compósito: camadas de tecido de fibra presas numa resina dura. Na proa fica um compartimento de carga fechado, para até 320 kg. Atrás dele vêm os tanques de combustível e, depois, o motor, dentro de uma cuba de alumínio assentada em espuma, com a eletrônica por cima. O motor aciona um propulsor a jato d'água na popa. O fabricante não revelou qual motor usa, então o modelo considera um motor a gasolina do tipo usado em jet ski, como os dos primeiros barcos. No convés ficam duas antenas Starlink, uma antena de satélite reserva, uma cúpula baixa com uma câmera térmica e uma câmera fixa na proa.", + "p3": "O modelo acompanha um percurso. O barco sai devagar de uma rampa, entra em planeio e segue em cruzeiro a 22 nós. Um nó é uma milha náutica por hora, então isso dá cerca de 41 km/h. Depois, ele acelera até a velocidade máxima, de 42 nós, cerca de 78 km/h, e mais dois barcos se juntam a ele. O percurso termina quando a proa chega à lateral de um grande navio cinza ancorado.", + "p4": "Na velocidade normal, o barco anda tão rápido quanto um de verdade, mas a viagem foi encurtada para caber em dois minutos. Um barco real ficaria muitas horas em cruzeiro: cerca de 20 horas para cobrir todo o seu alcance, de 450 milhas náuticas, cerca de 800 km. Aqui, o cruzeiro dura 42 segundos, e a arrancada também foi encurtada. O navio está a apenas 1,5 km da rampa, enquanto um alvo real estaria a centenas de quilômetros. O relógio lá no alto conta o tempo do modelo. Então T+1:05 quer dizer um minuto e cinco segundos depois do início. Arraste a linha do tempo ou toque num dos botões abaixo para ir direto a um momento.", + "mode": "Casco", + "covered": "Distância percorrida", + "done": "Progresso do percurso" + }, + "hull": { + "title": "Ele para de boiar e passa a deslizar", + "p1": "Parado, o barco boia como qualquer outro. A água que ele empurra para o lado o sustenta, e isso se chama flutuação. Quando o barco ganha velocidade, entra em cena uma segunda força. A água bate no fundo inclinado e o empurra para cima, e quanto mais rápido o barco vai, mais forte é esse empurrão. Assim que esse empurrão passa a carregar a maior parte do peso, o casco sobe e desliza sobre a água. Isso é o planeio. Mesmo assim, a parte de trás do fundo continua sempre dentro da água.", + "p2": "Todo barco que abre caminho pela água forma uma onda na proa. A cerca de 5,7 nós, essa onda fica tão comprida quanto este casco de 5,5 m. Essa é a velocidade de casco, uma regra prática para barcos que só flutuam. Ela não é um limite de velocidade: a 42 nós, o Magura anda a cerca de 7 vezes essa velocidade. Para chegar lá, ele precisa subir a corcova de resistência, um trecho em que o barco faz ondas grandes e o arrasto, a força com que a água o segura, cresce rápido. As bordas afiadas ao longo do fundo, as quinas, também aumentam o arrasto nessa hora. Elas só compensam depois que o barco entra em planeio.", + "p3": "Mexa no controle deslizante ou toque num botão e observe a proa. Perto da corcova, ela sobe ao máximo e depois baixa quando o barco entra em planeio. Esse ângulo é o trim. O arrasto é menor quando esse ângulo fica em cerca de 3 a 4 graus, e na velocidade máxima o barco anda um pouco mais nivelado. Neste barco, o planeio começa por volta de 15 a 18 nós, e o mostrador do modelo muda aos 16. A partir daí, a parte molhada do fundo encolhe rápido: de cerca de 4,5 m a 15 nós para cerca de 1,5 a 2 m a 42 nós. Não há dados publicados sobre a corcova deste barco. Por isso, o pico de 6 graus e a divisão do peso nos mostradores são números do próprio modelo.", + "p4": "O fundo tem a forma de um V profundo, que amortece as pancadas das ondas. O ângulo desse V não foi publicado: o modelo usa 20 graus na popa, com o V ficando mais fechado em direção à proa. A popa reta tem uma borda inferior afiada. Assim, em velocidade, a água se solta dela de forma limpa e a popa corre seca. Ao longo de cada lateral, um fino defletor de respingos joga os respingos para fora; esse detalhe é um palpite do modelo. Os frisos de sustentação, saliências mais abaixo no casco que ajudam um barco a entrar em planeio, ficaram de fora, porque nenhuma foto os mostra. Trim demais também traz problemas: acima de certo ângulo, um barco em planeio começa a quicar num ritmo constante, o chamado efeito golfinho.", + "speedLabel": "Velocidade do barco", + "mode": "Casco", + "trim": "Ângulo da proa (trim)", + "lift": "Peso sustentado", + "wetted": "Comprimento molhado do fundo", + "ratio": "Em relação à velocidade de casco" + }, + "jet": { + "title": "Uma bomba no lugar da hélice", + "p1": "Na popa fica um propulsor a jato d'água, uma bomba que faz o trabalho de uma hélice. A água entra por uma abertura no fundo, atrás de uma grade de barras de aço. Um rotor, uma pequena peça giratória parecida com um ventilador, acelera essa água. Logo atrás dele, aletas fixas, o estator, desfazem o giro da água. Depois, um bocal estreita o jato e o dispara para trás do barco. A vista de fluxo de água mostra esse caminho.", + "p2": "O empurrão vem de acelerar a água que passa pela bomba. A força de propulsão é a massa de água que passa a cada segundo multiplicada pela diferença entre a velocidade de saída do jato e a velocidade do barco. Por isso, o jato sempre precisa ser mais rápido que o barco. Numa bomba ideal, a parte do trabalho que move o barco é 2 / (1 + velocidade do jato / velocidade do barco). Quando o jato sai a uma vez e meia a velocidade do barco, essa parte é de 80 %. Em baixa velocidade, o jato é muito mais rápido que o barco, e a maior parte do trabalho se perde.", + "p3": "Acima de cerca de 25 a 30 nós, um jato iguala ou supera uma hélice, e ainda tem outras vantagens. Nada fica pendurado embaixo do casco para criar arrasto ou bater em alguma coisa, então o barco pode andar em águas rasas. Em velocidade de planeio, a grade quase sempre se limpa sozinha, mas algas e cordas ainda podem entupir a entrada. Os primeiros barcos, em 2022, usavam peças de jet ski: um motor a gasolina Rotax de três cilindros e uma bomba Sea-Doo. Já o motor do próprio Magura não foi divulgado. A bomba do modelo tem o tamanho da de um jet ski, 155 mm de diâmetro, e a 42 nós movimenta mais ou menos 100 a 200 litros de água do mar por segundo.", + "p4": "Não há leme. Para virar, o bocal de direção desvia o jato até 27 graus para qualquer um dos lados. Gire o bocal para a direita e a popa é empurrada para a esquerda, então a proa vira para a direita, igualzinho a um jet ski. Para parar ou dar ré, uma concha de ré desce sobre o bocal e vira o jato para a frente e para baixo. O controle deslizante ajusta o acelerador, que o modelo trata como a quantidade de água que a bomba movimenta. Toque nos botões para virar ou dar ré, e aperte o play para devolver o barco ao percurso.", + "throttleLabel": "Acelerador", + "boat": "Velocidade do barco", + "flow": "Água que passa pela bomba", + "velocity": "Velocidade do jato", + "thrust": "Força de propulsão", + "efficiency": "Eficiência ideal", + "push": "Para onde o jato empurra" + }, + "link": { + "title": "Pilotado a centenas de quilômetros de distância", + "p1": "Sem ninguém a bordo, tudo depende da conexão de rádio. Em novembro de 2023, um Magura foi capturado no oeste da Crimeia, e fontes russas mostraram depois o que havia dentro dele. Encontraram dois terminais de satélite Starlink, uma antena de satélite reserva fabricada pela Kymeta, um receptor de GPS e um pequeno computador com Linux. Um roteador móvel com dois cartões SIM dava conexão perto da costa, num raio de cerca de 40 km. O fabricante também anuncia uma rede de rádio em malha, controle autônomo e até três transmissões de vídeo, mas essas são afirmações do próprio fabricante.", + "p2": "O piloto trabalha num console transportado numa maleta rígida, com telas, alavancas e joysticks. Viagens longas pelo mar podem ser programadas com antecedência, mas um piloto acompanha o trajeto inteiro, e o trecho final muitas vezes é conduzido à mão. Então o barco não é totalmente autônomo. Toque nos botões para alternar entre o Starlink, o satélite reserva e nenhuma conexão. O que um barco real faz quando perde a conexão não foi divulgado, então o modelo simplesmente o mantém no rumo definido.", + "p3": "Para um serviço por satélite, o Starlink é rápido. O atraso típico em terra é de cerca de 25 a 35 milissegundos, e no mar pode passar de 100. O limite mais apertado é o upload, de 10 a 30 megabits por segundo, mais ou menos o que três transmissões de vídeo exigem. Então é o upload, e não o atraso, que define a nitidez da imagem do piloto. Mesmo assim, a imagem sempre chega um pouco atrasada, e ninguém publicou quanto. Arraste o controle para testar um atraso: a 42 nós, um quarto de segundo equivale a 5,4 m, mais ou menos o comprimento do barco. O barco fantasma mostra onde o piloto o vê.", + "p4": "Achar o caminho também exige cuidado. Desde meados de 2024, há muitos registros de sinais de GPS bloqueados e falsificados sobre o mar Negro, com navios aparecendo em terra firme nos mapas de rastreamento. É por isso que o fabricante também anuncia navegação inercial e visual, que funcionam com os próprios sensores de movimento e câmeras do barco. O Starlink também tem uma história aqui. Em setembro de 2022, a Ucrânia esperava ter cobertura perto da Crimeia, mas essa cobertura nunca tinha sido ligada. Um livro de 2023 sobre Elon Musk a princípio contou a história de outro jeito, e o autor corrigiu o relato depois que Musk disse que a SpaceX não desativou nada. O modelo desenha os satélites pequenos, baixos e perto, acompanhando o barco, para que você consiga vê-los.", + "delayLabel": "Atraso do vídeo", + "now": "Onde o piloto o vê", + "top": "Mesmo atraso na velocidade máxima", + "carrier": "Conexão", + "boat": "O que o barco faz", + "path": "Como um comando chega ao barco", + "step1": "Piloto: mexe numa alavanca ou num joystick do console.", + "step2": "Estação terrestre: uma estação da Starlink em terra manda o comando para o espaço por rádio.", + "step3": "Satélite: um satélite Starlink repassa o comando para o barco.", + "step4": "Barco: uma antena no convés recebe o comando, e o computador mexe no acelerador ou no bocal de direção.", + "step5": "Vídeo: as imagens da câmera voltam pelo mesmo caminho e chegam ao piloto com um pouco de atraso." + }, + "horizon": { + "title": "Baixo o bastante para se esconder atrás da curvatura do mar", + "p1": "A superfície do mar se curva, então só dá para enxergar até certo ponto sobre ela. De uma altura de h metros, o horizonte fica a cerca de 3,86 × √h quilômetros. A câmera térmica do barco fica a cerca de 0,7 m acima da água, então o horizonte dela está a apenas uns 3 km. O mar em 3D do modelo é plano, porque, em 1,5 km, a curvatura real desce só cerca de 0,1 m. O desenho abaixo deixa a curva bem mais acentuada para que você consiga vê-la.", + "p2": "O radar de um navio tem o mesmo problema. A antena dele fica no alto, mas o barco tem só cerca de 0,5 m de altura. As ondas de rádio se curvam um pouco em volta da Terra. Assim, a linha de visada do radar chega a cerca de 4,12 × (√h1 + √h2) quilômetros, com as duas alturas em metros. Para um radar a 20 m de altura, isso dá cerca de 21 km, e a 42 nós o barco percorre essa distância em cerca de 16 minutos. Mas essa é só a maior distância a que o radar poderia ver o barco, e não onde ele de fato o detecta. Arraste o controle para subir ou baixar o radar.", + "p3": "A detecção de verdade vem bem depois. Um barco pequeno devolve um eco fraco. A seção reta radar, o tamanho de alvo que o barco parece ter para um radar, fica em torno de 0,5 a 5 m², e nenhum número foi publicado para o Magura. Uma norma para radares costeiros espera que um alvo de 1 m², a 1 m acima do mar, seja encontrado a cerca de 9 km, e só por um radar avançado, com o mar até o estado 4. A 42 nós, o barco percorre essa distância em cerca de 7 minutos. As ondas dificultam ainda mais: um barco pequeno pode ficar escondido atrás delas até metade do tempo, e os ecos das próprias ondas, chamados de retorno do mar, podem abafar o eco dele.", + "p4": "Quem opera esses barcos diz que eles são difíceis de detectar. Um comandante do Grupo 13, a unidade de drones navais do GUR, disse que o radar tem dificuldade para enxergar o casco de plástico e que ele emite pouco calor. São afirmações, e nenhuma medição foi publicada. Uma coisa está registrada: o motor fica numa cuba de alumínio revestida de espuma, com a eletrônica por cima, o que mantém o calor longe da superfície externa. Os barcos são pintados de cinza, e os ataques mais conhecidos aconteceram à noite ou ao amanhecer. Toque nos botões para mudar o estado do mar, uma escala de altura das ondas que vai do mar calmo ao agitado.", + "caption": "Vista lateral do radar do navio, da curvatura do mar e do barco. A curvatura e as alturas estão exageradas.", + "heightLabel": "Altura do radar", + "radar": "Linha de visada do radar", + "minutes": "Tempo para percorrê-la a 42 nós", + "camera": "Horizonte da câmera do barco", + "detect": "Detecção esperada", + "waves": "Altura das ondas", + "hidden": "Escondido atrás das ondas" + }, + "fleet": { + "title": "Barato, pequeno e mudando rápido", + "p1": "Um Magura V5 custa cerca de um quarto de milhão de dólares. O fabricante fala em cerca de US$ 273 mil, e outras estimativas, em cerca de US$ 250 mil. O Sea Baby, um projeto à parte do SBU, o serviço de segurança da Ucrânia, custa cerca de US$ 221 mil. O GUR avalia em cerca de US$ 65 milhões o navio-patrulha Sergey Kotov, que afundou em março de 2024 depois de um ataque de barcos Magura. São números públicos, mostrados lado a lado nos mostradores abaixo.", + "p2": "Esses barcos raramente atuam sozinhos. Os ataques relatados usaram grupos de cerca de cinco a dez, e os desenvolvedores dizem que cada barco do grupo tem uma função própria. Os navios que, segundo os relatos, afundaram levaram vários impactos, e não um só. No percurso, dois barcos acompanhantes se juntam ao barco na arrancada; a formação deles é uma escolha do modelo.", + "p3": "O projeto não para de mudar. Em dezembro de 2024, o GUR equipou barcos V5 com dois mísseis ar-ar R-73 em trilhos, rebatizados de Sea Dragon. Toque nos botões para ver essa configuração. Só o convés muda: o barco do modelo faz o mesmo trajeto e continua terminando ao lado do navio. Em 2025, o GUR apresentou o Magura V7, maior, com 7,2 m de comprimento e carga de até 650 kg. O Sea Baby é uma linha própria do SBU, e não um Magura.", + "p4": "O registro abaixo se limita ao que cada lado declarou, e muitas dessas afirmações são contestadas. O Ministério da Defesa do Reino Unido conta pelo menos 26 navios da Marinha russa danificados ou destruídos entre fevereiro de 2022 e junho de 2024, cerca de um terço da Frota do Mar Negro. Essa conta inclui todo tipo de arma, e não só barcos-drone. Até outubro de 2023, imagens de satélite mostravam que cerca de 14 navios tinham sido levados de Sebastopol para Novorossiysk.", + "carries": "A bordo", + "cost": "Custo por barco", + "ship": "Navio-patrulha, segundo a avaliação do GUR", + "events": "Registro público, conforme relatado", + "event1": "29 de outubro de 2022, Sebastopol: drones navais e aéreos atingiram a fragata Admiral Makarov e um caça-minas. A Rússia admitiu apenas danos leves.", + "event2": "Noite de 31 de janeiro para 1º de fevereiro de 2024, lago Donuzlav: barcos Magura V5 afundaram a corveta lança-mísseis Ivanovets. O GUR fala em seis impactos; o vídeo mostra cerca de três.", + "event3": "Noite de 4 para 5 de março de 2024, perto do estreito de Kerch: barcos Magura V5 afundaram o navio-patrulha Sergey Kotov. Autoridades ucranianas disseram que cinco barcos participaram.", + "event4": "31 de dezembro de 2024, cabo Tarkhankut: o GUR diz que barcos Magura V5 derrubaram helicópteros Mi-8 com mísseis R-73, as primeiras aeronaves abatidas a partir de um drone naval. A princípio, falou em um; depois, em dois.", + "event5": "2 de maio de 2025, perto de Novorossiysk: o GUR diz que barcos Magura V7 derrubaram dois caças Su-30SM com mísseis Sidewinder. A Rússia não confirmou." + } + } +} From 8d36d1e55320543a0055c1673732d218acb426a5 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:01:44 +0200 Subject: [PATCH 11/99] feat(naval-drone): add the Chinese copy --- explainers/naval-drone/locales/zh.json | 322 +++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 explainers/naval-drone/locales/zh.json diff --git a/explainers/naval-drone/locales/zh.json b/explainers/naval-drone/locales/zh.json new file mode 100644 index 00000000..d9bc1bd0 --- /dev/null +++ b/explainers/naval-drone/locales/zh.json @@ -0,0 +1,322 @@ +{ + "meta": { + "title": "海上无人艇如何工作", + "eyebrow": "海上无人艇", + "tagline": "一艘小艇,艇上没有人:船体贴着水面滑行,一台水泵推着它走,远方的人通过卫星链路操控它。", + "description": "用3D模型跟着Magura V5海上无人艇跑一趟:升起滑行的船体、喷水推进器、卫星链路,还有低矮的小艇为什么难被发现。", + "summary": "滑行的船体、既推进又转向的喷水推进器、卫星链路及其延迟、海面的弧度,还有一款不断变化的设计。", + "socialAlt": "黎明时分,一艘灰色的无人艇贴着海面疾驰,浪花从船底两侧的折角线溅起。旁边是标题:海上无人艇如何工作。" + }, + "stage": { + "label": "海上无人艇互动模型", + "gauge": "实时航行读数" + }, + "controls": { + "label": "无人艇模型控制", + "views": { + "cutaway": { + "name": "剖面", + "short": "剖面", + "hint": "剖面(C)" + }, + "flow": { + "name": "水流", + "short": "水流", + "hint": "水流(W)" + }, + "links": { + "name": "无线电链路", + "short": "链路", + "hint": "无线电链路(K)" + }, + "labels": { + "name": "标注", + "short": "标注", + "hint": "标注(L)" + } + }, + "fit": { + "label": "甲板配置" + }, + "fitOptions": { + "standard": "标准", + "missile": "导弹导轨" + }, + "momentOptions": { + "hullSpeed": "船体速度", + "humpPeak": "阻力峰顶点", + "onPlane": "进入滑行", + "cruiseSpeed": "巡航", + "throttleUp": "开始冲刺", + "topSpeed": "最高速度", + "alongside": "抵达舰旁" + }, + "speedMarkOptions": { + "hullSpeed": "船体速度", + "hump": "阻力峰", + "planing": "滑行", + "cruise": "巡航", + "top": "最高速度" + }, + "helmOptions": { + "left": "左转", + "straight": "直行", + "right": "右转", + "reverse": "倒车" + }, + "linkOptions": { + "satellite": "星链", + "backup": "备用卫星", + "lost": "链路中断" + }, + "seaStateOptions": { + "smooth": "小浪", + "slight": "轻浪", + "moderate": "中浪", + "rough": "大浪" + } + }, + "timeline": { + "label": "航行时间", + "phases": "跳到航程的某个阶段", + "speed": "播放速度", + "value": "{{time}},{{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "相对于正常播放速度", + "phase": { + "launch": "出发", + "hump": "阻力峰", + "cruise": "巡航", + "sprint": "冲刺", + "arrival": "抵达" + }, + "during": { + "launch": "小艇低速驶离下水滑道,速度还不到船体速度", + "hump": "油门全开,小艇冲过阻力峰,进入滑行状态", + "cruise": "小艇以22节巡航,原本好几个小时的航程在这里大大缩短", + "sprint": "小艇加速到42节,另外两艘小艇也加入进来", + "arrival": "小艇驶完最后170米,来到舰船的侧舷" + }, + "jump": { + "launch": "跳到出发", + "hump": "跳到阻力峰", + "cruise": "跳到巡航", + "sprint": "跳到冲刺", + "arrival": "跳到抵达" + } + }, + "readouts": { + "clock": "航行时钟", + "speed": "航速", + "mode": "船体状态", + "distance": "离舰船还有" + }, + "mode": { + "floating": "漂浮", + "hump": "正在爬过阻力峰", + "planing": "滑行" + }, + "units": { + "speed": "{{kn}}节,{{kmh}}千米/小时", + "m": "{{value}}米", + "km": "{{value}}千米", + "percent": "{{value}}%", + "degrees": "{{value}}°", + "kilonewtons": "{{value}}千牛", + "mps": "{{ms}}米/秒,{{kmh}}千米/小时", + "ms": "{{value}}毫秒", + "minutes": "{{value}}分钟" + }, + "parts": { + "hull": "复合材料船体", + "chines": "折角线", + "sprayRails": "防溅条", + "payloadBay": "载荷舱", + "fuelTanks": "油箱", + "engine": "装在舱槽里的发动机", + "electronicsBay": "电子设备", + "starlinkPanels": "星链天线", + "backupPanel": "备用卫星天线", + "cameraDome": "热成像摄像机罩", + "bowCamera": "艇首摄像机", + "intake": "进水口和格栅", + "duct": "进水管道", + "driveShaft": "传动轴", + "impeller": "叶轮", + "waterjet": "喷水推进器", + "stator": "定子导叶", + "nozzle": "喷口", + "steeringNozzle": "转向喷口", + "reverseBucket": "倒车斗", + "missileRails": "导弹导轨", + "jetStream": "喷流", + "bowWave": "艏波", + "spray": "浪花", + "wake": "尾迹", + "wettedLength": "船底浸水长度", + "videoGhost": "操作员看到的位置", + "satLink": "星链链路", + "backupLink": "备用链路", + "satellite": "星链卫星", + "backupSatellite": "备用卫星", + "groundStation": "地面站", + "ship": "锚泊的舰船", + "shipRadar": "舰船雷达", + "companions": "同行的小艇" + }, + "hull": { + "lift": "{{dynamic}}%靠速度托起,{{buoyant}}%靠浮力", + "wetted": "{{m}}米,静止时为{{rest}}米", + "ratio": "是船体速度{{hullSpeed}}节的{{times}}倍", + "ratioStill": "静止不动。船体速度为{{hullSpeed}}节" + }, + "jet": { + "flow": "每秒{{kg}}千克,约{{litres}}升", + "backing": "正在停船,倒车斗已放下", + "astern": "被倒车斗折向前方", + "noEfficiency": "无,因为小艇没有向前走", + "push": { + "left": "喷口向左偏{{angle}}°:船头向左转", + "right": "喷口向右偏{{angle}}°:船头向右转", + "straight": "笔直向后:小艇保持航向", + "reverse": "倒车斗放下:水流朝前下方喷出" + } + }, + "link": { + "now": "航速{{kn}}节时,在小艇后方{{m}}米", + "nowLost": "链路中断时没有画面", + "top": "42节时为{{m}}米,相当于{{lengths}}个艇长", + "carrier": { + "satellite": "星链,两台终端", + "backup": "备用卫星天线", + "lost": "无" + }, + "boat": { + "satellite": "操作员看着视频操控", + "backup": "操作员通过备用链路操控", + "lost": "在模型里,它保持设定的航向;真实的小艇会怎样,没有公开" + } + }, + "horizon": { + "camera": "{{km}}千米,镜头离水面{{m}}米", + "detect": "约{{km}}千米,以42节航速约需{{min}}分钟", + "detectBeyond": "不在范围内:该标准只覆盖到4级海况", + "waves": { + "smooth": "{{from}}到{{to}}米,2级海况", + "slight": "{{from}}到{{to}}米,3级海况", + "moderate": "{{from}}到{{to}}米,4级海况", + "rough": "{{from}}到{{to}}米,5级海况" + }, + "hidden": { + "smooth": "很少:浪比它0.5米高的甲板还低", + "slight": "最多一半时间:浪和它的甲板一样高", + "moderate": "最多一半时间:浪比它的甲板还高", + "rough": "最多一半时间:浪远远高过它的甲板" + }, + "canvas": { + "radar": "舰船雷达", + "boat": "小艇,高0.5米", + "lineOfSight": "视距", + "detection": "预计发现距离", + "km": "{{value}}千米", + "note": "海面弧度已夸大" + } + }, + "fleet": { + "carries": { + "standard": "艇首载荷最多{{kg}}千克", + "missile": "导轨上挂两枚R-73导弹;模型保留了艇首载荷舱" + }, + "cost": "约{{from}}到{{to}}美元", + "ship": "约{{value}}百万美元" + }, + "sections": { + "overview": { + "title": "快艇上,一个人也没有", + "p1": "你眼前是一艘Magura V5,这是乌克兰的一款海上无人艇,说白了,就是一艘没人在上面的快艇。它由国营企业SpetsTechnoExport为乌克兰国防部情报总局(GUR)制造。2023年7月,它在伊斯坦布尔的IDEF防务展上首次亮相。艇长5.5米,宽1.5米,和一艘小快艇差不多大。它的甲板离水面只有0.5米,整艇重量不到1吨。一名远在别处的操作员通过卫星链路操控它。", + "p2": "打开剖面,就能看到里面。船体是复合材料做的:一层层纤维布,封在坚硬的树脂里。艇首是一个封闭的载荷舱,最多能装320千克。它后面是油箱,再往后是发动机。发动机装在一个铝制舱槽里,舱槽嵌在泡沫中,电子设备就在它上方。发动机带动艇尾的喷水推进器。制造商没有说用的是哪款发动机,所以模型假定它是水上摩托那一类的汽油发动机,早期的小艇用的就是这种。甲板上有两副星链天线、一副备用卫星天线、一个装着热成像摄像机的低矮圆罩,艇首还有一台固定摄像机。", + "p3": "模型从头到尾演示一次出航。小艇低速驶离下水滑道,进入滑行状态,然后以22节巡航。1节就是每小时1海里,所以22节约合每小时41千米。接着它加速到42节的最高速度,约合每小时78千米,另外两艘小艇也加入进来。等它的艇首抵达一艘锚泊的大型灰色舰船侧面,这次出航就结束了。", + "p4": "按正常速度播放时,小艇跑得和真艇一样快,只是整趟航程被压缩进了两分钟。真正的小艇要巡航好几个小时:跑完450海里(约800千米)的最大航程,大约要20小时。这里的巡航只有42秒,冲刺也缩短了。舰船离下水滑道只有1.5千米,而真实的目标会在几百千米之外。顶部的时钟按模型时间计时,所以T+1:05的意思是开始后1分5秒。拖动时间轴,或点下方的按钮,就能跳到某个时刻。", + "mode": "船体状态", + "covered": "已航行距离", + "done": "航程进度" + }, + "hull": { + "title": "不再浮着,开始滑行", + "p1": "静止时,这艘小艇和别的船一样浮在水上。它排开的水把它托住,这叫浮力。小艇一加速,就多了第二种力。水撞上倾斜的船底,把船底往上推。小艇跑得越快,这股推力就越大。等这股推力托起了大部分重量,船体就会抬起来,贴着水面滑过去。这就是滑行。即使这样,船底靠后的部分也始终留在水里。", + "p2": "每艘在水里前进的船,都会推起一道艏波。航速约5.7节时,这道波正好和这艘5.5米的船体一样长。这就是船体速度。对只靠浮力浮着的船来说,这是一条经验法则。它并不是速度上限:Magura跑到42节时,速度约是它的7倍。要跑到这么快,小艇得先爬过阻力峰。这时小艇掀起大浪,阻力急剧上升。阻力就是水拖住小艇、不让它前进的力。船底两侧那两道锋利的棱边叫折角线,在这一段也会增加阻力。要等小艇滑行起来,它们才显出好处。", + "p3": "拖动滑块,或点一下按钮,留意船头。快到阻力峰时,船头抬得最高;小艇滑行起来以后,船头又慢慢放低。这个角度叫纵倾。纵倾约3到4度时阻力最小,到了最高速度,小艇会跑得稍平一点。这艘小艇大约在15到18节开始滑行,模型的读数在16节时切换。之后,船底浸在水里的长度迅速缩短:15节时约4.5米,到42节时只剩约1.5到2米。这艘小艇的阻力峰数据没有公开,所以读数里6度的纵倾峰值,以及重量怎么分担,都是模型自己定的数字。", + "p4": "船底是很深的V形,能减轻海浪的撞击。它的角度没有公开:模型在艇尾给它20度,越往船头越尖。方形艇尾的下缘是一道锋利的棱边,所以高速时水流会在这里干净地脱开,艇尾的端面不沾水。两侧各有一条细细的防溅条,把浪花往外甩;这是模型自己猜的。升力条是船底更低处的凸棱,能帮船体升起、进入滑行。没有哪张照片能看到它们,所以模型没有画。纵倾太大也会惹麻烦:超过某个角度,滑行艇就会开始有节奏地一起一伏,这叫海豚跳。", + "speedLabel": "航速", + "mode": "船体状态", + "trim": "船头角度(纵倾)", + "lift": "重量由谁托起", + "wetted": "船底浸水长度", + "ratio": "与船体速度相比" + }, + "jet": { + "title": "用水泵代替螺旋桨", + "p1": "艇尾装着一台喷水推进器。它是一台水泵,干的是螺旋桨的活。水从船底的一个开口进来,开口处挡着一排钢条做的格栅。里面有一个旋转的叶轮,它是一个像风扇的小转子,负责给水加速。叶轮后面有一圈固定的叶片,叫定子,负责把水流里的旋转消掉。最后,喷口把水流收窄,朝小艇后方喷出去。打开“水流”视图,就能看到水走的路线。", + "p2": "小艇向前的推力,来自把流过水泵的水加速。推力等于每秒流过的水的质量,乘以喷流离开时比小艇快出的那部分速度。所以喷流必须永远比小艇快。在理想的水泵里,用来推动小艇的功所占的比例是 2 / (1 + 喷流速度 / 艇速)。喷流速度是艇速的1.5倍时,这个比例是80%。低速时,喷流比小艇快得多,大部分功都白白浪费了。", + "p3": "航速超过约25到30节以后,喷水推进器就能赶上甚至超过螺旋桨,而且它还有别的长处。船体下面没有东西悬着,不会增加阻力,也不会撞上东西,所以小艇能在浅水里跑。到了滑行速度,格栅大多能自己保持通畅,但水草和绳子仍然可能堵住进水口。2022年的第一批小艇用的是水上摩托的零件:一台三缸Rotax汽油发动机和一台Sea-Doo水泵。Magura自己用的发动机没有公开。模型里的水泵是水上摩托的尺寸,直径155毫米,在42节时每秒大约抽送100到200升海水。", + "p4": "它没有舵。要转弯时,转向喷口会把水流偏向一侧,左右最多各27度。喷口往右偏,艇尾就被推向左边,于是船头向右转,和水上摩托一模一样。要停船或倒车时,倒车斗会落下来罩住喷口,把水流转向前下方。滑块控制油门,模型把它看作水泵抽送的水量。点按钮可以转向或倒车,按下播放键,小艇就回到原来的航程中。", + "throttleLabel": "油门", + "boat": "航速", + "flow": "流过水泵的水", + "velocity": "喷流速度", + "thrust": "推力", + "efficiency": "理想效率", + "push": "喷流往哪里推" + }, + "link": { + "title": "在几百千米外遥控", + "p1": "艇上没有人,一切都要靠无线电链路。2023年11月,一艘Magura在克里米亚西部被缴获,后来俄方消息来源展示了它里面的东西。他们找到了两台星链卫星终端、一副Kymeta公司制造的备用卫星天线、一个GPS接收机,还有一台运行Linux的小电脑。在离岸约40千米以内,一台装着两张SIM卡的移动路由器也能提供连接。制造商还列出了网状无线电网络、自主控制和最多三路视频流,不过这些只是它自己的说法。", + "p2": "操作员面前是一台控制台,装在一个可以带着走的硬箱里,上面有屏幕、推杆和摇杆。横跨大海的长途航行可以事先编好程序,但操作员会全程盯着,最后一段往往由人手动操控。所以这艘小艇并不是完全自主的。点按钮,可以在星链、备用卫星和完全没有链路之间切换。真实的小艇断了链路后会怎么做,没有公开,所以模型只让它保持设定的航向。", + "p3": "在卫星服务里,星链算是快的。在陆地上,它的典型延迟约为25到35毫秒,在海上可能超过100毫秒。更紧的限制是上传速度,每秒10到30兆比特,大约正好够三路视频流用。所以决定操作员画面清晰度的,是上传速度,而不是延迟。不过画面总会晚到一点,至于晚多少,没有人公布过。拖动滑块,试试不同的延迟:在42节时,四分之一秒就是5.4米,大约一个艇长。那艘影子小艇,就是操作员眼中它所在的位置。", + "p4": "认路同样要小心。从2024年年中开始,黑海上空的GPS信号受到干扰和伪造,这一点已有大量记录,有些船在船舶追踪地图上甚至显示在内陆。所以制造商还列出了惯性导航和视觉导航,它们靠的是小艇自己的运动传感器和摄像机。星链在这里也有一段故事。2022年9月,乌克兰以为克里米亚附近有信号覆盖,可那里的覆盖从来就没有开通过。2023年一本关于埃隆·马斯克的书,起初讲的是另一个版本。后来马斯克说SpaceX并没有关闭任何东西,作者便更正了书中的说法。模型把卫星画得又小、又低、又近,还跟着小艇一起移动,好让你看得见。", + "delayLabel": "视频延迟", + "now": "操作员看到的位置", + "top": "同样的延迟,最高速度时", + "carrier": "链路", + "boat": "小艇怎么做", + "path": "指令怎样传到小艇上", + "step1": "操作员:在控制台上推一下推杆或摇杆。", + "step2": "地面站:陆地上的星链地面站用无线电把指令发上去。", + "step3": "卫星:一颗星链卫星把指令转发给小艇。", + "step4": "小艇:甲板上的天线收到指令,艇上的电脑随即调节油门或转向喷口。", + "step5": "视频:摄像机拍到的画面沿同一条路传回去,到操作员那里时已经晚了一点。" + }, + "horizon": { + "title": "矮到能躲在海面的弧度后面", + "p1": "海面是弯的,所以在海上,你能看到的距离是有限的。从h米的高度望出去,地平线大约在3.86 × √h千米之外。小艇的热成像摄像机离水面约0.7米,所以它的地平线只在约3千米外。模型里的3D海面是平的,因为在1.5千米的距离上,真实的弧度只下降约0.1米。下面的图把弧度画得陡得多,好让你看清楚。", + "p2": "舰船雷达也有同样的问题。它的天线架得很高,可小艇只有约0.5米高。无线电波会顺着地球表面稍稍弯曲,所以雷达的视距约为4.12 × (√h1 + √h2)千米,两个高度都以米为单位。雷达离水面20米时,视距约为21千米,小艇以42节跑完这段距离约需16分钟。但这只是雷达最远可能看到小艇的距离,并不是它实际发现小艇的地方。拖动滑块,可以升高或降低雷达。", + "p3": "真正发现小艇,要晚得多。小艇反射回去的回波很弱。它的雷达截面积,也就是它在雷达眼里相当于多大的目标,约为0.5到5平方米,Magura的这个数字从来没有公布过。有一项海岸雷达标准规定:一个1平方米大、离海面1米高的目标,应在约9千米处被发现,而且这只是对先进雷达、在4级及以下海况中的要求。小艇以42节跑完这段距离约需7分钟。海浪让发现变得更难:小艇最多有一半时间会藏在浪后面,海浪自己反射的回波,也就是海杂波,还可能把小艇的回波淹没。", + "p4": "操作这些小艇的人说,它们很难被发现。情报总局的海上无人艇部队叫“第13小组”,它的一名指挥官说,雷达很难看到塑料船体,而且小艇散发的热量很少。这些只是他们的说法,没有公布过任何测量结果。有一点是有据可查的:发动机装在内衬泡沫的铝制舱槽里,电子设备在它上方,这样热量就不容易传到外壳上。小艇漆成灰色,最出名的几次袭击都发生在夜里或黎明。点按钮可以改变海况,这是按浪高分的等级,从小浪到大浪。", + "caption": "侧视图:舰船雷达、海面弧度和小艇。弧度和高度都画得夸大了。", + "heightLabel": "雷达高度", + "radar": "雷达视距", + "minutes": "以42节跑完所需时间", + "camera": "小艇摄像机的地平线", + "detect": "预计发现距离", + "waves": "浪高", + "hidden": "藏在浪后" + }, + "fleet": { + "title": "便宜、小巧,变化很快", + "p1": "一艘Magura V5大约值25万美元。制造商的报价约为27.3万美元,其他估计约为25万美元。Sea Baby是乌克兰安全局(SBU)另外研制的一款无人艇,造价约22.1万美元。2024年3月,“谢尔盖·科托夫”号巡逻舰遭Magura无人艇袭击后沉没,情报总局估计它价值约6500万美元。这些都是公开的数字,下面的读数把它们并排列了出来。", + "p2": "这些小艇很少单独行动。据报道,每次袭击大约用五到十艘小艇编成一组,研发人员说,组里每艘小艇都有自己的分工。据报道被击沉的那些舰船,都是挨了好几次打击,而不是一次。在这次出航中,有两艘同行的小艇在冲刺时加入;它们怎么排列,是模型自己定的。", + "p3": "这款设计一直在变。2024年12月,情报总局给V5小艇装上导轨,挂上两枚R-73空空导弹,这种导弹被改称为“海龙”。点按钮就能看到这种配置。变的只有甲板:模型里的小艇走的还是同一条路线,最后仍然来到舰船旁边。2025年,情报总局展示了更大的Magura V7,艇长7.2米,载荷最多650千克。Sea Baby是乌克兰安全局自己的系列,不是Magura。", + "p4": "下面的记录只列出各方自己说过的话,其中很多说法都有争议。英国国防部统计,从2022年2月到2024年6月,至少有26艘俄罗斯海军舰船被击伤或击毁,约占黑海舰队的三分之一。这个数字包括各种武器造成的损失,不只是无人艇。到2023年10月,卫星图像显示,约有14艘舰船从塞瓦斯托波尔转移到了新罗西斯克。", + "carries": "艇上装载", + "cost": "每艘造价", + "ship": "巡逻舰,情报总局的估价", + "events": "公开记录,据报道", + "event1": "2022年10月29日,塞瓦斯托波尔:海上无人艇和空中无人机袭击了“马卡罗夫海军上将”号护卫舰和一艘扫雷舰。俄罗斯只承认受到轻微损伤。", + "event2": "2024年1月31日至2月1日夜间,多努兹拉夫湖:Magura V5无人艇击沉了“伊万诺韦茨”号导弹艇。情报总局称命中六次;视频里能看到约三次。", + "event3": "2024年3月4日至5日夜间,刻赤海峡附近:Magura V5无人艇击沉了“谢尔盖·科托夫”号巡逻舰。乌克兰官员说,共有五艘小艇参与。", + "event4": "2024年12月31日,塔尔汉库特角:情报总局称,Magura V5无人艇用R-73导弹击落了Mi-8直升机,这是首次有飞机被海上无人艇击落。它起初说击落一架,后来改口说两架。", + "event5": "2025年5月2日,新罗西斯克附近:情报总局称,Magura V7无人艇用“响尾蛇”导弹击落了两架Su-30SM战机。俄罗斯尚未证实。" + } + } +} From ba43a70b584ad1b8a13748bee1da528dfa9f5f62 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:02:01 +0200 Subject: [PATCH 12/99] feat(naval-drone): add the Japanese copy --- explainers/naval-drone/locales/ja.json | 322 +++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 explainers/naval-drone/locales/ja.json diff --git a/explainers/naval-drone/locales/ja.json b/explainers/naval-drone/locales/ja.json new file mode 100644 index 00000000..f85a4195 --- /dev/null +++ b/explainers/naval-drone/locales/ja.json @@ -0,0 +1,322 @@ +{ + "meta": { + "title": "水上ドローンが動くしくみ", + "eyebrow": "水上ドローン", + "tagline": "だれも乗っていない小さなボートです。船体で水面を滑るように走り、水を噴き出すポンプで進み、衛星通信で操られます。", + "description": "水上ドローンのマグラV5を3Dでたどります。滑走に入る船体、ウォータージェット、衛星通信、そして低いボートが見つけにくいわけを見ていきます。", + "summary": "水面を滑走する船体、ボートを進めて向きも変えるウォータージェット、衛星通信とその遅れ、海面の丸み、そして変わり続ける設計。", + "socialAlt": "夜明けの海を滑るように走る、灰色の無人ボート。船底の角(チャイン)から、しぶきが飛び散っています。横には「水上ドローンが動くしくみ」というタイトルがあります。" + }, + "stage": { + "label": "水上ドローンのインタラクティブモデル", + "gauge": "航走中のリアルタイム計測" + }, + "controls": { + "label": "水上ドローンの操作", + "views": { + "cutaway": { + "name": "断面表示", + "short": "断面", + "hint": "断面表示(C)" + }, + "flow": { + "name": "水の流れ", + "short": "水流", + "hint": "水の流れ(W)" + }, + "links": { + "name": "無線通信", + "short": "通信", + "hint": "無線通信(K)" + }, + "labels": { + "name": "ラベル", + "short": "ラベル", + "hint": "ラベル(L)" + } + }, + "fit": { + "label": "甲板の装備" + }, + "fitOptions": { + "standard": "標準", + "missile": "ミサイルレール" + }, + "momentOptions": { + "hullSpeed": "船体速度", + "humpPeak": "抵抗の山の頂上", + "onPlane": "滑走中", + "cruiseSpeed": "巡航中", + "throttleUp": "全速力へ加速", + "topSpeed": "最高速度", + "alongside": "船に到着" + }, + "speedMarkOptions": { + "hullSpeed": "船体速度", + "hump": "抵抗の山", + "planing": "滑走", + "cruise": "巡航", + "top": "最高速度" + }, + "helmOptions": { + "left": "左", + "straight": "直進", + "right": "右", + "reverse": "後進" + }, + "linkOptions": { + "satellite": "スターリンク", + "backup": "予備の衛星", + "lost": "通信途絶" + }, + "seaStateOptions": { + "smooth": "なめらか", + "slight": "やや波がある", + "moderate": "かなり波がある", + "rough": "波がやや高い" + } + }, + "timeline": { + "label": "航走の経過時間", + "phases": "航走の場面を選んで移動", + "speed": "再生速度", + "value": "{{time}}、{{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "通常の再生速度に対する倍率", + "phase": { + "launch": "出発", + "hump": "抵抗の山", + "cruise": "巡航", + "sprint": "全速力", + "arrival": "到着" + }, + "during": { + "launch": "ボートがアイドリングでスロープを離れ、船体速度より遅く進む", + "hump": "フルスロットルでボートが抵抗の山を越え、滑走に入る", + "cruise": "本来なら何時間もかかる道のりを縮めて、ボートが22ノットで巡航する", + "sprint": "ボートが42ノットまで加速し、さらに2隻が加わる", + "arrival": "ボートが最後の170mを走り、船の側面にたどり着く" + }, + "jump": { + "launch": "出発の場面へ移動", + "hump": "抵抗の山の場面へ移動", + "cruise": "巡航の場面へ移動", + "sprint": "全速力の場面へ移動", + "arrival": "到着の場面へ移動" + } + }, + "readouts": { + "clock": "航走の経過時間", + "speed": "速さ", + "mode": "船体の状態", + "distance": "船までの距離" + }, + "mode": { + "floating": "浮いている", + "hump": "抵抗の山を登っている", + "planing": "滑走している" + }, + "units": { + "speed": "{{kn}}ノット、時速{{kmh}}km", + "m": "{{value}}m", + "km": "{{value}}km", + "percent": "{{value}}%", + "degrees": "{{value}}°", + "kilonewtons": "{{value}}kN", + "mps": "秒速{{ms}}m、時速{{kmh}}km", + "ms": "{{value}}ミリ秒", + "minutes": "{{value}}分" + }, + "parts": { + "hull": "複合材の船体", + "chines": "チャイン", + "sprayRails": "スプレーレール", + "payloadBay": "ペイロード区画", + "fuelTanks": "燃料タンク", + "engine": "ケースに収めたエンジン", + "electronicsBay": "電子機器", + "starlinkPanels": "スターリンクのアンテナ", + "backupPanel": "予備の衛星アンテナ", + "cameraDome": "赤外線カメラのドーム", + "bowCamera": "船首のカメラ", + "intake": "取水口と格子", + "duct": "取水ダクト", + "driveShaft": "駆動軸", + "impeller": "インペラー", + "waterjet": "ウォータージェット", + "stator": "ステーターの羽根", + "nozzle": "ノズル", + "steeringNozzle": "操舵ノズル", + "reverseBucket": "リバースバケット", + "missileRails": "ミサイルレール", + "jetStream": "噴流", + "bowWave": "船首波", + "spray": "しぶき", + "wake": "航跡", + "wettedLength": "水につかった船底の長さ", + "videoGhost": "パイロットに見えている位置", + "satLink": "スターリンクの通信", + "backupLink": "予備の通信", + "satellite": "スターリンク衛星", + "backupSatellite": "予備の衛星", + "groundStation": "地上局", + "ship": "停泊中の船", + "shipRadar": "船のレーダー", + "companions": "仲間のボート" + }, + "hull": { + "lift": "速さで{{dynamic}}%、浮力で{{buoyant}}%", + "wetted": "{{m}}m、止まっているときは{{rest}}m", + "ratio": "船体速度{{hullSpeed}}ノットの{{times}}倍", + "ratioStill": "停止中。船体速度は{{hullSpeed}}ノット" + }, + "jet": { + "flow": "毎秒{{kg}}kg、約{{litres}}リットル", + "backing": "バケットを下ろして停止中", + "astern": "バケットで前向きに変えている", + "noEfficiency": "ボートが前へ進んでいないあいだはなし", + "push": { + "left": "ノズルを左へ{{angle}}°:船首が左を向く", + "right": "ノズルを右へ{{angle}}°:船首が右を向く", + "straight": "まっすぐ後ろへ:ボートは針路を保つ", + "reverse": "バケットを下ろす:噴流は前と下へ向かう" + } + }, + "link": { + "now": "{{kn}}ノットで、ボートの{{m}}m後ろ", + "nowLost": "通信が切れているあいだは映像なし", + "top": "42ノットで{{m}}m、ボートの長さの{{lengths}}倍", + "carrier": { + "satellite": "スターリンク、端末2台", + "backup": "予備の衛星アンテナ", + "lost": "なし" + }, + "boat": { + "satellite": "パイロットが映像を見ながら操る", + "backup": "パイロットが予備の通信で操る", + "lost": "モデルでは設定した針路を保つ。本物のボートがどうするかは公表されていない" + } + }, + "horizon": { + "camera": "{{km}}km、レンズは水面から{{m}}mの高さ", + "detect": "約{{km}}km、42ノットで{{min}}分", + "detectBeyond": "対象外:基準が扱うのは海況4まで", + "waves": { + "smooth": "{{from}}mから{{to}}m、海況2", + "slight": "{{from}}mから{{to}}m、海況3", + "moderate": "{{from}}mから{{to}}m、海況4", + "rough": "{{from}}mから{{to}}m、海況5" + }, + "hidden": { + "smooth": "まれ:波は高さ0.5mの甲板より低い", + "slight": "最大で半分の時間:波が甲板と同じくらいの高さ", + "moderate": "最大で半分の時間:波が甲板より高い", + "rough": "最大で半分の時間:波が甲板よりはるかに高い" + }, + "canvas": { + "radar": "船のレーダー", + "boat": "ボート、高さ0.5m", + "lineOfSight": "見通し距離", + "detection": "探知の目安", + "km": "{{value}}km", + "note": "海面の丸みは誇張しています" + } + }, + "fleet": { + "carries": { + "standard": "船首に最大{{kg}}kgのペイロード", + "missile": "レールにR-73ミサイル2発。モデルでは船首の区画も残している" + }, + "cost": "約{{from}}から{{to}}ドル", + "ship": "約{{value}}百万ドル" + }, + "sections": { + "overview": { + "title": "だれも乗っていないスピードボート", + "p1": "いま見ているのは、ウクライナの水上ドローンマグラV5です。だれも乗っていない、速いボートです。国営企業のスペツテクノエクスポルトが、ウクライナ国防省情報総局(GUR)向けにつくっています。初めて公開されたのは2023年7月、イスタンブールの防衛展示会IDEFでした。長さは5.5m、幅は1.5mで、小型のスピードボートくらいの大きさです。甲板は水面からわずか0.5mの高さにあり、重さは1トン未満です。遠く離れた場所にいるパイロットが、衛星通信を通して操ります。", + "p2": "断面表示にすると、中をのぞけます。船体は複合材でできています。繊維の布を何層も重ね、硬い樹脂で固めた素材です。船首には閉じたペイロード区画があり、最大320kgまで積めます。その後ろに燃料タンクが並び、さらに後ろにエンジンがあります。エンジンは発泡材で包んだアルミのケースに収まり、その上に電子機器が載っています。エンジンが回すのは、船尾にあるウォータージェットです。メーカーはエンジンの名前を明かしていません。そこでモデルでは、初期のボートと同じような、水上バイク用のガソリンエンジンを想定しています。甲板の上には、スターリンクのアンテナ2基、予備の衛星アンテナ、赤外線カメラを収めた低いドーム、そして船首に固定されたカメラがあります。", + "p3": "モデルでは、1回の航走を追いかけます。ボートはアイドリングのまま、進水用のスロープからゆっくり離れます。それから水面を滑るように走りはじめ、22ノットで巡航します。1ノットは、1時間に1海里進む速さです。22ノットなら、時速約41kmになります。そのあと最高速度の42ノット、時速約78kmまで加速し、さらに2隻のボートが加わります。航走が終わるのは、停泊している大きな灰色の船の側面に、船首が届いたときです。", + "p4": "ふつうの再生速度なら、ボートは本物と同じ速さで動きます。ただし、2分に収まるように、道のりを縮めています。本物のボートなら、何時間も巡航を続けます。航続距離いっぱいの450海里、約800kmを走るには、約20時間かかります。ここでは巡航は42秒だけで、全速力の区間も短くしています。船はスロープからわずか1.5kmのところにいますが、本物の標的なら何百kmも先にあるはずです。上の時計は、モデルの中の時間を数えます。T+1:05なら、スタートから1分5秒後という意味です。タイムラインをドラッグするか、下のチップをタップすると、好きな瞬間へ移れます。", + "mode": "船体の状態", + "covered": "走った距離", + "done": "航走の進み具合" + }, + "hull": { + "title": "浮かぶのをやめて、水面を滑りはじめる", + "p1": "止まっているとき、ボートはほかの船と同じように浮かんでいます。ボートが押しのけた水が、ボートを下から支えているのです。この力を浮力といいます。速く進みはじめると、もうひとつの力が加わります。斜めになった船底に水がぶつかり、船底を上へ押すのです。速く進むほど、この押す力は強くなります。この力が重さの大半を支えるようになると、船体は持ち上がり、水面を滑るように進みます。これが滑走です。そのときでも、船底の後ろの部分は、いつも水につかったままです。", + "p2": "水をかき分けて進む船は、どれも船首に波を立てます。速さが約5.7ノットになると、この波の長さが、5.5mある船体と同じになります。この速さを船体速度といいます。浮いて進むだけの船に使う、おおまかな目安です。速さの上限ではありません。マグラは42ノットで、その約7倍の速さで走ります。そこまで速くなるには、抵抗の山を越えなければなりません。抵抗とは、ボートを引き止める水の力のことです。抵抗の山では、ボートが大きな波を立て、抵抗が急に増えます。船底の両側に沿った鋭い角、チャインも、ここでは抵抗を増やします。チャインが役に立つのは、ボートが滑走に入ってからです。", + "p3": "スライダーを動かすか、チップをタップして、船首を見てください。船首がいちばん持ち上がるのは、抵抗の山のあたりです。滑走に入ると、また落ち着きます。この角度をトリムといいます。抵抗がいちばん小さくなるのは約3度から4度のときで、最高速度ではボートが少しだけ平らになって走ります。このボートが滑走しはじめるのは約15ノットから18ノットで、モデルの表示は16ノットで切り替わります。そこから、水につかった船底の長さが一気に短くなります。15ノットでは約4.5mですが、42ノットでは約1.5mから2mです。このボートについて、抵抗の山のデータは公表されていません。そのため、表示にあるピークの6度と、重さを支える力の割合は、モデルが独自に決めた数字です。", + "p4": "船底は深いV字になっていて、波から受ける衝撃をやわらげます。その角度は公表されていません。モデルでは船尾で20度とし、船首に近づくほど鋭くしています。四角い船尾の下の縁は鋭くなっています。そのため速く走ると、水がそこできれいに離れ、船尾の面はぬれないまま走ります。両側には細いスプレーレールがあり、しぶきを外へはじき飛ばします。これはモデルによる推測です。リフティングストレーキは、船体の低い位置にある細い出っぱりで、船体が滑走に入るのを助けます。ただ、どの写真にも写っていないので、モデルには付けていません。トリムが大きすぎると、別の問題が起きます。ある角度を超えると、滑走しているボートが一定のリズムで跳ねはじめるのです。これをポーポイジングといいます。", + "speedLabel": "ボートの速さ", + "mode": "船体の状態", + "trim": "船首の角度(トリム)", + "lift": "重さを支えている力", + "wetted": "水につかった船底の長さ", + "ratio": "船体速度との比較" + }, + "jet": { + "title": "プロペラのかわりにポンプ", + "p1": "船尾にあるのはウォータージェットです。プロペラの役目を果たすポンプです。水は、船底にあいた取水口から入ってきます。取水口には、鋼の棒を並べた格子がかぶせてあります。回転するインペラーが、その水を加速します。インペラーは、扇風機の羽根に似た小さな回転体です。その後ろにある固定された羽根、ステーターが、流れのうずを取り除きます。最後にノズルが流れを細く絞り、ボートの後ろへ勢いよく噴き出します。「水の流れ」の表示にすると、水の通り道が見えます。", + "p2": "ボートを進める力は、ポンプを通る水を加速することから生まれます。この推力は、1秒間にポンプを通る水の質量に、噴流がボートよりどれだけ速く出ていくかをかけたものです。だから噴流は、いつもボートより速くなければなりません。理想的なポンプでは、仕事のうちボートを進めるのに使われる割合は、2 / (1 + 噴流の速さ / ボートの速さ) です。噴流がボートの1.5倍の速さで出ていくなら、この割合は80%です。速さが低いうちは噴流がボートよりずっと速く、仕事の大半はむだになります。", + "p3": "約25ノットから30ノットを超えると、ウォータージェットの効率はプロペラと同じか、それ以上になります。ほかにも長所があります。船体の下に何もぶら下がっていないので、抵抗が増えることも、何かにぶつかることもありません。だから浅い水でも走れます。滑走する速さなら、格子はたいてい自然にきれいになります。それでも、海藻やロープが取水口をふさぐことはあります。2022年の最初のボートには、水上バイクの部品が使われていました。ロータックスの3気筒ガソリンエンジンと、シードゥのポンプです。マグラ自身のエンジンは公表されていません。モデルのポンプは水上バイクと同じくらいの大きさで、直径は155mmです。42ノットでは、毎秒およそ100リットルから200リットルの海水を送り出します。", + "p4": "舵はありません。向きを変えるときは、操舵ノズルが噴流を左右どちらへも最大27度振ります。ノズルを右へ振ると船尾が左へ押され、船首は右を向きます。水上バイクと同じです。止まったり後ろへ下がったりするときは、リバースバケットがノズルの上に下りてきて、噴流を前と下へ向けます。スライダーで決めるのはスロットルです。モデルでは、ポンプが送る水の量として扱っています。チップをタップすると、左右に曲げたり、後進したりできます。再生ボタンを押すと、ボートは航走に戻ります。", + "throttleLabel": "スロットル", + "boat": "ボートの速さ", + "flow": "ポンプを通る水の量", + "velocity": "噴流の速さ", + "thrust": "推力", + "efficiency": "理想的な効率", + "push": "噴流が押す向き" + }, + "link": { + "title": "何百kmも離れた場所から操る", + "p1": "船にはだれも乗っていないので、すべては無線通信が頼りです。2023年11月、クリミア西部で1隻のマグラが捕獲されました。のちにロシア側の情報源が、その中身を公開しました。見つかったのは、スターリンクの衛星通信端末2台、カイメタ社製の予備の衛星アンテナ、GPS受信機、そしてLinuxで動く小さなコンピューターです。SIMカードを2枚挿したモバイルルーターもあり、岸から約40km以内なら、これで通信できました。メーカーはほかにも、メッシュ型の無線ネットワーク、自律制御、最大3本の映像配信を挙げています。ただし、これはメーカー自身の主張です。", + "p2": "パイロットが使うのは、頑丈なケースに入れて持ち運ぶ操縦卓です。画面、レバー、ジョイスティックがついています。海を渡る長い道のりは、あらかじめプログラムしておくこともできます。それでもパイロットはずっと見守っていて、最後の区間は手で操ることがよくあります。つまり、このボートは完全な自律型ではありません。チップをタップすると、スターリンク、予備の衛星、通信なしを切り替えられます。本物のボートが通信を失ったときにどう動くかは、公表されていません。そこでモデルでは、設定した針路をそのまま保つことにしています。", + "p3": "スターリンクは、衛星通信サービスとしては速いほうです。陸上でのふつうの遅れは約25ミリ秒から35ミリ秒で、海上では100ミリ秒を超えることもあります。もっと厳しい制約は、アップロードの速さです。毎秒10メガビットから30メガビットで、映像3本を送るのにだいたい必要な量です。つまり、パイロットが見る映像の鮮明さを決めるのは、遅れではなくアップロードです。それでも映像は、いつも少し遅れて届きます。どれくらい遅れるかは、だれも公表していません。スライダーをドラッグして、遅れを試してみましょう。42ノットでは、4分の1秒の遅れは5.4mになります。ボートほぼ1隻分の長さです。うっすらと見えるボートが、パイロットに見えている位置を示しています。", + "p4": "自分の位置をつかむのにも、注意が要ります。黒海では2024年半ばから、GPS信号の妨害やなりすましが数多く記録されています。船舶の追跡マップで、船が陸の上に表示されることもあります。だからメーカーは、慣性航法と映像による航法も挙げています。ボート自身の動きを測るセンサーと、カメラを使って位置を割り出す方法です。スターリンクには、もうひとつ話があります。2022年9月、ウクライナはクリミアの近くでも通信が使えると考えていました。でも、その範囲はもともと一度も有効になっていませんでした。イーロン・マスクを描いた2023年の本は、最初これを違う形で伝えていました。その後、スペースXは何も停止していないとマスクが述べ、著者は記述を訂正しました。モデルでは見やすいように、衛星を小さく、低く、近くに描き、ボートと一緒に動かしています。", + "delayLabel": "映像の遅れ", + "now": "パイロットに見えている位置", + "top": "最高速度での同じ遅れ", + "carrier": "通信手段", + "boat": "ボートの動き", + "path": "指令がボートに届くまで", + "step1": "パイロット:操縦卓のレバーやジョイスティックを動かします。", + "step2": "地上局:陸にあるスターリンクの局が、指令を電波で上へ送ります。", + "step3": "衛星:スターリンクの衛星が、それをボートへ送り下ろします。", + "step4": "ボート:甲板のアンテナが受け取り、コンピューターがスロットルや操舵ノズルを動かします。", + "step5": "映像:カメラの映像が同じ道を戻り、少し遅れてパイロットに届きます。" + }, + "horizon": { + "title": "海の丸みの陰に隠れるほど低い", + "p1": "海面は丸く曲がっているので、見渡せる距離には限りがあります。高さhメートルから見ると、水平線までの距離は約3.86 × √h kmです。ボートの赤外線カメラは水面から約0.7mの高さにあるので、カメラから見た水平線は約3km先にすぎません。モデルの3Dの海は平らです。1.5kmの距離なら、本物の海面の丸みでも約0.1mしか下がらないからです。下の図では、見やすいように丸みをずっと強く描いています。", + "p2": "船のレーダーも、同じ問題を抱えています。アンテナは高い位置にありますが、ボートの高さは約0.5mしかありません。電波は地球の丸みに沿って少しだけ曲がるので、レーダーの見通し距離は約4.12 × (√h1 + √h2) kmになります。h1とh2は、どちらもメートルで表した高さです。高さ20mのレーダーなら約21kmで、42ノットのボートなら約16分でその距離を走ります。ただし、これはレーダーがボートを見られる可能性のある、いちばん遠い距離にすぎません。実際にボートを見つける距離ではないのです。スライダーをドラッグして、レーダーの高さを上げ下げしてみましょう。", + "p3": "実際に見つかるのは、ずっと近づいてからです。小さなボートがはね返すレーダーの電波は、弱いものです。レーダー反射断面積は、レーダーから見てどれくらいの大きさの標的に見えるかを表す値です。小さなボートでは約0.5m²から5m²で、マグラについての数字は公表されていません。沿岸レーダーのある基準では、海面から1mの高さにある1m²の標的を、約9kmで見つけることを想定しています。それも高性能なレーダーで、海況4までの海に限っての話です。42ノットなら、ボートは約7分でその距離を走ります。波があると、さらに見つけにくくなります。小さなボートは、最大で半分の時間、波の陰に隠れることがあります。さらに、波そのものからはね返ってくる反射、シークラッターが、ボートの反射をかき消してしまうこともあります。", + "p4": "このボートを運用する人たちは、見つかりにくいと話しています。GURの水上ドローン部隊「グループ13」の指揮官は、プラスチックの船体はレーダーに映りにくく、熱もあまり出さないと述べました。これは主張であり、測定結果は公表されていません。記録で確かめられることもひとつあります。エンジンは発泡材を内張りしたアルミのケースに収まり、その上に電子機器があります。このつくりが、熱を外側の船体から遠ざけています。ボートは灰色に塗られていて、よく知られた攻撃は夜間か明け方に行われました。チップをタップして、海況を変えてみましょう。海況は波の高さの目安で、なめらかな海から波の高い海までを段階で表します。", + "caption": "船のレーダー、海面の丸み、ボートを横から見た図です。丸みと高さは誇張しています。", + "heightLabel": "レーダーの高さ", + "radar": "レーダーの見通し距離", + "minutes": "42ノットで走りきる時間", + "camera": "ボートのカメラから見た水平線", + "detect": "探知の目安", + "waves": "波の高さ", + "hidden": "波に隠れる時間" + }, + "fleet": { + "title": "安く、小さく、どんどん変わる", + "p1": "マグラV5の値段は、1隻およそ25万ドルです。メーカーが示す価格は約27万3,000ドルで、ほかの推計では約25万ドルとされています。ウクライナ保安庁(SBU)が別に設計したシーベイビーは、約22万1,000ドルです。GURは、2024年3月にマグラの攻撃を受けて沈んだ哨戒艦「セルゲイ・コトフ」の価値を、約6,500万ドルと見積もっています。どれも公表されている数字で、下の表示に並べて示しています。", + "p2": "これらのボートが単独で動くことは、めったにありません。報じられた攻撃では、5隻から10隻ほどのグループで動いていました。開発者によると、グループの中の1隻ごとに役割があります。沈んだと報じられた船は、1回ではなく、何回も攻撃を受けていました。モデルの航走では、全速力の区間で2隻の仲間のボートが加わります。どう並ぶかは、モデルが決めたものです。", + "p3": "設計は変わり続けています。2024年12月、GURはV5のボートのレールに、空対空ミサイルR-73を2発載せました。このミサイルは「シードラゴン」と名前を改めています。チップをタップすると、この装備を見られます。変わるのは甲板の上だけです。モデルのボートは同じ道のりを走り、やはり船の横で航走を終えます。2025年には、GURがより大きなマグラV7を公開しました。長さは7.2mで、最大650kgのペイロードを積めます。シーベイビーはSBU独自のシリーズで、マグラとは別のボートです。", + "p4": "下の記録は、それぞれの側が発表した内容に沿っています。主張の多くには、異論があります。英国防省によると、2022年2月から2024年6月までに、損傷または破壊されたロシア海軍の艦艇は少なくとも26隻です。黒海艦隊の約3分の1にあたります。この数字には、水上ドローンだけでなく、あらゆる種類の兵器による被害が含まれます。2023年10月までに、約14隻がセバストポリからノボロシースクへ移ったことが、衛星画像からわかっています。", + "carries": "積んでいるもの", + "cost": "1隻あたりの値段", + "ship": "哨戒艦の価値(GURの見積もり)", + "events": "報じられた公の記録", + "event1": "2022年10月29日、セバストポリ:水上ドローンと空中ドローンが、フリゲート「アドミラル・マカロフ」と掃海艇1隻に命中しました。ロシアが認めたのは、軽い損傷だけでした。", + "event2": "2024年1月31日から2月1日にかけての夜、ドヌズラフ湖:マグラV5が、ミサイル艇「イワノベツ」を沈めました。GURは6発命中したとしていますが、映像で確認できるのは約3発です。", + "event3": "2024年3月4日から5日にかけての夜、ケルチ海峡の近く:マグラV5が、哨戒艦「セルゲイ・コトフ」を沈めました。ウクライナ当局者によると、5隻が加わっていました。", + "event4": "2024年12月31日、タルハンクト岬:GURによると、マグラV5がR-73ミサイルでMi-8ヘリコプターを撃ち落としました。水上ドローンが航空機を撃墜した初めての例だとしています。GURは最初1機と発表し、のちに2機としました。", + "event5": "2025年5月2日、ノボロシースクの近く:GURによると、マグラV7がサイドワインダーミサイルで、Su-30SM戦闘機2機を撃墜しました。ロシアはこれを確認していません。" + } + } +} From dc5e1d0acc4530a33ac3f4240799e1b51c7e5805 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:02:26 +0200 Subject: [PATCH 13/99] feat(naval-drone): add the Spanish copy --- explainers/naval-drone/locales/es.json | 322 +++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 explainers/naval-drone/locales/es.json diff --git a/explainers/naval-drone/locales/es.json b/explainers/naval-drone/locales/es.json new file mode 100644 index 00000000..12358676 --- /dev/null +++ b/explainers/naval-drone/locales/es.json @@ -0,0 +1,322 @@ +{ + "meta": { + "title": "Cómo funciona un dron naval", + "eyebrow": "Dron naval", + "tagline": "Una lancha pequeña sin nadie a bordo: se desliza sobre su casco, avanza gracias a una bomba de agua y se pilota vía satélite.", + "description": "Sigue en 3D a un dron naval Magura V5: el casco que se pone a planear, el chorro de agua, el enlace por satélite y por qué cuesta ver una lancha tan baja.", + "summary": "El casco que planea, el chorro de agua que mueve y dirige la lancha, el enlace por satélite y su retraso, la curvatura del mar y un diseño que no deja de cambiar.", + "socialAlt": "Una lancha gris sin tripulación se desliza sobre el mar al amanecer, con la espuma saltando de las aristas de su casco, junto al título «Cómo funciona un dron naval»" + }, + "stage": { + "label": "Modelo interactivo del dron naval", + "gauge": "Datos del recorrido en tiempo real" + }, + "controls": { + "label": "Controles del dron naval", + "views": { + "cutaway": { + "name": "Vista en corte", + "short": "Corte", + "hint": "Vista en corte (C)" + }, + "flow": { + "name": "Flujo de agua", + "short": "Flujo", + "hint": "Flujo de agua (W)" + }, + "links": { + "name": "Enlaces de radio", + "short": "Enlaces", + "hint": "Enlaces de radio (K)" + }, + "labels": { + "name": "Etiquetas", + "short": "Etiquetas", + "hint": "Etiquetas (L)" + } + }, + "fit": { + "label": "Equipo de cubierta" + }, + "fitOptions": { + "standard": "Estándar", + "missile": "Rieles para misiles" + }, + "momentOptions": { + "hullSpeed": "Velocidad de casco", + "humpPeak": "Cima de la joroba", + "onPlane": "Planeando", + "cruiseSpeed": "Crucero", + "throttleUp": "Empieza el esprint", + "topSpeed": "Velocidad máxima", + "alongside": "Junto al buque" + }, + "speedMarkOptions": { + "hullSpeed": "Velocidad de casco", + "hump": "Joroba", + "planing": "Planeo", + "cruise": "Crucero", + "top": "Máxima" + }, + "helmOptions": { + "left": "Izquierda", + "straight": "Recto", + "right": "Derecha", + "reverse": "Marcha atrás" + }, + "linkOptions": { + "satellite": "Starlink", + "backup": "Satélite de reserva", + "lost": "Sin enlace" + }, + "seaStateOptions": { + "smooth": "Tranquilo", + "slight": "Poco agitado", + "moderate": "Agitado", + "rough": "Muy agitado" + } + }, + "timeline": { + "label": "Tiempo de recorrido", + "phases": "Saltar a una parte del recorrido", + "speed": "Velocidad de reproducción", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "respecto a la velocidad normal", + "phase": { + "launch": "Salida", + "hump": "Joroba", + "cruise": "Crucero", + "sprint": "Esprint", + "arrival": "Llegada" + }, + "during": { + "launch": "la lancha sale de la rampa al ralentí, por debajo de su velocidad de casco", + "hump": "la lancha, a todo gas, supera la joroba y se pone a planear", + "cruise": "la lancha navega a 22 nudos, en un viaje de muchas horas que aquí se acorta", + "sprint": "la lancha acelera hasta los 42 nudos y se le unen otras dos", + "arrival": "la lancha recorre los últimos 170 m hasta el costado del buque" + }, + "jump": { + "launch": "Saltar a la salida", + "hump": "Saltar a la joroba", + "cruise": "Saltar al crucero", + "sprint": "Saltar al esprint", + "arrival": "Saltar a la llegada" + } + }, + "readouts": { + "clock": "Reloj del recorrido", + "speed": "Velocidad", + "mode": "Casco", + "distance": "Hasta el buque" + }, + "mode": { + "floating": "Flotando", + "hump": "Subiendo la joroba", + "planing": "Planeando" + }, + "units": { + "speed": "{{kn}} nudos, {{kmh}} km/h", + "m": "{{value}} m", + "km": "{{value}} km", + "percent": "{{value}} %", + "degrees": "{{value}}°", + "kilonewtons": "{{value}} kN", + "mps": "{{ms}} m/s, {{kmh}} km/h", + "ms": "{{value}} ms", + "minutes": "{{value}} min" + }, + "parts": { + "hull": "Casco de material compuesto", + "chines": "Codillos", + "sprayRails": "Deflectores de salpicaduras", + "payloadBay": "Compartimento de carga", + "fuelTanks": "Depósitos de combustible", + "engine": "Motor en su cubeta", + "electronicsBay": "Electrónica", + "starlinkPanels": "Antenas Starlink", + "backupPanel": "Antena de satélite de reserva", + "cameraDome": "Cúpula de la cámara térmica", + "bowCamera": "Cámara de proa", + "intake": "Toma de agua y rejilla", + "duct": "Conducto de entrada", + "driveShaft": "Eje de transmisión", + "impeller": "Impulsor", + "waterjet": "Propulsor de chorro", + "stator": "Álabes del estátor", + "nozzle": "Tobera", + "steeringNozzle": "Tobera de dirección", + "reverseBucket": "Cuchara de marcha atrás", + "missileRails": "Rieles para misiles", + "jetStream": "Chorro de agua", + "bowWave": "Ola de proa", + "spray": "Salpicaduras", + "wake": "Estela", + "wettedLength": "Longitud mojada del fondo", + "videoGhost": "Donde la ve el piloto", + "satLink": "Enlace Starlink", + "backupLink": "Enlace de reserva", + "satellite": "Satélite Starlink", + "backupSatellite": "Satélite de reserva", + "groundStation": "Estación terrestre", + "ship": "Buque fondeado", + "shipRadar": "Radar del buque", + "companions": "Lanchas acompañantes" + }, + "hull": { + "lift": "{{dynamic}} % por la velocidad, {{buoyant}} % por flotar", + "wetted": "{{m}} m, frente a {{rest}} m en reposo", + "ratio": "{{times}} veces la velocidad de casco, que es de {{hullSpeed}} nudos", + "ratioStill": "Parada. La velocidad de casco es de {{hullSpeed}} nudos" + }, + "jet": { + "flow": "{{kg}} kg por segundo, unos {{litres}} litros", + "backing": "Frenando, con la cuchara bajada", + "astern": "La cuchara lo desvía hacia delante", + "noEfficiency": "Ninguna mientras la lancha no avanza", + "push": { + "left": "Tobera {{angle}}° a la izquierda: la proa gira a la izquierda", + "right": "Tobera {{angle}}° a la derecha: la proa gira a la derecha", + "straight": "Chorro recto hacia atrás: la lancha mantiene el rumbo", + "reverse": "Cuchara bajada: el chorro sale hacia delante y hacia abajo" + } + }, + "link": { + "now": "{{m}} m por detrás de la lancha, a {{kn}} nudos", + "nowLost": "Sin imagen mientras no hay enlace", + "top": "{{m}} m a 42 nudos, {{lengths}} veces el largo de la lancha", + "carrier": { + "satellite": "Starlink, dos terminales", + "backup": "Antena de satélite de reserva", + "lost": "Ninguno" + }, + "boat": { + "satellite": "El piloto la guía mirando el vídeo", + "backup": "El piloto la guía por el enlace de reserva", + "lost": "En el modelo sigue el rumbo fijado; no se ha publicado qué hace una lancha real" + } + }, + "horizon": { + "camera": "{{km}} km, desde una lente a {{m}} m sobre el agua", + "detect": "Unos {{km}} km, o {{min}} min a 42 nudos", + "detectBeyond": "Sin datos: la norma no pasa del estado de mar 4", + "waves": { + "smooth": "De {{from}} a {{to}} m, estado de mar 2", + "slight": "De {{from}} a {{to}} m, estado de mar 3", + "moderate": "De {{from}} a {{to}} m, estado de mar 4", + "rough": "De {{from}} a {{to}} m, estado de mar 5" + }, + "hidden": { + "smooth": "Rara vez: las olas son más bajas que su cubierta de 0,5 m", + "slight": "Hasta la mitad del tiempo: las olas son tan altas como su cubierta", + "moderate": "Hasta la mitad del tiempo: las olas superan su cubierta", + "rough": "Hasta la mitad del tiempo: las olas se alzan muy por encima de su cubierta" + }, + "canvas": { + "radar": "Radar del buque", + "boat": "Lancha, 0,5 m de alto", + "lineOfSight": "Línea de vista", + "detection": "Detección esperada", + "km": "{{value}} km", + "note": "Curvatura del mar exagerada" + } + }, + "fleet": { + "carries": { + "standard": "Una carga de hasta {{kg}} kg en la proa", + "missile": "Dos misiles R-73 sobre rieles; el modelo conserva el compartimento de proa" + }, + "cost": "Entre unos {{from}} y {{to}} USD", + "ship": "Unos {{value}} millones de USD" + }, + "sections": { + "overview": { + "title": "Una lancha rápida sin nadie a bordo", + "p1": "Lo que tienes delante es un Magura V5, un dron naval de Ucrania: una lancha rápida sin nadie a bordo. Lo fabrica la empresa estatal SpetsTechnoExport para el GUR, la inteligencia militar de Ucrania. Se presentó por primera vez en la feria IDEF de Estambul, en julio de 2023. Mide 5,5 m de largo y 1,5 m de ancho, más o menos el tamaño de una lancha motora pequeña. Su cubierta queda a solo 0,5 m sobre el agua, y pesa menos de una tonelada. Un piloto lo maneja desde muy lejos a través de un enlace por satélite.", + "p2": "La vista en corte te deja mirar por dentro. El casco es de material compuesto: capas de tejido de fibra dentro de una resina dura. En la proa hay un compartimento de carga cerrado para un máximo de 320 kg. Detrás van los depósitos de combustible y luego el motor, dentro de una cubeta de aluminio asentada sobre espuma, con la electrónica encima. El motor mueve un propulsor de chorro de agua situado en la popa. El fabricante no ha dicho qué motor usa, así que el modelo supone uno de gasolina del tipo que llevan las motos de agua, como los que llevaban las primeras lanchas. En cubierta hay dos antenas Starlink, una antena de satélite de reserva, una cúpula baja con una cámara térmica y una cámara fija en la proa.", + "p3": "El modelo sigue un recorrido completo. La lancha sale de una rampa al ralentí, se pone a planear y navega a 22 nudos. Un nudo es una milla náutica por hora, así que eso son unos 41 km/h. Después acelera hasta su velocidad máxima de 42 nudos, unos 78 km/h, y se le unen otras dos lanchas. El recorrido termina cuando su proa llega al costado de un gran buque gris fondeado.", + "p4": "A velocidad normal, la lancha avanza tan rápido como una de verdad, pero el viaje se acorta para que quepa en dos minutos. Una lancha real navegaría durante muchas horas: unas 20 horas para recorrer toda su autonomía, de 450 millas náuticas, es decir, unos 800 km. Aquí el crucero dura 42 segundos, y el esprint también se acorta. El buque está a solo 1,5 km de la rampa, cuando un objetivo real estaría a cientos de kilómetros. El reloj de arriba cuenta el tiempo del modelo, así que T+1:05 quiere decir un minuto y cinco segundos después de la salida. Arrastra la línea de tiempo o toca un botón de abajo para saltar a un momento.", + "mode": "Casco", + "covered": "Distancia recorrida", + "done": "Avance del recorrido" + }, + "hull": { + "title": "Deja de flotar y empieza a deslizarse", + "p1": "En reposo, la lancha flota como cualquier otra. El agua que aparta la sostiene, y a eso se le llama flotabilidad. A medida que la lancha acelera, entra en juego una segunda fuerza. El agua choca contra el fondo inclinado y lo empuja hacia arriba, y cuanto más rápido va la lancha, más fuerte empuja. Cuando ese empuje sostiene la mayor parte del peso, el casco se eleva y se desliza sobre el agua. Eso es planear. Aun así, la parte trasera del fondo siempre sigue dentro del agua.", + "p2": "Todo barco que avanza por el agua levanta una ola en la proa. A unos 5,7 nudos, esa ola es tan larga como este casco de 5,5 m. Es la velocidad de casco, una regla práctica para los barcos que solo flotan. No es un límite de velocidad: a 42 nudos, el Magura va a unas 7 veces esa velocidad. Para llegar ahí, tiene que superar la joroba de resistencia. En ese tramo, la lancha levanta olas grandes y la resistencia al avance, la fuerza con la que el agua la frena, crece muy deprisa. Las aristas afiladas que recorren el fondo, los codillos, también añaden resistencia aquí. Solo resultan útiles cuando la lancha ya planea.", + "p3": "Mueve el control deslizante, o toca un botón, y fíjate en la proa. Se levanta más que nunca cerca de la joroba, y luego baja cuando la lancha planea. Ese ángulo es el trimado. La resistencia es mínima con unos 3 o 4 grados, y a velocidad máxima la lancha va un poco más plana. En esta lancha, el planeo empieza más o menos entre los 15 y los 18 nudos, y en el modelo el dato cambia a los 16. A partir de ahí, la parte mojada del fondo se acorta deprisa: a 15 nudos mide unos 4,5 m, y a 42 nudos, entre 1,5 y 2 m, aproximadamente. No hay datos publicados de la joroba de esta lancha, así que el pico de 6 grados y el reparto del peso que ves en los datos son cifras propias del modelo.", + "p4": "El fondo tiene forma de V profunda, que suaviza los golpes de las olas. Su ángulo no se ha publicado: el modelo le da 20 grados en la popa, y la V se va haciendo más afilada hacia la proa. La popa es cuadrada y tiene el borde inferior afilado, así que a buena velocidad el agua se separa limpiamente y la popa queda seca. A cada lado, un fino deflector de salpicaduras lanza el agua hacia fuera; este detalle es una suposición del modelo. El modelo no incluye los patines de sustentación, unos salientes situados más abajo que ayudan al casco a ponerse a planear, porque no aparecen en ninguna foto. Un trimado excesivo también trae problemas: pasado cierto ángulo, una lancha que planea empieza a dar saltos con un ritmo regular, y a eso se le llama marsopeo.", + "speedLabel": "Velocidad de la lancha", + "mode": "Casco", + "trim": "Ángulo de proa (trimado)", + "lift": "Cómo se sostiene el peso", + "wetted": "Longitud mojada del fondo", + "ratio": "Frente a la velocidad de casco" + }, + "jet": { + "title": "Una bomba en lugar de una hélice", + "p1": "En la popa va un propulsor de chorro de agua, una bomba que hace el trabajo de una hélice. El agua entra por una abertura en el fondo, protegida por una rejilla de barras de acero. Un impulsor, un pequeño rotor parecido a un ventilador, la acelera. Detrás, unos álabes fijos, el estátor, le quitan el giro al agua. Después, una tobera estrecha el chorro y lo lanza hacia atrás. La vista de flujo de agua te muestra su camino.", + "p2": "La lancha avanza porque la bomba acelera el agua que pasa por ella. El empuje es igual a la masa de agua que cruza la bomba cada segundo, multiplicada por la diferencia entre la velocidad del chorro y la de la lancha. Por eso el chorro siempre tiene que ir más rápido que la lancha. En una bomba ideal, la parte del trabajo que mueve la lancha es 2 / (1 + velocidad del chorro / velocidad de la lancha). Si el chorro sale a una vez y media la velocidad de la lancha, esa parte es del 80 %. A poca velocidad, el chorro va mucho más rápido que la lancha, y se pierde la mayor parte del trabajo.", + "p3": "Por encima de unos 25 a 30 nudos, un chorro iguala o supera a una hélice, y además tiene otras ventajas. Bajo el casco no cuelga nada que añada resistencia o choque con algo, así que la lancha puede navegar en aguas poco profundas. A velocidad de planeo, la rejilla suele limpiarse sola, pero las algas y las cuerdas todavía pueden atascar una toma de agua. Las primeras lanchas, en 2022, usaban piezas de motos de agua: un motor de gasolina Rotax de tres cilindros y una bomba Sea-Doo. No se ha dado a conocer qué motor lleva el Magura. La bomba del modelo tiene el tamaño de la de una moto de agua, 155 mm de diámetro, y a 42 nudos mueve más o menos entre 100 y 200 litros de agua de mar por segundo.", + "p4": "No hay timón. Para girar, la tobera de dirección desvía el chorro hasta 27 grados hacia cualquiera de los dos lados. Si la giras a la derecha, la popa recibe un empuje hacia la izquierda, así que la proa gira a la derecha, igual que en una moto de agua. Para frenar o ir marcha atrás, una cuchara de marcha atrás baja sobre la tobera y manda el chorro hacia delante y hacia abajo. El control deslizante marca el acelerador, que el modelo entiende como la cantidad de agua que mueve la bomba. Toca los botones para girar o dar marcha atrás, y pulsa reproducir para devolver la lancha al recorrido.", + "throttleLabel": "Acelerador", + "boat": "Velocidad de la lancha", + "flow": "Agua que pasa por la bomba", + "velocity": "Velocidad del chorro", + "thrust": "Empuje", + "efficiency": "Eficiencia ideal", + "push": "Hacia dónde empuja el chorro" + }, + "link": { + "title": "Se pilota desde cientos de kilómetros de distancia", + "p1": "Sin nadie a bordo, todo depende del enlace de radio. En noviembre de 2023 se capturó un Magura en el oeste de Crimea, y más tarde fuentes rusas enseñaron lo que llevaba dentro. Encontraron dos terminales de satélite Starlink, una antena de satélite de reserva fabricada por Kymeta, un receptor GPS y un pequeño ordenador con Linux. Un router móvil con dos tarjetas SIM daba conexión cerca de la costa, a unos 40 km como mucho. El fabricante también menciona una red de radio en malla, control autónomo y hasta tres señales de vídeo, pero son afirmaciones del propio fabricante.", + "p2": "El piloto trabaja en una consola que se transporta en una maleta rígida, con pantallas, palancas y joysticks. Los trayectos largos por mar se pueden programar de antemano, pero un piloto vigila durante todo el camino, y el último tramo a menudo se lleva a mano. Así que la lancha no es del todo autónoma. Toca los botones para cambiar entre Starlink, el satélite de reserva y ningún enlace. No se ha publicado qué hace una lancha real cuando pierde el enlace, así que el modelo simplemente la mantiene en su rumbo fijado.", + "p3": "Para ser un servicio por satélite, Starlink es rápido. En tierra, su retraso típico es de unos 25 a 35 milisegundos, y en el mar puede pasar de 100. El límite más estricto es la velocidad de subida, de 10 a 30 megabits por segundo, más o menos lo que necesitan tres señales de vídeo. Así que lo que marca la nitidez de la imagen del piloto es la subida, no el retraso. Aun así, la imagen siempre llega con algo de retraso, y nadie ha publicado cuánto. Arrastra el control deslizante para probar un retraso: a 42 nudos, un cuarto de segundo son 5,4 m, más o menos el largo de la lancha. La lancha fantasma muestra dónde la ve el piloto.", + "p4": "Orientarse también exige cuidado. Desde mediados de 2024 están bien documentadas las interferencias y las señales GPS falsas sobre el mar Negro, con barcos que aparecen tierra adentro en los mapas de seguimiento. Por eso el fabricante también menciona la navegación inercial y la visual, que se basan en los sensores de movimiento y las cámaras de la propia lancha. Starlink también tiene aquí su historia. En septiembre de 2022, Ucrania esperaba tener cobertura cerca de Crimea, pero esa cobertura nunca se había activado. Un libro de 2023 sobre Elon Musk lo contó al principio de otra manera, y su autor corrigió el relato después de que Musk dijera que SpaceX no había desactivado nada. El modelo dibuja los satélites pequeños, bajos y cerca, moviéndose con la lancha, para que puedas verlos.", + "delayLabel": "Retraso del vídeo", + "now": "Dónde la ve el piloto", + "top": "El mismo retraso a velocidad máxima", + "carrier": "Enlace", + "boat": "Qué hace la lancha", + "path": "Cómo llega una orden a la lancha", + "step1": "Piloto: mueve una palanca o un joystick de la consola.", + "step2": "Estación terrestre: una estación de Starlink en tierra sube la orden por radio.", + "step3": "Satélite: un satélite Starlink la baja hasta la lancha.", + "step4": "Lancha: una antena de cubierta la recibe, y el ordenador mueve el acelerador o la tobera de dirección.", + "step5": "Vídeo: las imágenes de la cámara vuelven por el mismo camino y llegan al piloto con algo de retraso." + }, + "horizon": { + "title": "Tan baja que se esconde tras la curvatura del mar", + "p1": "El mar se curva, así que solo puedes ver hasta cierta distancia. Desde una altura de h metros, el horizonte está a unos 3,86 × √h kilómetros. La cámara térmica de la lancha va a unos 0,7 m sobre el agua, así que su horizonte queda a solo unos 3 km. El mar 3D del modelo es plano, porque en 1,5 km la curvatura real solo baja unos 0,1 m. El dibujo de abajo exagera mucho la curvatura para que puedas verla.", + "p2": "El radar de un buque tiene el mismo problema. Su antena está alta, pero la lancha solo mide unos 0,5 m de alto. Las ondas de radio se curvan un poco alrededor de la Tierra, así que la línea de vista del radar llega a unos 4,12 × (√h1 + √h2) kilómetros, con las dos alturas en metros. Para un radar a 20 m de altura, eso son unos 21 km, y a 42 nudos la lancha los recorre en unos 16 minutos. Pero eso es solo lo más lejos que el radar podría llegar a ver la lancha, no donde de verdad la detecta. Arrastra el control deslizante para subir o bajar el radar.", + "p3": "En la práctica, la detección llega mucho más tarde. Una lancha pequeña devuelve un eco débil. Su sección transversal de radar, es decir, el tamaño que aparenta como blanco ante un radar, es de unos 0,5 a 5 m², y no se ha publicado ninguna cifra para el Magura. Una norma para radares costeros espera que un blanco de 1 m², a 1 m sobre el mar, se detecte a unos 9 km, y eso solo con un radar avanzado y con el mar hasta el estado 4. A 42 nudos, la lancha recorre esa distancia en unos 7 minutos. Las olas lo ponen aún más difícil: una lancha pequeña puede quedar oculta tras ellas hasta la mitad del tiempo. Además, las propias olas devuelven ecos, los ecos parásitos del mar, que pueden tapar el de la lancha.", + "p4": "Quienes manejan estas lanchas dicen que cuesta detectarlas. Un comandante del Grupo 13, la unidad de drones navales del GUR, dijo que al radar le cuesta ver el casco de plástico y que desprende poco calor. Son afirmaciones, y no se ha publicado ninguna medición. Hay algo que sí consta: el motor va en una cubeta de aluminio forrada de espuma, con la electrónica encima, y eso aleja el calor del exterior del casco. Las lanchas están pintadas de gris, y los ataques más conocidos fueron de noche o al amanecer. Toca los botones para cambiar el estado del mar, una escala de altura de las olas que va de mar tranquilo a muy agitado.", + "caption": "Vista lateral del radar del buque, la curvatura del mar y la lancha. La curvatura y las alturas están exageradas.", + "heightLabel": "Altura del radar", + "radar": "Línea de vista del radar", + "minutes": "Tiempo para recorrerla a 42 nudos", + "camera": "Horizonte de la cámara de la lancha", + "detect": "Detección esperada", + "waves": "Altura de las olas", + "hidden": "Oculta tras las olas" + }, + "fleet": { + "title": "Barata, pequeña y en constante cambio", + "p1": "Un Magura V5 cuesta alrededor de un cuarto de millón de dólares. El fabricante habla de unos 273.000 USD, y otras estimaciones, de unos 250.000 USD. Sea Baby, un diseño distinto del SBU, el servicio de seguridad de Ucrania, cuesta unos 221.000 USD. El GUR valora en unos 65 millones de USD el patrullero Serguéi Kótov, que se hundió en marzo de 2024 tras un ataque con lanchas Magura. Son cifras públicas, y en los datos de abajo las tienes una al lado de la otra.", + "p2": "Estas lanchas casi nunca actúan solas. Según lo publicado, en los ataques se usaron grupos de unas cinco a diez lanchas, y quienes las desarrollan dicen que cada lancha del grupo tiene su propio papel. Los buques que se dieron por hundidos recibieron varios impactos, no uno solo. En el recorrido, otras dos lanchas se unen para el esprint; cómo se colocan es una elección del modelo.", + "p3": "El diseño no deja de cambiar. En diciembre de 2024, el GUR equipó lanchas V5 con dos misiles aire-aire R-73 sobre rieles, rebautizados como Sea Dragon. Toca los botones para ver esta versión. Solo cambia la cubierta: la lancha del modelo hace la misma ruta y sigue acabando junto al buque. En 2025, el GUR presentó el Magura V7, más grande, de 7,2 m de largo y con una carga de hasta 650 kg. Sea Baby es la línea propia del SBU, no un Magura.", + "p4": "La lista de abajo se limita a lo que ha dicho cada parte, y muchas de esas afirmaciones están en disputa. El Ministerio de Defensa británico cuenta al menos 26 buques de la Armada rusa dañados o destruidos entre febrero de 2022 y junio de 2024, alrededor de un tercio de la Flota del mar Negro. Esa cifra incluye todo tipo de armas, no solo drones navales. En octubre de 2023, las imágenes de satélite ya mostraban unos 14 buques trasladados de Sebastopol a Novorosíisk.", + "carries": "A bordo", + "cost": "Coste por lancha", + "ship": "Patrullero, según la valoración del GUR", + "events": "Registro público, según lo publicado", + "event1": "29 de octubre de 2022, Sebastopol: drones navales y aéreos alcanzaron la fragata Almirante Makárov y un dragaminas. Rusia solo reconoció daños menores.", + "event2": "Noche del 31 de enero al 1 de febrero de 2024, lago Donuzlav: lanchas Magura V5 hundieron la corbeta lanzamisiles Ivanovets. El GUR habla de seis impactos; en el vídeo se ven unos tres.", + "event3": "Noche del 4 al 5 de marzo de 2024, cerca del estrecho de Kerch: lanchas Magura V5 hundieron el patrullero Serguéi Kótov. Según las autoridades ucranianas, participaron cinco lanchas.", + "event4": "31 de diciembre de 2024, cabo Tarjankut: el GUR dice que lanchas Magura V5 derribaron helicópteros Mi-8 con misiles R-73, las primeras aeronaves abatidas desde un dron naval. Primero habló de uno y más tarde de dos.", + "event5": "2 de mayo de 2025, cerca de Novorosíisk: el GUR dice que lanchas Magura V7 derribaron dos cazas Su-30SM con misiles Sidewinder. Rusia no lo ha confirmado." + } + } +} From 26cb564ed6f30872e2fec0c8e0e9e9d7dfa117fa Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:02:55 +0200 Subject: [PATCH 14/99] feat(naval-drone): add the German copy --- explainers/naval-drone/locales/de.json | 322 +++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 explainers/naval-drone/locales/de.json diff --git a/explainers/naval-drone/locales/de.json b/explainers/naval-drone/locales/de.json new file mode 100644 index 00000000..8c7a8ee9 --- /dev/null +++ b/explainers/naval-drone/locales/de.json @@ -0,0 +1,322 @@ +{ + "meta": { + "title": "So funktioniert eine Seedrohne", + "eyebrow": "Seedrohne", + "tagline": "Ein kleines Boot, an Bord niemand: Es gleitet auf seinem Rumpf übers Wasser, eine Wasserpumpe treibt es an, und gesteuert wird es per Satellit.", + "description": "Begleiten Sie die Seedrohne Magura V5 in 3D: Gleitrumpf, Wasserstrahlantrieb, Satellitenfunk und warum ein niedriges Boot schwer zu sehen ist.", + "summary": "Der Gleitrumpf, der Wasserstrahl, der das Boot antreibt und lenkt, die Satellitenverbindung und ihre Verzögerung, die Krümmung des Meeres und eine Bauweise, die sich ständig ändert.", + "socialAlt": "Ein graues, unbemanntes Boot gleitet im Morgengrauen über das Meer, an den scharfen Kanten seines Rumpfs spritzt Gischt auf. Daneben steht der Titel „So funktioniert eine Seedrohne“." + }, + "stage": { + "label": "Interaktives Modell einer Seedrohne", + "gauge": "Live-Fahrtdaten" + }, + "controls": { + "label": "Steuerung des Modells", + "views": { + "cutaway": { + "name": "Schnittansicht", + "short": "Schnitt", + "hint": "Schnittansicht (C)" + }, + "flow": { + "name": "Wasserströmung", + "short": "Strömung", + "hint": "Wasserströmung (W)" + }, + "links": { + "name": "Funkverbindungen", + "short": "Funk", + "hint": "Funkverbindungen (K)" + }, + "labels": { + "name": "Beschriftungen", + "short": "Namen", + "hint": "Beschriftungen (L)" + } + }, + "fit": { + "label": "Ausrüstung an Deck" + }, + "fitOptions": { + "standard": "Standard", + "missile": "Raketenschienen" + }, + "momentOptions": { + "hullSpeed": "Rumpfgeschwindigkeit", + "humpPeak": "Gipfel des Buckels", + "onPlane": "In Gleitfahrt", + "cruiseSpeed": "Reisefahrt", + "throttleUp": "Sprint beginnt", + "topSpeed": "Höchstgeschwindigkeit", + "alongside": "Am Schiff" + }, + "speedMarkOptions": { + "hullSpeed": "Rumpfgeschwindigkeit", + "hump": "Buckel", + "planing": "Gleitfahrt", + "cruise": "Reisefahrt", + "top": "Höchstgeschwindigkeit" + }, + "helmOptions": { + "left": "Links", + "straight": "Geradeaus", + "right": "Rechts", + "reverse": "Rückwärts" + }, + "linkOptions": { + "satellite": "Starlink", + "backup": "Reservesatellit", + "lost": "Verbindung verloren" + }, + "seaStateOptions": { + "smooth": "Glatt", + "slight": "Leicht", + "moderate": "Mäßig", + "rough": "Rau" + } + }, + "timeline": { + "label": "Fahrtzeit", + "phases": "Zu einem Abschnitt der Fahrt springen", + "speed": "Wiedergabetempo", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "im Vergleich zur normalen Wiedergabe", + "phase": { + "launch": "Start", + "hump": "Buckel", + "cruise": "Reisefahrt", + "sprint": "Sprint", + "arrival": "Ankunft" + }, + "during": { + "launch": "das Boot tuckert von der Slipanlage los, langsamer als seine Rumpfgeschwindigkeit", + "hump": "mit Vollgas kommt das Boot über den Buckel und ins Gleiten", + "cruise": "das Boot fährt mit 22 Knoten, eine Fahrt von vielen Stunden, hier stark gekürzt", + "sprint": "das Boot beschleunigt auf 42 Knoten, und zwei weitere Boote schließen sich an", + "arrival": "das Boot fährt die letzten 170 m bis an die Bordwand des Schiffs" + }, + "jump": { + "launch": "Zum Start springen", + "hump": "Zum Buckel springen", + "cruise": "Zur Reisefahrt springen", + "sprint": "Zum Sprint springen", + "arrival": "Zur Ankunft springen" + } + }, + "readouts": { + "clock": "Fahrtuhr", + "speed": "Geschwindigkeit", + "mode": "Rumpf", + "distance": "Bis zum Schiff" + }, + "mode": { + "floating": "Schwimmt", + "hump": "Klettert über den Buckel", + "planing": "Gleitet" + }, + "units": { + "speed": "{{kn}} kn, {{kmh}} km/h", + "m": "{{value}} m", + "km": "{{value}} km", + "percent": "{{value}} %", + "degrees": "{{value}}°", + "kilonewtons": "{{value}} kN", + "mps": "{{ms}} m/s, {{kmh}} km/h", + "ms": "{{value}} ms", + "minutes": "{{value}} min" + }, + "parts": { + "hull": "Rumpf aus Verbundwerkstoff", + "chines": "Kimmkanten", + "sprayRails": "Spritzleisten", + "payloadBay": "Nutzlastraum", + "fuelTanks": "Kraftstofftanks", + "engine": "Motor in seiner Wanne", + "electronicsBay": "Elektronik", + "starlinkPanels": "Starlink-Antennen", + "backupPanel": "Reserve-Satellitenantenne", + "cameraDome": "Kuppel der Wärmebildkamera", + "bowCamera": "Bugkamera", + "intake": "Einlass mit Gitter", + "duct": "Einlasskanal", + "driveShaft": "Antriebswelle", + "impeller": "Impeller", + "waterjet": "Wasserstrahlantrieb", + "stator": "Statorschaufeln", + "nozzle": "Düse", + "steeringNozzle": "Steuerdüse", + "reverseBucket": "Umkehrklappe", + "missileRails": "Raketenschienen", + "jetStream": "Wasserstrahl", + "bowWave": "Bugwelle", + "spray": "Gischt", + "wake": "Kielwasser", + "wettedLength": "Nasse Bodenlänge", + "videoGhost": "Wo der Pilot das Boot sieht", + "satLink": "Starlink-Verbindung", + "backupLink": "Reserveverbindung", + "satellite": "Starlink-Satellit", + "backupSatellite": "Reservesatellit", + "groundStation": "Bodenstation", + "ship": "Schiff vor Anker", + "shipRadar": "Radar des Schiffs", + "companions": "Begleitboote" + }, + "hull": { + "lift": "{{dynamic}} % durch die Fahrt, {{buoyant}} % durch Auftrieb", + "wetted": "{{m}} m, gegenüber {{rest}} m im Stand", + "ratio": "Das {{times}}-Fache der Rumpfgeschwindigkeit von {{hullSpeed}} kn", + "ratioStill": "Stillstand. Die Rumpfgeschwindigkeit liegt bei {{hullSpeed}} kn" + }, + "jet": { + "flow": "{{kg}} kg pro Sekunde, etwa {{litres}} Liter", + "backing": "Bremst mit gesenkter Umkehrklappe", + "astern": "Von der Umkehrklappe nach vorn gelenkt", + "noEfficiency": "Keiner, solange das Boot nicht vorwärtsfährt", + "push": { + "left": "Düse {{angle}}° nach links: Der Bug dreht nach links", + "right": "Düse {{angle}}° nach rechts: Der Bug dreht nach rechts", + "straight": "Gerade nach hinten: Das Boot hält seinen Kurs", + "reverse": "Klappe unten: Der Strahl geht nach vorn und unten" + } + }, + "link": { + "now": "{{m}} m hinter dem Boot bei {{kn}} kn", + "nowLost": "Kein Bild, solange die Verbindung weg ist", + "top": "{{m}} m bei 42 kn, {{lengths}} Bootslängen", + "carrier": { + "satellite": "Starlink, zwei Terminals", + "backup": "Reserve-Satellitenantenne", + "lost": "Keine" + }, + "boat": { + "satellite": "Der Pilot steuert nach dem Video", + "backup": "Der Pilot steuert über die Reserveverbindung", + "lost": "Im Modell hält es seinen eingestellten Kurs. Was ein echtes Boot tut, ist nicht veröffentlicht" + } + }, + "horizon": { + "camera": "{{km}} km, von einer Linse {{m}} m über dem Wasser aus", + "detect": "Etwa {{km}} km oder {{min}} min bei 42 kn", + "detectBeyond": "Nicht abgedeckt: Die Norm reicht nur bis Seegang 4", + "waves": { + "smooth": "{{from}} bis {{to}} m, Seegang 2", + "slight": "{{from}} bis {{to}} m, Seegang 3", + "moderate": "{{from}} bis {{to}} m, Seegang 4", + "rough": "{{from}} bis {{to}} m, Seegang 5" + }, + "hidden": { + "smooth": "Selten: Die Wellen bleiben unter seinem 0,5 m hohen Deck", + "slight": "Bis zur Hälfte der Zeit: Die Wellen sind so hoch wie sein Deck", + "moderate": "Bis zur Hälfte der Zeit: Die Wellen sind höher als sein Deck", + "rough": "Bis zur Hälfte der Zeit: Die Wellen ragen weit über sein Deck hinaus" + }, + "canvas": { + "radar": "Radar des Schiffs", + "boat": "Boot, 0,5 m hoch", + "lineOfSight": "Sichtlinie", + "detection": "Erwartete Entdeckung", + "km": "{{value}} km", + "note": "Krümmung des Meeres übertrieben" + } + }, + "fleet": { + "carries": { + "standard": "Bis zu {{kg}} kg Nutzlast im Bug", + "missile": "Zwei Raketen vom Typ R-73 auf Schienen. Den Nutzlastraum im Bug behält das Modell bei" + }, + "cost": "Etwa {{from}} bis {{to}} USD", + "ship": "Etwa {{value}} Millionen USD" + }, + "sections": { + "overview": { + "title": "Ein Speedboot ohne Menschen an Bord", + "p1": "Hier sehen Sie eine Magura V5, eine Seedrohne aus der Ukraine. Das ist ein schnelles Boot, auf dem niemand mitfährt. Die staatliche Firma SpetsTechnoExport baut es für den GUR, den Militärgeheimdienst der Ukraine. Zum ersten Mal war es im Juli 2023 auf der Messe IDEF in Istanbul zu sehen. Das Boot ist 5,5 m lang und 1,5 m breit, etwa so groß wie ein kleines Motorboot. Sein Deck liegt nur 0,5 m über dem Wasser, und es wiegt weniger als eine Tonne. Ein Pilot in weiter Ferne steuert es über eine Satellitenverbindung.", + "p2": "In der Schnittansicht können Sie hineinschauen. Der Rumpf besteht aus Verbundwerkstoff: Lagen aus Fasergewebe, eingebettet in hartes Harz. Im Bug liegt ein geschlossener Nutzlastraum für bis zu 320 kg. Dahinter folgen die Kraftstofftanks und dann der Motor. Er sitzt in einer Aluminiumwanne, die in Schaumstoff gebettet ist, und über ihm liegt die Elektronik. Der Motor treibt am Heck einen Wasserstrahlantrieb an. Welcher Motor es ist, verrät der Hersteller nicht. Das Modell nimmt deshalb einen Benzinmotor an, wie er in Jetskis steckt. Solche Motoren hatten auch die frühen Boote. An Deck sitzen zwei Starlink-Antennen, eine Reserve-Satellitenantenne, eine flache Kuppel mit einer Wärmebildkamera und eine fest eingebaute Kamera im Bug.", + "p3": "Das Modell begleitet eine Fahrt. Das Boot tuckert von einer Slipanlage los, kommt ins Gleiten und fährt mit 22 Knoten weiter. Ein Knoten ist eine Seemeile pro Stunde, das sind also etwa 41 km/h. Dann beschleunigt es auf seine Höchstgeschwindigkeit von 42 Knoten, etwa 78 km/h, und zwei weitere Boote schließen sich an. Die Fahrt endet, sobald sein Bug die Bordwand eines großen grauen Schiffs vor Anker erreicht.", + "p4": "Bei normalem Wiedergabetempo fährt das Boot so schnell wie ein echtes. Die Fahrt ist aber stark gekürzt, damit sie in zwei Minuten passt. Ein echtes Boot wäre viele Stunden unterwegs: etwa 20 Stunden für seine volle Reichweite von 450 Seemeilen, rund 800 km. Hier dauert die Reisefahrt 42 Sekunden, und auch der Sprint ist verkürzt. Das Schiff liegt nur 1,5 km von der Slipanlage entfernt. Ein echtes Ziel wäre Hunderte Kilometer weit weg. Die Uhr oben zählt die Modellzeit. T+1:05 heißt also: eine Minute und fünf Sekunden nach dem Start. Ziehen Sie den Regler der Zeitleiste an eine beliebige Stelle. Oder tippen Sie unten auf eine Schaltfläche, um zu einem bestimmten Moment zu springen.", + "mode": "Rumpf", + "covered": "Zurückgelegte Strecke", + "done": "Fortschritt der Fahrt" + }, + "hull": { + "title": "Vom Schwimmen zum Gleiten", + "p1": "Im Stand schwimmt das Boot wie jedes andere. Das Wasser, das es zur Seite drückt, hält es oben. Das nennt man Auftrieb. Wird das Boot schneller, kommt eine zweite Kraft hinzu. Das Wasser prallt gegen den schrägen Boden und drückt ihn nach oben. Je schneller das Boot fährt, desto stärker ist dieser Druck. Sobald er den größten Teil des Gewichts trägt, hebt sich der Rumpf und gleitet über das Wasser. Das ist die Gleitfahrt. Selbst dann bleibt der hintere Teil des Bodens immer im Wasser.", + "p2": "Jedes Boot, das sich durchs Wasser schiebt, macht eine Bugwelle. Bei etwa 5,7 Knoten ist diese Welle so lang wie der 5,5 m lange Rumpf dieses Boots. Das ist die Rumpfgeschwindigkeit, eine Faustregel für Boote, die nur schwimmen. Ein Tempolimit ist sie aber nicht: Mit 42 Knoten ist die Magura etwa 7-mal so schnell. Auf dem Weg dorthin muss das Boot den Widerstandsbuckel überwinden. Dort macht es große Wellen, und der Widerstand steigt steil an. So heißt die Kraft, mit der das Wasser das Boot bremst. Die scharfen Kanten entlang des Bodens, die Kimmkanten, bremsen hier zusätzlich. Ihr Nutzen zeigt sich erst, wenn das Boot gleitet.", + "p3": "Bewegen Sie den Regler oder tippen Sie auf eine Schaltfläche, und achten Sie auf den Bug. Rund um den Buckel hebt er sich am stärksten. Sobald das Boot gleitet, senkt er sich wieder. Diesen Winkel nennt man Trimm. Bei etwa 3 bis 4 Grad ist der Widerstand am kleinsten, und bei Höchstgeschwindigkeit liegt das Boot etwas flacher. Bei diesem Boot beginnt die Gleitfahrt bei etwa 15 bis 18 Knoten. Die Anzeige im Modell schaltet bei 16 um. Danach schrumpft der nasse Teil des Bodens schnell: von etwa 4,5 m bei 15 Knoten auf etwa 1,5 bis 2 m bei 42 Knoten. Für dieses Boot sind keine Daten zum Buckel veröffentlicht. Die Spitze von 6 Grad und die Aufteilung des Gewichts in den Anzeigen sind deshalb eigene Zahlen des Modells.", + "p4": "Der Boden hat die Form eines tiefen V. Das dämpft die Schläge der Wellen. Wie spitz das V ist, wurde nicht veröffentlicht: Im Modell sind es am Heck 20 Grad, zum Bug hin wird es schärfer. Das Heck ist gerade abgeschnitten und hat eine scharfe Unterkante. So reißt das Wasser bei schneller Fahrt sauber ab, und das Heck läuft trocken. An jeder Seite lenkt eine schmale Spritzleiste die Gischt nach außen. Diese Leisten sind eine Annahme des Modells. Auftriebsleisten fehlen dagegen, denn kein Foto zeigt sie. Das wären Rippen weiter unten am Rumpf, die ihm ins Gleiten helfen. Zu viel Trimm bringt eigene Probleme: Ab einem bestimmten Winkel beginnt ein gleitendes Boot in gleichmäßigem Takt zu springen. Das nennt man Delfinieren.", + "speedLabel": "Bootsgeschwindigkeit", + "mode": "Rumpf", + "trim": "Bugwinkel (Trimm)", + "lift": "Getragenes Gewicht", + "wetted": "Nasse Bodenlänge", + "ratio": "Im Vergleich zur Rumpfgeschwindigkeit" + }, + "jet": { + "title": "Eine Pumpe statt eines Propellers", + "p1": "Am Heck sitzt ein Wasserstrahlantrieb: eine Pumpe, die die Arbeit eines Propellers übernimmt. Das Wasser strömt durch eine Öffnung im Boden herein, hinter einem Gitter aus Stahlstäben. Ein rotierender Impeller, ein kleines Laufrad ähnlich einem Ventilator, beschleunigt es. Dahinter nehmen feste Schaufeln, der Stator, den Drall aus der Strömung. Dann verengt eine Düse den Strahl und schießt ihn hinter dem Boot heraus. Die Ansicht „Wasserströmung“ zeigt den Weg des Wassers.", + "p2": "Die Kraft nach vorn entsteht, weil die Pumpe das Wasser beschleunigt, das durch sie hindurchfließt. Der Schub ergibt sich so: die Wassermasse, die pro Sekunde durchfließt, mal dem Unterschied zwischen dem Tempo des Strahls und dem des Boots. Der Strahl muss also immer schneller sein als das Boot. Bei einer idealen Pumpe ist der Anteil der Arbeit, der das Boot voranbringt, 2 / (1 + Strahlgeschwindigkeit / Bootsgeschwindigkeit). Verlässt der Strahl die Düse mit dem Anderthalbfachen der Bootsgeschwindigkeit, liegt dieser Anteil bei 80 %. Bei langsamer Fahrt ist der Strahl viel schneller als das Boot, und der größte Teil der Arbeit geht verloren.", + "p3": "Ab etwa 25 bis 30 Knoten ist ein Wasserstrahlantrieb einem Propeller ebenbürtig oder sogar überlegen. Und er hat weitere Stärken. Unter dem Rumpf hängt nichts, das bremst oder irgendwo anstößt. So kann das Boot auch in flachem Wasser fahren. In Gleitfahrt hält sich das Gitter meist selbst frei. Tang und Leinen können einen Einlass aber trotzdem verstopfen. Die ersten Boote von 2022 nutzten Teile von Jetskis: einen Dreizylinder-Benzinmotor von Rotax und eine Pumpe von Sea-Doo. Welchen Motor die Magura selbst hat, ist nicht offengelegt. Die Pumpe im Modell hat Jetski-Größe, 155 mm im Durchmesser. Bei 42 Knoten bewegt sie grob 100 bis 200 Liter Meerwasser pro Sekunde.", + "p4": "Ein Ruder gibt es nicht. Für eine Kurve schwenkt die Steuerdüse den Strahl um bis zu 27 Grad nach links oder rechts. Schwenkt sie nach rechts, wird das Heck nach links gedrückt, und der Bug dreht nach rechts, genau wie bei einem Jetski. Zum Bremsen oder Rückwärtsfahren senkt sich eine Umkehrklappe über die Düse und lenkt den Strahl nach vorn und unten. Mit dem Regler geben Sie Gas. Das Modell versteht darunter, wie viel Wasser die Pumpe bewegt. Tippen Sie auf die Schaltflächen, um zu lenken oder rückwärtszufahren. Drücken Sie auf Wiedergabe, und das Boot kehrt zur Fahrt zurück.", + "throttleLabel": "Gas", + "boat": "Bootsgeschwindigkeit", + "flow": "Wasser durch die Pumpe", + "velocity": "Strahlgeschwindigkeit", + "thrust": "Schub", + "efficiency": "Idealer Wirkungsgrad", + "push": "Richtung des Strahls" + }, + "link": { + "title": "Gesteuert aus Hunderten Kilometern Entfernung", + "p1": "Ohne Menschen an Bord hängt alles am Funk. Im November 2023 wurde auf der westlichen Krim eine Magura erbeutet. Russische Quellen zeigten später ihr Inneres. Gefunden wurden zwei Starlink-Satellitenterminals, eine Reserve-Satellitenantenne der Firma Kymeta, ein GPS-Empfänger und ein kleiner Computer mit Linux. Ein Mobilfunkrouter mit zwei SIM-Karten hielt die Verbindung in Küstennähe, im Umkreis von etwa 40 km. Der Hersteller nennt außerdem ein Mesh-Funknetz, autonome Steuerung und bis zu drei Videostreams. Das sind aber nur seine eigenen Angaben.", + "p2": "Der Pilot arbeitet an einem Steuerpult mit Bildschirmen, Hebeln und Joysticks. Das Pult ist in einen robusten Koffer eingebaut und lässt sich so mitnehmen. Lange Fahrten über das Meer lassen sich vorab programmieren. Trotzdem schaut ein Pilot die ganze Zeit zu, und das letzte Stück wird oft von Hand gesteuert. Vollständig autonom ist das Boot also nicht. Tippen Sie auf die Schaltflächen, um zwischen Starlink, dem Reservesatelliten und gar keiner Verbindung zu wechseln. Was ein echtes Boot tut, wenn die Verbindung abreißt, ist nicht veröffentlicht. Das Modell hält es einfach auf seinem eingestellten Kurs.", + "p3": "Für einen Satellitendienst ist Starlink schnell. An Land liegt die typische Verzögerung bei etwa 25 bis 35 Millisekunden, auf See können es über 100 sein. Enger ist die Grenze beim Upload: 10 bis 30 Megabit pro Sekunde. Das ist etwa so viel, wie drei Videostreams brauchen. Wie scharf der Pilot sein Bild sieht, hängt also am Upload, nicht an der Verzögerung. Trotzdem kommt das Bild immer etwas zu spät an. Wie spät, hat niemand veröffentlicht. Ziehen Sie den Regler, um eine Verzögerung auszuprobieren: Bei 42 Knoten entspricht eine Viertelsekunde 5,4 m, etwa einer Bootslänge. Das Geisterboot zeigt, wo der Pilot das Boot sieht.", + "p4": "Auch die Navigation braucht Sorgfalt. Seit Mitte 2024 sind gestörte und gefälschte GPS-Signale über dem Schwarzen Meer gut belegt. Auf Karten zur Schiffsverfolgung tauchten Schiffe sogar im Landesinneren auf. Deshalb nennt der Hersteller auch Trägheitsnavigation und visuelle Navigation. Beide arbeiten mit den eigenen Bewegungssensoren und Kameras des Boots. Auch Starlink hat hier eine Geschichte. Im September 2022 erwartete die Ukraine Netzabdeckung in der Nähe der Krim, die nie eingeschaltet worden war. Ein Buch über Elon Musk aus dem Jahr 2023 erzählte das zunächst anders. Sein Autor korrigierte die Darstellung, nachdem Musk erklärt hatte, SpaceX habe nichts abgeschaltet. Das Modell zeichnet die Satelliten klein, niedrig und nah und lässt sie mit dem Boot mitwandern, damit Sie sie sehen können.", + "delayLabel": "Videoverzögerung", + "now": "Wo der Pilot das Boot sieht", + "top": "Gleiche Verzögerung bei Höchstgeschwindigkeit", + "carrier": "Verbindung", + "boat": "Was das Boot tut", + "path": "So erreicht ein Befehl das Boot", + "step1": "Pilot: Er bewegt einen Hebel oder einen Joystick am Steuerpult.", + "step2": "Bodenstation: Eine Starlink-Station an Land funkt den Befehl nach oben.", + "step3": "Satellit: Ein Starlink-Satellit gibt ihn nach unten an das Boot weiter.", + "step4": "Boot: Eine Antenne an Deck empfängt ihn, und der Computer verstellt das Gas oder die Steuerdüse.", + "step5": "Video: Die Kamerabilder nehmen denselben Weg zurück und kommen etwas verspätet beim Piloten an." + }, + "horizon": { + "title": "Niedrig genug, um hinter der Krümmung des Meeres zu verschwinden", + "p1": "Das Meer ist gekrümmt, deshalb können Sie nur ein Stück weit darüber blicken. Aus einer Höhe von h Metern liegt der Horizont etwa 3,86 × √h Kilometer entfernt. Die Wärmebildkamera des Boots sitzt etwa 0,7 m über dem Wasser. Der Horizont der Kamera liegt also nur etwa 3 km entfernt. Das 3D-Meer im Modell ist flach, denn auf 1,5 km fällt die echte Krümmung nur um etwa 0,1 m ab. Die Zeichnung unten macht die Krümmung viel steiler, damit Sie sie sehen können.", + "p2": "Das Radar eines Schiffs hat dasselbe Problem. Seine Antenne sitzt hoch, aber das Boot ist nur etwa 0,5 m hoch. Funkwellen biegen sich ein wenig um die Erde. Darum reicht die Sichtlinie des Radars etwa 4,12 × (√h1 + √h2) Kilometer weit, beide Höhen in Metern. Bei einem Radar in 20 m Höhe sind das etwa 21 km. Bei 42 Knoten braucht das Boot dafür etwa 16 Minuten. Das ist aber nur die größte Entfernung, auf die das Radar das Boot überhaupt sehen könnte. Wo es das Boot wirklich entdeckt, steht auf einem anderen Blatt. Ziehen Sie den Regler, um das Radar höher oder tiefer zu setzen.", + "p3": "Wirklich entdeckt wird das Boot viel später. Ein kleines Boot wirft nur ein schwaches Echo zurück. Sein Radarquerschnitt gibt an, wie groß es einem Radar als Ziel erscheint. Er liegt bei etwa 0,5 bis 5 m², und für die Magura ist kein Wert veröffentlicht. Eine Norm für Küstenradar erwartet, dass ein Ziel von 1 m² in 1 m Höhe über dem Meer in etwa 9 km Entfernung gefunden wird. Und das nur mit einem fortschrittlichen Radar und nur bis Seegang 4. Bei 42 Knoten legt das Boot diese Strecke in etwa 7 Minuten zurück. Wellen machen es noch schwerer: Ein kleines Boot kann bis zur Hälfte der Zeit hinter ihnen verborgen sein. Und die Echos der Wellen selbst, die Seegangsechos, können sein eigenes Echo überdecken.", + "p4": "Wer diese Boote einsetzt, sagt, sie seien schwer zu entdecken. Ein Kommandeur der Gruppe 13, der Seedrohnen-Einheit des GUR, sagte, Radar erfasse den Kunststoffrumpf nur schwer, und das Boot gebe wenig Wärme ab. Das sind Behauptungen. Veröffentlichte Messungen gibt es nicht. Belegt ist eines: Der Motor sitzt in einer mit Schaumstoff ausgekleideten Aluminiumwanne, die Elektronik darüber. Das hält Wärme von der Außenhaut fern. Die Boote sind grau gestrichen, und die bekanntesten Angriffe kamen nachts oder im Morgengrauen. Tippen Sie auf die Schaltflächen, um den Seegang zu ändern. Er wird auf einer Skala der Wellenhöhe angegeben, von glatt bis rau.", + "caption": "Seitenansicht: das Radar des Schiffs, die Krümmung des Meeres und das Boot. Krümmung und Höhen sind übertrieben dargestellt.", + "heightLabel": "Höhe des Radars", + "radar": "Sichtlinie des Radars", + "minutes": "Fahrzeit dafür bei 42 kn", + "camera": "Horizont der Bootskamera", + "detect": "Erwartete Entdeckung", + "waves": "Wellenhöhe", + "hidden": "Hinter Wellen verborgen" + }, + "fleet": { + "title": "Günstig, klein und ständig im Wandel", + "p1": "Eine Magura V5 kostet etwa eine Viertelmillion Dollar. Der Hersteller nennt etwa 273.000 USD, andere Schätzungen etwa 250.000 USD. Sea Baby, eine eigene Konstruktion des SBU, des ukrainischen Sicherheitsdienstes, kostet etwa 221.000 USD. Der GUR beziffert den Wert des Patrouillenschiffs Sergej Kotow auf etwa 65 Millionen USD. Es sank im März 2024 nach einem Magura-Angriff. Das sind öffentlich genannte Zahlen, unten in den Anzeigen nebeneinandergestellt.", + "p2": "Diese Boote sind selten allein unterwegs. Bei den gemeldeten Angriffen waren es Gruppen von etwa fünf bis zehn. Laut den Entwicklern hat jedes Boot in einer Gruppe seine eigene Aufgabe. Die Schiffe, die als versenkt gemeldet wurden, bekamen mehrere Treffer ab, nicht nur einen. In der Fahrt des Modells kommen für den Sprint zwei Begleitboote dazu. Wie sie sich aufstellen, hat das Modell selbst gewählt.", + "p3": "Die Bauweise ändert sich ständig. Im Dezember 2024 rüstete der GUR V5-Boote mit zwei Luft-Luft-Raketen vom Typ R-73 auf Schienen aus. Die Raketen bekamen den neuen Namen Sea Dragon. Tippen Sie auf die Schaltflächen, um diese Ausrüstung zu sehen. Nur das Deck ändert sich: Das Boot im Modell fährt dieselbe Route und endet auch so neben dem Schiff. 2025 stellte der GUR die größere Magura V7 vor, 7,2 m lang, mit bis zu 650 kg Nutzlast. Sea Baby ist eine eigene Baureihe des SBU und keine Magura.", + "p4": "Die Liste unten gibt nur wieder, was die Seiten jeweils gesagt haben, und vieles davon ist umstritten. Das britische Verteidigungsministerium zählt zwischen Februar 2022 und Juni 2024 mindestens 26 russische Marineschiffe, die beschädigt oder zerstört wurden. Das ist etwa ein Drittel der Schwarzmeerflotte. Diese Zahl umfasst alle Arten von Waffen, nicht nur Drohnenboote. Bis Oktober 2023 zeigten Satellitenbilder, dass etwa 14 Schiffe von Sewastopol nach Noworossijsk verlegt worden waren.", + "carries": "An Bord", + "cost": "Kosten pro Boot", + "ship": "Patrouillenschiff, Wert laut GUR", + "events": "Öffentlich bekannte Ereignisse, laut Berichten", + "event1": "29. Oktober 2022, Sewastopol: See- und Luftdrohnen trafen die Fregatte Admiral Makarow und ein Minenräumboot. Russland räumte nur leichte Schäden ein.", + "event2": "Nacht vom 31. Januar zum 1. Februar 2024, Donuslaw-See: Magura-V5-Boote versenkten die Raketenkorvette Iwanowez. Der GUR spricht von sechs Treffern, auf Videos sind etwa drei zu sehen.", + "event3": "Nacht vom 4. zum 5. März 2024, nahe der Straße von Kertsch: Magura-V5-Boote versenkten das Patrouillenschiff Sergej Kotow. Laut ukrainischen Stellen waren fünf Boote beteiligt.", + "event4": "31. Dezember 2024, Kap Tarchankut: Laut GUR schossen Magura-V5-Boote mit R-73-Raketen Hubschrauber vom Typ Mi-8 ab, die ersten Luftfahrzeuge, die von einer Seedrohne abgeschossen wurden. Zuerst sprach der GUR von einem, später von zwei.", + "event5": "2. Mai 2025, nahe Noworossijsk: Laut GUR schossen Magura-V7-Boote mit Sidewinder-Raketen zwei Jets vom Typ Su-30SM ab. Russland hat das nicht bestätigt." + } + } +} From cbd44358be1ebf400eab59335a78084878eb5687 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:03:11 +0200 Subject: [PATCH 15/99] fix(naval-drone): say the boat gives off little heat, as the commander did --- explainers/naval-drone/chapters.html | 12 ++++++------ explainers/naval-drone/locales/en.json | 2 +- explainers/naval-drone/locales/es.json | 2 +- explainers/naval-drone/locales/ja.json | 2 +- explainers/naval-drone/locales/pt.json | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/explainers/naval-drone/chapters.html b/explainers/naval-drone/chapters.html index bbb543c9..e025ddd7 100644 --- a/explainers/naval-drone/chapters.html +++ b/explainers/naval-drone/chapters.html @@ -462,12 +462,12 @@

The people who run these boats say they are hard to spot. A commander of Group 13, GUR's naval - drone unit, said radar has a hard time seeing the plastic hull and that it gives off little - heat. These are claims, and no measurement has been published. One thing is on record: the - engine sits in a foam-lined aluminium tub with the electronics above it, which keeps heat away - from the outer skin. The boats are painted grey, and the best-known attacks came at night or at - dawn. Tap the chips to change the sea state, a scale of wave height from smooth - to rough. + drone unit, said radar has a hard time seeing its plastic hull and that the boat gives off + little heat. These are claims, and no measurement has been published. One thing is on record: + the engine sits in a foam-lined aluminium tub with the electronics above it, which keeps heat + away from the outer skin. The boats are painted grey, and the best-known attacks came at night + or at dawn. Tap the chips to change the sea state, a scale of wave height from + smooth to rough.

diff --git a/explainers/naval-drone/locales/en.json b/explainers/naval-drone/locales/en.json index 83968fc0..675d6184 100644 --- a/explainers/naval-drone/locales/en.json +++ b/explainers/naval-drone/locales/en.json @@ -292,7 +292,7 @@ "p1": "The sea curves away, so you can only see so far across it. From a height of h metres, the horizon lies about 3.86 × √h kilometres away. The boat's thermal camera sits about 0.7 m above the water, so its horizon is only about 3 km off. The 3D sea in the model is flat, because over 1.5 km the real curve drops only about 0.1 m. The drawing below makes the curve much steeper so that you can see it.", "p2": "A ship's radar has the same problem. Its antenna is high, but the boat is only about 0.5 m tall. Radio waves bend a little around the Earth, so the radar's line of sight reaches about 4.12 × (√h1 + √h2) kilometres, with both heights in metres. For a radar 20 m up, that is about 21 km, and at 42 knots the boat covers it in about 16 minutes. But this is only the farthest the radar could ever see the boat, not where it actually spots it. Drag the slider to raise or lower the radar.", "p3": "Real detection comes much later. A small boat sends back a weak echo. Its radar cross-section, the size of target it looks like to a radar, is about 0.5 to 5 m², and no figure has been published for the Magura. A standard for coastal radar expects a target of 1 m², 1 m above the sea, to be found at about 9 km, and only by an advanced radar in seas up to state 4. At 42 knots the boat covers that in about 7 minutes. Waves make it harder still: a small boat may be hidden behind them up to half the time, and the echoes from the waves themselves, called sea clutter, can drown out its own.", - "p4": "The people who run these boats say they are hard to spot. A commander of Group 13, GUR's naval drone unit, said radar has a hard time seeing the plastic hull and that it gives off little heat. These are claims, and no measurement has been published. One thing is on record: the engine sits in a foam-lined aluminium tub with the electronics above it, which keeps heat away from the outer skin. The boats are painted grey, and the best-known attacks came at night or at dawn. Tap the chips to change the sea state, a scale of wave height from smooth to rough.", + "p4": "The people who run these boats say they are hard to spot. A commander of Group 13, GUR's naval drone unit, said radar has a hard time seeing its plastic hull and that the boat gives off little heat. These are claims, and no measurement has been published. One thing is on record: the engine sits in a foam-lined aluminium tub with the electronics above it, which keeps heat away from the outer skin. The boats are painted grey, and the best-known attacks came at night or at dawn. Tap the chips to change the sea state, a scale of wave height from smooth to rough.", "caption": "Side view of the ship's radar, the curve of the sea and the boat. The curve and the heights are exaggerated.", "heightLabel": "Radar height", "radar": "Radar line of sight", diff --git a/explainers/naval-drone/locales/es.json b/explainers/naval-drone/locales/es.json index 12358676..c685c429 100644 --- a/explainers/naval-drone/locales/es.json +++ b/explainers/naval-drone/locales/es.json @@ -292,7 +292,7 @@ "p1": "El mar se curva, así que solo puedes ver hasta cierta distancia. Desde una altura de h metros, el horizonte está a unos 3,86 × √h kilómetros. La cámara térmica de la lancha va a unos 0,7 m sobre el agua, así que su horizonte queda a solo unos 3 km. El mar 3D del modelo es plano, porque en 1,5 km la curvatura real solo baja unos 0,1 m. El dibujo de abajo exagera mucho la curvatura para que puedas verla.", "p2": "El radar de un buque tiene el mismo problema. Su antena está alta, pero la lancha solo mide unos 0,5 m de alto. Las ondas de radio se curvan un poco alrededor de la Tierra, así que la línea de vista del radar llega a unos 4,12 × (√h1 + √h2) kilómetros, con las dos alturas en metros. Para un radar a 20 m de altura, eso son unos 21 km, y a 42 nudos la lancha los recorre en unos 16 minutos. Pero eso es solo lo más lejos que el radar podría llegar a ver la lancha, no donde de verdad la detecta. Arrastra el control deslizante para subir o bajar el radar.", "p3": "En la práctica, la detección llega mucho más tarde. Una lancha pequeña devuelve un eco débil. Su sección transversal de radar, es decir, el tamaño que aparenta como blanco ante un radar, es de unos 0,5 a 5 m², y no se ha publicado ninguna cifra para el Magura. Una norma para radares costeros espera que un blanco de 1 m², a 1 m sobre el mar, se detecte a unos 9 km, y eso solo con un radar avanzado y con el mar hasta el estado 4. A 42 nudos, la lancha recorre esa distancia en unos 7 minutos. Las olas lo ponen aún más difícil: una lancha pequeña puede quedar oculta tras ellas hasta la mitad del tiempo. Además, las propias olas devuelven ecos, los ecos parásitos del mar, que pueden tapar el de la lancha.", - "p4": "Quienes manejan estas lanchas dicen que cuesta detectarlas. Un comandante del Grupo 13, la unidad de drones navales del GUR, dijo que al radar le cuesta ver el casco de plástico y que desprende poco calor. Son afirmaciones, y no se ha publicado ninguna medición. Hay algo que sí consta: el motor va en una cubeta de aluminio forrada de espuma, con la electrónica encima, y eso aleja el calor del exterior del casco. Las lanchas están pintadas de gris, y los ataques más conocidos fueron de noche o al amanecer. Toca los botones para cambiar el estado del mar, una escala de altura de las olas que va de mar tranquilo a muy agitado.", + "p4": "Quienes manejan estas lanchas dicen que cuesta detectarlas. Un comandante del Grupo 13, la unidad de drones navales del GUR, dijo que al radar le cuesta ver el casco de plástico y que la lancha desprende poco calor. Son afirmaciones, y no se ha publicado ninguna medición. Hay algo que sí consta: el motor va en una cubeta de aluminio forrada de espuma, con la electrónica encima, y eso aleja el calor del exterior del casco. Las lanchas están pintadas de gris, y los ataques más conocidos fueron de noche o al amanecer. Toca los botones para cambiar el estado del mar, una escala de altura de las olas que va de mar tranquilo a muy agitado.", "caption": "Vista lateral del radar del buque, la curvatura del mar y la lancha. La curvatura y las alturas están exageradas.", "heightLabel": "Altura del radar", "radar": "Línea de vista del radar", diff --git a/explainers/naval-drone/locales/ja.json b/explainers/naval-drone/locales/ja.json index f85a4195..bf6d397e 100644 --- a/explainers/naval-drone/locales/ja.json +++ b/explainers/naval-drone/locales/ja.json @@ -292,7 +292,7 @@ "p1": "海面は丸く曲がっているので、見渡せる距離には限りがあります。高さhメートルから見ると、水平線までの距離は約3.86 × √h kmです。ボートの赤外線カメラは水面から約0.7mの高さにあるので、カメラから見た水平線は約3km先にすぎません。モデルの3Dの海は平らです。1.5kmの距離なら、本物の海面の丸みでも約0.1mしか下がらないからです。下の図では、見やすいように丸みをずっと強く描いています。", "p2": "船のレーダーも、同じ問題を抱えています。アンテナは高い位置にありますが、ボートの高さは約0.5mしかありません。電波は地球の丸みに沿って少しだけ曲がるので、レーダーの見通し距離は約4.12 × (√h1 + √h2) kmになります。h1とh2は、どちらもメートルで表した高さです。高さ20mのレーダーなら約21kmで、42ノットのボートなら約16分でその距離を走ります。ただし、これはレーダーがボートを見られる可能性のある、いちばん遠い距離にすぎません。実際にボートを見つける距離ではないのです。スライダーをドラッグして、レーダーの高さを上げ下げしてみましょう。", "p3": "実際に見つかるのは、ずっと近づいてからです。小さなボートがはね返すレーダーの電波は、弱いものです。レーダー反射断面積は、レーダーから見てどれくらいの大きさの標的に見えるかを表す値です。小さなボートでは約0.5m²から5m²で、マグラについての数字は公表されていません。沿岸レーダーのある基準では、海面から1mの高さにある1m²の標的を、約9kmで見つけることを想定しています。それも高性能なレーダーで、海況4までの海に限っての話です。42ノットなら、ボートは約7分でその距離を走ります。波があると、さらに見つけにくくなります。小さなボートは、最大で半分の時間、波の陰に隠れることがあります。さらに、波そのものからはね返ってくる反射、シークラッターが、ボートの反射をかき消してしまうこともあります。", - "p4": "このボートを運用する人たちは、見つかりにくいと話しています。GURの水上ドローン部隊「グループ13」の指揮官は、プラスチックの船体はレーダーに映りにくく、熱もあまり出さないと述べました。これは主張であり、測定結果は公表されていません。記録で確かめられることもひとつあります。エンジンは発泡材を内張りしたアルミのケースに収まり、その上に電子機器があります。このつくりが、熱を外側の船体から遠ざけています。ボートは灰色に塗られていて、よく知られた攻撃は夜間か明け方に行われました。チップをタップして、海況を変えてみましょう。海況は波の高さの目安で、なめらかな海から波の高い海までを段階で表します。", + "p4": "このボートを運用する人たちは、見つかりにくいと話しています。GURの水上ドローン部隊「グループ13」の指揮官は、プラスチックの船体はレーダーに映りにくく、ボートは熱もあまり出さないと述べました。これは主張であり、測定結果は公表されていません。記録で確かめられることもひとつあります。エンジンは発泡材を内張りしたアルミのケースに収まり、その上に電子機器があります。このつくりが、熱を外側の船体から遠ざけています。ボートは灰色に塗られていて、よく知られた攻撃は夜間か明け方に行われました。チップをタップして、海況を変えてみましょう。海況は波の高さの目安で、なめらかな海から波の高い海までを段階で表します。", "caption": "船のレーダー、海面の丸み、ボートを横から見た図です。丸みと高さは誇張しています。", "heightLabel": "レーダーの高さ", "radar": "レーダーの見通し距離", diff --git a/explainers/naval-drone/locales/pt.json b/explainers/naval-drone/locales/pt.json index 81c2c192..16585448 100644 --- a/explainers/naval-drone/locales/pt.json +++ b/explainers/naval-drone/locales/pt.json @@ -292,7 +292,7 @@ "p1": "A superfície do mar se curva, então só dá para enxergar até certo ponto sobre ela. De uma altura de h metros, o horizonte fica a cerca de 3,86 × √h quilômetros. A câmera térmica do barco fica a cerca de 0,7 m acima da água, então o horizonte dela está a apenas uns 3 km. O mar em 3D do modelo é plano, porque, em 1,5 km, a curvatura real desce só cerca de 0,1 m. O desenho abaixo deixa a curva bem mais acentuada para que você consiga vê-la.", "p2": "O radar de um navio tem o mesmo problema. A antena dele fica no alto, mas o barco tem só cerca de 0,5 m de altura. As ondas de rádio se curvam um pouco em volta da Terra. Assim, a linha de visada do radar chega a cerca de 4,12 × (√h1 + √h2) quilômetros, com as duas alturas em metros. Para um radar a 20 m de altura, isso dá cerca de 21 km, e a 42 nós o barco percorre essa distância em cerca de 16 minutos. Mas essa é só a maior distância a que o radar poderia ver o barco, e não onde ele de fato o detecta. Arraste o controle para subir ou baixar o radar.", "p3": "A detecção de verdade vem bem depois. Um barco pequeno devolve um eco fraco. A seção reta radar, o tamanho de alvo que o barco parece ter para um radar, fica em torno de 0,5 a 5 m², e nenhum número foi publicado para o Magura. Uma norma para radares costeiros espera que um alvo de 1 m², a 1 m acima do mar, seja encontrado a cerca de 9 km, e só por um radar avançado, com o mar até o estado 4. A 42 nós, o barco percorre essa distância em cerca de 7 minutos. As ondas dificultam ainda mais: um barco pequeno pode ficar escondido atrás delas até metade do tempo, e os ecos das próprias ondas, chamados de retorno do mar, podem abafar o eco dele.", - "p4": "Quem opera esses barcos diz que eles são difíceis de detectar. Um comandante do Grupo 13, a unidade de drones navais do GUR, disse que o radar tem dificuldade para enxergar o casco de plástico e que ele emite pouco calor. São afirmações, e nenhuma medição foi publicada. Uma coisa está registrada: o motor fica numa cuba de alumínio revestida de espuma, com a eletrônica por cima, o que mantém o calor longe da superfície externa. Os barcos são pintados de cinza, e os ataques mais conhecidos aconteceram à noite ou ao amanhecer. Toque nos botões para mudar o estado do mar, uma escala de altura das ondas que vai do mar calmo ao agitado.", + "p4": "Quem opera esses barcos diz que eles são difíceis de detectar. Um comandante do Grupo 13, a unidade de drones navais do GUR, disse que o radar tem dificuldade para enxergar o casco de plástico e que o barco emite pouco calor. São afirmações, e nenhuma medição foi publicada. Uma coisa está registrada: o motor fica numa cuba de alumínio revestida de espuma, com a eletrônica por cima, o que mantém o calor longe da superfície externa. Os barcos são pintados de cinza, e os ataques mais conhecidos aconteceram à noite ou ao amanhecer. Toque nos botões para mudar o estado do mar, uma escala de altura das ondas que vai do mar calmo ao agitado.", "caption": "Vista lateral do radar do navio, da curvatura do mar e do barco. A curvatura e as alturas estão exageradas.", "heightLabel": "Altura do radar", "radar": "Linha de visada do radar", From a3dd0e8e19a91494c3c39ef46acb846b697ee98e Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:04:48 +0200 Subject: [PATCH 16/99] feat(naval-drone): add the Ukrainian copy --- explainers/naval-drone/locales/uk.json | 322 +++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 explainers/naval-drone/locales/uk.json diff --git a/explainers/naval-drone/locales/uk.json b/explainers/naval-drone/locales/uk.json new file mode 100644 index 00000000..d7a6c460 --- /dev/null +++ b/explainers/naval-drone/locales/uk.json @@ -0,0 +1,322 @@ +{ + "meta": { + "title": "Як працює морський дрон", + "eyebrow": "Морський дрон", + "tagline": "Невеликий катер без жодної людини на борту: він ковзає по воді на власному корпусі, рухається завдяки водяному насосу, а керують ним через супутник.", + "description": "Простежте в 3D за морським дроном Magura V5: корпус, що виходить на глісування, водомет, супутниковий зв’язок і чому низький катер важко помітити.", + "summary": "Корпус, що ковзає по воді, водомет, який рухає й повертає катер, супутниковий зв’язок і його затримка, кривизна моря та конструкція, що весь час змінюється.", + "socialAlt": "Сірий безекіпажний катер на світанку ковзає морем, а з-під його вилиць летять бризки. Поруч заголовок «Як працює морський дрон»" + }, + "stage": { + "label": "Інтерактивна модель морського дрона", + "gauge": "Поточні показники переходу" + }, + "controls": { + "label": "Керування моделлю морського дрона", + "views": { + "cutaway": { + "name": "Розріз", + "short": "Розріз", + "hint": "Розріз (C)" + }, + "flow": { + "name": "Потік води", + "short": "Потік", + "hint": "Потік води (W)" + }, + "links": { + "name": "Радіоканали", + "short": "Канали", + "hint": "Радіоканали (K)" + }, + "labels": { + "name": "Підписи", + "short": "Підписи", + "hint": "Підписи (L)" + } + }, + "fit": { + "label": "Оснащення палуби" + }, + "fitOptions": { + "standard": "Стандартне", + "missile": "Ракетні напрямні" + }, + "momentOptions": { + "hullSpeed": "Швидкість корпусу", + "humpPeak": "Вершина горба", + "onPlane": "Уже глісує", + "cruiseSpeed": "Крейсерський хід", + "throttleUp": "Початок ривка", + "topSpeed": "Максимальна швидкість", + "alongside": "Біля корабля" + }, + "speedMarkOptions": { + "hullSpeed": "Швидкість корпусу", + "hump": "Горб опору", + "planing": "Глісування", + "cruise": "Крейсерська", + "top": "Максимальна" + }, + "helmOptions": { + "left": "Ліворуч", + "straight": "Прямо", + "right": "Праворуч", + "reverse": "Задній хід" + }, + "linkOptions": { + "satellite": "Starlink", + "backup": "Резервний супутник", + "lost": "Зв’язок втрачено" + }, + "seaStateOptions": { + "smooth": "Тихе", + "slight": "Слабке", + "moderate": "Помірне", + "rough": "Сильне" + } + }, + "timeline": { + "label": "Час переходу", + "phases": "Обрати етап переходу", + "speed": "Швидкість відтворення", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "порівняно зі звичайною швидкістю", + "phase": { + "launch": "Старт", + "hump": "Горб опору", + "cruise": "Крейсерський хід", + "sprint": "Ривок", + "arrival": "Прибуття" + }, + "during": { + "launch": "катер на малому ходу відходить від сліпу, повільніше за свою швидкість корпусу", + "hump": "на повному газі катер долає горб опору й виходить на глісування", + "cruise": "катер іде на 22 вузлах, а багатогодинний шлях тут скорочено", + "sprint": "катер розганяється до 42 вузлів, і до нього приєднуються ще два катери", + "arrival": "катер проходить останні 170 м до борту корабля" + }, + "jump": { + "launch": "Перейти до старту", + "hump": "Перейти до горба опору", + "cruise": "Перейти до крейсерського ходу", + "sprint": "Перейти до ривка", + "arrival": "Перейти до прибуття" + } + }, + "readouts": { + "clock": "Час переходу", + "speed": "Швидкість", + "mode": "Корпус", + "distance": "До корабля" + }, + "mode": { + "floating": "Пливе", + "hump": "Долає горб опору", + "planing": "Глісує" + }, + "units": { + "speed": "{{kn}} вуз., {{kmh}} км/год", + "m": "{{value}} м", + "km": "{{value}} км", + "percent": "{{value}} %", + "degrees": "{{value}}°", + "kilonewtons": "{{value}} кН", + "mps": "{{ms}} м/с, {{kmh}} км/год", + "ms": "{{value}} мс", + "minutes": "{{value}} хв" + }, + "parts": { + "hull": "Композитний корпус", + "chines": "Вилиці", + "sprayRails": "Бризковідбійники", + "payloadBay": "Відсік корисного навантаження", + "fuelTanks": "Паливні баки", + "engine": "Двигун у коробі", + "electronicsBay": "Електроніка", + "starlinkPanels": "Антени Starlink", + "backupPanel": "Резервна супутникова антена", + "cameraDome": "Купол тепловізора", + "bowCamera": "Носова камера", + "intake": "Водозабір і решітка", + "duct": "Водозабірний канал", + "driveShaft": "Приводний вал", + "impeller": "Крильчатка", + "waterjet": "Водомет", + "stator": "Лопатки статора", + "nozzle": "Сопло", + "steeringNozzle": "Поворотне сопло", + "reverseBucket": "Заслінка заднього ходу", + "missileRails": "Ракетні напрямні", + "jetStream": "Струмінь водомета", + "bowWave": "Носова хвиля", + "spray": "Бризки", + "wake": "Кільватерний слід", + "wettedLength": "Змочена довжина днища", + "videoGhost": "Де його бачить пілот", + "satLink": "Канал Starlink", + "backupLink": "Резервний канал", + "satellite": "Супутник Starlink", + "backupSatellite": "Резервний супутник", + "groundStation": "Наземна станція", + "ship": "Корабель на якорі", + "shipRadar": "Корабельний радар", + "companions": "Інші катери групи" + }, + "hull": { + "lift": "{{dynamic}} % завдяки швидкості, {{buoyant}} % завдяки плавучості", + "wetted": "{{m}} м, а без руху {{rest}} м", + "ratio": "У {{times}} раза більше за швидкість корпусу ({{hullSpeed}} вуз.)", + "ratioStill": "Катер стоїть. Швидкість корпусу {{hullSpeed}} вуз." + }, + "jet": { + "flow": "{{kg}} кг за секунду, це близько {{litres}} л", + "backing": "Гальмує, заслінку опущено", + "astern": "Заслінка повертає струмінь уперед", + "noEfficiency": "Немає, поки катер не рухається вперед", + "push": { + "left": "Сопло на {{angle}}° ліворуч: ніс повертає ліворуч", + "right": "Сопло на {{angle}}° праворуч: ніс повертає праворуч", + "straight": "Прямо назад: катер тримає курс", + "reverse": "Заслінку опущено: струмінь іде вперед і вниз" + } + }, + "link": { + "now": "{{m}} м позаду катера за швидкості {{kn}} вуз.", + "nowLost": "Без зв’язку немає й зображення", + "top": "{{m}} м на 42 вузлах, тобто {{lengths}} довжини катера", + "carrier": { + "satellite": "Starlink, два термінали", + "backup": "Резервна супутникова антена", + "lost": "Немає" + }, + "boat": { + "satellite": "Пілот веде катер, дивлячись відео", + "backup": "Пілот керує через резервний канал", + "lost": "У моделі тримає заданий курс. Як поводиться справжній катер, не оприлюднено" + } + }, + "horizon": { + "camera": "{{km}} км, з об’єктива на висоті {{m}} м над водою", + "detect": "Близько {{km}} км, або {{min}} хв на 42 вузлах", + "detectBeyond": "Не визначено: стандарт описує хвилювання лише до 4 балів", + "waves": { + "smooth": "Від {{from}} до {{to}} м, хвилювання 2 бали", + "slight": "Від {{from}} до {{to}} м, хвилювання 3 бали", + "moderate": "Від {{from}} до {{to}} м, хвилювання 4 бали", + "rough": "Від {{from}} до {{to}} м, хвилювання 5 балів" + }, + "hidden": { + "smooth": "Рідко: хвилі нижчі за палубу заввишки 0,5 м", + "slight": "До половини часу: хвилі заввишки з палубу", + "moderate": "До половини часу: хвилі вищі за палубу", + "rough": "До половини часу: хвилі здіймаються високо над палубою" + }, + "canvas": { + "radar": "Корабельний радар", + "boat": "Катер, 0,5 м заввишки", + "lineOfSight": "Пряма видимість", + "detection": "Очікуване виявлення", + "km": "{{value}} км", + "note": "Кривизну моря перебільшено" + } + }, + "fleet": { + "carries": { + "standard": "Корисне навантаження до {{kg}} кг у носі", + "missile": "Дві ракети Р-73 на напрямних, а носовий відсік у моделі лишається" + }, + "cost": "Приблизно від {{from}} до {{to}} доларів", + "ship": "Близько {{value}} млн доларів" + }, + "sections": { + "overview": { + "title": "Швидкісний катер, на якому нікого немає", + "p1": "Перед вами Magura V5, український морський дрон: швидкісний катер, на борту якого немає жодної людини. Його виготовляє державна компанія «Спецтехноекспорт» для ГУР, Головного управління розвідки Міноборони України. Уперше катер показали на виставці IDEF у Стамбулі в липні 2023 року. Він завдовжки 5,5 м і завширшки 1,5 м, тобто приблизно з невеликий моторний човен. Палуба піднімається над водою лише на 0,5 м, а важить катер менше за тонну. Керує ним пілот здалеку, через супутниковий зв’язок.", + "p2": "Перемикач «Розріз» дає змогу зазирнути всередину. Корпус композитний: це шари тканини з волокна, залиті твердою смолою. У носі є закритий відсік корисного навантаження, розрахований на вантаж до 320 кг. За ним стоять паливні баки, далі двигун в алюмінієвому коробі, вкладеному в піну, а над двигуном електроніка. Двигун приводить у дію водомет на кормі. Виробник не називає двигуна, тож модель припускає бензиновий двигун, як у гідроцикла: такі стояли на перших катерах. На палубі розміщено дві антени Starlink, резервну супутникову антену, низький купол із тепловізійною камерою і нерухому камеру в носі.", + "p3": "Модель показує один перехід. Катер на малому ходу відходить від сліпу, похилого спуску до води, виходить на глісування й іде крейсерським ходом зі швидкістю 22 вузли. Вузол означає одну морську милю за годину, тож це близько 41 км/год. Потім катер розганяється до максимальної швидкості 42 вузли, приблизно 78 км/год, і до нього приєднуються ще два катери. Перехід закінчується, коли ніс катера доходить до борту великого сірого корабля, що стоїть на якорі.", + "p4": "За звичайної швидкості відтворення катер рухається так само швидко, як справжній, але шлях скорочено, щоб усе вмістилося у дві хвилини. Справжній катер ішов би крейсерським ходом багато годин: близько 20 годин, щоб пройти всю свою дальність ходу, 450 морських миль, тобто приблизно 800 км. Тут крейсерський відрізок триває 42 секунди, і ривок теж скорочено. Корабель стоїть лише за 1,5 км від сліпу, а справжня ціль була б за сотні кілометрів. Годинник угорі показує час моделі, тож T+1:05 означає одну хвилину й п’ять секунд від старту. Перетягніть шкалу часу або натисніть кнопку нижче, щоб перейти до потрібної миті.", + "mode": "Корпус", + "covered": "Пройдено", + "done": "Прогрес переходу" + }, + "hull": { + "title": "Уже не пливе, а ковзає по воді", + "p1": "Коли катер стоїть, він тримається на воді, як і будь-який інший. Його підпирає вода, яку він витісняє, і це називають плавучістю. Коли катер набирає швидкість, долучається друга сила. Вода б’є в похиле днище й тисне на нього знизу, і що швидше катер іде, то сильніше вона тисне. Щойно цей тиск бере на себе більшу частину ваги, корпус піднімається й ковзає по воді. Це і є глісування. Але навіть тоді задня частина днища завжди лишається у воді.", + "p2": "Кожне судно, що йде крізь воду, здіймає носову хвилю. На швидкості близько 5,7 вузла ця хвиля стає такою ж довгою, як 5,5-метровий корпус цього катера. Це швидкість корпусу, проста мірка для суден, які лише пливуть. Але це не межа швидкості: на 42 вузлах Magura йде приблизно в 7 разів швидше. Щоб так розігнатися, катер мусить подолати горб опору. Тут він здіймає великі хвилі, а опір, тобто сила, якою вода його стримує, різко зростає. Гострі ребра вздовж днища, вилиці, на цьому етапі теж додають опору. Користь від них з’являється лише тоді, коли катер глісує.", + "p3": "Посуньте повзунок або натисніть кнопку й стежте за носом. Біля горба опору він піднімається найвище, а коли катер глісує, опускається. Цей кут називають диферентом. Опір найменший, коли кут становить приблизно від 3 до 4 градусів, а на максимальній швидкості катер іде трохи рівніше. На цьому катері глісування починається приблизно на швидкості від 15 до 18 вузлів, а показник у моделі перемикається на 16. Далі змочена частина днища швидко коротшає. На 15 вузлах вона має близько 4,5 м, а на 42 вузлах уже приблизно від 1,5 до 2 м. Даних про горб опору для цього катера не оприлюднено, тож пік у 6 градусів і розподіл ваги в показниках модель обрала сама.", + "p4": "Днище має форму глибокої літери V, і це пом’якшує удари хвиль. Його кут не оприлюднено: модель задає 20 градусів на кормі, а ближче до носа днище стає гострішим. Плоска зрізана корма має гострий нижній край, тож на швидкості вода чисто відривається від неї, і корма лишається сухою. Уздовж кожного борту тонкий бризковідбійник відкидає бризки вбік; це припущення моделі. Поздовжніх реданів, тобто ребер нижче на днищі, що допомагають корпусу вийти на глісування, у моделі немає, бо їх не видно на жодному фото. Завеликий диферент теж створює проблему: понад певний кут катер, що глісує, починає підстрибувати в рівному ритмі. Це називають дельфінуванням.", + "speedLabel": "Швидкість катера", + "mode": "Корпус", + "trim": "Кут носа (диферент)", + "lift": "Що тримає вагу", + "wetted": "Змочена довжина днища", + "ratio": "Порівняно зі швидкістю корпусу" + }, + "jet": { + "title": "Насос замість гвинта", + "p1": "На кормі стоїть водомет, тобто насос, що виконує роботу гребного гвинта. Вода заходить через отвір у днищі, закритий решіткою зі сталевих прутів. Крильчатка, невеликий ротор, схожий на вентилятор, обертається й розганяє її. Нерухомі лопатки за нею, статор, випрямляють закручений потік. Потім сопло звужує струмінь і викидає його назад, за корму. Перемикач «Потік води» показує, яким шляхом іде вода.", + "p2": "Рушійна сила з’являється тому, що насос розганяє воду, яка крізь нього проходить. Тяга дорівнює масі води, що проходить крізь насос щосекунди, помноженій на те, наскільки струмінь швидший за катер. Тож струмінь завжди мусить бути швидшим за катер. В ідеальному насосі частка роботи, яка рухає катер, дорівнює 2 / (1 + швидкість струменя / швидкість катера). Коли струмінь вилітає в півтора раза швидше за катер, ця частка становить 80 %. На малій швидкості струмінь набагато швидший за катер, і більша частина роботи пропадає марно.", + "p3": "Десь від 25 до 30 вузлів і вище водомет не поступається гребному гвинту, а то й перевершує його. Крім того, у нього є інші переваги. Під корпусом нічого не звисає: немає зайвого опору і нічим зачепитися, тож катер може ходити мілководдям. На швидкості глісування решітка здебільшого очищається сама, але водорості й мотузки все одно можуть забити водозабір. Перші катери у 2022 році мали деталі від гідроциклів: трициліндровий бензиновий двигун Rotax і насос Sea-Doo. Який двигун стоїть на самій Magura, не розголошують. Насос у моделі завбільшки як у гідроцикла, 155 мм у діаметрі, і на 42 вузлах прокачує приблизно від 100 до 200 літрів морської води щосекунди.", + "p4": "Керма тут немає. Щоб повернути, поворотне сопло відхиляє струмінь до 27 градусів в один чи інший бік. Відхиліть його праворуч, і корму відштовхне ліворуч, тож ніс поверне праворуч, як на гідроциклі. Щоб зупинитися або піти назад, на сопло опускається заслінка заднього ходу і розвертає струмінь уперед і вниз. Повзунок задає газ, а газом модель вважає те, скільки води прокачує насос. Натискайте кнопки, щоб кермувати чи дати задній хід, а щоб повернути катер до переходу, натисніть відтворення.", + "throttleLabel": "Газ", + "boat": "Швидкість катера", + "flow": "Вода через насос", + "velocity": "Швидкість струменя", + "thrust": "Тяга", + "efficiency": "Ідеальна ефективність", + "push": "Куди штовхає струмінь" + }, + "link": { + "title": "Керують ним за сотні кілометрів", + "p1": "Коли на борту нікого немає, усе тримається на радіозв’язку. У листопаді 2023 року в західному Криму захопили катер Magura, а згодом російські джерела показали, що в нього всередині. Там знайшли два супутникові термінали Starlink, резервну супутникову антену виробництва Kymeta, приймач GPS і невеликий комп’ютер на Linux. Мобільний роутер із двома SIM-картками давав зв’язок біля берега, у межах приблизно 40 км. Виробник також згадує радіомережу типу mesh, автономне керування й до трьох відеопотоків, але це лише його власні заяви.", + "p2": "Пілот працює за пультом, який возять у міцному кейсі, з екранами, важелями й джойстиками. Довгі переходи морем можна запрограмувати заздалегідь, але пілот стежить за катером усю дорогу, а останню ділянку часто проходять уручну. Тож катер не повністю автономний. Натискайте кнопки, щоб перемикатися між Starlink, резервним супутником і повною відсутністю зв’язку. Що робить справжній катер, коли зв’язок обривається, не оприлюднено, тож модель просто веде його далі заданим курсом.", + "p3": "Як для супутникового зв’язку, Starlink працює швидко. Його звичайна затримка на суходолі становить приблизно від 25 до 35 мілісекунд, а в морі може перевищувати 100. Сильніше обмежує швидкість вивантаження, тобто відправлення даних: від 10 до 30 мегабіт за секунду, а приблизно стільки й потрібно для трьох відеопотоків. Тож чіткість картинки в пілота залежить від вивантаження, а не від затримки. І все ж картинка завжди трохи запізнюється, а наскільки, ніхто не оприлюднював. Посуньте повзунок, щоб спробувати різну затримку: на 42 вузлах чверть секунди означає 5,4 м, приблизно одну довжину катера. Напівпрозорий катер-привид показує, де його бачить пілот.", + "p4": "Визначати, де катер перебуває, теж непросто. Із середини 2024 року глушіння й підміну сигналів GPS над Чорним морем добре задокументовано: на картах відстеження суден кораблі раптом опиняються на суходолі. Тому виробник згадує й інерціальну та візуальну навігацію: вони працюють за власними датчиками руху катера і його камерами. Зі Starlink тут теж пов’язана історія. У вересні 2022 року Україна розраховувала на покриття біля Криму, якого насправді ніколи не вмикали. Книжка 2023 року про Ілона Маска спершу розповіла про це інакше, а її автор виправив розповідь після того, як Маск заявив, що SpaceX нічого не вимикала. У моделі супутники намальовано маленькими, низько й близько, і вони рухаються разом із катером, щоб ви могли їх побачити.", + "delayLabel": "Затримка відео", + "now": "Де його бачить пілот", + "top": "Та сама затримка на максимальній швидкості", + "carrier": "Канал зв’язку", + "boat": "Що робить катер", + "path": "Як команда потрапляє на катер", + "step1": "Пілот: рухає важіль або джойстик на пульті.", + "step2": "Наземна станція: станція Starlink на суходолі надсилає команду вгору радіосигналом.", + "step3": "Супутник: супутник Starlink передає її вниз, на катер.", + "step4": "Катер: антена на палубі ловить команду, а комп’ютер змінює газ або повертає поворотне сопло.", + "step5": "Відео: зображення з камер повертається тим самим шляхом і доходить до пілота з невеликим запізненням." + }, + "horizon": { + "title": "Такий низький, що ховається за кривизною моря", + "p1": "Поверхня моря вигинається, тож бачити вздовж неї можна лише на певну відстань. З висоти h метрів горизонт лежить приблизно за 3,86 × √h км. Тепловізор катера стоїть приблизно на 0,7 м над водою, тож його горизонт лише за 3 км. Море в 3D-моделі пласке, бо на відстані 1,5 км справжня поверхня опускається лише приблизно на 0,1 м. На малюнку нижче кривизну зроблено набагато крутішою, щоб її було видно.", + "p2": "Корабельний радар має ту саму проблему. Його антена стоїть високо, але катер заввишки лише близько 0,5 м. Радіохвилі трохи огинають Землю, тому пряма видимість радара сягає приблизно 4,12 × (√h1 + √h2) км, де обидві висоти в метрах. Для радара на висоті 20 м це близько 21 км, і на 42 вузлах катер долає цю відстань приблизно за 16 хвилин. Але це лише найбільша відстань, на якій радар у принципі міг би побачити катер, а не та, де він справді його помічає. Посуньте повзунок, щоб підняти чи опустити радар.", + "p3": "Насправді катер помічають набагато пізніше. Невеликий катер відбиває слабкий сигнал. Його ефективна площа розсіювання, тобто те, якою великою ціллю він здається радару, становить приблизно від 0,5 до 5 м², а для Magura таких даних не оприлюднювали. Один зі стандартів для берегових радарів передбачає, що ціль площею 1 м² на висоті 1 м над морем буде виявлено приблизно за 9 км, і то лише радаром вищого класу за хвилювання до 4 балів. На 42 вузлах катер долає цю відстань приблизно за 7 хвилин. Хвилі ще більше ускладнюють справу: невеликий катер може ховатися за ними до половини часу, а відбиття від самих хвиль, так звані морські завади, можуть заглушити його власний сигнал.", + "p4": "Ті, хто керує цими катерами, кажуть, що їх важко помітити. Командир «Групи 13», підрозділу ГУР, що працює з морськими дронами, казав, що радару важко побачити пластиковий корпус і що катер виділяє мало тепла. Це лише заяви, жодних вимірювань не оприлюднено. Задокументовано одне: двигун стоїть в алюмінієвому коробі, викладеному піною, а над ним електроніка, і так тепло тримається подалі від зовнішньої обшивки. Катери пофарбовані в сірий колір, а найвідоміші атаки відбувалися вночі або на світанку. Натискайте кнопки, щоб змінити хвилювання моря: його оцінюють у балах за висотою хвиль, від тихого до сильного.", + "caption": "Вигляд збоку на корабельний радар, кривизну моря й катер. Кривизну й висоти перебільшено.", + "heightLabel": "Висота радара", + "radar": "Пряма видимість радара", + "minutes": "Час, щоб пройти цю відстань на 42 вузлах", + "camera": "Горизонт камери катера", + "detect": "Очікуване виявлення", + "waves": "Висота хвиль", + "hidden": "Ховається за хвилями" + }, + "fleet": { + "title": "Дешеві й невеликі катери, які швидко змінюються", + "p1": "Magura V5 коштує приблизно чверть мільйона доларів. Виробник називає близько 273 000 доларів, інші оцінки близько 250 000 доларів. Sea Baby, окрема розробка СБУ, Служби безпеки України, коштує близько 221 000 доларів. Патрульний корабель «Сергій Котов», що затонув у березні 2024 року після атаки катерів Magura, ГУР оцінює приблизно в 65 мільйонів доларів. Це відкриті дані, і в показниках нижче вони стоять поруч.", + "p2": "Ці катери рідко діють поодинці. Під час атак, про які повідомляли, вони діяли групами приблизно від п’яти до десяти катерів, а розробники кажуть, що кожен катер у групі має свою роль. Кораблі, про потоплення яких повідомляли, отримали по кілька влучань, а не одне. У цьому переході до ривка приєднуються ще два катери; як вони шикуються, вирішила сама модель.", + "p3": "Конструкція весь час змінюється. У грудні 2024 року ГУР встановило на катери V5 по дві ракети «повітря-повітря» Р-73 на напрямних. Ці ракети дістали нову назву Sea Dragon. Натискайте кнопки, щоб побачити це оснащення. Змінюється лише палуба: катер у моделі йде тим самим маршрутом і так само закінчує перехід біля корабля. У 2025 році ГУР показало більший катер Magura V7 завдовжки 7,2 м, з корисним навантаженням до 650 кг. А Sea Baby належить до окремої лінійки СБУ і не є катером Magura.", + "p4": "У переліку нижче лише те, що заявляла кожна зі сторін, і чимало цих заяв оскаржують. Міністерство оборони Великої Британії нараховує щонайменше 26 кораблів ВМФ Росії, пошкоджених або знищених з лютого 2022 до червня 2024 року, а це приблизно третина Чорноморського флоту. Цей підрахунок охоплює всі види зброї, а не лише морські дрони. До жовтня 2023 року супутникові знімки показали, що близько 14 кораблів перевели із Севастополя до Новоросійська.", + "carries": "На борту", + "cost": "Ціна одного катера", + "ship": "Патрульний корабель, за оцінкою ГУР", + "events": "Відкриті дані, як про них повідомляли", + "event1": "29 жовтня 2022 року, Севастополь: морські й повітряні дрони вразили фрегат «Адмірал Макаров» і тральщик. Росія визнала лише незначні пошкодження.", + "event2": "Ніч з 31 січня на 1 лютого 2024 року, озеро Донузлав: катери Magura V5 потопили ракетний корвет «Івановець». ГУР заявляє про шість влучань, на відео видно близько трьох.", + "event3": "Ніч з 4 на 5 березня 2024 року, біля Керченської протоки: катери Magura V5 потопили патрульний корабель «Сергій Котов». За словами українських посадовців, в атаці брали участь п’ять катерів.", + "event4": "31 грудня 2024 року, мис Тарханкут: за словами ГУР, катери Magura V5 збили ракетами Р-73 гелікоптери Мі-8, і це перші літальні апарати, збиті з морського дрона. Спершу йшлося про один гелікоптер, згодом про два.", + "event5": "2 травня 2025 року, біля Новоросійська: за словами ГУР, катери Magura V7 збили два літаки Су-30СМ ракетами Sidewinder. Росія цього не підтвердила." + } + } +} From 2ceba988bc9db6ab169159d3681dc09c8afb4777 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:05:08 +0200 Subject: [PATCH 17/99] feat(naval-drone): add the French copy --- explainers/naval-drone/locales/fr.json | 322 +++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 explainers/naval-drone/locales/fr.json diff --git a/explainers/naval-drone/locales/fr.json b/explainers/naval-drone/locales/fr.json new file mode 100644 index 00000000..43d8349e --- /dev/null +++ b/explainers/naval-drone/locales/fr.json @@ -0,0 +1,322 @@ +{ + "meta": { + "title": "Comment fonctionne un drone naval", + "eyebrow": "Drone naval", + "tagline": "Un petit bateau sans personne à bord : sa coque glisse à la surface, une pompe à eau le fait avancer, et on le pilote par liaison satellite.", + "description": "Suivez en 3D un drone naval Magura V5 : la coque qui se met à planer, l’hydrojet, la liaison satellite, et pourquoi un bateau si bas se voit mal.", + "summary": "La coque qui plane, l’hydrojet qui fait avancer et tourner le bateau, la liaison satellite et son décalage, la courbure de la mer, et une conception qui ne cesse d’évoluer.", + "socialAlt": "Un bateau gris sans équipage glisse sur la mer à l’aube, des gerbes d’embruns jaillissant de ses bouchains, à côté du titre « Comment fonctionne un drone naval »" + }, + "stage": { + "label": "Modèle interactif du drone naval", + "gauge": "Mesures du trajet en direct" + }, + "controls": { + "label": "Commandes du drone naval", + "views": { + "cutaway": { + "name": "Vue en coupe", + "short": "Coupe", + "hint": "Vue en coupe (C)" + }, + "flow": { + "name": "Flux d’eau", + "short": "Flux", + "hint": "Flux d’eau (W)" + }, + "links": { + "name": "Liaisons radio", + "short": "Liaisons", + "hint": "Liaisons radio (K)" + }, + "labels": { + "name": "Étiquettes", + "short": "Étiquettes", + "hint": "Étiquettes (L)" + } + }, + "fit": { + "label": "Équipement du pont" + }, + "fitOptions": { + "standard": "Standard", + "missile": "Rails lance-missiles" + }, + "momentOptions": { + "hullSpeed": "Vitesse de carène", + "humpPeak": "Haut de la bosse", + "onPlane": "Le bateau plane", + "cruiseSpeed": "En croisière", + "throttleUp": "Début du sprint", + "topSpeed": "Vitesse maximale", + "alongside": "Au navire" + }, + "speedMarkOptions": { + "hullSpeed": "Vitesse de carène", + "hump": "Bosse", + "planing": "Déjaugeage", + "cruise": "Croisière", + "top": "Vitesse maximale" + }, + "helmOptions": { + "left": "Gauche", + "straight": "Tout droit", + "right": "Droite", + "reverse": "Marche arrière" + }, + "linkOptions": { + "satellite": "Starlink", + "backup": "Satellite de secours", + "lost": "Liaison perdue" + }, + "seaStateOptions": { + "smooth": "Belle", + "slight": "Peu agitée", + "moderate": "Agitée", + "rough": "Forte" + } + }, + "timeline": { + "label": "Temps du trajet", + "phases": "Aller à une étape du trajet", + "speed": "Vitesse de lecture", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "par rapport à la vitesse normale", + "phase": { + "launch": "Départ", + "hump": "Bosse", + "cruise": "Croisière", + "sprint": "Sprint", + "arrival": "Arrivée" + }, + "during": { + "launch": "le bateau quitte la cale au ralenti, sans atteindre sa vitesse de carène", + "hump": "à plein gaz, le bateau franchit la bosse et se met à planer", + "cruise": "le bateau file à 22 nœuds, une croisière de longues heures ici écourtée", + "sprint": "le bateau accélère jusqu’à 42 nœuds, et deux autres bateaux le rejoignent", + "arrival": "le bateau parcourt les 170 derniers mètres jusqu’au flanc du navire" + }, + "jump": { + "launch": "Aller au départ", + "hump": "Aller à la bosse", + "cruise": "Aller à la croisière", + "sprint": "Aller au sprint", + "arrival": "Aller à l’arrivée" + } + }, + "readouts": { + "clock": "Horloge du trajet", + "speed": "Vitesse", + "mode": "Coque", + "distance": "Jusqu’au navire" + }, + "mode": { + "floating": "Elle flotte", + "hump": "Elle franchit la bosse", + "planing": "Elle plane" + }, + "units": { + "speed": "{{kn}} nd, {{kmh}} km/h", + "m": "{{value}} m", + "km": "{{value}} km", + "percent": "{{value}} %", + "degrees": "{{value}}°", + "kilonewtons": "{{value}} kN", + "mps": "{{ms}} m/s, {{kmh}} km/h", + "ms": "{{value}} ms", + "minutes": "{{value}} min" + }, + "parts": { + "hull": "Coque en composite", + "chines": "Bouchains", + "sprayRails": "Déflecteurs d’embruns", + "payloadBay": "Soute de charge utile", + "fuelTanks": "Réservoirs de carburant", + "engine": "Moteur dans son bac", + "electronicsBay": "Électronique", + "starlinkPanels": "Antennes Starlink", + "backupPanel": "Antenne satellite de secours", + "cameraDome": "Dôme de la caméra thermique", + "bowCamera": "Caméra avant", + "intake": "Prise d’eau et grille", + "duct": "Conduit d’aspiration", + "driveShaft": "Arbre de transmission", + "impeller": "Turbine", + "waterjet": "Hydrojet", + "stator": "Aubes du stator", + "nozzle": "Tuyère", + "steeringNozzle": "Tuyère orientable", + "reverseBucket": "Godet de marche arrière", + "missileRails": "Rails lance-missiles", + "jetStream": "Jet d’eau", + "bowWave": "Vague d’étrave", + "spray": "Embruns", + "wake": "Sillage", + "wettedLength": "Longueur mouillée du fond", + "videoGhost": "Là où le pilote le voit", + "satLink": "Liaison Starlink", + "backupLink": "Liaison de secours", + "satellite": "Satellite Starlink", + "backupSatellite": "Satellite de secours", + "groundStation": "Station au sol", + "ship": "Navire à l’ancre", + "shipRadar": "Radar du navire", + "companions": "Bateaux qui l’accompagnent" + }, + "hull": { + "lift": "{{dynamic}} % par la vitesse, {{buoyant}} % par la flottaison", + "wetted": "{{m}} m, contre {{rest}} m au repos", + "ratio": "{{times}} fois la vitesse de carène, qui est de {{hullSpeed}} nd", + "ratioStill": "À l’arrêt. La vitesse de carène est de {{hullSpeed}} nd" + }, + "jet": { + "flow": "{{kg}} kg par seconde, soit environ {{litres}} litres", + "backing": "Le bateau freine, godet baissé", + "astern": "Renvoyé vers l’avant par le godet", + "noEfficiency": "Nul tant que le bateau n’avance pas", + "push": { + "left": "Tuyère tournée de {{angle}}° vers la gauche : l’avant vire à gauche", + "right": "Tuyère tournée de {{angle}}° vers la droite : l’avant vire à droite", + "straight": "Droit vers l’arrière : le bateau garde son cap", + "reverse": "Godet baissé : le jet part vers l’avant et vers le bas" + } + }, + "link": { + "now": "{{m}} m derrière le bateau à {{kn}} nd", + "nowLost": "Aucune image tant que la liaison est coupée", + "top": "{{m}} m à 42 nd, soit {{lengths}} fois la longueur du bateau", + "carrier": { + "satellite": "Starlink, deux terminaux", + "backup": "Antenne satellite de secours", + "lost": "Aucune" + }, + "boat": { + "satellite": "Le pilote dirige d’après la vidéo", + "backup": "Le pilote dirige par la liaison de secours", + "lost": "Dans le modèle, il garde son cap ; ce que fait un vrai bateau n’est pas publié" + } + }, + "horizon": { + "camera": "{{km}} km, depuis un objectif à {{m}} m au-dessus de l’eau", + "detect": "Environ {{km}} km, soit {{min}} min à 42 nd", + "detectBeyond": "Non couvert : la norme s’arrête à une mer de force 4", + "waves": { + "smooth": "{{from}} à {{to}} m, mer de force 2", + "slight": "{{from}} à {{to}} m, mer de force 3", + "moderate": "{{from}} à {{to}} m, mer de force 4", + "rough": "{{from}} à {{to}} m, mer de force 5" + }, + "hidden": { + "smooth": "Rarement : les vagues restent sous son pont, haut de 0,5 m", + "slight": "Jusqu’à la moitié du temps : les vagues sont aussi hautes que son pont", + "moderate": "Jusqu’à la moitié du temps : les vagues dépassent son pont", + "rough": "Jusqu’à la moitié du temps : les vagues dominent largement son pont" + }, + "canvas": { + "radar": "Radar du navire", + "boat": "Bateau, 0,5 m de haut", + "lineOfSight": "Ligne de vue", + "detection": "Détection attendue", + "km": "{{value}} km", + "note": "Courbure de la mer exagérée" + } + }, + "fleet": { + "carries": { + "standard": "Une charge utile de {{kg}} kg au plus, à l’avant", + "missile": "Deux missiles R-73 sur rails ; le modèle garde la soute avant" + }, + "cost": "De {{from}} à {{to}} dollars environ", + "ship": "Environ {{value}} millions de dollars" + }, + "sections": { + "overview": { + "title": "Une vedette rapide sans personne à bord", + "p1": "Vous avez sous les yeux un Magura V5, un drone naval venu d’Ukraine : un bateau rapide sans personne à bord. Une entreprise d’État, SpetsTechnoExport, le construit pour le GUR, le renseignement militaire ukrainien. Il a été présenté pour la première fois en juillet 2023, au salon IDEF d’Istanbul. Le bateau mesure 5,5 m de long et 1,5 m de large, à peu près la taille d’un petit hors-bord. Son pont ne dépasse l’eau que de 0,5 m, et il pèse moins d’une tonne. Un pilote, très loin de là, le dirige par une liaison satellite.", + "p2": "La vue en coupe vous permet de regarder à l’intérieur. La coque est en composite : des couches de tissu de fibres prises dans une résine dure. L’avant abrite une soute de charge utile fermée, qui peut recevoir jusqu’à 320 kg. Derrière viennent les réservoirs de carburant, puis le moteur, logé dans un bac en aluminium calé dans de la mousse, avec l’électronique au-dessus. À l’arrière, le moteur entraîne un hydrojet, un propulseur à jet d’eau. Le fabricant n’a pas dit quel moteur il utilise : le modèle suppose donc un moteur à essence de type jet-ski, comme sur les premiers bateaux. Sur le pont se trouvent deux antennes Starlink, une antenne satellite de secours, un dôme bas qui abrite une caméra thermique, et une caméra fixe à l’avant.", + "p3": "Le modèle suit un seul trajet. Le bateau quitte au ralenti une cale de mise à l’eau, se met à planer, puis file à 22 nœuds en croisière. Un nœud, c’est un mille marin par heure : cela fait donc environ 41 km/h. Ensuite, il accélère jusqu’à sa vitesse maximale de 42 nœuds, environ 78 km/h, et deux autres bateaux le rejoignent. Le trajet s’achève quand sa proue atteint le flanc d’un grand navire gris, à l’ancre.", + "p4": "À vitesse normale, le bateau avance aussi vite qu’un vrai, mais le voyage est raccourci pour tenir en deux minutes. Un vrai bateau naviguerait de longues heures : il lui faudrait environ 20 heures pour aller au bout de son autonomie, 450 milles marins, soit environ 800 km. Ici, la croisière dure 42 secondes, et le sprint est lui aussi écourté. Le navire n’est qu’à 1,5 km de la cale, alors qu’une vraie cible serait à des centaines de kilomètres. L’horloge, en haut, compte le temps du modèle : T+1:05 signifie donc une minute et cinq secondes après le départ. Faites glisser le curseur de la barre de lecture, ou appuyez sur un bouton ci-dessous pour aller directement à un moment.", + "mode": "Coque", + "covered": "Distance parcourue", + "done": "Avancement du trajet" + }, + "hull": { + "title": "Il cesse de flotter et glisse sur l’eau", + "p1": "Au repos, le bateau flotte comme n’importe quel autre. L’eau qu’il écarte le maintient à flot : c’est la poussée d’Archimède. Quand il prend de la vitesse, une deuxième force entre en jeu. L’eau qui frappe le fond incliné le pousse vers le haut, et plus le bateau va vite, plus elle pousse fort. Dès que cette poussée porte l’essentiel du poids, la coque se soulève et glisse sur l’eau : le bateau plane. Les marins disent qu’il déjauge. Même alors, la partie arrière du fond reste toujours dans l’eau.", + "p2": "Tout bateau qui fend l’eau crée une vague à l’avant, la vague d’étrave. Vers 5,7 nœuds, cette vague devient aussi longue que la coque, qui mesure 5,5 m. C’est la vitesse de carène, un repère approximatif pour les bateaux qui ne font que flotter. Ce n’est pas une limite de vitesse : à 42 nœuds, le Magura va environ 7 fois plus vite. Pour y arriver, il doit franchir la bosse de résistance. Là, le bateau fait de grosses vagues, et la traînée, la force de l’eau qui le freine, grimpe en flèche. Les bouchains, ces arêtes vives qui longent le fond, ajoutent eux aussi de la traînée à ce stade. Ils ne deviennent utiles qu’une fois que le bateau plane.", + "p3": "Déplacez le curseur, ou appuyez sur un bouton, et observez l’avant. C’est près de la bosse qu’il se lève le plus, puis il redescend quand le bateau plane. Cet angle s’appelle l’assiette. La traînée est la plus faible vers 3 à 4 degrés, et le bateau file un peu plus à plat à vitesse maximale. Sur ce bateau, le déjaugeage commence vers 15 à 18 nœuds, et l’indicateur du modèle bascule à 16. Ensuite, la partie mouillée du fond raccourcit vite : d’environ 4,5 m à 15 nœuds, elle passe à environ 1,5 à 2 m à 42 nœuds. Aucune donnée n’est publiée sur la bosse de ce bateau : le pic de 6 degrés et la répartition du poids dans les indicateurs sont donc des chiffres propres au modèle.", + "p4": "Le fond forme un V profond, qui amortit les chocs des vagues. Son angle n’est pas publié : le modèle lui donne 20 degrés à l’arrière, avec un V de plus en plus prononcé vers l’avant. L’arrière, coupé droit, se termine en bas par une arête vive : quand le bateau va vite, l’eau s’en détache net et l’arrière reste au sec. Le long de chaque flanc, un mince déflecteur d’embruns rejette les gerbes d’eau vers l’extérieur ; c’est une supposition du modèle. Les virures, des nervures placées plus bas qui aident une coque à déjauger, ne sont pas représentées, car aucune photo ne les montre. Trop d’assiette pose aussi problème : au-delà d’un certain angle, un bateau qui plane se met à rebondir à un rythme régulier. C’est ce qu’on appelle le marsouinage.", + "speedLabel": "Vitesse du bateau", + "mode": "Coque", + "trim": "Angle de la proue (assiette)", + "lift": "Poids porté", + "wetted": "Longueur mouillée du fond", + "ratio": "Par rapport à la vitesse de carène" + }, + "jet": { + "title": "Une pompe au lieu d’une hélice", + "p1": "À l’arrière se trouve un hydrojet, une pompe qui fait le travail d’une hélice. L’eau entre par une ouverture dans le fond, derrière une grille de barreaux d’acier. Une turbine, un petit rotor qui tourne comme un ventilateur, l’accélère. Derrière elle, des aubes fixes, le stator, redressent l’écoulement pour qu’il ne tourbillonne plus. Enfin, une tuyère, un embout plus étroit, resserre le jet et le projette derrière le bateau. La vue Flux d’eau montre son trajet.", + "p2": "La force qui fait avancer le bateau vient de l’eau que la pompe accélère. La poussée est égale à la masse d’eau qui passe chaque seconde, multipliée par l’écart entre la vitesse du jet et celle du bateau. Le jet doit donc toujours aller plus vite que le bateau. Dans une pompe idéale, la part du travail qui sert à faire avancer le bateau vaut 2 / (1 + vitesse du jet / vitesse du bateau). Quand le jet sort à une fois et demie la vitesse du bateau, cette part atteint 80 %. À basse vitesse, le jet va bien plus vite que le bateau, et l’essentiel du travail est perdu.", + "p3": "Au-delà d’environ 25 à 30 nœuds, un hydrojet fait aussi bien qu’une hélice, voire mieux, et il a d’autres atouts. Rien ne dépasse sous la coque pour freiner le bateau ou heurter un obstacle : il peut donc naviguer en eau peu profonde. Quand le bateau plane, la grille se dégage le plus souvent d’elle-même, mais des algues ou des cordages peuvent quand même boucher une prise d’eau. En 2022, les premiers bateaux utilisaient des pièces de jet-ski : un moteur à essence Rotax à trois cylindres et une pompe Sea-Doo. Le moteur du Magura lui-même n’est pas dévoilé. La pompe du modèle a la taille de celle d’un jet-ski, 155 mm de diamètre, et à 42 nœuds, elle fait passer à peu près 100 à 200 litres d’eau de mer par seconde.", + "p4": "Il n’y a pas de gouvernail. Pour tourner, la tuyère orientable dévie le jet jusqu’à 27 degrés d’un côté ou de l’autre. Si elle pivote vers la droite, l’arrière est poussé vers la gauche, et l’avant tourne donc à droite, comme sur un jet-ski. Pour s’arrêter ou reculer, un godet de marche arrière s’abaisse sur la sortie de la tuyère et renvoie le jet vers l’avant et vers le bas. Le curseur règle l’accélérateur, que le modèle traduit en quantité d’eau brassée par la pompe. Appuyez sur les boutons pour diriger le bateau ou passer en marche arrière, puis lancez la lecture pour le rendre au trajet.", + "throttleLabel": "Accélérateur", + "boat": "Vitesse du bateau", + "flow": "Eau qui traverse la pompe", + "velocity": "Vitesse du jet", + "thrust": "Poussée", + "efficiency": "Rendement idéal", + "push": "Direction du jet" + }, + "link": { + "title": "Piloté à des centaines de kilomètres", + "p1": "Sans personne à bord, tout dépend de la liaison radio. En novembre 2023, un Magura a été capturé dans l’ouest de la Crimée, et des sources russes ont ensuite montré ce qu’il contenait. Elles y ont trouvé deux terminaux satellite Starlink, une antenne satellite de secours fabriquée par Kymeta, un récepteur GPS et un petit ordinateur sous Linux. Un routeur mobile à deux cartes SIM assurait une liaison près des côtes, jusqu’à environ 40 km. Le fabricant annonce aussi un réseau radio maillé, un pilotage autonome et jusqu’à trois flux vidéo, mais ce ne sont que ses propres affirmations.", + "p2": "Le pilote travaille sur un pupitre qui se transporte dans une mallette rigide, avec des écrans, des leviers et des joysticks. Les longues traversées peuvent être programmées à l’avance, mais un pilote surveille tout le trajet, et la dernière portion se fait souvent à la main. Le bateau n’est donc pas entièrement autonome. Appuyez sur les boutons pour passer de Starlink au satellite de secours, ou pour simuler une liaison coupée. Ce que fait un vrai bateau quand sa liaison tombe n’a pas été publié : le modèle se contente donc de le garder sur son cap.", + "p3": "Pour un service par satellite, Starlink est rapide. Sur terre, sa latence, c’est-à-dire le délai de transmission, est en général d’environ 25 à 35 millisecondes, et en mer elle peut dépasser 100. La contrainte la plus forte, c’est le débit montant, ce que le bateau peut envoyer : 10 à 30 mégabits par seconde, à peu près ce qu’il faut pour trois flux vidéo. C’est donc le débit montant, et non la latence, qui fixe la netteté de l’image du pilote. Mais l’image arrive toujours un peu en retard, et personne n’a publié de combien. Faites glisser le curseur pour essayer un retard : à 42 nœuds, un quart de seconde représente 5,4 m, soit à peu près une longueur de bateau. Le bateau fantôme montre où le pilote le voit.", + "p4": "Se repérer demande aussi des précautions. Depuis la mi-2024, le brouillage et la falsification des signaux GPS au-dessus de la mer Noire sont bien documentés : sur les cartes de suivi, des navires apparaissent en pleine terre. C’est pourquoi le fabricant annonce aussi une navigation inertielle et visuelle, qui s’appuie sur les capteurs de mouvement et les caméras du bateau lui-même. Starlink a lui aussi son histoire. En septembre 2022, l’Ukraine comptait sur une couverture près de la Crimée qui n’avait jamais été activée. Un livre sur Elon Musk paru en 2023 a d’abord raconté l’épisode autrement, puis son auteur a corrigé son récit après que Musk a déclaré que SpaceX n’avait rien désactivé. Le modèle dessine les satellites petits, bas et proches, et les fait avancer avec le bateau, pour que vous puissiez les voir.", + "delayLabel": "Retard de la vidéo", + "now": "Là où le pilote le voit", + "top": "Même retard à vitesse maximale", + "carrier": "Liaison", + "boat": "Ce que fait le bateau", + "path": "Le trajet d’une commande jusqu’au bateau", + "step1": "Pilote : il bouge un levier ou un joystick sur le pupitre.", + "step2": "Station au sol : une station Starlink à terre envoie la commande par radio vers le satellite.", + "step3": "Satellite : un satellite Starlink la renvoie vers le bateau.", + "step4": "Bateau : une antenne sur le pont la capte, et l’ordinateur actionne l’accélérateur ou la tuyère orientable.", + "step5": "Vidéo : les images des caméras font le chemin inverse et arrivent au pilote avec un léger retard." + }, + "horizon": { + "title": "Assez bas pour se cacher derrière la courbure de la mer", + "p1": "La surface de la mer est courbe, si bien que le regard ne porte pas au-delà d’une certaine distance. Depuis une hauteur de h mètres, l’horizon se trouve à environ 3,86 × √h kilomètres. La caméra thermique du bateau est placée à environ 0,7 m au-dessus de l’eau : son horizon n’est donc qu’à environ 3 km. Dans le modèle, la mer en 3D est plate, car sur 1,5 km, la vraie courbure ne descend que d’environ 0,1 m. Le dessin ci-dessous rend la courbure bien plus prononcée pour que vous puissiez la voir.", + "p2": "Le radar d’un navire a le même problème. Son antenne est placée haut, mais le bateau ne mesure qu’environ 0,5 m de haut. Les ondes radio se courbent un peu autour de la Terre, si bien que la ligne de vue du radar s’étend jusqu’à environ 4,12 × (√h1 + √h2) kilomètres, les deux hauteurs étant en mètres. Pour un radar placé à 20 m, cela donne environ 21 km, et à 42 nœuds, le bateau les parcourt en environ 16 minutes. Mais c’est seulement la distance maximale à laquelle le radar pourrait voir le bateau, pas celle où il le repère vraiment. Faites glisser le curseur pour monter ou baisser le radar.", + "p3": "En réalité, la détection arrive bien plus tard. Un petit bateau ne renvoie qu’un faible écho. Sa surface équivalente radar, la taille de la cible qu’il semble être aux yeux d’un radar, est d’environ 0,5 à 5 m², et aucun chiffre n’a été publié pour le Magura. Une norme pour les radars côtiers prévoit qu’une cible de 1 m², à 1 m au-dessus de la mer, soit repérée à environ 9 km, et seulement par un radar de pointe, avec une mer de force 4 au plus. À 42 nœuds, le bateau couvre cette distance en environ 7 minutes. Les vagues compliquent encore les choses : un petit bateau peut rester caché derrière elles jusqu’à la moitié du temps, et les échos des vagues elles-mêmes, qu’on appelle le fouillis de mer, peuvent noyer le sien.", + "p4": "Ceux qui utilisent ces bateaux disent qu’ils sont difficiles à repérer. Un commandant du Groupe 13, l’unité de drones navals du GUR, a déclaré que les radars ont du mal à voir la coque en plastique, et que le bateau dégage peu de chaleur. Ce sont des affirmations, et aucune mesure n’a été publiée. Un fait, lui, est établi : le moteur se trouve dans un bac en aluminium garni de mousse, avec l’électronique au-dessus, ce qui tient la chaleur à l’écart de la paroi extérieure. Les bateaux sont peints en gris, et les attaques les plus connues ont eu lieu de nuit ou à l’aube. Appuyez sur les boutons pour changer l’état de la mer, une échelle de hauteur des vagues qui va de « belle » à « forte ».", + "caption": "Vue de côté du radar du navire, de la courbure de la mer et du bateau. La courbure et les hauteurs sont exagérées.", + "heightLabel": "Hauteur du radar", + "radar": "Ligne de vue du radar", + "minutes": "Temps de parcours à 42 nd", + "camera": "Horizon de la caméra du bateau", + "detect": "Détection attendue", + "waves": "Hauteur des vagues", + "hidden": "Masqué par les vagues" + }, + "fleet": { + "title": "Bon marché, petit et en pleine évolution", + "p1": "Un Magura V5 coûte environ un quart de million de dollars. Le fabricant annonce environ 273 000 dollars, et d’autres estimations parlent d’environ 250 000 dollars. Le Sea Baby, un modèle distinct conçu par le SBU, le service de sécurité ukrainien, coûte environ 221 000 dollars. Le GUR estime à environ 65 millions de dollars la valeur du patrouilleur Sergueï Kotov, qui a coulé en mars 2024 après une attaque de Magura. Ce sont des chiffres publics, placés côte à côte dans les indicateurs ci-dessous.", + "p2": "Ces bateaux agissent rarement seuls. Les attaques rapportées impliquaient des groupes d’environ cinq à dix bateaux, et les concepteurs disent que chacun y a son propre rôle. Les navires signalés comme coulés ont été touchés plusieurs fois, pas une seule. Dans le trajet, deux bateaux rejoignent le Magura pour le sprint ; leur formation est un choix du modèle.", + "p3": "La conception évolue sans cesse. En décembre 2024, le GUR a équipé des V5 de deux missiles air-air R-73 sur rails, rebaptisés Sea Dragon. Appuyez sur les boutons pour voir cette version. Seul le pont change : le bateau du modèle suit le même trajet et finit toujours contre le flanc du navire. En 2025, le GUR a présenté le Magura V7, plus grand, long de 7,2 m, avec une charge utile pouvant atteindre 650 kg. Le Sea Baby, lui, est une gamme propre au SBU : ce n’est pas un Magura.", + "p4": "La chronologie ci-dessous s’en tient à ce que chaque camp a déclaré, et beaucoup de ces affirmations sont contestées. Le ministère britannique de la Défense compte au moins 26 navires de la marine russe endommagés ou détruits entre février 2022 et juin 2024, soit environ un tiers de la flotte de la mer Noire. Ce décompte couvre tous les types d’armes, pas seulement les drones navals. En octobre 2023, des images satellite montraient qu’environ 14 navires avaient déjà été déplacés de Sébastopol à Novorossiisk.", + "carries": "À bord", + "cost": "Coût par bateau", + "ship": "Patrouilleur, selon l’estimation du GUR", + "events": "Faits rapportés publiquement", + "event1": "29 octobre 2022, Sébastopol : des drones marins et aériens ont touché la frégate Amiral Makarov et un dragueur de mines. La Russie n’a reconnu que des dégâts mineurs.", + "event2": "Nuit du 31 janvier au 1er février 2024, lac Donouzlav : des Magura V5 ont coulé la corvette lance-missiles Ivanovets. Le GUR revendique six coups au but ; la vidéo en montre environ trois.", + "event3": "Nuit du 4 au 5 mars 2024, près du détroit de Kertch : des Magura V5 ont coulé le patrouilleur Sergueï Kotov. Selon des responsables ukrainiens, cinq bateaux y ont pris part.", + "event4": "31 décembre 2024, cap Tarkhankout : selon le GUR, des Magura V5 ont abattu des hélicoptères Mi-8 avec des missiles R-73, les premiers aéronefs abattus depuis un drone naval. Le GUR en a d’abord revendiqué un, puis deux.", + "event5": "2 mai 2025, près de Novorossiisk : selon le GUR, des Magura V7 ont abattu deux avions de chasse Su-30SM avec des missiles Sidewinder. La Russie ne l’a pas confirmé." + } + } +} From bf4dc4f2f9896b27dc0b3a969441cd1ab06f8270 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:43:52 +0200 Subject: [PATCH 18/99] feat(naval-drone): use the writer's link preview description --- explainers/naval-drone/explainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/explainers/naval-drone/explainer.json b/explainers/naval-drone/explainer.json index 0ff9f1e7..8d2355ed 100644 --- a/explainers/naval-drone/explainer.json +++ b/explainers/naval-drone/explainer.json @@ -7,6 +7,6 @@ "locales": ["en", "zh", "es", "uk", "pt", "fr", "de", "ja"], "social": { "image": "social/og-image.png", - "alt": "A grey uncrewed boat skimming the sea at dawn, beside the title How a naval drone works" + "alt": "A grey uncrewed boat skimming over the sea at dawn with spray flying from its chines, beside the title How a naval drone works" } } From 4491b2e17c5125d2b07da8bf28a310787ef05bff Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:59:47 +0200 Subject: [PATCH 19/99] fix(naval-drone): keep the jet boat speed in step with the gauge --- .../naval-drone/src/ui/format.dom.test.ts | 8 +++++ explainers/naval-drone/src/ui/format.ts | 15 ++++++++-- .../naval-drone/src/ui/mountJetControl.ts | 17 ++++++++--- .../naval-drone/src/ui/mountUi.dom.test.ts | 29 +++++++++++++++++-- 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/explainers/naval-drone/src/ui/format.dom.test.ts b/explainers/naval-drone/src/ui/format.dom.test.ts index 26f440df..4e6223c8 100644 --- a/explainers/naval-drone/src/ui/format.dom.test.ts +++ b/explainers/naval-drone/src/ui/format.dom.test.ts @@ -40,6 +40,7 @@ import { formatTrialSpeed, formatWaves, formatWetted, + shownKnots, } from './format'; import { fill, isFilled } from './testing'; @@ -71,9 +72,16 @@ describe('naval drone formatting', () => { expect(formatBoatSpeed(5.7)).toBe(fill(units.speed, { kn: '5.7', kmh: '11' })); expect(formatBoatSpeed(22)).toBe(fill(units.speed, { kn: '22', kmh: '41' })); expect(formatBoatSpeed(42)).toBe(fill(units.speed, { kn: '42', kmh: '78' })); + expect(formatBoatSpeed(9.97)).toBe(fill(units.speed, { kn: '10', kmh: '19' })); expect(formatTrialSpeed(11)).toBe(fill(units.speed, { kn: '11.0', kmh: '20' })); }); + it('pairs the km/h with the knots it shows', () => { + expect(shownKnots(9.94)).toBe(9.9); + expect(shownKnots(21.6)).toBe(22); + expect(formatBoatSpeed(21.6)).toBe(formatBoatSpeed(22)); + }); + it('names the hull mode', () => { expect(formatMode('planing')).toBe(en.mode.planing); }); diff --git a/explainers/naval-drone/src/ui/format.ts b/explainers/naval-drone/src/ui/format.ts index af16a450..64f21506 100644 --- a/explainers/naval-drone/src/ui/format.ts +++ b/explainers/naval-drone/src/ui/format.ts @@ -87,12 +87,23 @@ export function describeSpeed(): string { return t('timeline.speedValue'); } +function roundTo(value: number, fractionDigits: number): number { + return Number(value.toFixed(fractionDigits)); +} + +export function shownKnots(knots: number): number { + const inTenths = roundTo(knots, TENTHS); + return inTenths < WHOLE_KNOTS_FROM ? inTenths : roundTo(knots, WHOLE); +} + function knotsText(knots: number): string { - return knots < WHOLE_KNOTS_FROM ? tenths(knots) : whole(knots); + const shown = shownKnots(knots); + return formatFixed(shown, shown < WHOLE_KNOTS_FROM ? TENTHS : WHOLE); } export function formatBoatSpeed(knots: number): string { - return t('units.speed', { kn: knotsText(knots), kmh: whole(knotsToKmh(knots)) }); + const shown = shownKnots(knots); + return t('units.speed', { kn: knotsText(shown), kmh: whole(knotsToKmh(shown)) }); } export function formatTrialSpeed(knots: number): string { diff --git a/explainers/naval-drone/src/ui/mountJetControl.ts b/explainers/naval-drone/src/ui/mountJetControl.ts index 2a1066f6..a320495e 100644 --- a/explainers/naval-drone/src/ui/mountJetControl.ts +++ b/explainers/naval-drone/src/ui/mountJetControl.ts @@ -2,6 +2,7 @@ import { disposeAll } from '@core/ui/disposers'; import type { Disposer } from '@core/ui/disposers'; import { mountLiveReadouts } from '@core/ui/liveReadouts'; import { mountRangeWidget } from '@core/ui/rangeWidget'; +import { TEXT_REFRESH_INTERVAL_MS } from '@core/ui/throttle'; import type { HelmId } from '../ids'; import { THROTTLE_PERCENT, throttleAt, throttlePercentOf } from '../model'; import { followedKnots, runAt } from '../state'; @@ -14,9 +15,15 @@ import { formatPercent, formatPush, formatThrust, + shownKnots, } from './format'; -type SelectedThrottle = readonly [percent: number, helm: HelmId, trialKnots: number | null]; +type SelectedThrottle = readonly [ + percent: number, + helm: HelmId, + trialKnots: number | null, + knots: number, +]; const PERCENT = 100; @@ -33,17 +40,19 @@ export function mountJetControl(root: Document, store: NavalDroneStore): Dispose throttlePercentOf(throttleAt(followedKnots(state))), state.helm, state.trialKnots, + shownKnots(runAt(state).boat.knots), ], value: ([percent]) => percent, format: ([percent]) => formatPercent(percent / PERCENT), set: (state, percent) => state.setThrottle(percent), + refreshIntervalMs: TEXT_REFRESH_INTERVAL_MS, readouts: { - 'jet-boat': (_, state) => formatBoatOrBacking(runAt(state).boat.knots, state.helm), + 'jet-boat': ([, helm, , knots]) => formatBoatOrBacking(knots, helm), 'jet-flow': (_, state) => formatFlow(jetOf(state).flow), 'jet-velocity': (_, state) => formatJetSpeed(jetOf(state).jetSpeed), 'jet-thrust': (_, state) => formatThrust(jetOf(state).thrust, state.helm), - 'jet-efficiency': (_, state) => - formatEfficiency(jetOf(state).efficiency, runAt(state).boat.knots), + 'jet-efficiency': ([, , , knots], state) => + formatEfficiency(jetOf(state).efficiency, knots), }, }), mountLiveReadouts(root, store, { diff --git a/explainers/naval-drone/src/ui/mountUi.dom.test.ts b/explainers/naval-drone/src/ui/mountUi.dom.test.ts index bfdb73c5..6bd36d22 100644 --- a/explainers/naval-drone/src/ui/mountUi.dom.test.ts +++ b/explainers/naval-drone/src/ui/mountUi.dom.test.ts @@ -1,11 +1,12 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { initI18n } from '@core/i18n'; import { mountActions } from '@core/ui/actions'; +import { TEXT_REFRESH_INTERVAL_MS } from '@core/ui/throttle'; import chapters from '../../chapters.html?raw'; import en from '../../locales/en.json'; import { FIT_IDS, HELM_IDS, LINK_MODES, SEA_STATE_IDS, SPEED_MARK_IDS } from '../ids'; import { HELD_PHASE, MOMENTS, distanceAt, knotsAtThrottle, speedAt, throttleAt } from '../model'; -import { createNavalDroneStore } from '../state'; +import { createNavalDroneStore, runAt } from '../state'; import type { NavalDroneStore } from '../state'; import { CHAPTER_ACTIONS } from './actions'; import { @@ -20,6 +21,7 @@ import { mountNavalDroneUi } from '.'; const { units, hull, jet, link, horizon, fleet } = en; const PERCENT = 100; +const PHASE_NUDGE = 0.05; function readout(id: string): string | null | undefined { return document.querySelector(`[data-readout="${id}"]`)?.textContent; @@ -55,6 +57,10 @@ function output(control: string): string | null | undefined { return document.querySelector(`output[for="${control}"]`)?.textContent; } +function settle(): void { + vi.advanceTimersByTime(TEXT_REFRESH_INTERVAL_MS); +} + describe('chapter widgets', () => { let store: NavalDroneStore; let dispose: () => void; @@ -62,13 +68,17 @@ describe('chapter widgets', () => { beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); beforeEach(() => { + vi.useFakeTimers(); document.body.innerHTML = chapters; store = createNavalDroneStore({ playing: false, phase: 0 }); dispose = mountNavalDroneUi(document, store); mountActions(document, store, CHAPTER_ACTIONS); }); - afterEach(() => dispose()); + afterEach(() => { + dispose(); + vi.useRealTimers(); + }); it('offers a chip for every option the chapters switch', () => { const values = (action: string) => @@ -127,9 +137,11 @@ describe('chapter widgets', () => { it('follows the run throttle on the jet slider and holds the boat when it moves', () => { store.getState().setPhase(HELD_PHASE); + settle(); expect(Number(input('throttle').value)).toBe(Math.round(throttleAt(22) * PERCENT)); expect(readout('jet-boat')).toBe(formatBoatSpeed(22)); slide('throttle', 100); + settle(); expect(store.getState().trialKnots).toBeCloseTo(knotsAtThrottle(1), 6); expect(output('throttle')).toBe(fill(units.percent, { value: '100' })); expect(readout('jet-flow')).toBe(fill(jet.flow, { kg: '213', litres: '207' })); @@ -138,6 +150,16 @@ describe('chapter widgets', () => { expect(readout('jet-efficiency')).toBe(fill(units.percent, { value: '80' })); }); + it('keeps the jet boat speed with the gauge while the run speeds up', () => { + for (let phase = MOMENTS.humpPeak; phase <= HELD_PHASE; phase += PHASE_NUDGE) { + store.getState().setPhase(phase); + settle(); + expect(readout('jet-boat'), `${phase}`).toBe( + formatBoatSpeed(runAt(store.getState()).boat.knots), + ); + } + }); + it('swings the nozzle and backs the boat with the helm chips', () => { store.getState().setPhase(HELD_PHASE); expect(readout('jet-push')).toBe(jet.push.straight); @@ -148,6 +170,7 @@ describe('chapter widgets', () => { click('helm', 'right'); expect(readout('jet-push')).toBe(fill(jet.push.right, { angle: '27' })); click('helm', 'reverse'); + settle(); expect(readout('jet-push')).toBe(jet.push.reverse); expect(readout('jet-boat')).toBe(jet.backing); expect(readout('jet-thrust')).toBe(jet.astern); From 36aace53eb2623c9464960d49a109a91493b3fcd Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 11:00:26 +0200 Subject: [PATCH 20/99] fix(naval-drone): redraw the link widget only when the shown speed changes --- explainers/naval-drone/src/ui/mountLinkControl.ts | 3 ++- explainers/naval-drone/src/ui/mountUi.dom.test.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/explainers/naval-drone/src/ui/mountLinkControl.ts b/explainers/naval-drone/src/ui/mountLinkControl.ts index f4e8e350..c9b25614 100644 --- a/explainers/naval-drone/src/ui/mountLinkControl.ts +++ b/explainers/naval-drone/src/ui/mountLinkControl.ts @@ -12,6 +12,7 @@ import { formatLinkCarrier, formatLinkNow, formatLinkTop, + shownKnots, } from './format'; type SelectedDelay = readonly [delayMs: number, mode: LinkMode, knots: number]; @@ -24,7 +25,7 @@ export function mountLinkControl(root: Document, store: NavalDroneStore): Dispos select: (state): SelectedDelay => [ state.videoDelayMs, state.linkMode, - runAt(state).boat.knots, + shownKnots(runAt(state).boat.knots), ], value: ([delayMs]) => delayMs, format: ([delayMs]) => formatDelay(delayMs), diff --git a/explainers/naval-drone/src/ui/mountUi.dom.test.ts b/explainers/naval-drone/src/ui/mountUi.dom.test.ts index 6bd36d22..6981289c 100644 --- a/explainers/naval-drone/src/ui/mountUi.dom.test.ts +++ b/explainers/naval-drone/src/ui/mountUi.dom.test.ts @@ -13,8 +13,10 @@ import { formatBoatSpeed, formatDistance, formatHullRatio, + formatLinkNow, formatPercent, formatTrialSpeed, + shownKnots, } from './format'; import { fill, isFilled } from './testing'; import { mountNavalDroneUi } from '.'; @@ -194,6 +196,14 @@ describe('chapter widgets', () => { expect(readout('link-carrier')).toBe(link.carrier.lost); }); + it('gives the video lag at the speed the boat readouts show while the run speeds up', () => { + for (let phase = MOMENTS.throttleUp; phase <= MOMENTS.topSpeed; phase += PHASE_NUDGE) { + store.getState().setPhase(phase); + const knots = shownKnots(runAt(store.getState()).boat.knots); + expect(readout('link-now'), `${phase}`).toBe(formatLinkNow('satellite', 250, knots)); + } + }); + it('reads the radar line of sight and the sea state', () => { expect(output('radar-height')).toBe(fill(units.m, { value: '20' })); expect(readout('horizon-radar')).toBe(fill(units.km, { value: '21.3' })); From 8370e312e3bf26f0866cfe372ade0952fcefe0c2 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 11:00:57 +0200 Subject: [PATCH 21/99] fix(naval-drone): show the hull slider speed the way the gauge does --- explainers/naval-drone/src/ui/format.dom.test.ts | 3 +-- explainers/naval-drone/src/ui/format.ts | 4 ---- explainers/naval-drone/src/ui/mountHullControl.ts | 4 ++-- explainers/naval-drone/src/ui/mountUi.dom.test.ts | 7 +++---- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/explainers/naval-drone/src/ui/format.dom.test.ts b/explainers/naval-drone/src/ui/format.dom.test.ts index 4e6223c8..769905c9 100644 --- a/explainers/naval-drone/src/ui/format.dom.test.ts +++ b/explainers/naval-drone/src/ui/format.dom.test.ts @@ -37,7 +37,6 @@ import { formatShipValue, formatSpeed, formatThrust, - formatTrialSpeed, formatWaves, formatWetted, shownKnots, @@ -73,7 +72,7 @@ describe('naval drone formatting', () => { expect(formatBoatSpeed(22)).toBe(fill(units.speed, { kn: '22', kmh: '41' })); expect(formatBoatSpeed(42)).toBe(fill(units.speed, { kn: '42', kmh: '78' })); expect(formatBoatSpeed(9.97)).toBe(fill(units.speed, { kn: '10', kmh: '19' })); - expect(formatTrialSpeed(11)).toBe(fill(units.speed, { kn: '11.0', kmh: '20' })); + expect(formatBoatSpeed(11)).toBe(fill(units.speed, { kn: '11', kmh: '20' })); }); it('pairs the km/h with the knots it shows', () => { diff --git a/explainers/naval-drone/src/ui/format.ts b/explainers/naval-drone/src/ui/format.ts index 64f21506..dd9a49e9 100644 --- a/explainers/naval-drone/src/ui/format.ts +++ b/explainers/naval-drone/src/ui/format.ts @@ -106,10 +106,6 @@ export function formatBoatSpeed(knots: number): string { return t('units.speed', { kn: knotsText(shown), kmh: whole(knotsToKmh(shown)) }); } -export function formatTrialSpeed(knots: number): string { - return t('units.speed', { kn: tenths(knots), kmh: whole(knotsToKmh(knots)) }); -} - export function formatMode(mode: HullMode): string { return t(`mode.${mode}`); } diff --git a/explainers/naval-drone/src/ui/mountHullControl.ts b/explainers/naval-drone/src/ui/mountHullControl.ts index f77965a6..6630f4d9 100644 --- a/explainers/naval-drone/src/ui/mountHullControl.ts +++ b/explainers/naval-drone/src/ui/mountHullControl.ts @@ -4,11 +4,11 @@ import { TRIAL_KNOTS, planingAt, trialKnotsOf } from '../model'; import { followedKnots } from '../state'; import type { NavalDroneStore } from '../state'; import { + formatBoatSpeed, formatDegrees, formatHullRatio, formatLift, formatMode, - formatTrialSpeed, formatWetted, } from './format'; @@ -24,7 +24,7 @@ export function mountHullControl(root: Document, store: NavalDroneStore): Dispos range: TRIAL_KNOTS, select: (state): SelectedSpeed => [trialKnotsOf(followedKnots(state))], value: ([knots]) => knots, - format: ([knots]) => formatTrialSpeed(knots), + format: ([knots]) => formatBoatSpeed(knots), set: (state, knots) => state.setTrialKnots(knots), readouts: { 'hull-mode': (selected) => formatMode(planingOf(selected).mode), diff --git a/explainers/naval-drone/src/ui/mountUi.dom.test.ts b/explainers/naval-drone/src/ui/mountUi.dom.test.ts index 6981289c..58493632 100644 --- a/explainers/naval-drone/src/ui/mountUi.dom.test.ts +++ b/explainers/naval-drone/src/ui/mountUi.dom.test.ts @@ -15,7 +15,6 @@ import { formatHullRatio, formatLinkNow, formatPercent, - formatTrialSpeed, shownKnots, } from './format'; import { fill, isFilled } from './testing'; @@ -110,7 +109,7 @@ describe('chapter widgets', () => { it('follows the run on the hull slider until the reader takes the boat over', () => { store.getState().setPhase(MOMENTS.humpPeak); expect(Number(input('trial-knots').value)).toBeCloseTo(speedAt(MOMENTS.humpPeak), 6); - expect(output('trial-knots')).toBe(formatTrialSpeed(11)); + expect(output('trial-knots')).toBe(fill(units.speed, { kn: '11', kmh: '20' })); expect(readout('hull-mode')).toBe(en.mode.hump); expect(pressed('speedMark', 'hump')).toBe('true'); store.getState().play(); @@ -124,12 +123,12 @@ describe('chapter widgets', () => { expect(pressed('speedMark', 'top')).toBe('true'); store.getState().play(); expect(store.getState().trialKnots).toBeNull(); - expect(output('trial-knots')).toBe(formatTrialSpeed(speedAt(store.getState().phase))); + expect(output('trial-knots')).toBe(formatBoatSpeed(speedAt(store.getState().phase))); }); it('holds the boat at each speed mark', () => { click('speedMark', 'hullSpeed'); - expect(output('trial-knots')).toBe(formatTrialSpeed(5.7)); + expect(output('trial-knots')).toBe(formatBoatSpeed(5.7)); expect(readout('hull-ratio')).toBe(fill(hull.ratio, { times: '1.0', hullSpeed: '5.7' })); click('speedMark', 'planing'); expect(readout('hull-mode')).toBe(en.mode.planing); From ad095d57eaa4b1d03c58885097f1c0efb6e7ff00 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 11:01:23 +0200 Subject: [PATCH 22/99] fix(naval-drone): keep one step rounding helper in the model --- explainers/naval-drone/src/model/trial.test.ts | 9 +++++++++ explainers/naval-drone/src/model/trial.ts | 15 ++++++++++----- explainers/naval-drone/src/state/store.ts | 6 +----- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/explainers/naval-drone/src/model/trial.test.ts b/explainers/naval-drone/src/model/trial.test.ts index 66193f13..e3667a31 100644 --- a/explainers/naval-drone/src/model/trial.test.ts +++ b/explainers/naval-drone/src/model/trial.test.ts @@ -6,6 +6,7 @@ import { THROTTLE_PERCENT, TRIAL_KNOTS, speedMarkAt, + stepTo, throttlePercentOf, throttleShareOf, trialKnotsOf, @@ -29,11 +30,19 @@ describe('speed trial', () => { expect(trialKnotsOf(5.66)).toBe(5.7); }); + it('keeps any value inside its range and on its step', () => { + const range = { min: 50, max: 1000, step: 10 }; + expect(stepTo(333, range)).toBe(330); + expect(stepTo(10, range)).toBe(50); + expect(stepTo(2000, range)).toBe(1000); + }); + it('turns throttle percents into a share of full flow and back', () => { expect(throttleShareOf(55)).toBeCloseTo(0.55, 12); expect(throttleShareOf(140)).toBe(1); expect(throttleShareOf(-5)).toBe(0); expect(throttlePercentOf(0.694)).toBe(69); + expect(throttlePercentOf(1.2)).toBe(100); }); it('finds the mark within a quarter knot and none between marks', () => { diff --git a/explainers/naval-drone/src/model/trial.ts b/explainers/naval-drone/src/model/trial.ts index 57c98c78..9a1a789b 100644 --- a/explainers/naval-drone/src/model/trial.ts +++ b/explainers/naval-drone/src/model/trial.ts @@ -3,6 +3,12 @@ import type { SpeedMarkId } from '../ids'; import { HULL_SPEED_KN, HUMP_PEAK_KN, PLANING_FROM_KN } from './hull'; import { BOAT } from './layout'; +export interface SteppedRange { + min: number; + max: number; + step: number; +} + export const TRIAL_KNOTS = { min: 0, max: BOAT.topKnots, step: 0.1 } as const; export const THROTTLE_PERCENT = { min: 0, max: 100, step: 1 } as const; export const MARK_TOLERANCE_KN = 0.25; @@ -17,13 +23,12 @@ export const SPEED_MARKS: Readonly> = { const PERCENT = 100; -export function roundToStep(value: number, step: number): number { - return Math.round(value / step) * step; +export function stepTo(value: number, range: SteppedRange): number { + return Math.round(clamp(value, range.min, range.max) / range.step) * range.step; } export function trialKnotsOf(knots: number): number { - const stepped = roundToStep(clamp(knots, TRIAL_KNOTS.min, TRIAL_KNOTS.max), TRIAL_KNOTS.step); - return Number(stepped.toFixed(1)); + return Number(stepTo(knots, TRIAL_KNOTS).toFixed(1)); } export function throttleShareOf(percent: number): number { @@ -31,7 +36,7 @@ export function throttleShareOf(percent: number): number { } export function throttlePercentOf(share: number): number { - return roundToStep(share * PERCENT, THROTTLE_PERCENT.step); + return stepTo(share * PERCENT, THROTTLE_PERCENT); } export function speedMarkAt(knots: number): SpeedMarkId | undefined { diff --git a/explainers/naval-drone/src/state/store.ts b/explainers/naval-drone/src/state/store.ts index 07584f0e..e9118634 100644 --- a/explainers/naval-drone/src/state/store.ts +++ b/explainers/naval-drone/src/state/store.ts @@ -1,5 +1,4 @@ import type { ExplainerStore, Playback, PlaybackState } from '@core/explainer'; -import { clamp } from '@core/math'; import { createExplainerStore } from '@core/store'; import { shallow } from 'zustand/vanilla/shallow'; import type { @@ -21,6 +20,7 @@ import { VIDEO_DELAY_MS, knotsAtThrottle, speedAt, + stepTo, throttleShareOf, trialKnotsOf, } from '../model'; @@ -67,10 +67,6 @@ export const CHAPTER_CONTROL_DEFAULTS: ChapterControls = { seaState: DEFAULT_SEA_STATE, }; -function stepTo(value: number, range: { min: number; max: number; step: number }): number { - return Math.round(clamp(value, range.min, range.max) / range.step) * range.step; -} - function presetFields(preset: Preset, state: NavalDroneFields): Partial { if (PRESETS[state.preset] === preset) return {}; return { ...CHAPTER_CONTROL_DEFAULTS, ...preset.start }; From 884d8d7d987a30b4ad1ac7f564fc1f7d71f1cee9 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 11:01:52 +0200 Subject: [PATCH 23/99] fix(naval-drone): define the percent and unit constants once in the model --- explainers/naval-drone/src/model/horizon.ts | 4 ++-- explainers/naval-drone/src/model/trial.ts | 5 ++--- explainers/naval-drone/src/ui/format.ts | 6 +++--- explainers/naval-drone/src/ui/mountJetControl.ts | 6 ++---- explainers/naval-drone/src/ui/mountUi.dom.test.ts | 13 ++++++++++--- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/explainers/naval-drone/src/model/horizon.ts b/explainers/naval-drone/src/model/horizon.ts index d54b0cd9..e2fa5bba 100644 --- a/explainers/naval-drone/src/model/horizon.ts +++ b/explainers/naval-drone/src/model/horizon.ts @@ -9,9 +9,9 @@ export const TARGET_HEIGHT_M = 0.5; export const LENS_HEIGHT_M = BOAT.lensHeight; export const RADAR_HEIGHT_M = { min: 5, max: 50, step: 1, default: 20 } as const; export const DETECTION_KM = 9.26; +export const METRES_PER_KM = 1000; +export const SECONDS_PER_MINUTE = 60; -const METRES_PER_KM = 1000; -const SECONDS_PER_MINUTE = 60; const DETECTION_COVERED_UP_TO: SeaStateId = 'moderate'; const HIDDEN_FROM: SeaStateId = 'slight'; diff --git a/explainers/naval-drone/src/model/trial.ts b/explainers/naval-drone/src/model/trial.ts index 9a1a789b..20645b8e 100644 --- a/explainers/naval-drone/src/model/trial.ts +++ b/explainers/naval-drone/src/model/trial.ts @@ -9,8 +9,9 @@ export interface SteppedRange { step: number; } +export const PERCENT = 100; export const TRIAL_KNOTS = { min: 0, max: BOAT.topKnots, step: 0.1 } as const; -export const THROTTLE_PERCENT = { min: 0, max: 100, step: 1 } as const; +export const THROTTLE_PERCENT = { min: 0, max: PERCENT, step: 1 } as const; export const MARK_TOLERANCE_KN = 0.25; export const SPEED_MARKS: Readonly> = { @@ -21,8 +22,6 @@ export const SPEED_MARKS: Readonly> = { top: BOAT.topKnots, }; -const PERCENT = 100; - export function stepTo(value: number, range: SteppedRange): number { return Math.round(clamp(value, range.min, range.max) / range.step) * range.step; } diff --git a/explainers/naval-drone/src/ui/format.ts b/explainers/naval-drone/src/ui/format.ts index dd9a49e9..76bb49f5 100644 --- a/explainers/naval-drone/src/ui/format.ts +++ b/explainers/naval-drone/src/ui/format.ts @@ -10,10 +10,13 @@ import { HULL_SPEED_KN, KOTOV_VALUE_MUSD, LENS_HEIGHT_M, + METRES_PER_KM, NEWTONS_PER_KILONEWTON, PAYLOAD_MAX_KG, + PERCENT, SEAWATER_DENSITY, SEA_STATES, + SECONDS_PER_MINUTE, boatLengths, detectionCovered, knotsToKmh, @@ -27,14 +30,11 @@ import { const WHOLE = 0; const TENTHS = 1; const HUNDREDTHS = 2; -const PERCENT = 100; -const SECONDS_PER_MINUTE = 60; const CLOCK_DIGITS = 2; const CLOCK_PAD = '0'; const CLOCK_SEPARATOR = ':'; const WHOLE_KNOTS_FROM = 10; const METRES_STEP = 10; -const METRES_PER_KM = 1000; const KG_PER_LITRE = SEAWATER_DENSITY / METRES_PER_KM; const STRAIGHT_NOZZLE = 0.5; diff --git a/explainers/naval-drone/src/ui/mountJetControl.ts b/explainers/naval-drone/src/ui/mountJetControl.ts index a320495e..973921a2 100644 --- a/explainers/naval-drone/src/ui/mountJetControl.ts +++ b/explainers/naval-drone/src/ui/mountJetControl.ts @@ -4,7 +4,7 @@ import { mountLiveReadouts } from '@core/ui/liveReadouts'; import { mountRangeWidget } from '@core/ui/rangeWidget'; import { TEXT_REFRESH_INTERVAL_MS } from '@core/ui/throttle'; import type { HelmId } from '../ids'; -import { THROTTLE_PERCENT, throttleAt, throttlePercentOf } from '../model'; +import { THROTTLE_PERCENT, throttleAt, throttlePercentOf, throttleShareOf } from '../model'; import { followedKnots, runAt } from '../state'; import type { NavalDroneStore, NavalDroneStoreState } from '../state'; import { @@ -25,8 +25,6 @@ type SelectedThrottle = readonly [ knots: number, ]; -const PERCENT = 100; - function jetOf(state: NavalDroneStoreState) { return runAt(state).jet; } @@ -43,7 +41,7 @@ export function mountJetControl(root: Document, store: NavalDroneStore): Dispose shownKnots(runAt(state).boat.knots), ], value: ([percent]) => percent, - format: ([percent]) => formatPercent(percent / PERCENT), + format: ([percent]) => formatPercent(throttleShareOf(percent)), set: (state, percent) => state.setThrottle(percent), refreshIntervalMs: TEXT_REFRESH_INTERVAL_MS, readouts: { diff --git a/explainers/naval-drone/src/ui/mountUi.dom.test.ts b/explainers/naval-drone/src/ui/mountUi.dom.test.ts index 58493632..e206587a 100644 --- a/explainers/naval-drone/src/ui/mountUi.dom.test.ts +++ b/explainers/naval-drone/src/ui/mountUi.dom.test.ts @@ -5,7 +5,15 @@ import { TEXT_REFRESH_INTERVAL_MS } from '@core/ui/throttle'; import chapters from '../../chapters.html?raw'; import en from '../../locales/en.json'; import { FIT_IDS, HELM_IDS, LINK_MODES, SEA_STATE_IDS, SPEED_MARK_IDS } from '../ids'; -import { HELD_PHASE, MOMENTS, distanceAt, knotsAtThrottle, speedAt, throttleAt } from '../model'; +import { + HELD_PHASE, + MOMENTS, + distanceAt, + knotsAtThrottle, + speedAt, + throttleAt, + throttlePercentOf, +} from '../model'; import { createNavalDroneStore, runAt } from '../state'; import type { NavalDroneStore } from '../state'; import { CHAPTER_ACTIONS } from './actions'; @@ -21,7 +29,6 @@ import { fill, isFilled } from './testing'; import { mountNavalDroneUi } from '.'; const { units, hull, jet, link, horizon, fleet } = en; -const PERCENT = 100; const PHASE_NUDGE = 0.05; function readout(id: string): string | null | undefined { @@ -139,7 +146,7 @@ describe('chapter widgets', () => { it('follows the run throttle on the jet slider and holds the boat when it moves', () => { store.getState().setPhase(HELD_PHASE); settle(); - expect(Number(input('throttle').value)).toBe(Math.round(throttleAt(22) * PERCENT)); + expect(Number(input('throttle').value)).toBe(throttlePercentOf(throttleAt(22))); expect(readout('jet-boat')).toBe(formatBoatSpeed(22)); slide('throttle', 100); settle(); From 9cba9bdfade0d09ee8bc9ec75735c0d327b67dab Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 11:02:07 +0200 Subject: [PATCH 24/99] fix(naval-drone): convert the jet flow to litres with litres per cubic metre --- explainers/naval-drone/src/ui/format.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/explainers/naval-drone/src/ui/format.ts b/explainers/naval-drone/src/ui/format.ts index 76bb49f5..20e61c98 100644 --- a/explainers/naval-drone/src/ui/format.ts +++ b/explainers/naval-drone/src/ui/format.ts @@ -35,7 +35,8 @@ const CLOCK_PAD = '0'; const CLOCK_SEPARATOR = ':'; const WHOLE_KNOTS_FROM = 10; const METRES_STEP = 10; -const KG_PER_LITRE = SEAWATER_DENSITY / METRES_PER_KM; +const LITRES_PER_CUBIC_METRE = 1000; +const KG_PER_LITRE = SEAWATER_DENSITY / LITRES_PER_CUBIC_METRE; const STRAIGHT_NOZZLE = 0.5; function phaseKeys(group: string): Readonly> { From 0e6ea91aea7f9db1d84a2d0ce2698cf9335916ea Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 11:02:21 +0200 Subject: [PATCH 25/99] fix(naval-drone): start the hull and jet trials on the hump and cruise marks --- explainers/naval-drone/src/state/presets.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/explainers/naval-drone/src/state/presets.ts b/explainers/naval-drone/src/state/presets.ts index e9b1b148..11bef21a 100644 --- a/explainers/naval-drone/src/state/presets.ts +++ b/explainers/naval-drone/src/state/presets.ts @@ -9,7 +9,7 @@ import type { SeaStateId, ViewOptions, } from '../ids'; -import { HELD_PHASE } from '../model'; +import { HELD_PHASE, SPEED_MARKS } from '../model'; export interface ChapterControls { trialKnots: number | null; @@ -31,8 +31,6 @@ export interface Preset extends ScenePreset { export const DEFAULT_FIT: FitId = 'standard'; const NORMAL_SPEED = 1; -const HULL_TRIAL_KNOTS = 11; -const JET_TRIAL_KNOTS = 22; const CHAPTER_START_SECONDS = { overview: 0, @@ -99,7 +97,7 @@ export const PRESETS: Record = { view: { cutaway: false, flow: true, links: false }, pauseAt: HELD_PHASE, controls: ['trialKnots'], - start: { trialKnots: HULL_TRIAL_KNOTS }, + start: { trialKnots: SPEED_MARKS.hump }, labels: HULL_PARTS, highlight: HULL_PARTS, }, @@ -109,7 +107,7 @@ export const PRESETS: Record = { view: { cutaway: true, flow: true, links: false }, pauseAt: HELD_PHASE, controls: ['trialKnots', 'helm'], - start: { trialKnots: JET_TRIAL_KNOTS, helm: 'straight' }, + start: { trialKnots: SPEED_MARKS.cruise, helm: 'straight' }, labels: JET_PARTS, highlight: [...JET_PARTS, 'waterjet', 'engine'], }, From 3558eecf995380a40bbdea7abdcf8bb2ae126fde Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 11:02:42 +0200 Subject: [PATCH 26/99] fix(naval-drone): take the rounding decimals from each range step --- explainers/naval-drone/src/model/trial.test.ts | 2 ++ explainers/naval-drone/src/model/trial.ts | 13 +++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/explainers/naval-drone/src/model/trial.test.ts b/explainers/naval-drone/src/model/trial.test.ts index e3667a31..e0a26beb 100644 --- a/explainers/naval-drone/src/model/trial.test.ts +++ b/explainers/naval-drone/src/model/trial.test.ts @@ -35,6 +35,8 @@ describe('speed trial', () => { expect(stepTo(333, range)).toBe(330); expect(stepTo(10, range)).toBe(50); expect(stepTo(2000, range)).toBe(1000); + expect(stepTo(0.7, { min: 0, max: 1, step: 0.1 })).toBe(0.7); + expect(stepTo(0.35, { min: 0, max: 1, step: 0.05 })).toBe(0.35); }); it('turns throttle percents into a share of full flow and back', () => { diff --git a/explainers/naval-drone/src/model/trial.ts b/explainers/naval-drone/src/model/trial.ts index 20645b8e..e1d27376 100644 --- a/explainers/naval-drone/src/model/trial.ts +++ b/explainers/naval-drone/src/model/trial.ts @@ -22,12 +22,21 @@ export const SPEED_MARKS: Readonly> = { top: BOAT.topKnots, }; +const DECIMAL_POINT = '.'; +const WHOLE_STEP_DECIMALS = 0; + +function decimalsOf(step: number): number { + const [, fraction] = String(step).split(DECIMAL_POINT); + return fraction?.length ?? WHOLE_STEP_DECIMALS; +} + export function stepTo(value: number, range: SteppedRange): number { - return Math.round(clamp(value, range.min, range.max) / range.step) * range.step; + const stepped = Math.round(clamp(value, range.min, range.max) / range.step) * range.step; + return Number(stepped.toFixed(decimalsOf(range.step))); } export function trialKnotsOf(knots: number): number { - return Number(stepTo(knots, TRIAL_KNOTS).toFixed(1)); + return stepTo(knots, TRIAL_KNOTS); } export function throttleShareOf(percent: number): number { From 3966dc223266f362771e4ac44e69d248b295f68c Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 11:03:22 +0200 Subject: [PATCH 27/99] fix(naval-drone): never let go of the boat in a chapter change --- explainers/naval-drone/src/state/store.test.ts | 11 +++++++++++ explainers/naval-drone/src/state/store.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/explainers/naval-drone/src/state/store.test.ts b/explainers/naval-drone/src/state/store.test.ts index 9915d57e..50b53b97 100644 --- a/explainers/naval-drone/src/state/store.test.ts +++ b/explainers/naval-drone/src/state/store.test.ts @@ -130,6 +130,17 @@ describe('speed trial', () => { expect(store.getState().playing).toBe(false); }); + it('never lets go of the boat in the update that changes the chapter', () => { + const store = createNavalDroneStore({ + preset: 'hull', + phase: HELD_PHASE, + playing: false, + trialKnots: 11, + }); + store.setState({ preset: 'jet', playing: true, phase: MOMENTS.topSpeed, trialKnots: 22 }); + expect(store.getState().trialKnots).toBe(22); + }); + it('keeps holding the boat while the phase stays put', () => { const store = heldStore(); store.getState().setPhase(50); diff --git a/explainers/naval-drone/src/state/store.ts b/explainers/naval-drone/src/state/store.ts index e9118634..bfdee62b 100644 --- a/explainers/naval-drone/src/state/store.ts +++ b/explainers/naval-drone/src/state/store.ts @@ -77,7 +77,7 @@ type TrialWatch = readonly [phase: number, playing: boolean, preset: PresetId]; function shouldRelease(next: TrialWatch, previous: TrialWatch): boolean { const [phase, playing, preset] = next; const [lastPhase, wasPlaying, lastPreset] = previous; - return (playing && !wasPlaying) || (phase !== lastPhase && preset === lastPreset); + return preset === lastPreset && ((playing && !wasPlaying) || phase !== lastPhase); } function releaseTrialOnRun(store: NavalDroneStore): NavalDroneStore { From f14866da7d6b84dd6a2fc890c302455375ea7a5d Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 11:03:38 +0200 Subject: [PATCH 28/99] fix(naval-drone): drop the repeated empty check in the run memo --- explainers/naval-drone/src/state/derived.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/explainers/naval-drone/src/state/derived.ts b/explainers/naval-drone/src/state/derived.ts index c3c6ac2d..f1057176 100644 --- a/explainers/naval-drone/src/state/derived.ts +++ b/explainers/naval-drone/src/state/derived.ts @@ -106,15 +106,16 @@ function sourceOf(state: RunSource): RunSource { return { phase, trialKnots, helm, linkMode, videoDelayMs, seaState, preset }; } -function isRemembered(source: RunSource): boolean { - return last !== null && RUN_SOURCE_KEYS.every((key) => last?.source[key] === source[key]); +function isRemembered(remembered: Remembered | null, source: RunSource): remembered is Remembered { + return ( + remembered !== null && RUN_SOURCE_KEYS.every((key) => remembered.source[key] === source[key]) + ); } export function runAt(state: RunSource): Readonly { - if (!last || !isRemembered(state)) { - const source = sourceOf(state); - last = { source, reading: readingOf(source) }; - } + if (isRemembered(last, state)) return last.reading; + const source = sourceOf(state); + last = { source, reading: readingOf(source) }; return last.reading; } From 5d172a6a1c571ed5bc25910db6abc7e348f0842e Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 11:03:49 +0200 Subject: [PATCH 29/99] fix(naval-drone): name the straight nozzle limit in degrees --- explainers/naval-drone/src/ui/format.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/explainers/naval-drone/src/ui/format.ts b/explainers/naval-drone/src/ui/format.ts index 20e61c98..33ee7328 100644 --- a/explainers/naval-drone/src/ui/format.ts +++ b/explainers/naval-drone/src/ui/format.ts @@ -37,7 +37,7 @@ const WHOLE_KNOTS_FROM = 10; const METRES_STEP = 10; const LITRES_PER_CUBIC_METRE = 1000; const KG_PER_LITRE = SEAWATER_DENSITY / LITRES_PER_CUBIC_METRE; -const STRAIGHT_NOZZLE = 0.5; +const STRAIGHT_NOZZLE_DEG = 0.5; function phaseKeys(group: string): Readonly> { return Object.fromEntries(PHASE_IDS.map((id) => [id, `timeline.${group}.${id}`])) as Record< @@ -166,7 +166,7 @@ export function formatEfficiency(efficiency: number, knots: number): string { export function formatPush(helm: HelmId, nozzleAngle: number): string { if (helm === 'reverse') return t('jet.push.reverse'); const degrees = toDegrees(nozzleAngle); - if (Math.abs(degrees) < STRAIGHT_NOZZLE) return t('jet.push.straight'); + if (Math.abs(degrees) < STRAIGHT_NOZZLE_DEG) return t('jet.push.straight'); const side = degrees < 0 ? 'left' : 'right'; return t(`jet.push.${side}`, { angle: whole(Math.abs(degrees)) }); } From dbd964691b8eaada00352b663d64d6cba2d9d45a Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Sat, 3 Oct 2026 10:56:27 +0200 Subject: [PATCH 30/99] fix(naval-drone): apply the review to the English copy --- explainers/naval-drone/chapters.html | 84 ++++++++++++++------------ explainers/naval-drone/locales/en.json | 32 +++++----- 2 files changed, 60 insertions(+), 56 deletions(-) diff --git a/explainers/naval-drone/chapters.html b/explainers/naval-drone/chapters.html index e025ddd7..65e4fea8 100644 --- a/explainers/naval-drone/chapters.html +++ b/explainers/naval-drone/chapters.html @@ -13,9 +13,9 @@

A speedboat with no set in hard resin. The bow holds a closed payload bay for up to 320 kg. Behind it come the fuel tanks, then the engine in an aluminium tub bedded in foam, with the electronics above it. The engine drives a waterjet at the stern. The maker has not named - the engine, so the model assumes a jet-ski-type petrol engine, like the ones in the early boats. - On deck sit two Starlink antennas, a backup satellite antenna, a low dome with a thermal camera - and a fixed camera in the bow. + the engine, so the model assumes a jet-ski-type petrol engine, like the ones in some boats from + 2022. On deck sit two Starlink antennas, a backup satellite antenna, a low dome with a thermal + camera and a fixed camera in the bow.

The model follows one run. The boat idles off a slipway, climbs onto the plane @@ -108,7 +108,7 @@

It stops floating and starts Every boat that pushes through the water makes a bow wave. At about 5.7 knots, that wave is as long as this 5.5 m hull. This is the hull speed, a rule of thumb for boats that only float. It is not a speed limit: at 42 knots the Magura runs at about 7 times it. To get - there, it has to climb the resistance hump, where the boat makes big waves and + there, it has to climb the resistance hump. Here the boat makes big waves, and the drag, the force of the water holding it back, climbs steeply. The sharp edges along the bottom, the chines, add drag here too. They pay off only once the boat planes. @@ -117,10 +117,10 @@

It stops floating and starts Move the slider, or tap a chip, and watch the bow. Near the hump it rises the most, then it settles as the boat planes. This angle is the trim. Drag is lowest at about 3 to 4 degrees, and the boat runs a little flatter at top speed. On this boat planing starts at - about 15 to 18 knots, and the model's readout switches at 16. Then the wet part of the bottom - shrinks fast, from about 4.5 m at 15 knots to about 1.5 to 2 m at 42 knots. No hump data is - published for this boat, so the 6 degree peak and the split of the weight in the readouts are - the model's own numbers. + about 15 to 18 knots, and the model's readout switches at 16. In the model the wet part of the + bottom is 5.1 m long at rest. Once the boat planes, it shrinks fast, from about 4.5 m at 15 + knots to about 1.5 to 2 m at 42 knots. No hump data is published for this boat, so the 6 degree + peak and the split of the weight in the readouts are the model's own numbers.

The bottom is a deep V, which softens the blows from waves. Its angle is not @@ -222,18 +222,20 @@

A pump instead of a propeller<

The push comes from speeding up the water that passes through the pump. The thrust equals the mass of water going through each second, times how much - faster the jet leaves than the boat moves. So the jet must always be faster than the boat. In an - ideal pump, the share of the work that moves the boat is 2 / (1 + jet speed / boat speed). When - the jet leaves at one and a half times the boat's speed, that share is 80 %. At low speed the - jet is far faster than the boat, and most of the work is lost. + faster the jet leaves than the boat moves. So the jet must always be faster than the boat. Even + with a perfect pump, the share of the work that moves the boat is 2 / (1 + jet speed / boat + speed). When the jet leaves at one and a half times the boat's speed, that share is 80 %. At low + speed the jet is far faster than the boat, and most of the work is lost.

- Above about 25 to 30 knots, a jet matches or beats a propeller, and it has other strengths. - Nothing hangs under the hull to add drag or hit things, so the boat can run in shallow water. - The grate mostly clears itself at planing speed, but weed and rope can still block an intake. - The first boats in 2022 used jet-ski parts: a three-cylinder Rotax petrol engine and a Sea-Doo - pump. The Magura's own engine is not disclosed. The model's pump is jet-ski sized, 155 mm - across, and at 42 knots it moves roughly 100 to 200 litres of seawater a second. + Above about 25 to 30 knots, a jet matches or beats a propeller. Nothing hangs under the hull to + add drag or hit things, so the boat can also run in shallow water. The grate mostly clears + itself at planing speed, but weed and rope can still block an intake. The first boat of this + family, in May 2022, had a small outboard motor. Later that year, boats like the one found near + Sevastopol used jet-ski parts: a three-cylinder Rotax petrol engine and a Sea-Doo pump. The + Magura's own engine is not disclosed. The model's pump is jet-ski sized, 155 mm across, and + moves about 210 litres of seawater a second at 42 knots. That is an estimate: if the jet left + faster, about half as much water would do.

There is no rudder. To turn, the steering nozzle swings the stream up to 27 @@ -346,12 +348,13 @@