From e1b62d61faddd56ac5713a5fe70d895f427bb589 Mon Sep 17 00:00:00 2001 From: TheLostLeo Date: Tue, 13 Oct 2026 07:54:35 +0530 Subject: [PATCH 01/12] feat(telemetry): update dashboard layout and source filenames Signed-off-by: TheLostLeo --- .../src/{App.tsx => app.tsx} | 101 +++++++++--------- .../{AltitudeCard.tsx => altitude_card.tsx} | 2 +- ...deIndicator.tsx => attitude_indicator.tsx} | 10 +- .../{AttitudeScene.tsx => attitude_scene.tsx} | 14 +-- .../{BatteryBox.tsx => battery_box.tsx} | 2 +- .../{ChartPanel.tsx => chart_panel.tsx} | 4 +- ...tStatusCard.tsx => flight_status_card.tsx} | 5 +- .../{LinkCard.tsx => link_card.tsx} | 4 +- .../{NavSignalBox.tsx => nav_signal_box.tsx} | 6 +- .../src/components/{Panel.tsx => panel.tsx} | 12 +-- ...{SbcStatusCard.tsx => sbc_status_card.tsx} | 5 +- ...TelemetryChart.tsx => telemetry_chart.tsx} | 6 +- .../components/{TopBar.tsx => top_bar.tsx} | 6 +- .../{useTelemetry.ts => use_telemetry.ts} | 0 telemetry/live-web-dashboard/src/main.tsx | 2 +- telemetry/live-web-dashboard/src/styles.css | 10 +- .../live-web-dashboard/tailwind.config.js | 7 +- 17 files changed, 103 insertions(+), 93 deletions(-) rename telemetry/live-web-dashboard/src/{App.tsx => app.tsx} (73%) rename telemetry/live-web-dashboard/src/components/{AltitudeCard.tsx => altitude_card.tsx} (97%) rename telemetry/live-web-dashboard/src/components/{AttitudeIndicator.tsx => attitude_indicator.tsx} (84%) rename telemetry/live-web-dashboard/src/components/{AttitudeScene.tsx => attitude_scene.tsx} (82%) rename telemetry/live-web-dashboard/src/components/{BatteryBox.tsx => battery_box.tsx} (98%) rename telemetry/live-web-dashboard/src/components/{ChartPanel.tsx => chart_panel.tsx} (92%) rename telemetry/live-web-dashboard/src/components/{FlightStatusCard.tsx => flight_status_card.tsx} (94%) rename telemetry/live-web-dashboard/src/components/{LinkCard.tsx => link_card.tsx} (92%) rename telemetry/live-web-dashboard/src/components/{NavSignalBox.tsx => nav_signal_box.tsx} (93%) rename telemetry/live-web-dashboard/src/components/{Panel.tsx => panel.tsx} (87%) rename telemetry/live-web-dashboard/src/components/{SbcStatusCard.tsx => sbc_status_card.tsx} (96%) rename telemetry/live-web-dashboard/src/components/{TelemetryChart.tsx => telemetry_chart.tsx} (90%) rename telemetry/live-web-dashboard/src/components/{TopBar.tsx => top_bar.tsx} (92%) rename telemetry/live-web-dashboard/src/hooks/{useTelemetry.ts => use_telemetry.ts} (100%) diff --git a/telemetry/live-web-dashboard/src/App.tsx b/telemetry/live-web-dashboard/src/app.tsx similarity index 73% rename from telemetry/live-web-dashboard/src/App.tsx rename to telemetry/live-web-dashboard/src/app.tsx index 3b4c196..d817dd2 100644 --- a/telemetry/live-web-dashboard/src/App.tsx +++ b/telemetry/live-web-dashboard/src/app.tsx @@ -1,16 +1,16 @@ import { useState } from "react"; import { GaugeIcon, PlugZapIcon, SlidersHorizontalIcon, WavesIcon, WifiIcon } from "lucide-react"; -import { AltitudeCard } from "./components/AltitudeCard"; -import { AttitudeIndicator } from "./components/AttitudeIndicator"; -import { BatteryBox } from "./components/BatteryBox"; -import { ChartPanel } from "./components/ChartPanel"; -import { FlightStatusCard } from "./components/FlightStatusCard"; -import { LinkCard } from "./components/LinkCard"; -import { NavSignalBox } from "./components/NavSignalBox"; -import { SbcStatusCard } from "./components/SbcStatusCard"; -import { TopBar } from "./components/TopBar"; +import { AltitudeCard } from "./components/altitude_card"; +import { AttitudeIndicator } from "./components/attitude_indicator"; +import { BatteryBox } from "./components/battery_box"; +import { ChartPanel } from "./components/chart_panel"; +import { FlightStatusCard } from "./components/flight_status_card"; +import { LinkCard } from "./components/link_card"; +import { NavSignalBox } from "./components/nav_signal_box"; +import { SbcStatusCard } from "./components/sbc_status_card"; +import { TopBar } from "./components/top_bar"; import { gyroSeries, motorSeries, pidSeries, vehicle } from "./data/telemetry"; -import { useTelemetry } from "./hooks/useTelemetry"; +import { useTelemetry } from "./hooks/use_telemetry"; import type { Telemetry } from "./types/telemetry"; function activeWarning(t: Telemetry): string | null { @@ -77,12 +77,12 @@ function ConnectionPanel({ }} >
- +

Connect ESP32 Telemetry

-

Enter the IP shown on the ESP32 OLED.

+

Enter the IP shown on the ESP32 OLED.

@@ -100,7 +100,7 @@ function ConnectionPanel({ /> + + + ); + } + return this.props.children; + } +} diff --git a/telemetry/live-web-dashboard/src/components/flight_status_card.tsx b/telemetry/live-web-dashboard/src/components/flight_status_card.tsx index a075d8d..e79901a 100644 --- a/telemetry/live-web-dashboard/src/components/flight_status_card.tsx +++ b/telemetry/live-web-dashboard/src/components/flight_status_card.tsx @@ -12,11 +12,16 @@ function Row({ label, children }: { label: string; children: React.ReactNode }) } export function FlightStatusCard({ flight, className }: { flight: FlightState; className?: string }) { + const armedLabel = flight.armed === null ? "UNKNOWN" : flight.armed ? "ARMED" : "DISARMED"; + const armedTone = flight.armed === null ? "amber" : flight.armed ? "red" : "neutral"; + const failsafeLabel = flight.failsafe === null ? "UNKNOWN" : flight.failsafe ? "TRIGGERED" : "CLEAR"; + const failsafeTone = flight.failsafe === null ? "amber" : flight.failsafe ? "red" : "green"; + return ( } - meta={{flight.armed ? "ARMED" : "DISARMED"}} + meta={{armedLabel}} className={className} bodyClassName="flex flex-col justify-center gap-1" > @@ -24,7 +29,7 @@ export function FlightStatusCard({ flight, className }: { flight: FlightState; c {flight.mode} - {flight.failsafe ? "TRIGGERED" : "CLEAR"} + {failsafeLabel} {flight.mission} diff --git a/telemetry/live-web-dashboard/src/components/link_card.tsx b/telemetry/live-web-dashboard/src/components/link_card.tsx index d8da8b3..c0143ce 100644 --- a/telemetry/live-web-dashboard/src/components/link_card.tsx +++ b/telemetry/live-web-dashboard/src/components/link_card.tsx @@ -2,24 +2,24 @@ import { RadioIcon } from "lucide-react"; import { Chip, Panel, Stat } from "./panel"; import type { LinkState } from "../types/telemetry"; -export function LinkCard({ link, className }: { link: LinkState; className?: string }) { +export function LinkCard({ link, available, className }: { link: LinkState; available: boolean; className?: string }) { const healthy = link.loss < 3 && link.rssi > -78; return ( } - meta={{healthy ? "STABLE" : "DEGRADED"}} + meta={{available ? healthy ? "STABLE" : "DEGRADED" : "NO DATA"}} className={className} bodyClassName="flex flex-col justify-between gap-2" >
- - - - + + + +
); diff --git a/telemetry/live-web-dashboard/src/components/nav_signal_box.tsx b/telemetry/live-web-dashboard/src/components/nav_signal_box.tsx index 13e945f..da1efe8 100644 --- a/telemetry/live-web-dashboard/src/components/nav_signal_box.tsx +++ b/telemetry/live-web-dashboard/src/components/nav_signal_box.tsx @@ -3,9 +3,9 @@ import { Chip, Panel, Stat } from "./panel"; import type { NavState } from "../types/telemetry"; function SignalBars({ rssi }: { rssi: number }) { - const level = rssi > -60 ? 4 : rssi > -68 ? 3 : rssi > -75 ? 2 : 1; + const level = rssi >= 80 ? 4 : rssi >= 60 ? 3 : rssi >= 40 ? 2 : rssi > 0 ? 1 : 0; return ( -
diff --git a/telemetry/live-web-dashboard/src/components/top_bar.tsx b/telemetry/live-web-dashboard/src/components/top_bar.tsx index 9de375d..0ea73d6 100644 --- a/telemetry/live-web-dashboard/src/components/top_bar.tsx +++ b/telemetry/live-web-dashboard/src/components/top_bar.tsx @@ -5,21 +5,20 @@ import { Chip } from "./panel"; type TopBarProps = { live: boolean; onToggleLive: () => void; - armed: boolean; + armed: boolean | null; mode: string; - clock: number; - warning: string | null; + now: number; + warnings: string[]; }; -function stamp(clock: number): string { - const total = clock * 4; - const h = String(Math.floor(total / 3600) % 24).padStart(2, "0"); - const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0"); - const s = String(total % 60).padStart(2, "0"); - return `${h}:${m}:${s}Z`; +function stamp(now: number): string { + return new Date(now).toISOString().slice(11, 19) + "Z"; } -export function TopBar({ live, onToggleLive, armed, mode, clock, warning }: TopBarProps) { +export function TopBar({ live, onToggleLive, armed, mode, now, warnings }: TopBarProps) { + const armedLabel = armed === null ? "ARMED UNKNOWN" : armed ? "ARMED" : "DISARMED"; + const armedTone = armed === null ? "amber" : armed ? "red" : "neutral"; + return (
@@ -28,25 +27,27 @@ export function TopBar({ live, onToggleLive, armed, mode, clock, warning }: TopB
{vehicle.name}
-
{vehicle.linkPath}
+
{vehicle.linkPath}
{mode} - {armed ? "ARMED" : "DISARMED"} + {armedLabel} {vehicle.sysId}
- {warning ? ( -
+ {warnings.length > 0 ? ( +
- {warning} + + {warnings[0]}{warnings.length > 1 ? ` +${warnings.length - 1}` : ""} +
) : null} -
- {stamp(clock)} +
0 ? "ml-auto md:ml-0" : "ml-auto"}`}> +
diff --git a/telemetry/live-web-dashboard/src/data/telemetry.ts b/telemetry/live-web-dashboard/src/data/telemetry.ts index 9fa486c..f59b319 100644 --- a/telemetry/live-web-dashboard/src/data/telemetry.ts +++ b/telemetry/live-web-dashboard/src/data/telemetry.ts @@ -7,7 +7,7 @@ export const vehicle = { linkPath: "MAVLINK · NRF24 · ESP32-WIFI" }; -export const cellLabels = ["1S", "2S", "3S", "4S"]; +export const cellLabels = ["1S", "2S", "3S", "4S", "5S", "6S"]; export const pidSeries: SeriesDef[] = [ { key: "roll", label: "ROLL", color: "#4c8dff" }, diff --git a/telemetry/live-web-dashboard/src/hooks/use_telemetry.ts b/telemetry/live-web-dashboard/src/hooks/use_telemetry.ts index 272d236..bf2a3ae 100644 --- a/telemetry/live-web-dashboard/src/hooks/use_telemetry.ts +++ b/telemetry/live-web-dashboard/src/hooks/use_telemetry.ts @@ -1,295 +1,16 @@ import { useEffect, useState } from "react"; -import { cellLabels } from "../data/telemetry"; +import { + createOfflineTelemetry, + OFFLINE_AFTER_FAILURES, + parseRawTelemetry, + POLL_INTERVAL_MS, + reduceDegradedTelemetry, + reduceLiveTelemetry, + reduceOfflineTelemetry +} from "../lib/telemetry_mapper"; import type { Telemetry } from "../types/telemetry"; -const SAMPLES = 64; -const POLL_MS = 1000; const REQUEST_TIMEOUT_MS = 2500; -const OFFLINE_AFTER_FAILURES = 3; -const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); -const num = (value: unknown, fallback = 0) => (typeof value === "number" && Number.isFinite(value) ? value : fallback); - -type RawTelemetry = { - connected?: boolean; - battery_voltage?: number; - battery_current?: number; - battery_remaining?: number; - cell_voltages?: number[]; - altitude_relative?: number; - altitude_msl?: number; - climb_rate?: number; - latitude?: number; - longitude?: number; - satellites?: number; - gps_fix_type?: string; - hdop?: number; - rc_rssi?: number; - radio_link_quality?: number; - heading?: number; - ground_speed?: number; - armed?: boolean; - flight_mode?: string; - mission_state?: string; - mission_current_seq?: number; - mission_total_items?: number; - attitude_roll?: number; - attitude_pitch?: number; - attitude_yaw?: number; - error_roll?: number; - error_pitch?: number; - error_yaw?: number; - motor_percent?: number[]; - gyro_x?: number; - gyro_y?: number; - gyro_z?: number; - sbc?: { - cpu_load_percent?: number; - ram_used_mb?: number; - ram_total_mb?: number; - ram_percent?: number; - cpu_temp_c?: number; - disk_percent?: number; - uptime_seconds?: number; - }; -}; - -function zeroPid() { - return Array.from({ length: SAMPLES }, (_, t) => ({ t, roll: 0, pitch: 0, yaw: 0 })); -} - -function zeroMotors() { - return Array.from({ length: SAMPLES }, (_, t) => ({ t, m1: 0, m2: 0, m3: 0, m4: 0 })); -} - -function zeroGyro() { - return Array.from({ length: SAMPLES }, (_, t) => ({ t, x: 0, y: 0, z: 0 })); -} - -function offlineTelemetry(endpoint = "", message = "Enter ESP IP to start"): Telemetry { - return { - clock: 0, - source: { - status: "offline", - endpoint, - message, - lastUpdated: 0 - }, - battery: { - total: 0, - percent: 0, - current: 0, - consumed: 0, - cells: cellLabels.slice(0, 4).map((label) => ({ label, volts: 0 })) - }, - nav: { - lat: 0, - lon: 0, - heading: 0, - sats: 0, - hdop: 99.9, - rssi: 0, - fix: "NO JSON", - groundSpeed: 0 - }, - altitude: { - relative: 0, - msl: 0, - vspeed: 0, - home: 0 - }, - link: { - rssi: 0, - rate: 0, - loss: 100, - latency: 0, - history: Array.from({ length: 28 }, () => 0) - }, - flight: { - mode: "NO ESP JSON", - armed: false, - failsafe: true, - mission: "ESP JSON OFFLINE", - wp: "0 / 0", - link: "LOST" - }, - sbc: { - cpu: 0, - cores: [0, 0, 0, 0], - memPercent: 0, - temp: 0, - uptime: 0, - diskPercent: 0, - load: [0, 0, 0] - }, - attitude: { roll: 0, pitch: 0, yaw: 0 }, - pid: zeroPid(), - motors: zeroMotors(), - gyro: zeroGyro() - }; -} - -function markJsonOffline(prev: Telemetry, endpoint: string, detail: string): Telemetry { - const t = prev.clock + 1; - - return { - ...prev, - clock: t, - source: { - status: "offline", - endpoint, - message: `ESP JSON unavailable: ${detail}`, - lastUpdated: prev.source.lastUpdated - }, - nav: { - ...prev.nav, - fix: "NO JSON", - rssi: 0, - groundSpeed: 0 - }, - link: { - rssi: 0, - rate: 0, - loss: 100, - latency: 0, - history: [...prev.link.history.slice(-27), 0] - }, - flight: { - ...prev.flight, - mode: "NO ESP JSON", - armed: false, - failsafe: true, - mission: "CHECK ESP WIFI JSON", - link: "LOST" - }, - pid: [...prev.pid.slice(-(SAMPLES - 1)), { t, roll: 0, pitch: 0, yaw: 0 }], - motors: [...prev.motors.slice(-(SAMPLES - 1)), { t, m1: 0, m2: 0, m3: 0, m4: 0 }], - gyro: [...prev.gyro.slice(-(SAMPLES - 1)), { t, x: 0, y: 0, z: 0 }] - }; -} - -function markJsonDegraded(prev: Telemetry, endpoint: string, detail: string, failures: number): Telemetry { - return { - ...prev, - source: { - status: "stale", - endpoint, - message: `ESP JSON slow: ${detail} (${failures}/${OFFLINE_AFTER_FAILURES})`, - lastUpdated: prev.source.lastUpdated - } - }; -} - -function fromLive(prev: Telemetry, raw: RawTelemetry, endpoint: string, latencyMs: number): Telemetry { - const t = prev.clock + 1; - const nrfFresh = raw.connected !== false; - const rawCells = Array.isArray(raw.cell_voltages) ? raw.cell_voltages.filter((cell) => cell > 0.5).slice(0, 6) : []; - const cells = (rawCells.length > 0 ? rawCells : prev.battery.cells.map((cell) => cell.volts)).map((volts, i) => ({ - label: cellLabels[i] ?? `${i + 1}S`, - volts - })); - const total = num(raw.battery_voltage, cells.reduce((sum, cell) => sum + cell.volts, 0)); - const percent = clamp(Math.round(num(raw.battery_remaining, prev.battery.percent)), 0, 100); - const heading = (num(raw.heading, prev.nav.heading) + 360) % 360; - const motors = Array.isArray(raw.motor_percent) ? raw.motor_percent : []; - const lastMotors = prev.motors[prev.motors.length - 1] ?? { m1: 0, m2: 0, m3: 0, m4: 0 }; - const sbc = raw.sbc ?? {}; - const ramPercent = clamp(num(sbc.ram_percent, prev.sbc.memPercent), 0, 100); - const diskPercent = clamp(num(sbc.disk_percent, prev.sbc.diskPercent), 0, 100); - const rssiPercent = clamp(Math.round(num(raw.rc_rssi, 0)), 0, 100); - const reportedQuality = num(raw.radio_link_quality, 0); - const linkRate = nrfFresh ? clamp(reportedQuality > 0 ? reportedQuality : rssiPercent > 0 ? rssiPercent : 95, 0, 100) : 0; - const linkLoss = nrfFresh ? clamp(100 - linkRate, 0, 100) : 100; - - return { - clock: t, - source: { - status: nrfFresh ? "online" : "stale", - endpoint, - message: nrfFresh ? "ESP JSON live" : "ESP JSON reachable, waiting for fresh NRF packets", - lastUpdated: Date.now() - }, - battery: { - total, - percent, - current: num(raw.battery_current, prev.battery.current), - consumed: prev.battery.consumed, - cells - }, - nav: { - lat: num(raw.latitude, prev.nav.lat), - lon: num(raw.longitude, prev.nav.lon), - heading, - sats: clamp(Math.round(num(raw.satellites, prev.nav.sats)), 0, 32), - hdop: num(raw.hdop, prev.nav.hdop), - rssi: rssiPercent, - fix: raw.gps_fix_type ?? prev.nav.fix, - groundSpeed: nrfFresh ? num(raw.ground_speed, prev.nav.groundSpeed) : 0 - }, - altitude: { - relative: num(raw.altitude_relative, prev.altitude.relative), - msl: num(raw.altitude_msl, prev.altitude.msl), - vspeed: num(raw.climb_rate, prev.altitude.vspeed), - home: prev.altitude.home - }, - link: { - rssi: rssiPercent, - rate: linkRate, - loss: linkLoss, - latency: latencyMs, - history: [...prev.link.history.slice(-27), linkRate] - }, - flight: { - mode: nrfFresh ? raw.flight_mode ?? prev.flight.mode : "NO NRF", - armed: nrfFresh ? raw.armed ?? prev.flight.armed : false, - failsafe: !nrfFresh, - mission: nrfFresh ? raw.mission_state ?? prev.flight.mission : "NRF LINK STALE", - wp: `${Math.round(num(raw.mission_current_seq, 0))} / ${Math.round(num(raw.mission_total_items, 0))}`, - link: nrfFresh ? "OK" : "LOST" - }, - sbc: { - cpu: clamp(num(sbc.cpu_load_percent, prev.sbc.cpu), 0, 100), - cores: Array.from({ length: 4 }, () => clamp(num(sbc.cpu_load_percent, prev.sbc.cpu), 0, 100)), - memPercent: ramPercent, - temp: num(sbc.cpu_temp_c, prev.sbc.temp), - uptime: num(sbc.uptime_seconds, prev.sbc.uptime), - diskPercent, - load: [clamp(num(sbc.cpu_load_percent, prev.sbc.cpu), 0, 100), ramPercent, diskPercent] - }, - attitude: { - roll: nrfFresh ? num(raw.attitude_roll, prev.attitude.roll) : 0, - pitch: nrfFresh ? num(raw.attitude_pitch, prev.attitude.pitch) : 0, - yaw: nrfFresh ? num(raw.attitude_yaw, heading) : heading - }, - pid: [ - ...prev.pid.slice(-(SAMPLES - 1)), - { - t, - roll: nrfFresh ? num(raw.error_roll, 0) : 0, - pitch: nrfFresh ? num(raw.error_pitch, 0) : 0, - yaw: nrfFresh ? num(raw.error_yaw, 0) : 0 - } - ], - motors: [ - ...prev.motors.slice(-(SAMPLES - 1)), - { - t, - m1: nrfFresh ? num(motors[0], lastMotors.m1) : 0, - m2: nrfFresh ? num(motors[1], lastMotors.m2) : 0, - m3: nrfFresh ? num(motors[2], lastMotors.m3) : 0, - m4: nrfFresh ? num(motors[3], lastMotors.m4) : 0 - } - ], - gyro: [ - ...prev.gyro.slice(-(SAMPLES - 1)), - { - t, - x: nrfFresh ? num(raw.gyro_x, 0) : 0, - y: nrfFresh ? num(raw.gyro_y, 0) : 0, - z: nrfFresh ? num(raw.gyro_z, 0) : 0 - } - ] - }; -} function errorText(error: unknown): string { if (error instanceof DOMException && error.name === "AbortError") return "request timeout"; @@ -297,12 +18,12 @@ function errorText(error: unknown): string { return "request failed"; } -export function useTelemetry(live: boolean, endpoint: string): Telemetry { - const [state, setState] = useState(() => offlineTelemetry(endpoint)); +export function useTelemetry(live: boolean, endpoint: string, cellCount: number): Telemetry { + const [state, setState] = useState(() => createOfflineTelemetry(endpoint, undefined, cellCount)); useEffect(() => { - setState(offlineTelemetry(endpoint, endpoint ? "Waiting for ESP JSON" : "Enter ESP IP to start")); - }, [endpoint]); + setState(createOfflineTelemetry(endpoint, endpoint ? "Waiting for telemetry JSON" : "Enter ESP endpoint to start", cellCount)); + }, [cellCount, endpoint]); useEffect(() => { if (!live || endpoint.length === 0) return; @@ -316,31 +37,26 @@ export function useTelemetry(live: boolean, endpoint: string): Telemetry { activeController = controller; const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); const startedAt = performance.now(); + try { const response = await fetch(endpoint, { cache: "no-store", signal: controller.signal }); - if (!response.ok) throw new Error(`http ${response.status}`); - const raw = (await response.json()) as RawTelemetry; + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const raw = parseRawTelemetry(await response.json()); if (stopped) return; const latencyMs = Math.max(0, Math.round(performance.now() - startedAt)); failures = 0; - setState((prev) => fromLive(prev, raw, endpoint, latencyMs)); + setState((previous) => reduceLiveTelemetry(previous, raw, endpoint, latencyMs, cellCount)); } catch (error) { if (stopped) return; failures += 1; const detail = errorText(error); - setState((prev) => - failures >= OFFLINE_AFTER_FAILURES - ? markJsonOffline(prev, endpoint, detail) - : markJsonDegraded(prev, endpoint, detail, failures) - ); + setState((previous) => failures >= OFFLINE_AFTER_FAILURES + ? reduceOfflineTelemetry(previous, endpoint, detail) + : reduceDegradedTelemetry(previous, endpoint, detail, failures)); } finally { window.clearTimeout(timeout); - if (activeController === controller) { - activeController = null; - } - if (!stopped) { - timer = window.setTimeout(() => void poll(), POLL_MS); - } + if (activeController === controller) activeController = null; + if (!stopped) timer = window.setTimeout(() => void poll(), POLL_INTERVAL_MS); } }; @@ -350,7 +66,7 @@ export function useTelemetry(live: boolean, endpoint: string): Telemetry { activeController?.abort(); if (timer !== undefined) window.clearTimeout(timer); }; - }, [live, endpoint]); + }, [cellCount, endpoint, live]); return state; } diff --git a/telemetry/live-web-dashboard/src/lib/telemetry_endpoint.test.ts b/telemetry/live-web-dashboard/src/lib/telemetry_endpoint.test.ts new file mode 100644 index 0000000..6e1da33 --- /dev/null +++ b/telemetry/live-web-dashboard/src/lib/telemetry_endpoint.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { telemetryEndpointFromInput } from "./telemetry_endpoint"; + +describe("telemetry endpoint normalization", () => { + it("adds the local JSON path to an ESP address", () => { + expect(telemetryEndpointFromInput("192.168.4.1", "http:")).toBe("http://192.168.4.1/telemetry.json"); + }); + + it("preserves a complete secure API path", () => { + expect(telemetryEndpointFromInput("https://telemetry.example/api/live?token=ignored", "https:")) + .toBe("https://telemetry.example/api/live"); + }); + + it("rejects insecure remote telemetry from an HTTPS dashboard", () => { + expect(() => telemetryEndpointFromInput("http://192.168.4.1/telemetry.json", "https:")) + .toThrow("Use an HTTPS telemetry URL"); + }); +}); diff --git a/telemetry/live-web-dashboard/src/lib/telemetry_endpoint.ts b/telemetry/live-web-dashboard/src/lib/telemetry_endpoint.ts new file mode 100644 index 0000000..9db1a4c --- /dev/null +++ b/telemetry/live-web-dashboard/src/lib/telemetry_endpoint.ts @@ -0,0 +1,17 @@ +export function telemetryEndpointFromInput(value: string, pageProtocol = window.location.protocol): string { + const trimmed = value.trim(); + if (trimmed.length === 0) return ""; + + const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; + const url = new URL(withProtocol); + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("unsupported protocol"); + + const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"; + if (pageProtocol === "https:" && url.protocol === "http:" && !loopback) { + throw new Error("Use an HTTPS telemetry URL from this hosted dashboard."); + } + if (url.pathname === "/" || url.pathname === "") url.pathname = "/telemetry.json"; + url.search = ""; + url.hash = ""; + return url.toString(); +} diff --git a/telemetry/live-web-dashboard/src/lib/telemetry_mapper.test.ts b/telemetry/live-web-dashboard/src/lib/telemetry_mapper.test.ts new file mode 100644 index 0000000..5cc4b63 --- /dev/null +++ b/telemetry/live-web-dashboard/src/lib/telemetry_mapper.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + createOfflineTelemetry, + parseRawTelemetry, + reduceLiveTelemetry, + reduceOfflineTelemetry +} from "./telemetry_mapper"; + +describe("telemetry payload validation", () => { + it("accepts a valid partial telemetry payload", () => { + expect(parseRawTelemetry({ connected: true, battery_voltage: 15.8 })).toEqual({ + connected: true, + battery_voltage: 15.8 + }); + }); + + it("rejects malformed and empty payloads", () => { + expect(() => parseRawTelemetry({ battery_voltage: "15.8" })).toThrow("invalid battery_voltage"); + expect(() => parseRawTelemetry({ unknown: 1 })).toThrow("no recognized fields"); + }); +}); + +describe("telemetry safety state", () => { + it("marks armed and failsafe state unknown when telemetry is offline", () => { + const previous = reduceLiveTelemetry( + createOfflineTelemetry("http://esp/telemetry.json"), + { connected: true, armed: true, attitude_roll: 12 }, + "http://esp/telemetry.json", + 10, + 4 + ); + const offline = reduceOfflineTelemetry(previous, "http://esp/telemetry.json", "request timeout"); + + expect(offline.flight.armed).toBeNull(); + expect(offline.flight.failsafe).toBeNull(); + expect(offline.attitude.roll).toBe(12); + }); + + it("uses measured RSSI when link quality is absent", () => { + const telemetry = reduceLiveTelemetry( + createOfflineTelemetry(), + { connected: true, rc_rssi: 42 }, + "http://esp/telemetry.json", + 8, + 4 + ); + + expect(telemetry.link.rate).toBe(42); + expect(telemetry.link.loss).toBe(58); + }); + + it("keeps unavailable consumption and CPU-core data explicit", () => { + const telemetry = reduceLiveTelemetry( + createOfflineTelemetry(), + { connected: true, sbc: { cpu_load_percent: 35 } }, + "http://esp/telemetry.json", + 8, + 4 + ); + + expect(telemetry.battery.consumed).toBeNull(); + expect(telemetry.sbc.cores).toEqual([]); + }); +}); diff --git a/telemetry/live-web-dashboard/src/lib/telemetry_mapper.ts b/telemetry/live-web-dashboard/src/lib/telemetry_mapper.ts new file mode 100644 index 0000000..d51a385 --- /dev/null +++ b/telemetry/live-web-dashboard/src/lib/telemetry_mapper.ts @@ -0,0 +1,289 @@ +import { cellLabels } from "../data/telemetry"; +import type { Telemetry } from "../types/telemetry"; + +export const SAMPLE_COUNT = 64; +export const POLL_INTERVAL_MS = 1000; +export const OFFLINE_AFTER_FAILURES = 3; + +export type RawTelemetry = { + connected?: boolean; + battery_voltage?: number; + battery_current?: number; + battery_remaining?: number; + battery_consumed_mah?: number; + cell_voltages?: number[]; + altitude_relative?: number; + altitude_msl?: number; + climb_rate?: number; + latitude?: number; + longitude?: number; + satellites?: number; + gps_fix_type?: string; + hdop?: number; + rc_rssi?: number; + radio_link_quality?: number; + heading?: number; + ground_speed?: number; + armed?: boolean; + failsafe?: boolean; + flight_mode?: string; + mission_state?: string; + mission_current_seq?: number; + mission_total_items?: number; + attitude_roll?: number; + attitude_pitch?: number; + attitude_yaw?: number; + error_roll?: number; + error_pitch?: number; + error_yaw?: number; + motor_percent?: number[]; + gyro_x?: number; + gyro_y?: number; + gyro_z?: number; + sbc?: { + cpu_load_percent?: number; + cpu_cores_percent?: number[]; + ram_used_mb?: number; + ram_total_mb?: number; + ram_percent?: number; + cpu_temp_c?: number; + disk_percent?: number; + uptime_seconds?: number; + }; +}; + +const numericKeys = [ + "battery_voltage", "battery_current", "battery_remaining", "battery_consumed_mah", "altitude_relative", + "altitude_msl", "climb_rate", "latitude", "longitude", "satellites", "hdop", "rc_rssi", + "radio_link_quality", "heading", "ground_speed", "mission_current_seq", "mission_total_items", + "attitude_roll", "attitude_pitch", "attitude_yaw", "error_roll", "error_pitch", "error_yaw", + "gyro_x", "gyro_y", "gyro_z" +] as const; +const booleanKeys = ["connected", "armed", "failsafe"] as const; +const stringKeys = ["gps_fix_type", "flight_mode", "mission_state"] as const; + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); +const numberOr = (value: unknown, fallback = 0) => typeof value === "number" && Number.isFinite(value) ? value : fallback; +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); + +function finiteNumberArray(value: unknown, maxLength: number): number[] | undefined { + if (!Array.isArray(value)) return undefined; + const values = value.filter((item): item is number => typeof item === "number" && Number.isFinite(item)); + return values.length === value.length ? values.slice(0, maxLength) : undefined; +} + +export function parseRawTelemetry(value: unknown): RawTelemetry { + if (!isRecord(value)) throw new Error("invalid telemetry payload"); + const parsed: RawTelemetry = {}; + let recognized = 0; + + for (const key of numericKeys) { + const candidate = value[key]; + if (candidate === undefined) continue; + if (typeof candidate !== "number" || !Number.isFinite(candidate)) throw new Error(`invalid ${key}`); + (parsed as Record)[key] = candidate; + recognized += 1; + } + for (const key of booleanKeys) { + const candidate = value[key]; + if (candidate === undefined) continue; + if (typeof candidate !== "boolean") throw new Error(`invalid ${key}`); + (parsed as Record)[key] = candidate; + recognized += 1; + } + for (const key of stringKeys) { + const candidate = value[key]; + if (candidate === undefined) continue; + if (typeof candidate !== "string") throw new Error(`invalid ${key}`); + (parsed as Record)[key] = candidate; + recognized += 1; + } + + for (const [key, maxLength] of [["cell_voltages", 6], ["motor_percent", 4]] as const) { + if (value[key] === undefined) continue; + const items = finiteNumberArray(value[key], maxLength); + if (!items) throw new Error(`invalid ${key}`); + parsed[key] = items; + recognized += 1; + } + + if (value.sbc !== undefined) { + if (!isRecord(value.sbc)) throw new Error("invalid sbc"); + const sbc: NonNullable = {}; + for (const key of ["cpu_load_percent", "ram_used_mb", "ram_total_mb", "ram_percent", "cpu_temp_c", "disk_percent", "uptime_seconds"] as const) { + const candidate = value.sbc[key]; + if (candidate === undefined) continue; + if (typeof candidate !== "number" || !Number.isFinite(candidate)) throw new Error(`invalid sbc.${key}`); + sbc[key] = candidate; + } + const cores = finiteNumberArray(value.sbc.cpu_cores_percent, 32); + if (value.sbc.cpu_cores_percent !== undefined && !cores) throw new Error("invalid sbc.cpu_cores_percent"); + if (cores) sbc.cpu_cores_percent = cores; + parsed.sbc = sbc; + recognized += 1; + } + + if (recognized === 0) throw new Error("telemetry payload has no recognized fields"); + return parsed; +} + +function zeroPid() { + return Array.from({ length: SAMPLE_COUNT }, (_, t) => ({ t, roll: 0, pitch: 0, yaw: 0 })); +} + +function zeroMotors() { + return Array.from({ length: SAMPLE_COUNT }, (_, t) => ({ t, m1: 0, m2: 0, m3: 0, m4: 0 })); +} + +function zeroGyro() { + return Array.from({ length: SAMPLE_COUNT }, (_, t) => ({ t, x: 0, y: 0, z: 0 })); +} + +export function createOfflineTelemetry(endpoint = "", message = "Enter ESP endpoint to start", cellCount = 4): Telemetry { + return { + clock: 0, + source: { status: "offline", endpoint, message, lastUpdated: 0 }, + battery: { + total: 0, + percent: 0, + current: 0, + consumed: null, + cells: cellLabels.slice(0, cellCount).map((label) => ({ label, volts: 0 })) + }, + nav: { lat: 0, lon: 0, heading: 0, sats: 0, hdop: 99.9, rssi: 0, fix: "NO DATA", groundSpeed: 0 }, + altitude: { relative: 0, msl: 0, vspeed: 0, home: 0 }, + link: { rssi: 0, rate: 0, loss: 100, latency: 0, history: Array.from({ length: 28 }, () => 0) }, + flight: { mode: "UNKNOWN", armed: null, failsafe: null, mission: "TELEMETRY OFFLINE", wp: "-- / --", link: "LOST" }, + sbc: { available: false, cpu: 0, cores: [], memPercent: 0, temp: 0, uptime: 0, diskPercent: 0, load: [0, 0, 0] }, + attitude: { roll: 0, pitch: 0, yaw: 0 }, + pid: zeroPid(), + motors: zeroMotors(), + gyro: zeroGyro() + }; +} + +export function reduceOfflineTelemetry(prev: Telemetry, endpoint: string, detail: string): Telemetry { + const t = prev.clock + 1; + return { + ...prev, + clock: t, + source: { status: "offline", endpoint, message: `ESP JSON unavailable: ${detail}`, lastUpdated: prev.source.lastUpdated }, + nav: { ...prev.nav, fix: "NO DATA" }, + link: { ...prev.link, rate: 0, loss: 100, latency: 0, history: [...prev.link.history.slice(-27), 0] }, + flight: { ...prev.flight, mode: "UNKNOWN", armed: null, failsafe: null, mission: "TELEMETRY OFFLINE", link: "LOST" }, + sbc: { ...prev.sbc, available: false }, + pid: [...prev.pid.slice(-(SAMPLE_COUNT - 1)), { t, roll: 0, pitch: 0, yaw: 0 }], + motors: [...prev.motors.slice(-(SAMPLE_COUNT - 1)), { t, m1: 0, m2: 0, m3: 0, m4: 0 }], + gyro: [...prev.gyro.slice(-(SAMPLE_COUNT - 1)), { t, x: 0, y: 0, z: 0 }] + }; +} + +export function reduceDegradedTelemetry(prev: Telemetry, endpoint: string, detail: string, failures: number): Telemetry { + return { + ...prev, + source: { + status: "stale", + endpoint, + message: `ESP JSON slow: ${detail} (${failures}/${OFFLINE_AFTER_FAILURES})`, + lastUpdated: prev.source.lastUpdated + } + }; +} + +export function reduceLiveTelemetry(prev: Telemetry, raw: RawTelemetry, endpoint: string, latencyMs: number, cellCount: number): Telemetry { + const t = prev.clock + 1; + const nrfFresh = raw.connected !== false; + const rawCells = raw.cell_voltages?.filter((cell) => cell > 0.5).slice(0, 6) ?? []; + const fallbackCells = prev.battery.cells.slice(0, cellCount).map((cell) => cell.volts); + const selectedCells = rawCells.length > 0 ? rawCells : fallbackCells; + const cells = selectedCells.map((volts, index) => ({ label: cellLabels[index] ?? `${index + 1}S`, volts })); + const total = numberOr(raw.battery_voltage, cells.reduce((sum, cell) => sum + cell.volts, 0)); + const percent = clamp(Math.round(numberOr(raw.battery_remaining, prev.battery.percent)), 0, 100); + const heading = (numberOr(raw.heading, prev.nav.heading) + 360) % 360; + const motors = raw.motor_percent ?? []; + const lastMotors = prev.motors[prev.motors.length - 1] ?? { m1: 0, m2: 0, m3: 0, m4: 0 }; + const sbc = raw.sbc; + const cpu = clamp(numberOr(sbc?.cpu_load_percent, prev.sbc.cpu), 0, 100); + const ramPercent = clamp(numberOr(sbc?.ram_percent, prev.sbc.memPercent), 0, 100); + const diskPercent = clamp(numberOr(sbc?.disk_percent, prev.sbc.diskPercent), 0, 100); + const rssiPercent = clamp(Math.round(numberOr(raw.rc_rssi, prev.link.rssi)), 0, 100); + const reportedQuality = raw.radio_link_quality === undefined ? null : clamp(raw.radio_link_quality, 0, 100); + const linkRate = nrfFresh ? reportedQuality ?? rssiPercent : 0; + const linkLoss = nrfFresh ? clamp(100 - linkRate, 0, 100) : 100; + + return { + clock: t, + source: { + status: nrfFresh ? "online" : "stale", + endpoint, + message: nrfFresh ? "ESP JSON live" : "ESP JSON reachable, waiting for fresh NRF packets", + lastUpdated: Date.now() + }, + battery: { + total, + percent, + current: numberOr(raw.battery_current, prev.battery.current), + consumed: raw.battery_consumed_mah === undefined ? prev.battery.consumed : Math.max(0, Math.round(raw.battery_consumed_mah)), + cells + }, + nav: { + lat: numberOr(raw.latitude, prev.nav.lat), + lon: numberOr(raw.longitude, prev.nav.lon), + heading, + sats: clamp(Math.round(numberOr(raw.satellites, prev.nav.sats)), 0, 64), + hdop: numberOr(raw.hdop, prev.nav.hdop), + rssi: rssiPercent, + fix: raw.gps_fix_type ?? prev.nav.fix, + groundSpeed: nrfFresh ? numberOr(raw.ground_speed, prev.nav.groundSpeed) : 0 + }, + altitude: { + relative: numberOr(raw.altitude_relative, prev.altitude.relative), + msl: numberOr(raw.altitude_msl, prev.altitude.msl), + vspeed: numberOr(raw.climb_rate, prev.altitude.vspeed), + home: prev.altitude.home + }, + link: { rssi: rssiPercent, rate: linkRate, loss: linkLoss, latency: latencyMs, history: [...prev.link.history.slice(-27), linkRate] }, + flight: { + mode: nrfFresh ? raw.flight_mode ?? prev.flight.mode : "UNKNOWN", + armed: nrfFresh ? raw.armed ?? prev.flight.armed : null, + failsafe: nrfFresh ? raw.failsafe ?? null : null, + mission: nrfFresh ? raw.mission_state ?? prev.flight.mission : "NRF LINK STALE", + wp: `${Math.round(numberOr(raw.mission_current_seq, 0))} / ${Math.round(numberOr(raw.mission_total_items, 0))}`, + link: nrfFresh ? "OK" : "LOST" + }, + sbc: { + available: sbc !== undefined, + cpu, + cores: sbc?.cpu_cores_percent?.map((value) => clamp(value, 0, 100)) ?? [], + memPercent: ramPercent, + temp: numberOr(sbc?.cpu_temp_c, prev.sbc.temp), + uptime: numberOr(sbc?.uptime_seconds, prev.sbc.uptime), + diskPercent, + load: [cpu, ramPercent, diskPercent] + }, + attitude: { + roll: nrfFresh ? numberOr(raw.attitude_roll, prev.attitude.roll) : prev.attitude.roll, + pitch: nrfFresh ? numberOr(raw.attitude_pitch, prev.attitude.pitch) : prev.attitude.pitch, + yaw: nrfFresh ? numberOr(raw.attitude_yaw, heading) : prev.attitude.yaw + }, + pid: [...prev.pid.slice(-(SAMPLE_COUNT - 1)), { + t, + roll: nrfFresh ? numberOr(raw.error_roll, 0) : 0, + pitch: nrfFresh ? numberOr(raw.error_pitch, 0) : 0, + yaw: nrfFresh ? numberOr(raw.error_yaw, 0) : 0 + }], + motors: [...prev.motors.slice(-(SAMPLE_COUNT - 1)), { + t, + m1: nrfFresh ? numberOr(motors[0], lastMotors.m1) : 0, + m2: nrfFresh ? numberOr(motors[1], lastMotors.m2) : 0, + m3: nrfFresh ? numberOr(motors[2], lastMotors.m3) : 0, + m4: nrfFresh ? numberOr(motors[3], lastMotors.m4) : 0 + }], + gyro: [...prev.gyro.slice(-(SAMPLE_COUNT - 1)), { + t, + x: nrfFresh ? numberOr(raw.gyro_x, 0) : 0, + y: nrfFresh ? numberOr(raw.gyro_y, 0) : 0, + z: nrfFresh ? numberOr(raw.gyro_z, 0) : 0 + }] + }; +} diff --git a/telemetry/live-web-dashboard/src/main.tsx b/telemetry/live-web-dashboard/src/main.tsx index e37303a..7ce407f 100644 --- a/telemetry/live-web-dashboard/src/main.tsx +++ b/telemetry/live-web-dashboard/src/main.tsx @@ -1,10 +1,13 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./app"; +import { ErrorBoundary } from "./components/error_boundary"; import "./styles.css"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + + + ); diff --git a/telemetry/live-web-dashboard/src/styles.css b/telemetry/live-web-dashboard/src/styles.css index 96b9bbc..a3b27ca 100644 --- a/telemetry/live-web-dashboard/src/styles.css +++ b/telemetry/live-web-dashboard/src/styles.css @@ -1,8 +1,5 @@ -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap"); - -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; +@config "../tailwind.config.js"; html, body, @@ -36,3 +33,14 @@ body { linear-gradient(to bottom, rgba(45, 212, 191, 0.04) 1px, transparent 1px); background-size: 48px 48px; } + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/telemetry/live-web-dashboard/src/types/telemetry.ts b/telemetry/live-web-dashboard/src/types/telemetry.ts index a123b1e..86cbc1d 100644 --- a/telemetry/live-web-dashboard/src/types/telemetry.ts +++ b/telemetry/live-web-dashboard/src/types/telemetry.ts @@ -7,7 +7,7 @@ export interface BatteryState { total: number; percent: number; current: number; - consumed: number; + consumed: number | null; cells: CellReading[]; } @@ -41,14 +41,15 @@ export type LinkQuality = "OK" | "DEGRADED" | "LOST"; export interface FlightState { mode: string; - armed: boolean; - failsafe: boolean; + armed: boolean | null; + failsafe: boolean | null; mission: string; wp: string; link: LinkQuality; } export interface SbcState { + available: boolean; cpu: number; cores: number[]; memPercent: number; diff --git a/telemetry/live-web-dashboard/tailwind.config.js b/telemetry/live-web-dashboard/tailwind.config.js index 55eb60b..09c7662 100644 --- a/telemetry/live-web-dashboard/tailwind.config.js +++ b/telemetry/live-web-dashboard/tailwind.config.js @@ -25,11 +25,11 @@ export default { } }, fontFamily: { - sans: ["Inter", "system-ui", "sans-serif"], - mono: ["JetBrains Mono", "ui-monospace", "monospace"] + sans: ["system-ui", "Segoe UI", "sans-serif"], + mono: ["ui-monospace", "Cascadia Code", "Consolas", "monospace"] }, fontSize: { - "2xs": ["10px", "12px"] + "2xs": ["11px", "14px"] } } }, diff --git a/telemetry/live-web-dashboard/vite.config.ts b/telemetry/live-web-dashboard/vite.config.ts index 4f6a825..142b7f0 100644 --- a/telemetry/live-web-dashboard/vite.config.ts +++ b/telemetry/live-web-dashboard/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ + base: process.env.VITE_BASE_PATH ?? "/", plugins: [react()], build: { outDir: "dist", From a01fbd4bc8feefd0c4696854f5b92fa9acb3b27d Mon Sep 17 00:00:00 2001 From: TheLostLeo Date: Tue, 13 Oct 2026 14:58:46 +0530 Subject: [PATCH 05/12] feat(telemetry): add safe companion mode controller Signed-off-by: TheLostLeo --- telemetry/README.md | 18 +++ .../pi-code/companion_mode.py | 124 ++++++++++++++++++ .../handheld-nrf-monitor/pi-code/main.py | 19 ++- .../pi-code/mavlink_manager.py | 8 +- .../pi-code/radio_tx_module.py | 4 +- .../pi-code/sbc_monitor.py | 2 +- .../pi-code/tests/test_companion_mode.py | 94 +++++++++++++ 7 files changed, 259 insertions(+), 10 deletions(-) create mode 100644 telemetry/handheld-nrf-monitor/pi-code/companion_mode.py create mode 100644 telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_mode.py diff --git a/telemetry/README.md b/telemetry/README.md index 85de241..fe0b66e 100644 --- a/telemetry/README.md +++ b/telemetry/README.md @@ -18,6 +18,9 @@ Entry point for the Raspberry Pi telemetry relay. It starts the MAVLink manager, ### `handheld-nrf-monitor/pi-code/mavlink_manager.py` Manages the Pixhawk MAVLink connection or simulator and keeps the shared telemetry state used by the NRF transmitter. +### `handheld-nrf-monitor/pi-code/companion_mode.py` +Evaluates the companion computer state from Pixhawk link freshness, battery, GPS, and Raspberry Pi temperature. It is monitor-only: every state has command output disabled, including `assist_ready` and `failsafe`. + ### `handheld-nrf-monitor/pi-code/radio_tx_module.py` Packs selected telemetry into typed NRF24L01+ frames and broadcasts them to the ESP32 handheld receiver. @@ -45,6 +48,21 @@ cd telemetry/handheld-nrf-monitor/pi-code python3 main.py --port /dev/serial0 --baud 115200 --radio-rate 5 ``` +The default companion mode is `monitor`. To validate whether the system is +healthy enough for a future assist feature without sending flight commands: + +```bash +python3 main.py --simulate --no-radio --companion-mode assist +``` + +Companion states are: + +- `disabled` - state evaluation is disabled by configuration. +- `monitor` - telemetry and companion health are current. +- `assist_ready` - link, GPS, battery, and computer health checks passed. Flight command output remains disabled. +- `degraded` - one or more required inputs are unavailable or outside the configured limits. +- `failsafe` - a critical battery or temperature condition exists, or telemetry became stale while the aircraft was armed. This is a reported state; Pixhawk remains responsible for flight stabilization and configured vehicle failsafes. + ## Running the Web Dashboard From `telemetry/live-web-dashboard` on the laptop or static-host machine: diff --git a/telemetry/handheld-nrf-monitor/pi-code/companion_mode.py b/telemetry/handheld-nrf-monitor/pi-code/companion_mode.py new file mode 100644 index 0000000..d746353 --- /dev/null +++ b/telemetry/handheld-nrf-monitor/pi-code/companion_mode.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Safety state machine for the Raspberry Pi companion computer. + +This module evaluates whether the companion computer is healthy enough to +monitor the aircraft or become ready for a future, separately guarded assist +feature. It never sends MAVLink commands. +""" + +from dataclasses import dataclass +from enum import Enum +import time +from typing import Any, Dict, Optional, Tuple + + +class RequestedCompanionMode(str, Enum): + DISABLED = "disabled" + MONITOR = "monitor" + ASSIST = "assist" + + +class CompanionState(str, Enum): + DISABLED = "disabled" + MONITOR = "monitor" + ASSIST_READY = "assist_ready" + DEGRADED = "degraded" + FAILSAFE = "failsafe" + + +@dataclass(frozen=True) +class CompanionStatus: + state: CompanionState + reasons: Tuple[str, ...] + commands_allowed: bool = False + + +@dataclass(frozen=True) +class CompanionLimits: + telemetry_timeout_s: float = 3.0 + minimum_satellites: int = 6 + minimum_battery_percent: int = 20 + critical_battery_percent: int = 10 + maximum_cpu_temp_c: float = 80.0 + critical_cpu_temp_c: float = 85.0 + + +class CompanionModeController: + """Evaluates companion readiness without taking control of the aircraft.""" + + def __init__( + self, + requested_mode: RequestedCompanionMode = RequestedCompanionMode.MONITOR, + limits: Optional[CompanionLimits] = None, + ): + self.requested_mode = requested_mode + self.limits = limits or CompanionLimits() + + def evaluate( + self, + telemetry: Dict[str, Any], + sbc_metrics: Dict[str, Any], + now: Optional[float] = None, + ) -> CompanionStatus: + """Return the current state and every reason preventing full readiness.""" + if self.requested_mode == RequestedCompanionMode.DISABLED: + return CompanionStatus(CompanionState.DISABLED, ("disabled by configuration",)) + + now = time.time() if now is None else now + reasons = [] + critical_reasons = [] + connected = bool(telemetry.get("connected", False)) + armed = bool(telemetry.get("armed", False)) + last_packet = self._number(telemetry.get("last_packet_timestamp"), 0.0) + packet_age = now - last_packet if last_packet > 0 else float("inf") + + if not connected: + reasons.append("Pixhawk link disconnected") + if packet_age > self.limits.telemetry_timeout_s: + reasons.append("telemetry is stale") + + battery = self._number(telemetry.get("battery_remaining"), 0.0) + if 0 < battery <= self.limits.critical_battery_percent: + critical_reasons.append("battery is critical") + elif 0 < battery < self.limits.minimum_battery_percent: + reasons.append("battery is low") + + cpu_temp = self._number(sbc_metrics.get("cpu_temp_c"), 0.0) + if cpu_temp >= self.limits.critical_cpu_temp_c: + critical_reasons.append("companion CPU temperature is critical") + elif cpu_temp >= self.limits.maximum_cpu_temp_c: + reasons.append("companion CPU temperature is high") + + if self.requested_mode == RequestedCompanionMode.ASSIST: + if battery <= 0: + reasons.append("battery telemetry is unavailable") + if cpu_temp <= 0: + reasons.append("companion CPU temperature is unavailable") + fix_type = str(telemetry.get("gps_fix_type", "NO FIX")).upper() + satellites = int(self._number(telemetry.get("satellites"), 0.0)) + if fix_type not in {"3D FIX", "DGPS", "RTK FLT", "RTK FIX"}: + reasons.append("3D GPS fix is unavailable") + if satellites < self.limits.minimum_satellites: + reasons.append("too few GPS satellites") + + if critical_reasons or (armed and (not connected or packet_age > self.limits.telemetry_timeout_s)): + all_reasons = tuple(critical_reasons + reasons) + return CompanionStatus(CompanionState.FAILSAFE, all_reasons) + + if reasons: + return CompanionStatus(CompanionState.DEGRADED, tuple(reasons)) + + if self.requested_mode == RequestedCompanionMode.ASSIST: + return CompanionStatus( + CompanionState.ASSIST_READY, + ("health checks passed; command output remains disabled",), + ) + + return CompanionStatus(CompanionState.MONITOR, ("telemetry monitoring healthy",)) + + @staticmethod + def _number(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default diff --git a/telemetry/handheld-nrf-monitor/pi-code/main.py b/telemetry/handheld-nrf-monitor/pi-code/main.py index 20d8970..16d517a 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/main.py +++ b/telemetry/handheld-nrf-monitor/pi-code/main.py @@ -19,6 +19,7 @@ from sbc_monitor import SBCMonitor from mavlink_manager import MAVLinkManager from radio_tx_module import RadioTXModule +from companion_mode import CompanionModeController, RequestedCompanionMode def main(): parser = argparse.ArgumentParser(description="Drone Telemetry Pi Relay") @@ -27,6 +28,12 @@ def main(): parser.add_argument("--radio-rate", type=float, default=5.0, help="NRF24 broadcast rate in Hz (default: 5.0)") parser.add_argument("--no-radio", action="store_true", help="Disable NRF24 radio module") parser.add_argument("--simulate", action="store_true", help="Run simulated flight telemetry without hardware") + parser.add_argument( + "--companion-mode", + choices=[mode.value for mode in RequestedCompanionMode], + default=RequestedCompanionMode.MONITOR.value, + help="Companion state evaluation mode; assist only checks readiness and never sends commands", + ) args = parser.parse_args() print("=" * 76) @@ -36,12 +43,15 @@ def main(): print("[*] Web Dashboard: laptop/static host reads ESP32 Wi-Fi JSON") print(f"[*] Radio TX Broadcast Rate: {args.radio_rate} Hz ({'DISABLED' if args.no_radio else 'ENABLED'})") print(f"[*] Operation Mode: {'SIMULATION' if args.simulate else 'LIVE PIXHAWK'}") + print(f"[*] Companion Mode: {args.companion_mode.upper()} (command output disabled)") print("----------------------------------------------------------------------------") # 1. SBC Monitor sbc_monitor = SBCMonitor() print("[+] Initialized SBC Hardware & Thermal Monitor.") + companion_mode = CompanionModeController(RequestedCompanionMode(args.companion_mode)) + # 2. Shared MAVLink Manager mav_manager = MAVLinkManager(port=args.port, baud=args.baud, simulate=args.simulate) mav_manager.start() @@ -68,7 +78,7 @@ def shutdown(signum, frame): print("\n[*] Stopping telemetry relay...") radio_module.stop() mav_manager.stop() - print("[✓] All modules stopped cleanly.") + print("[OK] All modules stopped cleanly.") sys.exit(0) signal.signal(signal.SIGINT, shutdown) @@ -83,7 +93,8 @@ def shutdown(signum, frame): last_print = now t = mav_manager.get_telemetry_snapshot() sbc = sbc_monitor.get_metrics_snapshot() - conn_status = "✓ LIVE" if t.get("connected") else "! WAITING" + companion_status = companion_mode.evaluate(t, sbc, now=now) + conn_status = "LIVE" if t.get("connected") else "! WAITING" v_bat = t.get('battery_voltage', 0.0) bat_str = f"{v_bat:5.2f}V" if v_bat > 0.5 else "USB 5V" print(f"[{conn_status}] Mode: {t.get('flight_mode'):<9} | " @@ -93,7 +104,9 @@ def shutdown(signum, frame): f"Hdg: {t.get('heading', 0.0):5.1f}° | " f"Alt: {t.get('altitude_relative', 0.0):4.1f}m | " f"Sats: {t.get('satellites', 0):2d} | " - f"CPU: {sbc.get('cpu_temp_c', 0.0):4.1f}°C") + f"CPU: {sbc.get('cpu_temp_c', 0.0):4.1f}°C | " + f"Companion: {companion_status.state.value.upper()} " + f"({'; '.join(companion_status.reasons)})") if __name__ == "__main__": main() diff --git a/telemetry/handheld-nrf-monitor/pi-code/mavlink_manager.py b/telemetry/handheld-nrf-monitor/pi-code/mavlink_manager.py index a3c488e..a8bfc1d 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/mavlink_manager.py +++ b/telemetry/handheld-nrf-monitor/pi-code/mavlink_manager.py @@ -146,7 +146,7 @@ def set_flight_mode(self, mode: str) -> bool: """Commands Pixhawk into requested flight mode (e.g. AUTO, LOITER, RTL, GUIDED).""" mode_upper = mode.upper() if self.simulate or not self.mav: - print(f"[✓] Simulation: Flight mode switched to '{mode_upper}'") + print(f"[OK] Simulation: Flight mode switched to '{mode_upper}'") with self._lock: self.state["flight_mode"] = mode_upper if mode_upper == "AUTO": @@ -194,7 +194,7 @@ def set_flight_mode(self, mode: str) -> bool: def set_arm(self, arm: bool = True, force: bool = False) -> bool: """Arms or disarms the motors.""" if self.simulate or not self.mav: - print(f"[✓] Simulation: Motors {'ARMED' if arm else 'DISARMED'}") + print(f"[OK] Simulation: Motors {'ARMED' if arm else 'DISARMED'}") with self._lock: self.state["armed"] = arm return True @@ -248,7 +248,7 @@ def clear_all_missions(self) -> bool: def upload_mission_items(self, items: List[Dict[str, Any]]) -> bool: """Thread-safe mission upload to Pixhawk using MAVLink Mission Protocol.""" if self.simulate or not self.mav: - print(f"[✓] Simulation: Loaded {len(items)} mission items into simulator memory.") + print(f"[OK] Simulation: Loaded {len(items)} mission items into simulator memory.") with self._lock: self.sim_mission_items = items self.sim_mission_index = 1 @@ -662,7 +662,7 @@ def _run_live_mavlink(self): if hb: tgt_sys = getattr(self.mav, "target_system", 1) or 1 tgt_comp = getattr(self.mav, "target_component", 1) or 1 - print(f"[✓] Heartbeat received from Pixhawk (System: {tgt_sys}, Component: {tgt_comp})") + print(f"[OK] Heartbeat received from Pixhawk (System: {tgt_sys}, Component: {tgt_comp})") self._handle_mavlink_message(hb, mavutil) else: self._set_connection_state(False, "Serial open but no MAVLink heartbeat") diff --git a/telemetry/handheld-nrf-monitor/pi-code/radio_tx_module.py b/telemetry/handheld-nrf-monitor/pi-code/radio_tx_module.py index e7d4687..e5bf379 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/radio_tx_module.py +++ b/telemetry/handheld-nrf-monitor/pi-code/radio_tx_module.py @@ -64,7 +64,7 @@ def start(self): self.running = True self.thread = threading.Thread(target=self._tx_loop, daemon=True) self.thread.start() - print(f"[✓] Module 1 (Radio TX): Running at {self.rate_hz} Hz ({'HARDWARE SPI' if self.radio_available else 'SIMULATED MOCK'}).") + print(f"[OK] Module 1 (Radio TX): Running at {self.rate_hz} Hz ({'HARDWARE SPI' if self.radio_available else 'SIMULATED MOCK'}).") def stop(self): self.running = False @@ -348,4 +348,4 @@ def _tx_loop(self): time.sleep(2.0) tx.stop() mgr.stop() - print("[✓] RadioTXModule test passed.") + print("[OK] RadioTXModule test passed.") diff --git a/telemetry/handheld-nrf-monitor/pi-code/sbc_monitor.py b/telemetry/handheld-nrf-monitor/pi-code/sbc_monitor.py index 51faaf5..22abdad 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/sbc_monitor.py +++ b/telemetry/handheld-nrf-monitor/pi-code/sbc_monitor.py @@ -46,7 +46,7 @@ def get_cpu_temp(self) -> float: return round(val / 1000.0, 1) if val > 1000 else round(val, 1) except Exception: pass - return 46.5 + return 0.0 def get_cpu_load(self) -> float: """Calculates instantaneous CPU load percentage.""" diff --git a/telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_mode.py b/telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_mode.py new file mode 100644 index 0000000..e8ee0f4 --- /dev/null +++ b/telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_mode.py @@ -0,0 +1,94 @@ +import pathlib +import sys +import unittest + + +PI_CODE = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PI_CODE)) + +from companion_mode import ( # noqa: E402 + CompanionModeController, + CompanionState, + RequestedCompanionMode, +) + + +class CompanionModeControllerTests(unittest.TestCase): + def setUp(self): + self.now = 1_000.0 + self.telemetry = { + "connected": True, + "armed": False, + "last_packet_timestamp": self.now - 0.5, + "battery_remaining": 80, + "gps_fix_type": "3D FIX", + "satellites": 12, + } + self.sbc = {"cpu_temp_c": 55.0} + + def test_monitor_mode_reports_healthy(self): + controller = CompanionModeController(RequestedCompanionMode.MONITOR) + + status = controller.evaluate(self.telemetry, self.sbc, now=self.now) + + self.assertEqual(CompanionState.MONITOR, status.state) + self.assertFalse(status.commands_allowed) + + def test_assist_mode_only_reports_readiness(self): + controller = CompanionModeController(RequestedCompanionMode.ASSIST) + + status = controller.evaluate(self.telemetry, self.sbc, now=self.now) + + self.assertEqual(CompanionState.ASSIST_READY, status.state) + self.assertFalse(status.commands_allowed) + + def test_assist_requires_usable_gps(self): + self.telemetry.update({"gps_fix_type": "2D FIX", "satellites": 4}) + controller = CompanionModeController(RequestedCompanionMode.ASSIST) + + status = controller.evaluate(self.telemetry, self.sbc, now=self.now) + + self.assertEqual(CompanionState.DEGRADED, status.state) + self.assertIn("3D GPS fix is unavailable", status.reasons) + self.assertIn("too few GPS satellites", status.reasons) + + def test_assist_fails_closed_when_health_values_are_unavailable(self): + self.telemetry["battery_remaining"] = 0 + self.sbc["cpu_temp_c"] = 0 + controller = CompanionModeController(RequestedCompanionMode.ASSIST) + + status = controller.evaluate(self.telemetry, self.sbc, now=self.now) + + self.assertEqual(CompanionState.DEGRADED, status.state) + self.assertIn("battery telemetry is unavailable", status.reasons) + self.assertIn("companion CPU temperature is unavailable", status.reasons) + + def test_stale_telemetry_while_armed_reports_failsafe(self): + self.telemetry.update({"armed": True, "last_packet_timestamp": self.now - 10.0}) + controller = CompanionModeController(RequestedCompanionMode.MONITOR) + + status = controller.evaluate(self.telemetry, self.sbc, now=self.now) + + self.assertEqual(CompanionState.FAILSAFE, status.state) + self.assertFalse(status.commands_allowed) + + def test_critical_battery_reports_failsafe(self): + self.telemetry["battery_remaining"] = 9 + controller = CompanionModeController(RequestedCompanionMode.MONITOR) + + status = controller.evaluate(self.telemetry, self.sbc, now=self.now) + + self.assertEqual(CompanionState.FAILSAFE, status.state) + self.assertIn("battery is critical", status.reasons) + + def test_disabled_mode_ignores_health(self): + controller = CompanionModeController(RequestedCompanionMode.DISABLED) + + status = controller.evaluate({}, {}, now=self.now) + + self.assertEqual(CompanionState.DISABLED, status.state) + self.assertFalse(status.commands_allowed) + + +if __name__ == "__main__": + unittest.main() From ec14de81b5984646bcc353e38a13a9ecb22a9584 Mon Sep 17 00:00:00 2001 From: TheLostLeo Date: Tue, 13 Oct 2026 15:13:45 +0530 Subject: [PATCH 06/12] feat(telemetry): add simulated grid mission planner Signed-off-by: TheLostLeo --- telemetry/README.md | 33 ++ .../pi-code/companion_api.py | 166 +++++++++ .../handheld-nrf-monitor/pi-code/main.py | 10 +- .../pi-code/search_mission_planner.py | 208 +++++++++++ .../pi-code/tests/test_companion_api.py | 94 +++++ .../tests/test_search_mission_planner.py | 51 +++ telemetry/live-web-dashboard/src/app.tsx | 16 + .../src/components/mission_planner.tsx | 342 ++++++++++++++++++ 8 files changed, 919 insertions(+), 1 deletion(-) create mode 100644 telemetry/handheld-nrf-monitor/pi-code/companion_api.py create mode 100644 telemetry/handheld-nrf-monitor/pi-code/search_mission_planner.py create mode 100644 telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_api.py create mode 100644 telemetry/handheld-nrf-monitor/pi-code/tests/test_search_mission_planner.py create mode 100644 telemetry/live-web-dashboard/src/components/mission_planner.tsx diff --git a/telemetry/README.md b/telemetry/README.md index fe0b66e..e0a7635 100644 --- a/telemetry/README.md +++ b/telemetry/README.md @@ -63,6 +63,39 @@ Companion states are: - `degraded` - one or more required inputs are unavailable or outside the configured limits. - `failsafe` - a critical battery or temperature condition exists, or telemetry became stale while the aircraft was armed. This is a reported state; Pixhawk remains responsible for flight stabilization and configured vehicle failsafes. +## Companion Search Mission Planner + +The Raspberry Pi service exposes a planning API on port `8765`. The dashboard's +**Mission planner** view lets an operator select a search centre on the offline +coordinate plot, enter a radius and altitude, and preview the complete route. + +The planner creates four cells per kilometre of search radius: + +- `1 km` radius → `4` cells +- `2 km` radius → `8` cells +- `3 km` radius → `12` cells + +Cells are visited in a lawnmower pattern. The generated mission contains home, +takeoff, every search-cell centre, a return waypoint, and landing. Radius is +limited to `0.25–10 km` and altitude to `5–120 m`. + +Start the Raspberry Pi simulator and API: + +```bash +python3 main.py --simulate --no-radio --companion-mode assist --companion-api-port 8765 +``` + +Then enter `http://:8765` in the dashboard's companion API +field. These endpoints are available: + +- `GET /api/health` - reports connection and simulation state. +- `POST /api/mission/plan` - validates inputs and returns the generated plan. +- `POST /api/mission/simulate` - loads and starts the plan only when the Pi + service was started with `--simulate`. + +Live Pixhawk mission execution remains locked. The API returns HTTP `403` for a +live execution request; planning and preview continue to work. + ## Running the Web Dashboard From `telemetry/live-web-dashboard` on the laptop or static-host machine: diff --git a/telemetry/handheld-nrf-monitor/pi-code/companion_api.py b/telemetry/handheld-nrf-monitor/pi-code/companion_api.py new file mode 100644 index 0000000..df40064 --- /dev/null +++ b/telemetry/handheld-nrf-monitor/pi-code/companion_api.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""HTTP API for mission preview and simulation-only execution.""" + +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import threading +from typing import Any, Dict, Optional +from urllib.parse import urlparse + +from search_mission_planner import build_search_mission + + +MAX_REQUEST_BYTES = 64 * 1024 + + +class CompanionAPIServer: + def __init__(self, mav_manager, host: str = "0.0.0.0", port: int = 8765): + self.mav_manager = mav_manager + self.host = host + self.port = port + self.server: Optional[ThreadingHTTPServer] = None + self.thread: Optional[threading.Thread] = None + + def start(self) -> None: + if self.server: + return + handler = _handler_for(self.mav_manager) + self.server = ThreadingHTTPServer((self.host, self.port), handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + bound_port = self.server.server_address[1] + print(f"[OK] Companion API: http://{self.host}:{bound_port} (live execution locked)") + + def stop(self) -> None: + if not self.server: + return + self.server.shutdown() + self.server.server_close() + if self.thread and self.thread.is_alive(): + self.thread.join(timeout=1.0) + self.server = None + self.thread = None + + +def _handler_for(mav_manager): + mission_lock = threading.Lock() + + class CompanionRequestHandler(BaseHTTPRequestHandler): + def do_OPTIONS(self) -> None: + self.send_response(204) + self._cors_headers() + self.end_headers() + + def do_GET(self) -> None: + path = urlparse(self.path).path + if path == "/api/health": + telemetry = mav_manager.get_telemetry_snapshot() + self._send_json(200, { + "status": "ok", + "simulation": bool(mav_manager.simulate), + "connected": bool(telemetry.get("connected", False)), + "live_execution_enabled": False, + }) + return + self._send_json(404, {"error": "endpoint not found"}) + + def do_POST(self) -> None: + path = urlparse(self.path).path + if path not in {"/api/mission/plan", "/api/mission/simulate"}: + self._send_json(404, {"error": "endpoint not found"}) + return + try: + payload = self._read_json() + plan = _plan_from_payload(payload, mav_manager.get_telemetry_snapshot()) + except (ValueError, UnicodeError, json.JSONDecodeError) as exc: + self._send_json(400, {"error": str(exc)}) + return + + if path == "/api/mission/plan": + self._send_json(200, {"status": "planned", "plan": plan.to_dict()}) + return + + if not mav_manager.simulate: + self._send_json(403, { + "error": "live Pixhawk execution is locked; validate this mission in simulation first" + }) + return + + if not mission_lock.acquire(blocking=False): + self._send_json(409, {"error": "another mission operation is in progress"}) + return + try: + if not mav_manager.clear_all_missions(): + self._send_json(500, {"error": "could not clear the simulated mission"}) + return + if not mav_manager.upload_mission_items(list(plan.mission_items)): + self._send_json(500, {"error": "could not load the simulated mission"}) + return + if not mav_manager.set_arm(True) or not mav_manager.set_flight_mode("AUTO"): + self._send_json(500, {"error": "could not start the simulated mission"}) + return + self._send_json(202, {"status": "simulation_started", "plan": plan.to_dict()}) + finally: + mission_lock.release() + + def _read_json(self) -> Dict[str, Any]: + try: + content_length = int(self.headers.get("Content-Length", "0")) + except ValueError as exc: + raise ValueError("invalid Content-Length") from exc + if content_length <= 0 or content_length > MAX_REQUEST_BYTES: + raise ValueError("request body must be between 1 byte and 64 KiB") + body = self.rfile.read(content_length) + payload = json.loads(body.decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("request body must be a JSON object") + return payload + + def _send_json(self, status: int, payload: Dict[str, Any]) -> None: + body = json.dumps(payload, allow_nan=False).encode("utf-8") + self.send_response(status) + self._cors_headers() + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _cors_headers(self) -> None: + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + + def log_message(self, format_string: str, *args) -> None: + print(f"[companion-api] {self.address_string()} {format_string % args}") + + return CompanionRequestHandler + + +def _plan_from_payload(payload: Dict[str, Any], telemetry: Dict[str, Any]): + center = payload.get("center") + if not isinstance(center, dict): + raise ValueError("center must contain latitude and longitude") + + home_latitude = telemetry.get("latitude") + home_longitude = telemetry.get("longitude") + if not _valid_position(home_latitude, home_longitude): + home_latitude = center.get("latitude") + home_longitude = center.get("longitude") + + return build_search_mission( + center_latitude=center.get("latitude"), + center_longitude=center.get("longitude"), + radius_km=payload.get("radius_km"), + altitude_m=payload.get("altitude_m"), + home_latitude=home_latitude, + home_longitude=home_longitude, + ) + + +def _valid_position(latitude: Any, longitude: Any) -> bool: + try: + return -90 <= float(latitude) <= 90 and -180 <= float(longitude) <= 180 and not ( + float(latitude) == 0 and float(longitude) == 0 + ) + except (TypeError, ValueError): + return False diff --git a/telemetry/handheld-nrf-monitor/pi-code/main.py b/telemetry/handheld-nrf-monitor/pi-code/main.py index 16d517a..b48e5b0 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/main.py +++ b/telemetry/handheld-nrf-monitor/pi-code/main.py @@ -20,6 +20,7 @@ from mavlink_manager import MAVLinkManager from radio_tx_module import RadioTXModule from companion_mode import CompanionModeController, RequestedCompanionMode +from companion_api import CompanionAPIServer def main(): parser = argparse.ArgumentParser(description="Drone Telemetry Pi Relay") @@ -34,6 +35,8 @@ def main(): default=RequestedCompanionMode.MONITOR.value, help="Companion state evaluation mode; assist only checks readiness and never sends commands", ) + parser.add_argument("--companion-api-port", type=int, default=8765, help="Mission planning API port") + parser.add_argument("--no-companion-api", action="store_true", help="Disable the mission planning API") args = parser.parse_args() print("=" * 76) @@ -43,7 +46,7 @@ def main(): print("[*] Web Dashboard: laptop/static host reads ESP32 Wi-Fi JSON") print(f"[*] Radio TX Broadcast Rate: {args.radio_rate} Hz ({'DISABLED' if args.no_radio else 'ENABLED'})") print(f"[*] Operation Mode: {'SIMULATION' if args.simulate else 'LIVE PIXHAWK'}") - print(f"[*] Companion Mode: {args.companion_mode.upper()} (command output disabled)") + print(f"[*] Companion Mode: {args.companion_mode.upper()} (live command output disabled)") print("----------------------------------------------------------------------------") # 1. SBC Monitor @@ -56,6 +59,10 @@ def main(): mav_manager = MAVLinkManager(port=args.port, baud=args.baud, simulate=args.simulate) mav_manager.start() + companion_api = CompanionAPIServer(mav_manager, port=args.companion_api_port) + if not args.no_companion_api: + companion_api.start() + # 3. NRF24 Radio Transmitter radio_module = RadioTXModule( mav_manager=mav_manager, @@ -76,6 +83,7 @@ def main(): # Graceful exit handler def shutdown(signum, frame): print("\n[*] Stopping telemetry relay...") + companion_api.stop() radio_module.stop() mav_manager.stop() print("[OK] All modules stopped cleanly.") diff --git a/telemetry/handheld-nrf-monitor/pi-code/search_mission_planner.py b/telemetry/handheld-nrf-monitor/pi-code/search_mission_planner.py new file mode 100644 index 0000000..b103d51 --- /dev/null +++ b/telemetry/handheld-nrf-monitor/pi-code/search_mission_planner.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Generate deterministic square-grid search missions. + +The planner is independent from MAVLink transport. It converts a selected +centre point and search radius into cells and a lawnmower route that can be +previewed, tested in SITL, and later passed through a guarded command gateway. +""" + +from dataclasses import asdict, dataclass +import math +from typing import Any, Dict, List, Optional, Tuple + + +METRES_PER_LATITUDE_DEGREE = 111_320.0 + + +@dataclass(frozen=True) +class SearchCell: + cell_id: int + row: int + column: int + north_m: float + east_m: float + south_m: float + west_m: float + center_latitude: float + center_longitude: float + + +@dataclass(frozen=True) +class SearchMissionPlan: + center_latitude: float + center_longitude: float + home_latitude: float + home_longitude: float + radius_km: float + altitude_m: float + cell_count: int + rows: int + columns: int + cells: Tuple[SearchCell, ...] + route_cell_ids: Tuple[int, ...] + mission_items: Tuple[Dict[str, Any], ...] + + def to_dict(self) -> Dict[str, Any]: + return { + "center": {"latitude": self.center_latitude, "longitude": self.center_longitude}, + "home": {"latitude": self.home_latitude, "longitude": self.home_longitude}, + "radius_km": self.radius_km, + "altitude_m": self.altitude_m, + "cell_count": self.cell_count, + "rows": self.rows, + "columns": self.columns, + "cells": [asdict(cell) for cell in self.cells], + "route_cell_ids": list(self.route_cell_ids), + "phases": ["takeoff", "grid_search", "return_home", "land"], + "mission_items": list(self.mission_items), + } + + +def build_search_mission( + center_latitude: float, + center_longitude: float, + radius_km: float, + altitude_m: float, + home_latitude: Optional[float] = None, + home_longitude: Optional[float] = None, +) -> SearchMissionPlan: + """Build a square search divided into four cells per kilometre of radius.""" + center_latitude = _bounded_number("center_latitude", center_latitude, -90.0, 90.0) + center_longitude = _bounded_number("center_longitude", center_longitude, -180.0, 180.0) + radius_km = _bounded_number("radius_km", radius_km, 0.25, 10.0) + altitude_m = _bounded_number("altitude_m", altitude_m, 5.0, 120.0) + home_latitude = center_latitude if home_latitude is None else _bounded_number( + "home_latitude", home_latitude, -90.0, 90.0 + ) + home_longitude = center_longitude if home_longitude is None else _bounded_number( + "home_longitude", home_longitude, -180.0, 180.0 + ) + + cell_count = max(1, int(math.ceil(radius_km * 4.0))) + rows, columns = _closest_factors(cell_count) + radius_m = radius_km * 1000.0 + cell_height_m = (radius_m * 2.0) / rows + cell_width_m = (radius_m * 2.0) / columns + + cells: List[SearchCell] = [] + for row in range(rows): + north_m = radius_m - row * cell_height_m + south_m = north_m - cell_height_m + for column in range(columns): + cell_id = row * columns + column + 1 + east_m = -radius_m + (column + 1) * cell_width_m + west_m = east_m - cell_width_m + center_north_m = (north_m + south_m) / 2.0 + center_east_m = (east_m + west_m) / 2.0 + latitude, longitude = _offset_coordinate( + center_latitude, + center_longitude, + center_north_m, + center_east_m, + ) + cells.append(SearchCell( + cell_id=cell_id, + row=row, + column=column, + north_m=round(north_m, 2), + east_m=round(east_m, 2), + south_m=round(south_m, 2), + west_m=round(west_m, 2), + center_latitude=round(latitude, 7), + center_longitude=round(longitude, 7), + )) + + route_cells: List[SearchCell] = [] + for row in range(rows): + row_cells = [cell for cell in cells if cell.row == row] + route_cells.extend(row_cells if row % 2 == 0 else reversed(row_cells)) + + mission_items: List[Dict[str, Any]] = [ + _mission_item(0, 16, home_latitude, home_longitude, 0.0, current=1), + _mission_item(1, 22, home_latitude, home_longitude, altitude_m), + ] + for cell in route_cells: + mission_items.append(_mission_item( + len(mission_items), + 16, + cell.center_latitude, + cell.center_longitude, + altitude_m, + )) + mission_items.append(_mission_item( + len(mission_items), + 16, + home_latitude, + home_longitude, + altitude_m, + )) + mission_items.append(_mission_item( + len(mission_items), + 21, + home_latitude, + home_longitude, + 0.0, + )) + + return SearchMissionPlan( + center_latitude=center_latitude, + center_longitude=center_longitude, + home_latitude=home_latitude, + home_longitude=home_longitude, + radius_km=radius_km, + altitude_m=altitude_m, + cell_count=cell_count, + rows=rows, + columns=columns, + cells=tuple(cells), + route_cell_ids=tuple(cell.cell_id for cell in route_cells), + mission_items=tuple(mission_items), + ) + + +def _closest_factors(value: int) -> Tuple[int, int]: + rows = int(math.sqrt(value)) + while rows > 1 and value % rows != 0: + rows -= 1 + return rows, value // rows + + +def _offset_coordinate(latitude: float, longitude: float, north_m: float, east_m: float) -> Tuple[float, float]: + latitude_offset = north_m / METRES_PER_LATITUDE_DEGREE + longitude_scale = METRES_PER_LATITUDE_DEGREE * max(0.01, math.cos(math.radians(latitude))) + longitude_offset = east_m / longitude_scale + return latitude + latitude_offset, longitude + longitude_offset + + +def _mission_item( + seq: int, + command: int, + latitude: float, + longitude: float, + altitude_m: float, + current: int = 0, +) -> Dict[str, Any]: + return { + "seq": seq, + "frame": 6, + "command": command, + "current": current, + "autocontinue": 1, + "param1": 0.0, + "param2": 2.0, + "param3": 0.0, + "param4": 0.0, + "x": int(round(latitude * 1e7)), + "y": int(round(longitude * 1e7)), + "z": altitude_m, + } + + +def _bounded_number(name: str, value: Any, minimum: float, maximum: float) -> float: + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be a number") from exc + if not math.isfinite(number) or number < minimum or number > maximum: + raise ValueError(f"{name} must be between {minimum:g} and {maximum:g}") + return number diff --git a/telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_api.py b/telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_api.py new file mode 100644 index 0000000..7096c03 --- /dev/null +++ b/telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_api.py @@ -0,0 +1,94 @@ +import json +import pathlib +import sys +import unittest +from urllib.error import HTTPError +from urllib.request import Request, urlopen + + +PI_CODE = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PI_CODE)) + +from companion_api import CompanionAPIServer # noqa: E402 + + +class FakeMAVLinkManager: + def __init__(self, simulate: bool): + self.simulate = simulate + self.calls = [] + + def get_telemetry_snapshot(self): + return {"connected": True, "latitude": 12.9700, "longitude": 77.5900} + + def clear_all_missions(self): + self.calls.append("clear") + return True + + def upload_mission_items(self, items): + self.calls.append(("upload", len(items))) + return True + + def set_arm(self, arm): + self.calls.append(("arm", arm)) + return True + + def set_flight_mode(self, mode): + self.calls.append(("mode", mode)) + return True + + +class CompanionAPITests(unittest.TestCase): + def setUp(self): + self.payload = json.dumps({ + "center": {"latitude": 12.9716, "longitude": 77.5946}, + "radius_km": 1, + "altitude_m": 30, + }).encode("utf-8") + + def _start(self, simulate): + manager = FakeMAVLinkManager(simulate) + server = CompanionAPIServer(manager, host="127.0.0.1", port=0) + server.start() + self.addCleanup(server.stop) + return manager, server.server.server_address[1] + + def _post(self, port, path): + request = Request( + f"http://127.0.0.1:{port}{path}", + data=self.payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + return urlopen(request, timeout=2) + + def test_plan_endpoint_returns_generated_grid(self): + _, port = self._start(simulate=False) + + with self._post(port, "/api/mission/plan") as response: + body = json.load(response) + + self.assertEqual("planned", body["status"]) + self.assertEqual(4, body["plan"]["cell_count"]) + + def test_live_execution_is_locked(self): + manager, port = self._start(simulate=False) + + with self.assertRaises(HTTPError) as raised: + self._post(port, "/api/mission/simulate") + + self.assertEqual(403, raised.exception.code) + self.assertEqual([], manager.calls) + + def test_simulation_execution_loads_and_starts_mission(self): + manager, port = self._start(simulate=True) + + with self._post(port, "/api/mission/simulate") as response: + body = json.load(response) + + self.assertEqual(202, response.status) + self.assertEqual("simulation_started", body["status"]) + self.assertEqual(["clear", ("upload", 8), ("arm", True), ("mode", "AUTO")], manager.calls) + + +if __name__ == "__main__": + unittest.main() diff --git a/telemetry/handheld-nrf-monitor/pi-code/tests/test_search_mission_planner.py b/telemetry/handheld-nrf-monitor/pi-code/tests/test_search_mission_planner.py new file mode 100644 index 0000000..294e689 --- /dev/null +++ b/telemetry/handheld-nrf-monitor/pi-code/tests/test_search_mission_planner.py @@ -0,0 +1,51 @@ +import pathlib +import sys +import unittest + + +PI_CODE = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PI_CODE)) + +from search_mission_planner import build_search_mission # noqa: E402 + + +class SearchMissionPlannerTests(unittest.TestCase): + def test_one_kilometre_radius_creates_four_cells(self): + plan = build_search_mission(12.9716, 77.5946, 1, 30) + + self.assertEqual(4, plan.cell_count) + self.assertEqual((2, 2), (plan.rows, plan.columns)) + self.assertEqual((1, 2, 4, 3), plan.route_cell_ids) + + def test_two_kilometre_radius_creates_eight_cells(self): + plan = build_search_mission(12.9716, 77.5946, 2, 40) + + self.assertEqual(8, plan.cell_count) + self.assertEqual((2, 4), (plan.rows, plan.columns)) + self.assertEqual((1, 2, 3, 4, 8, 7, 6, 5), plan.route_cell_ids) + + def test_mission_contains_takeoff_search_return_and_land(self): + plan = build_search_mission(12.9716, 77.5946, 1, 30, 12.9700, 77.5900) + commands = [item["command"] for item in plan.mission_items] + + self.assertEqual(16, commands[0]) + self.assertEqual(22, commands[1]) + self.assertEqual([16] * 4, commands[2:6]) + self.assertEqual(16, commands[-2]) + self.assertEqual(21, commands[-1]) + + def test_rejects_unsafe_or_invalid_ranges(self): + invalid_values = [ + (91, 77, 1, 30), + (12, 181, 1, 30), + (12, 77, 0.1, 30), + (12, 77, 1, 121), + ] + for values in invalid_values: + with self.subTest(values=values): + with self.assertRaises(ValueError): + build_search_mission(*values) + + +if __name__ == "__main__": + unittest.main() diff --git a/telemetry/live-web-dashboard/src/app.tsx b/telemetry/live-web-dashboard/src/app.tsx index 7aea23f..97eb62d 100644 --- a/telemetry/live-web-dashboard/src/app.tsx +++ b/telemetry/live-web-dashboard/src/app.tsx @@ -6,6 +6,7 @@ import { BatteryBox } from "./components/battery_box"; import { FlightStatusCard } from "./components/flight_status_card"; import { LinkCard } from "./components/link_card"; import { NavSignalBox } from "./components/nav_signal_box"; +import { MissionPlanner } from "./components/mission_planner"; import { SbcStatusCard } from "./components/sbc_status_card"; import { TopBar } from "./components/top_bar"; import { gyroSeries, motorSeries, pidSeries, vehicle } from "./data/telemetry"; @@ -139,6 +140,7 @@ export function App({ streaming = true }: { streaming?: boolean }) { const [endpoint, setEndpoint] = useState(""); const [connectError, setConnectError] = useState(""); const [now, setNow] = useState(Date.now()); + const [view, setView] = useState<"telemetry" | "mission">("telemetry"); const t = useTelemetry(live, endpoint, cellCount); const warnings = activeWarnings(t); const hasTelemetry = t.source.lastUpdated > 0; @@ -189,6 +191,19 @@ export function App({ streaming = true }: { streaming?: boolean }) { warnings={warnings} /> + + + {view === "mission" ? ( + + ) : ( +
{warnings.length > 0 ? (
+ )} ); } diff --git a/telemetry/live-web-dashboard/src/components/mission_planner.tsx b/telemetry/live-web-dashboard/src/components/mission_planner.tsx new file mode 100644 index 0000000..e40bb8f --- /dev/null +++ b/telemetry/live-web-dashboard/src/components/mission_planner.tsx @@ -0,0 +1,342 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { CrosshairIcon, MapPinnedIcon, PlayIcon, RouteIcon, ShieldCheckIcon } from "lucide-react"; + +interface MissionCell { + cell_id: number; + row: number; + column: number; + center_latitude: number; + center_longitude: number; +} + +interface MissionPlan { + center: { latitude: number; longitude: number }; + home: { latitude: number; longitude: number }; + radius_km: number; + altitude_m: number; + cell_count: number; + rows: number; + columns: number; + cells: MissionCell[]; + route_cell_ids: number[]; + phases: string[]; + mission_items: Array>; +} + +interface MissionResponse { + status: string; + plan: MissionPlan; +} + +const API_STORAGE_KEY = "droneCompanionApi"; +const VIEW_WIDTH = 720; +const VIEW_HEIGHT = 420; + +function isPosition(latitude: number, longitude: number): boolean { + return Number.isFinite(latitude) && Number.isFinite(longitude) && + latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180 && + !(latitude === 0 && longitude === 0); +} + +function normalizeApi(value: string): string { + const trimmed = value.trim(); + if (!trimmed) throw new Error("Enter the Raspberry Pi companion API address."); + const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; + const url = new URL(withProtocol); + if (!/^https?:$/.test(url.protocol)) throw new Error("Use an HTTP or HTTPS companion API address."); + return url.origin; +} + +function factors(value: number): [number, number] { + let rows = Math.floor(Math.sqrt(value)); + while (rows > 1 && value % rows !== 0) rows -= 1; + return [rows, value / rows]; +} + +function routeIds(rows: number, columns: number): number[] { + return Array.from({ length: rows }, (_, row) => { + const ids = Array.from({ length: columns }, (_, column) => row * columns + column + 1); + return row % 2 === 0 ? ids : ids.reverse(); + }).flat(); +} + +function coordinateOffset( + originLatitude: number, + originLongitude: number, + northMetres: number, + eastMetres: number +): [number, number] { + const latitude = originLatitude + northMetres / 111_320; + const longitudeScale = 111_320 * Math.max(0.01, Math.cos(originLatitude * Math.PI / 180)); + return [latitude, originLongitude + eastMetres / longitudeScale]; +} + +export function MissionPlanner({ + currentLatitude, + currentLongitude, + telemetryAvailable +}: { + currentLatitude: number; + currentLongitude: number; + telemetryAvailable: boolean; +}) { + const [apiInput, setApiInput] = useState(() => window.localStorage.getItem(API_STORAGE_KEY) ?? "localhost:8765"); + const [centerLatitude, setCenterLatitude] = useState(""); + const [centerLongitude, setCenterLongitude] = useState(""); + const [radiusKm, setRadiusKm] = useState(1); + const [altitudeM, setAltitudeM] = useState(30); + const [plan, setPlan] = useState(null); + const [requestState, setRequestState] = useState<"idle" | "planning" | "ready" | "starting" | "running" | "error">("idle"); + const [message, setMessage] = useState("Select a point on the plot or enter coordinates."); + const initializedFromTelemetry = useRef(false); + + const currentPositionValid = telemetryAvailable && isPosition(currentLatitude, currentLongitude); + useEffect(() => { + if (!initializedFromTelemetry.current && currentPositionValid) { + setCenterLatitude(currentLatitude.toFixed(7)); + setCenterLongitude(currentLongitude.toFixed(7)); + initializedFromTelemetry.current = true; + } + }, [currentLatitude, currentLongitude, currentPositionValid]); + + const selectedLatitude = Number(centerLatitude); + const selectedLongitude = Number(centerLongitude); + const selectedPositionValid = isPosition(selectedLatitude, selectedLongitude); + const estimatedCellCount = Math.max(1, Math.ceil(radiusKm * 4)); + const [estimatedRows, estimatedColumns] = useMemo(() => factors(estimatedCellCount), [estimatedCellCount]); + const displayedRows = plan?.rows ?? estimatedRows; + const displayedColumns = plan?.columns ?? estimatedColumns; + const displayedRoute = plan?.route_cell_ids ?? routeIds(displayedRows, displayedColumns); + + const plotOrigin = useMemo(() => currentPositionValid + ? { latitude: currentLatitude, longitude: currentLongitude } + : selectedPositionValid + ? { latitude: selectedLatitude, longitude: selectedLongitude } + : null, + [currentLatitude, currentLongitude, currentPositionValid, selectedLatitude, selectedLongitude, selectedPositionValid]); + const extentMetres = Math.max(1_000, radiusKm * 1_600); + + const selectedPlotPoint = useMemo(() => { + if (!plotOrigin || !selectedPositionValid) return null; + const north = (selectedLatitude - plotOrigin.latitude) * 111_320; + const east = (selectedLongitude - plotOrigin.longitude) * 111_320 * + Math.max(0.01, Math.cos(plotOrigin.latitude * Math.PI / 180)); + return { + x: VIEW_WIDTH / 2 + (east / extentMetres) * (VIEW_WIDTH / 2), + y: VIEW_HEIGHT / 2 - (north / extentMetres) * (VIEW_HEIGHT / 2) + }; + }, [extentMetres, plotOrigin, selectedLatitude, selectedLongitude, selectedPositionValid]); + + const searchBox = { + width: (radiusKm * 1_000 / extentMetres) * VIEW_WIDTH, + height: (radiusKm * 1_000 / extentMetres) * VIEW_HEIGHT + }; + + const selectPoint = (event: React.MouseEvent) => { + if (!plotOrigin) { + setRequestState("error"); + setMessage("Enter valid coordinates before selecting a point on the plot."); + return; + } + const bounds = event.currentTarget.getBoundingClientRect(); + const x = ((event.clientX - bounds.left) / bounds.width) * VIEW_WIDTH; + const y = ((event.clientY - bounds.top) / bounds.height) * VIEW_HEIGHT; + const east = ((x - VIEW_WIDTH / 2) / (VIEW_WIDTH / 2)) * extentMetres; + const north = ((VIEW_HEIGHT / 2 - y) / (VIEW_HEIGHT / 2)) * extentMetres; + const [latitude, longitude] = coordinateOffset(plotOrigin.latitude, plotOrigin.longitude, north, east); + setCenterLatitude(latitude.toFixed(7)); + setCenterLongitude(longitude.toFixed(7)); + setPlan(null); + setRequestState("idle"); + setMessage("Search centre selected. Generate the route to review every cell."); + }; + + const requestPlan = async (path: "/api/mission/plan" | "/api/mission/simulate") => { + if (!selectedPositionValid) { + setRequestState("error"); + setMessage("Enter a valid latitude and longitude."); + return; + } + const starting = path.endsWith("simulate"); + setRequestState(starting ? "starting" : "planning"); + setMessage(starting ? "Loading the mission into the simulator…" : "Generating the grid route on the Raspberry Pi…"); + try { + const api = normalizeApi(apiInput); + window.localStorage.setItem(API_STORAGE_KEY, apiInput.trim()); + const response = await fetch(`${api}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + center: { latitude: selectedLatitude, longitude: selectedLongitude }, + radius_km: radiusKm, + altitude_m: altitudeM + }) + }); + const body = await response.json() as MissionResponse | { error?: string }; + if (!response.ok || !("plan" in body)) { + throw new Error("error" in body && body.error ? body.error : `Companion API returned ${response.status}`); + } + setPlan(body.plan); + setRequestState(starting ? "running" : "ready"); + setMessage(starting + ? "Simulation started. Follow mission progress in the telemetry view." + : `${body.plan.cell_count} cells planned. Review the route before starting the simulator.`); + } catch (error) { + setRequestState("error"); + setMessage(error instanceof Error ? error.message : "The Raspberry Pi companion API could not be reached."); + } + }; + + const boxLeft = (selectedPlotPoint?.x ?? VIEW_WIDTH / 2) - searchBox.width / 2; + const boxTop = (selectedPlotPoint?.y ?? VIEW_HEIGHT / 2) - searchBox.height / 2; + const cellWidth = searchBox.width / displayedColumns; + const cellHeight = searchBox.height / displayedRows; + const routePoints = displayedRoute.map((cellId) => { + const index = cellId - 1; + const row = Math.floor(index / displayedColumns); + const column = index % displayedColumns; + return `${boxLeft + column * cellWidth + cellWidth / 2},${boxTop + row * cellHeight + cellHeight / 2}`; + }).join(" "); + + return ( +
+
+
+

Companion search planner

+

+ Choose the search centre, set radius and altitude, then inspect the complete takeoff, grid search, return and landing route. +

+
+
+ + Simulation execution only · live Pixhawk control locked +
+
+ +
+
+
+ Search area + click plot to place centre · N ↑ +
+ + + + + + + + + + + {selectedPlotPoint ? ( + + + {Array.from({ length: displayedColumns - 1 }, (_, index) => ( + + ))} + {Array.from({ length: displayedRows - 1 }, (_, index) => ( + + ))} + + {displayedRoute.map((cellId) => { + const index = cellId - 1; + const row = Math.floor(index / displayedColumns); + const column = index % displayedColumns; + return ; + })} + + + ) : null} + + {currentPositionValid ? ( + + + + DRONE / HOME + + ) : null} + +
+ {plotOrigin ? `${plotOrigin.latitude.toFixed(6)}, ${plotOrigin.longitude.toFixed(6)}` : "Waiting for a coordinate origin"} + plot span ±{(extentMetres / 1000).toFixed(1)} km +
+
+ + +
+
+ ); +} From 1a01fab9be00fe0c8f616a0a05e452240e4a073f Mon Sep 17 00:00:00 2001 From: TheLostLeo Date: Tue, 13 Oct 2026 15:15:47 +0530 Subject: [PATCH 07/12] feat(telemetry): make mission planner directly accessible Signed-off-by: TheLostLeo --- telemetry/README.md | 6 ++++ telemetry/live-web-dashboard/src/app.tsx | 38 ++++++++++++++++++++---- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/telemetry/README.md b/telemetry/README.md index e0a7635..2eb8b99 100644 --- a/telemetry/README.md +++ b/telemetry/README.md @@ -96,6 +96,12 @@ field. These endpoints are available: Live Pixhawk mission execution remains locked. The API returns HTTP `403` for a live execution request; planning and preview continue to work. +Open the planner directly without connecting an ESP32 first: + +```text +http://localhost:8080/?view=mission +``` + ## Running the Web Dashboard From `telemetry/live-web-dashboard` on the laptop or static-host machine: diff --git a/telemetry/live-web-dashboard/src/app.tsx b/telemetry/live-web-dashboard/src/app.tsx index 97eb62d..93e6ca3 100644 --- a/telemetry/live-web-dashboard/src/app.tsx +++ b/telemetry/live-web-dashboard/src/app.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense, useEffect, useState } from "react"; -import { GaugeIcon, PlugZapIcon, SlidersHorizontalIcon, WavesIcon, WifiIcon } from "lucide-react"; +import { GaugeIcon, MapPinnedIcon, PlugZapIcon, SlidersHorizontalIcon, WavesIcon, WifiIcon } from "lucide-react"; import { AltitudeCard } from "./components/altitude_card"; import { AttitudeIndicator } from "./components/attitude_indicator"; import { BatteryBox } from "./components/battery_box"; @@ -57,7 +57,8 @@ function ConnectionPanel({ error, onChange, onCellCountChange, - onSubmit + onSubmit, + onOpenMission }: { value: string; cellCount: number; @@ -65,6 +66,7 @@ function ConnectionPanel({ onChange: (value: string) => void; onCellCountChange: (value: number) => void; onSubmit: () => void; + onOpenMission: () => void; }) { return (
@@ -123,6 +125,15 @@ function ConnectionPanel({ {error ?

{error}

: null} + +
Example: 10.160.142.17
Local: http://<esp-ip>/telemetry.json
@@ -133,6 +144,11 @@ function ConnectionPanel({ ); } +function initialView(): "telemetry" | "mission" { + const query = new URLSearchParams(window.location.search); + return query.get("view") === "mission" || query.get("preview") === "mission" ? "mission" : "telemetry"; +} + export function App({ streaming = true }: { streaming?: boolean }) { const [live, setLive] = useState(streaming); const [espInput, setEspInput] = useState(initialEspInput); @@ -140,7 +156,7 @@ export function App({ streaming = true }: { streaming?: boolean }) { const [endpoint, setEndpoint] = useState(""); const [connectError, setConnectError] = useState(""); const [now, setNow] = useState(Date.now()); - const [view, setView] = useState<"telemetry" | "mission">("telemetry"); + const [view, setView] = useState<"telemetry" | "mission">(initialView); const t = useTelemetry(live, endpoint, cellCount); const warnings = activeWarnings(t); const hasTelemetry = t.source.lastUpdated > 0; @@ -167,7 +183,16 @@ export function App({ streaming = true }: { streaming?: boolean }) { } }; - if (endpoint.length === 0) { + const changeView = (nextView: "telemetry" | "mission") => { + setView(nextView); + const url = new URL(window.location.href); + url.searchParams.delete("preview"); + if (nextView === "mission") url.searchParams.set("view", "mission"); + else url.searchParams.delete("view"); + window.history.replaceState(null, "", url); + }; + + if (endpoint.length === 0 && view === "telemetry") { return ( changeView("mission")} /> ); } @@ -192,8 +218,8 @@ export function App({ streaming = true }: { streaming?: boolean }) { /> {view === "mission" ? ( From 675bdfe215a2464e303b0a4f0c2cb202395e6edb Mon Sep 17 00:00:00 2001 From: TheLostLeo Date: Tue, 13 Oct 2026 15:36:32 +0530 Subject: [PATCH 08/12] feat(telemetry): add camera coverage survey planning Signed-off-by: TheLostLeo --- .../pi-code/companion_api.py | 26 +- .../pi-code/mavlink_manager.py | 2 +- .../pi-code/search_mission_planner.py | 271 ++++++++++-- .../pi-code/terrain_provider.py | 66 +++ .../pi-code/tests/test_companion_api.py | 10 +- .../tests/test_search_mission_planner.py | 31 +- telemetry/live-web-dashboard/README.md | 38 ++ .../live-web-dashboard/package-lock.json | 228 ++++++++++ telemetry/live-web-dashboard/package.json | 3 +- .../src/components/mission_planner.tsx | 391 +++++++----------- .../src/components/terrain_mission_map.tsx | 247 +++++++++++ telemetry/live-web-dashboard/src/styles.css | 22 + .../live-web-dashboard/src/types/assets.d.ts | 4 + 13 files changed, 1052 insertions(+), 287 deletions(-) create mode 100644 telemetry/handheld-nrf-monitor/pi-code/terrain_provider.py create mode 100644 telemetry/live-web-dashboard/src/components/terrain_mission_map.tsx create mode 100644 telemetry/live-web-dashboard/src/types/assets.d.ts diff --git a/telemetry/handheld-nrf-monitor/pi-code/companion_api.py b/telemetry/handheld-nrf-monitor/pi-code/companion_api.py index df40064..1ea3fa9 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/companion_api.py +++ b/telemetry/handheld-nrf-monitor/pi-code/companion_api.py @@ -8,6 +8,7 @@ from urllib.parse import urlparse from search_mission_planner import build_search_mission +from terrain_provider import sample_plan_terrain MAX_REQUEST_BYTES = 64 * 1024 @@ -72,12 +73,19 @@ def do_POST(self) -> None: try: payload = self._read_json() plan = _plan_from_payload(payload, mav_manager.get_telemetry_snapshot()) + terrain = ( + sample_plan_terrain(plan, str(payload.get("surface_type", "water"))) + if payload.get("terrain_enabled", False) + else {"status": "disabled", "used_for_clearance": False} + ) except (ValueError, UnicodeError, json.JSONDecodeError) as exc: self._send_json(400, {"error": str(exc)}) return if path == "/api/mission/plan": - self._send_json(200, {"status": "planned", "plan": plan.to_dict()}) + plan_payload = plan.to_dict() + plan_payload["terrain"] = terrain + self._send_json(200, {"status": "planned", "plan": plan_payload}) return if not mav_manager.simulate: @@ -86,6 +94,13 @@ def do_POST(self) -> None: }) return + if not plan.estimate.simulation_ready: + self._send_json(422, { + "error": "mission exceeds single-sortie limits", + "reasons": list(plan.estimate.blocking_reasons), + }) + return + if not mission_lock.acquire(blocking=False): self._send_json(409, {"error": "another mission operation is in progress"}) return @@ -99,7 +114,9 @@ def do_POST(self) -> None: if not mav_manager.set_arm(True) or not mav_manager.set_flight_mode("AUTO"): self._send_json(500, {"error": "could not start the simulated mission"}) return - self._send_json(202, {"status": "simulation_started", "plan": plan.to_dict()}) + plan_payload = plan.to_dict() + plan_payload["terrain"] = terrain + self._send_json(202, {"status": "simulation_started", "plan": plan_payload}) finally: mission_lock.release() @@ -154,6 +171,11 @@ def _plan_from_payload(payload: Dict[str, Any], telemetry: Dict[str, Any]): altitude_m=payload.get("altitude_m"), home_latitude=home_latitude, home_longitude=home_longitude, + camera_horizontal_fov_deg=payload.get("camera_horizontal_fov_deg", 78.0), + camera_vertical_fov_deg=payload.get("camera_vertical_fov_deg", 60.0), + side_overlap_percent=payload.get("side_overlap_percent", 70.0), + forward_overlap_percent=payload.get("forward_overlap_percent", 75.0), + cruise_speed_m_s=payload.get("cruise_speed_m_s", 3.0), ) diff --git a/telemetry/handheld-nrf-monitor/pi-code/mavlink_manager.py b/telemetry/handheld-nrf-monitor/pi-code/mavlink_manager.py index a8bfc1d..4353c19 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/mavlink_manager.py +++ b/telemetry/handheld-nrf-monitor/pi-code/mavlink_manager.py @@ -770,7 +770,7 @@ def _run_simulation(self): mission_state = f"CRUISE -> WP 1" # 2. Speed Command (MAV_CMD_DO_CHANGE_SPEED = 178) - elif cmd == 178: + elif cmd in (178, 206): self.sim_mission_index += 1 # 3. Waypoint Command (MAV_CMD_NAV_WAYPOINT = 16) diff --git a/telemetry/handheld-nrf-monitor/pi-code/search_mission_planner.py b/telemetry/handheld-nrf-monitor/pi-code/search_mission_planner.py index b103d51..66af839 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/search_mission_planner.py +++ b/telemetry/handheld-nrf-monitor/pi-code/search_mission_planner.py @@ -1,10 +1,5 @@ #!/usr/bin/env python3 -"""Generate deterministic square-grid search missions. - -The planner is independent from MAVLink transport. It converts a selected -centre point and search radius into cells and a lawnmower route that can be -previewed, tested in SITL, and later passed through a guarded command gateway. -""" +"""Generate camera-aware square-grid search missions for simulation review.""" from dataclasses import asdict, dataclass import math @@ -12,6 +7,13 @@ METRES_PER_LATITUDE_DEGREE = 111_320.0 +MAV_CMD_NAV_WAYPOINT = 16 +MAV_CMD_NAV_LAND = 21 +MAV_CMD_NAV_TAKEOFF = 22 +MAV_CMD_DO_SET_CAM_TRIGG_DIST = 206 +MAX_MISSION_ITEMS = 500 +MAX_SORTIE_MINUTES = 20.0 +MAX_SURVEY_LINES = 10_000 @dataclass(frozen=True) @@ -27,6 +29,43 @@ class SearchCell: center_longitude: float +@dataclass(frozen=True) +class SurveyLine: + cell_id: int + line_index: int + start_latitude: float + start_longitude: float + end_latitude: float + end_longitude: float + length_m: float + + +@dataclass(frozen=True) +class CameraCoverage: + horizontal_fov_deg: float + vertical_fov_deg: float + side_overlap_percent: float + forward_overlap_percent: float + footprint_width_m: float + footprint_height_m: float + line_spacing_m: float + trigger_distance_m: float + + +@dataclass(frozen=True) +class MissionEstimate: + survey_line_count: int + survey_distance_m: float + total_distance_m: float + estimated_minutes: float + estimated_photos: int + mission_item_count: int + recommended_sorties: int + simulation_ready: bool + blocking_reasons: Tuple[str, ...] + warnings: Tuple[str, ...] + + @dataclass(frozen=True) class SearchMissionPlan: center_latitude: float @@ -35,26 +74,40 @@ class SearchMissionPlan: home_longitude: float radius_km: float altitude_m: float + cruise_speed_m_s: float cell_count: int rows: int columns: int cells: Tuple[SearchCell, ...] route_cell_ids: Tuple[int, ...] + survey_lines: Tuple[SurveyLine, ...] + camera: CameraCoverage + estimate: MissionEstimate mission_items: Tuple[Dict[str, Any], ...] def to_dict(self) -> Dict[str, Any]: + browser_mission_items = list(self.mission_items) if self.estimate.simulation_ready else [] return { "center": {"latitude": self.center_latitude, "longitude": self.center_longitude}, "home": {"latitude": self.home_latitude, "longitude": self.home_longitude}, "radius_km": self.radius_km, "altitude_m": self.altitude_m, + "cruise_speed_m_s": self.cruise_speed_m_s, "cell_count": self.cell_count, "rows": self.rows, "columns": self.columns, "cells": [asdict(cell) for cell in self.cells], "route_cell_ids": list(self.route_cell_ids), - "phases": ["takeoff", "grid_search", "return_home", "land"], - "mission_items": list(self.mission_items), + "survey_lines": [asdict(line) for line in self.survey_lines], + "camera": asdict(self.camera), + "estimate": asdict(self.estimate), + "surface_tracking": { + "required": self.altitude_m <= 15.0, + "source": "downward_rangefinder", + "terrain_database_is_clearance_source": False, + }, + "phases": ["takeoff", "camera_grid_survey", "return_home", "land"], + "mission_items": browser_mission_items, } @@ -65,24 +118,59 @@ def build_search_mission( altitude_m: float, home_latitude: Optional[float] = None, home_longitude: Optional[float] = None, + camera_horizontal_fov_deg: float = 78.0, + camera_vertical_fov_deg: float = 60.0, + side_overlap_percent: float = 70.0, + forward_overlap_percent: float = 75.0, + cruise_speed_m_s: float = 3.0, ) -> SearchMissionPlan: - """Build a square search divided into four cells per kilometre of radius.""" - center_latitude = _bounded_number("center_latitude", center_latitude, -90.0, 90.0) + """Build a camera survey with parallel flight lines inside every cell.""" + center_latitude = _bounded_number("center_latitude", center_latitude, -85.0, 85.0) center_longitude = _bounded_number("center_longitude", center_longitude, -180.0, 180.0) - radius_km = _bounded_number("radius_km", radius_km, 0.25, 10.0) + radius_km = _bounded_number("radius_km", radius_km, 0.01, 10.0) altitude_m = _bounded_number("altitude_m", altitude_m, 5.0, 120.0) home_latitude = center_latitude if home_latitude is None else _bounded_number( - "home_latitude", home_latitude, -90.0, 90.0 + "home_latitude", home_latitude, -85.0, 85.0 ) home_longitude = center_longitude if home_longitude is None else _bounded_number( "home_longitude", home_longitude, -180.0, 180.0 ) + horizontal_fov = _bounded_number("camera_horizontal_fov_deg", camera_horizontal_fov_deg, 10.0, 170.0) + vertical_fov = _bounded_number("camera_vertical_fov_deg", camera_vertical_fov_deg, 10.0, 170.0) + side_overlap = _bounded_number("side_overlap_percent", side_overlap_percent, 0.0, 95.0) + forward_overlap = _bounded_number("forward_overlap_percent", forward_overlap_percent, 0.0, 95.0) + cruise_speed = _bounded_number("cruise_speed_m_s", cruise_speed_m_s, 0.5, 15.0) + + footprint_width = 2.0 * altitude_m * math.tan(math.radians(horizontal_fov / 2.0)) + footprint_height = 2.0 * altitude_m * math.tan(math.radians(vertical_fov / 2.0)) + line_spacing = footprint_height * (1.0 - side_overlap / 100.0) + trigger_distance = footprint_width * (1.0 - forward_overlap / 100.0) + if line_spacing < 0.25 or trigger_distance < 0.25: + raise ValueError("camera overlap creates spacing below the 0.25 m planning limit") + + camera = CameraCoverage( + horizontal_fov_deg=horizontal_fov, + vertical_fov_deg=vertical_fov, + side_overlap_percent=side_overlap, + forward_overlap_percent=forward_overlap, + footprint_width_m=round(footprint_width, 2), + footprint_height_m=round(footprint_height, 2), + line_spacing_m=round(line_spacing, 2), + trigger_distance_m=round(trigger_distance, 2), + ) cell_count = max(1, int(math.ceil(radius_km * 4.0))) rows, columns = _closest_factors(cell_count) radius_m = radius_km * 1000.0 cell_height_m = (radius_m * 2.0) / rows cell_width_m = (radius_m * 2.0) / columns + cell_line_count = max(1, int(math.ceil(cell_height_m / line_spacing))) + predicted_line_count = cell_count * cell_line_count + if predicted_line_count > MAX_SURVEY_LINES: + raise ValueError( + f"survey requires {predicted_line_count} flight lines; reduce the area or overlap, " + f"or increase altitude (planning limit {MAX_SURVEY_LINES} lines)" + ) cells: List[SearchCell] = [] for row in range(rows): @@ -92,13 +180,11 @@ def build_search_mission( cell_id = row * columns + column + 1 east_m = -radius_m + (column + 1) * cell_width_m west_m = east_m - cell_width_m - center_north_m = (north_m + south_m) / 2.0 - center_east_m = (east_m + west_m) / 2.0 latitude, longitude = _offset_coordinate( center_latitude, center_longitude, - center_north_m, - center_east_m, + (north_m + south_m) / 2.0, + (east_m + west_m) / 2.0, ) cells.append(SearchCell( cell_id=cell_id, @@ -117,31 +203,97 @@ def build_search_mission( row_cells = [cell for cell in cells if cell.row == row] route_cells.extend(row_cells if row % 2 == 0 else reversed(row_cells)) + survey_lines: List[SurveyLine] = [] + route_offsets: List[Tuple[float, float]] = [] + survey_distance_m = 0.0 + for cell_order, cell in enumerate(route_cells): + actual_spacing = cell_height_m / cell_line_count + for line_index in range(cell_line_count): + north_m = cell.north_m - actual_spacing * (line_index + 0.5) + forward = (cell_order + line_index) % 2 == 0 + start_east_m = cell.west_m if forward else cell.east_m + end_east_m = cell.east_m if forward else cell.west_m + start_latitude, start_longitude = _offset_coordinate( + center_latitude, center_longitude, north_m, start_east_m + ) + end_latitude, end_longitude = _offset_coordinate( + center_latitude, center_longitude, north_m, end_east_m + ) + survey_lines.append(SurveyLine( + cell_id=cell.cell_id, + line_index=line_index + 1, + start_latitude=round(start_latitude, 7), + start_longitude=round(start_longitude, 7), + end_latitude=round(end_latitude, 7), + end_longitude=round(end_longitude, 7), + length_m=round(cell_width_m, 2), + )) + route_offsets.extend([(north_m, start_east_m), (north_m, end_east_m)]) + survey_distance_m += cell_width_m + + home_offset = _coordinate_offset_metres( + center_latitude, center_longitude, home_latitude, home_longitude + ) + total_distance_m = _polyline_distance([home_offset] + route_offsets + [home_offset]) + mission_item_count = 6 + len(route_offsets) + estimated_minutes = (total_distance_m / cruise_speed + 120.0) / 60.0 + estimated_photos = int(math.ceil(survey_distance_m / trigger_distance)) + recommended_sorties = max( + 1, + int(math.ceil(mission_item_count / MAX_MISSION_ITEMS)), + int(math.ceil(estimated_minutes / MAX_SORTIE_MINUTES)), + ) + + blocking_reasons: List[str] = [] + if mission_item_count > MAX_MISSION_ITEMS: + blocking_reasons.append( + f"mission has {mission_item_count} items; split it below {MAX_MISSION_ITEMS} items per sortie" + ) + if estimated_minutes > MAX_SORTIE_MINUTES: + blocking_reasons.append( + f"estimated flight time is {estimated_minutes:.1f} minutes; split it below {MAX_SORTIE_MINUTES:.0f} minutes per sortie" + ) + warnings: List[str] = [] + if altitude_m <= 15.0: + warnings.append("low-altitude flight requires a healthy downward rangefinder; terrain elevation is not a clearance sensor") + if radius_km >= 1.0 and altitude_m <= 10.0: + warnings.append("this area is extremely large for low-altitude camera coverage") + + estimate = MissionEstimate( + survey_line_count=len(survey_lines), + survey_distance_m=round(survey_distance_m, 1), + total_distance_m=round(total_distance_m, 1), + estimated_minutes=round(estimated_minutes, 1), + estimated_photos=estimated_photos, + mission_item_count=mission_item_count, + recommended_sorties=recommended_sorties, + simulation_ready=not blocking_reasons, + blocking_reasons=tuple(blocking_reasons), + warnings=tuple(warnings), + ) + mission_items: List[Dict[str, Any]] = [ - _mission_item(0, 16, home_latitude, home_longitude, 0.0, current=1), - _mission_item(1, 22, home_latitude, home_longitude, altitude_m), + _navigation_item(0, MAV_CMD_NAV_WAYPOINT, home_latitude, home_longitude, 0.0, current=1), + _navigation_item(1, MAV_CMD_NAV_TAKEOFF, home_latitude, home_longitude, altitude_m), + _camera_trigger_item(2, trigger_distance), ] - for cell in route_cells: - mission_items.append(_mission_item( - len(mission_items), - 16, - cell.center_latitude, - cell.center_longitude, - altitude_m, + for line in survey_lines: + mission_items.append(_navigation_item( + len(mission_items), MAV_CMD_NAV_WAYPOINT, + line.start_latitude, line.start_longitude, altitude_m, + )) + mission_items.append(_navigation_item( + len(mission_items), MAV_CMD_NAV_WAYPOINT, + line.end_latitude, line.end_longitude, altitude_m, )) - mission_items.append(_mission_item( - len(mission_items), - 16, - home_latitude, - home_longitude, - altitude_m, + mission_items.append(_camera_trigger_item(len(mission_items), 0.0)) + mission_items.append(_navigation_item( + len(mission_items), MAV_CMD_NAV_WAYPOINT, + home_latitude, home_longitude, altitude_m, )) - mission_items.append(_mission_item( - len(mission_items), - 21, - home_latitude, - home_longitude, - 0.0, + mission_items.append(_navigation_item( + len(mission_items), MAV_CMD_NAV_LAND, + home_latitude, home_longitude, 0.0, )) return SearchMissionPlan( @@ -151,11 +303,15 @@ def build_search_mission( home_longitude=home_longitude, radius_km=radius_km, altitude_m=altitude_m, + cruise_speed_m_s=cruise_speed, cell_count=cell_count, rows=rows, columns=columns, cells=tuple(cells), route_cell_ids=tuple(cell.cell_id for cell in route_cells), + survey_lines=tuple(survey_lines), + camera=camera, + estimate=estimate, mission_items=tuple(mission_items), ) @@ -170,11 +326,29 @@ def _closest_factors(value: int) -> Tuple[int, int]: def _offset_coordinate(latitude: float, longitude: float, north_m: float, east_m: float) -> Tuple[float, float]: latitude_offset = north_m / METRES_PER_LATITUDE_DEGREE longitude_scale = METRES_PER_LATITUDE_DEGREE * max(0.01, math.cos(math.radians(latitude))) - longitude_offset = east_m / longitude_scale - return latitude + latitude_offset, longitude + longitude_offset + return latitude + latitude_offset, longitude + east_m / longitude_scale -def _mission_item( +def _coordinate_offset_metres( + origin_latitude: float, + origin_longitude: float, + latitude: float, + longitude: float, +) -> Tuple[float, float]: + north_m = (latitude - origin_latitude) * METRES_PER_LATITUDE_DEGREE + longitude_scale = METRES_PER_LATITUDE_DEGREE * max(0.01, math.cos(math.radians(origin_latitude))) + east_m = (longitude - origin_longitude) * longitude_scale + return north_m, east_m + + +def _polyline_distance(points: List[Tuple[float, float]]) -> float: + return sum( + math.hypot(current[0] - previous[0], current[1] - previous[1]) + for previous, current in zip(points, points[1:]) + ) + + +def _navigation_item( seq: int, command: int, latitude: float, @@ -198,6 +372,23 @@ def _mission_item( } +def _camera_trigger_item(seq: int, trigger_distance_m: float) -> Dict[str, Any]: + return { + "seq": seq, + "frame": 2, + "command": MAV_CMD_DO_SET_CAM_TRIGG_DIST, + "current": 0, + "autocontinue": 1, + "param1": round(trigger_distance_m, 2), + "param2": 0.0, + "param3": 1.0 if trigger_distance_m > 0 else 0.0, + "param4": 0.0, + "x": 0, + "y": 0, + "z": 0.0, + } + + def _bounded_number(name: str, value: Any, minimum: float, maximum: float) -> float: try: number = float(value) diff --git a/telemetry/handheld-nrf-monitor/pi-code/terrain_provider.py b/telemetry/handheld-nrf-monitor/pi-code/terrain_provider.py new file mode 100644 index 0000000..b76cd6a --- /dev/null +++ b/telemetry/handheld-nrf-monitor/pi-code/terrain_provider.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Optional terrain elevation sampling for mission planning context.""" + +import json +import os +from typing import Any, Dict, Iterable, Tuple +from urllib.request import Request, urlopen + + +DEFAULT_TERRAIN_API_URL = "https://api.opentopodata.org/v1/srtm90m" + + +def sample_plan_terrain(plan, surface_type: str = "water") -> Dict[str, Any]: + """Sample home, centre, and cell centres without using DEM as clearance data.""" + points = [ + (plan.home_latitude, plan.home_longitude), + (plan.center_latitude, plan.center_longitude), + ] + points.extend((cell.center_latitude, cell.center_longitude) for cell in plan.cells) + result = sample_elevations(points) + result["surface_type"] = surface_type + result["used_for_clearance"] = False + result["clearance_source"] = "downward_rangefinder" if surface_type == "water" else "rangefinder_or_validated_terrain" + return result + + +def sample_elevations(points: Iterable[Tuple[float, float]]) -> Dict[str, Any]: + point_list = list(points) + api_url = os.environ.get("TERRAIN_API_URL", DEFAULT_TERRAIN_API_URL).rstrip("/") + locations = "|".join(f"{latitude:.7f},{longitude:.7f}" for latitude, longitude in point_list) + request = Request( + api_url, + data=json.dumps({"locations": locations, "interpolation": "cubic"}).encode("utf-8"), + headers={"Content-Type": "application/json", "User-Agent": "SentinelBlue-Companion/1.0"}, + method="POST", + ) + try: + with urlopen(request, timeout=5) as response: + payload = json.load(response) + elevations = [ + float(item["elevation"]) + for item in payload.get("results", []) + if item.get("elevation") is not None + ] + if not elevations: + return { + "status": "unavailable", + "provider": api_url, + "sample_count": 0, + "message": "terrain service returned no elevations for this area", + } + return { + "status": "available", + "provider": api_url, + "sample_count": len(elevations), + "minimum_elevation_m": round(min(elevations), 1), + "maximum_elevation_m": round(max(elevations), 1), + "elevation_range_m": round(max(elevations) - min(elevations), 1), + } + except Exception as exc: + return { + "status": "unavailable", + "provider": api_url, + "sample_count": 0, + "message": f"terrain service unavailable: {type(exc).__name__}", + } diff --git a/telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_api.py b/telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_api.py index 7096c03..7c09d0a 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_api.py +++ b/telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_api.py @@ -41,7 +41,7 @@ class CompanionAPITests(unittest.TestCase): def setUp(self): self.payload = json.dumps({ "center": {"latitude": 12.9716, "longitude": 77.5946}, - "radius_km": 1, + "radius_km": 0.01, "altitude_m": 30, }).encode("utf-8") @@ -68,7 +68,8 @@ def test_plan_endpoint_returns_generated_grid(self): body = json.load(response) self.assertEqual("planned", body["status"]) - self.assertEqual(4, body["plan"]["cell_count"]) + self.assertEqual(1, body["plan"]["cell_count"]) + self.assertGreater(body["plan"]["estimate"]["survey_line_count"], 0) def test_live_execution_is_locked(self): manager, port = self._start(simulate=False) @@ -87,7 +88,10 @@ def test_simulation_execution_loads_and_starts_mission(self): self.assertEqual(202, response.status) self.assertEqual("simulation_started", body["status"]) - self.assertEqual(["clear", ("upload", 8), ("arm", True), ("mode", "AUTO")], manager.calls) + self.assertEqual("clear", manager.calls[0]) + self.assertEqual("upload", manager.calls[1][0]) + self.assertGreater(manager.calls[1][1], 8) + self.assertEqual([("arm", True), ("mode", "AUTO")], manager.calls[2:]) if __name__ == "__main__": diff --git a/telemetry/handheld-nrf-monitor/pi-code/tests/test_search_mission_planner.py b/telemetry/handheld-nrf-monitor/pi-code/tests/test_search_mission_planner.py index 294e689..7ce82bd 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/tests/test_search_mission_planner.py +++ b/telemetry/handheld-nrf-monitor/pi-code/tests/test_search_mission_planner.py @@ -11,11 +11,13 @@ class SearchMissionPlannerTests(unittest.TestCase): def test_one_kilometre_radius_creates_four_cells(self): - plan = build_search_mission(12.9716, 77.5946, 1, 30) + plan = build_search_mission(12.9716, 77.5946, 1, 5) self.assertEqual(4, plan.cell_count) self.assertEqual((2, 2), (plan.rows, plan.columns)) self.assertEqual((1, 2, 4, 3), plan.route_cell_ids) + self.assertGreater(plan.estimate.survey_line_count, 2_000) + self.assertFalse(plan.estimate.simulation_ready) def test_two_kilometre_radius_creates_eight_cells(self): plan = build_search_mission(12.9716, 77.5946, 2, 40) @@ -25,20 +27,37 @@ def test_two_kilometre_radius_creates_eight_cells(self): self.assertEqual((1, 2, 3, 4, 8, 7, 6, 5), plan.route_cell_ids) def test_mission_contains_takeoff_search_return_and_land(self): - plan = build_search_mission(12.9716, 77.5946, 1, 30, 12.9700, 77.5900) + plan = build_search_mission(12.9716, 77.5946, 0.01, 30, 12.9700, 77.5900) commands = [item["command"] for item in plan.mission_items] self.assertEqual(16, commands[0]) self.assertEqual(22, commands[1]) - self.assertEqual([16] * 4, commands[2:6]) + self.assertEqual(206, commands[2]) + self.assertEqual(206, commands[-3]) self.assertEqual(16, commands[-2]) self.assertEqual(21, commands[-1]) + self.assertTrue(plan.estimate.simulation_ready) + + def test_camera_footprint_controls_line_and_photo_spacing(self): + plan = build_search_mission( + 12.9716, 77.5946, 0.01, 5, + camera_horizontal_fov_deg=78, + camera_vertical_fov_deg=60, + side_overlap_percent=70, + forward_overlap_percent=75, + ) + + self.assertAlmostEqual(8.1, plan.camera.footprint_width_m, places=1) + self.assertAlmostEqual(5.77, plan.camera.footprint_height_m, places=1) + self.assertAlmostEqual(1.73, plan.camera.line_spacing_m, places=1) + self.assertAlmostEqual(2.02, plan.camera.trigger_distance_m, places=1) + self.assertEqual(12, plan.estimate.survey_line_count) def test_rejects_unsafe_or_invalid_ranges(self): invalid_values = [ (91, 77, 1, 30), (12, 181, 1, 30), - (12, 77, 0.1, 30), + (12, 77, 0.001, 30), (12, 77, 1, 121), ] for values in invalid_values: @@ -46,6 +65,10 @@ def test_rejects_unsafe_or_invalid_ranges(self): with self.assertRaises(ValueError): build_search_mission(*values) + def test_rejects_plan_too_large_to_render_safely(self): + with self.assertRaisesRegex(ValueError, "planning limit 10000 lines"): + build_search_mission(12.9716, 77.5946, 10, 5) + if __name__ == "__main__": unittest.main() diff --git a/telemetry/live-web-dashboard/README.md b/telemetry/live-web-dashboard/README.md index 4eb351d..73dbb0c 100644 --- a/telemetry/live-web-dashboard/README.md +++ b/telemetry/live-web-dashboard/README.md @@ -48,3 +48,41 @@ The Pages deployment hosts only the frontend. It does not run Docker, Nginx, MQT - Values that are not provided remain unavailable instead of being generated. - The battery cell count can be selected from 2S through 6S. - Charts display 64 samples collected at the dashboard's 1 Hz polling rate. + +## Camera coverage mission planner + +Run the Raspberry Pi companion in simulation mode, then open the dashboard with +`?view=mission`. The planner: + +- draws terrain and map context with MapLibre; +- divides the selected square into the requested number of search cells; +- calculates the downward camera footprint from altitude and horizontal and + vertical field of view; +- creates parallel lawnmower lines in every cell using the requested side + overlap; +- sets camera trigger distance from forward overlap; +- estimates route distance, duration, photographs, mission items and required + sorties; and +- blocks simulator start when the mission exceeds the 500 item or 20 minute + single sortie limits. + +For a plan that fits the single sortie limits, select **Download Mission +Planner file**. In Mission Planner, open **PLAN**, select **Load WP File**, and +choose `sentinelblue_camera_survey.waypoints`. Review the route and vehicle +parameters before writing it to a flight controller. + +The default map uses OpenStreetMap raster tiles and MapLibre demo terrain. Set +`VITE_MAPTILER_KEY` at build time to use the MapTiler hybrid map: + +```bash +VITE_MAPTILER_KEY=your_key npm run build +``` + +The companion samples OpenTopoData SRTM90m by default. Set +`TERRAIN_API_URL` to another compatible OpenTopoData endpoint when running a +private terrain service. + +Terrain elevation is planning context. A 5 metre flight over water requires a +tested downward rangefinder to measure surface clearance. The UI requires the +operator to acknowledge that sensor before simulation. Live Pixhawk mission +execution remains locked. diff --git a/telemetry/live-web-dashboard/package-lock.json b/telemetry/live-web-dashboard/package-lock.json index 0d8989b..61ee354 100644 --- a/telemetry/live-web-dashboard/package-lock.json +++ b/telemetry/live-web-dashboard/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@react-three/fiber": "^8.17.10", "lucide-react": "^0.468.0", + "maplibre-gl": "^6.14.0", "react": "^18.3.1", "react-dom": "^18.3.1", "recharts": "^3.10.1", @@ -329,6 +330,92 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.3.0.tgz", + "integrity": "sha512-gBvcNDBBB66bgQYvnaJ6Jk+VCaMmVq5+IZR2kWPisT88Z8s0kBQt6hfVi8QgRW8Vpg0UWZzBA4ZSyzHHC+VeRA==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz", + "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.2.tgz", + "integrity": "sha512-2ZYhobdAY+y4/P/z1xe0698165LywD0yDdWugdu3NRUrajgVWut+tLkcz7i/RJguMTURwK7gxwmwYMFJbgbQFw==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "26.4.4", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.4.tgz", + "integrity": "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.3", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz", + "integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, "node_modules/@oxc-project/types": { "version": "0.153.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", @@ -1367,6 +1454,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1905,6 +1998,15 @@ ], "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", @@ -2196,6 +2298,12 @@ "node": ">=8" } }, + "node_modules/earcut": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.4.tgz", + "integrity": "sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==", + "license": "ISC" + }, "node_modules/enhanced-resolve": { "version": "5.26.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", @@ -2540,6 +2648,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2770,6 +2884,18 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3121,6 +3247,39 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/maplibre-gl": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.14.0.tgz", + "integrity": "sha512-L7U9y9TyPJ3P/JJbW+eH+A3FzdPq+3fnZFqqeCwf/S6cRGganHwVI5b/C8/zOYzreUzsloDPZVVaMQA94wOt4A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.2.0", + "@mapbox/unitbezier": "^1.0.0", + "@mapbox/vector-tile": "^3.0.0", + "@maplibre/geojson-vt": "^6.1.2", + "@maplibre/maplibre-gl-style-spec": "^26.4.4", + "@maplibre/mlt": "^1.3.0", + "@maplibre/vt-pbf": "^4.3.2", + "@types/geojson": "^7946.0.16", + "bidi-js": "^1.1.0", + "earcut": "^3.2.4", + "gl-matrix": "^3.4.4", + "kdbush": "^4.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^5.1.2", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/meshoptimizer": { "version": "0.18.1", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", @@ -3141,6 +3300,15 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3148,6 +3316,12 @@ "dev": true, "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", @@ -3271,6 +3445,18 @@ "node": ">=8" } }, + "node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3307,6 +3493,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3317,6 +3509,12 @@ "node": ">= 0.8.0" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3327,6 +3525,12 @@ "node": ">=6" } }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -3467,6 +3671,15 @@ "redux": "^5.0.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", @@ -3483,6 +3696,15 @@ "node": ">=4" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/rolldown": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", @@ -3702,6 +3924,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", diff --git a/telemetry/live-web-dashboard/package.json b/telemetry/live-web-dashboard/package.json index 7c0f722..71f79da 100644 --- a/telemetry/live-web-dashboard/package.json +++ b/telemetry/live-web-dashboard/package.json @@ -14,18 +14,19 @@ "dependencies": { "@react-three/fiber": "^8.17.10", "lucide-react": "^0.468.0", + "maplibre-gl": "^6.14.0", "react": "^18.3.1", "react-dom": "^18.3.1", "recharts": "^3.10.1", "three": "^0.165.0" }, "devDependencies": { + "@eslint/js": "^9.39.5", "@tailwindcss/postcss": "^4.3.3", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@types/three": "^0.165.0", "@vitejs/plugin-react": "^6.1.2", - "@eslint/js": "^9.39.5", "eslint": "^9.39.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.26", diff --git a/telemetry/live-web-dashboard/src/components/mission_planner.tsx b/telemetry/live-web-dashboard/src/components/mission_planner.tsx index e40bb8f..e9dd39d 100644 --- a/telemetry/live-web-dashboard/src/components/mission_planner.tsx +++ b/telemetry/live-web-dashboard/src/components/mission_planner.tsx @@ -1,93 +1,100 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { CrosshairIcon, MapPinnedIcon, PlayIcon, RouteIcon, ShieldCheckIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { CameraIcon, CrosshairIcon, DownloadIcon, MapPinnedIcon, PlayIcon, RouteIcon, ShieldAlertIcon, ShieldCheckIcon } from "lucide-react"; +import { TerrainMissionMap } from "./terrain_mission_map"; interface MissionCell { - cell_id: number; - row: number; - column: number; - center_latitude: number; - center_longitude: number; + cell_id: number; row: number; column: number; + north_m: number; east_m: number; south_m: number; west_m: number; + center_latitude: number; center_longitude: number; +} +interface SurveyLine { + cell_id: number; line_index: number; + start_latitude: number; start_longitude: number; + end_latitude: number; end_longitude: number; length_m: number; } - interface MissionPlan { center: { latitude: number; longitude: number }; home: { latitude: number; longitude: number }; - radius_km: number; - altitude_m: number; - cell_count: number; - rows: number; - columns: number; - cells: MissionCell[]; - route_cell_ids: number[]; - phases: string[]; + radius_km: number; altitude_m: number; cruise_speed_m_s: number; + cell_count: number; rows: number; columns: number; + cells: MissionCell[]; survey_lines: SurveyLine[]; phases: string[]; mission_items: Array>; + camera: { + horizontal_fov_deg: number; vertical_fov_deg: number; + side_overlap_percent: number; forward_overlap_percent: number; + footprint_width_m: number; footprint_height_m: number; + line_spacing_m: number; trigger_distance_m: number; + }; + estimate: { + survey_line_count: number; survey_distance_m: number; total_distance_m: number; + estimated_minutes: number; estimated_photos: number; mission_item_count: number; + recommended_sorties: number; simulation_ready: boolean; + blocking_reasons: string[]; warnings: string[]; + }; + terrain: { + status: string; provider?: string; sample_count?: number; + minimum_elevation_m?: number; maximum_elevation_m?: number; + elevation_range_m?: number; message?: string; + used_for_clearance: boolean; clearance_source?: string; + }; } - -interface MissionResponse { - status: string; - plan: MissionPlan; -} +interface MissionResponse { status: string; plan: MissionPlan } +interface ApiError { error?: string; reasons?: string[] } const API_STORAGE_KEY = "droneCompanionApi"; -const VIEW_WIDTH = 720; -const VIEW_HEIGHT = 420; +const inputClass = "mt-1 h-9 w-full rounded border border-hud-line bg-hud-bg px-2 font-mono text-sm text-hud-text outline-none focus:border-teal-300"; function isPosition(latitude: number, longitude: number): boolean { return Number.isFinite(latitude) && Number.isFinite(longitude) && latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180 && !(latitude === 0 && longitude === 0); } - function normalizeApi(value: string): string { const trimmed = value.trim(); if (!trimmed) throw new Error("Enter the Raspberry Pi companion API address."); - const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; - const url = new URL(withProtocol); + const url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`); if (!/^https?:$/.test(url.protocol)) throw new Error("Use an HTTP or HTTPS companion API address."); return url.origin; } - -function factors(value: number): [number, number] { - let rows = Math.floor(Math.sqrt(value)); - while (rows > 1 && value % rows !== 0) rows -= 1; - return [rows, value / rows]; +function compactDistance(metres: number): string { + return metres >= 1000 ? `${(metres / 1000).toFixed(1)} km` : `${metres.toFixed(1)} m`; } - -function routeIds(rows: number, columns: number): number[] { - return Array.from({ length: rows }, (_, row) => { - const ids = Array.from({ length: columns }, (_, column) => row * columns + column + 1); - return row % 2 === 0 ? ids : ids.reverse(); - }).flat(); +function Metric({ label, value }: { label: string; value: string | number }) { + return
{value}
{label}
; } - -function coordinateOffset( - originLatitude: number, - originLongitude: number, - northMetres: number, - eastMetres: number -): [number, number] { - const latitude = originLatitude + northMetres / 111_320; - const longitudeScale = 111_320 * Math.max(0.01, Math.cos(originLatitude * Math.PI / 180)); - return [latitude, originLongitude + eastMetres / longitudeScale]; +function missionPlannerFile(items: Array>): string { + const rows = items.map((item) => { + const coordinateFrame = item.frame === 6 ? 3 : item.frame; + const latitude = item.frame === 6 ? item.x / 1e7 : item.x; + const longitude = item.frame === 6 ? item.y / 1e7 : item.y; + return [ + item.seq, item.current, coordinateFrame, item.command, + item.param1, item.param2, item.param3, item.param4, + latitude.toFixed(7), longitude.toFixed(7), item.z, item.autocontinue + ].join("\t"); + }); + return `QGC WPL 110\n${rows.join("\n")}\n`; } -export function MissionPlanner({ - currentLatitude, - currentLongitude, - telemetryAvailable -}: { - currentLatitude: number; - currentLongitude: number; - telemetryAvailable: boolean; +export function MissionPlanner({ currentLatitude, currentLongitude, telemetryAvailable }: { + currentLatitude: number; currentLongitude: number; telemetryAvailable: boolean; }) { const [apiInput, setApiInput] = useState(() => window.localStorage.getItem(API_STORAGE_KEY) ?? "localhost:8765"); const [centerLatitude, setCenterLatitude] = useState(""); const [centerLongitude, setCenterLongitude] = useState(""); - const [radiusKm, setRadiusKm] = useState(1); - const [altitudeM, setAltitudeM] = useState(30); + const [radiusKm, setRadiusKm] = useState(0.025); + const [altitudeM, setAltitudeM] = useState(5); + const [horizontalFov, setHorizontalFov] = useState(78); + const [verticalFov, setVerticalFov] = useState(60); + const [sideOverlap, setSideOverlap] = useState(70); + const [forwardOverlap, setForwardOverlap] = useState(75); + const [cruiseSpeed, setCruiseSpeed] = useState(3); + const [surfaceType, setSurfaceType] = useState("water"); + const [terrainEnabled, setTerrainEnabled] = useState(true); + const [rangefinderConfirmed, setRangefinderConfirmed] = useState(false); const [plan, setPlan] = useState(null); const [requestState, setRequestState] = useState<"idle" | "planning" | "ready" | "starting" | "running" | "error">("idle"); - const [message, setMessage] = useState("Select a point on the plot or enter coordinates."); + const [message, setMessage] = useState("Select a point on the terrain map or enter coordinates."); const initializedFromTelemetry = useRef(false); const currentPositionValid = telemetryAvailable && isPosition(currentLatitude, currentLongitude); @@ -102,64 +109,18 @@ export function MissionPlanner({ const selectedLatitude = Number(centerLatitude); const selectedLongitude = Number(centerLongitude); const selectedPositionValid = isPosition(selectedLatitude, selectedLongitude); - const estimatedCellCount = Math.max(1, Math.ceil(radiusKm * 4)); - const [estimatedRows, estimatedColumns] = useMemo(() => factors(estimatedCellCount), [estimatedCellCount]); - const displayedRows = plan?.rows ?? estimatedRows; - const displayedColumns = plan?.columns ?? estimatedColumns; - const displayedRoute = plan?.route_cell_ids ?? routeIds(displayedRows, displayedColumns); - - const plotOrigin = useMemo(() => currentPositionValid - ? { latitude: currentLatitude, longitude: currentLongitude } - : selectedPositionValid - ? { latitude: selectedLatitude, longitude: selectedLongitude } - : null, - [currentLatitude, currentLongitude, currentPositionValid, selectedLatitude, selectedLongitude, selectedPositionValid]); - const extentMetres = Math.max(1_000, radiusKm * 1_600); - - const selectedPlotPoint = useMemo(() => { - if (!plotOrigin || !selectedPositionValid) return null; - const north = (selectedLatitude - plotOrigin.latitude) * 111_320; - const east = (selectedLongitude - plotOrigin.longitude) * 111_320 * - Math.max(0.01, Math.cos(plotOrigin.latitude * Math.PI / 180)); - return { - x: VIEW_WIDTH / 2 + (east / extentMetres) * (VIEW_WIDTH / 2), - y: VIEW_HEIGHT / 2 - (north / extentMetres) * (VIEW_HEIGHT / 2) - }; - }, [extentMetres, plotOrigin, selectedLatitude, selectedLongitude, selectedPositionValid]); - - const searchBox = { - width: (radiusKm * 1_000 / extentMetres) * VIEW_WIDTH, - height: (radiusKm * 1_000 / extentMetres) * VIEW_HEIGHT - }; - - const selectPoint = (event: React.MouseEvent) => { - if (!plotOrigin) { - setRequestState("error"); - setMessage("Enter valid coordinates before selecting a point on the plot."); - return; - } - const bounds = event.currentTarget.getBoundingClientRect(); - const x = ((event.clientX - bounds.left) / bounds.width) * VIEW_WIDTH; - const y = ((event.clientY - bounds.top) / bounds.height) * VIEW_HEIGHT; - const east = ((x - VIEW_WIDTH / 2) / (VIEW_WIDTH / 2)) * extentMetres; - const north = ((VIEW_HEIGHT / 2 - y) / (VIEW_HEIGHT / 2)) * extentMetres; - const [latitude, longitude] = coordinateOffset(plotOrigin.latitude, plotOrigin.longitude, north, east); - setCenterLatitude(latitude.toFixed(7)); - setCenterLongitude(longitude.toFixed(7)); - setPlan(null); - setRequestState("idle"); - setMessage("Search centre selected. Generate the route to review every cell."); - }; + const needsRangefinder = altitudeM <= 15; + const clearPlan = () => { setPlan(null); setRequestState("idle"); }; const requestPlan = async (path: "/api/mission/plan" | "/api/mission/simulate") => { if (!selectedPositionValid) { setRequestState("error"); - setMessage("Enter a valid latitude and longitude."); + setMessage("Enter a valid latitude and longitude or click the map."); return; } const starting = path.endsWith("simulate"); setRequestState(starting ? "starting" : "planning"); - setMessage(starting ? "Loading the mission into the simulator…" : "Generating the grid route on the Raspberry Pi…"); + setMessage(starting ? "Loading the checked mission into the simulator..." : "Calculating camera coverage and terrain context..."); try { const api = normalizeApi(apiInput); window.localStorage.setItem(API_STORAGE_KEY, apiInput.trim()); @@ -168,172 +129,130 @@ export function MissionPlanner({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ center: { latitude: selectedLatitude, longitude: selectedLongitude }, - radius_km: radiusKm, - altitude_m: altitudeM + radius_km: radiusKm, altitude_m: altitudeM, + camera_horizontal_fov_deg: horizontalFov, camera_vertical_fov_deg: verticalFov, + side_overlap_percent: sideOverlap, forward_overlap_percent: forwardOverlap, + cruise_speed_m_s: cruiseSpeed, surface_type: surfaceType, + terrain_enabled: terrainEnabled }) }); - const body = await response.json() as MissionResponse | { error?: string }; + const body = await response.json() as MissionResponse | ApiError; if (!response.ok || !("plan" in body)) { - throw new Error("error" in body && body.error ? body.error : `Companion API returned ${response.status}`); + const details = "reasons" in body && body.reasons?.length ? ` ${body.reasons.join(" ")}` : ""; + throw new Error(`${"error" in body && body.error ? body.error : `Companion API returned ${response.status}`}${details}`); } setPlan(body.plan); setRequestState(starting ? "running" : "ready"); setMessage(starting - ? "Simulation started. Follow mission progress in the telemetry view." - : `${body.plan.cell_count} cells planned. Review the route before starting the simulator.`); + ? "Simulation started. Follow progress in the telemetry view." + : body.plan.estimate.simulation_ready + ? "Coverage plan generated. Confirm the rangefinder check before simulation." + : `Plan generated, but it must be split into about ${body.plan.estimate.recommended_sorties} sorties.`); } catch (error) { setRequestState("error"); setMessage(error instanceof Error ? error.message : "The Raspberry Pi companion API could not be reached."); } }; - const boxLeft = (selectedPlotPoint?.x ?? VIEW_WIDTH / 2) - searchBox.width / 2; - const boxTop = (selectedPlotPoint?.y ?? VIEW_HEIGHT / 2) - searchBox.height / 2; - const cellWidth = searchBox.width / displayedColumns; - const cellHeight = searchBox.height / displayedRows; - const routePoints = displayedRoute.map((cellId) => { - const index = cellId - 1; - const row = Math.floor(index / displayedColumns); - const column = index % displayedColumns; - return `${boxLeft + column * cellWidth + cellWidth / 2},${boxTop + row * cellHeight + cellHeight / 2}`; - }).join(" "); + const simulatorBlocked = !plan?.estimate.simulation_ready || (needsRangefinder && !rangefinderConfirmed); + const terrainSummary = plan?.terrain.status === "available" + ? `${plan.terrain.sample_count} DEM samples · ${plan.terrain.minimum_elevation_m}-${plan.terrain.maximum_elevation_m} m AMSL` + : plan?.terrain.message ?? (plan?.terrain.status === "disabled" ? "Terrain sampling disabled" : "Terrain data unavailable"); + const downloadMission = () => { + if (!plan?.mission_items.length) return; + const blob = new Blob([missionPlannerFile(plan.mission_items)], { type: "text/plain;charset=utf-8" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = "sentinelblue_camera_survey.waypoints"; + link.click(); + URL.revokeObjectURL(link.href); + }; return (
-

Companion search planner

-

- Choose the search centre, set radius and altitude, then inspect the complete takeoff, grid search, return and landing route. -

-
-
- - Simulation execution only · live Pixhawk control locked +

Camera coverage mission planner

+

Plan a complete lawnmower survey from the camera footprint, overlap and flight altitude. Every cell receives parallel coverage lines.

+
Simulation only · live Pixhawk control locked
-
-
+
+
- Search area - click plot to place centre · N ↑ + Terrain and coverage + click map to place search centre
- - - - - - - - - - - {selectedPlotPoint ? ( - - - {Array.from({ length: displayedColumns - 1 }, (_, index) => ( - - ))} - {Array.from({ length: displayedRows - 1 }, (_, index) => ( - - ))} - - {displayedRoute.map((cellId) => { - const index = cellId - 1; - const row = Math.floor(index / displayedColumns); - const column = index % displayedColumns; - return ; - })} - - - ) : null} - - {currentPositionValid ? ( - - - - DRONE / HOME - - ) : null} - + { + setCenterLatitude(latitude.toFixed(7)); + setCenterLongitude(longitude.toFixed(7)); + clearPlan(); + setMessage("Search centre selected. Generate the plan to calculate coverage."); + }} + />
- {plotOrigin ? `${plotOrigin.latitude.toFixed(6)}, ${plotOrigin.longitude.toFixed(6)}` : "Waiting for a coordinate origin"} - plot span ±{(extentMetres / 1000).toFixed(1)} km + {selectedPositionValid ? `${selectedLatitude.toFixed(6)}, ${selectedLongitude.toFixed(6)}` : "No search centre selected"} + amber: flight lines · teal: cells · blue: home
diff --git a/telemetry/live-web-dashboard/src/components/terrain_mission_map.tsx b/telemetry/live-web-dashboard/src/components/terrain_mission_map.tsx new file mode 100644 index 0000000..d0534ee --- /dev/null +++ b/telemetry/live-web-dashboard/src/components/terrain_mission_map.tsx @@ -0,0 +1,247 @@ +import { useEffect, useRef } from "react"; +import { + LngLatBounds, + Map, + NavigationControl, + setWorkerUrl, + type GeoJSONSource, + type MapMouseEvent, + type StyleSpecification +} from "maplibre-gl"; +import mapLibreWorkerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?url"; + +setWorkerUrl(mapLibreWorkerUrl); + +interface MapCell { + cell_id: number; + north_m: number; + east_m: number; + south_m: number; + west_m: number; +} + +interface MapSurveyLine { + cell_id: number; + start_latitude: number; + start_longitude: number; + end_latitude: number; + end_longitude: number; +} + +interface MapPlan { + center: { latitude: number; longitude: number }; + home: { latitude: number; longitude: number }; + radius_km: number; + cells: MapCell[]; + survey_lines: MapSurveyLine[]; +} + +const emptyCollection: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: [] }; + +function mapStyle(): StyleSpecification | string { + const mapTilerKey = (import.meta as ImportMeta & { + env?: Record; + }).env?.VITE_MAPTILER_KEY; + if (mapTilerKey) { + return `https://api.maptiler.com/maps/hybrid/style.json?key=${encodeURIComponent(mapTilerKey)}`; + } + return { + version: 8, + sources: { + open_street_map: { + type: "raster", + tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"], + tileSize: 256, + attribution: "© OpenStreetMap contributors" + }, + terrain_dem: { + type: "raster-dem", + url: "https://demotiles.maplibre.org/terrain-tiles/tiles.json", + tileSize: 256 + }, + terrain_hillshade_dem: { + type: "raster-dem", + url: "https://demotiles.maplibre.org/terrain-tiles/tiles.json", + tileSize: 256 + } + }, + layers: [ + { id: "open_street_map", type: "raster", source: "open_street_map" }, + { + id: "terrain_hillshade", + type: "hillshade", + source: "terrain_hillshade_dem", + paint: { + "hillshade-exaggeration": 0.35, + "hillshade-shadow-color": "#071014", + "hillshade-highlight-color": "#d7fff8", + "hillshade-accent-color": "#0f766e" + } + } + ] + }; +} + +function offsetCoordinate(latitude: number, longitude: number, northM: number, eastM: number): [number, number] { + const nextLatitude = latitude + northM / 111_320; + const longitudeScale = 111_320 * Math.max(0.01, Math.cos(latitude * Math.PI / 180)); + return [longitude + eastM / longitudeScale, nextLatitude]; +} + +function planGeometry(plan: MapPlan | null, selected: { latitude: number; longitude: number } | null) { + const areas: GeoJSON.Feature[] = []; + const lines: GeoJSON.Feature[] = []; + const points: GeoJSON.Feature[] = []; + + if (plan) { + for (const cell of plan.cells) { + const northWest = offsetCoordinate(plan.center.latitude, plan.center.longitude, cell.north_m, cell.west_m); + const northEast = offsetCoordinate(plan.center.latitude, plan.center.longitude, cell.north_m, cell.east_m); + const southEast = offsetCoordinate(plan.center.latitude, plan.center.longitude, cell.south_m, cell.east_m); + const southWest = offsetCoordinate(plan.center.latitude, plan.center.longitude, cell.south_m, cell.west_m); + areas.push({ + type: "Feature", + properties: { cell_id: cell.cell_id }, + geometry: { type: "Polygon", coordinates: [[northWest, northEast, southEast, southWest, northWest]] } + }); + } + for (const line of plan.survey_lines) { + lines.push({ + type: "Feature", + properties: { cell_id: line.cell_id }, + geometry: { + type: "LineString", + coordinates: [ + [line.start_longitude, line.start_latitude], + [line.end_longitude, line.end_latitude] + ] + } + }); + } + points.push({ + type: "Feature", + properties: { kind: "home" }, + geometry: { type: "Point", coordinates: [plan.home.longitude, plan.home.latitude] } + }); + } + if (selected) { + points.push({ + type: "Feature", + properties: { kind: "center" }, + geometry: { type: "Point", coordinates: [selected.longitude, selected.latitude] } + }); + } + return { + areas: { type: "FeatureCollection", features: areas } as GeoJSON.FeatureCollection, + lines: { type: "FeatureCollection", features: lines } as GeoJSON.FeatureCollection, + points: { type: "FeatureCollection", features: points } as GeoJSON.FeatureCollection + }; +} + +function addMissionLayers(map: Map) { + if (map.getSource("mission_cells")) return; + map.addSource("mission_cells", { type: "geojson", data: emptyCollection }); + map.addSource("mission_lines", { type: "geojson", data: emptyCollection }); + map.addSource("mission_points", { type: "geojson", data: emptyCollection }); + map.addLayer({ + id: "mission_cell_fill", + type: "fill", + source: "mission_cells", + paint: { "fill-color": "#2dd4bf", "fill-opacity": 0.10 } + }); + map.addLayer({ + id: "mission_cell_outline", + type: "line", + source: "mission_cells", + paint: { "line-color": "#5eead4", "line-width": 2 } + }); + map.addLayer({ + id: "mission_survey_lines", + type: "line", + source: "mission_lines", + paint: { "line-color": "#fbbf24", "line-width": 2, "line-opacity": 0.9 } + }); + map.addLayer({ + id: "mission_home", + type: "circle", + source: "mission_points", + filter: ["==", ["get", "kind"], "home"], + paint: { "circle-radius": 7, "circle-color": "#60a5fa", "circle-stroke-color": "#071014", "circle-stroke-width": 2 } + }); + map.addLayer({ + id: "mission_center", + type: "circle", + source: "mission_points", + filter: ["==", ["get", "kind"], "center"], + paint: { "circle-radius": 7, "circle-color": "#5eead4", "circle-stroke-color": "#071014", "circle-stroke-width": 2 } + }); +} + +export function TerrainMissionMap({ + selected, + plan, + onSelect +}: { + selected: { latitude: number; longitude: number } | null; + plan: MapPlan | null; + onSelect: (latitude: number, longitude: number) => void; +}) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const onSelectRef = useRef(onSelect); + const initialSelectedRef = useRef(selected); + onSelectRef.current = onSelect; + + useEffect(() => { + if (!containerRef.current || mapRef.current) return; + const initialSelected = initialSelectedRef.current; + const initialCenter: [number, number] = initialSelected + ? [initialSelected.longitude, initialSelected.latitude] + : [0, 20]; + const map = new Map({ + container: containerRef.current, + style: mapStyle(), + center: initialCenter, + zoom: initialSelected ? 13 : 1.5, + attributionControl: {}, + maxPitch: 70 + }); + map.addControl(new NavigationControl({ visualizePitch: true }), "top-right"); + map.on("click", (event: MapMouseEvent) => onSelectRef.current(event.lngLat.lat, event.lngLat.lng)); + map.on("style.load", () => { + addMissionLayers(map); + if (map.getSource("terrain_dem")) map.setTerrain({ source: "terrain_dem", exaggeration: 1.25 }); + }); + mapRef.current = map; + return () => { + map.remove(); + mapRef.current = null; + }; + }, []); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + const geometry = planGeometry(plan, selected); + const update = () => { + addMissionLayers(map); + (map.getSource("mission_cells") as GeoJSONSource | undefined)?.setData(geometry.areas); + (map.getSource("mission_lines") as GeoJSONSource | undefined)?.setData(geometry.lines); + (map.getSource("mission_points") as GeoJSONSource | undefined)?.setData(geometry.points); + if (plan) { + const bounds = new LngLatBounds(); + for (const cell of plan.cells) { + bounds.extend(offsetCoordinate(plan.center.latitude, plan.center.longitude, cell.north_m, cell.west_m)); + bounds.extend(offsetCoordinate(plan.center.latitude, plan.center.longitude, cell.south_m, cell.east_m)); + } + map.fitBounds(bounds, { padding: 48, duration: 500, maxZoom: 19 }); + } else if (selected) { + map.easeTo({ center: [selected.longitude, selected.latitude], zoom: Math.max(map.getZoom(), 13), duration: 350 }); + } + }; + if (map.isStyleLoaded()) update(); + else map.once("style.load", update); + }, [plan, selected]); + + return
; +} diff --git a/telemetry/live-web-dashboard/src/styles.css b/telemetry/live-web-dashboard/src/styles.css index a3b27ca..b823743 100644 --- a/telemetry/live-web-dashboard/src/styles.css +++ b/telemetry/live-web-dashboard/src/styles.css @@ -1,3 +1,4 @@ +@import "maplibre-gl/dist/maplibre-gl.css"; @import "tailwindcss"; @config "../tailwind.config.js"; @@ -34,6 +35,27 @@ body { background-size: 48px 48px; } +.maplibregl-map { + font: inherit; + background: #070b10; +} + +.maplibregl-ctrl-group { + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.25); + border-radius: 8px; + background: #10161f; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28); +} + +.maplibregl-ctrl-group button { + background-color: #10161f; +} + +.maplibregl-ctrl-attrib { + color: #334155; +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/telemetry/live-web-dashboard/src/types/assets.d.ts b/telemetry/live-web-dashboard/src/types/assets.d.ts new file mode 100644 index 0000000..c0ce482 --- /dev/null +++ b/telemetry/live-web-dashboard/src/types/assets.d.ts @@ -0,0 +1,4 @@ +declare module "*?url" { + const url: string; + export default url; +} From ec40475a72371c0ab5e091ead585fde4534102b1 Mon Sep 17 00:00:00 2001 From: TheLostLeo Date: Tue, 13 Oct 2026 15:39:06 +0530 Subject: [PATCH 09/12] chore(telemetry): remove github pages deployment Signed-off-by: TheLostLeo --- .github/workflows/telemetry_web_pages.yml | 52 --------------------- telemetry/README.md | 9 +--- telemetry/live-web-dashboard/README.md | 8 +--- telemetry/live-web-dashboard/vite.config.ts | 2 +- 4 files changed, 3 insertions(+), 68 deletions(-) delete mode 100644 .github/workflows/telemetry_web_pages.yml diff --git a/.github/workflows/telemetry_web_pages.yml b/.github/workflows/telemetry_web_pages.yml deleted file mode 100644 index e5b5417..0000000 --- a/.github/workflows/telemetry_web_pages.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Deploy telemetry dashboard to GitHub Pages - -on: - push: - branches: - - main - paths: - - "telemetry/live-web-dashboard/**" - - ".github/workflows/telemetry_web_pages.yml" - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: telemetry-pages - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - defaults: - run: - working-directory: telemetry/live-web-dashboard - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: telemetry/live-web-dashboard/package-lock.json - - run: npm ci - - run: npm run check - env: - VITE_BASE_PATH: /SentinelBlue/ - - uses: actions/configure-pages@v5 - - uses: actions/upload-pages-artifact@v4 - with: - path: telemetry/live-web-dashboard/dist - - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - name: Deploy - id: deployment - uses: actions/deploy-pages@v4 diff --git a/telemetry/README.md b/telemetry/README.md index 2eb8b99..b699372 100644 --- a/telemetry/README.md +++ b/telemetry/README.md @@ -141,19 +141,12 @@ host firewall allows inbound TCP port 8080. Stop it with `docker compose down`. The dashboard reads `http:///telemetry.json` directly from each viewer's browser during local use. A viewer must be able to reach the ESP32 on the same -network. An HTTPS deployment, including GitHub Pages, requires a complete HTTPS +network. An HTTPS deployment requires a complete HTTPS telemetry endpoint because browsers block insecure HTTP telemetry requests from secure pages. Remote access requires an authenticated HTTPS or MQTT relay between the vehicle and dashboard; publishing Docker port 8080 does not make the ESP32 endpoint reachable over the Internet. -## GitHub Pages - -The repository includes a GitHub Actions workflow that validates and publishes -the dashboard after changes reach `main`. Enable **GitHub Actions** as the Pages -source in the repository settings. The Pages deployment hosts the static frontend -only and does not run Docker, Nginx, or a telemetry relay. - ## Mission Control Mission planning, grid-search, and `/api/mission/*` control endpoints are intentionally out of scope for this telemetry dashboard update. They should live in a separate mission-control module or PR so the telemetry relay remains focused on Pixhawk MAVLink input, NRF24 transmission, ESP32 JSON output, and laptop dashboard display. diff --git a/telemetry/live-web-dashboard/README.md b/telemetry/live-web-dashboard/README.md index 73dbb0c..df84102 100644 --- a/telemetry/live-web-dashboard/README.md +++ b/telemetry/live-web-dashboard/README.md @@ -1,6 +1,6 @@ # Live Web Dashboard -The dashboard displays ESP32 drone telemetry through a React and Vite frontend. It supports local Docker hosting and static deployment through GitHub Pages. +The dashboard displays ESP32 drone telemetry through a React and Vite frontend. Docker and Nginx provide the production runtime. ## Local development @@ -35,12 +35,6 @@ ESP32 or companion computer -> authenticated HTTPS or MQTT service -> HTTPS tele The relay should authenticate the vehicle, encrypt traffic, restrict allowed browser origins, and return the same JSON fields as the ESP32 endpoint. Do not expose the ESP32 HTTP server directly to the internet. -## GitHub Pages - -The `telemetry_web_pages.yml` workflow builds the dashboard after the pull request is merged into `main`. Repository administrators must select **GitHub Actions** as the Pages source once in the repository settings. - -The Pages deployment hosts only the frontend. It does not run Docker, Nginx, MQTT, or an API server. - ## Telemetry behavior - Payloads are validated before they update the display. diff --git a/telemetry/live-web-dashboard/vite.config.ts b/telemetry/live-web-dashboard/vite.config.ts index 142b7f0..260d245 100644 --- a/telemetry/live-web-dashboard/vite.config.ts +++ b/telemetry/live-web-dashboard/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ - base: process.env.VITE_BASE_PATH ?? "/", + base: "/", plugins: [react()], build: { outDir: "dist", From 14e88903a7355537958c78ff2f1fd6a3dc199f21 Mon Sep 17 00:00:00 2001 From: TheLostLeo Date: Thu, 15 Oct 2026 06:25:31 +0530 Subject: [PATCH 10/12] feat(telemetry): add pi-managed radius survey planning Signed-off-by: TheLostLeo --- .../pi-code/auto_grid_planner.py | 392 ++++++++++++++++++ .../pi-code/companion_api.py | 78 +++- .../pi-code/mavlink_manager.py | 42 +- .../pi-code/requirements.txt | 3 + .../pi-code/survey_config.json | 26 ++ .../pi-code/terrain_provider.py | 1 + .../pi-code/tests/test_auto_grid_planner.py | 162 ++++++++ .../pi-code/tests/test_companion_api.py | 49 ++- telemetry/live-web-dashboard/README.md | 74 ++-- telemetry/live-web-dashboard/nginx.conf | 6 + .../src/components/mission_planner.tsx | 368 ++++++---------- .../src/components/terrain_mission_map.tsx | 316 ++++---------- .../src/lib/survey_plan.test.ts | 29 ++ .../live-web-dashboard/src/lib/survey_plan.ts | 67 +++ telemetry/live-web-dashboard/src/styles.css | 15 + 15 files changed, 1108 insertions(+), 520 deletions(-) create mode 100644 telemetry/handheld-nrf-monitor/pi-code/auto_grid_planner.py create mode 100644 telemetry/handheld-nrf-monitor/pi-code/requirements.txt create mode 100644 telemetry/handheld-nrf-monitor/pi-code/survey_config.json create mode 100644 telemetry/handheld-nrf-monitor/pi-code/tests/test_auto_grid_planner.py create mode 100644 telemetry/live-web-dashboard/src/lib/survey_plan.test.ts create mode 100644 telemetry/live-web-dashboard/src/lib/survey_plan.ts diff --git a/telemetry/handheld-nrf-monitor/pi-code/auto_grid_planner.py b/telemetry/handheld-nrf-monitor/pi-code/auto_grid_planner.py new file mode 100644 index 0000000..8bf6f77 --- /dev/null +++ b/telemetry/handheld-nrf-monitor/pi-code/auto_grid_planner.py @@ -0,0 +1,392 @@ +"""Polygon camera surveys in a local metric projection; review/simulation only. + +Tracks are clipped to the survey polygon. Transit legs and optional run-outs +are displayed explicitly and are NOT obstacle/geofence avoidance routes. +""" + +from dataclasses import dataclass +import math +import json +import pathlib +import time +from typing import Any, Dict + +from pyproj import CRS, Transformer, Geod +from shapely import affinity +from shapely.geometry import LineString, Polygon, box +from shapely.ops import unary_union +from shapely.validation import explain_validity + +from search_mission_planner import _bounded_number, _navigation_item, _camera_trigger_item + +MAX_LINES = 10000 +MAX_ITEMS = 500 +MAX_SORTIES = 100 + + +def build_radius_survey(payload, telemetry): + """The browser supplies an area only; all flight settings belong to the Pi.""" + area = payload.get("search_area") + if not isinstance(area, dict): + raise ValueError("search_area must contain center and radius_m") + center = position(area.get("center"), "search center") + radius = number(area, "radius_m", None, 2, 10000) + if telemetry.get("armed"): + raise ValueError("Finish the current flight before planning from a new launch position") + try: + config = json.loads(pathlib.Path(__file__).with_name("survey_config.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError("Pi survey_config.json is missing or invalid; check the aircraft configuration") from exc + if not isinstance(config, dict) or not isinstance(config.get("survey_options"), dict): + raise ValueError("Pi survey_config.json must include survey_options") + geod = Geod(ellps="WGS84") + boundary = [] + for index in range(64): + # Circumscribe the requested circle so polygon approximation never + # silently excludes the rim of the user's search area. + longitude, latitude, _ = geod.fwd(center["longitude"], center["latitude"], index * 360 / 64, + radius / math.cos(math.pi / 64)) + boundary.append({"latitude": latitude, "longitude": longitude}) + source = dict(telemetry) + try: + fresh = all(0 <= time.time() - float(source.get(key, 0)) <= 5 + for key in ("last_position_timestamp", "last_gps_timestamp")) + except (TypeError, ValueError): + fresh = False + if not fresh or source.get("gps_fix_type") not in ("3D FIX", "DGPS", "RTK FLT", "RTK FIX"): + source["connected"] = False + request = {**config, "boundary": boundary} + request.pop("home", None) + plan = build_auto_grid_mission(request, source) + plan.data["search_area"] = {"center": center, "radius_m": radius} + plan.data["profile_name"] = str(config.get("profile_name", "pi_survey_profile")) + plan.data["terrain_enabled"] = config.get("terrain_enabled") is True + plan.data["surface_type"] = config.get("surface_type", "water") + if plan.data["home_source"] == "area_preview_only": + reason = "Pi needs fresh 3D GPS from the disarmed vehicle to determine launch/home" + plan.data["estimate"]["blocking_reasons"] = [reason] + plan.data["estimate"]["blocking_reasons"][1:] + for sortie in plan.data["sorties"]: + sortie["blocking_reasons"][0] = reason + return plan + + +def number(data, key, default, minimum, maximum): + value = data.get(key, default) + if isinstance(value, bool): + raise ValueError(f"{key} must be a number") + return _bounded_number(key, value, minimum, maximum) + + +def choice(data, key, default, options): + value = data.get(key, default) + if value not in options: + raise ValueError(f"{key} must be one of {', '.join(options)}") + return value + + +def position(value, name): + if not isinstance(value, dict): + raise ValueError(f"{name} must contain latitude and longitude") + return { + "latitude": number(value, "latitude", None, -85, 85), + "longitude": number(value, "longitude", None, -180, 180), + } + + +@dataclass +class AutoGridPlan: + data: Dict[str, Any] + + @property + def altitude_m(self): + return self.data["altitude_m"] + + @property + def home_latitude(self): + return self.data["home"]["latitude"] + + @property + def home_longitude(self): + return self.data["home"]["longitude"] + + @property + def center_latitude(self): + return self.data["center"]["latitude"] + + @property + def center_longitude(self): + return self.data["center"]["longitude"] + + @property + def cells(self): + return () + + def to_dict(self): + return self.data + + +def build_auto_grid_mission(payload, telemetry): + options = payload.get("survey_options") + if not isinstance(options, dict): + raise ValueError("survey_options must be an object") + raw_boundary = payload.get("boundary") + if not isinstance(raw_boundary, list) or not 3 <= len(raw_boundary) <= 64: + raise ValueError("Draw a closed boundary with 3 to 64 vertices") + boundary = [position(point, "boundary vertex") for point in raw_boundary] + if boundary[0] == boundary[-1]: + boundary.pop() + if len({(p['latitude'], p['longitude']) for p in boundary}) != len(boundary): + raise ValueError("Boundary contains duplicate vertices; remove repeated points") + if len(boundary) < 3: + raise ValueError("Boundary needs at least 3 distinct vertices") + lat = sum(p["latitude"] for p in boundary) / len(boundary) + lon = sum(p["longitude"] for p in boundary) / len(boundary) + local_crs = CRS.from_proj4(f"+proj=aeqd +lat_0={lat} +lon_0={lon} +datum=WGS84 +units=m") + to_local = Transformer.from_crs("EPSG:4326", local_crs, always_xy=True) + to_geo = Transformer.from_crs(local_crs, "EPSG:4326", always_xy=True) + polygon = Polygon([to_local.transform(p["longitude"], p["latitude"]) for p in boundary]) + if not polygon.is_valid: + raise ValueError(f"Invalid boundary: {explain_validity(polygon)}. Redraw without crossing edges") + if polygon.area < 4: + raise ValueError("Survey area must be at least 4 square metres") + x0, y0, x1, y1 = polygon.bounds + if max(x1 - x0, y1 - y0) > 20000: + raise ValueError("Survey boundary must fit within a 20 km extent; divide the area") + + def geo(point): + longitude, latitude = to_geo.transform(*point) + return {"latitude": round(latitude, 7), "longitude": round(longitude, 7)} + + home_source = "manual" + if payload.get("home") is not None: + home = position(payload["home"], "home") + elif telemetry.get("connected") and telemetry.get("latitude") is not None and telemetry.get("longitude") is not None: + home = position(telemetry, "telemetry position") + home_source = "vehicle_position" + else: + home = geo(polygon.representative_point().coords[0]) + home_source = "area_preview_only" + home_xy = to_local.transform(home["longitude"], home["latitude"]) + if math.hypot(*home_xy) > 30000: + raise ValueError("Home is more than 30 km from the area; set a nearby launch position") + + altitude = number(payload, "altitude_m", 5, 5, 120) + speed = number(payload, "cruise_speed_m_s", 2, 0.5, 15) + side = number(payload, "side_overlap_percent", 70, 0, 95) + forward = number(payload, "forward_overlap_percent", 75, 0, 95) + model = choice(options, "camera_model", "fov", ("fov", "sensor")) + if model == "sensor": + sensor_width = number(options, "sensor_width_mm", 6.17, 1, 60) + sensor_height = number(options, "sensor_height_mm", 4.55, 1, 60) + focal = number(options, "focal_length_mm", 4.5, 1, 300) + hfov = math.degrees(2 * math.atan(sensor_width / (2 * focal))) + vfov = math.degrees(2 * math.atan(sensor_height / (2 * focal))) + else: + hfov = number(payload, "camera_horizontal_fov_deg", 78, 10, 150) + vfov = number(payload, "camera_vertical_fov_deg", 60, 10, 150) + width = 2 * altitude * math.tan(math.radians(hfov / 2)) + height = 2 * altitude * math.tan(math.radians(vfov / 2)) + image_width = number(options, "image_width_px", 4000, 100, 30000) + image_height = number(options, "image_height_px", 3000, 100, 30000) + orientation = choice(options, "camera_orientation", "landscape", ("landscape", "portrait")) + across, along = (width, height) if orientation == "landscape" else (height, width) + automatic_spacing = across * (1 - side / 100) + trigger = round(along * (1 - forward / 100), 6) + spacing_mode = choice(options, "spacing_mode", "camera", ("camera", "manual")) + spacing = automatic_spacing if spacing_mode == "camera" else number(options, "line_spacing_m", 2, 0.25, 1000) + if min(spacing, trigger) < 0.25: + raise ValueError("Spacing below 0.25 m is unsupported; adjust altitude or overlap") + min_interval = number(options, "minimum_trigger_interval_s", 0.5, 0.1, 60) + overshoot = number(options, "overshoot_m", 0, 0, 50) + endurance = number(options, "endurance_minutes", 20, 3, 120) + reserve = number(options, "reserve_percent", 20, 10, 50) + budget = endurance * (1 - reserve / 100) + turn_seconds = 2 + number(options, "turn_seconds", 3, 0, 30) + angle_mode = choice(options, "angle_mode", "auto", ("auto", "manual")) + requested_angle = number(options, "grid_angle_deg", 90, 0, 179.99) + entry = choice(options, "entry", "nearest", ("nearest", "reverse")) + pattern = choice(options, "pattern", "single", ("single", "crosshatch")) + + def make_tracks(heading): + # Rotated x is along flight; y is across flight. Bearing is clockwise from north. + rotation = heading - 90 + rotated = affinity.rotate(polygon, rotation, origin=(0, 0)) + left, bottom, right, top = rotated.bounds + count = max(1, math.ceil((top - bottom) / spacing)) + if count > MAX_LINES: + raise ValueError("Grid exceeds 10000 lines; reduce the area or increase spacing") + actual = (top - bottom) / count + tracks = [] + for row in range(count): + y = bottom + (row + 0.5) * actual + cut = rotated.intersection(LineString([(left - 1, y), (right + 1, y)])) + pieces = [cut] if cut.geom_type == "LineString" else list(getattr(cut, "geoms", [])) + pieces = sorted((p for p in pieces if p.geom_type == "LineString" and p.length > 0.01), key=lambda p: p.bounds[0]) + if row % 2: + pieces.reverse() + for piece in pieces: + start, end = (piece.bounds[0] - overshoot, y), (piece.bounds[2] + overshoot, y) + if row % 2: + start, end = end, start + line = affinity.rotate(LineString([start, end]), -rotation, origin=(0, 0)) + tracks.append((tuple(line.coords[0]), tuple(line.coords[-1]), heading)) + if len(tracks) > MAX_LINES: + raise ValueError("Grid exceeds 10000 line segments; divide the boundary") + return tracks, actual + + def duration(tracks): + distance = 0.0 + previous = home_xy + for start, end, _ in tracks: + distance += math.dist(previous, start) + math.dist(start, end) + previous = end + distance += math.dist(previous, home_xy) + return distance, (distance / speed + 120 + turn_seconds * len(tracks)) / 60 + + if angle_mode == "auto": + # Bounded candidate search, not a claim of a globally optimal route. + candidates = list(range(0, 180, 15)) + edge = max(zip(polygon.exterior.coords, list(polygon.exterior.coords)[1:]), key=lambda p: math.dist(*p)) + candidates.append(math.degrees(math.atan2(edge[1][0] - edge[0][0], edge[1][1] - edge[0][1])) % 180) + choices = [] + for candidate in candidates: + try: + tracks, _ = make_tracks(candidate) + if tracks: + choices.append((duration(tracks)[1], candidate)) + except ValueError: + continue + if not choices: + raise ValueError("No grid within the 10000 line limit; divide the survey area") + angle = min(choices)[1] + else: + angle = requested_angle + tracks, actual_spacing = make_tracks(angle) + if pattern == "crosshatch": + second, _ = make_tracks((angle + 90) % 180) + tracks += second + if not tracks or len(tracks) > MAX_LINES: + raise ValueError("Grid must contain between 1 and 10000 line segments") + if math.dist(home_xy, tracks[-1][1]) < math.dist(home_xy, tracks[0][0]): + tracks = [(end, start, heading) for start, end, heading in reversed(tracks)] + if entry == "reverse": + tracks = [(end, start, heading) for start, end, heading in reversed(tracks)] + + footprints = [] + for start, end, heading in tracks: + rotation = heading - 90 + line = affinity.rotate(LineString([start, end]), rotation, origin=(0, 0)) + left, y, right, _ = line.bounds + # Continuous footprint union, conservative for discrete triggering when + # the final photograph is up to one trigger distance short of the end. + if line.coords[0][0] <= line.coords[-1][0]: + right = left + math.floor((right - left) / trigger) * trigger + else: + left = right - math.floor((right - left) / trigger) * trigger + footprints.append(affinity.rotate(box(left - along / 2, y - across / 2, right + along / 2, y + across / 2), -rotation, origin=(0, 0))) + covered = unary_union(footprints) + coverage = min(100, covered.intersection(polygon).area / polygon.area * 100) + blockers = [] + warnings = ["Flat-surface, nadir-camera model; terrain samples do not change mission altitudes.", + "Transit legs are straight connections, not obstacle or geofence avoidance.", + "ArduCopter setup: validate face-next-waypoint yaw, waypoint radius smaller than the line gap, and the camera trigger interface in SITL. Waypoint yaw/radius fields do not configure these settings."] + if home_source == "area_preview_only": + blockers.append("Set a launch/home position before export or simulation") + if coverage < 99.5: + blockers.append(f"Predicted footprint covers {coverage:.2f}% of the boundary; increase overlap or change grid angle") + if spacing > automatic_spacing + 0.001: + blockers.append("Manual line spacing exceeds the requested side-overlap spacing") + if trigger / speed < min_interval - 1e-9: + blockers.append(f"Camera cannot keep up: reduce speed to {trigger / min_interval:.2f} m/s or change forward overlap") + if altitude <= 15: + warnings.append("Low-altitude water clearance requires a validated sensor and autopilot setup; this planner does not configure surface following.") + + groups = [] + group = [] + group_distance = 0.0 + for track in tracks: + start, end, _ = track + previous = group[-1][1] if group else home_xy + candidate_distance = group_distance + math.dist(previous, start) + math.dist(start, end) + candidate_minutes = ((candidate_distance + math.dist(end, home_xy)) / speed + 120 + turn_seconds * (len(group) + 1)) / 60 + if group and (5 + 4 * (len(group) + 1) > MAX_ITEMS or candidate_minutes > budget): + groups.append(group) + group = [] + candidate_distance = math.dist(home_xy, start) + math.dist(start, end) + group.append(track) + group_distance = candidate_distance + if group: + groups.append(group) + if len(groups) > MAX_SORTIES: + raise ValueError("Survey needs more than 100 sorties; divide it into smaller areas") + + sorties, survey_lines = [], [] + # A centimetre tolerance avoids classifying coincident boundary transfers + # as outside because of floating-point intersection coordinates. + boundary_tolerance = polygon.buffer(0.01) + for group_index, group in enumerate(groups): + distance, minutes = duration(group) + items = [_navigation_item(0, 16, home["latitude"], home["longitude"], 0, current=1), + _navigation_item(1, 22, home["latitude"], home["longitude"], altitude)] + speed_item = _camera_trigger_item(2, 0) + speed_item.update(command=178, param1=1, param2=speed, param3=-1) + items.append(speed_item) + photo_count = 0 + path = [home] + outside = 0.0 + previous = home_xy + for start, end, heading in group: + start_geo, end_geo = geo(start), geo(end) + length = math.dist(start, end) + for point, before in ((start_geo, True), (end_geo, False)): + item = _navigation_item(len(items), 16, point["latitude"], point["longitude"], altitude) + # Copter uses its configured yaw behaviour and waypoint radius; + # it does not implement per-waypoint yaw/acceptance parameters. + item.update(param1=1.0, param2=0.0, param4=0.0) + items.append(item) + trigger_item = _camera_trigger_item(len(items), trigger if before else 0) + trigger_item["param1"] = trigger if before else 0 + items.append(trigger_item) + photo_count += math.floor(length / trigger) + 1 + outside += LineString([previous, start]).difference(boundary_tolerance).length + outside += LineString([start, end]).difference(boundary_tolerance).length + path.extend([start_geo, end_geo]) + survey_lines.append({"cell_id": 1, "line_index": len(survey_lines) + 1, + "sortie_id": group_index + 1, "start_latitude": start_geo["latitude"], + "start_longitude": start_geo["longitude"], "end_latitude": end_geo["latitude"], + "end_longitude": end_geo["longitude"], "length_m": round(length, 2)}) + previous = end + outside += LineString([previous, home_xy]).difference(boundary_tolerance).length + path.append(home) + items.append(_navigation_item(len(items), 16, home["latitude"], home["longitude"], altitude)) + items.append(_navigation_item(len(items), 21, home["latitude"], home["longitude"], 0)) + local_blockers = list(blockers) + if minutes > budget: + local_blockers.append("A single line plus home transit exceeds the sortie budget; reduce the area or move home") + sorties.append({"sortie_id": group_index + 1, "line_count": len(group), "route": path, + "distance_m": round(distance, 1), "estimated_minutes": round(minutes, 1), + "estimated_photos": photo_count, "outside_boundary_m": round(outside, 1), + "mission_item_count": len(items), "ready": not local_blockers, + "blocking_reasons": local_blockers, "mission_items": items if not local_blockers else []}) + + camera = {"horizontal_fov_deg": round(hfov, 2), "vertical_fov_deg": round(vfov, 2), + "footprint_width_m": round(width, 3), "footprint_height_m": round(height, 3), + "cross_track_m": round(across, 3), "along_track_m": round(along, 3), + "line_spacing_m": round(actual_spacing, 3), "requested_line_spacing_m": round(spacing, 3), + "trigger_distance_m": round(trigger, 3), "trigger_interval_s": round(trigger / speed, 3), + "effective_side_overlap_percent": round(100 * (1 - actual_spacing / across), 2), + "gsd_width_cm": round(width / image_width * 100, 3), + "gsd_height_cm": round(height / image_height * 100, 3)} + return AutoGridPlan({"schema_version": 2, "center": geo(polygon.centroid.coords[0]), "home": home, + "home_source": home_source, "boundary": boundary, "altitude_m": altitude, + "altitude_reference": "relative_home", "camera": camera, + "cruise_speed_m_s": speed, "survey_lines": survey_lines, "sorties": sorties, + "grid_angle_deg": round(angle, 2), "pattern": pattern, "area_m2": round(polygon.area, 1), + "coverage_percent": round(coverage, 2), "sortie_budget_minutes": round(budget, 2), + "estimate": {"survey_line_count": len(tracks), "recommended_sorties": len(sorties), + "total_distance_m": round(sum(s["distance_m"] for s in sorties), 1), + "estimated_minutes": round(sum(s["estimated_minutes"] for s in sorties), 1), + "estimated_photos": sum(s["estimated_photos"] for s in sorties), + "simulation_ready": all(s["ready"] for s in sorties), + "blocking_reasons": list(dict.fromkeys(r for s in sorties for r in s["blocking_reasons"])), + "warnings": warnings}}) diff --git a/telemetry/handheld-nrf-monitor/pi-code/companion_api.py b/telemetry/handheld-nrf-monitor/pi-code/companion_api.py index 1ea3fa9..946035c 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/companion_api.py +++ b/telemetry/handheld-nrf-monitor/pi-code/companion_api.py @@ -4,6 +4,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json import threading +import time +import uuid from typing import Any, Dict, Optional from urllib.parse import urlparse @@ -45,6 +47,8 @@ def stop(self) -> None: def _handler_for(mav_manager): mission_lock = threading.Lock() + plan_lock = threading.Lock() + plans = {} class CompanionRequestHandler(BaseHTTPRequestHandler): def do_OPTIONS(self) -> None: @@ -61,6 +65,8 @@ def do_GET(self) -> None: "simulation": bool(mav_manager.simulate), "connected": bool(telemetry.get("connected", False)), "live_execution_enabled": False, + "armed": bool(telemetry.get("armed", False)), + "mission_state": telemetry.get("mission_state", "idle"), }) return self._send_json(404, {"error": "endpoint not found"}) @@ -72,22 +78,45 @@ def do_POST(self) -> None: return try: payload = self._read_json() + if path.endswith("simulate") and not mav_manager.simulate: + self._send_json(403, {"error": "live Pixhawk execution is locked"}) + return + if "plan_id" in payload: + self._start_reviewed_plan(payload) + return plan = _plan_from_payload(payload, mav_manager.get_telemetry_snapshot()) + settings = plan.to_dict() if "search_area" in payload else payload terrain = ( - sample_plan_terrain(plan, str(payload.get("surface_type", "water"))) - if payload.get("terrain_enabled", False) + sample_plan_terrain(plan, str(settings.get("surface_type", "water"))) + if settings.get("terrain_enabled", False) else {"status": "disabled", "used_for_clearance": False} ) except (ValueError, UnicodeError, json.JSONDecodeError) as exc: self._send_json(400, {"error": str(exc)}) return + except ImportError: + self._send_json(503, {"error": "Install the Pi requirements.txt to enable polygon surveys"}) + return if path == "/api/mission/plan": plan_payload = plan.to_dict() plan_payload["terrain"] = terrain + if plan_payload.get("schema_version") == 2: + plan_payload["plan_id"] = uuid.uuid4().hex + with plan_lock: + expired = [key for key, (created, _) in plans.items() if time.monotonic() - created > 600] + for key in expired: + del plans[key] + while len(plans) >= 16: + del plans[next(iter(plans))] + plans[plan_payload["plan_id"]] = (time.monotonic(), plan_payload) self._send_json(200, {"status": "planned", "plan": plan_payload}) return + if "survey_options" in payload or "search_area" in payload: + self._send_json(400, {"error": "Generate and review a plan, then submit its plan_id and sortie_id"}) + return + if not mav_manager.simulate: self._send_json(403, { "error": "live Pixhawk execution is locked; validate this mission in simulation first" @@ -105,6 +134,9 @@ def do_POST(self) -> None: self._send_json(409, {"error": "another mission operation is in progress"}) return try: + if mav_manager.get_telemetry_snapshot().get("armed"): + self._send_json(409, {"error": "Simulator is armed; finish the current flight first"}) + return if not mav_manager.clear_all_missions(): self._send_json(500, {"error": "could not clear the simulated mission"}) return @@ -120,6 +152,42 @@ def do_POST(self) -> None: finally: mission_lock.release() + def _start_reviewed_plan(self, payload): + if urlparse(self.path).path != "/api/mission/simulate": + raise ValueError("plan_id is only accepted for simulation") + if not isinstance(payload["plan_id"], str): + raise ValueError("plan_id must be a string") + with plan_lock: + cached = plans.get(payload["plan_id"]) + if not cached or time.monotonic() - cached[0] > 600: + self._send_json(409, {"error": "Plan expired or API restarted; generate and review it again"}) + return + plan = cached[1] + sortie = next((s for s in plan["sorties"] if s["sortie_id"] == payload.get("sortie_id")), None) + if not sortie: + raise ValueError("Select a valid sortie_id") + if not sortie["ready"]: + self._send_json(422, {"error": "Resolve the survey checks", "reasons": sortie["blocking_reasons"]}) + return + if payload.get("review_confirmed") is not True: + raise ValueError("Confirm the selected route and relative-home altitude before simulation") + if not mission_lock.acquire(blocking=False): + self._send_json(409, {"error": "Another mission operation is in progress"}) + return + try: + if mav_manager.get_telemetry_snapshot().get("armed"): + self._send_json(409, {"error": "Simulator is armed; finish the current flight first"}) + return + if not mav_manager.clear_all_missions() or not mav_manager.upload_mission_items(sortie["mission_items"]): + self._send_json(500, {"error": "Could not load the simulated mission"}) + return + if not mav_manager.set_arm(True) or not mav_manager.set_flight_mode("AUTO"): + self._send_json(500, {"error": "Could not start the simulated mission"}) + return + self._send_json(202, {"status": "simulation_started", "plan": plan, "sortie_id": sortie["sortie_id"]}) + finally: + mission_lock.release() + def _read_json(self) -> Dict[str, Any]: try: content_length = int(self.headers.get("Content-Length", "0")) @@ -154,6 +222,12 @@ def log_message(self, format_string: str, *args) -> None: def _plan_from_payload(payload: Dict[str, Any], telemetry: Dict[str, Any]): + if "search_area" in payload: + from auto_grid_planner import build_radius_survey + return build_radius_survey(payload, telemetry) + if "survey_options" in payload: + from auto_grid_planner import build_auto_grid_mission + return build_auto_grid_mission(payload, telemetry) center = payload.get("center") if not isinstance(center, dict): raise ValueError("center must contain latitude and longitude") diff --git a/telemetry/handheld-nrf-monitor/pi-code/mavlink_manager.py b/telemetry/handheld-nrf-monitor/pi-code/mavlink_manager.py index 4353c19..8203eb6 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/mavlink_manager.py +++ b/telemetry/handheld-nrf-monitor/pi-code/mavlink_manager.py @@ -251,6 +251,10 @@ def upload_mission_items(self, items: List[Dict[str, Any]]) -> bool: print(f"[OK] Simulation: Loaded {len(items)} mission items into simulator memory.") with self._lock: self.sim_mission_items = items + if items: + self.sim_current_lat = items[0].get("x", 0) / 1e7 + self.sim_current_lon = items[0].get("y", 0) / 1e7 + self.sim_current_alt = 0.0 self.sim_mission_index = 1 self.state["mission_total_items"] = len(items) self.state["mission_current_seq"] = 1 @@ -514,6 +518,7 @@ def _handle_mavlink_message(self, msg, mavutil, now: Optional[float] = None) -> self.state["battery_remaining"] = msg.battery_remaining elif msg_type == 'GLOBAL_POSITION_INT': + self.state["last_position_timestamp"] = now self.state["latitude"] = round(msg.lat / 1e7, 7) self.state["longitude"] = round(msg.lon / 1e7, 7) self.state["altitude_relative"] = round(msg.relative_alt / 1000.0, 2) @@ -523,6 +528,7 @@ def _handle_mavlink_message(self, msg, mavutil, now: Optional[float] = None) -> self.state["heading"] = heading elif msg_type == 'GPS_RAW_INT': + self.state["last_gps_timestamp"] = now if msg.satellites_visible != 255: self.state["satellites"] = msg.satellites_visible fix_map = {0: "NO FIX", 1: "NO FIX", 2: "2D FIX", 3: "3D FIX", 4: "DGPS", 5: "RTK FLT", 6: "RTK FIX"} @@ -729,6 +735,8 @@ def _run_simulation(self): self.sim_mission_index = 1 EARTH_R = 6378137.0 + survey_speed = 5.0 + dwell_remaining = 0.0 while self.running: time.sleep(0.1) @@ -745,6 +753,7 @@ def _run_simulation(self): roll_actual = 0.0 pitch_actual = 0.0 climb_rate = 0.0 + sim_ground_speed = 0.0 mission_state = self.state.get("mission_state", "STANDBY") progress_pct = self.state.get("mission_progress_percent", 0) @@ -771,6 +780,8 @@ def _run_simulation(self): # 2. Speed Command (MAV_CMD_DO_CHANGE_SPEED = 178) elif cmd in (178, 206): + if cmd == 178 and item.get("param2", 0) > 0: + survey_speed = item["param2"] self.sim_mission_index += 1 # 3. Waypoint Command (MAV_CMD_NAV_WAYPOINT = 16) @@ -791,28 +802,34 @@ def _run_simulation(self): roll_actual = round(min(max(heading_err * 0.5, -20.0), 20.0), 2) pitch_actual = -3.5 # Forward acceleration pitch - # Step towards target at 5.0 m/s (0.5m per 0.1s tick) - step_m = min(0.5, dist) + step_m = min(survey_speed * 0.1, dist) if dist > 0.01: + sim_ground_speed = step_m / 0.1 sim_lat += (dlat / dist) * (step_m / EARTH_R) * (180.0 / math.pi) sim_lon += (dlon / dist) * (step_m / (EARTH_R * math.cos(math.radians(sim_lat)))) * (180.0 / math.pi) mission_state = f"NAV WP {self.sim_mission_index}/{total_wps - 1} ({dist:.1f}m)" - # Reached Waypoint Acceptance Radius (2.0m) - if dist <= 2.0: - self.sim_mission_index += 1 + if dist <= 0.05: + sim_lat, sim_lon, sim_alt = target_lat, target_lon, target_alt + dwell_remaining += 0.1 + if dwell_remaining >= item.get("param1", 0): + self.sim_mission_index += 1 + dwell_remaining = 0.0 # 4. Return to Launch / Land (MAV_CMD_NAV_RETURN_TO_LAUNCH = 20, MAV_CMD_NAV_LAND = 21) elif cmd in (20, 21): - dlat = (base_lat - sim_lat) * (math.pi / 180.0) * EARTH_R - dlon = (base_lon - sim_lon) * (math.pi / 180.0) * EARTH_R * math.cos(math.radians(sim_lat)) + land_lat = item.get("x", 0) / 1e7 if cmd == 21 else base_lat + land_lon = item.get("y", 0) / 1e7 if cmd == 21 else base_lon + dlat = (land_lat - sim_lat) * (math.pi / 180.0) * EARTH_R + dlon = (land_lon - sim_lon) * (math.pi / 180.0) * EARTH_R * math.cos(math.radians(sim_lat)) dist = math.sqrt(dlat**2 + dlon**2) if dist > 2.0: target_bearing = (math.degrees(math.atan2(dlon, dlat)) + 360.0) % 360.0 sim_heading = target_bearing - step_m = min(0.5, dist) + step_m = min(survey_speed * 0.1, dist) + sim_ground_speed = step_m / 0.1 sim_lat += (dlat / dist) * (step_m / EARTH_R) * (180.0 / math.pi) sim_lon += (dlon / dist) * (step_m / (EARTH_R * math.cos(math.radians(sim_lat)))) * (180.0 / math.pi) mission_state = f"RTL RETURN ({dist:.1f}m)" @@ -833,7 +850,7 @@ def _run_simulation(self): self.sim_current_lon = sim_lon self.sim_current_alt = sim_alt - elif is_auto and has_mission and self.sim_mission_index >= len(self.sim_mission_items): + elif has_mission and self.sim_mission_index >= len(self.sim_mission_items): mission_state = "MISSION COMPLETED" progress_pct = 100 @@ -845,6 +862,7 @@ def _run_simulation(self): sim_heading = round((t * 12.0) % 360, 1) roll_actual = round(3.5 * math.sin(t * 0.8), 2) pitch_actual = round(2.0 * math.cos(t * 0.5), 2) + sim_ground_speed = 2.0 gyro_x = round(5.0 * math.cos(t * 1.5), 2) gyro_y = round(4.0 * math.sin(t * 1.2), 2) @@ -865,6 +883,8 @@ def _run_simulation(self): m2 = int(base_pwm - 35 * math.sin(t * 2.0)) m3 = int(base_pwm + 40 * math.cos(t * 2.0)) m4 = int(base_pwm - 30 * math.cos(t * 2.0)) + if has_mission and not self.state.get("armed"): + m1 = m2 = m3 = m4 = 1000 with self._lock: self.state.update({ @@ -884,7 +904,7 @@ def _run_simulation(self): "rc_rssi": int(92 + 5 * math.sin(t * 0.1)), "radio_link_quality": 98, "heading": round(sim_heading, 1), - "ground_speed": 5.0 if is_auto and has_mission else 2.0, + "ground_speed": round(sim_ground_speed, 2), "mission_state": mission_state, "mission_progress_percent": progress_pct, "mission_current_seq": self.sim_mission_index, @@ -907,6 +927,8 @@ def _run_simulation(self): "motor_pwm": [m1, m2, m3, m4], "motor_percent": [int((m - 1000) / 10.0) for m in [m1, m2, m3, m4]], "last_packet_timestamp": time.time(), + "last_position_timestamp": time.time(), + "last_gps_timestamp": time.time(), "last_heartbeat_timestamp": time.time(), "last_message_type": "SIMULATION", "mavlink_status": "Simulation telemetry running", diff --git a/telemetry/handheld-nrf-monitor/pi-code/requirements.txt b/telemetry/handheld-nrf-monitor/pi-code/requirements.txt new file mode 100644 index 0000000..92c7178 --- /dev/null +++ b/telemetry/handheld-nrf-monitor/pi-code/requirements.txt @@ -0,0 +1,3 @@ +shapely>=2.0,<3 +pyproj>=3.6,<4 +pymavlink>=2.4,<3 diff --git a/telemetry/handheld-nrf-monitor/pi-code/survey_config.json b/telemetry/handheld-nrf-monitor/pi-code/survey_config.json new file mode 100644 index 0000000..e69c449 --- /dev/null +++ b/telemetry/handheld-nrf-monitor/pi-code/survey_config.json @@ -0,0 +1,26 @@ +{ + "profile_name": "downward_water_camera", + "altitude_m": 5, + "cruise_speed_m_s": 2, + "camera_horizontal_fov_deg": 78, + "camera_vertical_fov_deg": 60, + "side_overlap_percent": 70, + "forward_overlap_percent": 75, + "surface_type": "water", + "terrain_enabled": false, + "survey_options": { + "camera_model": "fov", + "camera_orientation": "landscape", + "image_width_px": 4000, + "image_height_px": 3000, + "minimum_trigger_interval_s": 0.5, + "spacing_mode": "camera", + "angle_mode": "auto", + "entry": "nearest", + "pattern": "single", + "overshoot_m": 0, + "endurance_minutes": 20, + "reserve_percent": 20, + "turn_seconds": 3 + } +} diff --git a/telemetry/handheld-nrf-monitor/pi-code/terrain_provider.py b/telemetry/handheld-nrf-monitor/pi-code/terrain_provider.py index b76cd6a..f27e8f1 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/terrain_provider.py +++ b/telemetry/handheld-nrf-monitor/pi-code/terrain_provider.py @@ -17,6 +17,7 @@ def sample_plan_terrain(plan, surface_type: str = "water") -> Dict[str, Any]: (plan.center_latitude, plan.center_longitude), ] points.extend((cell.center_latitude, cell.center_longitude) for cell in plan.cells) + points.extend((point["latitude"], point["longitude"]) for point in plan.to_dict().get("boundary", [])) result = sample_elevations(points) result["surface_type"] = surface_type result["used_for_clearance"] = False diff --git a/telemetry/handheld-nrf-monitor/pi-code/tests/test_auto_grid_planner.py b/telemetry/handheld-nrf-monitor/pi-code/tests/test_auto_grid_planner.py new file mode 100644 index 0000000..bbe8691 --- /dev/null +++ b/telemetry/handheld-nrf-monitor/pi-code/tests/test_auto_grid_planner.py @@ -0,0 +1,162 @@ +import math +import pathlib +import sys +import unittest +import time +from unittest.mock import patch + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) +from auto_grid_planner import build_auto_grid_mission, build_radius_survey +from mavlink_manager import MAVLinkManager + + +def survey_payload(width=50, height=50): + lat, lon = 12.971598, 77.594562 + dx, dy = width / 111320 / math.cos(math.radians(lat)), height / 111320 + return {"boundary": [{"latitude": lat, "longitude": lon}, {"latitude": lat, "longitude": lon + dx}, + {"latitude": lat + dy, "longitude": lon + dx}, {"latitude": lat + dy, "longitude": lon}], + "home": {"latitude": lat, "longitude": lon}, "altitude_m": 5, "cruise_speed_m_s": 2, + "survey_options": {"angle_mode": "manual", "grid_angle_deg": 90}} + + +class AutoGridTests(unittest.TestCase): + def test_radius_request_uses_pi_settings_and_vehicle_home(self): + center = {"latitude": 12.971598, "longitude": 77.594562} + request = {"search_area": {"center": center, "radius_m": 25}, "altitude_m": 100, + "home": {"latitude": 0, "longitude": 0}, "survey_options": {"spacing_mode": "manual", "line_spacing_m": 100}} + telemetry = {**center, "connected": True, "armed": False, "gps_fix_type": "3D FIX", + "last_position_timestamp": time.time(), "last_gps_timestamp": time.time()} + plan = build_radius_survey(request, telemetry).to_dict() + self.assertEqual(5, plan["altitude_m"]) + self.assertEqual(center, plan["home"]) + self.assertEqual("vehicle_position", plan["home_source"]) + self.assertEqual(64, len(plan["boundary"])) + self.assertGreaterEqual(plan["area_m2"], math.pi * 25 ** 2) + self.assertLess(plan["camera"]["line_spacing_m"], 3) + self.assertTrue(all(sortie["ready"] for sortie in plan["sorties"])) + stale = build_radius_survey(request, {**telemetry, "last_gps_timestamp": 0}).to_dict() + self.assertFalse(stale["sorties"][0]["ready"]) + self.assertIn("fresh 3D GPS", stale["estimate"]["blocking_reasons"][0]) + with self.assertRaisesRegex(ValueError, "current flight"): + build_radius_survey(request, {**telemetry, "armed": True}) + + def plan(self, payload): + return build_auto_grid_mission(payload, {}).to_dict() + + def test_camera_axes_coverage_and_export_sequence(self): + plan = self.plan(survey_payload()) + camera = plan["camera"] + self.assertAlmostEqual(camera["cross_track_m"], 10 * math.tan(math.radians(39)), places=3) + self.assertAlmostEqual(camera["trigger_distance_m"], 10 * math.tan(math.radians(30)) * .25, places=3) + self.assertLessEqual(camera["line_spacing_m"], camera["cross_track_m"] * .3 + .001) + self.assertGreaterEqual(plan["coverage_percent"], 99.5) + for sortie in plan["sorties"]: + self.assertTrue(sortie["ready"], sortie["blocking_reasons"]) + self.assertLessEqual(sortie["outside_boundary_m"], .1) + items = sortie["mission_items"] + self.assertEqual([16, 22, 178], [item["command"] for item in items[:3]]) + self.assertEqual(2, items[2]["param2"]) + self.assertEqual([16, 206, 16, 206], [item["command"] for item in items[3:7]]) + self.assertGreater(items[4]["param1"], 0) + self.assertEqual(0, items[6]["param1"]) + self.assertEqual(21, items[-1]["command"]) + self.assertEqual(list(range(len(items))), [item["seq"] for item in items]) + self.assertEqual(items[0]["x"], items[-1]["x"]) + + def test_sensor_orientation_and_gsd(self): + payload = survey_payload() + payload["survey_options"].update(camera_model="sensor", sensor_width_mm=6, sensor_height_mm=4, + focal_length_mm=5, camera_orientation="portrait", minimum_trigger_interval_s=.1) + camera = self.plan(payload)["camera"] + self.assertEqual(4, camera["cross_track_m"]) + self.assertEqual(6, camera["along_track_m"]) + self.assertEqual(.15, camera["gsd_width_cm"]) + + def test_invalid_geometry_and_numeric_values(self): + payload = survey_payload() + payload["boundary"][1], payload["boundary"][2] = payload["boundary"][2], payload["boundary"][1] + with self.assertRaisesRegex(ValueError, "Invalid boundary"): + self.plan(payload) + for value in [float("nan"), float("inf"), True, -10]: + payload = survey_payload(); payload["altitude_m"] = value + with self.assertRaises(ValueError): + self.plan(payload) + + def test_camera_rate_manual_gaps_and_missing_home_block(self): + payload = survey_payload(); payload["cruise_speed_m_s"] = 15 + plan = self.plan(payload) + self.assertIn("Camera cannot keep up", " ".join(plan["estimate"]["blocking_reasons"])) + self.assertEqual([], plan["sorties"][0]["mission_items"]) + payload = survey_payload(); payload["survey_options"].update(spacing_mode="manual", line_spacing_m=20) + plan = self.plan(payload) + self.assertLess(plan["coverage_percent"], 99.5) + self.assertIn("Manual line spacing", " ".join(plan["estimate"]["blocking_reasons"])) + payload = survey_payload(); del payload["home"] + self.assertFalse(self.plan(payload)["sorties"][0]["ready"]) + + def test_split_keeps_every_line_and_limits_each_sortie(self): + plan = self.plan(survey_payload(150, 150)) + self.assertGreater(len(plan["sorties"]), 1) + self.assertEqual(len(plan["survey_lines"]), sum(sortie["line_count"] for sortie in plan["sorties"])) + for sortie in plan["sorties"]: + self.assertLessEqual(sortie["mission_item_count"], 500) + self.assertLessEqual(sortie["estimated_minutes"], plan["sortie_budget_minutes"] + .05) + self.assertTrue(sortie["ready"]) + + def test_rotation_reverse_crosshatch_and_concave_boundary(self): + payload = survey_payload(60, 30); payload["survey_options"]["grid_angle_deg"] = 35 + first = self.plan(payload) + payload["survey_options"]["entry"] = "reverse" + reversed_plan = self.plan(payload) + self.assertEqual(first["survey_lines"][0]["start_latitude"], reversed_plan["survey_lines"][-1]["end_latitude"]) + payload["survey_options"]["pattern"] = "crosshatch" + self.assertGreater(self.plan(payload)["estimate"]["survey_line_count"], first["estimate"]["survey_line_count"]) + payload = survey_payload(); a, b, c, d = payload["boundary"] + middle = {"latitude": (a["latitude"] + c["latitude"]) / 2, "longitude": (a["longitude"] + c["longitude"]) / 2} + payload["boundary"] = [a, b, middle, c, d] + plan = self.plan(payload) + self.assertGreater(plan["area_m2"], 1000) + self.assertTrue(plan["survey_lines"]) + payload["survey_options"]["angle_mode"] = "auto" + self.assertGreaterEqual(self.plan(payload)["grid_angle_deg"], 0) + + def test_indivisible_line_exceeding_budget_is_blocked(self): + payload = survey_payload(500, 10) + payload["survey_options"].update(endurance_minutes=3, reserve_percent=50) + plan = self.plan(payload) + self.assertFalse(plan["sorties"][0]["ready"]) + self.assertEqual([], plan["sorties"][0]["mission_items"]) + + def test_simulator_completes_at_home_and_stays_landed(self): + payload = survey_payload(5, 5) + plan = self.plan(payload) + manager = MAVLinkManager(simulate=True) + manager.running = True + ticks, snapshots = 0, [] + + def tick(_): + nonlocal ticks + ticks += 1 + if ticks == 1: + manager.upload_mission_items(plan["sorties"][0]["mission_items"]) + manager.set_arm(True) + manager.set_flight_mode("AUTO") + snapshots.append(manager.get_telemetry_snapshot()) + if ticks >= 600: + manager.running = False + + with patch("mavlink_manager.time.sleep", side_effect=tick): + manager._run_simulation() + final = manager.get_telemetry_snapshot() + self.assertEqual("MISSION COMPLETED", final["mission_state"]) + self.assertFalse(final["armed"]) + self.assertEqual(0, final["altitude_relative"]) + self.assertEqual(0, final["ground_speed"]) + self.assertEqual(100, final["mission_progress_percent"]) + self.assertAlmostEqual(payload["home"]["latitude"], final["latitude"], places=6) + self.assertTrue(any(1.9 <= item["ground_speed"] <= 2.1 for item in snapshots)) + self.assertEqual(0, snapshots[-50]["altitude_relative"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_api.py b/telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_api.py index 7c09d0a..daae3af 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_api.py +++ b/telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_api.py @@ -2,6 +2,8 @@ import pathlib import sys import unittest +import time +from unittest.mock import patch from urllib.error import HTTPError from urllib.request import Request, urlopen @@ -10,15 +12,19 @@ sys.path.insert(0, str(PI_CODE)) from companion_api import CompanionAPIServer # noqa: E402 +from test_auto_grid_planner import survey_payload class FakeMAVLinkManager: def __init__(self, simulate: bool): self.simulate = simulate self.calls = [] + self.armed = False + self.uploaded = [] def get_telemetry_snapshot(self): - return {"connected": True, "latitude": 12.9700, "longitude": 77.5900} + return {"connected": True, "latitude": 12.9700, "longitude": 77.5900, "armed": self.armed, + "gps_fix_type": "3D FIX", "last_position_timestamp": time.time(), "last_gps_timestamp": time.time()} def clear_all_missions(self): self.calls.append("clear") @@ -26,10 +32,12 @@ def clear_all_missions(self): def upload_mission_items(self, items): self.calls.append(("upload", len(items))) + self.uploaded = items return True def set_arm(self, arm): self.calls.append(("arm", arm)) + self.armed = arm return True def set_flight_mode(self, mode): @@ -71,6 +79,16 @@ def test_plan_endpoint_returns_generated_grid(self): self.assertEqual(1, body["plan"]["cell_count"]) self.assertGreater(body["plan"]["estimate"]["survey_line_count"], 0) + def test_radius_only_api_request_loads_pi_profile(self): + _, port = self._start(simulate=True) + self.payload = json.dumps({"search_area": {"center": {"latitude": 12.9700, "longitude": 77.5900}, "radius_m": 25}}).encode() + with self._post(port, "/api/mission/plan") as response: + plan = json.load(response)["plan"] + self.assertEqual(25, plan["search_area"]["radius_m"]) + self.assertEqual("downward_water_camera", plan["profile_name"]) + self.assertTrue(plan["plan_id"]) + self.assertEqual("disabled", plan["terrain"]["status"]) + def test_live_execution_is_locked(self): manager, port = self._start(simulate=False) @@ -93,6 +111,35 @@ def test_simulation_execution_loads_and_starts_mission(self): self.assertGreater(manager.calls[1][1], 8) self.assertEqual([("arm", True), ("mode", "AUTO")], manager.calls[2:]) + def test_reviewed_sortie_runs_exact_cached_items_and_rejects_replacement(self): + manager, port = self._start(simulate=True) + self.payload = json.dumps(survey_payload()).encode() + with self._post(port, "/api/mission/plan") as response: + plan = json.load(response)["plan"] + self.payload = json.dumps({"plan_id": plan["plan_id"], "sortie_id": 1, "review_confirmed": True, + "altitude_m": 100}).encode() + with self._post(port, "/api/mission/simulate") as response: + self.assertEqual(202, response.status) + self.assertEqual(plan["sorties"][0]["mission_items"], manager.uploaded) + with self.assertRaises(HTTPError) as raised: + self._post(port, "/api/mission/simulate") + self.assertEqual(409, raised.exception.code) + + def test_cache_expiry_and_review_confirmation(self): + manager, port = self._start(simulate=True) + self.payload = json.dumps(survey_payload()).encode() + with self._post(port, "/api/mission/plan") as response: + plan = json.load(response)["plan"] + self.payload = json.dumps({"plan_id": plan["plan_id"], "sortie_id": 1}).encode() + with self.assertRaises(HTTPError) as raised: + self._post(port, "/api/mission/simulate") + self.assertEqual(400, raised.exception.code) + with patch("companion_api.time.monotonic", return_value=float("inf")): + with self.assertRaises(HTTPError) as raised: + self._post(port, "/api/mission/simulate") + self.assertEqual(409, raised.exception.code) + self.assertEqual([], manager.calls) + if __name__ == "__main__": unittest.main() diff --git a/telemetry/live-web-dashboard/README.md b/telemetry/live-web-dashboard/README.md index df84102..113e4a4 100644 --- a/telemetry/live-web-dashboard/README.md +++ b/telemetry/live-web-dashboard/README.md @@ -43,40 +43,50 @@ The relay should authenticate the vehicle, encrypt traffic, restrict allowed bro - The battery cell count can be selected from 2S through 6S. - Charts display 64 samples collected at the dashboard's 1 Hz polling rate. -## Camera coverage mission planner - -Run the Raspberry Pi companion in simulation mode, then open the dashboard with -`?view=mission`. The planner: - -- draws terrain and map context with MapLibre; -- divides the selected square into the requested number of search cells; -- calculates the downward camera footprint from altitude and horizontal and - vertical field of view; -- creates parallel lawnmower lines in every cell using the requested side - overlap; -- sets camera trigger distance from forward overlap; -- estimates route distance, duration, photographs, mission items and required - sorties; and -- blocks simulator start when the mission exceeds the 500 item or 20 minute - single sortie limits. - -For a plan that fits the single sortie limits, select **Download Mission -Planner file**. In Mission Planner, open **PLAN**, select **Load WP File**, and -choose `sentinelblue_camera_survey.waypoints`. Review the route and vehicle -parameters before writing it to a flight controller. - -The default map uses OpenStreetMap raster tiles and MapLibre demo terrain. Set -`VITE_MAPTILER_KEY` at build time to use the MapTiler hybrid map: +## Auto-grid survey planner + +Start the companion API on the Pi (or locally without hardware): ```bash -VITE_MAPTILER_KEY=your_key npm run build +cd telemetry/handheld-nrf-monitor/pi-code +python -m pip install -r requirements.txt +python main.py --simulate --no-radio ``` -The companion samples OpenTopoData SRTM90m by default. Set -`TERRAIN_API_URL` to another compatible OpenTopoData endpoint when running a -private terrain service. +Open the Docker dashboard at `http://localhost:8080/?view=mission`, or the Vite URL with `?view=mission`. Enter the Pi address and port 8765 in **Companion API**. Docker serves the website; the Python companion runs separately and must be reachable from the browser. Use a trusted network; the planning/simulation API has no authentication. + +1. Click the map to select the **search centre**. Coordinate entry is available under **Connection & coordinate entry** for keyboard use. +2. Enter the **search radius in metres** (1,000 m = 1 km), then select **Generate survey on Pi**. +3. The browser sends only `search_area: {center: {latitude, longitude}, radius_m}`. The Pi reads its local `survey_config.json`, gets launch/home from fresh 3D vehicle GPS, creates a geodesic circular boundary and computes the grid, spacing, direction, camera triggering and battery sorties. The vehicle must be disarmed. Without usable GPS, the Pi can preview coverage but blocks export and simulation. +4. Review the returned route. Select a sortie and **Export to Mission Planner** to download `sentinelblue_survey_N.waypoints` (QGC WPL 110). In Mission Planner use **PLAN → Load WP File**. Export each battery sortie separately. Changes to the point or radius invalidate the old plan. +5. **Simulate this sortie** executes the exact cached plan after acknowledgement. Plans expire after 10 minutes or API restart. The API rejects replacement while the simulator is armed and rejects all live execution. Companion assist mode remains a readiness monitor. + +### Configure the aircraft once on the Pi + +Edit `telemetry/handheld-nrf-monitor/pi-code/survey_config.json` on the companion computer. Defaults are 5 m height, 2 m/s, 78° × 60° FOV, 70% side overlap, 75% forward overlap, 20 minute endurance and 20% reserve. These are starting values, not measured specifications for your hardware. Calibrate the camera, endurance and trigger interval for the actual aircraft before flight validation. The file is loaded for each planning request; the browser cannot override it through a point/radius request. Existing reviewed plans retain their original settings. + +The Pi calculates spacing from its camera model, chooses an estimated shortest direction from 15° bearing candidates plus the longest boundary edge, and splits complete lines into sorties capped at 500 commands and the configured battery budget. Every sortie contains takeoff, speed, survey/camera commands, return and land. A 64-sided polygon circumscribes the requested circle so the approximation does not exclude the circular rim. Very large searches may exceed the 10,000 line / 100 sortie planning limits and must be divided into smaller search areas. + +The geometry engine also retains its polygon planning API for integration; the operator-facing screen uses only point and radius. + +### Camera and flight assumptions + +Footprint = `2 × height × tan(FOV / 2)`. Maximum line gap = cross-track footprint × `(1 − side overlap)`. Trigger distance = along-track footprint × `(1 − forward overlap)`. Spacing tightens to fit the polygon extent. Crosshatch passes can have slightly different actual gaps; the displayed gap is the first pass. Modelled coverage uses flat, nadir, aligned footprints with distance-triggered photos. At least 99.5% modelled coverage is required to export; this is not measured image coverage. + +A camera-rate check blocks intervals shorter than the entered camera minimum. Export includes `DO_CHANGE_SPEED` and `DO_SET_CAM_TRIGG_DIST`; configure and test the actual camera interface and yaw behaviour in ArduPilot before flight. The simulator models route, commanded speed and waypoint dwell; it does not simulate actual photos, wind, vehicle dynamics or camera latency. + +For ArduCopter, configure face-next-waypoint yaw and a waypoint radius smaller than the line gap, and validate the route in ArduPilot SITL. Per-waypoint yaw and acceptance-radius fields do not configure these behaviours in Copter; see the [official mission command reference](https://docs.ardupilot.org/copter/docs/mission-command-list.html). The built-in simulator is not ArduPilot SITL. + +Each line has a one-second dwell at each end. Estimates add the entered turn allowance per line and two minutes for takeoff/landing. They do not measure battery capacity, wind or power consumption. A line that cannot fit one sortie is blocked rather than truncated. + +**Altitude is relative to home, not terrain-following.** Coverage assumes a flat surface at home elevation. A 5 m mission over water requires a validated downward sensor and flight-controller configuration; this planner does not configure them. DEM data is not water clearance. Run-outs and straight transfers may leave the boundary; the boundary is not a geofence. The planner does not provide obstacle avoidance, airspace checks or industrial flight certification. + +### Maps and terrain + +The default map uses OpenStreetMap raster tiles and MapLibre demo hillshade. Set `VITE_MAPTILER_KEY` at build time for MapTiler hybrid imagery. The map stays top-down for boundary accuracy. + +Optional terrain context samples home, centre and boundary corners using OpenTopoData SRTM90m. Set `TERRAIN_API_URL` to another compatible endpoint. Missing tiles or terrain data do not change mission altitude. Coordinates remain editable when imagery is unavailable. + +### Verification -Terrain elevation is planning context. A 5 metre flight over water requires a -tested downward rangefinder to measure surface clearance. The UI requires the -operator to acknowledge that sensor before simulation. Live Pixhawk mission -execution remains locked. +Run `python -m unittest discover -s tests -v` from the Pi code directory and `npm run check` from the dashboard directory. Tests cover camera axes, sensor dimensions, coverage, invalid polygons, rotation, reverse entry, concave boundaries, crosshatch, camera rate, sortie limits, exact-plan execution, expiry, confirmation, live/armed guards, WPL coordinate frames and boundary parsing. diff --git a/telemetry/live-web-dashboard/nginx.conf b/telemetry/live-web-dashboard/nginx.conf index 1a40d2c..24c4fd3 100644 --- a/telemetry/live-web-dashboard/nginx.conf +++ b/telemetry/live-web-dashboard/nginx.conf @@ -29,4 +29,10 @@ server { location /assets/ { expires 1y; } + + location ~ \.mjs$ { + default_type application/javascript; + expires 1y; + try_files $uri =404; + } } diff --git a/telemetry/live-web-dashboard/src/components/mission_planner.tsx b/telemetry/live-web-dashboard/src/components/mission_planner.tsx index e9dd39d..c3684ed 100644 --- a/telemetry/live-web-dashboard/src/components/mission_planner.tsx +++ b/telemetry/live-web-dashboard/src/components/mission_planner.tsx @@ -1,261 +1,139 @@ -import { useEffect, useRef, useState } from "react"; -import { CameraIcon, CrosshairIcon, DownloadIcon, MapPinnedIcon, PlayIcon, RouteIcon, ShieldAlertIcon, ShieldCheckIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { DownloadIcon, MapPinnedIcon } from "lucide-react"; import { TerrainMissionMap } from "./terrain_mission_map"; +import { downloadText, missionPlannerFile, radiusPreview, type Position, type SurveyPlan } from "../lib/survey_plan"; -interface MissionCell { - cell_id: number; row: number; column: number; - north_m: number; east_m: number; south_m: number; west_m: number; - center_latitude: number; center_longitude: number; -} -interface SurveyLine { - cell_id: number; line_index: number; - start_latitude: number; start_longitude: number; - end_latitude: number; end_longitude: number; length_m: number; -} -interface MissionPlan { - center: { latitude: number; longitude: number }; - home: { latitude: number; longitude: number }; - radius_km: number; altitude_m: number; cruise_speed_m_s: number; - cell_count: number; rows: number; columns: number; - cells: MissionCell[]; survey_lines: SurveyLine[]; phases: string[]; - mission_items: Array>; - camera: { - horizontal_fov_deg: number; vertical_fov_deg: number; - side_overlap_percent: number; forward_overlap_percent: number; - footprint_width_m: number; footprint_height_m: number; - line_spacing_m: number; trigger_distance_m: number; - }; - estimate: { - survey_line_count: number; survey_distance_m: number; total_distance_m: number; - estimated_minutes: number; estimated_photos: number; mission_item_count: number; - recommended_sorties: number; simulation_ready: boolean; - blocking_reasons: string[]; warnings: string[]; - }; - terrain: { - status: string; provider?: string; sample_count?: number; - minimum_elevation_m?: number; maximum_elevation_m?: number; - elevation_range_m?: number; message?: string; - used_for_clearance: boolean; clearance_source?: string; - }; -} -interface MissionResponse { status: string; plan: MissionPlan } -interface ApiError { error?: string; reasons?: string[] } - -const API_STORAGE_KEY = "droneCompanionApi"; -const inputClass = "mt-1 h-9 w-full rounded border border-hud-line bg-hud-bg px-2 font-mono text-sm text-hud-text outline-none focus:border-teal-300"; - -function isPosition(latitude: number, longitude: number): boolean { - return Number.isFinite(latitude) && Number.isFinite(longitude) && - latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180 && - !(latitude === 0 && longitude === 0); -} -function normalizeApi(value: string): string { - const trimmed = value.trim(); - if (!trimmed) throw new Error("Enter the Raspberry Pi companion API address."); - const url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`); - if (!/^https?:$/.test(url.protocol)) throw new Error("Use an HTTP or HTTPS companion API address."); +const inputClass = "mt-1 h-11 w-full rounded border border-hud-line bg-hud-bg px-3 font-mono text-sm text-hud-text focus:border-teal-300 focus:outline-none"; +const buttonClass = "min-h-11 rounded border border-hud-line px-3 py-2 text-sm font-semibold hover:border-teal-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-teal-300 disabled:cursor-not-allowed disabled:opacity-40"; +function apiOrigin(input: string) { + const url = new URL(/^https?:\/\//i.test(input) ? input : `http://${input}`); + if (!/^https?:$/.test(url.protocol) || url.username || url.password) throw new Error("Enter an HTTP or HTTPS companion API address."); return url.origin; } -function compactDistance(metres: number): string { - return metres >= 1000 ? `${(metres / 1000).toFixed(1)} km` : `${metres.toFixed(1)} m`; -} -function Metric({ label, value }: { label: string; value: string | number }) { - return
{value}
{label}
; -} -function missionPlannerFile(items: Array>): string { - const rows = items.map((item) => { - const coordinateFrame = item.frame === 6 ? 3 : item.frame; - const latitude = item.frame === 6 ? item.x / 1e7 : item.x; - const longitude = item.frame === 6 ? item.y / 1e7 : item.y; - return [ - item.seq, item.current, coordinateFrame, item.command, - item.param1, item.param2, item.param3, item.param4, - latitude.toFixed(7), longitude.toFixed(7), item.z, item.autocontinue - ].join("\t"); - }); - return `QGC WPL 110\n${rows.join("\n")}\n`; -} export function MissionPlanner({ currentLatitude, currentLongitude, telemetryAvailable }: { currentLatitude: number; currentLongitude: number; telemetryAvailable: boolean; }) { - const [apiInput, setApiInput] = useState(() => window.localStorage.getItem(API_STORAGE_KEY) ?? "localhost:8765"); - const [centerLatitude, setCenterLatitude] = useState(""); - const [centerLongitude, setCenterLongitude] = useState(""); - const [radiusKm, setRadiusKm] = useState(0.025); - const [altitudeM, setAltitudeM] = useState(5); - const [horizontalFov, setHorizontalFov] = useState(78); - const [verticalFov, setVerticalFov] = useState(60); - const [sideOverlap, setSideOverlap] = useState(70); - const [forwardOverlap, setForwardOverlap] = useState(75); - const [cruiseSpeed, setCruiseSpeed] = useState(3); - const [surfaceType, setSurfaceType] = useState("water"); - const [terrainEnabled, setTerrainEnabled] = useState(true); - const [rangefinderConfirmed, setRangefinderConfirmed] = useState(false); - const [plan, setPlan] = useState(null); - const [requestState, setRequestState] = useState<"idle" | "planning" | "ready" | "starting" | "running" | "error">("idle"); - const [message, setMessage] = useState("Select a point on the terrain map or enter coordinates."); - const initializedFromTelemetry = useRef(false); - - const currentPositionValid = telemetryAvailable && isPosition(currentLatitude, currentLongitude); + const [api, setApi] = useState(() => { try { return localStorage.getItem("droneCompanionApi") ?? "localhost:8765"; } catch { return "localhost:8765"; } }); + const [center, setCenter] = useState(null); + const [radius, setRadius] = useState(25); + const [focus, setFocus] = useState({ latitude: 12.971598, longitude: 77.594562 }); + const [latitude, setLatitude] = useState(""); + const [longitude, setLongitude] = useState(""); + const [fitVersion, setFitVersion] = useState(0); + const [plan, setPlan] = useState(null); + const [sortie, setSortie] = useState(1); + const [confirmed, setConfirmed] = useState(false); + const [busy, setBusy] = useState<"planning" | "starting" | null>(null); + const [running, setRunning] = useState(false); + const [message, setMessage] = useState("Click the map to choose the search centre."); + const [failed, setFailed] = useState(false); + const request = useRef(null); + const version = useRef(0); + const initialized = useRef(false); + const vehicleValid = telemetryAvailable && Number.isFinite(currentLatitude) && Number.isFinite(currentLongitude) && Math.abs(currentLatitude) <= 85 && Math.abs(currentLongitude) <= 180 && (currentLatitude !== 0 || currentLongitude !== 0); useEffect(() => { - if (!initializedFromTelemetry.current && currentPositionValid) { - setCenterLatitude(currentLatitude.toFixed(7)); - setCenterLongitude(currentLongitude.toFixed(7)); - initializedFromTelemetry.current = true; + if (!initialized.current && vehicleValid) { + initialized.current = true; setFocus({ latitude: currentLatitude, longitude: currentLongitude }); } - }, [currentLatitude, currentLongitude, currentPositionValid]); - - const selectedLatitude = Number(centerLatitude); - const selectedLongitude = Number(centerLongitude); - const selectedPositionValid = isPosition(selectedLatitude, selectedLongitude); - const needsRangefinder = altitudeM <= 15; - const clearPlan = () => { setPlan(null); setRequestState("idle"); }; - - const requestPlan = async (path: "/api/mission/plan" | "/api/mission/simulate") => { - if (!selectedPositionValid) { - setRequestState("error"); - setMessage("Enter a valid latitude and longitude or click the map."); - return; - } - const starting = path.endsWith("simulate"); - setRequestState(starting ? "starting" : "planning"); - setMessage(starting ? "Loading the checked mission into the simulator..." : "Calculating camera coverage and terrain context..."); + }, [vehicleValid, currentLatitude, currentLongitude]); + useEffect(() => () => request.current?.abort(), []); + const preview = useMemo(() => radiusPreview(center, radius), [center, radius]); + const boundary = plan?.boundary ?? preview; + const invalidate = () => { + version.current += 1; request.current?.abort(); setPlan(null); setConfirmed(false); setBusy(null); setFailed(false); + setMessage("Search area changed. Send it to the Pi to generate the flight plan."); + }; + const chooseCenter = (point: Position) => { + if (busy === "starting") return; + invalidate(); initialized.current = true; setCenter(point); + setLatitude(point.latitude.toFixed(7)); setLongitude(point.longitude.toFixed(7)); + }; + const fail = (error: unknown) => { setFailed(true); setMessage(error instanceof Error ? error.message : "The companion API could not be reached."); }; + const generate = async () => { + if (!center || !preview.length) return; + invalidate(); const revision = version.current; + const controller = new AbortController(); request.current = controller; + const timeout = setTimeout(() => controller.abort(), 60000); + setBusy("planning"); setMessage("Pi is calculating camera coverage, grid direction and battery sorties…"); try { - const api = normalizeApi(apiInput); - window.localStorage.setItem(API_STORAGE_KEY, apiInput.trim()); - const response = await fetch(`${api}${path}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - center: { latitude: selectedLatitude, longitude: selectedLongitude }, - radius_km: radiusKm, altitude_m: altitudeM, - camera_horizontal_fov_deg: horizontalFov, camera_vertical_fov_deg: verticalFov, - side_overlap_percent: sideOverlap, forward_overlap_percent: forwardOverlap, - cruise_speed_m_s: cruiseSpeed, surface_type: surfaceType, - terrain_enabled: terrainEnabled - }) - }); - const body = await response.json() as MissionResponse | ApiError; - if (!response.ok || !("plan" in body)) { - const details = "reasons" in body && body.reasons?.length ? ` ${body.reasons.join(" ")}` : ""; - throw new Error(`${"error" in body && body.error ? body.error : `Companion API returned ${response.status}`}${details}`); - } - setPlan(body.plan); - setRequestState(starting ? "running" : "ready"); - setMessage(starting - ? "Simulation started. Follow progress in the telemetry view." - : body.plan.estimate.simulation_ready - ? "Coverage plan generated. Confirm the rangefinder check before simulation." - : `Plan generated, but it must be split into about ${body.plan.estimate.recommended_sorties} sorties.`); - } catch (error) { - setRequestState("error"); - setMessage(error instanceof Error ? error.message : "The Raspberry Pi companion API could not be reached."); - } + try { localStorage.setItem("droneCompanionApi", api); } catch { /* Storage is optional. */ } + const response = await fetch(`${apiOrigin(api)}/api/mission/plan`, { method: "POST", signal: controller.signal, headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ search_area: { center, radius_m: radius } }) }); + const body = await response.json(); + if (!response.ok) throw new Error(body.error ?? `Planning failed (${response.status}).`); + if (!body.plan?.search_area || !Array.isArray(body.plan.sorties)) throw new Error("Update the Pi companion API for point-and-radius planning."); + if (revision !== version.current) return; + setPlan(body.plan); setSortie(1); setFitVersion(value => value + 1); + setMessage("Pi-generated plan ready. Review the route and select a flight sortie."); + } catch (error) { if (revision === version.current) fail(controller.signal.aborted ? new Error("Planning timed out. Check the Pi connection or reduce the search radius.") : error); } + finally { clearTimeout(timeout); if (revision === version.current) setBusy(null); } }; - - const simulatorBlocked = !plan?.estimate.simulation_ready || (needsRangefinder && !rangefinderConfirmed); - const terrainSummary = plan?.terrain.status === "available" - ? `${plan.terrain.sample_count} DEM samples · ${plan.terrain.minimum_elevation_m}-${plan.terrain.maximum_elevation_m} m AMSL` - : plan?.terrain.message ?? (plan?.terrain.status === "disabled" ? "Terrain sampling disabled" : "Terrain data unavailable"); - const downloadMission = () => { - if (!plan?.mission_items.length) return; - const blob = new Blob([missionPlannerFile(plan.mission_items)], { type: "text/plain;charset=utf-8" }); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = "sentinelblue_camera_survey.waypoints"; - link.click(); - URL.revokeObjectURL(link.href); + const simulate = async () => { + if (!plan || !confirmed) return; + setBusy("starting"); setFailed(false); + try { + const response = await fetch(`${apiOrigin(api)}/api/mission/simulate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ plan_id: plan.plan_id, sortie_id: sortie, review_confirmed: true }) }); + const body = await response.json(); + if (!response.ok) throw new Error(body.error ?? "Simulator could not start."); + setRunning(true); setMessage(`Sortie ${sortie} is running in simulation.`); + } catch (error) { fail(error); } finally { setBusy(null); } }; + useEffect(() => { + if (!running) return; + const controller = new AbortController(); + const timer = setInterval(() => { + fetch(`${apiOrigin(api)}/api/health`, { signal: controller.signal }).then(response => response.json()).then(body => { + if (body.armed === false) { setRunning(false); setMessage("Simulator is disarmed. Review the next sortie before starting it."); } + }).catch(() => { /* Connection loss must not enable another start. */ }); + }, 3000); + return () => { clearInterval(timer); controller.abort(); }; + }, [api, running]); + const selected = plan?.sorties.find(item => item.sortie_id === sortie); - return ( -
-
-
-

Camera coverage mission planner

-

Plan a complete lawnmower survey from the camera footprint, overlap and flight altitude. Every cell receives parallel coverage lines.

-
-
Simulation only · live Pixhawk control locked
-
- -
-
-
- Terrain and coverage - click map to place search centre -
- { - setCenterLatitude(latitude.toFixed(7)); - setCenterLongitude(longitude.toFixed(7)); - clearPlan(); - setMessage("Search centre selected. Generate the plan to calculate coverage."); - }} - /> -
- {selectedPositionValid ? `${selectedLatitude.toFixed(6)}, ${selectedLongitude.toFixed(6)}` : "No search centre selected"} - amber: flight lines · teal: cells · blue: home -
+ return
+
+

Automatic search survey

Choose a point and radius. The Pi plans the flight.

+ Planning & simulation · live flight locked +
+
+
+
Click the map to move the search centre.
+ {}} /> +
C: centre · H: Pi launch position · amber: survey · dashed: transfers{center ? `${radius} m radius` : "No point selected"}
+
+
-
- ); + +
+
; } diff --git a/telemetry/live-web-dashboard/src/components/terrain_mission_map.tsx b/telemetry/live-web-dashboard/src/components/terrain_mission_map.tsx index d0534ee..bd1c73d 100644 --- a/telemetry/live-web-dashboard/src/components/terrain_mission_map.tsx +++ b/telemetry/live-web-dashboard/src/components/terrain_mission_map.tsx @@ -1,247 +1,103 @@ -import { useEffect, useRef } from "react"; -import { - LngLatBounds, - Map, - NavigationControl, - setWorkerUrl, - type GeoJSONSource, - type MapMouseEvent, - type StyleSpecification -} from "maplibre-gl"; +import { useEffect, useRef, useState } from "react"; +import { Map, Marker, LngLatBounds, NavigationControl, setWorkerUrl, type GeoJSONSource, type StyleSpecification } from "maplibre-gl"; import mapLibreWorkerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?url"; +import type { Position, SurveyPlan } from "../lib/survey_plan"; setWorkerUrl(mapLibreWorkerUrl); - -interface MapCell { - cell_id: number; - north_m: number; - east_m: number; - south_m: number; - west_m: number; -} - -interface MapSurveyLine { - cell_id: number; - start_latitude: number; - start_longitude: number; - end_latitude: number; - end_longitude: number; -} - -interface MapPlan { - center: { latitude: number; longitude: number }; - home: { latitude: number; longitude: number }; - radius_km: number; - cells: MapCell[]; - survey_lines: MapSurveyLine[]; +export type DrawMode = "navigate" | "rectangle" | "polygon" | "home" | "center"; +interface Props { + boundary: Position[]; home: Position | null; plan: SurveyPlan | null; sortie: number; + center?: Position | null; + mode: DrawMode; focus: Position; fitVersion: number; + onClick: (point: Position) => void; onMoveVertex: (index: number, point: Position) => void; } - -const emptyCollection: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: [] }; +const coordinates = (point: Position): [number, number] => [point.longitude, point.latitude]; +const collection = (features: GeoJSON.Feature[]): GeoJSON.FeatureCollection => ({ type: "FeatureCollection", features }); +const line = (points: Position[], properties = {}): GeoJSON.Feature => ({ type: "Feature", properties, geometry: { type: "LineString", coordinates: points.map(coordinates) } }); function mapStyle(): StyleSpecification | string { - const mapTilerKey = (import.meta as ImportMeta & { - env?: Record; - }).env?.VITE_MAPTILER_KEY; - if (mapTilerKey) { - return `https://api.maptiler.com/maps/hybrid/style.json?key=${encodeURIComponent(mapTilerKey)}`; - } - return { - version: 8, - sources: { - open_street_map: { - type: "raster", - tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"], - tileSize: 256, - attribution: "© OpenStreetMap contributors" - }, - terrain_dem: { - type: "raster-dem", - url: "https://demotiles.maplibre.org/terrain-tiles/tiles.json", - tileSize: 256 - }, - terrain_hillshade_dem: { - type: "raster-dem", - url: "https://demotiles.maplibre.org/terrain-tiles/tiles.json", - tileSize: 256 - } - }, - layers: [ - { id: "open_street_map", type: "raster", source: "open_street_map" }, - { - id: "terrain_hillshade", - type: "hillshade", - source: "terrain_hillshade_dem", - paint: { - "hillshade-exaggeration": 0.35, - "hillshade-shadow-color": "#071014", - "hillshade-highlight-color": "#d7fff8", - "hillshade-accent-color": "#0f766e" - } - } - ] - }; -} - -function offsetCoordinate(latitude: number, longitude: number, northM: number, eastM: number): [number, number] { - const nextLatitude = latitude + northM / 111_320; - const longitudeScale = 111_320 * Math.max(0.01, Math.cos(latitude * Math.PI / 180)); - return [longitude + eastM / longitudeScale, nextLatitude]; -} - -function planGeometry(plan: MapPlan | null, selected: { latitude: number; longitude: number } | null) { - const areas: GeoJSON.Feature[] = []; - const lines: GeoJSON.Feature[] = []; - const points: GeoJSON.Feature[] = []; - - if (plan) { - for (const cell of plan.cells) { - const northWest = offsetCoordinate(plan.center.latitude, plan.center.longitude, cell.north_m, cell.west_m); - const northEast = offsetCoordinate(plan.center.latitude, plan.center.longitude, cell.north_m, cell.east_m); - const southEast = offsetCoordinate(plan.center.latitude, plan.center.longitude, cell.south_m, cell.east_m); - const southWest = offsetCoordinate(plan.center.latitude, plan.center.longitude, cell.south_m, cell.west_m); - areas.push({ - type: "Feature", - properties: { cell_id: cell.cell_id }, - geometry: { type: "Polygon", coordinates: [[northWest, northEast, southEast, southWest, northWest]] } - }); - } - for (const line of plan.survey_lines) { - lines.push({ - type: "Feature", - properties: { cell_id: line.cell_id }, - geometry: { - type: "LineString", - coordinates: [ - [line.start_longitude, line.start_latitude], - [line.end_longitude, line.end_latitude] - ] - } - }); - } - points.push({ - type: "Feature", - properties: { kind: "home" }, - geometry: { type: "Point", coordinates: [plan.home.longitude, plan.home.latitude] } - }); - } - if (selected) { - points.push({ - type: "Feature", - properties: { kind: "center" }, - geometry: { type: "Point", coordinates: [selected.longitude, selected.latitude] } - }); - } - return { - areas: { type: "FeatureCollection", features: areas } as GeoJSON.FeatureCollection, - lines: { type: "FeatureCollection", features: lines } as GeoJSON.FeatureCollection, - points: { type: "FeatureCollection", features: points } as GeoJSON.FeatureCollection - }; + const key = (import.meta as ImportMeta & { env?: Record }).env?.VITE_MAPTILER_KEY; + if (key) return `https://api.maptiler.com/maps/hybrid/style.json?key=${encodeURIComponent(key)}`; + return { version: 8, sources: { + tiles: { type: "raster", tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"], tileSize: 256, maxzoom: 19, attribution: "© OpenStreetMap contributors" }, + dem: { type: "raster-dem", url: "https://demotiles.maplibre.org/terrain-tiles/tiles.json", tileSize: 256 } + }, layers: [{ id: "tiles", type: "raster", source: "tiles" }, { id: "terrain", type: "hillshade", source: "dem", paint: { "hillshade-exaggeration": 0.25 } }] }; } -function addMissionLayers(map: Map) { - if (map.getSource("mission_cells")) return; - map.addSource("mission_cells", { type: "geojson", data: emptyCollection }); - map.addSource("mission_lines", { type: "geojson", data: emptyCollection }); - map.addSource("mission_points", { type: "geojson", data: emptyCollection }); - map.addLayer({ - id: "mission_cell_fill", - type: "fill", - source: "mission_cells", - paint: { "fill-color": "#2dd4bf", "fill-opacity": 0.10 } - }); - map.addLayer({ - id: "mission_cell_outline", - type: "line", - source: "mission_cells", - paint: { "line-color": "#5eead4", "line-width": 2 } - }); - map.addLayer({ - id: "mission_survey_lines", - type: "line", - source: "mission_lines", - paint: { "line-color": "#fbbf24", "line-width": 2, "line-opacity": 0.9 } - }); - map.addLayer({ - id: "mission_home", - type: "circle", - source: "mission_points", - filter: ["==", ["get", "kind"], "home"], - paint: { "circle-radius": 7, "circle-color": "#60a5fa", "circle-stroke-color": "#071014", "circle-stroke-width": 2 } - }); - map.addLayer({ - id: "mission_center", - type: "circle", - source: "mission_points", - filter: ["==", ["get", "kind"], "center"], - paint: { "circle-radius": 7, "circle-color": "#5eead4", "circle-stroke-color": "#071014", "circle-stroke-width": 2 } - }); -} - -export function TerrainMissionMap({ - selected, - plan, - onSelect -}: { - selected: { latitude: number; longitude: number } | null; - plan: MapPlan | null; - onSelect: (latitude: number, longitude: number) => void; -}) { - const containerRef = useRef(null); +export function TerrainMissionMap(props: Props) { + const { boundary, home, plan, sortie, mode, center } = props; + const container = useRef(null); const mapRef = useRef(null); - const onSelectRef = useRef(onSelect); - const initialSelectedRef = useRef(selected); - onSelectRef.current = onSelect; - + const current = useRef(props); current.current = props; + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(false); useEffect(() => { - if (!containerRef.current || mapRef.current) return; - const initialSelected = initialSelectedRef.current; - const initialCenter: [number, number] = initialSelected - ? [initialSelected.longitude, initialSelected.latitude] - : [0, 20]; - const map = new Map({ - container: containerRef.current, - style: mapStyle(), - center: initialCenter, - zoom: initialSelected ? 13 : 1.5, - attributionControl: {}, - maxPitch: 70 - }); - map.addControl(new NavigationControl({ visualizePitch: true }), "top-right"); - map.on("click", (event: MapMouseEvent) => onSelectRef.current(event.lngLat.lat, event.lngLat.lng)); + if (!container.current) return; + setLoaded(false); + setError(false); + const map = new Map({ container: container.current, style: mapStyle(), center: coordinates(current.current.focus), zoom: 17, maxPitch: 0 }); + mapRef.current = map; + map.addControl(new NavigationControl({ showCompass: false }), "top-right"); + map.doubleClickZoom.disable(); + map.on("click", event => current.current.onClick({ latitude: event.lngLat.lat, longitude: event.lngLat.lng })); + map.on("error", () => setError(true)); map.on("style.load", () => { - addMissionLayers(map); - if (map.getSource("terrain_dem")) map.setTerrain({ source: "terrain_dem", exaggeration: 1.25 }); + for (const source of ["area", "edge", "grid", "transit"]) map.addSource(source, { type: "geojson", data: collection([]) }); + map.addLayer({ id: "area", type: "fill", source: "area", paint: { "fill-color": "#2dd4bf", "fill-opacity": 0.14 } }); + map.addLayer({ id: "edge", type: "line", source: "edge", paint: { "line-color": "#0d9488", "line-width": 3 } }); + map.addLayer({ id: "transit", type: "line", source: "transit", paint: { "line-color": "#2563eb", "line-width": 2, "line-dasharray": [3, 3] } }); + map.addLayer({ id: "grid", type: "line", source: "grid", paint: { "line-color": ["case", ["get", "active"], "#b45309", "#64748b"], "line-width": ["case", ["get", "active"], 3, 1], "line-opacity": ["case", ["get", "active"], 1, 0.4] } }); + setLoaded(true); }); - mapRef.current = map; - return () => { - map.remove(); - mapRef.current = null; - }; + const observer = new ResizeObserver(() => map.resize()); observer.observe(container.current); + return () => { observer.disconnect(); map.remove(); mapRef.current = null; }; }, []); - + useEffect(() => { + const map = mapRef.current; + if (!loaded || !map || !map.getSource("area")) return; + const closed = boundary.length >= 3 ? [...boundary, boundary[0]] : boundary; + (map.getSource("area") as GeoJSONSource).setData(collection(boundary.length >= 3 ? [{ type: "Feature", properties: {}, geometry: { type: "Polygon", coordinates: [closed.map(coordinates)] } }] : [])); + (map.getSource("edge") as GeoJSONSource).setData(collection(boundary.length >= 2 ? [line(closed)] : [])); + (map.getSource("grid") as GeoJSONSource).setData(collection((plan?.survey_lines ?? []).map(track => line([ + { latitude: track.start_latitude, longitude: track.start_longitude }, { latitude: track.end_latitude, longitude: track.end_longitude } + ], { active: track.sortie_id === sortie })))); + const selected = plan?.sorties.find(item => item.sortie_id === sortie); + const transits = selected ? selected.route.flatMap((point, index, route) => index % 2 === 0 && index + 1 < route.length ? [line([point, route[index + 1]])] : []) : []; + (map.getSource("transit") as GeoJSONSource).setData(collection(transits)); + const markers = (mode === "center" ? [] : boundary).map((point, index) => { + const element = document.createElement("button"); element.type = "button"; element.textContent = String(index + 1); + element.className = "survey-vertex"; element.setAttribute("aria-label", `Boundary vertex ${index + 1}; drag to move. Coordinates can also be edited in Area.`); + element.addEventListener("click", event => event.stopPropagation()); + const marker = new Marker({ element, draggable: mode === "navigate" }).setLngLat(coordinates(point)).addTo(map); + marker.on("dragend", () => { const p = marker.getLngLat(); current.current.onMoveVertex(index, { latitude: p.lat, longitude: p.lng }); }); + return marker; + }); + for (const [point, label, color] of [[center, "C", "#0f766e"], [home ?? plan?.home, "H", "#2563eb"], [selected?.route[1], "S", "#15803d"], [selected ? selected.route[selected.route.length - 2] : undefined, "E", "#b45309"]] as const) { + if (!point) continue; + const element = document.createElement("span"); element.className = "survey-vertex"; + element.style.background = color; element.textContent = label; element.title = label === "C" ? "Search centre" : label === "H" ? "Launch and landing" : label === "S" ? "First survey line" : "Last survey line"; + markers.push(new Marker({ element }).setLngLat(coordinates(point)).addTo(map)); + } + return () => markers.forEach(marker => marker.remove()); + }, [boundary, home, plan, sortie, mode, center, loaded]); + useEffect(() => { + const map = mapRef.current; + if (map) map.getCanvas().style.cursor = props.mode === "navigate" ? "" : "crosshair"; + }, [props.mode]); + useEffect(() => { + mapRef.current?.easeTo({ center: coordinates(props.focus), zoom: 17, duration: 300 }); + }, [props.focus]); useEffect(() => { const map = mapRef.current; if (!map) return; - const geometry = planGeometry(plan, selected); - const update = () => { - addMissionLayers(map); - (map.getSource("mission_cells") as GeoJSONSource | undefined)?.setData(geometry.areas); - (map.getSource("mission_lines") as GeoJSONSource | undefined)?.setData(geometry.lines); - (map.getSource("mission_points") as GeoJSONSource | undefined)?.setData(geometry.points); - if (plan) { - const bounds = new LngLatBounds(); - for (const cell of plan.cells) { - bounds.extend(offsetCoordinate(plan.center.latitude, plan.center.longitude, cell.north_m, cell.west_m)); - bounds.extend(offsetCoordinate(plan.center.latitude, plan.center.longitude, cell.south_m, cell.east_m)); - } - map.fitBounds(bounds, { padding: 48, duration: 500, maxZoom: 19 }); - } else if (selected) { - map.easeTo({ center: [selected.longitude, selected.latitude], zoom: Math.max(map.getZoom(), 13), duration: 350 }); - } - }; - if (map.isStyleLoaded()) update(); - else map.once("style.load", update); - }, [plan, selected]); - - return
; + const { boundary, home, focus } = current.current; + if (boundary.length > 1) { + const bounds = new LngLatBounds(); + [...boundary, ...(home ? [home] : [])].forEach(point => bounds.extend(coordinates(point))); + map.fitBounds(bounds, { padding: 60, maxZoom: 20, duration: 300 }); + } else map.easeTo({ center: coordinates(focus), zoom: 17, duration: 300 }); + }, [props.fitVersion]); + return
+ {error &&
Some map tiles could not load. You can still enter boundary coordinates in Area.
} +
; } diff --git a/telemetry/live-web-dashboard/src/lib/survey_plan.test.ts b/telemetry/live-web-dashboard/src/lib/survey_plan.test.ts new file mode 100644 index 0000000..a6613f6 --- /dev/null +++ b/telemetry/live-web-dashboard/src/lib/survey_plan.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { missionPlannerFile, parseBoundary, rectangle, radiusPreview } from "./survey_plan"; + +describe("survey geometry input and Mission Planner export", () => { + it("previews a search radius without generating flight lines", () => { + expect(radiusPreview(null, 25)).toEqual([]); + expect(radiusPreview({ latitude: 0, longitude: 0 }, NaN)).toEqual([]); + const circle = radiusPreview({ latitude: 0, longitude: 0 }, 1000); + expect(circle).toHaveLength(64); + expect(circle[0].latitude).toBeCloseTo(0.008993, 5); + expect(circle[16].longitude).toBeCloseTo(0.008993, 5); + }); + it("converts integer global relative altitude into WPL degree coordinates", () => { + const text = missionPlannerFile([{ seq: 1, current: 0, frame: 6, command: 16, param1: 1, param2: 0.1, param3: 0, param4: 90, x: 129715980, y: 775945620, z: 5, autocontinue: 1 }, + { seq: 2, current: 0, frame: 2, command: 206, param1: 1.443, param2: 0, param3: 1, param4: 0, x: 0, y: 0, z: 0, autocontinue: 1 }]); + const rows = text.trim().split("\n"); + expect(rows[0]).toBe("QGC WPL 110"); + expect(rows[1].split("\t")).toEqual(["1", "0", "3", "16", "1", "0.1", "0", "90", "12.9715980", "77.5945620", "5", "1"]); + expect(rows[2].split("\t")[3]).toBe("206"); + expect(() => missionPlannerFile([])).toThrow(); + }); + it("rejects invalid coordinate text and creates four rectangle corners", () => { + expect(() => parseBoundary("12,77\n13,78\nNaN,79")).toThrow(); + expect(() => parseBoundary("12,77\n13,78")).toThrow(); + const corners = rectangle({ latitude: 12, longitude: 77 }, { latitude: 13, longitude: 78 }); + expect(corners).toHaveLength(4); + expect(parseBoundary(corners.map(p => `${p.latitude},${p.longitude}`).join("\n"))).toEqual(corners); + }); +}); diff --git a/telemetry/live-web-dashboard/src/lib/survey_plan.ts b/telemetry/live-web-dashboard/src/lib/survey_plan.ts new file mode 100644 index 0000000..653187b --- /dev/null +++ b/telemetry/live-web-dashboard/src/lib/survey_plan.ts @@ -0,0 +1,67 @@ +export interface Position { latitude: number; longitude: number } +export type MissionItem = Record; +export interface SurveyPlan { + search_area?: { center: Position; radius_m: number }; profile_name?: string; cruise_speed_m_s: number; + plan_id: string; schema_version: number; home: Position; boundary: Position[]; + altitude_m: number; area_m2: number; coverage_percent: number; grid_angle_deg: number; + sortie_budget_minutes: number; + camera: { cross_track_m: number; along_track_m: number; line_spacing_m: number; + trigger_distance_m: number; trigger_interval_s: number; gsd_width_cm: number; gsd_height_cm: number }; + survey_lines: { sortie_id: number; start_latitude: number; start_longitude: number; end_latitude: number; end_longitude: number }[]; + sorties: { sortie_id: number; line_count: number; route: Position[]; distance_m: number; + estimated_minutes: number; estimated_photos: number; outside_boundary_m: number; + mission_item_count: number; ready: boolean; blocking_reasons: string[]; mission_items: MissionItem[] }[]; + estimate: { survey_line_count: number; total_distance_m: number; estimated_minutes: number; + estimated_photos: number; blocking_reasons: string[]; warnings: string[] }; + terrain: { status: string; message?: string; sample_count?: number; elevation_range_m?: number }; +} + +// Display preview only. The Pi computes the authoritative geodesic boundary. +export function radiusPreview(center: Position | null, radius: number): Position[] { + if (!center || !Number.isFinite(radius) || radius < 2 || radius > 10000) return []; + const lat = center.latitude * Math.PI / 180, lon = center.longitude * Math.PI / 180; + const distance = radius / 6371008.8; + return Array.from({ length: 64 }, (_, index) => { + const bearing = index * 2 * Math.PI / 64; + const latitude = Math.asin(Math.sin(lat) * Math.cos(distance) + Math.cos(lat) * Math.sin(distance) * Math.cos(bearing)); + const longitude = lon + Math.atan2(Math.sin(bearing) * Math.sin(distance) * Math.cos(lat), Math.cos(distance) - Math.sin(lat) * Math.sin(latitude)); + return { latitude: latitude * 180 / Math.PI, longitude: ((longitude * 180 / Math.PI + 540) % 360) - 180 }; + }); +} + +export function rectangle(a: Position, b: Position): Position[] { + return [a, { latitude: a.latitude, longitude: b.longitude }, b, { latitude: b.latitude, longitude: a.longitude }]; +} + +export function parseBoundary(text: string): Position[] { + const points = text.trim().split(/\n/).filter(line => line.trim()).map(line => { + const pair = line.trim().split(/[,\s]+/); + const [latitude, longitude] = pair.map(Number); + if (pair.length !== 2 || !Number.isFinite(latitude) || !Number.isFinite(longitude) || + Math.abs(latitude) > 85 || Math.abs(longitude) > 180) throw new Error("Use one latitude, longitude pair per line (latitude −85 to 85)."); + return { latitude, longitude }; + }); + if (points.length < 3 || points.length > 64) throw new Error("Enter 3 to 64 boundary vertices."); + return points; +} + +export function missionPlannerFile(items: MissionItem[]): string { + if (!items.length) throw new Error("No exportable mission items."); + const rows = items.map(item => { + const frame = item.frame === 6 ? 3 : item.frame; + const lat = item.frame === 6 ? item.x / 1e7 : item.x; + const lon = item.frame === 6 ? item.y / 1e7 : item.y; + const values = [item.seq, item.current, frame, item.command, item.param1, item.param2, + item.param3, item.param4, lat, lon, item.z, item.autocontinue]; + if (values.some(value => !Number.isFinite(value))) throw new Error("Invalid mission coordinates or command parameters."); + return values.map((value, index) => index === 8 || index === 9 ? value.toFixed(7) : value).join("\t"); + }); + return `QGC WPL 110\n${rows.join("\n")}\n`; +} + +export function downloadText(name: string, text: string) { + const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" })); + const link = document.createElement("a"); + link.href = url; link.download = name; link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} diff --git a/telemetry/live-web-dashboard/src/styles.css b/telemetry/live-web-dashboard/src/styles.css index b823743..9019ebd 100644 --- a/telemetry/live-web-dashboard/src/styles.css +++ b/telemetry/live-web-dashboard/src/styles.css @@ -66,3 +66,18 @@ body { animation-iteration-count: 1 !important; } } +/* Survey handles remain readable against aerial imagery and street maps. */ +.survey-vertex { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: 2px solid white; + border-radius: 50%; + background: #0f766e; + color: white; + font: 600 12px/1 monospace; + cursor: grab; + box-shadow: 0 1px 4px #0008; +} +.survey-vertex:focus-visible { outline: 3px solid #fbbf24; outline-offset: 2px; } From e43217ee395f77135a69db109e183620500f47e7 Mon Sep 17 00:00:00 2001 From: TheLostLeo Date: Fri, 16 Oct 2026 01:55:38 +0530 Subject: [PATCH 11/12] feat(grid-search): separate planner and improve dashboard legibility --- .github/workflows/telemetry_web_ci.yml | 33 - .gitignore | 11 + autonomous-grid-search/README.md | 63 + .../backend}/auto_grid_planner.py | 124 +- .../backend}/companion_api.py | 0 .../backend/requirements.txt | 3 + .../backend}/search_mission_planner.py | 0 .../backend}/survey_config.json | 4 + .../backend}/terrain_provider.py | 0 .../backend}/tests/test_auto_grid_planner.py | 46 +- .../backend}/tests/test_companion_api.py | 0 .../tests/test_search_mission_planner.py | 0 autonomous-grid-search/web/eslint.config.js | 26 + autonomous-grid-search/web/index.html | 14 + autonomous-grid-search/web/package-lock.json | 4295 +++++++++++++++++ autonomous-grid-search/web/package.json | 41 + autonomous-grid-search/web/postcss.config.js | 5 + .../web/src/assets/JetBrainsMono-OFL.txt | 93 + .../src/assets/JetBrainsMono-Regular.woff2 | Bin 0 -> 29900 bytes .../src/assets/JetBrainsMono-SemiBold.woff2 | Bin 0 -> 30920 bytes .../web}/src/components/mission_planner.tsx | 54 +- .../src/components/terrain_mission_map.tsx | 39 +- .../web}/src/lib/survey_plan.test.ts | 25 +- .../web}/src/lib/survey_plan.ts | 33 +- autonomous-grid-search/web/src/main.tsx | 12 + autonomous-grid-search/web/src/styles.css | 100 + .../web/src/types/assets.d.ts | 4 + autonomous-grid-search/web/tailwind.config.js | 39 + autonomous-grid-search/web/tsconfig.json | 21 + autonomous-grid-search/web/tsconfig.node.json | 10 + autonomous-grid-search/web/vite.config.ts | 12 + telemetry/README.md | 43 +- .../handheld-nrf-monitor/pi-code/main.py | 3 + telemetry/live-web-dashboard/README.md | 47 +- telemetry/live-web-dashboard/src/app.tsx | 76 +- .../src/components/attitude_indicator.tsx | 4 +- .../src/components/panel.tsx | 4 +- .../src/components/telemetry_chart.tsx | 4 +- .../src/components/top_bar.tsx | 8 +- telemetry/live-web-dashboard/src/styles.css | 17 + .../live-web-dashboard/tailwind.config.js | 8 +- 41 files changed, 5081 insertions(+), 240 deletions(-) delete mode 100644 .github/workflows/telemetry_web_ci.yml create mode 100644 autonomous-grid-search/README.md rename {telemetry/handheld-nrf-monitor/pi-code => autonomous-grid-search/backend}/auto_grid_planner.py (73%) rename {telemetry/handheld-nrf-monitor/pi-code => autonomous-grid-search/backend}/companion_api.py (100%) create mode 100644 autonomous-grid-search/backend/requirements.txt rename {telemetry/handheld-nrf-monitor/pi-code => autonomous-grid-search/backend}/search_mission_planner.py (100%) rename {telemetry/handheld-nrf-monitor/pi-code => autonomous-grid-search/backend}/survey_config.json (85%) rename {telemetry/handheld-nrf-monitor/pi-code => autonomous-grid-search/backend}/terrain_provider.py (100%) rename {telemetry/handheld-nrf-monitor/pi-code => autonomous-grid-search/backend}/tests/test_auto_grid_planner.py (74%) rename {telemetry/handheld-nrf-monitor/pi-code => autonomous-grid-search/backend}/tests/test_companion_api.py (100%) rename {telemetry/handheld-nrf-monitor/pi-code => autonomous-grid-search/backend}/tests/test_search_mission_planner.py (100%) create mode 100644 autonomous-grid-search/web/eslint.config.js create mode 100644 autonomous-grid-search/web/index.html create mode 100644 autonomous-grid-search/web/package-lock.json create mode 100644 autonomous-grid-search/web/package.json create mode 100644 autonomous-grid-search/web/postcss.config.js create mode 100644 autonomous-grid-search/web/src/assets/JetBrainsMono-OFL.txt create mode 100644 autonomous-grid-search/web/src/assets/JetBrainsMono-Regular.woff2 create mode 100644 autonomous-grid-search/web/src/assets/JetBrainsMono-SemiBold.woff2 rename {telemetry/live-web-dashboard => autonomous-grid-search/web}/src/components/mission_planner.tsx (73%) rename {telemetry/live-web-dashboard => autonomous-grid-search/web}/src/components/terrain_mission_map.tsx (71%) rename {telemetry/live-web-dashboard => autonomous-grid-search/web}/src/lib/survey_plan.test.ts (50%) rename {telemetry/live-web-dashboard => autonomous-grid-search/web}/src/lib/survey_plan.ts (61%) create mode 100644 autonomous-grid-search/web/src/main.tsx create mode 100644 autonomous-grid-search/web/src/styles.css create mode 100644 autonomous-grid-search/web/src/types/assets.d.ts create mode 100644 autonomous-grid-search/web/tailwind.config.js create mode 100644 autonomous-grid-search/web/tsconfig.json create mode 100644 autonomous-grid-search/web/tsconfig.node.json create mode 100644 autonomous-grid-search/web/vite.config.ts diff --git a/.github/workflows/telemetry_web_ci.yml b/.github/workflows/telemetry_web_ci.yml deleted file mode 100644 index 38ee6e1..0000000 --- a/.github/workflows/telemetry_web_ci.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Telemetry web CI - -on: - pull_request: - paths: - - "telemetry/live-web-dashboard/**" - - ".github/workflows/telemetry_web_ci.yml" - push: - branches: - - feature/telemetry - paths: - - "telemetry/live-web-dashboard/**" - - ".github/workflows/telemetry_web_ci.yml" - -permissions: - contents: read - -jobs: - check: - runs-on: ubuntu-latest - defaults: - run: - working-directory: telemetry/live-web-dashboard - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: telemetry/live-web-dashboard/package-lock.json - - run: npm ci - - run: npm run check - - run: docker build --tag sentinelblue-telemetry-web:test . diff --git a/.gitignore b/.gitignore index c440836..eb6994f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,16 @@ node_modules/ +node_modules dist/ *.tsbuildinfo __pycache__/ *.py[cod] + +# Trail-code's graft map is regenerable local state (run `trail build`). +/graft/ + +# Local agent state and notes. +/.codex/ +/.agent +/.agents/ +/.trail/ +/.ignore diff --git a/autonomous-grid-search/README.md b/autonomous-grid-search/README.md new file mode 100644 index 0000000..9040e4d --- /dev/null +++ b/autonomous-grid-search/README.md @@ -0,0 +1,63 @@ +# Autonomous Grid Search + +The Pi companion API plans square-cell camera surveys, and the standalone web app previews routes, battery sorties, and simulation. Live Pixhawk mission execution remains locked. + +## Auto-grid survey planner + +Start the companion API on the Pi, or run the simulator locally from the repository root: + +```bash +cd telemetry/handheld-nrf-monitor/pi-code +python -m pip install -r ../../../autonomous-grid-search/backend/requirements.txt +python main.py --simulate --no-radio +``` + +In a second terminal, start the standalone webpage from the repository root: + +```bash +cd autonomous-grid-search/web +npm ci +npm run dev +``` + +Open `http://127.0.0.1:5174/` and enter the Pi address and port 8765 in **Companion API**. The Python companion runs separately and must be reachable from the browser. Use a trusted network; the planning/simulation API has no authentication. + +1. Click the map to select the **search centre**. Coordinate entry is available under **Connection & coordinate entry** for keyboard use. +2. Enter **centre to edge in metres**, then select **Generate survey on Pi**. The search area is a square: 50 m centre-to-edge means a 100 × 100 m square. +3. The browser sends only `search_area: {center: {latitude, longitude}, radius_m}`; the existing API field `radius_m` now represents square half-side. The Pi reads `survey_config.json`, gets launch/home from fresh 3D vehicle GPS, divides a square in a local metric projection into equal square cells, and computes a complete camera grid in every cell. The vehicle must be disarmed. Without usable GPS, the Pi previews coverage but blocks export and simulation. +4. Review the returned route. Select a sortie and **Export to Mission Planner** to download `sentinelblue_survey_N.waypoints` (QGC WPL 110). In Mission Planner use **PLAN → Load WP File**. Export each battery sortie separately. Changes to the point or radius invalidate the old plan. +5. **Play swarm preview** animates the Pi-generated assignments and timelines for virtual drones. Pause, reset or change playback speed; the display tracks completed cells and each drone's phase. This visual preview sends no vehicle commands. **Run single-drone simulator** executes the selected cached sortie after acknowledgement. Plans expire after 10 minutes or API restart. The API rejects replacement while the simulator is armed and rejects all live execution. + +### Configure the aircraft once on the Pi + +Edit `autonomous-grid-search/backend/survey_config.json` on the companion computer. Defaults are 5 m height, 2 m/s, 78° × 60° FOV, 70% side overlap, 75% forward overlap, 20 minute endurance and 20% reserve. These are starting values, not measured specifications for your hardware. Calibrate the camera, endurance and trigger interval for the actual aircraft before flight validation. The file is loaded for each planning request; the browser cannot override it through a point/radius request. Existing reviewed plans retain their original settings. + +`max_cell_side_m` defaults to **50 m**, `swarm_drone_count` to **4**, `square_grid_angle_deg` to **0°** (north/south tracks), and `battery_swap_seconds` to **120 s** between a virtual drone's sorties. These values are configured on the Pi. Cell count per axis is `ceil(square_side / max_cell_side_m)`; the entire area is tiled by equal cells no larger than the maximum. For example, a 120 m square becomes nine 40 × 40 m cells, with no uncovered remainder strip. + +The Pi calculates camera spacing and produces alternating parallel tracks within each cell. It assigns whole cells to the least-loaded virtual drone by accumulated estimated flight time. Every cell has one owner; its complete lines are split into battery sorties capped at 500 commands and the configured battery budget. Every sortie contains takeoff, speed, survey/camera commands, return and land. Planning is capped at 100 cells, 100 sorties and 10,000 lines; oversized requests return an error instead of dropping coverage. + +**Swarm preview is a planning visualization, not live swarm control.** Timelines include takeoff/landing allowances, survey, transfers, turns and battery changes. Virtual drones share the same launch/home position. Shared launch and transit paths are not collision-deconflicted. Real simultaneous multi-drone flight remains disabled; this does not implement multi-vehicle MAVLink connections or formation control. + +The geometry engine retains its polygon API for integration; the operator-facing screen uses only a centre and square extent. Exports remain separate by cell/drone/sortie, with the selected assignment shown in the sortie picker. + +### Camera and flight assumptions + +Footprint = `2 × height × tan(FOV / 2)`. Maximum line gap = cross-track footprint × `(1 − side overlap)`. Trigger distance = along-track footprint × `(1 − forward overlap)`. Spacing tightens to fit the polygon extent. Crosshatch passes can have slightly different actual gaps; the displayed gap is the first pass. Modelled coverage uses flat, nadir, aligned footprints with distance-triggered photos. At least 99.5% modelled coverage is required to export; this is not measured image coverage. + +A camera-rate check blocks intervals shorter than the entered camera minimum. Export includes `DO_CHANGE_SPEED` and `DO_SET_CAM_TRIGG_DIST`; configure and test the actual camera interface and yaw behaviour in ArduPilot before flight. The simulator models route, commanded speed and waypoint dwell; it does not simulate actual photos, wind, vehicle dynamics or camera latency. + +For ArduCopter, configure face-next-waypoint yaw and a waypoint radius smaller than the line gap, and validate the route in ArduPilot SITL. Per-waypoint yaw and acceptance-radius fields do not configure these behaviours in Copter; see the [official mission command reference](https://docs.ardupilot.org/copter/docs/mission-command-list.html). The built-in simulator is not ArduPilot SITL. + +Each line has a one-second dwell at each end. Estimates add the entered turn allowance per line and two minutes for takeoff/landing. They do not measure battery capacity, wind or power consumption. A line that cannot fit one sortie is blocked rather than truncated. + +**Altitude is relative to home, not terrain-following.** Coverage assumes a flat surface at home elevation. A 5 m mission over water requires a validated downward sensor and flight-controller configuration; this planner does not configure them. DEM data is not water clearance. Run-outs and straight transfers may leave the boundary; the boundary is not a geofence. The planner does not provide obstacle avoidance, airspace checks or industrial flight certification. + +### Maps and terrain + +The default map uses OpenStreetMap raster tiles and MapLibre demo hillshade. Set `VITE_MAPTILER_KEY` at build time for MapTiler hybrid imagery. The map stays top-down for boundary accuracy. + +Optional terrain context samples home, centre and boundary corners using OpenTopoData SRTM90m. Set `TERRAIN_API_URL` to another compatible endpoint. Missing tiles or terrain data do not change mission altitude. Coordinates remain editable when imagery is unavailable. + +### Verification + +Run `python -m unittest discover -s tests -v` from `autonomous-grid-search/backend` and `npm run check` from `autonomous-grid-search/web`. Tests cover camera axes, sensor dimensions, coverage, invalid polygons, rotation, reverse entry, concave boundaries, crosshatch, camera rate, sortie limits, exact-plan execution, expiry, confirmation, live/armed guards, WPL coordinate frames and boundary parsing. diff --git a/telemetry/handheld-nrf-monitor/pi-code/auto_grid_planner.py b/autonomous-grid-search/backend/auto_grid_planner.py similarity index 73% rename from telemetry/handheld-nrf-monitor/pi-code/auto_grid_planner.py rename to autonomous-grid-search/backend/auto_grid_planner.py index 8bf6f77..2b89f47 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/auto_grid_planner.py +++ b/autonomous-grid-search/backend/auto_grid_planner.py @@ -11,7 +11,7 @@ import time from typing import Any, Dict -from pyproj import CRS, Transformer, Geod +from pyproj import CRS, Transformer from shapely import affinity from shapely.geometry import LineString, Polygon, box from shapely.ops import unary_union @@ -39,14 +39,6 @@ def build_radius_survey(payload, telemetry): raise ValueError("Pi survey_config.json is missing or invalid; check the aircraft configuration") from exc if not isinstance(config, dict) or not isinstance(config.get("survey_options"), dict): raise ValueError("Pi survey_config.json must include survey_options") - geod = Geod(ellps="WGS84") - boundary = [] - for index in range(64): - # Circumscribe the requested circle so polygon approximation never - # silently excludes the rim of the user's search area. - longitude, latitude, _ = geod.fwd(center["longitude"], center["latitude"], index * 360 / 64, - radius / math.cos(math.pi / 64)) - boundary.append({"latitude": latitude, "longitude": longitude}) source = dict(telemetry) try: fresh = all(0 <= time.time() - float(source.get(key, 0)) <= 5 @@ -55,10 +47,8 @@ def build_radius_survey(payload, telemetry): fresh = False if not fresh or source.get("gps_fix_type") not in ("3D FIX", "DGPS", "RTK FLT", "RTK FIX"): source["connected"] = False - request = {**config, "boundary": boundary} - request.pop("home", None) - plan = build_auto_grid_mission(request, source) - plan.data["search_area"] = {"center": center, "radius_m": radius} + plan = build_cell_survey(config, center, radius, source) + plan.data["search_area"] = {"center": center, "radius_m": radius, "shape": "square", "side_m": radius * 2} plan.data["profile_name"] = str(config.get("profile_name", "pi_survey_profile")) plan.data["terrain_enabled"] = config.get("terrain_enabled") is True plan.data["surface_type"] = config.get("surface_type", "water") @@ -70,6 +60,114 @@ def build_radius_survey(payload, telemetry): return plan +def build_cell_survey(config, center, half_side, telemetry): + """Partition a metric square; every cell gets a full coverage mission. + + Drone assignments and timelines are for visual simulation only. Shared + launch/transit paths are not deconflicted for simultaneous real flight. + """ + maximum = number(config, "max_cell_side_m", 50, 5, 1000) + drone_count = number(config, "swarm_drone_count", 4, 1, 8) + battery_swap = number(config, "battery_swap_seconds", 120, 0, 3600) + if not drone_count.is_integer(): + raise ValueError("swarm_drone_count must be an integer") + count = math.ceil(2 * half_side / maximum) + if count * count > 100: + raise ValueError("Search exceeds 100 square cells; reduce the search extent or adjust the Pi cell size") + cell_side = 2 * half_side / count + local = CRS.from_proj4(f"+proj=aeqd +lat_0={center['latitude']} +lon_0={center['longitude']} +datum=WGS84 +units=m") + to_geo = Transformer.from_crs(local, "EPSG:4326", always_xy=True) + to_local = Transformer.from_crs("EPSG:4326", local, always_xy=True) + + def geo(x, y): + lon, lat = to_geo.transform(x, y) + return {"latitude": lat, "longitude": lon} + + def corners(west, south, east, north): + return [geo(west, south), geo(east, south), geo(east, north), geo(west, north)] + + home = position(telemetry, "vehicle position") if telemetry.get("connected") else center + home_source = "vehicle_position" if telemetry.get("connected") else "area_preview_only" + drones = [{"drone_id": index + 1, "cell_ids": [], "sortie_ids": [], "duration_s": 0.0, "segments": []} + for index in range(min(int(drone_count), count * count))] + cells, sorties, lines, components = [], [], [], [] + options = {**config["survey_options"], "angle_mode": "manual", + "grid_angle_deg": number(config, "square_grid_angle_deg", 0, 0, 179.99)} + + for row in range(count): + for col in (range(count) if row % 2 == 0 else reversed(range(count))): + cell_id = row * count + col + 1 + west, south = -half_side + col * cell_side, -half_side + row * cell_side + boundary = corners(west, south, west + cell_side, south + cell_side) + data = build_auto_grid_mission({**config, "survey_options": options, "home": home, "boundary": boundary}, {}).to_dict() + drone = min(drones, key=lambda entry: (entry["duration_s"], entry["drone_id"])) + drone["cell_ids"].append(cell_id) + cell_sorties = [] + for sortie in data["sorties"]: + original_id = sortie["sortie_id"] + sortie_id = len(sorties) + 1 + sortie.update(sortie_id=sortie_id, cell_id=cell_id, drone_id=drone["drone_id"]) + if home_source == "area_preview_only": + sortie["ready"] = False + sortie["mission_items"] = [] + sortie["blocking_reasons"].insert(0, "Pi needs fresh 3D GPS from the disarmed vehicle to determine launch/home") + sorties.append(sortie) + cell_sorties.append(sortie_id) + drone["sortie_ids"].append(sortie_id) + for track in data["survey_lines"]: + if track["sortie_id"] == original_id: + # Copy: original local IDs are needed for subsequent sorties. + lines.append({**track, "cell_id": cell_id, "drone_id": drone["drone_id"], + "sortie_id": sortie_id, "line_index": len(lines) + 1}) + start_time = drone["duration_s"] + + def segment(start, end, duration, phase): + begin = drone["duration_s"] + drone["segments"].append({"start_s": round(begin, 3), "end_s": round(begin + duration, 3), + "start": start, "end": end, "phase": phase, + "cell_id": cell_id, "sortie_id": sortie_id}) + drone["duration_s"] += duration + + if len(drone["sortie_ids"]) > 1: + segment(home, home, battery_swap, "battery_swap") + segment(home, home, 60, "takeoff") + route = sortie["route"] + for index, (a, b) in enumerate(zip(route, route[1:])): + distance = math.dist(to_local.transform(a["longitude"], a["latitude"]), to_local.transform(b["longitude"], b["latitude"])) + survey = index % 2 == 1 + segment(a, b, max(.001, distance / data["cruise_speed_m_s"]), "survey" if survey else "transit") + if survey: + segment(b, b, 2 + number(options, "turn_seconds", 3, 0, 30), "turn") + segment(home, home, 60, "landing") + sortie["scheduled_start_s"] = round(start_time, 3) + sortie["scheduled_end_s"] = round(drone["duration_s"], 3) + cells.append({"cell_id": cell_id, "row": row + 1, "column": col + 1, "side_m": cell_side, + "boundary": boundary, "center": geo(west + cell_side / 2, south + cell_side / 2), + "drone_id": drone["drone_id"], "sortie_ids": cell_sorties, + "coverage_percent": data["coverage_percent"], "line_count": len(data["survey_lines"])}) + components.append(data) + if len(sorties) > MAX_SORTIES or len(lines) > MAX_LINES: + raise ValueError("Search exceeds 100 sorties or 10000 lines; reduce the search extent") + for drone in drones: + drone["duration_s"] = round(drone["duration_s"], 3) + result = dict(components[0]) + result.update(center=center, home=home, home_source=home_source, boundary=corners(-half_side, -half_side, half_side, half_side), + cells=sorted(cells, key=lambda cell: cell["cell_id"]), cell_count=len(cells), + max_cell_side_m=maximum, cell_side_m=cell_side, rows=count, columns=count, + area_m2=round((2 * half_side) ** 2, 1), sorties=sorties, survey_lines=lines, + coverage_percent=round(sum(item["coverage_percent"] for item in components) / len(components), 2), + swarm={"simulation_only": True, "drone_count": len(drones), "drones": drones, + "duration_s": max(drone["duration_s"] for drone in drones), + "warning": "Visual swarm simulation only. Shared home and transit paths are not collision-deconflicted; real multi-drone launch is disabled."}) + result["estimate"] = {**components[0]["estimate"], "survey_line_count": len(lines), "recommended_sorties": len(sorties), + "total_distance_m": round(sum(s["distance_m"] for s in sorties), 1), + "estimated_minutes": round(sum(s["estimated_minutes"] for s in sorties), 1), + "estimated_photos": sum(s["estimated_photos"] for s in sorties), + "simulation_ready": all(s["ready"] for s in sorties), + "blocking_reasons": list(dict.fromkeys(reason for s in sorties for reason in s["blocking_reasons"]))} + return AutoGridPlan(result) + + def number(data, key, default, minimum, maximum): value = data.get(key, default) if isinstance(value, bool): diff --git a/telemetry/handheld-nrf-monitor/pi-code/companion_api.py b/autonomous-grid-search/backend/companion_api.py similarity index 100% rename from telemetry/handheld-nrf-monitor/pi-code/companion_api.py rename to autonomous-grid-search/backend/companion_api.py diff --git a/autonomous-grid-search/backend/requirements.txt b/autonomous-grid-search/backend/requirements.txt new file mode 100644 index 0000000..92c7178 --- /dev/null +++ b/autonomous-grid-search/backend/requirements.txt @@ -0,0 +1,3 @@ +shapely>=2.0,<3 +pyproj>=3.6,<4 +pymavlink>=2.4,<3 diff --git a/telemetry/handheld-nrf-monitor/pi-code/search_mission_planner.py b/autonomous-grid-search/backend/search_mission_planner.py similarity index 100% rename from telemetry/handheld-nrf-monitor/pi-code/search_mission_planner.py rename to autonomous-grid-search/backend/search_mission_planner.py diff --git a/telemetry/handheld-nrf-monitor/pi-code/survey_config.json b/autonomous-grid-search/backend/survey_config.json similarity index 85% rename from telemetry/handheld-nrf-monitor/pi-code/survey_config.json rename to autonomous-grid-search/backend/survey_config.json index e69c449..619ba70 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/survey_config.json +++ b/autonomous-grid-search/backend/survey_config.json @@ -8,6 +8,10 @@ "forward_overlap_percent": 75, "surface_type": "water", "terrain_enabled": false, + "max_cell_side_m": 50, + "swarm_drone_count": 4, + "battery_swap_seconds": 120, + "square_grid_angle_deg": 0, "survey_options": { "camera_model": "fov", "camera_orientation": "landscape", diff --git a/telemetry/handheld-nrf-monitor/pi-code/terrain_provider.py b/autonomous-grid-search/backend/terrain_provider.py similarity index 100% rename from telemetry/handheld-nrf-monitor/pi-code/terrain_provider.py rename to autonomous-grid-search/backend/terrain_provider.py diff --git a/telemetry/handheld-nrf-monitor/pi-code/tests/test_auto_grid_planner.py b/autonomous-grid-search/backend/tests/test_auto_grid_planner.py similarity index 74% rename from telemetry/handheld-nrf-monitor/pi-code/tests/test_auto_grid_planner.py rename to autonomous-grid-search/backend/tests/test_auto_grid_planner.py index bbe8691..10668c4 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/tests/test_auto_grid_planner.py +++ b/autonomous-grid-search/backend/tests/test_auto_grid_planner.py @@ -6,6 +6,7 @@ from unittest.mock import patch sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[3] / "telemetry" / "handheld-nrf-monitor" / "pi-code")) from auto_grid_planner import build_auto_grid_mission, build_radius_survey from mavlink_manager import MAVLinkManager @@ -30,8 +31,9 @@ def test_radius_request_uses_pi_settings_and_vehicle_home(self): self.assertEqual(5, plan["altitude_m"]) self.assertEqual(center, plan["home"]) self.assertEqual("vehicle_position", plan["home_source"]) - self.assertEqual(64, len(plan["boundary"])) - self.assertGreaterEqual(plan["area_m2"], math.pi * 25 ** 2) + self.assertEqual(4, len(plan["boundary"])) + self.assertEqual(2500, plan["area_m2"]) + self.assertEqual("square", plan["search_area"]["shape"]) self.assertLess(plan["camera"]["line_spacing_m"], 3) self.assertTrue(all(sortie["ready"] for sortie in plan["sorties"])) stale = build_radius_survey(request, {**telemetry, "last_gps_timestamp": 0}).to_dict() @@ -40,6 +42,46 @@ def test_radius_request_uses_pi_settings_and_vehicle_home(self): with self.assertRaisesRegex(ValueError, "current flight"): build_radius_survey(request, {**telemetry, "armed": True}) + def test_square_cells_cover_area_once_and_assign_every_line(self): + from pyproj import CRS, Transformer + from shapely.geometry import Polygon + from shapely.ops import unary_union + center = {"latitude": 12.971598, "longitude": 77.594562} + telemetry = {**center, "connected": True, "gps_fix_type": "3D FIX", + "last_position_timestamp": time.time(), "last_gps_timestamp": time.time()} + for extent, count in [(50, 4), (60, 9)]: + plan = build_radius_survey({"search_area": {"center": center, "radius_m": extent}}, telemetry).to_dict() + self.assertEqual(count, plan["cell_count"]) + self.assertLessEqual(plan["cell_side_m"], 50) + crs = CRS.from_proj4("+proj=aeqd +lat_0=12.971598 +lon_0=77.594562 +datum=WGS84 +units=m") + projection = Transformer.from_crs("EPSG:4326", crs, always_xy=True) + polygons = [Polygon([projection.transform(p["longitude"], p["latitude"]) for p in cell["boundary"]]) for cell in plan["cells"]] + self.assertAlmostEqual(sum(p.area for p in polygons), (2 * extent) ** 2, places=2) + self.assertAlmostEqual(unary_union(polygons).area, (2 * extent) ** 2, places=2) + assignments = [cell for drone in plan["swarm"]["drones"] for cell in drone["cell_ids"]] + self.assertEqual(list(range(1, count + 1)), sorted(assignments)) + for cell in plan["cells"]: + tracks = [line for line in plan["survey_lines"] if line["cell_id"] == cell["cell_id"]] + self.assertEqual(cell["line_count"], len(tracks)) + self.assertGreater(len(tracks), 1) + self.assertGreaterEqual(cell["coverage_percent"], 99.5) + for track in tracks: + self.assertEqual(cell["drone_id"], track["drone_id"]) + self.assertIn(track["sortie_id"], cell["sortie_ids"]) + for drone in plan["swarm"]["drones"]: + segments = drone["segments"] + self.assertEqual("takeoff", segments[0]["phase"]) + self.assertEqual("landing", segments[-1]["phase"]) + self.assertEqual(plan["home"], segments[-1]["end"]) + for a, b in zip(segments, segments[1:]): + self.assertEqual(a["end_s"], b["start_s"]) + self.assertEqual(a["end"], b["start"]) + self.assertTrue(plan["swarm"]["simulation_only"]) + + def test_square_cell_limit_rejects_without_truncation(self): + with self.assertRaisesRegex(ValueError, "100 square cells"): + build_radius_survey({"search_area": {"center": {"latitude": 12, "longitude": 77}, "radius_m": 1000}}, {}) + def plan(self, payload): return build_auto_grid_mission(payload, {}).to_dict() diff --git a/telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_api.py b/autonomous-grid-search/backend/tests/test_companion_api.py similarity index 100% rename from telemetry/handheld-nrf-monitor/pi-code/tests/test_companion_api.py rename to autonomous-grid-search/backend/tests/test_companion_api.py diff --git a/telemetry/handheld-nrf-monitor/pi-code/tests/test_search_mission_planner.py b/autonomous-grid-search/backend/tests/test_search_mission_planner.py similarity index 100% rename from telemetry/handheld-nrf-monitor/pi-code/tests/test_search_mission_planner.py rename to autonomous-grid-search/backend/tests/test_search_mission_planner.py diff --git a/autonomous-grid-search/web/eslint.config.js b/autonomous-grid-search/web/eslint.config.js new file mode 100644 index 0000000..041127d --- /dev/null +++ b/autonomous-grid-search/web/eslint.config.js @@ -0,0 +1,26 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist"] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }] + } + } +); diff --git a/autonomous-grid-search/web/index.html b/autonomous-grid-search/web/index.html new file mode 100644 index 0000000..c01ed53 --- /dev/null +++ b/autonomous-grid-search/web/index.html @@ -0,0 +1,14 @@ + + + + + + + + SentinelBlue Autonomous Grid Search + + +
+ + + diff --git a/autonomous-grid-search/web/package-lock.json b/autonomous-grid-search/web/package-lock.json new file mode 100644 index 0000000..cc90b64 --- /dev/null +++ b/autonomous-grid-search/web/package-lock.json @@ -0,0 +1,4295 @@ +{ + "name": "sentinelblue-autonomous-grid-search", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sentinelblue-autonomous-grid-search", + "version": "0.1.0", + "dependencies": { + "@react-three/fiber": "^8.17.10", + "lucide-react": "^0.468.0", + "maplibre-gl": "^6.14.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "recharts": "^3.10.1", + "three": "^0.165.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.5", + "@tailwindcss/postcss": "^4.3.3", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/three": "^0.165.0", + "@vitejs/plugin-react": "^6.1.2", + "eslint": "^9.39.5", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.26", + "globals": "^17.13.0", + "postcss": "^8.5.30", + "tailwindcss": "^4.3.3", + "typescript": "^5.5.3", + "typescript-eslint": "^8.71.2", + "vite": "^8.3.4", + "vitest": "^5.0.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.3.0.tgz", + "integrity": "sha512-gBvcNDBBB66bgQYvnaJ6Jk+VCaMmVq5+IZR2kWPisT88Z8s0kBQt6hfVi8QgRW8Vpg0UWZzBA4ZSyzHHC+VeRA==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz", + "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.2.tgz", + "integrity": "sha512-2ZYhobdAY+y4/P/z1xe0698165LywD0yDdWugdu3NRUrajgVWut+tLkcz7i/RJguMTURwK7gxwmwYMFJbgbQFw==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "26.4.4", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.4.tgz", + "integrity": "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.3", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz", + "integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@react-three/fiber": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", + "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/react-reconciler": "^0.26.7", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^1.0.6", + "react-reconciler": "^0.27.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.21.0", + "suspend-react": "^0.1.3", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=18 <19", + "react-dom": ">=18 <19", + "react-native": ">=0.64", + "three": ">=0.133" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz", + "integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", + "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.165.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.165.0.tgz", + "integrity": "sha512-AJK8JZAFNBF0kBXiAIl5pggYlzAGGA8geVYQXAcPCEDRbyA+oEjkpUBcJJrtNz6IiALwzGexFJGZG2yV3WsYBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "~23.1.1", + "@types/stats.js": "*", + "@types/webxr": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.4", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/earcut": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.4.tgz", + "integrity": "sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==", + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "11.1.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.21.tgz", + "integrity": "sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/maplibre-gl": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.14.0.tgz", + "integrity": "sha512-L7U9y9TyPJ3P/JJbW+eH+A3FzdPq+3fnZFqqeCwf/S6cRGganHwVI5b/C8/zOYzreUzsloDPZVVaMQA94wOt4A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.2.0", + "@mapbox/unitbezier": "^1.0.0", + "@mapbox/vector-tile": "^3.0.0", + "@maplibre/geojson-vt": "^6.1.2", + "@maplibre/maplibre-gl-style-spec": "^26.4.4", + "@maplibre/mlt": "^1.3.0", + "@maplibre/vt-pbf": "^4.3.2", + "@types/geojson": "^7946.0.16", + "bidi-js": "^1.1.0", + "earcut": "^3.2.4", + "gl-matrix": "^3.4.4", + "kdbush": "^4.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^5.1.2", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT", + "peer": true + }, + "node_modules/react-reconciler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.21.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/three": { + "version": "0.165.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.165.0.tgz", + "integrity": "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA==", + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", + "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", + "license": "MIT", + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + } + } +} diff --git a/autonomous-grid-search/web/package.json b/autonomous-grid-search/web/package.json new file mode 100644 index 0000000..13f50e4 --- /dev/null +++ b/autonomous-grid-search/web/package.json @@ -0,0 +1,41 @@ +{ + "name": "sentinelblue-autonomous-grid-search", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc -b && vite build", + "preview": "vite preview --host 0.0.0.0", + "lint": "eslint .", + "test": "vitest run", + "check": "npm run lint && npm run test && npm run build" + }, + "dependencies": { + "@react-three/fiber": "^8.17.10", + "lucide-react": "^0.468.0", + "maplibre-gl": "^6.14.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "recharts": "^3.10.1", + "three": "^0.165.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.5", + "@tailwindcss/postcss": "^4.3.3", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/three": "^0.165.0", + "@vitejs/plugin-react": "^6.1.2", + "eslint": "^9.39.5", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.26", + "globals": "^17.13.0", + "postcss": "^8.5.30", + "tailwindcss": "^4.3.3", + "typescript": "^5.5.3", + "typescript-eslint": "^8.71.2", + "vite": "^8.3.4", + "vitest": "^5.0.4" + } +} diff --git a/autonomous-grid-search/web/postcss.config.js b/autonomous-grid-search/web/postcss.config.js new file mode 100644 index 0000000..9b544e3 --- /dev/null +++ b/autonomous-grid-search/web/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {} + } +}; diff --git a/autonomous-grid-search/web/src/assets/JetBrainsMono-OFL.txt b/autonomous-grid-search/web/src/assets/JetBrainsMono-OFL.txt new file mode 100644 index 0000000..23a3dca --- /dev/null +++ b/autonomous-grid-search/web/src/assets/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/autonomous-grid-search/web/src/assets/JetBrainsMono-Regular.woff2 b/autonomous-grid-search/web/src/assets/JetBrainsMono-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..8a7726bf7a22aa602aaca3f92df35a3286d75214 GIT binary patch literal 29900 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 literal 0 HcmV?d00001 diff --git a/telemetry/live-web-dashboard/src/components/mission_planner.tsx b/autonomous-grid-search/web/src/components/mission_planner.tsx similarity index 73% rename from telemetry/live-web-dashboard/src/components/mission_planner.tsx rename to autonomous-grid-search/web/src/components/mission_planner.tsx index c3684ed..693b410 100644 --- a/telemetry/live-web-dashboard/src/components/mission_planner.tsx +++ b/autonomous-grid-search/web/src/components/mission_planner.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { DownloadIcon, MapPinnedIcon } from "lucide-react"; import { TerrainMissionMap } from "./terrain_mission_map"; -import { downloadText, missionPlannerFile, radiusPreview, type Position, type SurveyPlan } from "../lib/survey_plan"; +import { downloadText, missionPlannerFile, squarePreview, swarmPositions, droneColors, type Position, type SurveyPlan } from "../lib/survey_plan"; const inputClass = "mt-1 h-11 w-full rounded border border-hud-line bg-hud-bg px-3 font-mono text-sm text-hud-text focus:border-teal-300 focus:outline-none"; const buttonClass = "min-h-11 rounded border border-hud-line px-3 py-2 text-sm font-semibold hover:border-teal-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-teal-300 disabled:cursor-not-allowed disabled:opacity-40"; @@ -16,7 +16,7 @@ export function MissionPlanner({ currentLatitude, currentLongitude, telemetryAva }) { const [api, setApi] = useState(() => { try { return localStorage.getItem("droneCompanionApi") ?? "localhost:8765"; } catch { return "localhost:8765"; } }); const [center, setCenter] = useState(null); - const [radius, setRadius] = useState(25); + const [radius, setRadius] = useState(50); const [focus, setFocus] = useState({ latitude: 12.971598, longitude: 77.594562 }); const [latitude, setLatitude] = useState(""); const [longitude, setLongitude] = useState(""); @@ -26,6 +26,9 @@ export function MissionPlanner({ currentLatitude, currentLongitude, telemetryAva const [confirmed, setConfirmed] = useState(false); const [busy, setBusy] = useState<"planning" | "starting" | null>(null); const [running, setRunning] = useState(false); + const [swarmPlaying, setSwarmPlaying] = useState(false); + const [swarmElapsed, setSwarmElapsed] = useState(0); + const [swarmRate, setSwarmRate] = useState(30); const [message, setMessage] = useState("Click the map to choose the search centre."); const [failed, setFailed] = useState(false); const request = useRef(null); @@ -38,9 +41,24 @@ export function MissionPlanner({ currentLatitude, currentLongitude, telemetryAva } }, [vehicleValid, currentLatitude, currentLongitude]); useEffect(() => () => request.current?.abort(), []); - const preview = useMemo(() => radiusPreview(center, radius), [center, radius]); + const preview = useMemo(() => squarePreview(center, radius), [center, radius]); const boundary = plan?.boundary ?? preview; + const virtualPositions = useMemo(() => swarmElapsed > 0 || swarmPlaying ? swarmPositions(plan?.swarm, swarmElapsed) : [], [plan, swarmElapsed, swarmPlaying]); + useEffect(() => { + if (!swarmPlaying || !plan?.swarm) return; + const duration = plan.swarm.duration_s; + let previous = performance.now(); + const timer = setInterval(() => { + const now = performance.now(); const delta = (now - previous) / 1000; previous = now; + setSwarmElapsed(value => Math.min(duration, value + delta * swarmRate)); + }, 100); + return () => clearInterval(timer); + }, [plan, swarmPlaying, swarmRate]); + useEffect(() => { + if (plan?.swarm && swarmElapsed >= plan.swarm.duration_s) setSwarmPlaying(false); + }, [plan, swarmElapsed]); const invalidate = () => { + setSwarmPlaying(false); setSwarmElapsed(0); version.current += 1; request.current?.abort(); setPlan(null); setConfirmed(false); setBusy(null); setFailed(false); setMessage("Search area changed. Send it to the Pi to generate the flight plan."); }; @@ -62,11 +80,11 @@ export function MissionPlanner({ currentLatitude, currentLongitude, telemetryAva body: JSON.stringify({ search_area: { center, radius_m: radius } }) }); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? `Planning failed (${response.status}).`); - if (!body.plan?.search_area || !Array.isArray(body.plan.sorties)) throw new Error("Update the Pi companion API for point-and-radius planning."); + if (!body.plan?.cells || !body.plan?.swarm || !Array.isArray(body.plan.sorties)) throw new Error("Update the Pi companion API for square-cell planning."); if (revision !== version.current) return; setPlan(body.plan); setSortie(1); setFitVersion(value => value + 1); setMessage("Pi-generated plan ready. Review the route and select a flight sortie."); - } catch (error) { if (revision === version.current) fail(controller.signal.aborted ? new Error("Planning timed out. Check the Pi connection or reduce the search radius.") : error); } + } catch (error) { if (revision === version.current) fail(controller.signal.aborted ? new Error("Planning timed out. Check the Pi connection or reduce the search extent.") : error); } finally { clearTimeout(timeout); if (revision === version.current) setBusy(null); } }; const simulate = async () => { @@ -93,22 +111,22 @@ export function MissionPlanner({ currentLatitude, currentLongitude, telemetryAva return
-

Automatic search survey

Choose a point and radius. The Pi plans the flight.

+

Automatic search survey

Choose a centre and square size. The Pi grids every cell.

Planning & simulation · live flight locked
-
+
Click the map to move the search centre.
- {}} /> -
C: centre · H: Pi launch position · amber: survey · dashed: transfers{center ? `${radius} m radius` : "No point selected"}
+ {}} swarmPositions={virtualPositions} /> +
Cells: C1, C2… · D: virtual drone · H: home · dashed: transfers{center ? `${radius * 2} × ${radius * 2} m square` : "No point selected"}
diff --git a/telemetry/live-web-dashboard/src/components/terrain_mission_map.tsx b/autonomous-grid-search/web/src/components/terrain_mission_map.tsx similarity index 71% rename from telemetry/live-web-dashboard/src/components/terrain_mission_map.tsx rename to autonomous-grid-search/web/src/components/terrain_mission_map.tsx index bd1c73d..706c95d 100644 --- a/telemetry/live-web-dashboard/src/components/terrain_mission_map.tsx +++ b/autonomous-grid-search/web/src/components/terrain_mission_map.tsx @@ -1,13 +1,14 @@ import { useEffect, useRef, useState } from "react"; import { Map, Marker, LngLatBounds, NavigationControl, setWorkerUrl, type GeoJSONSource, type StyleSpecification } from "maplibre-gl"; import mapLibreWorkerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?url"; -import type { Position, SurveyPlan } from "../lib/survey_plan"; +import { droneColors, type Position, type SurveyPlan, type SwarmPosition } from "../lib/survey_plan"; setWorkerUrl(mapLibreWorkerUrl); export type DrawMode = "navigate" | "rectangle" | "polygon" | "home" | "center"; interface Props { boundary: Position[]; home: Position | null; plan: SurveyPlan | null; sortie: number; center?: Position | null; + swarmPositions?: SwarmPosition[]; mode: DrawMode; focus: Position; fitVersion: number; onClick: (point: Position) => void; onMoveVertex: (index: number, point: Position) => void; } @@ -28,6 +29,7 @@ export function TerrainMissionMap(props: Props) { const { boundary, home, plan, sortie, mode, center } = props; const container = useRef(null); const mapRef = useRef(null); + const swarmMarkers = useRef>({}); const current = useRef(props); current.current = props; const [loaded, setLoaded] = useState(false); const [error, setError] = useState(false); @@ -35,6 +37,7 @@ export function TerrainMissionMap(props: Props) { if (!container.current) return; setLoaded(false); setError(false); + Object.values(swarmMarkers.current).forEach(marker => marker.remove()); swarmMarkers.current = {}; const map = new Map({ container: container.current, style: mapStyle(), center: coordinates(current.current.focus), zoom: 17, maxPitch: 0 }); mapRef.current = map; map.addControl(new NavigationControl({ showCompass: false }), "top-right"); @@ -42,11 +45,13 @@ export function TerrainMissionMap(props: Props) { map.on("click", event => current.current.onClick({ latitude: event.lngLat.lat, longitude: event.lngLat.lng })); map.on("error", () => setError(true)); map.on("style.load", () => { - for (const source of ["area", "edge", "grid", "transit"]) map.addSource(source, { type: "geojson", data: collection([]) }); + for (const source of ["area", "cells", "edge", "grid", "transit"]) map.addSource(source, { type: "geojson", data: collection([]) }); map.addLayer({ id: "area", type: "fill", source: "area", paint: { "fill-color": "#2dd4bf", "fill-opacity": 0.14 } }); + map.addLayer({ id: "cell_fill", type: "fill", source: "cells", paint: { "fill-color": ["get", "color"], "fill-opacity": 0.12 } }); + map.addLayer({ id: "cell_edge", type: "line", source: "cells", paint: { "line-color": ["get", "color"], "line-width": 2 } }); map.addLayer({ id: "edge", type: "line", source: "edge", paint: { "line-color": "#0d9488", "line-width": 3 } }); map.addLayer({ id: "transit", type: "line", source: "transit", paint: { "line-color": "#2563eb", "line-width": 2, "line-dasharray": [3, 3] } }); - map.addLayer({ id: "grid", type: "line", source: "grid", paint: { "line-color": ["case", ["get", "active"], "#b45309", "#64748b"], "line-width": ["case", ["get", "active"], 3, 1], "line-opacity": ["case", ["get", "active"], 1, 0.4] } }); + map.addLayer({ id: "grid", type: "line", source: "grid", paint: { "line-color": ["case", ["get", "active"], "#b45309", ["get", "color"]], "line-width": ["case", ["get", "active"], 3, 1.5], "line-opacity": ["case", ["get", "active"], 1, 0.8] } }); setLoaded(true); }); const observer = new ResizeObserver(() => map.resize()); observer.observe(container.current); @@ -58,9 +63,10 @@ export function TerrainMissionMap(props: Props) { const closed = boundary.length >= 3 ? [...boundary, boundary[0]] : boundary; (map.getSource("area") as GeoJSONSource).setData(collection(boundary.length >= 3 ? [{ type: "Feature", properties: {}, geometry: { type: "Polygon", coordinates: [closed.map(coordinates)] } }] : [])); (map.getSource("edge") as GeoJSONSource).setData(collection(boundary.length >= 2 ? [line(closed)] : [])); + (map.getSource("cells") as GeoJSONSource).setData(collection((plan?.cells ?? []).map(cell => ({ type: "Feature", properties: { color: droneColors[(cell.drone_id - 1) % droneColors.length] }, geometry: { type: "Polygon", coordinates: [[...cell.boundary, cell.boundary[0]].map(coordinates)] } })))); (map.getSource("grid") as GeoJSONSource).setData(collection((plan?.survey_lines ?? []).map(track => line([ { latitude: track.start_latitude, longitude: track.start_longitude }, { latitude: track.end_latitude, longitude: track.end_longitude } - ], { active: track.sortie_id === sortie })))); + ], { active: track.sortie_id === sortie, color: droneColors[((track.drone_id ?? 1) - 1) % droneColors.length] })))); const selected = plan?.sorties.find(item => item.sortie_id === sortie); const transits = selected ? selected.route.flatMap((point, index, route) => index % 2 === 0 && index + 1 < route.length ? [line([point, route[index + 1]])] : []) : []; (map.getSource("transit") as GeoJSONSource).setData(collection(transits)); @@ -72,6 +78,14 @@ export function TerrainMissionMap(props: Props) { marker.on("dragend", () => { const p = marker.getLngLat(); current.current.onMoveVertex(index, { latitude: p.lat, longitude: p.lng }); }); return marker; }); + for (const cell of plan?.cells ?? []) { + const element = document.createElement("span"); element.className = "survey-vertex"; + element.style.width = "70px"; element.style.borderRadius = "4px"; + element.style.background = droneColors[(cell.drone_id - 1) % droneColors.length]; + element.textContent = `C${cell.cell_id} · D${cell.drone_id}`; + element.title = `Cell ${cell.cell_id}, virtual drone ${cell.drone_id}, ${cell.line_count} survey lines`; + markers.push(new Marker({ element }).setLngLat(coordinates(cell.center)).addTo(map)); + } for (const [point, label, color] of [[center, "C", "#0f766e"], [home ?? plan?.home, "H", "#2563eb"], [selected?.route[1], "S", "#15803d"], [selected ? selected.route[selected.route.length - 2] : undefined, "E", "#b45309"]] as const) { if (!point) continue; const element = document.createElement("span"); element.className = "survey-vertex"; @@ -80,6 +94,23 @@ export function TerrainMissionMap(props: Props) { } return () => markers.forEach(marker => marker.remove()); }, [boundary, home, plan, sortie, mode, center, loaded]); + useEffect(() => { + const map = mapRef.current; + if (!map || !loaded) return; + const positions = props.swarmPositions ?? []; + for (const [key, marker] of Object.entries(swarmMarkers.current)) { + if (!positions.some(point => point.drone_id === Number(key))) { marker.remove(); delete swarmMarkers.current[Number(key)]; } + } + for (const point of positions) { + if (!swarmMarkers.current[point.drone_id]) { + const element = document.createElement("span"); element.className = "survey-vertex"; + element.style.width = "36px"; element.style.height = "36px"; element.style.zIndex = "5"; + element.style.background = droneColors[(point.drone_id - 1) % droneColors.length]; element.textContent = `D${point.drone_id}`; + swarmMarkers.current[point.drone_id] = new Marker({ element }).setLngLat(coordinates(point)).addTo(map); + } + swarmMarkers.current[point.drone_id].setLngLat(coordinates(point)); + } + }, [props.swarmPositions, loaded]); useEffect(() => { const map = mapRef.current; if (map) map.getCanvas().style.cursor = props.mode === "navigate" ? "" : "crosshair"; diff --git a/telemetry/live-web-dashboard/src/lib/survey_plan.test.ts b/autonomous-grid-search/web/src/lib/survey_plan.test.ts similarity index 50% rename from telemetry/live-web-dashboard/src/lib/survey_plan.test.ts rename to autonomous-grid-search/web/src/lib/survey_plan.test.ts index a6613f6..4014b4f 100644 --- a/telemetry/live-web-dashboard/src/lib/survey_plan.test.ts +++ b/autonomous-grid-search/web/src/lib/survey_plan.test.ts @@ -1,14 +1,23 @@ import { describe, expect, it } from "vitest"; -import { missionPlannerFile, parseBoundary, rectangle, radiusPreview } from "./survey_plan"; +import { missionPlannerFile, parseBoundary, rectangle, squarePreview, swarmPositions } from "./survey_plan"; describe("survey geometry input and Mission Planner export", () => { - it("previews a search radius without generating flight lines", () => { - expect(radiusPreview(null, 25)).toEqual([]); - expect(radiusPreview({ latitude: 0, longitude: 0 }, NaN)).toEqual([]); - const circle = radiusPreview({ latitude: 0, longitude: 0 }, 1000); - expect(circle).toHaveLength(64); - expect(circle[0].latitude).toBeCloseTo(0.008993, 5); - expect(circle[16].longitude).toBeCloseTo(0.008993, 5); + it("previews a square using the centre-to-edge distance", () => { + expect(squarePreview(null, 25)).toEqual([]); + expect(squarePreview({ latitude: 0, longitude: 0 }, NaN)).toEqual([]); + const square = squarePreview({ latitude: 0, longitude: 0 }, 1000); + expect(square).toHaveLength(4); + expect(square[0].latitude).toBeCloseTo(-0.008993, 5); + expect(square[0].longitude).toBeCloseTo(-0.008993, 5); + expect(square[2].latitude).toBeCloseTo(0.008993, 5); + expect(square[2].longitude).toBeCloseTo(0.008993, 5); + }); + it("interpolates the Pi swarm timeline and stops at its final position", () => { + const swarm = { simulation_only: true, warning: "preview", drone_count: 1, duration_s: 10, + drones: [{ drone_id: 1, cell_ids: [1], sortie_ids: [1], duration_s: 10, segments: [{ start_s: 0, end_s: 10, start: { latitude: 12, longitude: 77 }, end: { latitude: 12.001, longitude: 77.001 }, phase: "survey", cell_id: 1, sortie_id: 1 }] }] }; + expect(swarmPositions(swarm, 5)[0].latitude).toBeCloseTo(12.0005, 7); + expect(swarmPositions(swarm, 20)[0]).toMatchObject({ latitude: 12.001, longitude: 77.001, phase: "complete" }); + expect(swarmPositions(undefined, 5)).toEqual([]); }); it("converts integer global relative altitude into WPL degree coordinates", () => { const text = missionPlannerFile([{ seq: 1, current: 0, frame: 6, command: 16, param1: 1, param2: 0.1, param3: 0, param4: 90, x: 129715980, y: 775945620, z: 5, autocontinue: 1 }, diff --git a/telemetry/live-web-dashboard/src/lib/survey_plan.ts b/autonomous-grid-search/web/src/lib/survey_plan.ts similarity index 61% rename from telemetry/live-web-dashboard/src/lib/survey_plan.ts rename to autonomous-grid-search/web/src/lib/survey_plan.ts index 653187b..48a0638 100644 --- a/telemetry/live-web-dashboard/src/lib/survey_plan.ts +++ b/autonomous-grid-search/web/src/lib/survey_plan.ts @@ -1,14 +1,21 @@ export interface Position { latitude: number; longitude: number } export type MissionItem = Record; +export interface SwarmSegment { start_s: number; end_s: number; start: Position; end: Position; phase: string; cell_id: number; sortie_id: number } +export interface SwarmPosition extends Position { drone_id: number; cell_id: number; phase: string } +export const droneColors = ["#2563eb", "#c026d3", "#15803d", "#ea580c", "#0891b2", "#7c3aed", "#be123c", "#854d0e"]; export interface SurveyPlan { - search_area?: { center: Position; radius_m: number }; profile_name?: string; cruise_speed_m_s: number; + search_area?: { center: Position; radius_m: number; shape?: string; side_m?: number }; profile_name?: string; cruise_speed_m_s: number; + cell_count?: number; cell_side_m?: number; max_cell_side_m?: number; + cells?: { cell_id: number; boundary: Position[]; center: Position; drone_id: number; line_count: number; coverage_percent: number; sortie_ids: number[] }[]; + swarm?: { simulation_only: boolean; drone_count: number; duration_s: number; warning: string; + drones: { drone_id: number; cell_ids: number[]; sortie_ids: number[]; duration_s: number; segments: SwarmSegment[] }[] }; plan_id: string; schema_version: number; home: Position; boundary: Position[]; altitude_m: number; area_m2: number; coverage_percent: number; grid_angle_deg: number; sortie_budget_minutes: number; camera: { cross_track_m: number; along_track_m: number; line_spacing_m: number; trigger_distance_m: number; trigger_interval_s: number; gsd_width_cm: number; gsd_height_cm: number }; - survey_lines: { sortie_id: number; start_latitude: number; start_longitude: number; end_latitude: number; end_longitude: number }[]; - sorties: { sortie_id: number; line_count: number; route: Position[]; distance_m: number; + survey_lines: { cell_id?: number; drone_id?: number; sortie_id: number; start_latitude: number; start_longitude: number; end_latitude: number; end_longitude: number }[]; + sorties: { cell_id?: number; drone_id?: number; scheduled_end_s?: number; sortie_id: number; line_count: number; route: Position[]; distance_m: number; estimated_minutes: number; estimated_photos: number; outside_boundary_m: number; mission_item_count: number; ready: boolean; blocking_reasons: string[]; mission_items: MissionItem[] }[]; estimate: { survey_line_count: number; total_distance_m: number; estimated_minutes: number; @@ -17,18 +24,30 @@ export interface SurveyPlan { } // Display preview only. The Pi computes the authoritative geodesic boundary. -export function radiusPreview(center: Position | null, radius: number): Position[] { +export function squarePreview(center: Position | null, radius: number): Position[] { if (!center || !Number.isFinite(radius) || radius < 2 || radius > 10000) return []; const lat = center.latitude * Math.PI / 180, lon = center.longitude * Math.PI / 180; - const distance = radius / 6371008.8; - return Array.from({ length: 64 }, (_, index) => { - const bearing = index * 2 * Math.PI / 64; + const distance = Math.SQRT2 * radius / 6371008.8; + return [225, 135, 45, 315].map(degrees => { + const bearing = degrees * Math.PI / 180; const latitude = Math.asin(Math.sin(lat) * Math.cos(distance) + Math.cos(lat) * Math.sin(distance) * Math.cos(bearing)); const longitude = lon + Math.atan2(Math.sin(bearing) * Math.sin(distance) * Math.cos(lat), Math.cos(distance) - Math.sin(lat) * Math.sin(latitude)); return { latitude: latitude * 180 / Math.PI, longitude: ((longitude * 180 / Math.PI + 540) % 360) - 180 }; }); } +export function swarmPositions(swarm: SurveyPlan["swarm"], elapsed: number): SwarmPosition[] { + return (swarm?.drones ?? []).flatMap(drone => { + const segments = drone.segments; + const segment = segments.find(item => elapsed < item.end_s) ?? segments[segments.length - 1]; + if (!segment) return []; + const progress = Math.max(0, Math.min(1, (elapsed - segment.start_s) / Math.max(.001, segment.end_s - segment.start_s))); + return [{ drone_id: drone.drone_id, cell_id: segment.cell_id, phase: elapsed >= drone.duration_s ? "complete" : segment.phase, + latitude: segment.start.latitude + (segment.end.latitude - segment.start.latitude) * progress, + longitude: segment.start.longitude + (segment.end.longitude - segment.start.longitude) * progress }]; + }); +} + export function rectangle(a: Position, b: Position): Position[] { return [a, { latitude: a.latitude, longitude: b.longitude }, b, { latitude: b.latitude, longitude: a.longitude }]; } diff --git a/autonomous-grid-search/web/src/main.tsx b/autonomous-grid-search/web/src/main.tsx new file mode 100644 index 0000000..48d944a --- /dev/null +++ b/autonomous-grid-search/web/src/main.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { MissionPlanner } from "./components/mission_planner"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + +
+ +
+
+); diff --git a/autonomous-grid-search/web/src/styles.css b/autonomous-grid-search/web/src/styles.css new file mode 100644 index 0000000..db31e62 --- /dev/null +++ b/autonomous-grid-search/web/src/styles.css @@ -0,0 +1,100 @@ +@import "maplibre-gl/dist/maplibre-gl.css"; +@import "tailwindcss"; +@config "../tailwind.config.js"; + +@font-face { + font-family: "JetBrains Mono"; + src: url("./assets/JetBrainsMono-Regular.woff2") format("woff2"); + font-style: normal; + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("./assets/JetBrainsMono-SemiBold.woff2") format("woff2"); + font-style: normal; + font-weight: 600 700; + font-display: swap; +} + +html, +body, +#root { + height: 100%; + width: 100%; +} + +body { + margin: 0; + font-family: "JetBrains Mono", ui-monospace, monospace; + background-color: #080b10; + color: #ffffff; + -webkit-font-smoothing: antialiased; +} + +::placeholder { + color: rgba(255, 255, 255, 0.7); +} + +[class*="text-sig-"] { + color: #ffffff; +} + +.tabular { + font-variant-numeric: tabular-nums; +} + +.hud-grid-bg { + background-image: + linear-gradient(to right, rgba(45, 212, 191, 0.04) 1px, transparent 1px), + linear-gradient(to bottom, rgba(45, 212, 191, 0.04) 1px, transparent 1px); + background-size: 48px 48px; +} + +.maplibregl-map { + font: inherit; + background: #070b10; +} + +.maplibregl-ctrl-group { + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.25); + border-radius: 8px; + background: #10161f; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28); +} + +.maplibregl-ctrl-group button { + background-color: #10161f; +} + +.maplibregl-ctrl-attrib { + color: #334155; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} +/* Survey handles remain readable against aerial imagery and street maps. */ +.survey-vertex { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: 2px solid white; + border-radius: 50%; + background: #0f766e; + color: white; + font: 600 12px/1 monospace; + cursor: grab; + box-shadow: 0 1px 4px #0008; +} +.survey-vertex:focus-visible { outline: 3px solid #fbbf24; outline-offset: 2px; } diff --git a/autonomous-grid-search/web/src/types/assets.d.ts b/autonomous-grid-search/web/src/types/assets.d.ts new file mode 100644 index 0000000..c0ce482 --- /dev/null +++ b/autonomous-grid-search/web/src/types/assets.d.ts @@ -0,0 +1,4 @@ +declare module "*?url" { + const url: string; + export default url; +} diff --git a/autonomous-grid-search/web/tailwind.config.js b/autonomous-grid-search/web/tailwind.config.js new file mode 100644 index 0000000..dba1b5f --- /dev/null +++ b/autonomous-grid-search/web/tailwind.config.js @@ -0,0 +1,39 @@ +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + hud: { + bg: "#080b10", + panel: "#0e131a", + raise: "#141b24", + line: "#1d2733", + edge: "#27333f", + text: "#ffffff", + muted: "#ffffff", + dim: "#ffffff", + faint: "#ffffff" + }, + sig: { + cyan: "#2dd4bf", + blue: "#4c8dff", + amber: "#f5a524", + red: "#f2555a", + green: "#3fcf8e", + violet: "#9b8cff", + pink: "#f472b6" + } + }, + fontFamily: { + sans: ["JetBrains Mono", "ui-monospace", "monospace"], + mono: ["JetBrains Mono", "ui-monospace", "monospace"] + }, + fontSize: { + "2xs": ["14px", "20px"], + xs: ["15px", "22px"], + sm: ["16px", "24px"] + } + } + }, + plugins: [] +}; diff --git a/autonomous-grid-search/web/tsconfig.json b/autonomous-grid-search/web/tsconfig.json new file mode 100644 index 0000000..54881ad --- /dev/null +++ b/autonomous-grid-search/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "composite": true, + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2020"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/autonomous-grid-search/web/tsconfig.node.json b/autonomous-grid-search/web/tsconfig.node.json new file mode 100644 index 0000000..7065ca9 --- /dev/null +++ b/autonomous-grid-search/web/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/autonomous-grid-search/web/vite.config.ts b/autonomous-grid-search/web/vite.config.ts new file mode 100644 index 0000000..5396a16 --- /dev/null +++ b/autonomous-grid-search/web/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + base: "/", + plugins: [react()], + server: { port: 5174 }, + build: { + outDir: "dist", + emptyOutDir: true + } +}); diff --git a/telemetry/README.md b/telemetry/README.md index b699372..26e85fd 100644 --- a/telemetry/README.md +++ b/telemetry/README.md @@ -63,44 +63,9 @@ Companion states are: - `degraded` - one or more required inputs are unavailable or outside the configured limits. - `failsafe` - a critical battery or temperature condition exists, or telemetry became stale while the aircraft was armed. This is a reported state; Pixhawk remains responsible for flight stabilization and configured vehicle failsafes. -## Companion Search Mission Planner +## Autonomous Grid Search -The Raspberry Pi service exposes a planning API on port `8765`. The dashboard's -**Mission planner** view lets an operator select a search centre on the offline -coordinate plot, enter a radius and altitude, and preview the complete route. - -The planner creates four cells per kilometre of search radius: - -- `1 km` radius → `4` cells -- `2 km` radius → `8` cells -- `3 km` radius → `12` cells - -Cells are visited in a lawnmower pattern. The generated mission contains home, -takeoff, every search-cell centre, a return waypoint, and landing. Radius is -limited to `0.25–10 km` and altitude to `5–120 m`. - -Start the Raspberry Pi simulator and API: - -```bash -python3 main.py --simulate --no-radio --companion-mode assist --companion-api-port 8765 -``` - -Then enter `http://:8765` in the dashboard's companion API -field. These endpoints are available: - -- `GET /api/health` - reports connection and simulation state. -- `POST /api/mission/plan` - validates inputs and returns the generated plan. -- `POST /api/mission/simulate` - loads and starts the plan only when the Pi - service was started with `--simulate`. - -Live Pixhawk mission execution remains locked. The API returns HTTP `403` for a -live execution request; planning and preview continue to work. - -Open the planner directly without connecting an ESP32 first: - -```text -http://localhost:8080/?view=mission -``` +Survey planning, the companion API, and the mission webpage are maintained in [autonomous-grid-search](../autonomous-grid-search/README.md). The Pi relay starts the planning API unless `--no-companion-api` is set. The telemetry dashboard displays ESP32 telemetry only. ## Running the Web Dashboard @@ -147,10 +112,6 @@ secure pages. Remote access requires an authenticated HTTPS or MQTT relay betwee the vehicle and dashboard; publishing Docker port 8080 does not make the ESP32 endpoint reachable over the Internet. -## Mission Control - -Mission planning, grid-search, and `/api/mission/*` control endpoints are intentionally out of scope for this telemetry dashboard update. They should live in a separate mission-control module or PR so the telemetry relay remains focused on Pixhawk MAVLink input, NRF24 transmission, ESP32 JSON output, and laptop dashboard display. - ## ESP32 Wi-Fi Credentials Do not commit Wi-Fi secrets into `esp.ino`. Provide them as build-time defines or through your local Arduino/PlatformIO environment: diff --git a/telemetry/handheld-nrf-monitor/pi-code/main.py b/telemetry/handheld-nrf-monitor/pi-code/main.py index b48e5b0..670fd35 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/main.py +++ b/telemetry/handheld-nrf-monitor/pi-code/main.py @@ -15,6 +15,9 @@ import signal import argparse import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "autonomous-grid-search" / "backend")) from sbc_monitor import SBCMonitor from mavlink_manager import MAVLinkManager diff --git a/telemetry/live-web-dashboard/README.md b/telemetry/live-web-dashboard/README.md index 113e4a4..9196faf 100644 --- a/telemetry/live-web-dashboard/README.md +++ b/telemetry/live-web-dashboard/README.md @@ -43,50 +43,5 @@ The relay should authenticate the vehicle, encrypt traffic, restrict allowed bro - The battery cell count can be selected from 2S through 6S. - Charts display 64 samples collected at the dashboard's 1 Hz polling rate. -## Auto-grid survey planner -Start the companion API on the Pi (or locally without hardware): - -```bash -cd telemetry/handheld-nrf-monitor/pi-code -python -m pip install -r requirements.txt -python main.py --simulate --no-radio -``` - -Open the Docker dashboard at `http://localhost:8080/?view=mission`, or the Vite URL with `?view=mission`. Enter the Pi address and port 8765 in **Companion API**. Docker serves the website; the Python companion runs separately and must be reachable from the browser. Use a trusted network; the planning/simulation API has no authentication. - -1. Click the map to select the **search centre**. Coordinate entry is available under **Connection & coordinate entry** for keyboard use. -2. Enter the **search radius in metres** (1,000 m = 1 km), then select **Generate survey on Pi**. -3. The browser sends only `search_area: {center: {latitude, longitude}, radius_m}`. The Pi reads its local `survey_config.json`, gets launch/home from fresh 3D vehicle GPS, creates a geodesic circular boundary and computes the grid, spacing, direction, camera triggering and battery sorties. The vehicle must be disarmed. Without usable GPS, the Pi can preview coverage but blocks export and simulation. -4. Review the returned route. Select a sortie and **Export to Mission Planner** to download `sentinelblue_survey_N.waypoints` (QGC WPL 110). In Mission Planner use **PLAN → Load WP File**. Export each battery sortie separately. Changes to the point or radius invalidate the old plan. -5. **Simulate this sortie** executes the exact cached plan after acknowledgement. Plans expire after 10 minutes or API restart. The API rejects replacement while the simulator is armed and rejects all live execution. Companion assist mode remains a readiness monitor. - -### Configure the aircraft once on the Pi - -Edit `telemetry/handheld-nrf-monitor/pi-code/survey_config.json` on the companion computer. Defaults are 5 m height, 2 m/s, 78° × 60° FOV, 70% side overlap, 75% forward overlap, 20 minute endurance and 20% reserve. These are starting values, not measured specifications for your hardware. Calibrate the camera, endurance and trigger interval for the actual aircraft before flight validation. The file is loaded for each planning request; the browser cannot override it through a point/radius request. Existing reviewed plans retain their original settings. - -The Pi calculates spacing from its camera model, chooses an estimated shortest direction from 15° bearing candidates plus the longest boundary edge, and splits complete lines into sorties capped at 500 commands and the configured battery budget. Every sortie contains takeoff, speed, survey/camera commands, return and land. A 64-sided polygon circumscribes the requested circle so the approximation does not exclude the circular rim. Very large searches may exceed the 10,000 line / 100 sortie planning limits and must be divided into smaller search areas. - -The geometry engine also retains its polygon planning API for integration; the operator-facing screen uses only point and radius. - -### Camera and flight assumptions - -Footprint = `2 × height × tan(FOV / 2)`. Maximum line gap = cross-track footprint × `(1 − side overlap)`. Trigger distance = along-track footprint × `(1 − forward overlap)`. Spacing tightens to fit the polygon extent. Crosshatch passes can have slightly different actual gaps; the displayed gap is the first pass. Modelled coverage uses flat, nadir, aligned footprints with distance-triggered photos. At least 99.5% modelled coverage is required to export; this is not measured image coverage. - -A camera-rate check blocks intervals shorter than the entered camera minimum. Export includes `DO_CHANGE_SPEED` and `DO_SET_CAM_TRIGG_DIST`; configure and test the actual camera interface and yaw behaviour in ArduPilot before flight. The simulator models route, commanded speed and waypoint dwell; it does not simulate actual photos, wind, vehicle dynamics or camera latency. - -For ArduCopter, configure face-next-waypoint yaw and a waypoint radius smaller than the line gap, and validate the route in ArduPilot SITL. Per-waypoint yaw and acceptance-radius fields do not configure these behaviours in Copter; see the [official mission command reference](https://docs.ardupilot.org/copter/docs/mission-command-list.html). The built-in simulator is not ArduPilot SITL. - -Each line has a one-second dwell at each end. Estimates add the entered turn allowance per line and two minutes for takeoff/landing. They do not measure battery capacity, wind or power consumption. A line that cannot fit one sortie is blocked rather than truncated. - -**Altitude is relative to home, not terrain-following.** Coverage assumes a flat surface at home elevation. A 5 m mission over water requires a validated downward sensor and flight-controller configuration; this planner does not configure them. DEM data is not water clearance. Run-outs and straight transfers may leave the boundary; the boundary is not a geofence. The planner does not provide obstacle avoidance, airspace checks or industrial flight certification. - -### Maps and terrain - -The default map uses OpenStreetMap raster tiles and MapLibre demo hillshade. Set `VITE_MAPTILER_KEY` at build time for MapTiler hybrid imagery. The map stays top-down for boundary accuracy. - -Optional terrain context samples home, centre and boundary corners using OpenTopoData SRTM90m. Set `TERRAIN_API_URL` to another compatible endpoint. Missing tiles or terrain data do not change mission altitude. Coordinates remain editable when imagery is unavailable. - -### Verification - -Run `python -m unittest discover -s tests -v` from the Pi code directory and `npm run check` from the dashboard directory. Tests cover camera axes, sensor dimensions, coverage, invalid polygons, rotation, reverse entry, concave boundaries, crosshatch, camera rate, sortie limits, exact-plan execution, expiry, confirmation, live/armed guards, WPL coordinate frames and boundary parsing. +Autonomous grid-search planning has moved to [autonomous-grid-search](../../autonomous-grid-search/README.md). diff --git a/telemetry/live-web-dashboard/src/app.tsx b/telemetry/live-web-dashboard/src/app.tsx index 93e6ca3..0f00523 100644 --- a/telemetry/live-web-dashboard/src/app.tsx +++ b/telemetry/live-web-dashboard/src/app.tsx @@ -1,12 +1,11 @@ import { lazy, Suspense, useEffect, useState } from "react"; -import { GaugeIcon, MapPinnedIcon, PlugZapIcon, SlidersHorizontalIcon, WavesIcon, WifiIcon } from "lucide-react"; +import { GaugeIcon, PlugZapIcon, SlidersHorizontalIcon, WavesIcon, WifiIcon } from "lucide-react"; import { AltitudeCard } from "./components/altitude_card"; import { AttitudeIndicator } from "./components/attitude_indicator"; import { BatteryBox } from "./components/battery_box"; import { FlightStatusCard } from "./components/flight_status_card"; import { LinkCard } from "./components/link_card"; import { NavSignalBox } from "./components/nav_signal_box"; -import { MissionPlanner } from "./components/mission_planner"; import { SbcStatusCard } from "./components/sbc_status_card"; import { TopBar } from "./components/top_bar"; import { gyroSeries, motorSeries, pidSeries, vehicle } from "./data/telemetry"; @@ -57,8 +56,7 @@ function ConnectionPanel({ error, onChange, onCellCountChange, - onSubmit, - onOpenMission + onSubmit }: { value: string; cellCount: number; @@ -66,31 +64,30 @@ function ConnectionPanel({ onChange: (value: string) => void; onCellCountChange: (value: number) => void; onSubmit: () => void; - onOpenMission: () => void; }) { return (
{ event.preventDefault(); onSubmit(); }} > -
- - +
+ +
-

Connect ESP32 Telemetry

-

Enter the ESP32 address or a secure telemetry URL.

+

Connect ESP32 Telemetry

+

Enter the ESP32 address or a secure telemetry URL.

-