Signed-off-by: Onur Özkan <work@onurozkan.dev>
This commit is contained in:
Onur Özkan committed 2026-08-17 18:51:40 +03:00
1 parent 43347c1172
commit 6e15063502
122 files changed
+7848 -9828

No files matched your search

+7 -45
View File
@@ -1,45 +1,7 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
node_modules/
dist/
coverage/
.vite/
playwright-report/
test-results/
*.tsbuildinfo
-30
View File
@@ -1,30 +0,0 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "nixpkgs000000000000000000000000000000000"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: nixpkgs000000000000000000000000000000000
base_revision: nixpkgs000000000000000000000000000000000
- platform: linux
create_revision: nixpkgs000000000000000000000000000000000
base_revision: nixpkgs000000000000000000000000000000000
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+68 -14
View File
@@ -1,24 +1,78 @@
# Ground Suite
# Kaptan Ground Suite
Ground Suite is Kaptan's desktop ground-control application.
Kaptan Ground Suite is a map-first web workspace for coordinating missions,
tasks, groups, roles, and approximately 50 robotic vehicles. This first release
is deliberately backed by a deterministic simulation. It does **not** connect
to Kaptan firmware and does not send commands to real vehicles.
## Development
## Run and verify
From the repository root:
```sh
nix develop -c flutter analyze ground-suite
nix develop -c dart format --set-exit-if-changed ground-suite
nix develop -c sh -c 'cd ground-suite && flutter test'
nix develop -c sh -c 'cd ground-suite && flutter run -d linux'
nix run .#ground-suite
nix run .#ground-suite -- test
nix flake check
nix develop .#ground-suite
```
## Layout
The server binds to `127.0.0.1:4173` by default, prints its URL, and prints a
simulated-data warning. Override only when intentionally exposing the local
demo:
- `lib/app/` — application shell and appearance.
- `lib/features/fleet/` — fleet canvas and local-unit workspace.
- `lib/features/vehicle_workspace/` — installed vehicle definitions and Add-unit flow.
- `lib/features/diagnostics/` — diagnostic event stream.
- `lib/core/` — transport and MAVLink decoding boundaries.
```sh
KAPTAN_GROUND_SUITE_HOST=127.0.0.1 KAPTAN_GROUND_SUITE_PORT=4173 nix run .#ground-suite
```
The durable design and integration notes live in [the documentation](../docs/src/ground-suite.md).
Inside the development shell:
```sh
cd ground-suite
npm ci
npm run dev
npm test
npm run test:e2e
npm run check
```
The application has no remote runtime dependency: the demonstration map,
catalog imagery, fonts, simulation, and overlays are local. Catalog imagery is
covered by `public/assets/vehicle_catalog/LICENSE.md` (CC0 1.0).
## Operating model
The map is one continuously mounted operations scene. Fleet, queue, events,
deployment, commissioning, recovery, layers, and settings are drawers, trays,
or overlays; they never replace or remount the map. Strategic, operational,
tactical, and engineering detail is driven by the same camera and selection.
Selection, operator-local control groups, authoritative mission groups, and
authority leases are separate. Every consequential intent remains a local
spatial draft until its immutable targets, authority, energy, link, freshness,
constraints, and source status have been reviewed. The fake adapter is the only
mutation-capable source and is labelled as simulation throughout the scene.
The typed interaction controller and its mode contract live in
`src/interaction/controller.ts`. It owns selection, lasso/marquee, pan, command
intent, formation, annotation, task/safety geometry, measurement, mission
points, editing, erasing, and draft undo/redo. The benchmark and composition
decisions are recorded in `docs/DESIGN_RESET.md`.
Primary code boundaries:
- `src/core/data-plane/`: transport-neutral source, envelope validation,
ordering/deduplication, journal, reducers, preview, and command submission.
- `src/adapters/fake/`: scenario-owned deterministic clock and 50-vehicle
source; no UI component imports it.
- `src/domain/` and `src/projections/`: authoritative types and derived
selectors.
- `src/core/persistence/`: versioned local operator preferences only.
- `src/adapters/kaptan/` and `src/adapters/cerebrum/`: explicit future ports;
neither claims a live integration.
- `src/app/` and `src/design-system/`: persistent spatial scene, coordinate map,
docked workbenches, charts, SVG iconography, and the supplied graphite/navy
visual language.
- `tests/e2e/visual-compositions.spec.ts`: eight deterministic high-fidelity
states rendered at 1366×768, 1920×1080, and 3440×1440.
See `docs/` for architecture, safety, source behavior, and issue traceability.
-28
View File
@@ -1,28 +0,0 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+46
View File
@@ -0,0 +1,46 @@
# Architecture
## Boundary
```text
FakeRealtimeSource now / KaptanDataSource later
→ EventEnvelope validation
→ stream ordering and event-ID deduplication
→ bounded immutable DataPlane journal
→ mission reducer
→ projections and selectors
→ useSyncExternalStore
→ React presentation
React intent
→ DataPlane.preview
→ authority + safety checks
→ operator confirmation
→ DataPlane.submit
→ DataSource.send
→ receipt event
→ journal and mission projection
```
`main.tsx` is the composition root and the only presentation-side file that
chooses the simulation runtime. UI features receive a `DataPlane`; they cannot
import the fake source, transport, or virtual clock.
## State ownership
| Category | Owner | Persistence |
| --- | --- | --- |
| Mission truth | DataPlane projection | Source/replay service in a future release |
| Derived fleet/task/selection views | Pure selectors | Never |
| Local UI state | React feature state | Never unless promoted to preference |
| Operator preferences | `PreferenceStore` schema v2 | Local storage |
| Audit/event journal | DataPlane, bounded to 1,000 raw envelopes | Replaceable journal port |
Telemetry is updated in the data plane and subscriber notification is batched
to a projection cadence. React does not own a second authoritative mission
copy. The 50 markers use CSS transforms and the fake source uses scenario-level
ticks instead of component timers.
The map is a local raster demonstration asset with DOM/SVG overlays. Spatial
values are simulated percentages and must not be interpreted as surveyed
coordinates, navigation truth, or a safe route.
+25
View File
@@ -0,0 +1,25 @@
# Data plane
`DataSource` exposes descriptor/capabilities, connect, disconnect, command
submission, and optional replay controls. It contains no WebSocket, serial,
MAVLink, or Cerebrum types.
Every `EventEnvelope` carries schema version, stable event and stream IDs,
monotonic sequence, type, entity reference, source/receive timestamps,
optional correlation/causation IDs, payload, and quality/staleness metadata.
Unsupported schemas, invalid identity, sequence, timestamp, type, or quality
metadata are rejected into observable source diagnostics. Duplicate event IDs
and sequences at or below a stream high-water mark never mutate mission truth.
The fake journal is bounded to 1,000 envelopes and activity projection to 500
rows.
A snapshot replaces the projection. Telemetry updates known vehicles and
never creates identities. Source-health events represent pause, loss,
degradation, and recovery. An eventual live adapter must add unknown-reference
quarantine, signed source identity, restart epochs, and durable replay storage.
Commands use explicit lifecycle names. This release exercises Draft →
Validating → Validated → Dispatched → Acknowledged through preview and receipt
events, and represents queued/executing/completed intents in the action queue.
Rejected previews or source receipts include a human reason and remediation;
they do not mutate mission truth.
+37
View File
@@ -0,0 +1,37 @@
# Kaptan Ground Suite — spatial design reset
This board is the implementation benchmark for the persistent operations scene. The supplied Kaptan images are treated as visual-language evidence, not as an information architecture.
## Evidence board
| Reference | Borrow | Avoid |
| --- | --- | --- |
| Company of Heroes 2 | Safe left-click selection, additive selection, numbered control groups, contextual command verbs, readable queued intent, tactical overview | Orders that fire from an ambiguous right-click; game-only pause semantics; selection implying command rights |
| Total War battles | Formation footprints, drag-to-orient placement, compact unit cards, group framing, large-force distributions | Screen-filling unit-card chrome; hidden morale/state meanings; instant consequential placement |
| QGroundControl setup | Capability coverage, explicit calibration state, parameter metadata, preflight blockers, geofence concepts | A separate page per subsystem, long forced setup wizard, scalar-only telemetry, forms that hide geography |
| Supplied semantic-zoom image | Strategic → operational → tactical information density; selected/observed/planned separation | Three separate map instances or page transitions to represent zoom |
| Supplied mission overview | Graphite/navy palette, compact status strip, authority/presence language, direct map labels | Top navigation, oversized attention columns, cards competing with the map |
| Supplied task/recovery images | Spatial route preview, exact recommendation comparison, audit timeline | Full-screen recovery page; acceptance before a final revalidation |
| Supplied design-system image | Cyan selection, violet planning, amber attention, red only for danger, non-color marker shapes | Color-only state, generic rectangular button walls, tiny low-contrast text |
## Interaction state boundary
`Select` is the only safe resting state. Map pointer input is routed through one typed interaction controller. A mode owns its cursor, valid targets, pointer/keyboard semantics, draft geometry, completion, cancellation, validation, and output. Space/middle pointer temporarily owns pan. Escape cancels segment → draft → mode. Draft undo/redo never alters observed mission truth.
Selection, local control groups, authoritative mission groups, and authority leases are independent state domains. A command draft captures an immutable target set and is revalidated before the simulated adapter can acknowledge it.
## Persistent compositions
All viewports use the same mounted scene and the same regions; only secondary density changes.
| Viewport | Map target | Composition |
| --- | ---: | --- |
| 1366×768 | ≥ 52% of useful body area | 42 px status strip; 48 px tool rail; 218 px roster; map center; 286 px inspector; 122 px unit deck; 32 px collapsed timeline |
| 1920×1080 | ≥ 60% | 48 px rail; 238 px roster; 320 px inspector; expanded spatial labels and telemetry charts |
| 3440×1440 | ≥ 68% | fixed secondary rails; map absorbs width; inspector becomes a two-column engineering surface; unit cards remain bounded |
Tools never replace the map. Fleet occupies the roster overlay; deployment and contextual abilities occupy the bottom deck; queue/events/telemetry expand upward; configuration and recovery dock from the right; settings is a compact drawer.
## Required high-fidelity states
The live implementation itself is the high-fidelity composition source. Deterministic state presets and screenshot tests cover: default tactical control, large multi-selection, route/formation drafting, task-area drawing, deployment/discovery, calibration, parameter diff, and degraded recovery. Screenshots are captured at 1366×768, 1920×1080, and 3440×1440; no preset is allowed to unmount `[data-testid="operational-map"]`.
+20
View File
@@ -0,0 +1,20 @@
# Deterministic fake scenarios
The default seed (`17`) creates Coastal Survey / ALPHA-17, five operators,
five groups, fifty vehicles, six archetypes, tasks, queues, authority leases,
and a common initial event history. Positions, energy, link quality, and
headings are reproducible.
The virtual clock supports pause, resume, restart, 0.5×–4× UI speed, and
manual stepping in tests. A 100 ms source cadence models 10 Hz vehicle input;
the data plane batches subscriber notification. Deterministic tick windows
exercise packet staleness, ALPHA navigation degradation and coverage loss,
source degradation, and recovery. BRAVO starts near constraint R-210B.
Recovery ranks compatible reserve candidates from energy, link, distance, and
capability. The recommendation remains a preview until the operator confirms;
the alert is marked resolved only by the acknowledged simulated command event.
Restart reconstitutes the exact seed and clears the data-plane ordering state.
Tests construct the source with automatic timers disabled and call `step`, so
no assertion depends on wall-clock scheduling.
+16
View File
@@ -0,0 +1,16 @@
# Ground-station issue traceability
Firmware-dependent work is explicitly deferred. “Implemented” below means the
Ground Suite simulation side, not a live-aircraft integration.
| Issue | Implemented scope | Deferred / firmware dependency | Modules and tests | Acceptance criteria |
| --- | --- | --- | --- | --- |
| #24 `ground-station: add persistent app state` | Schema-v2 layers, panels, density, motion, viewport, hotkeys, last workspace, safe draft references; migration/reset/corruption recovery | Shared mission persistence and telemetry storage | `core/persistence/preferences.ts`, `preferences.test.ts`, Settings | Reload-safe preferences; invalid data resets; no mission truth persisted |
| #23 `ground-station: support "jump to" on events` | Entity/position targets change selection, zoom, map highlight, inspector/context; alert and timeline paths | Distributed event provenance | `domain/model.ts`, `App.tsx`, `App.test.tsx` | Jump selects and identifies group/task/vehicle/constraint context |
| #22 `ground-station: toggleable information layers` | Ten searchable/operational layers, all/none, legend, persistence, critical mandatory layers | Certified geodata and live coverage | `preferences.ts`, `LayerPanel`, component tests | Keyboard toggles persist; mandatory safety context remains visible |
| #21 `ground-station: tactical selection (via configurable hotkeys)` | Click, Shift add/remove, marquee, group keys, double-frame, F/Esc/L/Q/Space, conflicts, accessible list alternatives | Policy-synchronized hotkeys and robust lasso | `App.tsx`, `preferences.test.ts`, `App.test.tsx` | Inputs never capture shortcuts; exact selection shown |
| #20 `ground-station (+firmware): action queue` | Ordered intentions, dependency/owner/resource/duration/timeline, accessible reorder, preview, receipt audit | Firmware queue protocol, cancellation/reconciliation | `domain/model.ts`, Queue workspace, `DataPlane.test.ts` | Reorder remains draft until validated, confirmed, acknowledged |
| #19 `ground-station (+firmware): cerebrum integration` | Port, discovery/outcome/result/error types, deterministic fake and contract tests | Real API, transport, concurrency, certification | `adapters/cerebrum/*` | Unsupported outcomes reject explicitly; no live claim |
| #18 `ground-station: multi-operator support` | Five presences/cursors/roles/activity, observers, leases, explicit transfer preview and audit, read-only language | Shared server, arbitration, durable lease expiry | `scenario.ts`, mission inspector, preview tests | Selection and authority remain distinct; transfer is explicit |
| #17 `ground-station: animated vehicle archetypes on vehicle catalog` | Six imagery-backed cards, capabilities/roles/simulated range, counts/status, restrained orientation | Reviewed build-manifest catalog generation | Catalog workspace/assets; reduced-motion CSS | Motion respects preference; placeholders say simulated |
| #16 `ground-station: fleet management (multi vehicle management)` | Overview, membership/archetype/status/freshness/task/role/formation/filtering/reserve/bulk target preview/binding | Real source binding and server-side group transactions | Fleet workspace, selectors tests, preview tests | Every bulk operation exposes exact target set and validation |
+29
View File
@@ -0,0 +1,29 @@
# Future Kaptan and Cerebrum integration
`KaptanDataSource` is intentionally a rejecting skeleton. Current firmware is
telemetry-only and the repository safety boundary rejects inbound authority.
No reviewed command API, authenticated source binding, or capability exchange
exists, so this release claims none.
A future adapter must define and review:
- authenticated source and vehicle identity, key provisioning/rotation, and
persistent anti-replay state;
- MAVLink 2 signing policy and exact Kaptan dialect/schema revision;
- snapshot/restart epochs, sequence wrap, time synchronization, stale data,
reconnect and partial-fleet behavior;
- capability negotiation for vehicle/task/formation/action-queue behavior;
- idempotent command IDs, exact target binding, authority leases,
acknowledgement/rejection, cancellation, expiry, and reconciliation;
- safe handling of unsupported or downgraded firmware.
`AutonomyCoordinatorPort` separately models capability discovery and exchange
of desired outcomes, constraints, success criteria, proposals, explanations,
and typed errors. `FakeCerebrumAdapter` is contract-tested. The real Cerebrum
transport, concurrency semantics, conflict resolution, plan provenance,
versioning, timeout policy, and safety certification remain unresolved. A
wire protocol must not be inferred from the TypeScript interface.
The next integration step is a reviewed, read-only Kaptan telemetry adapter
that validates signed source-to-vehicle bindings and event freshness without
adding command authority.
+21
View File
@@ -0,0 +1,21 @@
# Safety model
Selection is local attention; authority is a mission-truth lease. Selected,
observed, requested, authority-held, conflict, and unavailable states use text
and symbols as well as color. No selection shortcut changes a lease.
Consequential commands always show what and where, the exact affected vehicle
set, current holder, reason for allowance/rejection, next state, and failure
effect. Validation covers simulated energy reserve, link quality, telemetry
freshness, route/constraint warning, capability where applicable, authority,
and source connectivity. A failed preview cannot be confirmed. Source
rejections carry a remediation path and are journaled.
UI movement is presentation only. The event stream is telemetry truth. The
persistent `SIMULATED DATA` badge and server warning distinguish every
interaction from real aircraft control. Local storage contains no telemetry,
lease, credential, receipt, or audit truth. Critical-state mandatory layers
cannot be hidden.
This is an operator workflow prototype and simulation test surface, not a
flight-safety approval or physical command system.
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#08111b" />
<meta name="description" content="Kaptan Ground Suite simulated mission workspace" />
<title>Kaptan Ground Suite — Simulated Mission</title>
</head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>
-20
View File
@@ -1,20 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app_shell.dart';
import 'theme.dart';
class GroundSuiteApp extends StatelessWidget {
const GroundSuiteApp({super.key});
@override
Widget build(BuildContext context) => ProviderScope(
child: MaterialApp(
title: 'Ground Suite',
debugShowCheckedModeBanner: false,
theme: groundSuiteDarkTheme(),
themeMode: ThemeMode.dark,
home: const AppShell(),
),
);
}
-71
View File
@@ -1,71 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/command/presentation/tactical_command_page.dart';
import '../features/diagnostics/presentation/diagnostics_page.dart';
import '../features/observability/presentation/observability_controller.dart';
enum _ApplicationSurface { tactical, eventStream }
/// Owns the two first-class Ground Suite surfaces. Tactical planning never
/// hides decoded diagnostics or critical accessibility announcements.
class AppShell extends ConsumerStatefulWidget {
const AppShell({super.key});
@override
ConsumerState<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell> {
_ApplicationSurface _surface = _ApplicationSurface.tactical;
@override
Widget build(BuildContext context) {
final observation = ref.watch(observabilityControllerProvider);
return Scaffold(
body: Stack(
children: [
switch (_surface) {
_ApplicationSurface.tactical => TacticalCommandPage(
sourceLabel: observation.sourceLabel,
sourceStatus: observation.status.label,
onOpenEventStream: () =>
setState(() => _surface = _ApplicationSurface.eventStream),
),
_ApplicationSurface.eventStream => _EventStreamWorkspace(
onReturnToTactical: () =>
setState(() => _surface = _ApplicationSurface.tactical),
),
},
Semantics(
liveRegion: true,
label: observation.accessibilityAnnouncement ?? '',
child: const SizedBox.shrink(),
),
],
),
);
}
}
class _EventStreamWorkspace extends StatelessWidget {
const _EventStreamWorkspace({required this.onReturnToTactical});
final VoidCallback onReturnToTactical;
@override
Widget build(BuildContext context) => Stack(
children: [
const Positioned.fill(child: DiagnosticsPage()),
Positioned(
top: 16,
right: 20,
child: FilledButton.icon(
onPressed: onReturnToTactical,
icon: const Icon(Icons.map_outlined),
label: const Text('Tactical workspace'),
),
),
],
);
}
-70
View File
@@ -1,70 +0,0 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// Ground Suite's single visual language: a dark tactical operations deck.
const panelBackground = Color(0xD912141A);
const panelBorder = Color(0x1AFFFFFF);
const textPrimary = Color(0xFFF8FAFC);
const textSecondary = Color(0xFF94A3B8);
const accentOrange = Color(0xFFF97316);
const accentMint = Color(0xFF34D399);
const statusGreen = Color(0xFF10B981);
const statusRed = Color(0xFFEF4444);
const tacticalBlack = Color(0xFF000000);
const tacticalTransparent = Color(0x00000000);
ThemeData groundSuiteDarkTheme() {
final base = ThemeData.dark(useMaterial3: true);
final interTextTheme = GoogleFonts.interTextTheme(
base.textTheme,
).apply(bodyColor: textPrimary, displayColor: textPrimary);
return base.copyWith(
scaffoldBackgroundColor: tacticalBlack,
cardColor: panelBackground,
colorScheme: const ColorScheme.dark(
primary: accentMint,
onPrimary: tacticalBlack,
secondary: accentOrange,
onSecondary: tacticalBlack,
surface: panelBackground,
onSurface: textPrimary,
error: statusRed,
onError: textPrimary,
outline: panelBorder,
),
textTheme: interTextTheme.copyWith(
displayLarge: interTextTheme.displayLarge?.copyWith(color: textPrimary),
displayMedium: interTextTheme.displayMedium?.copyWith(color: textPrimary),
headlineLarge: interTextTheme.headlineLarge?.copyWith(color: textPrimary),
headlineMedium: interTextTheme.headlineMedium?.copyWith(
color: textPrimary,
),
headlineSmall: interTextTheme.headlineSmall?.copyWith(color: textPrimary),
titleLarge: interTextTheme.titleLarge?.copyWith(color: textPrimary),
titleMedium: interTextTheme.titleMedium?.copyWith(color: textPrimary),
titleSmall: interTextTheme.titleSmall?.copyWith(color: textPrimary),
bodyLarge: interTextTheme.bodyLarge?.copyWith(color: textPrimary),
bodyMedium: interTextTheme.bodyMedium?.copyWith(color: textPrimary),
bodySmall: interTextTheme.bodySmall?.copyWith(color: textSecondary),
labelLarge: interTextTheme.labelLarge?.copyWith(color: textPrimary),
labelMedium: interTextTheme.labelMedium?.copyWith(color: textSecondary),
labelSmall: interTextTheme.labelSmall?.copyWith(color: textSecondary),
),
cardTheme: const CardThemeData(color: panelBackground, elevation: 0),
dividerTheme: const DividerThemeData(color: panelBorder),
progressIndicatorTheme: ProgressIndicatorThemeData(
color: accentMint,
linearTrackColor: textSecondary.withValues(alpha: .2),
),
chipTheme: ChipThemeData(
backgroundColor: tacticalTransparent,
selectedColor: accentMint.withValues(alpha: .1),
side: const BorderSide(color: panelBorder),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
labelStyle: GoogleFonts.inter(color: textSecondary),
secondaryLabelStyle: GoogleFonts.inter(color: textPrimary),
),
);
}
@@ -1,12 +0,0 @@
import 'dart:typed_data';
/// Receive-only bytes from one MAVLink transport endpoint.
///
/// A source has one reader and owns its close lifecycle. A future adapter will
/// bind serial, UDP, or a recording source here; no source in the first slice
/// may write bytes to a vehicle.
abstract interface class MavlinkByteSource {
Stream<Uint8List> get chunks;
Future<void> close();
}
@@ -1,13 +0,0 @@
import '../observations/vehicle_data_update.dart';
import 'mavlink_byte_source.dart';
/// Converts a receive-only MAVLink byte stream into semantically safe events.
///
/// TODO(gcs-live-mavlink): generate this adapter from pinned MAVLink Common
/// definitions plus Kaptan's `kaptan.xml` root. It must reject MAVLink 1 and
/// CRC-invalid MAVLink 2 frames, validate every fixed diagnostic length before
/// UTF-8 decoding, retain unknown enum values and flags, and emit parser or
/// semantic drop updates without ending the connection.
abstract interface class MavlinkEventDecoder {
Stream<VehicleDataUpdate> decode(MavlinkByteSource source);
}
@@ -1,136 +0,0 @@
/// A MAVLink severity value retained exactly as it appeared on the wire.
///
/// Known values follow MAVLink's numeric severity semantics. Unknown values
/// remain displayable so a newer vehicle cannot silently lose diagnostic
/// meaning before Ground Suite has been updated.
final class MavSeverity {
const MavSeverity(this.wireValue);
static const emergency = MavSeverity(0);
static const alert = MavSeverity(1);
static const critical = MavSeverity(2);
static const error = MavSeverity(3);
static const warning = MavSeverity(4);
static const notice = MavSeverity(5);
static const info = MavSeverity(6);
static const debug = MavSeverity(7);
final int wireValue;
String get label => switch (wireValue) {
0 => 'Emergency',
1 => 'Alert',
2 => 'Critical',
3 => 'Error',
4 => 'Warning',
5 => 'Notice',
6 => 'Info',
7 => 'Debug',
_ => 'Unknown ($wireValue)',
};
}
/// A KAPTAN_SHUTDOWN reason retained exactly as it appeared on the wire.
final class KaptanShutdownReason {
const KaptanShutdownReason(this.wireValue);
static const operator = KaptanShutdownReason(1);
static const safety = KaptanShutdownReason(2);
final int wireValue;
String get label => switch (wireValue) {
1 => 'Operator',
2 => 'Safety',
_ => 'Unknown ($wireValue)',
};
}
enum TelemetryKind {
rawImu,
distanceSensor,
opticalFlow;
String get label => switch (this) {
TelemetryKind.rawImu => 'Raw IMU',
TelemetryKind.distanceSensor => 'Distance sensor',
TelemetryKind.opticalFlow => 'Optical flow',
};
}
sealed class ObservedEvent {
const ObservedEvent({required this.receivedAt});
final DateTime receivedAt;
}
final class HeartbeatEvent extends ObservedEvent {
const HeartbeatEvent({
required super.receivedAt,
required this.systemId,
required this.componentId,
required this.armed,
required this.vehicleState,
});
final int systemId;
final int componentId;
final bool armed;
final String vehicleState;
}
final class TelemetryEvent extends ObservedEvent {
const TelemetryEvent({required super.receivedAt, required this.kind});
final TelemetryKind kind;
}
final class LogEvent extends ObservedEvent {
const LogEvent({
required super.receivedAt,
required this.level,
required this.message,
this.flags = 0,
});
static const traceFlag = 1;
static const truncatedFlag = 2;
static const coalescedFlag = 4;
static const knownFlags = traceFlag | truncatedFlag | coalescedFlag;
final MavSeverity level;
final String message;
final int flags;
bool get isTrace => flags & traceFlag != 0;
bool get isTruncated => flags & truncatedFlag != 0;
bool get isCoalesced => flags & coalescedFlag != 0;
int get unknownFlags => flags & ~knownFlags;
}
final class ErrorEvent extends ObservedEvent {
const ErrorEvent({
required super.receivedAt,
required this.path,
this.additionalInformation = '',
this.flags = 0,
});
static const truncatedFlag = 2;
static const coalescedFlag = 4;
static const knownFlags = truncatedFlag | coalescedFlag;
final String path;
final String additionalInformation;
final int flags;
bool get isTruncated => flags & truncatedFlag != 0;
bool get isCoalesced => flags & coalescedFlag != 0;
int get unknownFlags => flags & ~knownFlags;
}
final class ShutdownEvent extends ObservedEvent {
const ShutdownEvent({required super.receivedAt, required this.reason});
final KaptanShutdownReason reason;
}
@@ -1,16 +0,0 @@
import 'vehicle_data_update.dart';
/// A receive-only source of already-classified vehicle observations.
///
/// The replay implementation is the first concrete port. A live port may be
/// added only after the custom MAVLink decoder has its length/CRC/enum safety
/// contract and endpoint lifecycle tests.
abstract interface class VehicleDataPort {
String get label;
bool get isReplay;
Stream<VehicleDataUpdate> observe();
Future<void> close();
}
@@ -1,28 +0,0 @@
import 'observed_event.dart';
/// One receive-side outcome from a vehicle-data transport.
///
/// Expected malformed or unsupported MAVLink frames are reported as drops and
/// must not terminate the transport stream. Transport failures remain stream
/// errors because the connection lifecycle must handle them separately.
sealed class VehicleDataUpdate {
const VehicleDataUpdate();
}
/// A classified observation that is safe to present.
final class ObservationReceived extends VehicleDataUpdate {
const ObservationReceived(this.event);
final ObservedEvent event;
}
enum ObservationDropKind { parser, semantic }
/// One or more rejected frames, retained only as bounded accounting.
final class ObservationDropped extends VehicleDataUpdate {
const ObservationDropped({required this.kind, this.count = 1})
: assert(count > 0);
final ObservationDropKind kind;
final int count;
}
@@ -1,65 +0,0 @@
import '../observations/observed_event.dart';
import '../observations/vehicle_data_port.dart';
import '../observations/vehicle_data_update.dart';
/// Deterministic observations for the first Ground Suite workflow.
///
/// This is intentionally a typed replay rather than a pretend connected
/// vehicle. Raw frame replay moves behind the MAVLink decoder once bindings
/// are generated from the MAVLink Common and Kaptan dialect definitions.
final class ReplayVehicleDataPort implements VehicleDataPort {
const ReplayVehicleDataPort();
@override
String get label => 'Replay fixture';
@override
bool get isReplay => true;
@override
Stream<VehicleDataUpdate> observe() => Stream<VehicleDataUpdate>.fromIterable(
_events.map(ObservationReceived.new),
);
@override
Future<void> close() async {}
static final List<ObservedEvent> _events = [
HeartbeatEvent(
receivedAt: DateTime.utc(2026, 8, 10, 12),
systemId: 1,
componentId: 1,
armed: false,
vehicleState: 'Standby',
),
TelemetryEvent(
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 1),
kind: TelemetryKind.rawImu,
),
TelemetryEvent(
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 2),
kind: TelemetryKind.distanceSensor,
),
LogEvent(
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 3),
level: MavSeverity.warning,
message: 'Range aid stale',
flags: LogEvent.coalescedFlag,
),
ErrorEvent(
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 4),
path: 'kaptan_drivers::lidar_lite_v3::error::Error::TransferFault',
additionalInformation: 'The replay contains a bounded error detail.',
flags: ErrorEvent.truncatedFlag,
),
LogEvent(
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 5),
level: MavSeverity.info,
message: 'Replay fixture complete',
),
ShutdownEvent(
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 6),
reason: KaptanShutdownReason.operator,
),
];
}
File diff suppressed because it is too large. Load diff
@@ -1,616 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../fleet/domain/fleet_unit.dart';
import '../../vehicle_workspace/domain/vehicle_definition.dart';
/// A non-modal tactical side panel. Tool state remains in [TacticalMode], so a
/// panel can never silently become a map action.
enum TacticalPanel { fleetRoster, unitHangar, unitSetup }
/// Exactly one local map interaction can be active at a time.
sealed class TacticalMode {
const TacticalMode();
}
class TacticalBrowseMode extends TacticalMode {
const TacticalBrowseMode();
}
/// The operator's uncommitted local-unit setup. This is intentionally a panel
/// draft rather than a map tool: choosing a category never creates, connects,
/// or configures a vehicle.
class UnitCreationDraft {
const UnitCreationDraft({
required this.definition,
required this.displayName,
});
final VehicleDefinition definition;
final String displayName;
UnitCreationDraft copyWith({String? displayName}) => UnitCreationDraft(
definition: definition,
displayName: displayName ?? this.displayName,
);
}
/// Session-local freehand drawing tool. It never emits a vehicle command.
class TacticalPencilMode extends TacticalMode {
TacticalPencilMode({
this.hue = 164,
this.saturation = .78,
this.brightness = .94,
this.thickness = 3,
List<LocalScenePoint> keyboardPoints = const [],
}) : keyboardPoints = List.unmodifiable(keyboardPoints);
final double hue;
final double saturation;
final double brightness;
final double thickness;
final List<LocalScenePoint> keyboardPoints;
}
/// A local, freehand mark. It deliberately has no map pin or label: Pencil is
/// continuous ink, not a point-placement tool.
class LocalSceneDrawing {
LocalSceneDrawing({
required this.id,
required this.hue,
required this.saturation,
required this.brightness,
required this.thickness,
required List<LocalScenePoint> points,
}) : points = List.unmodifiable(points);
final String id;
final double hue;
final double saturation;
final double brightness;
final double thickness;
final List<LocalScenePoint> points;
}
/// A local point annotation. It is kept distinct from [LocalSceneDrawing] so
/// an accidental Pencil click can never become a named marker.
class TacticalMarkerMode extends TacticalMode {
const TacticalMarkerMode({
this.hue = 26,
this.saturation = .68,
this.brightness = .98,
this.pendingPoint,
});
final double hue;
final double saturation;
final double brightness;
final LocalScenePoint? pendingPoint;
}
class LocalSceneMarker {
const LocalSceneMarker({
required this.id,
required this.name,
required this.point,
required this.hue,
required this.saturation,
required this.brightness,
});
final String id;
final String name;
final LocalScenePoint point;
final double hue;
final double saturation;
final double brightness;
}
class TacticalUiState {
const TacticalUiState({
required this.selectedUnitIds,
required this.mode,
required this.panel,
required this.unitDraft,
required this.drawings,
required this.markers,
required this.selectedDrawingId,
required this.selectedMarkerId,
required this.overlays,
});
factory TacticalUiState.initial() => const TacticalUiState(
selectedUnitIds: {'DEMO-1021'},
mode: TacticalBrowseMode(),
panel: null,
unitDraft: null,
drawings: [],
markers: [],
selectedDrawingId: null,
selectedMarkerId: null,
overlays: {TacticalMapOverlay.drones},
);
final Set<String> selectedUnitIds;
final TacticalMode mode;
final TacticalPanel? panel;
final UnitCreationDraft? unitDraft;
final List<LocalSceneDrawing> drawings;
final List<LocalSceneMarker> markers;
final String? selectedDrawingId;
final String? selectedMarkerId;
final Set<TacticalMapOverlay> overlays;
TacticalUiState copyWith({
Set<String>? selectedUnitIds,
TacticalMode? mode,
TacticalPanel? panel,
bool clearPanel = false,
UnitCreationDraft? unitDraft,
bool clearUnitDraft = false,
List<LocalSceneDrawing>? drawings,
List<LocalSceneMarker>? markers,
String? selectedDrawingId,
String? selectedMarkerId,
bool clearSelectedDrawing = false,
bool clearSelectedMarker = false,
Set<TacticalMapOverlay>? overlays,
}) => TacticalUiState(
selectedUnitIds: selectedUnitIds == null
? this.selectedUnitIds
: Set.unmodifiable(selectedUnitIds),
mode: mode ?? this.mode,
panel: clearPanel ? null : panel ?? this.panel,
unitDraft: clearUnitDraft ? null : unitDraft ?? this.unitDraft,
drawings: drawings == null ? this.drawings : List.unmodifiable(drawings),
markers: markers == null ? this.markers : List.unmodifiable(markers),
selectedDrawingId: clearSelectedDrawing
? null
: selectedDrawingId ?? this.selectedDrawingId,
selectedMarkerId: clearSelectedMarker
? null
: selectedMarkerId ?? this.selectedMarkerId,
overlays: overlays == null ? this.overlays : Set.unmodifiable(overlays),
);
}
/// Only data-backed scene layers belong here. Synthetic sector, route, and
/// range-ring artwork is intentionally not part of the tactical workspace.
enum TacticalMapOverlay { drones }
final tacticalControllerProvider =
NotifierProvider<TacticalController, TacticalUiState>(
TacticalController.new,
);
/// Deterministic reducer for session-local tactical interaction state.
///
/// Agreed action taxonomy:
/// - unit selection and placement affect only a local workspace;
/// - Pencil affects only [LocalSceneDrawing] values;
/// - Marker affects only [LocalSceneMarker] values;
/// - overlay actions affect only local rendering.
///
/// This controller has no transport dependency and no method can emit MAVLink
/// or create a live vehicle identity.
class TacticalController extends Notifier<TacticalUiState> {
int _nextDrawing = 1;
int _nextMarker = 1;
@override
TacticalUiState build() => TacticalUiState.initial();
void selectUnit(String unitId, {bool toggle = false}) {
final selected = toggle ? {...state.selectedUnitIds} : <String>{};
if (toggle && selected.contains(unitId)) {
selected.remove(unitId);
} else {
selected.add(unitId);
}
state = state.copyWith(
selectedUnitIds: selected,
mode: const TacticalBrowseMode(),
clearSelectedDrawing: true,
clearSelectedMarker: true,
clearPanel: true,
);
}
/// Replaces the local visual selection after a drag-box gesture. This is a
/// scene-only selection and cannot establish a source or vehicle binding.
void selectUnits(Iterable<String> unitIds) => state = state.copyWith(
selectedUnitIds: Set.unmodifiable(unitIds.toSet()),
mode: const TacticalBrowseMode(),
clearSelectedDrawing: true,
clearSelectedMarker: true,
clearPanel: true,
);
void clearSelection() => state = state.copyWith(
selectedUnitIds: const {},
clearSelectedDrawing: true,
clearSelectedMarker: true,
);
void showFleetRoster() =>
state = state.copyWith(panel: TacticalPanel.fleetRoster);
void closePanel() => state = state.copyWith(clearPanel: true);
/// Opens the horizontal RTS hangar. The map remains in Browse mode behind
/// it; the hangar never becomes a pointer-placement tool.
void showUnitHangar() => state = state.copyWith(
mode: const TacticalBrowseMode(),
panel: TacticalPanel.unitHangar,
clearUnitDraft: true,
);
void chooseUnitDefinition(
VehicleDefinition definition, {
required String displayName,
}) => state = state.copyWith(
panel: TacticalPanel.unitHangar,
unitDraft: UnitCreationDraft(
definition: definition,
displayName: displayName,
),
);
void updateUnitDraftName(String displayName) {
final draft = state.unitDraft;
if (draft == null) {
return;
}
state = state.copyWith(unitDraft: draft.copyWith(displayName: displayName));
}
void beginUnitSetup() {
if (state.unitDraft == null) {
return;
}
state = state.copyWith(panel: TacticalPanel.unitSetup);
}
/// Returns to the catalog without discarding the selected category or its
/// local name draft. Closing the hangar is the explicit discard boundary.
void returnToUnitHangar() =>
state = state.copyWith(panel: TacticalPanel.unitHangar);
/// Opens setup for the one already-selected local workspace unit. The ID is
/// supplied by the presentation layer after it verifies the unit exists in
/// its workspace; this reducer only accepts a matching tactical selection.
void showUnitSetupForSelectedLocalUnit(String localUnitId) {
if (state.unitDraft != null ||
state.selectedUnitIds.length != 1 ||
!state.selectedUnitIds.contains(localUnitId)) {
return;
}
state = state.copyWith(
mode: const TacticalBrowseMode(),
panel: TacticalPanel.unitSetup,
);
}
void closeUnitHangar() =>
state = state.copyWith(clearPanel: true, clearUnitDraft: true);
void completeUnitPlacement(String unitId) => state = state.copyWith(
selectedUnitIds: {unitId},
mode: const TacticalBrowseMode(),
clearPanel: true,
clearUnitDraft: true,
);
void beginPencil() => state = state.copyWith(
mode: TacticalPencilMode(),
selectedUnitIds: const {},
clearSelectedDrawing: true,
clearSelectedMarker: true,
clearPanel: true,
clearUnitDraft: true,
);
void configurePencil({
double? hue,
double? saturation,
double? brightness,
double? thickness,
}) {
final mode = state.mode;
if (mode is! TacticalPencilMode) {
return;
}
state = state.copyWith(
mode: TacticalPencilMode(
hue: hue ?? mode.hue,
saturation: saturation ?? mode.saturation,
brightness: brightness ?? mode.brightness,
thickness: thickness ?? mode.thickness,
keyboardPoints: mode.keyboardPoints,
),
);
}
/// Accessible alternative to a direct pointer trace. Keyboard users add
/// points at a movable scene cursor; it never exposes a grid or location.
void addKeyboardPencilPoint(LocalScenePoint point) {
final mode = state.mode;
if (mode is! TacticalPencilMode) {
return;
}
state = state.copyWith(
mode: TacticalPencilMode(
hue: mode.hue,
saturation: mode.saturation,
brightness: mode.brightness,
thickness: mode.thickness,
keyboardPoints: [...mode.keyboardPoints, point],
),
);
}
bool get canFinishKeyboardPencil {
final mode = state.mode;
return mode is TacticalPencilMode && mode.keyboardPoints.length >= 2;
}
bool finishKeyboardPencil() {
final mode = state.mode;
if (mode is! TacticalPencilMode || !canFinishKeyboardPencil) {
return false;
}
_commitDrawing(
hue: mode.hue,
saturation: mode.saturation,
brightness: mode.brightness,
thickness: mode.thickness,
points: mode.keyboardPoints,
);
return true;
}
/// Commits a direct press-drag-release map gesture. Preview samples stay in
/// the input widget; only the simplified stroke becomes durable local scene
/// geometry here.
bool commitPencilStroke(List<LocalScenePoint> points) {
final mode = state.mode;
if (mode is! TacticalPencilMode) {
return false;
}
if (points.length < 2) {
return false;
}
_commitDrawing(
hue: mode.hue,
saturation: mode.saturation,
brightness: mode.brightness,
thickness: mode.thickness,
points: _decimateStroke(points),
);
return true;
}
void beginMarker() => state = state.copyWith(
mode: const TacticalMarkerMode(),
selectedUnitIds: const {},
clearSelectedDrawing: true,
clearSelectedMarker: true,
clearPanel: true,
clearUnitDraft: true,
);
void configureMarker({double? hue, double? saturation, double? brightness}) {
final mode = state.mode;
if (mode is! TacticalMarkerMode) {
return;
}
state = state.copyWith(
mode: TacticalMarkerMode(
hue: hue ?? mode.hue,
saturation: saturation ?? mode.saturation,
brightness: brightness ?? mode.brightness,
pendingPoint: mode.pendingPoint,
),
);
}
void chooseMarkerPoint(LocalScenePoint point) {
final mode = state.mode;
if (mode is! TacticalMarkerMode || mode.pendingPoint != null) {
return;
}
state = state.copyWith(
mode: TacticalMarkerMode(
hue: mode.hue,
saturation: mode.saturation,
brightness: mode.brightness,
pendingPoint: point,
),
);
}
/// Returns false when the label is not acceptable. Labels are local plain
/// text only, deliberately bounded before they can reach a map renderer.
bool commitMarkerName(String value) {
final mode = state.mode;
final name = value.trim();
if (mode is! TacticalMarkerMode ||
mode.pendingPoint == null ||
name.isEmpty ||
name.runes.length > 48) {
return false;
}
final marker = LocalSceneMarker(
id: 'local-marker-${_nextMarker++}',
name: name,
point: mode.pendingPoint!,
hue: mode.hue,
saturation: mode.saturation,
brightness: mode.brightness,
);
state = state.copyWith(
markers: [...state.markers, marker],
selectedMarkerId: marker.id,
clearSelectedDrawing: true,
mode: TacticalMarkerMode(
hue: mode.hue,
saturation: mode.saturation,
brightness: mode.brightness,
),
);
return true;
}
/// First cancellation closes an unfinished marker name prompt; the next
/// returns to Select. This gives Escape/right click a predictable ladder.
bool cancelActiveTool() {
final mode = state.mode;
if (mode is TacticalMarkerMode && mode.pendingPoint != null) {
state = state.copyWith(
mode: TacticalMarkerMode(
hue: mode.hue,
saturation: mode.saturation,
brightness: mode.brightness,
),
);
return false;
}
cancelMode();
return true;
}
void selectDrawing(String drawingId) => state = state.copyWith(
selectedUnitIds: const {},
selectedDrawingId: drawingId,
clearSelectedMarker: true,
mode: const TacticalBrowseMode(),
);
void selectMarker(String markerId) => state = state.copyWith(
selectedUnitIds: const {},
selectedMarkerId: markerId,
clearSelectedDrawing: true,
mode: const TacticalBrowseMode(),
);
void removeSelectedSceneObject() {
final drawing = state.selectedDrawingId;
final marker = state.selectedMarkerId;
if (drawing != null) {
state = state.copyWith(
drawings: state.drawings.where((value) => value.id != drawing).toList(),
clearSelectedDrawing: true,
);
return;
}
if (marker == null) {
return;
}
state = state.copyWith(
markers: state.markers.where((value) => value.id != marker).toList(),
clearSelectedMarker: true,
);
}
void clearSceneObjects() => state = state.copyWith(
drawings: const [],
markers: const [],
clearSelectedDrawing: true,
clearSelectedMarker: true,
);
void cancelMode() => state = state.copyWith(mode: const TacticalBrowseMode());
void toggleOverlay(TacticalMapOverlay overlay, bool visible) {
final overlays = {...state.overlays};
if (visible) {
overlays.add(overlay);
} else {
overlays.remove(overlay);
}
state = state.copyWith(overlays: overlays);
}
void resetLocalView() => state = state.copyWith(
overlays: {TacticalMapOverlay.drones},
mode: const TacticalBrowseMode(),
);
void _commitDrawing({
required double hue,
required double saturation,
required double brightness,
required double thickness,
required List<LocalScenePoint> points,
}) {
final drawing = LocalSceneDrawing(
id: 'local-drawing-${_nextDrawing++}',
hue: hue,
saturation: saturation,
brightness: brightness,
thickness: thickness,
points: List.unmodifiable(points),
);
state = state.copyWith(
drawings: [...state.drawings, drawing],
selectedDrawingId: drawing.id,
clearSelectedMarker: true,
mode: TacticalPencilMode(
hue: hue,
saturation: saturation,
brightness: brightness,
thickness: thickness,
),
);
}
// Preserve enough detail for a visibly hand-drawn line while keeping local
// session state bounded. Input preview has its own higher short-lived cap.
static const _maximumStrokePoints = 256;
List<LocalScenePoint> _decimateStroke(List<LocalScenePoint> points) {
if (points.length <= _maximumStrokePoints) {
return List.unmodifiable(points);
}
return List.unmodifiable([
for (var index = 0; index < _maximumStrokePoints; index++)
points[((points.length - 1) * index / (_maximumStrokePoints - 1))
.round()],
]);
}
}
/// Pointer preview is isolated from command/selection state. A map widget may
/// update it once per frame while a placement tool is active.
class TacticalPointerPreview {
TacticalPointerPreview({this.point, List<LocalScenePoint> stroke = const []})
: stroke = List.unmodifiable(stroke);
final LocalScenePoint? point;
final List<LocalScenePoint> stroke;
}
final tacticalPointerPreviewProvider =
NotifierProvider<TacticalPointerPreviewController, TacticalPointerPreview>(
TacticalPointerPreviewController.new,
);
class TacticalPointerPreviewController
extends Notifier<TacticalPointerPreview> {
@override
TacticalPointerPreview build() => TacticalPointerPreview();
void show(LocalScenePoint point, {List<LocalScenePoint> stroke = const []}) =>
state = TacticalPointerPreview(point: point, stroke: stroke);
void nudge(double dx, double dy) {
final current = state.point ?? const LocalScenePoint(x: .5, y: .5);
show(
LocalScenePoint(
x: (current.x + dx).clamp(0.0, 1.0),
y: (current.y + dy).clamp(0.0, 1.0),
),
);
}
void clear() => state = TacticalPointerPreview();
}
@@ -1,356 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/observations/observed_event.dart';
import '../../../shared/widgets/severity_badge.dart';
import '../../../shared/widgets/surfaces.dart';
import '../../observability/presentation/observability_controller.dart';
enum _DiagnosticFilter { all, logs, errors, shutdowns }
class DiagnosticsPage extends ConsumerStatefulWidget {
const DiagnosticsPage({super.key});
@override
ConsumerState<DiagnosticsPage> createState() => _DiagnosticsPageState();
}
class _DiagnosticsPageState extends ConsumerState<DiagnosticsPage> {
_DiagnosticFilter _filter = _DiagnosticFilter.all;
String _query = '';
@override
Widget build(BuildContext context) {
final state = ref.watch(observabilityControllerProvider);
final diagnostics = state.events
.where(_isDiagnostic)
.toList(growable: false);
final events = diagnostics.where(_matches).toList(growable: false);
return CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(28, 28, 28, 16),
sliver: SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ConsoleLabel('Event stream'),
const SizedBox(height: 8),
Text(
'Event timeline',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Read-only decoded observations. Diagnostic delivery can be bounded and lossy.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Text(
'Retained diagnostics ${state.events.length} · local records not retained ${state.localTimelineEvictionCount}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
const SizedBox(height: 20),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final filter in _DiagnosticFilter.values)
ChoiceChip(
label: Text(_filterLabel(filter, diagnostics)),
selected: _filter == filter,
onSelected: (_) => setState(() => _filter = filter),
),
],
),
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Search message, error path, or detail',
prefixIcon: Icon(Icons.search),
),
onChanged: (value) {
setState(() => _query = value.trim().toLowerCase());
},
),
],
),
),
),
if (events.isEmpty)
const SliverPadding(
padding: EdgeInsets.fromLTRB(28, 0, 28, 28),
sliver: SliverToBoxAdapter(
child: InstrumentPanel(
child: Padding(
padding: EdgeInsets.all(20),
child: Text('No events match this view.'),
),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(28, 0, 28, 28),
sliver: SliverList.builder(
itemCount: events.length,
itemBuilder: (context, index) =>
_DiagnosticRow(event: events[index]),
),
),
],
);
}
bool _matches(ObservedEvent event) {
final typeMatches = switch (_filter) {
_DiagnosticFilter.all => true,
_DiagnosticFilter.logs => event is LogEvent,
_DiagnosticFilter.errors => event is ErrorEvent,
_DiagnosticFilter.shutdowns => event is ShutdownEvent,
};
return typeMatches &&
(_query.isEmpty ||
_searchableText(event).toLowerCase().contains(_query));
}
}
class _DiagnosticRow extends StatelessWidget {
const _DiagnosticRow({required this.event});
final ObservedEvent event;
@override
Widget build(BuildContext context) {
final content = _contentFor(
event,
Theme.of(context).brightness == Brightness.dark,
);
return Semantics(
container: true,
label: content.semanticLabel,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InstrumentPanel(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(width: 3, height: 56, color: content.tone),
const SizedBox(width: 12),
SizedBox(
width: 92,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ConsoleLabel(content.kind, color: content.tone),
const SizedBox(height: 6),
Text(
_timelineTimestamp(event.receivedAt),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
],
),
),
const SizedBox(width: 8),
content.icon,
const SizedBox(width: 10),
Expanded(
child: SelectionArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
content.title,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
content.detail,
style: content.monoDetail
? Theme.of(context).textTheme.bodyMedium
?.copyWith(fontFamily: 'monospace')
: null,
),
if (content.additionalDetail case final detail?) ...[
const SizedBox(height: 8),
Text(
detail,
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
),
),
],
),
),
),
),
);
}
}
class _DiagnosticContent {
const _DiagnosticContent({
required this.kind,
required this.tone,
required this.icon,
required this.title,
required this.detail,
required this.semanticLabel,
this.additionalDetail,
this.monoDetail = false,
});
final String kind;
final Color tone;
final Widget icon;
final String title;
final String detail;
final String? additionalDetail;
final String semanticLabel;
final bool monoDetail;
}
_DiagnosticContent _contentFor(
ObservedEvent event,
bool dark,
) => switch (event) {
LogEvent(:final level, :final message, :final flags) => _DiagnosticContent(
kind: 'Log',
tone: _toneForSeverity(level, dark),
icon: SeverityBadge(level: level),
title: level.label,
detail: message,
additionalDetail: _formatLogFlags(flags),
semanticLabel: _semanticLogLabel(level, message, flags),
),
ErrorEvent(:final path, :final additionalInformation, :final flags) =>
_DiagnosticContent(
kind: 'Error',
tone: dark ? const Color(0xffff6b63) : const Color(0xffd92d20),
icon: Icon(
Icons.error_outline,
color: dark ? const Color(0xffff6b63) : const Color(0xffd92d20),
),
title: 'Runtime error observed',
detail: path,
additionalDetail: _formatErrorDetail(additionalInformation, flags),
semanticLabel: _semanticErrorLabel(path, additionalInformation, flags),
monoDetail: true,
),
ShutdownEvent(:final reason) => _DiagnosticContent(
kind: 'Shutdown',
tone: dark ? const Color(0xfff7b955) : const Color(0xffb54708),
icon: Icon(
Icons.power_settings_new_outlined,
color: dark ? const Color(0xfff7b955) : const Color(0xffb54708),
),
title: 'Orderly shutdown report',
detail: 'Reason: ${reason.label}',
additionalDetail:
'Completed orderly, output-inhibited report; not a command, panic, hard-fault, or reset.',
semanticLabel: 'Orderly shutdown report: ${reason.label}',
),
_ => throw StateError('only diagnostic events reach the timeline'),
};
bool _isDiagnostic(ObservedEvent event) =>
event is LogEvent || event is ErrorEvent || event is ShutdownEvent;
String _filterLabel(_DiagnosticFilter filter, List<ObservedEvent> events) {
final count = switch (filter) {
_DiagnosticFilter.all => events.length,
_DiagnosticFilter.logs => events.whereType<LogEvent>().length,
_DiagnosticFilter.errors => events.whereType<ErrorEvent>().length,
_DiagnosticFilter.shutdowns => events.whereType<ShutdownEvent>().length,
};
final label = switch (filter) {
_DiagnosticFilter.all => 'All events',
_DiagnosticFilter.logs => 'Logs',
_DiagnosticFilter.errors => 'Errors',
_DiagnosticFilter.shutdowns => 'Shutdown reports',
};
return '$label $count';
}
String _searchableText(ObservedEvent event) => switch (event) {
LogEvent(:final level, :final message) => '${level.label} $message',
ErrorEvent(:final path, :final additionalInformation) =>
'$path $additionalInformation',
ShutdownEvent(:final reason) => reason.label,
_ => '',
};
Color _toneForSeverity(MavSeverity level, bool dark) =>
switch (level.wireValue) {
0 ||
1 ||
2 ||
3 => dark ? const Color(0xffff6b63) : const Color(0xffd92d20),
4 => dark ? const Color(0xfff7b955) : const Color(0xffb54708),
5 || 6 => dark ? const Color(0xff70b8ff) : const Color(0xff175cd3),
7 => dark ? const Color(0xffa8b5ac) : const Color(0xff546258),
_ => dark ? const Color(0xffa8b5ac) : const Color(0xff546258),
};
String? _formatLogFlags(int flags) {
final labels = <String>[
if (flags & LogEvent.traceFlag != 0) 'Trace',
if (flags & LogEvent.truncatedFlag != 0) 'Truncated',
if (flags & LogEvent.coalescedFlag != 0) 'Coalesced',
if (flags & ~LogEvent.knownFlags != 0)
'Unknown bits 0x${(flags & ~LogEvent.knownFlags).toRadixString(16)}',
];
return labels.isEmpty ? null : 'Flags: ${labels.join(' · ')}';
}
String? _formatErrorDetail(String additionalInformation, int flags) {
final flagDetail = _formatErrorFlags(flags);
final details = <String>[
if (additionalInformation.isNotEmpty) additionalInformation,
?flagDetail,
];
return details.isEmpty ? null : details.join('\n');
}
String? _formatErrorFlags(int flags) {
final labels = <String>[
if (flags & ErrorEvent.truncatedFlag != 0) 'Truncated',
if (flags & ErrorEvent.coalescedFlag != 0) 'Coalesced',
if (flags & ~ErrorEvent.knownFlags != 0)
'Unknown bits 0x${(flags & ~ErrorEvent.knownFlags).toRadixString(16)}',
];
return labels.isEmpty ? null : 'Flags: ${labels.join(' · ')}';
}
String _semanticLogLabel(MavSeverity level, String message, int flags) {
final flagDetail = _formatLogFlags(flags);
return flagDetail == null
? '${level.label} log: $message'
: '${level.label} log: $message. $flagDetail';
}
String _semanticErrorLabel(
String path,
String additionalInformation,
int flags,
) {
final detail = _formatErrorDetail(additionalInformation, flags);
return detail == null ? 'Error: $path' : 'Error: $path. $detail';
}
String _timelineTimestamp(DateTime value) =>
'${value.hour.toString().padLeft(2, '0')}:'
'${value.minute.toString().padLeft(2, '0')}:'
'${value.second.toString().padLeft(2, '0')}Z';
@@ -1,46 +0,0 @@
import '../../vehicle_workspace/domain/vehicle_definition.dart';
/// An operator-created fleet unit in the local planning workspace.
///
/// It deliberately contains no received identity, platform, authority, or
/// physical pose. Those must arrive through separate reviewed bindings later.
class FleetUnit {
const FleetUnit({
required this.localUnitId,
required this.displayName,
required this.definition,
required this.localPlacement,
});
final String localUnitId;
final String displayName;
final VehicleDefinition definition;
final LocalScenePoint localPlacement;
FleetUnit copyWith({String? displayName}) => FleetUnit(
localUnitId: localUnitId,
displayName: displayName ?? this.displayName,
definition: definition,
localPlacement: localPlacement,
);
}
/// A normalized placement in the operator's local tactical scene.
///
/// This is deliberately not a geographic position, vehicle pose, or received
/// location. A future map binding will keep reported coordinates separate.
class LocalScenePoint {
const LocalScenePoint({required this.x, required this.y})
: assert(x >= 0 && x <= 1),
assert(y >= 0 && y <= 1);
final double x;
final double y;
@override
bool operator ==(Object other) =>
other is LocalScenePoint && other.x == x && other.y == y;
@override
int get hashCode => Object.hash(x, y);
}
@@ -1,261 +0,0 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/observations/observed_event.dart';
import '../../../core/observations/vehicle_data_port.dart';
import '../../../core/observations/vehicle_data_update.dart';
import '../../../core/transport/replay_vehicle_data_port.dart';
final vehicleDataPortProvider = Provider<VehicleDataPort>((ref) {
final port = const ReplayVehicleDataPort();
ref.onDispose(() => unawaited(port.close()));
return port;
});
final observabilityControllerProvider =
NotifierProvider<ObservabilityController, ObservabilityState>(
ObservabilityController.new,
);
class ObservabilityController extends Notifier<ObservabilityState> {
static const _criticalAnnouncementWindow = Duration(seconds: 2);
static const _timelineCapacity = 500;
StreamSubscription<VehicleDataUpdate>? _subscription;
DateTime? _lastCriticalAnnouncementAt;
@override
ObservabilityState build() {
final source = ref.read(vehicleDataPortProvider);
ref.onDispose(_cancelSubscription);
Future<void>.microtask(() => _observe(source));
return ObservabilityState.initial(source);
}
void _observe(VehicleDataPort source) {
_subscription = source.observe().listen(
_handleUpdate,
onError: (Object _, StackTrace _) {
state = state.copyWith(
status: ObservationStatus.disconnected,
accessibilityAnnouncement: 'Observation connection closed.',
replaceAccessibilityAnnouncement: true,
);
},
onDone: () {
state = state.copyWith(
status: source.isReplay
? ObservationStatus.replayComplete
: ObservationStatus.disconnected,
accessibilityAnnouncement: source.isReplay
? 'Replay complete.'
: 'Observation connection closed.',
replaceAccessibilityAnnouncement: true,
);
},
);
}
void _handleUpdate(VehicleDataUpdate update) {
switch (update) {
case ObservationReceived(:final event):
_record(event);
case ObservationDropped(:final kind, :final count):
state = state.copyWith(
status: ObservationStatus.receiving,
parserDropCount:
state.parserDropCount +
(kind == ObservationDropKind.parser ? count : 0),
semanticDropCount:
state.semanticDropCount +
(kind == ObservationDropKind.semantic ? count : 0),
);
}
}
void _record(ObservedEvent event) {
final announcement = _announcementFor(event);
final retention = _isTimelineEvent(event)
? _retainTimelineEvent(state.events, event)
: _TimelineRetention(state.events);
final heartbeat = event is HeartbeatEvent ? event : state.lastHeartbeat;
final observedStreams = switch (event) {
TelemetryEvent(:final kind) => {...state.observedTelemetryKinds, kind},
_ => state.observedTelemetryKinds,
};
state = state.copyWith(
status: ObservationStatus.receiving,
lastFrameAt: event.receivedAt,
events: retention.events,
lastHeartbeat: heartbeat,
observedTelemetryKinds: Set.unmodifiable(observedStreams),
localTimelineEvictionCount:
state.localTimelineEvictionCount + retention.evictionCount,
accessibilityAnnouncement: announcement.message,
replaceAccessibilityAnnouncement: true,
coalescedAlertCount:
state.coalescedAlertCount + (announcement.coalesced ? 1 : 0),
);
}
_AnnouncementDecision _announcementFor(ObservedEvent event) {
final candidate = switch (event) {
ErrorEvent(:final path) => 'Error received: $path',
ShutdownEvent(:final reason) =>
'Orderly shutdown observed: ${reason.label}',
_ => null,
};
if (candidate == null) {
return const _AnnouncementDecision();
}
final now = DateTime.now();
final previous = _lastCriticalAnnouncementAt;
if (previous != null &&
now.difference(previous) < _criticalAnnouncementWindow) {
return const _AnnouncementDecision(coalesced: true);
}
_lastCriticalAnnouncementAt = now;
return _AnnouncementDecision(message: candidate);
}
void _cancelSubscription() {
_subscription?.cancel();
}
_TimelineRetention _retainTimelineEvent(
List<ObservedEvent> current,
ObservedEvent incoming,
) {
if (current.length < _timelineCapacity) {
return _TimelineRetention([...current, incoming]);
}
final retained = List<ObservedEvent>.of(current);
final optionalIndex = retained.indexWhere((event) => !_isCritical(event));
if (optionalIndex >= 0) {
retained.removeAt(optionalIndex);
retained.add(incoming);
return _TimelineRetention(retained, evictionCount: 1);
}
if (_isCritical(incoming)) {
retained.removeAt(0);
retained.add(incoming);
return _TimelineRetention(retained, evictionCount: 1);
}
return _TimelineRetention(retained, evictionCount: 1);
}
}
bool _isCritical(ObservedEvent event) =>
event is ErrorEvent || event is ShutdownEvent;
bool _isTimelineEvent(ObservedEvent event) =>
event is LogEvent || _isCritical(event);
class _TimelineRetention {
_TimelineRetention(Iterable<ObservedEvent> events, {this.evictionCount = 0})
: events = List.unmodifiable(events);
final List<ObservedEvent> events;
final int evictionCount;
}
enum ObservationStatus {
starting('Starting'),
receiving('Observing'),
replayComplete('Replay complete'),
disconnected('Disconnected');
const ObservationStatus(this.label);
final String label;
}
class ObservabilityState {
const ObservabilityState({
required this.sourceLabel,
required this.isReplay,
required this.status,
required this.events,
required this.lastHeartbeat,
required this.observedTelemetryKinds,
required this.parserDropCount,
required this.semanticDropCount,
required this.coalescedAlertCount,
required this.localTimelineEvictionCount,
this.lastFrameAt,
this.accessibilityAnnouncement,
});
factory ObservabilityState.initial(VehicleDataPort source) {
return ObservabilityState(
sourceLabel: source.label,
isReplay: source.isReplay,
status: ObservationStatus.starting,
events: const [],
lastHeartbeat: null,
observedTelemetryKinds: const {},
parserDropCount: 0,
semanticDropCount: 0,
coalescedAlertCount: 0,
localTimelineEvictionCount: 0,
);
}
final String sourceLabel;
final bool isReplay;
final ObservationStatus status;
final List<ObservedEvent> events;
final HeartbeatEvent? lastHeartbeat;
final Set<TelemetryKind> observedTelemetryKinds;
final int parserDropCount;
final int semanticDropCount;
final int coalescedAlertCount;
final int localTimelineEvictionCount;
final DateTime? lastFrameAt;
final String? accessibilityAnnouncement;
ObservabilityState copyWith({
ObservationStatus? status,
List<ObservedEvent>? events,
HeartbeatEvent? lastHeartbeat,
Set<TelemetryKind>? observedTelemetryKinds,
int? parserDropCount,
int? semanticDropCount,
int? coalescedAlertCount,
int? localTimelineEvictionCount,
DateTime? lastFrameAt,
String? accessibilityAnnouncement,
bool replaceAccessibilityAnnouncement = false,
}) {
return ObservabilityState(
sourceLabel: sourceLabel,
isReplay: isReplay,
status: status ?? this.status,
events: events ?? this.events,
lastHeartbeat: lastHeartbeat ?? this.lastHeartbeat,
observedTelemetryKinds:
observedTelemetryKinds ?? this.observedTelemetryKinds,
parserDropCount: parserDropCount ?? this.parserDropCount,
semanticDropCount: semanticDropCount ?? this.semanticDropCount,
coalescedAlertCount: coalescedAlertCount ?? this.coalescedAlertCount,
localTimelineEvictionCount:
localTimelineEvictionCount ?? this.localTimelineEvictionCount,
lastFrameAt: lastFrameAt ?? this.lastFrameAt,
accessibilityAnnouncement: replaceAccessibilityAnnouncement
? accessibilityAnnouncement
: this.accessibilityAnnouncement,
);
}
}
class _AnnouncementDecision {
const _AnnouncementDecision({this.message, this.coalesced = false});
final String? message;
final bool coalesced;
}
@@ -1,66 +0,0 @@
import 'vehicle_definition.dart';
/// Revision of the local reviewed definition catalog.
///
/// TODO(gcs-vehicle-catalog): generate this catalog from reviewed firmware
/// build manifests, then persist both this revision and a definition ID in a
/// versioned local vehicle workspace record.
const vehicleDefinitionCatalogRevision = 1;
/// Local vehicle types available in the tactical scene catalog.
const supportedVehicleDefinitions = <VehicleDefinition>[
VehicleDefinition(
id: 'kaptan.vehicle.quadcopter.local',
name: 'Quadcopter',
vehicleClass: 'Multirotor',
supportedProfile: 'Local scene unit',
media: VehicleCatalogMedia(
imageAssetPath: 'assets/vehicle_catalog/photos/quadcopter.png',
),
),
VehicleDefinition(
id: 'kaptan.vehicle.fixed-wing.local',
name: 'Fixed-wing',
vehicleClass: 'Fixed-wing',
supportedProfile: 'Local scene unit',
media: VehicleCatalogMedia(
imageAssetPath: 'assets/vehicle_catalog/photos/fixed_wing.png',
),
),
VehicleDefinition(
id: 'kaptan.vehicle.tiltrotor-vtol.local',
name: 'Tiltrotor VTOL',
vehicleClass: 'VTOL',
supportedProfile: 'Local scene unit',
media: VehicleCatalogMedia(
imageAssetPath: 'assets/vehicle_catalog/photos/tiltrotor_vtol.png',
),
),
VehicleDefinition(
id: 'kaptan.vehicle.helicopter.local',
name: 'Helicopter',
vehicleClass: 'Rotorcraft',
supportedProfile: 'Local scene unit',
media: VehicleCatalogMedia(
imageAssetPath: 'assets/vehicle_catalog/photos/helicopter.png',
),
),
VehicleDefinition(
id: 'kaptan.vehicle.rover.local',
name: 'Ground rover',
vehicleClass: 'Ground vehicle',
supportedProfile: 'Local scene unit',
media: VehicleCatalogMedia(
imageAssetPath: 'assets/vehicle_catalog/photos/rover.png',
),
),
VehicleDefinition(
id: 'kaptan.vehicle.surface-vessel.local',
name: 'Surface vessel',
vehicleClass: 'Marine vehicle',
supportedProfile: 'Local scene unit',
media: VehicleCatalogMedia(
imageAssetPath: 'assets/vehicle_catalog/photos/surface_vessel.png',
),
),
];
@@ -1,27 +0,0 @@
/// Bundled profile image for a local vehicle definition.
class VehicleCatalogMedia {
const VehicleCatalogMedia({required this.imageAssetPath});
final String imageAssetPath;
}
/// A reviewed vehicle definition that can own a Ground Suite workspace.
///
/// It is deliberately separate from platform identity, compatibility, and
/// write authority reported by a future live vehicle source.
class VehicleDefinition {
const VehicleDefinition({
required this.id,
required this.name,
required this.vehicleClass,
required this.supportedProfile,
required this.media,
});
/// Stable local identity for persistence and future build matching.
final String id;
final String name;
final String vehicleClass;
final String supportedProfile;
final VehicleCatalogMedia media;
}
@@ -1,127 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../fleet/domain/fleet_unit.dart';
import '../domain/vehicle_definition.dart';
final vehicleWorkspaceProvider =
NotifierProvider<VehicleWorkspaceController, VehicleWorkspaceState>(
VehicleWorkspaceController.new,
);
/// Local fleet workspace state.
///
/// TODO(gcs-fleet-storage): persist local-unit IDs and reviewed definition IDs
/// in a versioned workspace store.
class VehicleWorkspaceState {
const VehicleWorkspaceState({required this.units});
const VehicleWorkspaceState.unselected() : units = const [];
final List<FleetUnit> units;
bool get hasUnits => units.isNotEmpty;
VehicleWorkspaceState copyWith({List<FleetUnit>? units}) =>
VehicleWorkspaceState(units: units ?? this.units);
}
class VehicleWorkspaceController extends Notifier<VehicleWorkspaceState> {
static const maximumLocalUnits = 64;
static const _minimumSeparationSquared = .0016;
@override
VehicleWorkspaceState build() => const VehicleWorkspaceState.unselected();
/// Validation shared by the hangar draft and committed workspace updates.
/// It applies only to the operator-local display label, never a vehicle ID.
static bool isValidLocalDisplayName(String value) =>
_normalizeDisplayName(value) != null;
bool canPlace(LocalScenePoint localPlacement) =>
state.units.length < maximumLocalUnits &&
state.units.every((unit) {
final dx = unit.localPlacement.x - localPlacement.x;
final dy = unit.localPlacement.y - localPlacement.y;
return dx * dx + dy * dy >= _minimumSeparationSquared;
});
bool addUnit({
required VehicleDefinition definition,
required LocalScenePoint localPlacement,
String? displayName,
}) {
if (!canPlace(localPlacement)) {
return false;
}
final sequence = state.units.length + 1;
final unit = FleetUnit(
localUnitId: 'local-unit-$sequence',
displayName:
_normalizeDisplayName(displayName) ??
'${definition.name.toUpperCase()}-${sequence.toString().padLeft(2, '0')}',
definition: definition,
localPlacement: localPlacement,
);
state = VehicleWorkspaceState(units: [...state.units, unit]);
return true;
}
/// Creates a local unit at a deterministic, scattered scene position.
///
/// Until a reviewed MAVLink position binding exists, this is presentation
/// placement only: it is neither a GPS position nor received vehicle data.
bool addUnitAutomatically({
required VehicleDefinition definition,
String? displayName,
}) {
for (var attempt = 0; attempt < maximumLocalUnits; attempt++) {
final sequence = state.units.length + attempt + 1;
final placement = LocalScenePoint(
x: .14 + _fraction(sequence * .61803398875) * .72,
y: .16 + _fraction(sequence * .41421356237) * .66,
);
if (addUnit(
definition: definition,
localPlacement: placement,
displayName: displayName,
)) {
return true;
}
}
return false;
}
static double _fraction(double value) => value - value.floorToDouble();
bool renameUnit(String localUnitId, String displayName) {
final normalized = _normalizeDisplayName(displayName);
if (normalized == null) {
return false;
}
var changed = false;
final units = state.units
.map((unit) {
if (unit.localUnitId != localUnitId) {
return unit;
}
changed = true;
return unit.copyWith(displayName: normalized);
})
.toList(growable: false);
if (!changed) {
return false;
}
state = state.copyWith(units: units);
return true;
}
static String? _normalizeDisplayName(String? value) {
final normalized = value?.trim();
if (normalized == null ||
normalized.isEmpty ||
normalized.runes.length > 32) {
return null;
}
return normalized;
}
}
@@ -1,139 +0,0 @@
import 'package:flutter/material.dart';
import '../../../../shared/widgets/surfaces.dart';
import '../../domain/vehicle_definition.dart';
/// A compact, keyboard-selectable workspace definition tile.
///
/// Only a reviewed [VehicleDefinition] reaches this widget. The radio control
/// changes local pending workspace state; it does not connect, configure, or
/// identify a vehicle.
class VehicleCatalogCard extends StatefulWidget {
const VehicleCatalogCard({
super.key,
required this.definition,
required this.selected,
required this.onSelected,
});
final VehicleDefinition definition;
final bool selected;
final VoidCallback onSelected;
@override
State<VehicleCatalogCard> createState() => _VehicleCatalogCardState();
}
class _VehicleCatalogCardState extends State<VehicleCatalogCard> {
bool _showFocus = false;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final accent = widget.selected || _showFocus ? scheme.primary : null;
return Semantics(
button: true,
checked: widget.selected,
inMutuallyExclusiveGroup: true,
label:
'${widget.definition.name}, ${widget.definition.vehicleClass}, '
'${widget.definition.supportedProfile}, local unit type, '
'local vehicle image, not telemetry',
onTap: widget.onSelected,
child: DecoratedBox(
decoration: BoxDecoration(
border: _showFocus
? Border.all(color: scheme.primary, width: 2)
: null,
borderRadius: BorderRadius.circular(14),
),
child: Material(
color: Colors.transparent,
child: InkWell(
excludeFromSemantics: true,
onFocusChange: (value) => setState(() => _showFocus = value),
onTap: widget.onSelected,
borderRadius: BorderRadius.circular(12),
child: InstrumentPanel(
accent: accent,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_VehicleImage(media: widget.definition.media),
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ConsoleLabel('Local unit type'),
const SizedBox(height: 6),
Text(
widget.definition.name,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 2),
Text(
'${widget.definition.vehicleClass} · ${widget.definition.supportedProfile}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 10),
Row(
children: [
Icon(
widget.selected
? Icons.radio_button_checked
: Icons.radio_button_off,
size: 18,
color: widget.selected
? scheme.primary
: scheme.onSurfaceVariant,
),
const SizedBox(width: 7),
Expanded(
child: Text(
widget.selected
? 'Selected for workspace'
: 'Select for workspace',
style: Theme.of(context).textTheme.labelLarge,
),
),
],
),
],
),
),
],
),
),
),
),
),
);
}
}
class _VehicleImage extends StatelessWidget {
const _VehicleImage({required this.media});
final VehicleCatalogMedia media;
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(11)),
child: AspectRatio(
aspectRatio: 16 / 10,
child: Image.asset(
media.imageAssetPath,
key: Key('vehicle-image-${media.imageAssetPath}'),
fit: BoxFit.cover,
filterQuality: FilterQuality.high,
excludeFromSemantics: true,
errorBuilder: (context, error, stackTrace) => ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Center(child: Icon(Icons.image_not_supported_outlined)),
),
),
),
);
}
-7
View File
@@ -1,7 +0,0 @@
import 'package:flutter/widgets.dart';
import 'app/app.dart';
void main() {
runApp(const GroundSuiteApp());
}
@@ -1,41 +0,0 @@
import 'package:flutter/material.dart';
import '../../core/observations/observed_event.dart';
class SeverityBadge extends StatelessWidget {
const SeverityBadge({required this.level, super.key});
final MavSeverity level;
@override
Widget build(BuildContext context) {
final dark = Theme.of(context).brightness == Brightness.dark;
final (icon, color) = switch (level.wireValue) {
0 || 1 || 2 || 3 => (
Icons.error_outline,
dark ? const Color(0xffff6b63) : const Color(0xffd92d20),
),
4 => (
Icons.warning_amber_outlined,
dark ? const Color(0xfff7b955) : const Color(0xffb54708),
),
5 || 6 => (
Icons.info_outline,
dark ? const Color(0xff70b8ff) : const Color(0xff175cd3),
),
7 => (
Icons.bug_report_outlined,
dark ? const Color(0xffa8b5ac) : const Color(0xff546258),
),
_ => (
Icons.help_outline,
dark ? const Color(0xffa8b5ac) : const Color(0xff546258),
),
};
return Semantics(
label: 'Severity: ${level.label}',
child: Icon(icon, color: color),
);
}
}
@@ -1,216 +0,0 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// A blurred surface for stable application chrome only.
///
/// Keep this out of scrollable content. Diagnostic rows and metric panels use
/// [InstrumentPanel], which has the same visual language without creating a
/// compositing layer per item.
class FrostedChrome extends StatelessWidget {
const FrostedChrome({
required this.child,
super.key,
this.borderRadius = BorderRadius.zero,
});
final Widget child;
final BorderRadius borderRadius;
@override
Widget build(BuildContext context) {
final highContrast = MediaQuery.maybeOf(context)?.highContrast ?? false;
final surface = _SurfaceFrame(
borderRadius: borderRadius,
chrome: true,
opaque: highContrast,
child: child,
);
if (highContrast) {
return surface;
}
return ClipRRect(
borderRadius: borderRadius,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: surface,
),
);
}
}
/// An opaque, high-legibility panel for operational data and event rows.
class InstrumentPanel extends StatelessWidget {
const InstrumentPanel({
required this.child,
super.key,
this.borderRadius = const BorderRadius.all(Radius.circular(12)),
this.accent,
});
final Widget child;
final BorderRadius borderRadius;
final Color? accent;
@override
Widget build(BuildContext context) => _SurfaceFrame(
borderRadius: borderRadius,
accent: accent,
opaque: true,
child: child,
);
}
class _SurfaceFrame extends StatelessWidget {
const _SurfaceFrame({
required this.child,
required this.borderRadius,
this.chrome = false,
this.opaque = false,
this.accent,
});
final Widget child;
final BorderRadius borderRadius;
final bool chrome;
final bool opaque;
final Color? accent;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final dark = scheme.brightness == Brightness.dark;
final opacity = opaque ? 0xf6 : (dark ? 0xbf : 0xd9);
final top = dark
? Color.fromARGB(
opacity,
chrome ? 12 : 16,
chrome ? 17 : 23,
chrome ? 14 : 18,
)
: Color.fromARGB(opacity, 255, 255, 255);
final bottom = dark
? Color.fromARGB(opacity, 8, 11, 9)
: Color.fromARGB(opacity, 244, 248, 244);
final border =
accent?.withValues(alpha: 0.74) ??
scheme.outline.withValues(alpha: dark ? 0.92 : 0.82);
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [top, bottom],
),
border: Border.all(color: border),
borderRadius: borderRadius,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: dark ? 0.18 : 0.06),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: child,
);
}
}
/// The static canvas behind Ground Suite's chrome and instrument surfaces.
class AppCanvas extends StatelessWidget {
const AppCanvas({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context) {
final dark = Theme.of(context).brightness == Brightness.dark;
final base = dark ? const Color(0xff080b09) : const Color(0xfff3f6f3);
final primaryHaze = (dark ? accentMint : const Color(0xff087a3b))
.withValues(alpha: dark ? 0.075 : 0.05);
return ColoredBox(
color: base,
child: Stack(
children: [
Positioned(
top: -250,
right: -200,
child: IgnorePointer(child: _Haze(color: primaryHaze)),
),
Positioned(
bottom: -330,
left: -260,
child: IgnorePointer(
child: _Haze(color: primaryHaze.withValues(alpha: 0.55)),
),
),
Positioned.fill(child: child),
],
),
);
}
}
class _Haze extends StatelessWidget {
const _Haze({required this.color});
final Color color;
@override
Widget build(BuildContext context) => Container(
width: 600,
height: 600,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(colors: [color, Colors.transparent]),
),
);
}
class ConsoleLabel extends StatelessWidget {
const ConsoleLabel(this.text, {super.key, this.color});
final String text;
final Color? color;
@override
Widget build(BuildContext context) => Text(
text.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color ?? Theme.of(context).colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
fontWeight: FontWeight.w700,
letterSpacing: 1.25,
),
);
}
class StatusBadge extends StatelessWidget {
const StatusBadge({required this.label, super.key, this.color});
final String label;
final Color? color;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final tone = color ?? scheme.onSurfaceVariant;
return DecoratedBox(
decoration: BoxDecoration(
color: tone.withValues(alpha: 0.11),
border: Border.all(color: tone.withValues(alpha: 0.45)),
borderRadius: BorderRadius.circular(7),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
child: ConsoleLabel(label, color: tone),
),
);
}
}
-1
View File
@@ -1 +0,0 @@
flutter/ephemeral
-128
View File
@@ -1,128 +0,0 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "ground_suite")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "dev.kaptan.ground_suite")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(SET CMP0063 NEW)
# Load bundled libraries from the lib/ directory relative to the binary.
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
# Root filesystem for cross-building.
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
endif()
# Define build configuration options.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
# Compilation settings that should be applied to most targets.
#
# Be cautious about adding new options here, as plugins use this function by
# default. In most cases, you should add new options to specific targets instead
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_14)
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction()
# Flutter library and tool build rules.
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
# Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
# Only the install-generated bundle's copy of the executable will launch
# correctly, since the resources must in the right relative locations. To avoid
# people trying to run the unbundled copy, put it in a subdirectory instead of
# the default top-level location.
set_target_properties(${BINARY_NAME}
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
)
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# By default, "installing" just makes a relocatable bundle in the build
# directory.
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
# Start with a clean build bundle directory every time.
install(CODE "
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
" COMPONENT Runtime)
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
install(FILES "${bundled_library}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endforeach(bundled_library)
# Copy the native assets provided by the build.dart from all packages.
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()
-88
View File
@@ -1,88 +0,0 @@
# This file controls Flutter-level build steps. It should not be edited.
cmake_minimum_required(VERSION 3.10)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
# which isn't available in 3.10.
function(list_prepend LIST_NAME PREFIX)
set(NEW_LIST "")
foreach(element ${${LIST_NAME}})
list(APPEND NEW_LIST "${PREFIX}${element}")
endforeach(element)
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
endfunction()
# === Flutter Library ===
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"fl_basic_message_channel.h"
"fl_binary_codec.h"
"fl_binary_messenger.h"
"fl_dart_project.h"
"fl_engine.h"
"fl_json_message_codec.h"
"fl_json_method_codec.h"
"fl_message_codec.h"
"fl_method_call.h"
"fl_method_channel.h"
"fl_method_codec.h"
"fl_method_response.h"
"fl_plugin_registrar.h"
"fl_plugin_registry.h"
"fl_standard_message_codec.h"
"fl_standard_method_codec.h"
"fl_string_codec.h"
"fl_value.h"
"fl_view.h"
"flutter_linux.h"
)
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
target_link_libraries(flutter INTERFACE
PkgConfig::GTK
PkgConfig::GLIB
PkgConfig::GIO
)
add_dependencies(flutter flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CMAKE_CURRENT_BINARY_DIR}/_phony_
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
)
@@ -1,11 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
void fl_register_plugins(FlPluginRegistry* registry) {
}
@@ -1,15 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_
@@ -1,24 +0,0 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)
-26
View File
@@ -1,26 +0,0 @@
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# Define the application target. To change its name, change BINARY_NAME in the
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
# work.
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
)
# Apply the standard set of build settings. This can be removed for applications
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# Add preprocessor definitions for the application ID.
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
# Add dependency libraries. Add any application-specific dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
-6
View File
@@ -1,6 +0,0 @@
#include "my_application.h"
int main(int argc, char** argv) {
g_autoptr(MyApplication) app = my_application_new();
return g_application_run(G_APPLICATION(app), argc, argv);
}
-148
View File
@@ -1,148 +0,0 @@
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif
#include "flutter/generated_plugin_registrant.h"
struct _MyApplication {
GtkApplication parent_instance;
char** dart_entrypoint_arguments;
};
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
// Called when first Flutter frame received.
static void first_frame_cb(MyApplication* self, FlView* view) {
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
}
// Implements GApplication::activate.
static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application);
GtkWindow* window =
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
// Use a header bar when running in GNOME as this is the common style used
// by applications and is the setup most users will be using (e.g. Ubuntu
// desktop).
// If running on X and not using GNOME then just use a traditional title bar
// in case the window manager does more exotic layout, e.g. tiling.
// If running on Wayland assume the header bar will work (may need changing
// if future cases occur).
gboolean use_header_bar = TRUE;
#ifdef GDK_WINDOWING_X11
GdkScreen* screen = gtk_window_get_screen(window);
if (GDK_IS_X11_SCREEN(screen)) {
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
use_header_bar = FALSE;
}
}
#endif
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "Ground Suite");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "Ground Suite");
}
gtk_window_set_default_size(window, 1280, 720);
g_autoptr(FlDartProject) project = fl_dart_project_new();
fl_dart_project_set_dart_entrypoint_arguments(
project, self->dart_entrypoint_arguments);
FlView* view = fl_view_new(project);
GdkRGBA background_color;
// Background defaults to black, override it here if necessary, e.g. #00000000
// for transparent.
gdk_rgba_parse(&background_color, "#000000");
fl_view_set_background_color(view, &background_color);
gtk_widget_show(GTK_WIDGET(view));
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
// Show the window when Flutter renders.
// Requires the view to be realized so we can start rendering.
g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
self);
gtk_widget_realize(GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
gtk_widget_grab_focus(GTK_WIDGET(view));
}
// Implements GApplication::local_command_line.
static gboolean my_application_local_command_line(GApplication* application,
gchar*** arguments,
int* exit_status) {
MyApplication* self = MY_APPLICATION(application);
// Strip out the first argument as it is the binary name.
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
g_autoptr(GError) error = nullptr;
if (!g_application_register(application, nullptr, &error)) {
g_warning("Failed to register: %s", error->message);
*exit_status = 1;
return TRUE;
}
g_application_activate(application);
*exit_status = 0;
return TRUE;
}
// Implements GApplication::startup.
static void my_application_startup(GApplication* application) {
// MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application startup.
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
}
// Implements GApplication::shutdown.
static void my_application_shutdown(GApplication* application) {
// MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application shutdown.
G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
}
// Implements GObject::dispose.
static void my_application_dispose(GObject* object) {
MyApplication* self = MY_APPLICATION(object);
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
}
static void my_application_class_init(MyApplicationClass* klass) {
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
G_APPLICATION_CLASS(klass)->local_command_line =
my_application_local_command_line;
G_APPLICATION_CLASS(klass)->startup = my_application_startup;
G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
}
static void my_application_init(MyApplication* self) {}
MyApplication* my_application_new() {
// Set the program name to the application ID, which helps various systems
// like GTK and desktop environments map this running application to its
// corresponding .desktop file. This ensures better integration by allowing
// the application to be recognized beyond its binary name.
g_set_prgname(APPLICATION_ID);
return MY_APPLICATION(g_object_new(my_application_get_type(),
"application-id", APPLICATION_ID, "flags",
G_APPLICATION_NON_UNIQUE, nullptr));
}
@@ -1,21 +0,0 @@
#ifndef FLUTTER_MY_APPLICATION_H_
#define FLUTTER_MY_APPLICATION_H_
#include <gtk/gtk.h>
G_DECLARE_FINAL_TYPE(MyApplication,
my_application,
MY,
APPLICATION,
GtkApplication)
/**
* my_application_new:
*
* Creates a new Flutter-based application.
*
* Returns: a new #MyApplication.
*/
MyApplication* my_application_new();
#endif // FLUTTER_MY_APPLICATION_H_
+2524
View File
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@kaptan/ground-suite",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "npm run test:unit && npm run test:e2e",
"test:unit": "vitest run",
"test:e2e": "playwright test",
"test:watch": "vitest",
"check": "tsc -b && vitest run && vite build"
},
"dependencies": {
"@vitejs/plugin-react": "6.0.5",
"vite": "8.2.1",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.4",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"jsdom": "30.0.1",
"typescript": "7.0.2",
"vitest": "4.1.10"
}
}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from '@playwright/test';
const environment = globalThis as typeof globalThis & { process?: { env: Record<string, string | undefined> } };
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
workers: 1,
retries: 0,
reporter: 'line',
use: {
baseURL: 'http://127.0.0.1:4174',
viewport: { width: 1366, height: 768 },
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
launchOptions: {
executablePath: environment.process?.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
args: ['--no-sandbox', '--disable-gpu'],
},
},
webServer: {
command: 'npm run build && KAPTAN_GROUND_SUITE_PORT=4174 node scripts/server.mjs',
url: 'http://127.0.0.1:4174',
reuseExistingServer: false,
timeout: 30_000,
},
});
File renamed without changes.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

-677
View File
@@ -1,677 +0,0 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c
url: "https://pub.dev"
source: hosted
version: "99.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
url: "https://pub.dev"
source: hosted
version: "12.1.0"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.2.1"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
coverage:
dependency: transitive
description:
name: coverage
sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d"
url: "https://pub.dev"
source: hosted
version: "1.15.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "56e81e662e6e54e59b231da57e90ac0d06ac67d8e3f2273c129a37ae6282b40c"
url: "https://pub.dev"
source: hosted
version: "3.4.2"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
url: "https://pub.dev"
source: hosted
version: "6.3.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
jni:
dependency: transitive
description:
name: jni
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
url: "https://pub.dev"
source: hosted
version: "1.0.3"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
jni_util:
dependency: transitive
description:
name: jni_util
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
listen:
dependency: transitive
description:
name: listen
sha256: "47501a08016a43fcad79252439d723f50f14f88fa7bfd8a177e0a417e5c9e1f2"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
url: "https://pub.dev"
source: hosted
version: "9.5.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "84351b3472a447f7b89f961f95c73287009d58c59d8cc1267425d995f78e50f1"
url: "https://pub.dev"
source: hosted
version: "3.4.2"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
url: "https://pub.dev"
source: hosted
version: "0.10.13"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
dependency: transitive
description:
name: test
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
url: "https://pub.dev"
source: hosted
version: "1.31.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
test_core:
dependency: transitive
description:
name: test_core
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.dev"
source: hosted
version: "0.6.17"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.38.4"
-88
View File
@@ -1,88 +0,0 @@
name: ground_suite
description: "Kaptan's desktop ground-control workspace."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.1.0+1
environment:
sdk: ^3.12.2
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^3.4.2
google_fonts: ^6.3.2
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
assets:
- assets/test.png
- assets/vehicle_catalog/
- assets/vehicle_catalog/photos/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
+20
View File
@@ -0,0 +1,20 @@
import { createReadStream, existsSync, statSync } from 'node:fs';
import { createServer } from 'node:http';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(fileURLToPath(new URL('..', import.meta.url)), 'dist');
const host = process.env.KAPTAN_GROUND_SUITE_HOST ?? '127.0.0.1';
const port = Number(process.env.KAPTAN_GROUND_SUITE_PORT ?? '4173');
const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml' };
createServer((request, response) => {
const requested = normalize(decodeURIComponent((request.url ?? '/').split('?')[0])).replace(/^(\.\.[/\\])+/, '');
let path = join(root, requested === '/' ? 'index.html' : requested);
if (!path.startsWith(root) || !existsSync(path) || statSync(path).isDirectory()) path = join(root, 'index.html');
response.writeHead(200, { 'Content-Type': mime[extname(path)] ?? 'application/octet-stream', 'Cache-Control': 'no-store' });
createReadStream(path).pipe(response);
}).listen(port, host, () => {
console.log(`Kaptan Ground Suite: http://${host}:${port}`);
console.log('SIMULATED DATA ONLY — no real vehicle connection or command authority.');
});
@@ -0,0 +1,4 @@
export interface AutonomyCapabilities { formationPlanning: boolean; replacementEvaluation: boolean; supportedIntentKinds: string[]; protocolRevision?: string }
export interface DesiredOutcome { id: string; kind: string; groupId: string; constraints: string[]; successCriteria: string[] }
export interface AutonomyResult { outcomeId: string; accepted: boolean; explanation: string; proposedIntentIds: string[]; errors: Array<{ code: string; message: string; retryable: boolean }> }
export interface AutonomyCoordinatorPort { discover(): Promise<AutonomyCapabilities>; evaluate(outcome: DesiredOutcome): Promise<AutonomyResult>; close(): Promise<void> }
@@ -0,0 +1,6 @@
import { describe, expect, it } from 'vitest';
import { FakeCerebrumAdapter } from './FakeCerebrumAdapter';
describe('AutonomyCoordinatorPort contract', () => {
it('discovers explicit simulated capabilities', async () => { const adapter = new FakeCerebrumAdapter(); expect((await adapter.discover()).replacementEvaluation).toBe(true); });
it('rejects outcomes outside discovered capability', async () => { const result = await new FakeCerebrumAdapter().evaluate({ id: 'x', kind: 'invent-protocol', groupId: 'group:alpha', constraints: [], successCriteria: [] }); expect(result.accepted).toBe(false); expect(result.errors[0].code).toBe('UNSUPPORTED_OUTCOME'); });
});
@@ -0,0 +1,6 @@
import type { AutonomyCapabilities, AutonomyCoordinatorPort, AutonomyResult, DesiredOutcome } from './CerebrumPort';
export class FakeCerebrumAdapter implements AutonomyCoordinatorPort {
async discover(): Promise<AutonomyCapabilities> { return { formationPlanning: true, replacementEvaluation: true, supportedIntentKinds: ['maintain-coverage', 'form', 'route', 'hold'], protocolRevision: 'simulation-contract-v1' }; }
async evaluate(outcome: DesiredOutcome): Promise<AutonomyResult> { const accepted = ['maintain-coverage', 'form', 'route', 'hold'].includes(outcome.kind); return { outcomeId: outcome.id, accepted, explanation: accepted ? 'Deterministic simulation produced an auditable candidate plan' : 'Outcome kind is outside discovered simulation capabilities', proposedIntentIds: accepted ? [`proposed:${outcome.id}`] : [], errors: accepted ? [] : [{ code: 'UNSUPPORTED_OUTCOME', message: `Unsupported outcome: ${outcome.kind}`, retryable: false }] }; }
async close(): Promise<void> {}
}
@@ -0,0 +1,66 @@
import { VirtualClock } from '../../core/clock/VirtualClock';
import type { CommandEnvelope, CommandReceipt } from '../../core/data-plane/CommandEnvelope';
import type { DataPlaneSink, DataSource } from '../../core/data-plane/DataSource';
import type { EventEnvelope } from '../../core/data-plane/EventEnvelope';
import type { ActivityEvent, MissionState } from '../../domain/model';
import { createScenario } from './scenario';
export class FakeRealtimeSource implements DataSource {
readonly descriptor = { id: 'source:fake-alpha17', name: 'ALPHA-17 deterministic simulation', kind: 'simulation' as const, simulated: true, capabilities: ['snapshot', 'telemetry', 'commands', 'replay', 'authority', 'action-queue'] };
private sink?: DataPlaneSink;
private sequence = 0;
private timer?: ReturnType<typeof setInterval>;
private state: MissionState;
private readonly clock = new VirtualClock();
constructor(private readonly seed = 17, private readonly automatic = true) { this.state = createScenario(seed); }
private emit(type: EventEnvelope['type'], entity: EventEnvelope['entity'], payload: unknown, quality: EventEnvelope['quality'] = { stale: false, confidence: 1 }): void {
const now = this.clock.now();
this.sink?.accept({ schemaVersion: 1, eventId: `event:${++this.sequence}`, streamId: this.descriptor.id, sequence: this.sequence, type, entity, sourceTimestamp: now, receivedTimestamp: now, quality, payload });
}
async connect(sink: DataPlaneSink): Promise<void> {
this.sink = sink;
this.state.source = { ...this.state.source, state: 'connected', message: 'Simulation connected', lastEventAt: this.clock.now() };
this.emit('snapshot', { kind: 'mission', id: this.state.mission.id }, structuredClone(this.state));
if (this.automatic) this.timer = setInterval(() => this.step(100), 100);
}
async disconnect(): Promise<void> { if (this.timer) clearInterval(this.timer); this.timer = undefined; this.sink = undefined; }
private step = (milliseconds = 100): void => {
const before = this.clock.state.time;
const now = this.clock.advance(milliseconds);
if (now === before) return;
const tick = this.state.simulation.tick + 1;
this.state.simulation = { paused: this.clock.state.paused, speed: this.clock.state.speed, tick };
const updates = Object.values(this.state.vehicles).map((vehicle, index) => {
const phase = tick / 18 + index * 0.31;
const packetLost = (tick + index * 7) % 97 === 0;
return { id: vehicle.id, position: { ...vehicle.position, x: Math.max(3, Math.min(96, vehicle.position.x + Math.cos(phase) * 0.035)), y: Math.max(4, Math.min(94, vehicle.position.y + Math.sin(phase) * 0.028)) }, heading: (vehicle.heading + 0.7) % 360, speed: vehicle.speed + Math.sin(phase) * 0.4, altitude: Math.max(0, vehicle.altitude + Math.sin(phase * 0.6) * 0.15), energy: Math.max(8, vehicle.energy - 0.0008 * this.clock.state.speed), link: packetLost ? vehicle.link : Math.max(20, Math.min(100, vehicle.link + Math.sin(phase) * 0.04)), freshnessMs: packetLost ? vehicle.freshnessMs + milliseconds : 0 };
});
this.emit('telemetry', { kind: 'mission', id: this.state.mission.id }, { updates, elapsedSeconds: this.state.mission.elapsedSeconds + milliseconds / 1000, simulation: this.state.simulation });
if (tick === 35) this.triggerDegradation();
if (tick === 65) this.emit('source-status', { kind: 'source', id: this.descriptor.id }, { state: 'degraded', message: 'Deterministic packet-loss window; telemetry may be stale' }, { stale: true, confidence: 0.72, droppedSinceLast: 3 });
if (tick === 78) this.emit('source-status', { kind: 'source', id: this.descriptor.id }, { state: 'connected', message: 'Simulation recovered; event ordering verified' });
};
private triggerDegradation(): void {
const vehicle = Object.values(this.state.vehicles).find(item => item.groupId === 'group:alpha' && item.name === 'UAV-03');
if (!vehicle) return;
const alert = { id: 'alert:alpha-coverage', severity: 'critical' as const, title: 'ALPHA coverage below requirement', detail: `${vehicle.name} navigation unavailable · 11 minute impact`, entityId: vehicle.id, acknowledged: false, resolved: false, target: { kind: 'vehicle' as const, entityId: vehicle.id, zoom: 3 as const, inspector: 'vehicle' as const } };
this.emit('alert', { kind: 'alert', id: alert.id }, alert);
}
readonly replay = {
pause: (): void => { this.clock.pause(); this.emit('source-status', { kind: 'source', id: this.descriptor.id }, { state: 'connected', message: 'Simulation paused', simulation: { paused: true, speed: this.clock.state.speed } }); },
resume: (): void => { this.clock.resume(); this.emit('source-status', { kind: 'source', id: this.descriptor.id }, { state: 'connected', message: 'Simulation running', simulation: { paused: false, speed: this.clock.state.speed } }); },
restart: (): void => { this.clock.restart(); this.state = createScenario(this.seed); this.sequence = 0; this.emit('snapshot', { kind: 'mission', id: this.state.mission.id }, structuredClone(this.state)); },
setSpeed: (speed: number): void => { this.clock.setSpeed(speed); this.state.simulation.speed = this.clock.state.speed; this.emit('source-status', { kind: 'source', id: this.descriptor.id }, { state: 'connected', message: `Simulation at ${this.clock.state.speed}×`, simulation: this.state.simulation }); },
step: (milliseconds = 100): void => this.step(milliseconds),
};
async send(command: CommandEnvelope): Promise<CommandReceipt> {
const now = this.clock.now();
const connected = this.state.source.state !== 'disconnected';
const actorHasAuthority = Object.values(this.state.leases).some(lease => lease.holderId === command.actorId && command.targetIds.some(id => id === lease.entityId || this.state.vehicles[id]?.groupId === lease.entityId));
const accepted = connected && (command.kind === 'request-authority' || command.kind === 'transfer-authority' || command.kind === 'acknowledge-alert' || actorHasAuthority);
const receipt: CommandReceipt = accepted ? { commandId: command.id, accepted: true, state: 'acknowledged', acknowledgedAt: now } : { commandId: command.id, accepted: false, state: 'rejected', reason: connected ? 'Current operator does not hold authority for every target' : 'Simulation source is disconnected', remediation: connected ? 'Request or transfer the command lease, then preview again' : 'Reconnect the source and refresh telemetry', acknowledgedAt: now };
const activity: ActivityEvent = { id: `activity:${this.sequence + 1}`, timestamp: now, actor: 'Alex', category: 'command', severity: accepted ? 'info' : 'attention', message: accepted ? `${command.kind} acknowledged by simulated source` : `${command.kind} rejected: ${receipt.reason}`, entityId: command.targetIds[0], correlationId: command.id, acknowledged: accepted, target: { kind: this.state.groups[command.targetIds[0]] ? 'group' : 'vehicle', entityId: command.targetIds[0], zoom: this.state.groups[command.targetIds[0]] ? 2 : 3, inspector: 'event' } };
this.emit('command-state', { kind: 'mission', id: this.state.mission.id }, { command, receipt, activity });
return receipt;
}
}
@@ -0,0 +1,73 @@
import type { ActivityEvent, Archetype, Group, MissionState, Operator, Task, Vehicle } from '../../domain/model';
import { seededRandom } from './seededRandom';
const groupSpecs = [
['group:alpha', 'ALPHA', 12, 'task:alpha', 'operator:alex', 82, 'nominal', 'Area Search', 'Return', 'wedge'],
['group:bravo', 'BRAVO', 10, 'task:bravo', 'operator:sara', 71, 'nominal', 'Route Recon', 'Survey pass', 'column'],
['group:charlie', 'CHARLIE', 8, 'task:charlie', 'operator:david', 64, 'nominal', 'Communications Relay', 'Hold station', 'orbit'],
['group:delta', 'DELTA', 12, 'task:delta', 'operator:maria', 35, 'attention', 'Reserve / Support', 'Awaiting allocation', 'staging'],
['group:reserve', 'RESERVE', 8, undefined, 'operator:ken', 100, 'nominal', 'Available', 'Available', 'staging'],
] as const;
const archetypes: Archetype[] = ['quadcopter', 'fixed-wing', 'tiltrotor-vtol', 'helicopter', 'rover', 'surface-vessel'];
export function createScenario(seed = 17): MissionState {
const random = seededRandom(seed);
const tasks: Record<string, Task> = {
'task:alpha': { id: 'task:alpha', name: 'TASK-143 · Alpha Search', kind: 'Area Search', progress: 82, required: 12, assignedGroupId: 'group:alpha', state: 'active', etaMinutes: 18 },
'task:bravo': { id: 'task:bravo', name: 'TASK-144 · Coastal Route', kind: 'Route Recon', progress: 71, required: 10, assignedGroupId: 'group:bravo', state: 'active', etaMinutes: 24 },
'task:charlie': { id: 'task:charlie', name: 'TASK-145 · Relay Mesh', kind: 'Communications Relay', progress: 64, required: 8, assignedGroupId: 'group:charlie', state: 'active', etaMinutes: 31 },
'task:delta': { id: 'task:delta', name: 'TASK-146 · Support Readiness', kind: 'Reserve / Support', progress: 35, required: 8, assignedGroupId: 'group:delta', state: 'active', etaMinutes: 45 },
};
const groups: Record<string, Group> = Object.fromEntries(groupSpecs.map(([id, name, count, taskId, operatorId, progress, health, currentIntent, nextIntent, formation]) => [id, {
id, name, count, taskId, operatorId, progress, health, currentIntent, nextIntent, formation, attentionCount: name === 'DELTA' ? 1 : 0,
}]));
const operatorsArray: Operator[] = [
{ id: 'operator:alex', name: 'Alex', role: 'Mission operator · ALPHA', presence: 'online', color: '#45c7ef', cursor: { x: 36, y: 64, lat: 41.08, lon: 29.06 }, selectedEntityId: 'group:alpha' },
{ id: 'operator:sara', name: 'Sara', role: 'Group operator · BRAVO', presence: 'online', color: '#70d56c', cursor: { x: 47, y: 25, lat: 41.13, lon: 29.08 }, selectedEntityId: 'group:bravo' },
{ id: 'operator:david', name: 'David', role: 'Mission planner', presence: 'online', color: '#ed9a55', cursor: { x: 72, y: 18, lat: 41.15, lon: 29.13 } },
{ id: 'operator:maria', name: 'Maria', role: 'Safety observer', presence: 'online', color: '#a77ae4', cursor: { x: 46, y: 76, lat: 41.04, lon: 29.08 } },
{ id: 'operator:ken', name: 'Ken', role: 'Engineering observer', presence: 'online', color: '#ead365', cursor: { x: 58, y: 80, lat: 41.03, lon: 29.10 } },
];
const operators = Object.fromEntries(operatorsArray.map(operator => [operator.id, operator]));
const vehicles: Record<string, Vehicle> = {};
let ordinal = 1;
for (const [groupId, groupName, count, taskId] of groupSpecs) {
for (let index = 0; index < count; index += 1) {
const id = `vehicle:${String(ordinal).padStart(3, '0')}`;
const alpha = groupName === 'ALPHA';
const archetype = groupName === 'CHARLIE' ? (index % 2 ? 'tiltrotor-vtol' : 'quadcopter') : archetypes[(ordinal - 1) % archetypes.length];
const centerX = 25 + groupSpecs.findIndex(spec => spec[0] === groupId) * 12;
const centerY = 27 + groupSpecs.findIndex(spec => spec[0] === groupId) * 11;
vehicles[id] = {
id, name: alpha ? `UAV-${String(index + 1).padStart(2, '0')}` : `${groupName.slice(0, 3)}-${String(index + 1).padStart(2, '0')}`,
groupId, archetype, capabilities: archetype === 'rover' || archetype === 'surface-vessel' ? ['support', 'mapping'] : ['ISR', archetype === 'tiltrotor-vtol' ? 'relay' : 'imaging'],
payload: archetype === 'surface-vessel' ? 'Sonar (simulated)' : 'EO/IR (simulated)',
position: { x: centerX + (index % 4) * 3 + random() * 2, y: centerY + Math.floor(index / 4) * 4 + random() * 2, lat: 41.02 + random() * 0.14, lon: 28.98 + random() * 0.19 },
heading: Math.round(random() * 359), speed: Math.round(8 + random() * 22), altitude: archetype === 'rover' || archetype === 'surface-vessel' ? 0 : Math.round(90 + random() * 230),
energy: Math.round(61 + random() * 34), link: Math.round(68 + random() * 31), health: groupName === 'DELTA' && index < 2 ? 'attention' : 'nominal', freshnessMs: 0, available: groupName === 'RESERVE', binding: 'simulated-bound', taskId,
};
ordinal += 1;
}
}
const events: ActivityEvent[] = [
{ id: 'activity:1', timestamp: Date.UTC(2026, 7, 17, 11, 30, 2), actor: 'System', category: 'source', severity: 'info', message: 'Deterministic scenario ALPHA-17 connected', entityId: 'source:fake', acknowledged: true, target: { kind: 'source', entityId: 'source:fake', zoom: 1, inspector: 'event' } },
{ id: 'activity:2', timestamp: Date.UTC(2026, 7, 17, 11, 31, 8), actor: 'Sara', category: 'operator', severity: 'info', message: 'Updated BRAVO coastal route', entityId: 'group:bravo', acknowledged: true, target: { kind: 'group', entityId: 'group:bravo', zoom: 2, inspector: 'summary' } },
{ id: 'activity:3', timestamp: Date.UTC(2026, 7, 17, 11, 32, 11), actor: 'System', category: 'alert', severity: 'attention', message: 'BRAVO approaching constraint R-210B', entityId: 'group:bravo', acknowledged: false, target: { kind: 'constraint', entityId: 'constraint:r210b', position: { x: 73, y: 47, lat: 41.08, lon: 29.15 }, zoom: 2, inspector: 'event' } },
];
return {
mission: { id: 'mission:alpha17', name: 'Coastal Survey', code: 'ALPHA-17', revision: 184, elapsedSeconds: 6137 }, tasks, groups, vehicles, operators,
leases: Object.fromEntries(groupSpecs.map(([id, , , , operatorId]) => [`lease:${id}`, { id: `lease:${id}`, entityId: id, holderId: operatorId, state: 'held', expiresAt: Date.UTC(2026, 7, 17, 11, 35, 0), observers: operatorsArray.filter(operator => operator.id !== operatorId).slice(0, 2).map(operator => operator.id) }])),
queue: [
{ id: 'intent:1', title: 'Transit', detail: 'to Northern Sector', ownerId: 'operator:alex', groupId: 'group:alpha', taskId: 'task:alpha', durationMinutes: 8, state: 'completed', dependencies: [], resources: ['group:alpha'], validation: 'valid' },
{ id: 'intent:2', title: 'Search Area A', detail: 'coverage pattern', ownerId: 'operator:alex', groupId: 'group:alpha', taskId: 'task:alpha', durationMinutes: 18, state: 'executing', dependencies: ['intent:1'], resources: ['group:alpha'], validation: 'valid' },
{ id: 'intent:3', title: 'Loiter', detail: '6 minute observation', ownerId: 'operator:alex', groupId: 'group:alpha', durationMinutes: 6, state: 'queued', dependencies: ['intent:2'], resources: ['group:alpha'], validation: 'valid' },
{ id: 'intent:4', title: 'Return', detail: 'staging point', ownerId: 'operator:alex', groupId: 'group:alpha', durationMinutes: 12, state: 'queued', dependencies: ['intent:3'], resources: ['group:alpha'], validation: 'valid' },
{ id: 'intent:5', title: 'Recharge', detail: 'base service', ownerId: 'operator:alex', groupId: 'group:alpha', durationMinutes: 35, state: 'queued', dependencies: ['intent:4'], resources: ['group:alpha'], validation: 'valid' },
],
alerts: [
{ id: 'alert:bravo', severity: 'attention', title: 'BRAVO approaching constraint', detail: 'R-210B coastal limit · ETA 04:12', entityId: 'group:bravo', acknowledged: false, resolved: false, target: { kind: 'constraint', entityId: 'constraint:r210b', position: { x: 73, y: 47, lat: 41.08, lon: 29.15 }, zoom: 2, inspector: 'event' } },
{ id: 'alert:alpha-eta', severity: 'attention', title: 'ALPHA task behind schedule', detail: 'ETA impact +00:08:45', entityId: 'task:alpha', acknowledged: false, resolved: false, target: { kind: 'task', entityId: 'task:alpha', position: { x: 39, y: 38, lat: 41.09, lon: 29.05 }, zoom: 2, inspector: 'task' } },
{ id: 'alert:delta-readiness', severity: 'attention', title: 'DELTA readiness below plan', detail: '2 support vehicles require review', entityId: 'group:delta', acknowledged: false, resolved: false, target: { kind: 'group', entityId: 'group:delta', zoom: 2, inspector: 'summary' } },
], events, source: { state: 'connecting', message: 'Connecting deterministic simulation', invalidEvents: 0, duplicates: 0, outOfOrder: 0 }, simulated: true, simulation: { paused: false, speed: 1, tick: 0 },
};
}
@@ -0,0 +1,7 @@
export function seededRandom(seed = 17): () => number {
let value = seed >>> 0;
return () => {
value = (value * 1664525 + 1013904223) >>> 0;
return value / 0x100000000;
};
}
@@ -0,0 +1,8 @@
import type { CommandEnvelope, CommandReceipt } from '../../core/data-plane/CommandEnvelope';
import type { DataPlaneSink, DataSource } from '../../core/data-plane/DataSource';
export class KaptanDataSource implements DataSource {
readonly descriptor = { id: 'source:kaptan-unconfigured', name: 'Kaptan adapter boundary (not connected)', kind: 'kaptan' as const, simulated: false, capabilities: [] };
async connect(_sink: DataPlaneSink): Promise<void> { throw new Error('No reviewed Kaptan live-data API is configured. Use the simulation source.'); }
async disconnect(): Promise<void> {}
async send(command: CommandEnvelope): Promise<CommandReceipt> { return { commandId: command.id, accepted: false, state: 'rejected', reason: 'Kaptan command transport is intentionally unavailable', remediation: 'Define and review authentication, binding, signing, replay protection, capability negotiation, and command acknowledgement contracts', acknowledgedAt: Date.now() }; }
}
+54
View File
@@ -0,0 +1,54 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { DataPlane } from '../core/data-plane/DataPlane';
import { FakeRealtimeSource } from '../adapters/fake/FakeRealtimeSource';
import { initialInteraction, interactionReducer } from '../interaction/controller';
import { App } from './App';
const renderApp = () => { const plane = new DataPlane(new FakeRealtimeSource(17, false), callback => callback()); return { plane, ...render(<App plane={plane} />) }; };
describe('persistent spatial operations scene', () => {
it('keeps one map mounted while tools open and close', async () => {
renderApp(); const map = await screen.findByTestId('operational-map');
await userEvent.click(screen.getByRole('button', { name: 'Settings' }));
expect(screen.getByTestId('operational-map')).toBe(map);
expect(screen.getByText('SIMULATION · NO LIVE MUTATIONS')).toBeVisible();
await userEvent.click(screen.getByRole('button', { name: 'Close settings' }));
expect(screen.getByTestId('operational-map')).toBe(map);
});
it('separates selection from authority and exposes group distributions', async () => {
renderApp(); expect(await screen.findByText('12 VEHICLES')).toBeVisible();
expect(screen.getByText('AUTHORITY HELD')).toBeVisible();
await userEvent.click(screen.getAllByRole('button', { name: /BRAVO/ })[0]);
expect(await screen.findByText('10 VEHICLES')).toBeVisible();
expect(screen.getByText('SELECTION ≠ AUTHORITY')).toBeVisible();
expect(screen.getByText('ENERGY DISTRIBUTION')).toBeVisible();
});
it('does not intercept tactical hotkeys in controls', async () => {
renderApp(); await userEvent.click(screen.getByRole('button', { name: 'Settings' }));
const density = screen.getByLabelText('Density'); await userEvent.click(density);
fireEvent.keyDown(density, { key: '1', ctrlKey: true });
await waitFor(() => expect(density).toHaveFocus());
});
});
describe('typed interaction controller', () => {
it('builds a route review without mutating observed truth', () => {
let state = interactionReducer(initialInteraction, { type: 'mode', mode: 'route' });
state = interactionReducer(state, { type: 'point', point: { x: 20, y: 20 }, targetIds: ['vehicle:001'] });
state = interactionReducer(state, { type: 'point', point: { x: 35, y: 27 }, targetIds: ['vehicle:001'] });
state = interactionReducer(state, { type: 'complete' });
expect(state.draft?.phase).toBe('review'); expect(state.draft?.targetIds).toEqual(['vehicle:001']); expect(state.artifacts).toHaveLength(0);
});
it('supports artifact completion, undo, and redo', () => {
let state = interactionReducer(initialInteraction, { type: 'mode', mode: 'polygon' });
for (const point of [{ x: 1, y: 1 }, { x: 5, y: 1 }, { x: 5, y: 5 }]) state = interactionReducer(state, { type: 'point', point, targetIds: [] });
state = interactionReducer(state, { type: 'complete' }); expect(state.artifacts).toHaveLength(1);
state = interactionReducer(state, { type: 'undo' }); expect(state.artifacts).toHaveLength(0);
state = interactionReducer(state, { type: 'redo' }); expect(state.artifacts).toHaveLength(1);
});
});
+175
View File
@@ -0,0 +1,175 @@
import { useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from 'react';
import type { DataPlane } from '../core/data-plane/DataPlane';
import type { CommandEnvelope } from '../core/data-plane/CommandEnvelope';
import { LAYERS, PreferenceStore, type LayerId, type Preferences } from '../core/persistence/preferences';
import type { Archetype, Vehicle } from '../domain/model';
import { fleetSummary } from '../projections/selectors';
import { MODES, draftDistanceKm, initialInteraction, interactionReducer, type DraftGeometry, type DraftPoint, type InteractionMode } from '../interaction/controller';
import { Icon } from './Icon';
import { OperationalMap, type Camera } from './OperationalMap';
type Tool = 'queue' | 'events' | 'deploy' | 'configuration' | 'recovery' | 'settings' | 'layers' | 'telemetry' | null;
type DeploymentMode = 'simulate' | 'discover' | 'import';
const actorId = 'operator:alex';
const layerLabels: Record<LayerId, string> = { searchAreas: 'Search areas', currentRoutes: 'Observed routes', plannedRoutes: 'Planned intent', constraints: 'Constraints', noFlyZones: 'Keep-out geometry', communicationCoverage: 'Communication coverage', pointsOfInterest: 'Points of interest', mapLabels: 'Map labels', operatorPresence: 'Operator presence', eventLocations: 'Event locations' };
const toolButtons: Array<{ tool: Exclude<Tool, null | 'telemetry'>; icon: Parameters<typeof Icon>[0]['name']; key?: string }> = [
{ tool: 'deploy', icon: 'deploy', key: 'V' }, { tool: 'queue', icon: 'queue', key: 'Q' }, { tool: 'events', icon: 'events' }, { tool: 'configuration', icon: 'configure' }, { tool: 'layers', icon: 'layers', key: 'L' }, { tool: 'settings', icon: 'settings' },
];
const primaryModes: Array<{ mode: InteractionMode; icon: Parameters<typeof Icon>[0]['name']; key?: string }> = [
{ mode: 'select', icon: 'pointer', key: 'S' }, { mode: 'pan', icon: 'hand' }, { mode: 'inspect', icon: 'inspect' }, { mode: 'route', icon: 'route', key: 'R' }, { mode: 'formation', icon: 'formation', key: 'F' }, { mode: 'polygon', icon: 'tools' }, { mode: 'measure-distance', icon: 'measure' },
];
const formatElapsed = (seconds: number) => [Math.floor(seconds / 3600), Math.floor(seconds / 60) % 60, Math.floor(seconds) % 60].map(value => String(value).padStart(2, '0')).join(':');
const exact = (ids: string[]) => [...new Set(ids)].sort();
const commandId = () => `command:${Date.now()}:${crypto.randomUUID?.() ?? Math.random().toString(16).slice(2)}`;
const makeCommand = (kind: CommandEnvelope['kind'], targetIds: string[], payload: Record<string, unknown>): CommandEnvelope => ({ id: commandId(), kind, actorId, targetIds: exact(targetIds), payload, createdAt: Date.now() });
export function App({ plane }: { plane: DataPlane }) {
const state = useSyncExternalStore(plane.subscribe, plane.getSnapshot, plane.getSnapshot);
const preferenceStore = useMemo(() => new PreferenceStore(), []);
const [preferences, setPreferences] = useState<Preferences>(() => preferenceStore.load());
const [selection, setSelection] = useState<string[]>([]);
const [focus, setFocus] = useState(0);
const [activeControlGroup, setActiveControlGroup] = useState<number | null>(1);
const [controlGroups, setControlGroups] = useState<Record<number, string[]>>({});
const [tool, setTool] = useState<Tool>(null);
const [camera, setCamera] = useState<Camera>(() => ({ x: preferences.viewport.x, y: preferences.viewport.y, zoom: preferences.viewport.scale }));
const [interaction, dispatchInteraction] = useReducer(interactionReducer, initialInteraction);
const [contextPoint, setContextPoint] = useState<DraftPoint | null>(null);
const [toast, setToast] = useState('');
const [deploymentMode, setDeploymentMode] = useState<DeploymentMode>('simulate');
const [deployCount, setDeployCount] = useState(50);
const [deployArchetype, setDeployArchetype] = useState<Archetype>('quadcopter');
const [staged, setStaged] = useState(0);
const [configSection, setConfigSection] = useState<'readiness' | 'sensors' | 'radio' | 'parameters'>('readiness');
const [timelineOpen, setTimelineOpen] = useState(false);
const [modePaletteOpen, setModePaletteOpen] = useState(false);
const [now, setNow] = useState(Date.now());
const lastGroupTap = useRef<{ number: number; at: number }>({ number: -1, at: 0 });
useEffect(() => { void plane.connect(); return () => { void plane.disconnect(); }; }, [plane]);
useEffect(() => { if (!selection.length && Object.keys(state.vehicles).length) { const alpha = Object.values(state.vehicles).filter(vehicle => vehicle.groupId === 'group:alpha').map(vehicle => vehicle.id); setSelection(alpha); setControlGroups({ 1: alpha }); } }, [selection.length, state.vehicles]);
useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, []);
useEffect(() => { preferenceStore.save({ ...preferences, viewport: { x: camera.x, y: camera.y, scale: camera.zoom }, lastWorkspace: tool ?? 'mission' }); }, [camera, preferenceStore, preferences, tool]);
useEffect(() => { if (!toast) return; const timer = setTimeout(() => setToast(''), 3800); return () => clearTimeout(timer); }, [toast]);
useEffect(() => {
const keyboard = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || (event.target as HTMLElement | null)?.isContentEditable) return;
const key = event.key.toLowerCase();
if ((event.ctrlKey || event.metaKey) && key === 'z') { event.preventDefault(); dispatchInteraction({ type: 'undo' }); return; }
if ((event.ctrlKey || event.metaKey) && key === 'y') { event.preventDefault(); dispatchInteraction({ type: 'redo' }); return; }
if (/^[0-9]$/.test(key)) { event.preventDefault(); const group = Number(key); if (event.ctrlKey || event.metaKey) { setControlGroups(value => ({ ...value, [group]: exact(selection) })); setActiveControlGroup(group); setToast(`Control group ${group} assigned · ${selection.length} local selections`); return; } const ids = controlGroups[group] ?? []; setSelection(current => event.shiftKey ? exact([...current, ...ids]) : ids); setActiveControlGroup(group); const at = Date.now(); if (lastGroupTap.current.number === group && at - lastGroupTap.current.at < 430) setCamera(frameVehicles(ids, state.vehicles)); lastGroupTap.current = { number: group, at }; return; }
if (key === 'escape') { if (contextPoint) setContextPoint(null); else if (tool) setTool(null); else dispatchInteraction({ type: 'cancel' }); return; }
if (key === 'enter') { dispatchInteraction({ type: 'complete' }); return; }
if (key === 'tab' && selection.length > 1) { event.preventDefault(); setFocus(value => (value + (event.shiftKey ? -1 : 1) + selection.length) % selection.length); return; }
if (key === 'v') { setTool('deploy'); return; } if (key === 'q') { setTool('queue'); setTimelineOpen(true); return; } if (key === 'l') { setTool('layers'); return; }
if (key === 's') dispatchInteraction({ type: 'mode', mode: 'select' }); if (key === 'r') dispatchInteraction({ type: 'mode', mode: 'route' }); if (key === 'm') dispatchInteraction({ type: 'mode', mode: 'marker' });
};
window.addEventListener('keydown', keyboard); return () => window.removeEventListener('keydown', keyboard);
}, [contextPoint, controlGroups, selection, state.vehicles, tool]);
const selectedVehicles = selection.map(id => state.vehicles[id]).filter((value): value is Vehicle => Boolean(value));
const focusedVehicle = selectedVehicles[focus % Math.max(selectedVehicles.length, 1)];
const summary = fleetSummary(state);
const authorities = new Set(selectedVehicles.map(vehicle => state.leases[`lease:${vehicle.groupId}`]?.holderId));
const hasAuthority = authorities.size === 1 && authorities.has(actorId);
function selectVehicles(ids: string[], additive = false) { setSelection(current => additive ? exact([...current.filter(id => !ids.includes(id)), ...ids.filter(id => !current.includes(id))]) : exact(ids)); setFocus(0); }
function chooseMode(mode: InteractionMode) { dispatchInteraction({ type: 'mode', mode }); setContextPoint(null); setModePaletteOpen(false); }
function openTool(next: Tool) { setTool(current => current === next ? null : next); if (next === 'events' || next === 'queue') setTimelineOpen(true); }
async function dispatchDraft() {
if (!interaction.draft) return;
const cmd = makeCommand('move', interaction.draft.targetIds, { geometry: interaction.draft.points, correlationId: commandId(), formation: interaction.draft.mode === 'formation' ? 'wedge' : undefined });
const preview = plane.preview(cmd);
if (!preview.allowed) { setToast(`Dispatch blocked · ${preview.checks.filter(item => item.status === 'fail').map(item => item.label).join(', ')}`); return; }
const receipt = await plane.submit(cmd); setToast(receipt.accepted ? `Accepted by simulated adapter · ${cmd.targetIds.length} exact targets` : `${receipt.reason} · no mission truth changed`); dispatchInteraction({ type: 'discard-draft' });
}
function updatePreferences(next: (value: Preferences) => Preferences) { setPreferences(value => next(value)); }
return <div className={`app ops-shell density-${preferences.density} ${preferences.reducedMotion ? 'reduced-motion' : ''}`}>
<a className="skip-link" href="#operational-scene">Skip to operational scene</a>
<header className="status-strip">
<div className="mission-id"><i>K</i><span><b>{state.mission.code}</b><small>{state.mission.name} · rev {state.mission.revision}</small></span></div>
<Status label="SOURCE" value={state.source.state === 'connected' ? 'ONLINE' : state.source.state.toUpperCase()} tone={state.source.state === 'connected' ? 'nominal' : 'attention'} icon="signal"/>
<Status label="MODE" value={state.simulation.paused ? 'SIM PAUSED' : 'LIVE SIM'} tone="simulation"/>
<Status label="AUTHORITY" value={hasAuthority ? 'ALEX · HELD' : `${authorities.size > 1 ? 'MIXED' : 'OBSERVER'}`} tone={hasAuthority ? 'nominal' : 'attention'}/>
<Status label="FLEET" value={`${summary.healthy}/${summary.total} READY`} tone="nominal"/>
<button className="alert-status" aria-label="Recovery" onClick={() => openTool('recovery')}><Icon name="alert"/><span><small>ATTENTION</small><b>{state.alerts.filter(alert => !alert.resolved).length} ACTIVE</b></span></button>
<div className="clock"><small>MISSION / LOCAL</small><b>{formatElapsed(state.mission.elapsedSeconds)} <i/> {new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(now)}</b></div>
<span className="sim-watermark">SIMULATION · NO LIVE MUTATIONS</span>
</header>
<main id="operational-scene" className="spatial-scene">
<nav className="tool-rail" aria-label="Map tools">
<div className="mode-tools">{primaryModes.map(item => { const label = item.mode === 'polygon' ? 'Map creation tools' : MODES[item.mode].label; return <button key={item.mode} className={interaction.mode === item.mode || item.mode === 'polygon' && modePaletteOpen ? 'active' : ''} aria-label={label} data-tooltip={label} onClick={() => item.mode === 'polygon' ? setModePaletteOpen(value => !value) : chooseMode(item.mode)}><Icon name={item.icon}/>{item.key && <kbd>{item.key}</kbd>}</button>; })}</div>
<div className="app-tools">{toolButtons.map(item => <button key={item.tool} className={tool === item.tool ? 'active' : ''} aria-label={title(item.tool)} data-tooltip={title(item.tool)} onClick={() => openTool(item.tool)}><Icon name={item.icon}/>{item.key && <kbd>{item.key}</kbd>}</button>)}</div>
</nav>
<aside className="roster-drawer" aria-label="Fleet roster and control groups">
<header><span><small>MISSION FORCES</small><b>{summary.total} VEHICLES</b></span><span className="local-group-chip"><small>LOCAL</small><b>{activeControlGroup === null ? '—' : `CTRL ${activeControlGroup}`}</b></span></header>
<section className="force-roster">{Object.values(state.groups).map((group, index) => { const number = index + 1; const ids = controlGroups[number] ?? Object.values(state.vehicles).filter(vehicle => vehicle.groupId === group.id).map(vehicle => vehicle.id); const selected = ids.some(id => selection.includes(id)); const operator = state.operators[group.operatorId]; return <button key={group.id} aria-label={`${group.name} ${group.count} vehicles`} className={`force-row ${selected ? 'selected' : ''} health-${group.health}`} onClick={event => { selectVehicles(ids, event.shiftKey); setActiveControlGroup(number); }} onDoubleClick={() => setCamera(frameVehicles(ids, state.vehicles))}><kbd>{number}</kbd><span className="force-identity"><i>{group.name[0]}</i><span><b>{group.name}</b><small>{group.currentIntent}</small></span></span><span className="force-state"><b>{group.count}</b><small>{operator?.name ?? 'Unassigned'} · {group.progress}%</small></span><span className="force-progress"><i style={{ width: `${group.progress}%` }}/></span></button>; })}</section>
<section className="smart-filters"><header><b>QUICK SELECT</b><small>LOCAL ONLY</small></header><div><button onClick={() => selectVehicles(Object.values(state.vehicles).filter(vehicle => vehicle.energy < 75).map(vehicle => vehicle.id))}><Icon name="battery"/><span>Low reserve</span><i>{Object.values(state.vehicles).filter(vehicle => vehicle.energy < 75).length}</i></button><button onClick={() => selectVehicles(Object.values(state.vehicles).filter(vehicle => vehicle.health !== 'nominal').map(vehicle => vehicle.id))}><Icon name="alert"/><span>Exceptions</span><i>{Object.values(state.vehicles).filter(vehicle => vehicle.health !== 'nominal').length}</i></button></div><button className="deploy-entry" onClick={() => setTool('deploy')}><Icon name="deploy"/><span><b>Deploy vehicles</b><small>Simulate · discover · import</small></span><kbd>V</kbd></button></section>
<footer><span><kbd>Ctrl+1–0</kbd> assign</span><span><kbd>Shift+1–0</kbd> add</span><span><b>2×</b> frame</span></footer>
</aside>
<div className="map-stage">
<OperationalMap state={state} selection={selection} interaction={interaction} dispatch={dispatchInteraction} camera={camera} setCamera={setCamera} selectVehicles={selectVehicles} onContext={point => setContextPoint(point)}/>
{modePaletteOpen && <ModePalette active={interaction.mode} choose={chooseMode} close={() => setModePaletteOpen(false)}/>}
{contextPoint && <div className="context-palette"><header><small>CONTEXT · LOCAL DRAFT</small><button aria-label="Close context palette" onClick={() => setContextPoint(null)}><Icon name="close"/></button></header><button onClick={() => { chooseMode('move-intent'); dispatchInteraction({ type: 'point', point: contextPoint, targetIds: selection }); setContextPoint(null); }}><Icon name="route"/><span><b>Move intent</b><small>Review before dispatch</small></span></button><button onClick={() => { chooseMode('marker'); dispatchInteraction({ type: 'point', point: contextPoint, targetIds: selection }); setContextPoint(null); }}><Icon name="marker"/><span><b>Place marker</b><small>Team annotation</small></span></button><button onClick={() => { chooseMode('measure-distance'); setContextPoint(null); }}><Icon name="measure"/><span><b>Measure from here</b><small>No command effect</small></span></button></div>}
{tool && ['layers', 'settings'].includes(tool) && <CompactDrawer tool={tool as 'layers' | 'settings'} close={() => setTool(null)} preferences={preferences} updatePreferences={updatePreferences}/>}
{tool && ['configuration', 'recovery'].includes(tool) && <RightWorkbench tool={tool as 'configuration' | 'recovery'} close={() => setTool(null)} vehicle={focusedVehicle} state={state} section={configSection} setSection={setConfigSection}/>}
</div>
<SelectionInspector vehicles={selectedVehicles} focused={focusedVehicle} state={state} hasAuthority={hasAuthority} onFocus={id => setFocus(Math.max(0, selection.indexOf(id)))} onConfigure={() => setTool('configuration')}/>
<section className={`selection-deck ${tool === 'deploy' ? 'deployment-active' : ''}`}>
{tool === 'deploy' ? <DeploymentDeck mode={deploymentMode} setMode={setDeploymentMode} archetype={deployArchetype} setArchetype={setDeployArchetype} count={deployCount} setCount={setDeployCount} staged={staged} onStage={() => { setStaged(value => value + deployCount); setToast(`${deployCount} provisional ${deployArchetype} vehicles staged · not mission-ready`); }}/>: <>
<div className="unit-card-strip" aria-label="Selected unit cards">{selectedVehicles.length ? selectedVehicles.slice(0, 12).map((vehicle, index) => <button key={vehicle.id} className={`unit-card ${focusedVehicle?.id === vehicle.id ? 'focused' : ''} ${vehicle.health}`} onClick={() => setFocus(index)}><Icon name="aircraft"/><span><b>{vehicle.name}</b><small>{vehicle.archetype.replace('-', ' ')}</small></span><Meters vehicle={vehicle}/><em>{state.queue.filter(intent => intent.groupId === vehicle.groupId && ['queued', 'validated'].includes(intent.state)).length}</em></button>) : <div className="no-selection"><Icon name="pointer"/><span><b>No local selection</b><small>Click or marquee units on the map</small></span></div>}</div>
<div className="ability-bar"><header><span><small>{selectedVehicles.length} SELECTED</small><b>{selectedVehicles.length > 1 ? `${Math.round(Math.min(...selectedVehicles.map(v => v.energy)))}–${Math.round(Math.max(...selectedVehicles.map(v => v.energy)))}% ENERGY` : focusedVehicle?.name ?? 'NO UNIT'}</b></span><span className={hasAuthority ? 'authority-ok' : 'authority-warn'}>{hasAuthority ? 'AUTHORITY HELD' : 'SELECTION ≠ AUTHORITY'}</span></header><nav>{[
['move-intent', 'route', 'MOVE'], ['route', 'route', 'ROUTE'], ['formation', 'formation', 'FORMATION'], ['marker', 'marker', 'MARK'], ['task-area', 'draw', 'TASK AREA'], ['measure-distance', 'measure', 'MEASURE'],
].map(([mode, icon, label]) => <button key={mode} disabled={!selection.length} className={interaction.mode === mode ? 'active' : ''} onClick={() => chooseMode(mode as InteractionMode)}><Icon name={icon as Parameters<typeof Icon>[0]['name']}/><span>{label}</span></button>)}</nav></div>
</>}
</section>
<section className={`lower-drawer ${timelineOpen ? 'open' : ''}`} aria-label="Telemetry queue and event timeline">
<header><nav><button className={tool === 'telemetry' ? 'active' : ''} onClick={() => { setTool('telemetry'); setTimelineOpen(true); }}>TELEMETRY</button><button className={tool === 'queue' ? 'active' : ''} onClick={() => { setTool('queue'); setTimelineOpen(true); }}>QUEUE <i>{state.queue.length}</i></button><button className={tool === 'events' ? 'active' : ''} onClick={() => { setTool('events'); setTimelineOpen(true); }}>EVENTS <i>{state.events.length}</i></button></nav><span>LIVE TELEMETRY CONTINUES</span><button aria-label={timelineOpen ? 'Collapse lower drawer' : 'Expand lower drawer'} onClick={() => setTimelineOpen(value => !value)}><Icon name="chevron"/></button></header>
{timelineOpen && <LowerDrawer tool={tool} state={state} vehicles={selectedVehicles} onJump={entityId => { const vehicle = state.vehicles[entityId ?? '']; if (vehicle) { selectVehicles([vehicle.id]); setCamera(frameVehicles([vehicle.id], state.vehicles)); } }}/>}
</section>
{interaction.draft?.phase === 'review' && <ReviewStrip draft={interaction.draft} state={state} hasAuthority={hasAuthority} onCancel={() => dispatchInteraction({ type: 'discard-draft' })} onStage={() => setToast('Intent staged locally · no vehicle commanded')} onDispatch={() => void dispatchDraft()}/>}
{toast && <div role="status" className="toast"><Icon name="check"/><span>{toast}</span><button aria-label="Dismiss" onClick={() => setToast('')}><Icon name="close"/></button></div>}
</main>
</div>;
}
function title(value: string) { return value.charAt(0).toUpperCase() + value.slice(1); }
function ModePalette({ active, choose, close }: { active: InteractionMode; choose: (mode: InteractionMode) => void; close: () => void }) { const groups: Array<[string, InteractionMode[]]> = [['SELECTION', ['select-additive','marquee','lasso']], ['ANNOTATION', ['marker','ping','note','polyline','arrow','freehand','polygon','circle']], ['TASK / SAFETY', ['task-area','corridor','geofence-include','geofence-exclude']], ['MEASUREMENT', ['measure-distance','measure-bearing','measure-area']], ['MISSION POINTS', ['home','rally','landing','recovery','staging']], ['REVISION', ['edit','erase']]]; return <aside className="mode-palette"><header><span><small>TYPED INTERACTION CONTROLLER</small><b>MAP TOOLS</b></span><button aria-label="Close map tools" onClick={close}><Icon name="close"/></button></header>{groups.map(([label, modes]) => <section key={label}><small>{label}</small><div>{modes.map(mode => <button key={mode} className={active === mode ? 'active' : ''} onClick={() => choose(mode)}><Icon name={['marker','ping','note','home','rally','landing','recovery','staging'].includes(mode) ? 'marker' : mode.startsWith('measure') ? 'measure' : mode === 'erase' ? 'close' : mode.includes('select') || ['marquee','lasso'].includes(mode) ? 'pointer' : 'draw'}/><span>{MODES[mode].label}</span></button>)}</div></section>)}</aside>; }
function Status({ label, value, tone, icon }: { label: string; value: string; tone: string; icon?: Parameters<typeof Icon>[0]['name'] }) { return <div className={`strip-status ${tone}`}>{icon && <Icon name={icon}/>}<span><small>{label}</small><b>{value}</b></span></div>; }
function frameVehicles(ids: string[], vehicles: Record<string, Vehicle>): Camera { const selected = ids.map(id => vehicles[id]).filter(Boolean); if (!selected.length) return { x: 0, y: 0, zoom: .92 }; const x = selected.reduce((sum, v) => sum + v.position.x, 0) / selected.length; const y = selected.reduce((sum, v) => sum + v.position.y, 0) / selected.length; return { x: 50 - x, y: 50 - y, zoom: selected.length === 1 ? 3.2 : selected.length < 8 ? 2.1 : 1.5 }; }
function Meters({ vehicle }: { vehicle: Vehicle }) { return <span className="card-meters"><i title={`Energy ${Math.round(vehicle.energy)}%`} style={{ '--v': `${vehicle.energy}%` } as React.CSSProperties}/><i title={`Link ${Math.round(vehicle.link)}%`} style={{ '--v': `${vehicle.link}%` } as React.CSSProperties}/><small>{Math.round(vehicle.energy)}% · {vehicle.freshnessMs < 1000 ? 'LIVE' : `${(vehicle.freshnessMs / 1000).toFixed(1)}s`}</small></span>; }
function SelectionInspector({ vehicles, focused, state, hasAuthority, onFocus, onConfigure }: { vehicles: Vehicle[]; focused?: Vehicle; state: ReturnType<DataPlane['getSnapshot']>; hasAuthority: boolean; onFocus: (id: string) => void; onConfigure: () => void }) {
const energy = vehicles.map(vehicle => Math.round(vehicle.energy)).sort((a, b) => a - b); const median = energy.length ? energy[Math.floor(energy.length / 2)] : 0;
if (!vehicles.length) return <aside className="selection-inspector"><header><small>SELECTION INSPECTOR</small><b>Nothing selected</b></header><div className="inspector-empty"><Icon name="inspect"/><p>Select observed units to inspect truth. Selection never grants command or configuration authority.</p></div></aside>;
return <aside className="selection-inspector"><header><span><small>{vehicles.length === 1 ? 'TACTICAL / ENGINEERING' : 'MULTI-SELECTION'}</small><b>{vehicles.length === 1 ? focused?.name : `${vehicles.length} VEHICLES`}</b></span><span className={hasAuthority ? 'authority-ok' : 'authority-warn'}>{hasAuthority ? 'LEASE HELD' : 'MIXED / OBSERVE'}</span></header>
{vehicles.length === 1 && focused ? <><div className="truth-banner"><Icon name="aircraft"/><span><b>{focused.archetype.toUpperCase()}</b><small>Observed {focused.binding} truth</small></span><strong>{focused.health}</strong></div><section className="position-readout"><div><small>POSITION</small><b>{focused.position.lat.toFixed(5)}°N</b><b>{focused.position.lon.toFixed(5)}°E</b></div><div><small>HDG / ALT / SPD</small><b>{Math.round(focused.heading)}° · {Math.round(focused.altitude)} m</b><b>{Math.round(focused.speed)} m/s</b></div></section><TelemetryChart label="ENERGY / PROJECTED RESERVE" unit="%" value={Math.round(focused.energy)} color="#55c77a" values={[89,88,86,84,82,80,79,77,76,focused.energy]} threshold={55}/><TelemetryChart label="LINK / LATENCY" unit="%" value={Math.round(focused.link)} color="#45c8ef" values={[96,91,94,83,88,72,78,focused.link,focused.link]} threshold={50}/><section className="subsystem-grid"><Metric label="GNSS / EST" value="3D FIX · 0.8 m" tone="nominal"/><Metric label="FRESHNESS" value={`${Math.round(focused.freshnessMs)} ms`} tone={focused.freshnessMs < 1500 ? 'nominal' : 'attention'}/><Metric label="ROUTE DEV" value="4.2 m" tone="nominal"/><Metric label="TASK" value={`${state.tasks[focused.taskId ?? '']?.progress ?? 0}%`} tone="nominal"/></section><button className="configure-button" onClick={onConfigure}><Icon name="settings"/> Open commissioning & configuration</button></> : <><section className="distribution-summary"><div><small>ENERGY MIN</small><b>{energy[0]}%</b></div><div><small>MEDIAN</small><b>{median}%</b></div><div><small>MAX</small><b>{energy.at(-1)}%</b></div></section><Histogram values={vehicles.map(v => v.energy)} label="ENERGY DISTRIBUTION"/><Histogram values={vehicles.map(v => v.link)} label="LINK DISTRIBUTION"/><section className="availability"><header><small>OBSERVED AVAILABILITY</small><b>{vehicles.filter(v => v.health === 'nominal').length}/{vehicles.length} NOMINAL</b></header><div><i className="nominal" style={{ flex: vehicles.filter(v => v.health === 'nominal').length }}/><i className="attention" style={{ flex: vehicles.filter(v => v.health !== 'nominal').length }}/></div></section><section className="exceptions"><header><small>WORST EXCEPTIONS</small><b>{vehicles.filter(v => v.health !== 'nominal' || v.energy < 70).length}</b></header>{vehicles.filter(v => v.health !== 'nominal' || v.energy < 70).slice(0, 4).map(vehicle => <button key={vehicle.id} onClick={() => onFocus(vehicle.id)}><Icon name="alert"/><span><b>{vehicle.name}</b><small>{vehicle.health !== 'nominal' ? vehicle.health : `Reserve ${Math.round(vehicle.energy)}%`}</small></span><em>LOCATE</em></button>)}</section></>}
</aside>;
}
function TelemetryChart({ label, unit, value, values, color, threshold }: { label: string; unit: string; value: number; values: number[]; color: string; threshold: number }) { const pts = values.map((v, i) => `${i / (values.length - 1) * 100},${36 - v * .32}`).join(' '); return <section className="telemetry-chart"><header><small>{label}</small><b>{value} {unit}</b></header><svg viewBox="0 0 100 40" preserveAspectRatio="none"><line x1="0" y1={36 - threshold * .32} x2="100" y2={36 - threshold * .32} className="threshold"/><polygon points={`0,40 ${pts} 100,40`} style={{ fill: color, opacity: .09 }}/><polyline points={pts} style={{ stroke: color }}/><path d="M57 0v40" className="gap"/></svg><footer><span>−10 min</span><span>threshold {threshold}{unit}</span><span>now</span></footer></section>; }
function Metric({ label, value, tone }: { label: string; value: string; tone: string }) { return <div className={tone}><small>{label}</small><b>{value}</b></div>; }
function Histogram({ values, label }: { values: number[]; label: string }) { const bins = [50, 60, 70, 80, 90].map(low => values.filter(value => value >= low && value < low + 10).length); const max = Math.max(...bins, 1); return <section className="histogram"><header><small>{label}</small><span>10% BINS</span></header><div>{bins.map((value, index) => <i key={index} style={{ height: `${value / max * 100}%` }}><em>{value}</em></i>)}</div><footer><span>50</span><span>70</span><span>90+</span></footer></section>; }
function DeploymentDeck({ mode, setMode, archetype, setArchetype, count, setCount, staged, onStage }: { mode: DeploymentMode; setMode: (mode: DeploymentMode) => void; archetype: Archetype; setArchetype: (type: Archetype) => void; count: number; setCount: (count: number) => void; staged: number; onStage: () => void }) { const archetypes: Archetype[] = ['quadcopter', 'fixed-wing', 'rover', 'surface-vessel']; return <div className="deployment-deck"><header><span><small>VEHICLE DEPLOYMENT · PROVISIONAL</small><b>{staged ? `${staged} STAGED` : 'PLACE A COHORT'}</b></span><nav>{(['simulate', 'discover', 'import'] as const).map(value => <button className={mode === value ? 'active' : ''} onClick={() => setMode(value)} key={value}>{value.toUpperCase()}</button>)}</nav></header>{mode === 'simulate' ? <><div className="archetype-tiles">{archetypes.map(value => <button key={value} className={archetype === value ? 'active' : ''} onClick={() => setArchetype(value)}><Icon name="aircraft"/><span><b>{value.replace('-', ' ')}</b><small>{value === 'rover' ? 'wheeled · mapping' : value === 'surface-vessel' ? 'marine · sonar' : 'air · EO/IR'}</small></span></button>)}</div><div className="placement-controls"><label>COUNT <select aria-label="Deployment count" value={count} onChange={event => setCount(Number(event.target.value))}><option>1</option><option>12</option><option>25</option><option>50</option></select></label><label>FORMATION <select aria-label="Deployment formation"><option>Grid</option><option>Wedge</option><option>Relay chain</option><option>Coverage sweep</option></select></label><label>SPACING <input aria-label="Deployment spacing" type="range" min="10" max="100" defaultValue="42"/></label><button className="primary" onClick={onStage}>STAGE {count} GHOSTS</button></div></> : mode === 'discover' ? <div className="discover-row">{[['USB / SERIAL', 'KPT-F722-9A31', 'trusted'], ['RADIO', 'KPT-H743-113E', 'system ID conflict'], ['ETHERNET', 'PX4-A91C-17', 'unclaimed']].map(item => <button key={item[1]}><Icon name="signal"/><span><b>{item[1]}</b><small>{item[0]} · v0.8.4</small></span><em className={item[2] === 'trusted' ? 'nominal' : 'attention'}>{item[2]}</em></button>)}</div> : <div className="import-drop"><Icon name="deploy"/><span><b>Versioned provisioning manifest</b><small>Preview identities, template versions, anchors, and conflicts before staging.</small></span><button>CHOOSE BUNDLE</button></div>}</div>; }
function CompactDrawer({ tool, close, preferences, updatePreferences }: { tool: 'layers' | 'settings'; close: () => void; preferences: Preferences; updatePreferences: (fn: (value: Preferences) => Preferences) => void }) { return <aside className="compact-drawer"><header><span><small>MAP-RESIDENT TOOL</small><b>{tool.toUpperCase()}</b></span><button aria-label={`Close ${tool}`} onClick={close}><Icon name="close"/></button></header>{tool === 'layers' ? <div className="layer-list">{LAYERS.map(layer => <label key={layer}><input type="checkbox" checked={preferences.layers[layer]} onChange={event => updatePreferences(value => ({ ...value, layers: { ...value.layers, [layer]: event.target.checked } }))}/><span>{layerLabels[layer]}</span><small>{['noFlyZones', 'constraints'].includes(layer) ? 'critical remains visible' : 'optional layer'}</small></label>)}</div> : <div className="settings-list"><label>Density<select aria-label="Density" value={preferences.density} onChange={event => updatePreferences(value => ({ ...value, density: event.target.value as Preferences['density'] }))}><option value="compact">Compact</option><option value="comfortable">Comfortable</option></select></label><label><input type="checkbox" checked={preferences.reducedMotion} onChange={event => updatePreferences(value => ({ ...value, reducedMotion: event.target.checked }))}/> Reduced motion</label><div className="keymap"><span><kbd>1–0</kbd> recall groups</span><span><kbd>Ctrl+1–0</kbd> assign groups</span><span><kbd>Esc</kbd> cancel draft</span><span><kbd>Tab</kbd> cycle focus</span></div></div>}</aside>; }
function RightWorkbench({ tool, close, vehicle, state, section, setSection }: { tool: 'configuration' | 'recovery'; close: () => void; vehicle?: Vehicle; state: ReturnType<DataPlane['getSnapshot']>; section: 'readiness' | 'sensors' | 'radio' | 'parameters'; setSection: (section: 'readiness' | 'sensors' | 'radio' | 'parameters') => void }) { if (tool === 'recovery') return <aside className="right-workbench recovery-workbench"><header><span><small>DEGRADED OPERATIONS</small><b>RECOVERY</b></span><button aria-label="Close recovery" onClick={close}><Icon name="close"/></button></header>{state.alerts.filter(a => !a.resolved).map(alert => <article key={alert.id}><Icon name="alert"/><span><b>{alert.title}</b><small>{alert.detail}</small></span><em>{alert.acknowledged ? 'ACK' : 'UNACK'}</em></article>)}<section className="recovery-chain"><small>RECOVERY CHECKPOINT</small><div><span>Observed failure</span><i/><span>Isolate</span><i/><span>Restore</span><i/><span>Verify</span></div><p>Live mutations remain unavailable until a reviewed Kaptan adapter is installed. Simulated recovery is audit-only.</p></section></aside>;
return <aside className="right-workbench commissioning-workbench"><header><span><small>DOCKED COMMISSIONING</small><b>{vehicle?.name ?? 'STAGING TRAY'}</b></span><button aria-label="Close configuration" onClick={close}><Icon name="close"/></button></header><div className="lifecycle"><span className="done">BOUND</span><i/><span className="done">CONFIGURED</span><i/><span className="current">CALIBRATE</span><i/><span>VALIDATE</span></div><nav>{(['readiness', 'sensors', 'radio', 'parameters'] as const).map(value => <button key={value} className={section === value ? 'active' : ''} onClick={() => setSection(value)}>{value.toUpperCase()}</button>)}</nav>
{section === 'readiness' && <div className="readiness-view"><div className="readiness-ring"><svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="42"/><circle className="progress-ring" cx="50" cy="50" r="42"/></svg><span><b>72%</b><small>READY</small></span></div><div className="dependency-graph">{[['Identity', 'complete'], ['Firmware', 'complete'], ['Sensors', 'blocker'], ['Radio', 'warning'], ['Power', 'complete'], ['Failsafes', 'blocker']].map(item => <button key={item[0]} className={item[1]}><i/><span><b>{item[0]}</b><small>{item[1]}</small></span></button>)}</div><div className="blocker-box"><Icon name="alert"/><span><b>2 readiness blockers</b><small>Compass calibration failed · return behavior has no rally point</small></span></div></div>}
{section === 'sensors' && <div className="calibration-view"><div className="orientation-model"><Icon name="aircraft"/><span className="axis x">X</span><span className="axis y">Y</span><span className="axis z">Z</span></div><TelemetryChart label="GYRO RESIDUAL" unit="°/s" value={0.8} color="#45c8ef" values={[21,15,9,5,3,2,1,.8]} threshold={5}/><div className="sensor-list">{['Accelerometer · passed','Gyroscope · passed','Compass · failed','Level horizon · required','GNSS · 3D fix'].map(value => <span className={value.includes('failed') || value.includes('required') ? 'attention' : 'nominal'} key={value}>{value}</span>)}</div><button className="hold-control">PRESS AND HOLD TO CALIBRATE</button></div>}
{section === 'radio' && <div className="radio-view"><div className="stick-pair"><i><b/></i><i><b/></i></div>{Array.from({ length: 8 }, (_, index) => <label key={index}><span>CH {index + 1}</span><i><b style={{ width: `${35 + index * 7}%` }}/></i><em>{1100 + index * 83} μs</em></label>)}</div>}
{section === 'parameters' && <div className="parameter-view"><header><input aria-label="Search parameters" placeholder="Search key, description, unit…"/><button>3-WAY DIFF</button></header>{[['BAT_CAPACITY','5000','5200','mAh'],['RTL_ALT','30','45','m'],['COM_DISARM_LAND','2','2','s'],['GF_MAX_HOR_DIST','1000','850','m']].map((item, index) => <div key={item[0]} className={index === 1 ? 'safety' : ''}><span><b>{item[0]}</b><small>{index === 1 ? 'Safety critical · reboot required' : 'Fleet template'}</small></span><em>{item[1]}</em><strong>{item[2]}</strong><small>{item[3]}</small></div>)}<footer><span>BASELINE → CURRENT → DESIRED</span><button>REVIEW 2 CHANGES</button></footer></div>}
</aside>; }
function LowerDrawer({ tool, state, vehicles, onJump }: { tool: Tool; state: ReturnType<DataPlane['getSnapshot']>; vehicles: Vehicle[]; onJump: (id?: string) => void }) { if (tool === 'queue') return <div className="queue-drawer">{state.queue.map((intent, index) => <article key={intent.id} className={intent.state}><i>{index + 1}</i><span><b>{intent.title}</b><small>{intent.detail} · {intent.durationMinutes} min</small></span><em>{intent.state}</em><strong>{intent.validation}</strong></article>)}</div>; if (tool === 'events') return <div className="event-drawer">{state.events.slice(0, 8).map(event => <article key={event.id} className={event.severity}><time>{new Date(event.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</time><span><b>{event.actor}</b><small>{event.message}</small></span><em>{event.category}</em><button onClick={() => onJump(event.entityId)}>LOCATE</button></article>)}</div>; return <div className="telemetry-drawer"><TelemetryChart label="GROUP ENERGY · 15 MIN" unit="%" value={vehicles.length ? Math.round(vehicles.reduce((a, v) => a + v.energy, 0) / vehicles.length) : 0} color="#55c77a" values={[92,90,89,87,84,83,81,79,77,76]} threshold={55}/><TelemetryChart label="LINK QUALITY · PACKET GAPS" unit="%" value={vehicles.length ? Math.round(vehicles.reduce((a, v) => a + v.link, 0) / vehicles.length) : 0} color="#45c8ef" values={[98,94,91,82,90,86,70,88,91,89]} threshold={50}/><section className="mode-timeline"><small>COMMAND LIFECYCLE</small><div><span className="done">VALIDATED</span><i/><span className="done">SENT</span><i/><span className="current">8 ACK</span><i/><span>4 EXEC</span></div><p>Crosshair 14:32:11 · 1 source gap · telemetry buffer bounded to 15 min</p></section></div>; }
function ReviewStrip({ draft, state, hasAuthority, onCancel, onStage, onDispatch }: { draft: DraftGeometry; state: ReturnType<DataPlane['getSnapshot']>; hasAuthority: boolean; onCancel: () => void; onStage: () => void; onDispatch: () => void }) { const distance = draftDistanceKm(draft.points); const targets = draft.targetIds.map(id => state.vehicles[id]).filter(Boolean); const minEnergy = targets.length ? Math.min(...targets.map(v => v.energy)) : 0; const projectedReserve = Math.round(Math.max(0, minEnergy - Math.ceil(distance * 1.7))); const conflict = draft.points.some(point => point.x > 68 && point.x < 84 && point.y > 20 && point.y < 40); return <section className="review-strip" aria-label="Command draft review"><header><Icon name="route"/><span><small>LOCAL {draft.mode.toUpperCase()} DRAFT · NOT SENT</small><b>{draft.points.length} WAYPOINTS · {targets.length} EXACT TARGETS</b></span></header><div className="review-metrics"><Metric label="ROUTE / ETA" value={`${distance.toFixed(1)} km · ${Math.ceil(distance / .8)} min`} tone="nominal"/><Metric label="ENERGY RESERVE" value={`${projectedReserve}% projected`} tone={minEnergy > 60 ? 'nominal' : 'attention'}/><Metric label="AUTHORITY" value={hasAuthority ? 'lease held' : 'blocked'} tone={hasAuthority ? 'nominal' : 'attention'}/><Metric label="CONSTRAINTS" value={conflict ? 'R-210B intersection' : 'clear'} tone={conflict ? 'attention' : 'nominal'}/><Metric label="ACK EXPECTED" value={`${targets.length} individual`} tone="nominal"/></div><footer><button onClick={onCancel}>CANCEL</button><button onClick={onStage}>STAGE</button><button className="primary" disabled={!hasAuthority || conflict} onClick={onDispatch}>DISPATCH SIMULATION</button></footer></section>; }
+34
View File
@@ -0,0 +1,34 @@
import type { SVGProps } from 'react';
const paths: Record<string, React.ReactNode> = {
pointer: <><path d="M5 3l12 10-6 .7 3 6-3 1.5-3-6-4 4z"/></>,
hand: <><path d="M8 11V5a1.5 1.5 0 013 0v5-7a1.5 1.5 0 013 0v7-5a1.5 1.5 0 013 0v6-3a1.5 1.5 0 013 0v5c0 5-3 8-7 8h-1c-3 0-5-2-6-5l-1-3a1.5 1.5 0 012.8-1L8 15"/></>,
inspect: <><circle cx="10" cy="10" r="6"/><path d="M15 15l6 6M10 7v6M7 10h6"/></>,
route: <><circle cx="5" cy="18" r="2"/><circle cx="19" cy="6" r="2"/><path d="M7 18c8 0 2-12 10-12"/></>,
formation: <><circle cx="12" cy="5" r="2"/><circle cx="5" cy="16" r="2"/><circle cx="12" cy="16" r="2"/><circle cx="19" cy="16" r="2"/><path d="M12 7v4M5 12h14"/></>,
marker: <><path d="M12 22s7-7 7-13a7 7 0 10-14 0c0 6 7 13 7 13z"/><circle cx="12" cy="9" r="2"/></>,
draw: <><path d="M4 19l4-1 11-11-3-3L5 15zM14 6l3 3"/></>,
measure: <><path d="M4 18L18 4l3 3L7 21zM12 7l2 2M8 11l2 2M16 3l2 2"/></>,
layers: <><path d="M12 3L3 8l9 5 9-5zM3 12l9 5 9-5M3 16l9 5 9-5"/></>,
deploy: <><path d="M12 3v13M7 8l5-5 5 5M5 21h14a2 2 0 002-2v-3M3 16v3a2 2 0 002 2"/></>,
fleet: <><path d="M4 7h16v12H4zM7 7V4h10v3M8 12h8M8 16h5"/></>,
queue: <><path d="M8 6h13M8 12h13M8 18h13"/><circle cx="4" cy="6" r="1"/><circle cx="4" cy="12" r="1"/><circle cx="4" cy="18" r="1"/></>,
events: <><path d="M12 3a8 8 0 108 8M12 7v5l3 2"/><path d="M18 3v5h-5"/></>,
settings: <><circle cx="12" cy="12" r="3"/><path d="M19 13.5l2 1-2 3-2-1a8 8 0 01-2 1v2.5h-4V18a8 8 0 01-2-1l-2 1-2-3 2-1a8 8 0 010-3l-2-1 2-3 2 1a8 8 0 012-1V4h4v2.5a8 8 0 012 1l2-1 2 3-2 1a8 8 0 010 3z"/></>,
configure: <><path d="M4 6h7M15 6h5M4 12h2M10 12h10M4 18h10M18 18h2"/><circle cx="13" cy="6" r="2"/><circle cx="8" cy="12" r="2"/><circle cx="16" cy="18" r="2"/></>,
recovery: <><path d="M4 11a8 8 0 0114-4l2 2M20 4v5h-5M20 13a8 8 0 01-14 4l-2-2M4 20v-5h5"/><path d="M12 8v4l3 2"/></>,
tools: <><path d="M14 6l4-4 4 4-4 4M3 21l8-8M5 9l10 10M3 5l4-2 2 4-2 2z"/></>,
alert: <><path d="M12 3L2 21h20z"/><path d="M12 9v5M12 17h.01"/></>,
signal: <><path d="M4 18h2v2H4zM9 14h2v6H9zM14 10h2v10h-2zM19 5h2v15h-2z"/></>,
battery: <><rect x="3" y="7" width="17" height="10" rx="1"/><path d="M20 10h2v4M6 10v4M9 10v4M12 10v4M15 10v4"/></>,
aircraft: <><path d="M12 2l2 7 7 4v2l-7-1-1 7h-2l-1-7-7 1v-2l7-4z"/></>,
close: <><path d="M5 5l14 14M19 5L5 19"/></>,
chevron: <><path d="M8 10l4 4 4-4"/></>,
undo: <><path d="M9 7L4 12l5 5M5 12h8a6 6 0 016 6"/></>,
redo: <><path d="M15 7l5 5-5 5M19 12h-8a6 6 0 00-6 6"/></>,
check: <><path d="M4 12l5 5L20 6"/></>,
};
export function Icon({ name, ...props }: { name: keyof typeof paths } & SVGProps<SVGSVGElement>) {
return <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...props}>{paths[name]}</svg>;
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from 'react';
import type { Dispatch, PointerEvent as ReactPointerEvent, SetStateAction, WheelEvent as ReactWheelEvent } from 'react';
import type { MissionState } from '../domain/model';
import { MODES, type DraftGeometry, type DraftPoint, type InteractionAction, type InteractionState } from '../interaction/controller';
import { Icon } from './Icon';
export type Camera = { x: number; y: number; zoom: number };
type Props = {
state: MissionState;
selection: string[];
interaction: InteractionState;
dispatch: Dispatch<InteractionAction>;
camera: Camera;
setCamera: Dispatch<SetStateAction<Camera>>;
selectVehicles: (ids: string[], additive?: boolean) => void;
onContext: (point: DraftPoint) => void;
};
const points = (draft: DraftGeometry) => draft.points.map(point => `${point.x},${point.y}`).join(' ');
const path = (draft: DraftGeometry) => draft.points.map((point, index) => `${index ? 'L' : 'M'} ${point.x} ${point.y}`).join(' ');
const isPolygon = (draft: DraftGeometry) => ['polygon', 'task-area', 'measure-area', 'geofence-include', 'geofence-exclude'].includes(draft.mode);
const groupColors: Record<string, string> = { 'group:alpha': '#45c8ef', 'group:bravo': '#6ed779', 'group:charlie': '#8ea9ff', 'group:delta': '#e8b94b', 'group:reserve': '#a879e2' };
const insidePolygon = (point: DraftPoint, polygon: DraftPoint[]) => { let inside = false; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const a = polygon[i]; const b = polygon[j]; if ((a.y > point.y) !== (b.y > point.y) && point.x < (b.x - a.x) * (point.y - a.y) / ((b.y - a.y) || .0001) + a.x) inside = !inside; } return inside; };
export function OperationalMap({ state, selection, interaction, dispatch, camera, setCamera, selectVehicles, onContext }: Props) {
const pointer = useRef<{ id: number; x: number; y: number; mapX: number; mapY: number; kind: 'pan' | 'marquee' | 'draw' } | null>(null);
const spaceHeld = useRef(false);
const [marquee, setMarquee] = useState<{ x1: number; y1: number; x2: number; y2: number } | null>(null);
const [cursorPoint, setCursorPoint] = useState<DraftPoint | null>(null);
useEffect(() => {
const down = (event: KeyboardEvent) => { if (event.code === 'Space' && !(event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement)) spaceHeld.current = true; };
const up = (event: KeyboardEvent) => { if (event.code === 'Space') spaceHeld.current = false; };
window.addEventListener('keydown', down); window.addEventListener('keyup', up); return () => { window.removeEventListener('keydown', down); window.removeEventListener('keyup', up); };
}, []);
const toMap = (event: ReactPointerEvent<HTMLDivElement>): DraftPoint => {
const rect = event.currentTarget.getBoundingClientRect();
return { x: Math.max(0, Math.min(100, ((event.clientX - rect.left) / rect.width - .5) * 100 / camera.zoom + 50 - camera.x)), y: Math.max(0, Math.min(100, ((event.clientY - rect.top) / rect.height - .5) * 100 / camera.zoom + 50 - camera.y)) };
};
const toScreen = (event: ReactPointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top };
};
function down(event: ReactPointerEvent<HTMLDivElement>) {
if ((event.target as HTMLElement).closest('button,[data-map-entity]')) return;
const map = toMap(event); const screen = toScreen(event); const temporaryPan = event.button === 1 || event.buttons === 4 || spaceHeld.current;
const kind = interaction.mode === 'pan' || temporaryPan ? 'pan' : ['select', 'select-additive', 'marquee'].includes(interaction.mode) ? 'marquee' : 'draw';
pointer.current = { id: event.pointerId, x: screen.x, y: screen.y, mapX: map.x, mapY: map.y, kind };
event.currentTarget.setPointerCapture(event.pointerId);
if (kind === 'marquee') setMarquee({ x1: screen.x, y1: screen.y, x2: screen.x, y2: screen.y });
if (kind === 'draw') dispatch({ type: 'point', point: map, targetIds: [...selection] });
}
function move(event: ReactPointerEvent<HTMLDivElement>) {
const map = toMap(event); setCursorPoint(map); if (!pointer.current) return;
const screen = toScreen(event);
if (pointer.current.kind === 'pan') {
const dx = (screen.x - pointer.current.x) / event.currentTarget.clientWidth * 100 / camera.zoom;
const dy = (screen.y - pointer.current.y) / event.currentTarget.clientHeight * 100 / camera.zoom;
setCamera(value => ({ ...value, x: value.x + dx, y: value.y + dy })); pointer.current.x = screen.x; pointer.current.y = screen.y;
} else if (pointer.current.kind === 'marquee') setMarquee(value => value ? { ...value, x2: screen.x, y2: screen.y } : null);
else if (['freehand', 'lasso'].includes(interaction.mode)) dispatch({ type: 'freehand', point: map });
}
function up(event: ReactPointerEvent<HTMLDivElement>) {
if (!pointer.current) return;
if (pointer.current.kind === 'marquee' && marquee) {
const rect = event.currentTarget.getBoundingClientRect();
const x1 = ((Math.min(marquee.x1, marquee.x2) / rect.width - .5) * 100 / camera.zoom + 50 - camera.x);
const x2 = ((Math.max(marquee.x1, marquee.x2) / rect.width - .5) * 100 / camera.zoom + 50 - camera.x);
const y1 = ((Math.min(marquee.y1, marquee.y2) / rect.height - .5) * 100 / camera.zoom + 50 - camera.y);
const y2 = ((Math.max(marquee.y1, marquee.y2) / rect.height - .5) * 100 / camera.zoom + 50 - camera.y);
const moved = Math.abs(marquee.x2 - marquee.x1) + Math.abs(marquee.y2 - marquee.y1) > 8;
const additive = event.shiftKey || interaction.mode === 'select-additive';
if (moved) selectVehicles(Object.values(state.vehicles).filter(vehicle => vehicle.position.x >= x1 && vehicle.position.x <= x2 && vehicle.position.y >= y1 && vehicle.position.y <= y2).map(vehicle => vehicle.id), additive);
else if (!additive) selectVehicles([]);
}
if (pointer.current.kind === 'draw' && ['circle', 'formation', 'move-intent', 'measure-distance', 'measure-bearing'].includes(interaction.mode)) dispatch({ type: 'point', point: toMap(event), targetIds: [...selection] });
if (pointer.current.kind === 'draw' && interaction.mode === 'lasso') { const polygon = [...(interaction.draft?.points ?? []), toMap(event)]; selectVehicles(Object.values(state.vehicles).filter(vehicle => insidePolygon(vehicle.position, polygon)).map(vehicle => vehicle.id), event.shiftKey); dispatch({ type: 'discard-draft' }); }
else if (pointer.current.kind === 'draw' && interaction.mode === 'freehand') dispatch({ type: 'complete' });
pointer.current = null; setMarquee(null);
}
function wheel(event: ReactWheelEvent<HTMLDivElement>) { event.preventDefault(); setCamera(value => ({ ...value, zoom: Math.max(.72, Math.min(4.2, value.zoom * Math.exp(-event.deltaY * .0012))) })); }
const semantic = camera.zoom < 1.05 ? 'STRATEGIC' : camera.zoom < 1.75 ? 'OPERATIONAL' : camera.zoom < 2.8 ? 'TACTICAL' : 'ENGINEERING';
const allDrafts = [...interaction.artifacts, ...(interaction.draft ? [interaction.draft] : [])];
return <section className="operational-map" data-testid="operational-map" data-camera={`${camera.x.toFixed(2)}:${camera.y.toFixed(2)}:${camera.zoom.toFixed(2)}`} aria-label={`Offline tactical map · ${semantic.toLowerCase()} zoom`} style={{ cursor: MODES[interaction.mode].cursor }} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={() => { pointer.current = null; setMarquee(null); }} onWheel={wheel} onContextMenu={event => { event.preventDefault(); onContext(toMap(event as unknown as ReactPointerEvent<HTMLDivElement>)); }}>
<div className="map-grid"/>
<div className="map-world" style={{ transform: `translate(calc(${camera.x}% * ${camera.zoom}), calc(${camera.y}% * ${camera.zoom})) scale(${camera.zoom})` }}>
<div className="map-raster"/>
<svg className="map-geometry" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
<defs><pattern id="task-hatch" width="2" height="2" patternUnits="userSpaceOnUse" patternTransform="rotate(35)"><line x1="0" y1="0" x2="0" y2="2" stroke="#45c8ef" strokeOpacity=".22" strokeWidth=".5"/></pattern><pattern id="danger-hatch" width="2" height="2" patternUnits="userSpaceOnUse" patternTransform="rotate(35)"><line x1="0" y1="0" x2="0" y2="2" stroke="#ef5b61" strokeOpacity=".42" strokeWidth=".6"/></pattern></defs>
<polygon points="24,20 43,15 56,28 52,47 35,51 20,38" fill="url(#task-hatch)" stroke="#45c8ef" strokeWidth=".28" strokeDasharray="1.2 1"/>
<polygon points="72,22 83,25 80,38 69,34" fill="url(#danger-hatch)" stroke="#ef5b61" strokeWidth=".25" strokeDasharray="1 1"/>
<path d="M29 25 C34 31 38 28 42 38 S48 42 51 31" fill="none" stroke="#55c77a" strokeWidth=".28" strokeDasharray="1 1"/>
<path d="M49 46 Q63 45 75 58" fill="none" stroke="#e9b949" strokeWidth=".32" strokeDasharray="1.5 1.2"/>
{allDrafts.map(draft => <g key={draft.id} className={`draft-shape ${draft.phase} ${draft.valid ? '' : 'invalid'}`} data-map-entity>
{isPolygon(draft) ? <polygon points={points(draft)} /> : draft.mode === 'circle' && draft.points.length > 1 ? <circle cx={draft.points[0].x} cy={draft.points[0].y} r={Math.hypot(draft.points[1].x - draft.points[0].x, draft.points[1].y - draft.points[0].y)} /> : <path d={path(draft)} />}
{draft.points.map((point, index) => <g key={index}><circle className="draft-handle" cx={point.x} cy={point.y} r=".75"/><text x={point.x} y={point.y + .27}>{index + 1}</text></g>)}
</g>)}
{interaction.draft?.mode === 'formation' && interaction.draft.points.length > 1 && Array.from({ length: Math.min(12, selection.length) }, (_, index) => { const anchor = interaction.draft!.points[0]; const end = interaction.draft!.points[1]; const angle = Math.atan2(end.y - anchor.y, end.x - anchor.x); const row = Math.floor(index / 4); const col = index % 4 - 1.5; const x = anchor.x + Math.cos(angle) * row * 3 - Math.sin(angle) * col * 2.5; const y = anchor.y + Math.sin(angle) * row * 3 + Math.cos(angle) * col * 2.5; return <g key={index} className="formation-slot"><circle cx={x} cy={y} r="1.1"/><text x={x} y={y + .3}>{index + 1}</text></g>; })}
</svg>
<span className="map-label task-label" style={{ left: '29%', top: '22%' }}><b>ALPHA SEARCH</b><small>82% coverage</small></span>
<span className="map-label constraint-label" style={{ left: '70%', top: '31%' }}><b>R-210B</b><small>keep out</small></span>
{semantic === 'STRATEGIC' ? Object.values(state.groups).map(group => { const vehicles = Object.values(state.vehicles).filter(vehicle => vehicle.groupId === group.id); const x = vehicles.reduce((sum, item) => sum + item.position.x, 0) / vehicles.length; const y = vehicles.reduce((sum, item) => sum + item.position.y, 0) / vehicles.length; return <button key={group.id} data-map-entity className={`group-map-marker ${vehicles.some(v => selection.includes(v.id)) ? 'selected' : ''}`} style={{ left: `${x}%`, top: `${y}%`, '--group': groupColors[group.id] } as React.CSSProperties} onPointerDown={event => event.stopPropagation()} onClick={event => selectVehicles(vehicles.map(v => v.id), event.shiftKey)}><i>{group.name[0]}</i><span><b>{group.name}</b><small>{group.count} units · {group.progress}%</small></span></button>; }) : Object.values(state.vehicles).map(vehicle => <button key={vehicle.id} data-map-entity aria-label={`${vehicle.name} ${vehicle.health}`} className={`vehicle-map-marker ${selection.includes(vehicle.id) ? 'selected' : ''} ${vehicle.health} ${vehicle.freshnessMs > 1500 ? 'stale' : ''}`} style={{ left: `${vehicle.position.x}%`, top: `${vehicle.position.y}%`, transform: `translate(-50%,-50%) rotate(${vehicle.heading}deg)`, '--group': groupColors[vehicle.groupId] } as React.CSSProperties} onPointerDown={event => event.stopPropagation()} onClick={event => selectVehicles([vehicle.id], event.shiftKey)}><Icon name="aircraft"/><span>{vehicle.name}</span></button>)}
</div>
{marquee && <div className="selection-marquee" style={{ left: Math.min(marquee.x1, marquee.x2), top: Math.min(marquee.y1, marquee.y2), width: Math.abs(marquee.x2 - marquee.x1), height: Math.abs(marquee.y2 - marquee.y1) }}/>}
<div className="map-mode-hud"><span>{semantic}</span><b>{MODES[interaction.mode].label}</b><small>{interaction.draft ? interaction.draft.message : MODES[interaction.mode].pointer}</small></div>
<div className="map-scale"><i/><span>{Math.round(5 / camera.zoom)} km</span></div>
<div className="map-zoom-controls"><button aria-label="Zoom in" onClick={() => setCamera(value => ({ ...value, zoom: Math.min(4.2, value.zoom * 1.25) }))}>+</button><button aria-label="Zoom out" onClick={() => setCamera(value => ({ ...value, zoom: Math.max(.72, value.zoom / 1.25) }))}>−</button></div>
<button className="minimap" aria-label="Tactical overview; click to frame mission" onClick={() => setCamera({ x: 0, y: 0, zoom: .92 })}>
<span className="mini-coast"/><span className="mini-task"/><span className="mini-alert"/>
<span className="mini-viewport" style={{ width: `${Math.min(82, 76 / camera.zoom)}%`, height: `${Math.min(82, 64 / camera.zoom)}%`, left: `${12 - camera.x / 4}%`, top: `${18 - camera.y / 4}%` }}/><small>TACTICAL OVERVIEW</small>
</button>
{cursorPoint && interaction.mode !== 'select' && <span className="coordinate-readout">{(41.02 + cursorPoint.y * .0014).toFixed(5)}°N&nbsp; {(28.98 + cursorPoint.x * .0019).toFixed(5)}°E</span>}
</section>;
}
+3
View File
@@ -0,0 +1,3 @@
import { FakeRealtimeSource } from '../adapters/fake/FakeRealtimeSource';
import { DataPlane } from '../core/data-plane/DataPlane';
export function createSimulationRuntime(options: { automatic?: boolean; seed?: number; schedule?: (callback: () => void) => unknown } = {}): DataPlane { return new DataPlane(new FakeRealtimeSource(options.seed ?? 17, options.automatic ?? true), options.schedule); }
@@ -0,0 +1,13 @@
export class VirtualClock {
private time: number;
private paused = false;
private speed = 1;
constructor(start = Date.UTC(2026, 7, 17, 11, 30, 0)) { this.time = start; }
now(): number { return this.time; }
advance(milliseconds: number): number { if (!this.paused) this.time += milliseconds * this.speed; return this.time; }
pause(): void { this.paused = true; }
resume(): void { this.paused = false; }
setSpeed(speed: number): void { this.speed = Math.max(0.25, Math.min(8, speed)); }
restart(start = Date.UTC(2026, 7, 17, 11, 30, 0)): void { this.time = start; this.paused = false; this.speed = 1; }
get state(): { time: number; paused: boolean; speed: number } { return { time: this.time, paused: this.paused, speed: this.speed }; }
}
@@ -0,0 +1,4 @@
export type CommandKind = 'assign-task' | 'move' | 'hold' | 'resume' | 'transfer-authority' | 'request-authority' | 'reorder-queue' | 'accept-replacement' | 'acknowledge-alert' | 'create-group' | 'split-group' | 'merge-group';
export interface CommandEnvelope { id: string; kind: CommandKind; actorId: string; targetIds: string[]; payload: Record<string, unknown>; createdAt: number }
export interface CommandReceipt { commandId: string; accepted: boolean; state: 'acknowledged' | 'rejected'; reason?: string; remediation?: string; acknowledgedAt: number }
export interface CommandPreview { command: CommandEnvelope; allowed: boolean; affectedVehicles: string[]; authorityHolder?: string; summary: string; location: string; next: string; failureEffect: string; checks: Array<{ label: string; status: 'pass' | 'warning' | 'fail'; detail: string }> }
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { FakeRealtimeSource } from '../../adapters/fake/FakeRealtimeSource';
import { createScenario } from '../../adapters/fake/scenario';
import { DataPlane } from './DataPlane';
import type { EventEnvelope } from './EventEnvelope';
const immediate = (callback: () => void) => callback();
describe('DataPlane', () => {
it('accepts a deterministic 50 vehicle snapshot only through the source contract', async () => {
const plane = new DataPlane(new FakeRealtimeSource(17, false), immediate);
await plane.connect();
expect(Object.keys(plane.getSnapshot().vehicles)).toHaveLength(50);
expect(plane.getSnapshot().mission.code).toBe('ALPHA-17');
expect(plane.getSnapshot().source.state).toBe('connected');
});
it('deduplicates IDs and drops out-of-order sequence numbers', () => {
const plane = new DataPlane(new FakeRealtimeSource(17, false), immediate);
const event = { schemaVersion: 1, eventId: 'x', streamId: 'stream', sequence: 2, type: 'source-status', entity: { kind: 'source', id: 'source' }, sourceTimestamp: 1, receivedTimestamp: 1, quality: { stale: false, confidence: 1 }, payload: { state: 'connected', message: 'ok' } } satisfies EventEnvelope;
plane.accept(event); plane.accept(event);
plane.accept({ ...event, eventId: 'y', sequence: 1 });
expect(plane.getSnapshot().source.duplicates).toBe(1);
expect(plane.getSnapshot().source.outOfOrder).toBe(1);
});
it('surfaces invalid events without crashing', () => {
const plane = new DataPlane(new FakeRealtimeSource(17, false), immediate);
plane.accept({ nope: true });
expect(plane.getSnapshot().source.state).toBe('degraded');
expect(plane.getSnapshot().source.invalidEvents).toBe(1);
});
it('batches subscriber notification using the configured projection cadence', () => {
const callbacks: Array<() => void> = []; const plane = new DataPlane(new FakeRealtimeSource(17, false), callback => callbacks.push(callback)); let calls = 0; plane.subscribe(() => calls++);
plane.accept({ schemaVersion: 1, eventId: 'a', streamId: 's', sequence: 1, type: 'source-status', entity: { kind: 'source', id: 's' }, sourceTimestamp: 1, receivedTimestamp: 1, quality: { stale: false, confidence: 1 }, payload: { state: 'connected' } });
plane.accept({ schemaVersion: 1, eventId: 'b', streamId: 's', sequence: 2, type: 'source-status', entity: { kind: 'source', id: 's' }, sourceTimestamp: 2, receivedTimestamp: 2, quality: { stale: false, confidence: 1 }, payload: { state: 'connected' } });
expect(callbacks).toHaveLength(1); callbacks[0](); expect(calls).toBe(1);
});
it('does not infer authority from selection in command previews', async () => {
const plane = new DataPlane(new FakeRealtimeSource(17, false), immediate); await plane.connect();
const preview = plane.preview({ id: 'cmd', kind: 'move', actorId: 'operator:sara', targetIds: ['group:alpha'], payload: {}, createdAt: 1 });
expect(preview.allowed).toBe(false);
expect(preview.checks.find(check => check.label === 'Authority')?.status).toBe('fail');
});
it('supports pause, deterministic stepping, restart and speed control', async () => {
const plane = new DataPlane(new FakeRealtimeSource(17, false), immediate); await plane.connect(); const initial = plane.getSnapshot().simulation.tick;
plane.step(100); expect(plane.getSnapshot().simulation.tick).toBe(initial + 1);
plane.pause(); const paused = plane.getSnapshot().simulation.tick; plane.step(100); expect(plane.getSnapshot().simulation.tick).toBe(paused);
plane.resume(); plane.setSpeed(2); plane.step(100); expect(plane.getSnapshot().simulation.speed).toBe(2);
plane.restart(); expect(plane.getSnapshot().simulation.tick).toBe(0);
});
it('starts from the required scenario shape', () => {
const scenario = createScenario(17);
expect(Object.values(scenario.groups).map(group => group.count)).toEqual([12, 10, 8, 12, 8]);
expect(Object.keys(scenario.operators)).toHaveLength(5);
expect(new Set(Object.values(scenario.vehicles).map(vehicle => vehicle.archetype)).size).toBe(6);
});
});
@@ -0,0 +1,105 @@
import type { ActivityEvent, Alert, MissionState, Vehicle } from '../../domain/model';
import { emptyMissionState } from '../../domain/model';
import type { CommandEnvelope, CommandPreview, CommandReceipt } from './CommandEnvelope';
import type { DataSource } from './DataSource';
import type { EventEnvelope } from './EventEnvelope';
import { validateEvent } from './eventValidation';
type Listener = () => void;
export class DataPlane {
private state = emptyMissionState();
private listeners = new Set<Listener>();
private seen = new Set<string>();
private sequences = new Map<string, number>();
private pendingNotify = false;
private readonly journal: EventEnvelope[] = [];
constructor(private readonly source: DataSource, private readonly schedule: (callback: () => void) => unknown = callback => globalThis.setTimeout(callback, 16)) {}
getSnapshot = (): MissionState => this.state;
subscribe = (listener: Listener): (() => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); };
getJournal(): readonly EventEnvelope[] { return this.journal; }
get descriptor() { return this.source.descriptor; }
async connect(): Promise<void> {
this.state = { ...this.state, source: { ...this.state.source, state: 'connecting', message: `Connecting ${this.source.descriptor.name}` } };
this.notify();
try { await this.source.connect({ accept: event => this.accept(event), reportError: error => this.reportError(error) }); }
catch (error) { this.reportError(error instanceof Error ? error : new Error(String(error))); }
}
async disconnect(): Promise<void> { await this.source.disconnect(); this.state = { ...this.state, source: { ...this.state.source, state: 'disconnected', message: 'Source disconnected safely' } }; this.notify(); }
accept(value: unknown): void {
const result = validateEvent(value);
if (!result.valid) { this.state = { ...this.state, source: { ...this.state.source, state: 'degraded', message: result.reason, invalidEvents: this.state.source.invalidEvents + 1 } }; this.notify(); return; }
const event = result.event;
if (this.seen.has(event.eventId)) { this.state = { ...this.state, source: { ...this.state.source, duplicates: this.state.source.duplicates + 1 } }; this.notify(); return; }
this.seen.add(event.eventId);
if (this.seen.size > 5000) this.seen = new Set([...this.seen].slice(-2500));
const previous = this.sequences.get(event.streamId) ?? -1;
if (event.sequence <= previous) { this.state = { ...this.state, source: { ...this.state.source, outOfOrder: this.state.source.outOfOrder + 1 } }; this.notify(); return; }
this.sequences.set(event.streamId, event.sequence);
this.journal.push(event); if (this.journal.length > 1000) this.journal.shift();
this.reduce(event);
this.notify();
}
private reduce(event: EventEnvelope): void {
if (event.type === 'snapshot') { this.state = structuredClone(event.payload as MissionState); return; }
if (event.type === 'telemetry') {
const payload = event.payload as { updates: Array<Partial<Vehicle> & Pick<Vehicle, 'id'>>; elapsedSeconds: number; simulation: MissionState['simulation'] };
const vehicles = { ...this.state.vehicles };
for (const update of payload.updates) if (vehicles[update.id]) vehicles[update.id] = { ...vehicles[update.id], ...update };
this.state = { ...this.state, vehicles, mission: { ...this.state.mission, elapsedSeconds: payload.elapsedSeconds }, simulation: payload.simulation, source: { ...this.state.source, state: event.quality.stale ? 'degraded' : this.state.source.state, lastEventAt: event.receivedTimestamp } };
return;
}
if (event.type === 'alert') {
const alert = event.payload as Alert;
if (this.state.alerts.some(item => item.id === alert.id)) return;
const activity: ActivityEvent = { id: `activity:${alert.id}`, timestamp: event.sourceTimestamp, actor: 'System', category: 'alert', severity: alert.severity, message: alert.title, entityId: alert.entityId, acknowledged: false, target: alert.target };
this.state = { ...this.state, alerts: [alert, ...this.state.alerts], events: [activity, ...this.state.events].slice(0, 500) };
return;
}
if (event.type === 'source-status') {
const payload = event.payload as Partial<MissionState['source']> & { simulation?: Partial<MissionState['simulation']> };
this.state = { ...this.state, source: { ...this.state.source, ...payload, lastEventAt: event.receivedTimestamp }, simulation: { ...this.state.simulation, ...(payload.simulation ?? {}) } };
return;
}
if (event.type === 'command-state') {
const payload = event.payload as { command: CommandEnvelope; receipt: CommandReceipt; activity: MissionState['events'][number] };
let next = { ...this.state, events: [payload.activity, ...this.state.events].slice(0, 500) };
if (payload.receipt.accepted && payload.command.kind === 'transfer-authority') {
const groupId = payload.command.targetIds[0]; const leaseId = `lease:${groupId}`; const nextHolder = String(payload.command.payload.toOperatorId);
next = { ...next, leases: { ...next.leases, [leaseId]: { ...next.leases[leaseId], holderId: nextHolder, state: 'held', requestedBy: undefined } }, groups: { ...next.groups, [groupId]: { ...next.groups[groupId], operatorId: nextHolder } } };
}
if (payload.receipt.accepted && payload.command.kind === 'reorder-queue') {
const order = payload.command.payload.order as string[]; next = { ...next, queue: order.map(id => next.queue.find(item => item.id === id)).filter((item): item is MissionState['queue'][number] => Boolean(item)) };
}
if (payload.receipt.accepted && payload.command.kind === 'accept-replacement') {
const replacementId = String(payload.command.payload.replacementId); const failedId = String(payload.command.payload.failedId);
const vehicles: Record<string, Vehicle> = { ...next.vehicles, [replacementId]: { ...next.vehicles[replacementId], groupId: 'group:alpha', available: false, taskId: 'task:alpha' }, [failedId]: { ...next.vehicles[failedId], health: 'unavailable', available: false } };
next = { ...next, vehicles, alerts: next.alerts.map(alert => alert.id === 'alert:alpha-coverage' ? { ...alert, resolved: true } : alert), groups: { ...next.groups, 'group:alpha': { ...next.groups['group:alpha'], health: 'nominal', attentionCount: 0 } } };
}
if (payload.receipt.accepted && payload.command.kind === 'acknowledge-alert') next = { ...next, alerts: next.alerts.map(alert => payload.command.targetIds.includes(alert.id) ? { ...alert, acknowledged: true } : alert) };
this.state = next;
}
}
private reportError(error: Error): void { this.state = { ...this.state, source: { ...this.state.source, state: 'degraded', message: error.message, invalidEvents: this.state.source.invalidEvents + 1 } }; this.notify(); }
private notify(): void { if (this.pendingNotify) return; this.pendingNotify = true; this.schedule(() => { this.pendingNotify = false; for (const listener of this.listeners) listener(); }); }
preview(command: CommandEnvelope): CommandPreview {
const affected = command.targetIds.flatMap(id => this.state.groups[id] ? Object.values(this.state.vehicles).filter(vehicle => vehicle.groupId === id).map(vehicle => vehicle.id) : this.state.vehicles[id] ? [id] : []);
const leases = new Set(affected.map(id => this.state.vehicles[id]?.groupId).filter(Boolean).map(groupId => this.state.leases[`lease:${groupId}`]?.holderId));
const authority = leases.size === 1 ? [...leases][0] : undefined;
const checks: CommandPreview['checks'] = [
{ label: 'Energy reserve', status: affected.every(id => (this.state.vehicles[id]?.energy ?? 0) >= 55) ? 'pass' : 'fail', detail: affected.every(id => (this.state.vehicles[id]?.energy ?? 0) >= 55) ? 'All targets retain ≥55% simulated reserve' : 'One or more targets are below simulated reserve' },
{ label: 'Communication link', status: affected.every(id => (this.state.vehicles[id]?.link ?? 0) >= 50) ? 'pass' : 'fail', detail: 'Minimum required simulated link quality is 50%' },
{ label: 'Telemetry freshness', status: affected.every(id => (this.state.vehicles[id]?.freshnessMs ?? 0) < 1500) ? 'pass' : 'warning', detail: 'No target may exceed 1.5 s freshness for dispatch' },
{ label: 'Constraints / route', status: command.targetIds.includes('group:bravo') && command.kind === 'move' ? 'warning' : 'pass', detail: command.targetIds.includes('group:bravo') ? 'R-210B is within the projected route envelope' : 'No simulated conflict detected' },
{ label: 'Authority', status: authority === command.actorId || ['request-authority', 'transfer-authority', 'acknowledge-alert'].includes(command.kind) ? 'pass' : 'fail', detail: authority === command.actorId ? 'Alex holds the applicable lease' : `Lease holder: ${this.state.operators[authority ?? '']?.name ?? 'none'}` },
{ label: 'Source connectivity', status: this.state.source.state === 'connected' ? 'pass' : 'fail', detail: this.state.source.message },
];
return { command, allowed: checks.every(check => check.status !== 'fail'), affectedVehicles: affected, authorityHolder: authority, summary: `${command.kind.replaceAll('-', ' ')} for ${affected.length} exact target${affected.length === 1 ? '' : 's'}`, location: command.kind === 'move' ? 'Operator-defined map position' : 'Current mission workspace', next: 'Dispatch to the active simulated source, then await acknowledgement', failureEffect: 'Mission truth remains unchanged and a rejection is written to the event journal', checks };
}
async submit(command: CommandEnvelope): Promise<CommandReceipt> { const preview = this.preview(command); if (!preview.allowed) return { commandId: command.id, accepted: false, state: 'rejected', reason: preview.checks.filter(check => check.status === 'fail').map(check => check.label).join(', '), remediation: 'Resolve failed preview checks and retry', acknowledgedAt: Date.now() }; return this.source.send(command); }
pause(): void { this.source.replay?.pause(); }
resume(): void { this.source.replay?.resume(); }
restart(): void { this.seen.clear(); this.sequences.clear(); this.journal.length = 0; this.source.replay?.restart(); }
setSpeed(speed: number): void { this.source.replay?.setSpeed(speed); }
step(milliseconds = 100): void { this.source.replay?.step(milliseconds); }
advance(steps = 1, milliseconds = 100): void { for (let index = 0; index < steps; index += 1) this.source.replay?.step(milliseconds); }
}
@@ -0,0 +1,13 @@
import type { CommandEnvelope, CommandReceipt } from './CommandEnvelope';
import type { EventEnvelope } from './EventEnvelope';
export interface DataSourceDescriptor { id: string; name: string; kind: 'simulation' | 'kaptan'; simulated: boolean; capabilities: string[] }
export interface DataPlaneSink { accept(event: EventEnvelope): void; reportError(error: Error): void }
export interface ReplayControls { pause(): void; resume(): void; restart(): void; setSpeed(speed: number): void; step(milliseconds?: number): void }
export interface DataSource {
readonly descriptor: DataSourceDescriptor;
connect(sink: DataPlaneSink): Promise<void>;
disconnect(): Promise<void>;
send(command: CommandEnvelope): Promise<CommandReceipt>;
replay?: ReplayControls;
}
@@ -0,0 +1,18 @@
import type { EntityKind, MissionState } from '../../domain/model';
export interface EventEnvelope<T = unknown> {
schemaVersion: 1;
eventId: string;
streamId: string;
sequence: number;
type: 'snapshot' | 'telemetry' | 'task-progress' | 'alert' | 'activity' | 'authority' | 'command-state' | 'source-status';
entity: { kind: EntityKind; id: string };
sourceTimestamp: number;
receivedTimestamp: number;
correlationId?: string;
causationId?: string;
quality: { stale: boolean; confidence: number; droppedSinceLast?: number };
payload: T;
}
export type SnapshotEvent = EventEnvelope<MissionState>;
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import { validateEvent } from './eventValidation';
describe('event validation', () => {
it.each([
[{}, 'Unsupported event schema'],
[{ schemaVersion: 1 }, 'Missing envelope identity'],
[{ schemaVersion: 1, eventId: 'a', streamId: 's', entity: {}, sequence: -1 }, 'Invalid monotonic sequence'],
])('rejects malformed envelopes', (value, reason) => expect(validateEvent(value)).toEqual({ valid: false, reason }));
});
@@ -0,0 +1,14 @@
import type { EventEnvelope } from './EventEnvelope';
const eventTypes = new Set(['snapshot', 'telemetry', 'task-progress', 'alert', 'activity', 'authority', 'command-state', 'source-status']);
export function validateEvent(value: unknown): { valid: true; event: EventEnvelope } | { valid: false; reason: string } {
if (!value || typeof value !== 'object') return { valid: false, reason: 'Event is not an object' };
const event = value as Partial<EventEnvelope>;
if (event.schemaVersion !== 1) return { valid: false, reason: 'Unsupported event schema' };
if (!event.eventId || !event.streamId || !event.entity) return { valid: false, reason: 'Missing envelope identity' };
if (!Number.isSafeInteger(event.sequence) || (event.sequence ?? -1) < 0) return { valid: false, reason: 'Invalid monotonic sequence' };
if (!event.type || !eventTypes.has(event.type)) return { valid: false, reason: 'Unknown event type' };
if (!Number.isFinite(event.sourceTimestamp) || !Number.isFinite(event.receivedTimestamp)) return { valid: false, reason: 'Invalid timestamps' };
if (!event.quality || typeof event.quality.stale !== 'boolean') return { valid: false, reason: 'Missing quality metadata' };
return { valid: true, event: event as EventEnvelope };
}
@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest';
import { PreferenceStore, defaultPreferences, hotkeyConflicts, migratePreferences } from './preferences';
describe('operator preference persistence', () => {
it('migrates schema 1 and fills new layer defaults', () => { const next = migratePreferences({ schemaVersion: 1, layers: { searchAreas: false }, hotkeys: { alpha: 'a' } }); expect(next.schemaVersion).toBe(2); expect(next.layers.searchAreas).toBe(false); expect(next.layers.constraints).toBe(true); expect(next.hotkeys.alpha).toBe('a'); });
it('recovers from corrupt storage', () => { const storage = { getItem: () => '{broken', setItem: () => {}, removeItemCalls: 0, removeItem() { this.removeItemCalls++; } }; const value = new PreferenceStore(storage).load(); expect(value).toEqual(defaultPreferences()); expect(storage.removeItemCalls).toBe(1); });
it('detects configurable key conflicts', () => expect(hotkeyConflicts({ alpha: '1', bravo: '1', clear: 'escape' })).toEqual(['alpha', 'bravo']));
});
@@ -0,0 +1,23 @@
export const LAYERS = ['searchAreas', 'currentRoutes', 'plannedRoutes', 'constraints', 'noFlyZones', 'communicationCoverage', 'pointsOfInterest', 'mapLabels', 'operatorPresence', 'eventLocations'] as const;
export type LayerId = typeof LAYERS[number];
export interface Preferences {
schemaVersion: 2; layers: Record<LayerId, boolean>; railCollapsed: boolean; inspectorCollapsed: boolean; density: 'comfortable' | 'compact'; reducedMotion: boolean;
viewport: { x: number; y: number; scale: number }; hotkeys: Record<string, string>; lastWorkspace: string; draftIntentIds: string[];
}
const defaultLayers = Object.fromEntries(LAYERS.map(layer => [layer, true])) as Record<LayerId, boolean>;
export const defaultPreferences = (): Preferences => ({ schemaVersion: 2, layers: { ...defaultLayers }, railCollapsed: false, inspectorCollapsed: false, density: 'comfortable', reducedMotion: globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false, viewport: { x: 0, y: 0, scale: 1 }, hotkeys: { alpha: '1', bravo: '2', charlie: '3', delta: '4', reserve: '5', frame: 'f', layers: 'l', queue: 'q', attention: ' ', clear: 'escape' }, lastWorkspace: 'mission', draftIntentIds: [] });
export function migratePreferences(value: unknown): Preferences {
const defaults = defaultPreferences();
if (!value || typeof value !== 'object') return defaults;
const input = value as Omit<Partial<Preferences>, 'schemaVersion'> & { schemaVersion?: number };
if (input.schemaVersion !== 1 && input.schemaVersion !== 2) return defaults;
return { ...defaults, ...input, schemaVersion: 2, layers: { ...defaults.layers, ...(input.layers ?? {}) }, viewport: { ...defaults.viewport, ...(input.viewport ?? {}) }, hotkeys: { ...defaults.hotkeys, ...(input.hotkeys ?? {}) }, draftIntentIds: Array.isArray(input.draftIntentIds) ? input.draftIntentIds.filter(id => typeof id === 'string') : [] };
}
export class PreferenceStore {
static readonly key = 'kaptan.ground-suite.preferences';
constructor(private readonly storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> = localStorage) {}
load(): Preferences { try { const raw = this.storage.getItem(PreferenceStore.key); return raw ? migratePreferences(JSON.parse(raw)) : defaultPreferences(); } catch { this.storage.removeItem(PreferenceStore.key); return defaultPreferences(); } }
save(preferences: Preferences): void { this.storage.setItem(PreferenceStore.key, JSON.stringify(migratePreferences(preferences))); }
reset(): Preferences { this.storage.removeItem(PreferenceStore.key); return defaultPreferences(); }
}
export function hotkeyConflicts(hotkeys: Record<string, string>): string[] { const entries = Object.entries(hotkeys).map(([action, key]) => [action, key.trim().toLowerCase()] as const).filter(([, key]) => key); const counts = new Map<string, number>(); for (const [, key] of entries) counts.set(key, (counts.get(key) ?? 0) + 1); return entries.filter(([, key]) => (counts.get(key) ?? 0) > 1).map(([action]) => action); }
+41
View File
@@ -0,0 +1,41 @@
*{box-sizing:border-box}html,body,#root{width:100%;height:100%;margin:0;overflow:hidden}body{font:12px/1.35 var(--font);color:var(--text);background:var(--surface-0)}button,input,select{font:inherit}button{min-height:32px;border:1px solid var(--border);color:var(--text);background:#12202e;cursor:pointer}button:hover{border-color:#4a6582;background:#182a3c}button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--cyan);outline-offset:1px}button:disabled{opacity:.38;cursor:not-allowed}svg{width:20px;height:20px}small{color:var(--muted);font-size:9px;letter-spacing:.04em}b,strong{font-weight:650}.skip-link{position:fixed;z-index:9000;top:-80px;left:10px;padding:10px;background:#fff;color:#000}.skip-link:focus{top:10px}kbd{padding:1px 4px;border:1px solid #41546a;border-radius:2px;color:#8fa7bd;background:#0a121b;font:8px var(--mono)}.primary{border-color:#328fde;background:#1875c7;color:white}.primary:hover{background:#2586da}.nominal{color:var(--green)!important}.attention{color:var(--amber)!important}.critical{color:var(--red)!important}.authority-ok{color:var(--green)}.authority-warn{color:var(--amber)}
.ops-shell{width:100vw;height:100vh;display:grid;grid-template-rows:42px minmax(0,1fr);background:var(--surface-0)}.status-strip{position:relative;z-index:100;display:grid;grid-template-columns:minmax(210px,1.2fr) repeat(4,minmax(105px,.62fr)) minmax(105px,.6fr) minmax(175px,.85fr);align-items:stretch;border-bottom:1px solid #314158;background:#07111b;box-shadow:0 3px 18px #0008}.mission-id,.strip-status,.clock,.alert-status{min-width:0;display:flex;align-items:center;gap:8px;padding:0 12px;border:0;border-right:1px solid var(--border-soft);background:transparent;text-align:left}.mission-id>i{width:25px;height:25px;display:grid;place-items:center;border:1px solid var(--cyan);transform:rotate(45deg);color:var(--cyan);font-style:normal;font-weight:800}.mission-id>i::first-letter{transform:rotate(-45deg)}.mission-id span,.strip-status span,.alert-status span{min-width:0;display:grid}.mission-id b,.strip-status b,.alert-status b,.clock b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:10px var(--mono)}.mission-id b{color:var(--cyan);font:700 12px var(--font)}.mission-id small{white-space:nowrap}.strip-status svg,.alert-status svg{width:17px;color:currentColor}.strip-status.nominal b::before,.strip-status.attention b::before{content:"";display:inline-block;width:6px;height:6px;margin-right:6px;border-radius:50%;background:currentColor}.strip-status.simulation{color:var(--violet)}.alert-status{color:var(--amber)}.alert-status:hover{background:#2b220d}.clock{display:grid;place-content:center}.clock i{display:inline-block;width:1px;height:9px;margin:0 8px;background:var(--border)}.sim-watermark{position:absolute;right:8px;bottom:-16px;padding:2px 7px;border:1px solid #745ba0;border-top:0;background:#211834;color:#caa8f5;font:8px var(--mono);letter-spacing:.08em}
.spatial-scene{position:relative;min-height:0;display:grid;grid-template-columns:48px 218px minmax(420px,1fr) 286px;grid-template-rows:minmax(0,1fr) 122px 32px;grid-template-areas:"rail roster map inspector" "rail deck deck inspector" "rail lower lower inspector";overflow:hidden}.tool-rail{z-index:50;grid-area:rail;display:flex;flex-direction:column;justify-content:space-between;border-right:1px solid var(--border);background:#09131e}.tool-rail>div{display:grid}.tool-rail button{position:relative;width:47px;height:43px;display:grid;place-items:center;border:0;border-bottom:1px solid var(--border-soft);background:transparent;color:#91a6ba}.tool-rail button:hover{background:#152333;color:#dbe7f0}.tool-rail button.active{box-shadow:inset 3px 0 var(--cyan);background:#102b3d;color:var(--cyan)}.tool-rail kbd{position:absolute;right:2px;bottom:2px;padding:0 2px;border:0;background:transparent;color:#71879b}.app-tools button:nth-child(6){color:var(--amber)}
.roster-drawer{z-index:30;grid-area:roster;min-height:0;overflow:auto;border-right:1px solid var(--border);background:rgba(10,20,30,.98)}.roster-drawer>header{height:45px;display:flex;align-items:center;justify-content:space-between;padding:0 10px;border-bottom:1px solid var(--border)}.roster-drawer>header span{display:grid}.roster-drawer>header button{width:28px;min-height:28px;border:0;background:transparent}.roster-drawer>header svg{width:15px}.control-groups{padding:6px}.control-groups>button{position:relative;width:100%;height:39px;display:grid;grid-template-columns:25px 1fr;align-items:center;gap:7px;margin-bottom:3px;padding:4px 7px;overflow:hidden;border-color:transparent;background:#0d1824;text-align:left}.control-groups>button.active{border-color:#357da7;background:#112536}.control-groups>button>span{display:grid}.control-groups>button>i{position:absolute;left:0;bottom:0;width:var(--p);height:2px;background:var(--cyan)}.roster-section{padding:6px;border-top:1px solid var(--border)}.roster-section>header,.smart-filters>b{height:24px;display:flex;justify-content:space-between;align-items:center;color:#8da0b3;font-size:9px;letter-spacing:.08em}.roster-group{width:100%;height:47px;display:grid;grid-template-columns:32px 1fr 34px;align-items:center;gap:7px;margin-bottom:4px;padding:4px 7px;border-color:#1f3043;background:#0e1925;text-align:left}.roster-group.selected{border-color:#3c92c2;box-shadow:inset 2px 0 var(--cyan);background:#102333}.roster-group>span{min-width:0;display:grid}.roster-group>span:last-child{text-align:right}.roster-group strong{color:var(--cyan)}.group-glyph{width:29px;height:29px;display:grid!important;place-items:center;border:1px solid currentColor;clip-path:polygon(50% 0,94% 25%,94% 75%,50% 100%,6% 75%,6% 25%);color:var(--cyan);background:#102636;font-weight:800}.group-glyph.attention{color:var(--amber)}.smart-filters{padding:7px;border-top:1px solid var(--border)}.smart-filters>button{width:100%;height:32px;display:flex;align-items:center;justify-content:space-between;margin-bottom:3px;padding:0 8px;background:#0d1925}.smart-filters>button i{min-width:22px;height:18px;display:grid;place-items:center;border-radius:9px;background:#203348;font-style:normal;color:var(--cyan)}.smart-filters>button:last-child{justify-content:flex-start;gap:8px;color:var(--cyan)}.smart-filters svg{width:15px}
.map-stage{position:relative;z-index:10;grid-area:map;min-width:0;min-height:0;overflow:hidden}.operational-map{position:absolute;inset:0;overflow:hidden;touch-action:none;user-select:none;background:#08131c}.map-grid{position:absolute;inset:0;z-index:2;pointer-events:none;background-image:linear-gradient(#3e61720c 1px,transparent 1px),linear-gradient(90deg,#3e61720c 1px,transparent 1px);background-size:42px 42px}.map-world{position:absolute;inset:0;transform-origin:center;will-change:transform}.map-raster{position:absolute;inset:-2%;background-image:linear-gradient(rgba(4,13,21,.17),rgba(4,13,21,.43)),url('/assets/coastal-map.png');background-position:center;background-size:cover;filter:saturate(.62) contrast(1.08) brightness(.72)}.map-geometry{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none}.map-label{position:absolute;display:grid;padding:5px 7px;border:1px solid #307ba4;background:#091724dc;color:var(--cyan);font-size:8px;letter-spacing:.06em}.map-label small{color:#9dc6db}.constraint-label{border-color:#a23c45;color:#f08186;background:#260f15dc}.group-map-marker,.vehicle-map-marker{position:absolute;z-index:8;min-height:0;border:0;background:transparent}.group-map-marker{display:flex;align-items:center;gap:6px;padding:0;transform:translate(-50%,-50%);text-align:left}.group-map-marker>i{width:30px;height:30px;display:grid;place-items:center;border:2px solid var(--group);border-radius:50%;box-shadow:0 0 0 4px #07131ccf;color:var(--group);background:#101b26;font-style:normal;font-weight:800}.group-map-marker>span{display:grid;padding:4px 7px;border:1px solid #334a60;border-left:2px solid var(--group);background:#07131de8}.group-map-marker.selected>i{box-shadow:0 0 0 5px color-mix(in srgb,var(--group),transparent 70%),0 0 0 8px #07131ccc}.vehicle-map-marker{width:17px;height:17px;display:grid;place-items:center;padding:0;color:#d8e4ed;filter:drop-shadow(0 1px 2px #000)}.vehicle-map-marker svg{width:16px;height:16px;fill:#0c1a25;stroke:currentColor;stroke-width:2.2}.vehicle-map-marker span{position:absolute;top:17px;left:50%;display:none;padding:1px 3px;transform:translateX(-50%) rotate(calc(-1 * var(--heading,0deg)));background:#08131cdd;white-space:nowrap;color:#fff;font:6px var(--mono)}.vehicle-map-marker:hover span,.vehicle-map-marker.selected span{display:block}.vehicle-map-marker.selected{z-index:15;color:var(--cyan)}.vehicle-map-marker.selected::before{content:"";position:absolute;inset:-5px;border:1px solid var(--cyan);border-radius:50%;animation:pulse 2s infinite}.vehicle-map-marker.attention{color:var(--amber)}.vehicle-map-marker.critical,.vehicle-map-marker.unavailable{color:var(--red)}.vehicle-map-marker.stale{opacity:.55}.draft-shape path,.draft-shape polygon,.draft-shape circle{fill:#a879e21c;stroke:var(--violet);stroke-width:.45;stroke-dasharray:1.2 .7;vector-effect:non-scaling-stroke}.draft-shape.review path,.draft-shape.review polygon,.draft-shape.review circle{stroke:#6ed5f4;fill:#45c8ef17}.draft-shape.invalid path,.draft-shape.invalid polygon{stroke:var(--red)}.draft-handle{fill:#111d29;stroke:#90dff6;stroke-width:.3}.draft-shape text,.formation-slot text{fill:#fff;font-size:1.1px;text-anchor:middle}.formation-slot circle{fill:#342b4b;stroke:var(--violet);stroke-width:.25;stroke-dasharray:.6 .3}.selection-marquee{position:absolute;z-index:40;border:1px solid var(--cyan);background:#45c8ef14;pointer-events:none}.map-mode-hud{position:absolute;z-index:20;top:10px;left:10px;max-width:310px;display:grid;grid-template-columns:auto 1fr;gap:0 8px;padding:6px 9px;border-left:2px solid var(--cyan);background:#07131deb;box-shadow:0 5px 20px #0008}.map-mode-hud>span{grid-row:span 2;color:var(--cyan);font:8px var(--mono);writing-mode:vertical-rl}.map-mode-hud>b{font-size:11px;text-transform:uppercase}.map-mode-hud small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.map-scale{position:absolute;z-index:20;left:12px;bottom:10px;display:grid;color:#c4d0d9;font:8px var(--mono)}.map-scale i{width:72px;height:6px;border:1px solid #bccbd7;border-top:0}.map-zoom-controls{position:absolute;z-index:20;right:10px;top:10px;display:grid;gap:3px}.map-zoom-controls button{width:32px;min-height:30px;padding:0;background:#08141fe8;font-size:18px}.minimap{position:absolute;z-index:20;right:10px;bottom:10px;width:142px;height:86px;overflow:hidden;padding:0;border-color:#4b6075;background:linear-gradient(145deg,#142e3c,#0a1a25);text-align:left}.minimap::before{content:"";position:absolute;left:-12px;top:-16px;width:85px;height:120px;border-radius:48%;background:#25392f;transform:rotate(18deg);opacity:.8}.mini-coast{position:absolute;inset:0;background:repeating-linear-gradient(45deg,transparent 0 8px,#b8d0dc0b 8px 9px)}.mini-task{position:absolute;left:35%;top:25%;width:38%;height:36%;border:1px solid var(--cyan);background:#45c8ef18}.mini-alert{position:absolute;right:11%;top:20%;width:9px;height:9px;border:2px solid var(--red);transform:rotate(45deg)}.mini-viewport{poLine truncated
.selection-inspector{z-index:25;grid-area:inspector;min-width:0;min-height:0;overflow:auto;border-left:1px solid var(--border);background:#0b1621}.selection-inspector>header{height:47px;display:flex;align-items:center;justify-content:space-between;padding:0 11px;border-bottom:1px solid var(--border)}.selection-inspector>header>span{display:grid}.selection-inspector>header>span:last-child{display:block;padding:3px 5px;border:1px solid currentColor;font:8px var(--mono)}.inspector-empty{display:grid;place-items:center;gap:15px;height:70%;padding:30px;text-align:center;color:var(--muted)}.inspector-empty svg{width:38px;height:38px}.truth-banner{display:grid;grid-template-columns:36px 1fr auto;align-items:center;gap:8px;padding:10px;border-bottom:1px solid var(--border)}.truth-banner svg{width:31px;height:31px;color:var(--cyan)}.truth-banner span{display:grid}.truth-banner strong{padding:3px 5px;border:1px solid var(--green);color:var(--green);font-size:8px;text-transform:uppercase}.position-readout{display:grid;grid-template-columns:1fr 1fr;border-bottom:1px solid var(--border)}.position-readout>div{display:grid;gap:2px;padding:9px;border-right:1px solid var(--border);font:9px var(--mono)}.telemetry-chart{padding:8px 10px;border-bottom:1px solid var(--border)}.telemetry-chart>header,.histogram>header,.availability>header,.exceptions>header{display:flex;align-items:center;justify-content:space-between}.telemetry-chart>header b{color:#d7e4ee;font:10px var(--mono)}.telemetry-chart svg{width:100%;height:55px;margin-top:4px;overflow:visible;background:linear-gradient(transparent 49%,#30405255 50%,transparent 51%)}.telemetry-chart polyline{fill:none;stroke-width:1.3;vector-effect:non-scaling-stroke}.telemetry-chart .threshold{stroke:var(--amber);stroke-width:.5;stroke-dasharray:2 2;vector-effect:non-scaling-stroke}.telemetry-chart .gap{stroke:#ef5b6177;stroke-width:2;stroke-dasharray:1 1}.telemetry-chart>footer,.histogram>footer{display:flex;justify-content:space-between;color:#71869a;font:7px var(--mono)}.subsystem-grid,.distribution-summary{display:grid;grid-template-columns:1fr 1fr;border-bottom:1px solid var(--border)}.subsystem-grid>div,.distribution-summary>div{display:grid;gap:2px;padding:8px;border-right:1px solid var(--border);border-bottom:1px solid var(--border-soft)}.subsystem-grid b,.distribution-summary b{color:var(--text);font:9px var(--mono)}.configure-button{width:calc(100% - 20px);margin:10px;display:flex;align-items:center;justify-content:center;gap:7px;color:var(--cyan)}.configure-button svg{width:15px}.distribution-summary{grid-template-columns:repeat(3,1fr)}.distribution-summary>div{text-align:center}.distribution-summary b{font-size:15px;color:var(--green)}.histogram{padding:10px;border-bottom:1px solid var(--border)}.histogram>div{height:51px;display:flex;align-items:flex-end;gap:5px;padding-top:14px}.histogram>div i{position:relative;flex:1;min-height:2px;background:linear-gradient(#45c8ef,#2472a8)}.histogram em{position:absolute;top:-13px;left:50%;transform:translateX(-50%);color:#bcd0dc;font:7px var(--mono)}.availability{padding:10px;border-bottom:1px solid var(--border)}.availability>div{height:6px;display:flex;margin-top:7px}.availability i.nominal{background:var(--green)}.availability i.attention{background:var(--amber)}.exceptions{padding:10px}.exceptions>button{width:100%;display:grid;grid-template-columns:20px 1fr auto;align-items:center;gap:6px;padding:6px;border:0;border-top:1px solid var(--border);background:transparent;text-align:left}.exceptions svg{width:15px;color:var(--amber)}.exceptions span{display:grid}.exceptions em{color:var(--cyan);font:7px var(--mono)}
.selection-deck{z-index:35;grid-area:deck;min-width:0;display:grid;grid-template-columns:minmax(0,1.35fr) minmax(430px,1fr);border-top:1px solid #34475d;background:#0d1823;box-shadow:0 -8px 24px #0005}.unit-card-strip{min-width:0;display:flex;gap:4px;padding:7px;overflow-x:auto;border-right:1px solid var(--border)}.unit-card{position:relative;min-width:112px;height:106px;display:grid;grid-template-columns:25px 1fr;grid-template-rows:31px 1fr;gap:3px;padding:8px;background:#111f2c;text-align:left}.unit-card.focused{border-color:var(--cyan);box-shadow:inset 0 -2px var(--cyan)}.unit-card.attention{border-color:#7a5f24}.unit-card>svg{width:23px;color:#b9c9d4}.unit-card.focused>svg{color:var(--cyan)}.unit-card>span{min-width:0;display:grid}.unit-card>span b,.unit-card>span small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.unit-card>em{position:absolute;right:4px;top:4px;width:16px;height:16px;display:grid;place-items:center;border-radius:8px;background:#32274c;color:#cfaff5;font:7px var(--mono)}.card-meters{grid-column:1/-1;display:grid;align-content:end;gap:4px}.card-meters>i{height:4px;background:linear-gradient(90deg,var(--green) var(--v),#273547 var(--v))}.card-meters>i:nth-child(2){background:linear-gradient(90deg,var(--cyan) var(--v),#273547 var(--v))}.no-selection{display:flex;align-items:center;gap:10px;padding:15px;color:var(--muted)}.ability-bar{min-width:0;display:grid;grid-template-rows:38px 1fr}.ability-bar>header{display:flex;align-items:center;justify-content:space-between;padding:0 9px;border-bottom:1px solid var(--border)}.ability-bar>header>span:first-child{display:flex;gap:8px;align-items:baseline}.ability-bar>header>span:last-child{padding:2px 5px;border:1px solid currentColor;font:7px var(--mono)}.ability-bar nav{display:grid;grid-template-columns:repeat(6,1fr);gap:4px;padding:5px}.ability-bar button{display:grid;place-items:center;align-content:center;gap:3px;padding:2px;background:#142331;font-size:8px}.ability-bar button.active{border-color:var(--cyan);color:var(--cyan);background:#102b3b}.ability-bar svg{width:18px;height:18px}.lower-drawer{z-index:55;grid-area:lower;min-width:0;background:#0b1621;border-top:1px solid var(--border)}.lower-drawer>header{height:32px;display:flex;align-items:center;justify-content:space-between}.lower-drawer>header nav{height:100%;display:flex}.lower-drawer>header nav button{min-height:31px;border:0;border-right:1px solid var(--border);background:transparent;font:8px var(--mono)}.lower-drawer>header nav button.active{color:var(--cyan);box-shadow:inset 0 -2px var(--cyan)}.lower-drawer>header nav i{padding:1px 4px;border-radius:7px;background:#203347;font-style:normal}.lower-drawer>header>span{color:#6f8397;font:7px var(--mono)}.lower-drawer>header>button{width:34px;min-height:30px;border:0;background:transparent}.lower-drawer>header svg{width:14px}.lower-drawer.open{position:absolute;z-index:80;left:48px;right:286px;bottom:0;height:230px;box-shadow:0 -18px 45px #000a}.lower-drawer.open>header>button svg{transform:rotate(180deg)}.queue-drawer,.event-drawer{display:flex;gap:6px;padding:8px;overflow-x:auto}.queue-drawer article{min-width:195px;height:172px;display:grid;grid-template-columns:26px 1fr;align-content:start;gap:9px;padding:10px;border:1px solid var(--border);background:#111f2c}.queue-drawer article.executing{border-color:var(--blue)}.queue-drawer article>i{width:25px;height:25px;display:grid;place-items:center;border:1px solid #496078;font-style:normal}.queue-drawer article>span{display:grid}.queue-drawer article>em,.queue-drawer article>strong{grid-column:1/-1;padding:5px;border-top:1px solid var(--border);font-style:normal;text-transform:uppercase}.queue-drawer article>em{color:var(--violet)}.queue-drawer article>strong{color:var(--green)}.event-drawer{display:grid;gap:0}.event-drawer article{height:41px;display:grid;grid-template-columns:70px 1fr 75px 60px;align-items:center;gap:8px;padding:4px 9px;border-bottom:1px solid var(--border);border-left:2px solid var(--blue)}.event-drawer article.attention{border-left-color:var(--amber)}.event-drawer article.critical{border-left-color:var(--red)}.event-drawer time{font:8px var(--mono);color:#8ea2b6}.event-drawer span{display:grid}.event-drawer em{color:var(--muted);font:8px var(--mono)}.event-drawer button{min-height:25px;font-size:8px}.telemetry-drawer{height:198px;display:grid;grid-template-columns:1fr 1fr .9fr}.telemetry-drawer>.telemetry-chart{border-right:1px solid var(--border)}.mode-timeline{padding:15px}.mode-timeline>div,.lifecycle,.recovery-chain>div{display:flex;align-items:center;margin-top:18px}.mode-timeline span,.lifecycle span,.recovery-chain span{padding:4px;border:1px solid #405269;color:#8396a8;font:7px var(--mono)}.mode-timeline span.done,.lifecycle span.done{border-color:var(--green);color:var(--green)}.mode-timeline span.current,.lifecycle span.current{border-color:var(--cyan);color:var(--cyan)}.mode-timeline i,.lifecycle i,.recovery-chain i{flex:1;height:1px;backgLine truncated
.context-palette,.compact-drawer,.right-workbench{position:absolute;z-index:70;border:1px solid #40556c;background:#0d1925f5;box-shadow:0 18px 55px #000b}.context-palette{right:12px;bottom:12px;width:210px;padding:6px}.context-palette header,.compact-drawer>header,.right-workbench>header,.deployment-deck>header{display:flex;align-items:center;justify-content:space-between;padding:7px;border-bottom:1px solid var(--border)}.context-palette header button,.compact-drawer>header button,.right-workbench>header button{width:27px;min-height:27px;border:0;background:transparent}.context-palette header svg,.compact-drawer>header svg,.right-workbench>header svg{width:14px}.context-palette>button{width:100%;display:grid;grid-template-columns:24px 1fr;align-items:center;gap:6px;padding:6px;border:0;border-bottom:1px solid var(--border);background:transparent;text-align:left}.context-palette>button span{display:grid}.compact-drawer{top:10px;left:10px;width:300px;max-height:calc(100% - 20px);overflow:auto}.compact-drawer>header span,.right-workbench>header span,.deployment-deck>header span{display:grid}.layer-list,.settings-list{padding:7px}.layer-list label{height:38px;display:grid;grid-template-columns:20px 1fr auto;align-items:center;border-bottom:1px solid var(--border-soft)}.layer-list label small{font-size:7px}.settings-list>label{min-height:42px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border)}.settings-list select,.parameter-view input{min-height:30px;padding:4px;border:1px solid var(--border);background:#07121b;color:var(--text)}.keymap{display:grid;gap:8px;padding:13px 0}.keymap span{display:flex;justify-content:space-between}.right-workbench{top:0;right:0;width:min(520px,78%);height:100%;overflow:auto;border-top:0;border-bottom:0}.right-workbench>header{height:48px;padding:0 12px}.recovery-workbench>article{display:grid;grid-template-columns:27px 1fr auto;align-items:center;gap:8px;margin:8px;padding:10px;border:1px solid #6d5525;background:#2a210d}.recovery-workbench>article svg{color:var(--amber)}.recovery-workbench>article span{display:grid}.recovery-workbench>article em{font:7px var(--mono);color:var(--amber)}.recovery-chain{margin:14px;padding:14px;border:1px solid var(--border)}.recovery-chain>div{margin:12px 0}.recovery-chain p{color:var(--muted)}.commissioning-workbench>nav{display:grid;grid-template-columns:repeat(4,1fr);border-bottom:1px solid var(--border)}.commissioning-workbench>nav button{border:0;border-right:1px solid var(--border);background:transparent;font-size:8px}.commissioning-workbench>nav button.active{color:var(--cyan);box-shadow:inset 0 -2px var(--cyan)}.lifecycle{padding:10px;margin:0}.readiness-view{display:grid;grid-template-columns:150px 1fr;gap:10px;padding:14px}.readiness-ring{position:relative;width:140px;height:140px}.readiness-ring svg{width:100%;height:100%;transform:rotate(-90deg)}.readiness-ring circle{fill:none;stroke:#26384a;stroke-width:7}.readiness-ring .progress-ring{stroke:var(--cyan);stroke-dasharray:190 264;stroke-linecap:round}.readiness-ring>span{position:absolute;inset:0;display:grid;place-content:center;text-align:center}.readiness-ring b{font:25px var(--mono)}.dependency-graph{display:grid;grid-template-columns:1fr 1fr;gap:5px}.dependency-graph button{display:flex;align-items:center;gap:7px;text-align:left}.dependency-graph button>i{width:9px;height:9px;border-radius:50%;background:var(--green)}.dependency-graph button>span{display:grid}.dependency-graph button.blocker>i{background:var(--red);clip-path:polygon(50% 0,100% 100%,0 100%);border-radius:0}.dependency-graph button.warning>i{background:var(--amber)}.blocker-box{grid-column:1/-1;display:flex;gap:8px;padding:10px;border:1px solid #8a3139;background:#2a1116;color:var(--red)}.blocker-box span{display:grid}.orientation-model{position:relative;height:145px;display:grid;place-items:center;margin:14px;border:1px solid var(--border);background:radial-gradient(circle,#1a3040,#0c1721 65%)}.orientation-model svg{width:110px;height:110px;color:var(--cyan);transform:rotate(30deg)}.orientation-model .axis{position:absolute;font:9px var(--mono)}.axis.x{right:15%;top:50%;color:var(--red)}.axis.y{left:48%;top:8%;color:var(--green)}.axis.z{left:28%;bottom:15%;color:var(--blue)}.sensor-list{display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:10px}.sensor-list span{padding:6px;border:1px solid currentColor;background:#112018}.sensor-list span.attention{background:#2b210d}.hold-control{width:calc(100% - 20px);height:42px;margin:10px;border-color:var(--amber);color:var(--amber)}.radio-view{padding:14px}.stick-pair{display:flex;justify-content:center;gap:18px;margin-bottom:15px}.stick-pair>i{position:relative;width:90px;height:90px;border:1px solid #4e6277;border-radius:50%;background:repeating-radial-gradient(circle,transparent 0 15px,#34475b55 16px)}.stick-pair b{position:absolute;left:44%;top:34%;width:10px;height:10px;border-radius:50%;background:var(--cyan);box-sLine truncated
.deployment-active{display:block}.deployment-deck{height:100%;display:grid;grid-template-columns:minmax(250px,.75fr) minmax(390px,1.5fr) minmax(320px,1fr);grid-template-areas:"head tiles controls"}.deployment-deck>header{grid-area:head;display:grid;align-content:center;padding:8px 12px;border-right:1px solid var(--border)}.deployment-deck>header nav{display:flex;margin-top:7px}.deployment-deck>header nav button{min-height:25px;padding:0 8px;border:0;border-bottom:1px solid var(--border);background:transparent;font-size:8px}.deployment-deck>header nav button.active{color:var(--cyan);border-color:var(--cyan)}.archetype-tiles{grid-area:tiles;display:flex;gap:4px;padding:6px;overflow-x:auto}.archetype-tiles>button{min-width:125px;display:flex;align-items:center;gap:6px;padding:7px;text-align:left}.archetype-tiles>button.active{border-color:var(--cyan);background:#102b3b}.archetype-tiles svg{width:29px;color:var(--cyan)}.archetype-tiles span{display:grid}.placement-controls{grid-area:controls;display:grid;grid-template-columns:1fr 1fr;gap:5px;padding:7px;border-left:1px solid var(--border)}.placement-controls label{display:grid;grid-template-columns:1fr 80px;align-items:center;font-size:8px}.placement-controls select{height:27px;border:1px solid var(--border);background:#09131d;color:var(--text)}.placement-controls input{width:80px}.placement-controls button{grid-column:1/-1}.discover-row{grid-column:2/-1;display:flex;gap:5px;padding:7px}.discover-row>button{flex:1;display:grid;grid-template-columns:27px 1fr;align-items:center;gap:6px;text-align:left}.discover-row span{display:grid}.discover-row em{grid-column:1/-1;font-size:8px}.import-drop{grid-column:2/-1;display:flex;align-items:center;justify-content:center;gap:12px}.import-drop>span{display:grid}.import-drop>svg{width:32px;color:var(--cyan)}
.review-strip{position:absolute;z-index:110;left:48px;right:286px;bottom:32px;min-height:104px;display:grid;grid-template-columns:220px 1fr 245px;align-items:stretch;border:1px solid #5291a9;border-left:4px solid var(--cyan);background:#0d1b27f8;box-shadow:0 -15px 50px #000b}.review-strip>header{display:flex;align-items:center;gap:9px;padding:12px;border-right:1px solid var(--border)}.review-strip>header svg{width:28px;color:var(--cyan)}.review-strip>header span{display:grid}.review-metrics{display:grid;grid-template-columns:repeat(5,1fr)}.review-metrics>div{display:grid;place-content:center;gap:4px;padding:8px;border-right:1px solid var(--border)}.review-metrics b{color:var(--text);font:9px var(--mono)}.review-strip>footer{display:flex;align-items:center;justify-content:flex-end;gap:5px;padding:10px}.review-strip>footer button{padding:0 10px;font-size:9px}.toast{position:fixed;z-index:1500;right:18px;bottom:18px;max-width:500px;display:flex;align-items:center;gap:8px;padding:11px;border:1px solid #3f8497;background:#122631;box-shadow:0 18px 50px #000c}.toast>svg{color:var(--green)}.toast>button{width:28px;min-height:27px;margin-left:auto;border:0;background:transparent}.toast>button svg{width:14px}
.density-compact .unit-card{height:94px}.density-compact .control-groups>button{height:35px}.reduced-motion *,.reduced-motion *::before,.reduced-motion *::after{animation:none!important;transition:none!important}@keyframes pulse{50%{box-shadow:0 0 0 4px #45c8ef3d}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}
@media(max-width:1450px){.status-strip{grid-template-columns:minmax(195px,1.1fr) repeat(4,minmax(96px,.55fr)) minmax(100px,.55fr) minmax(155px,.78fr)}.mission-id,.strip-status,.clock,.alert-status{padding-inline:8px}.spatial-scene{grid-template-columns:46px 202px minmax(410px,1fr) 270px}.lower-drawer.open,.review-strip{left:46px;right:270px}.tool-rail button{width:45px}.roster-drawer{font-size:11px}.selection-deck{grid-template-columns:minmax(0,1fr) minmax(400px,.9fr)}.unit-card{min-width:105px}.ability-bar button{font-size:7px}.telemetry-chart svg{height:48px}}
@media(min-width:2000px){.spatial-scene{grid-template-columns:50px 238px minmax(700px,1fr) 330px;grid-template-rows:minmax(0,1fr) 132px 34px}.lower-drawer.open,.review-strip{left:50px;right:330px}.selection-inspector{font-size:13px}.map-mode-hud{transform:scale(1.08);transform-origin:top left}.unit-card{min-width:132px}}
@media(max-width:1100px){.status-strip{grid-template-columns:minmax(190px,1.2fr) repeat(3,minmax(90px,.7fr)) minmax(105px,.7fr)}.strip-status:nth-of-type(4),.clock{display:none}.spatial-scene{grid-template-columns:46px 182px minmax(420px,1fr);grid-template-areas:"rail roster map" "rail deck deck" "rail lower lower"}.selection-inspector{display:none}.lower-drawer.open,.review-strip{right:0}.selection-deck{grid-template-columns:minmax(0,1fr) minmax(360px,.9fr)}}
.right-workbench{width:min(440px,65%);background:#0d1925}.mode-palette{position:absolute;z-index:72;left:10px;top:10px;width:370px;max-height:calc(100% - 20px);overflow:auto;border:1px solid #40556c;background:#0d1925;box-shadow:0 18px 55px #000b}.mode-palette>header{height:44px;display:flex;align-items:center;justify-content:space-between;padding:6px 9px;border-bottom:1px solid var(--border)}.mode-palette>header span{display:grid}.mode-palette>header button{width:28px;min-height:27px;border:0;background:transparent}.mode-palette>header svg{width:14px}.mode-palette>section{padding:7px;border-bottom:1px solid var(--border)}.mode-palette>section>div{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin-top:5px}.mode-palette>section button{min-height:34px;display:flex;align-items:center;gap:5px;padding:5px;text-align:left;font-size:9px}.mode-palette>section button.active{border-color:var(--cyan);color:var(--cyan);background:#102b3b}.mode-palette>section svg{width:14px;height:14px}
/* Reference-aligned operations composition: one roster, one tool rail, readable hierarchy. */
body{font-size:13px;letter-spacing:.005em}small{font-size:10px;line-height:1.25}button{min-height:38px}.ops-shell{grid-template-rows:48px minmax(0,1fr)}.status-strip{display:flex;align-items:stretch;background:#07111b;border-bottom-color:#30465d}.mission-id{flex:0 0 230px;padding:0 15px}.mission-id>i{width:28px;height:28px}.mission-id b{font-size:13px}.strip-status{flex:0 0 120px;padding:0 11px}.strip-status b,.alert-status b,.clock b{font-size:10px}.strip-status small,.alert-status small,.clock small{font-size:9px}.alert-status{flex:0 0 135px;margin-left:auto;padding:0 12px;border-left:1px solid var(--border-soft)}.clock{flex:0 0 185px;padding:0 14px}.sim-watermark{right:10px;bottom:-18px;padding:3px 9px;font-size:8px}
.spatial-scene{grid-template-columns:52px 230px minmax(430px,1fr) 300px;grid-template-rows:minmax(0,1fr) 134px 36px}.tool-rail{background:#09131e}.tool-rail button{width:51px;height:48px;color:#9cafbf}.tool-rail button.active{box-shadow:inset 3px 0 var(--cyan),inset 0 0 24px #2d8ff018;background:#102739}.tool-rail button svg{width:21px;height:21px}.tool-rail button::after{content:attr(data-tooltip);position:absolute;z-index:100;left:58px;top:50%;padding:5px 8px;transform:translateY(-50%) translateX(-4px);border:1px solid #3d536a;background:#09131ef5;color:var(--text);font-size:10px;white-space:nowrap;opacity:0;pointer-events:none;transition:.12s ease}.tool-rail button:hover::after,.tool-rail button:focus-visible::after{opacity:1;transform:translateY(-50%) translateX(0)}.tool-rail kbd{font-size:8px}.app-tools{border-top:1px solid #2d4257}
.roster-drawer{background:#0a1520}.roster-drawer>header{height:58px;padding:0 13px;background:#0c1925}.roster-drawer>header>span:first-child{display:grid;gap:2px}.roster-drawer>header>span:first-child b{font-size:13px;letter-spacing:.04em}.local-group-chip{display:grid;padding:5px 7px;border:1px solid #3c607b;background:#102536;text-align:center}.local-group-chip small{font-size:8px;color:var(--cyan)}.local-group-chip b{font:9px var(--mono)}.force-roster{padding:8px}.force-row{position:relative;width:100%;height:66px;display:grid;grid-template-columns:26px 1fr 48px;grid-template-rows:1fr 4px;align-items:center;gap:0 8px;margin-bottom:6px;padding:7px 8px 4px;border-color:#263b50;background:#0e1b28;text-align:left;overflow:hidden}.force-row:hover{background:#132435}.force-row.selected{border-color:#368db9;box-shadow:inset 3px 0 var(--cyan);background:#11283a}.force-row.attention{border-color:#705a2b}.force-row>kbd{width:24px;padding:3px 0;text-align:center;color:#c8d5df}.force-identity{display:flex;align-items:center;gap:8px;min-width:0}.force-identity>i{width:28px;height:28px;display:grid;place-items:center;clip-path:polygon(50% 0,94% 25%,94% 75%,50% 100%,6% 75%,6% 25%);background:#15344a;color:var(--cyan);font-style:normal;font-weight:750}.force-row.attention .force-identity>i{background:#3a2e13;color:var(--amber)}.force-identity>span,.force-state{min-width:0;display:grid}.force-identity b{font-size:12px;letter-spacing:.025em}.force-identity small,.force-state small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.force-state{text-align:right}.force-state>b{color:var(--cyan);font:700 14px var(--mono)}.force-progress{grid-column:1/-1;height:3px;background:#223448}.force-progress>i{display:block;height:100%;background:var(--cyan)}.force-row.attention .force-progress>i{background:var(--amber)}.smart-filters{padding:10px;border-top:1px solid var(--border)}.smart-filters>header{display:flex;align-items:center;justify-content:space-between;height:28px}.smart-filters>header b{font-size:10px;letter-spacing:.08em}.smart-filters>div{display:grid;grid-template-columns:1fr 1fr;gap:6px}.smart-filters>div>button{height:46px;display:grid;grid-template-columns:19px 1fr auto;align-items:center;gap:5px;padding:6px;border-color:#2a4054;background:#0d1a26;text-align:left}.smart-filters>div>button svg{width:16px;color:#aebdca}.smart-filters>div>button>i{width:20px;height:20px;display:grid;place-items:center;border-radius:10px;background:#20384c;color:var(--cyan);font-style:normal;font-size:9px}.smart-filters .deploy-entry{height:52px;display:grid;grid-template-columns:24px 1fr auto;align-items:center;gap:8px;margin-top:8px;padding:7px 9px;border-color:#337da3;background:#102536;color:var(--cyan)}.deploy-entry svg{width:20px}.deploy-entry>span{display:grid;text-align:left}.deploy-entry>span b{color:var(--text)}.roster-drawer>footer{display:flex;justify-content:space-between;padding:10px 9px;border-top:1px solid var(--border);color:var(--muted);font-size:9px}.roster-drawer>footer span{display:grid;gap:3px;text-align:center}
.map-mode-hud{top:12px;left:12px;min-width:270px;padding:8px 10px;border-left-width:3px;background:#0a1825f2}.map-mode-hud>b{font-size:12px}.map-mode-hud small{font-size:9px}.group-map-marker>i{width:34px;height:34px}.group-map-marker>span{padding:6px 9px}.group-map-marker>span b{font-size:11px}.group-map-marker>span small{font-size:9px}.map-label{padding:6px 8px;font-size:9px}.map-scale,.coordinate-readout{font-size:8px}.minimap{width:154px;height:94px}.map-zoom-controls button{width:36px;min-height:34px}
.selection-inspector{background:#0a1621}.selection-inspector>header{height:56px;padding:0 13px}.selection-inspector>header b{font-size:13px}.selection-inspector>header>span:last-child{padding:4px 6px;font-size:9px}.distribution-summary>div{padding:11px 8px}.distribution-summary b{font-size:18px}.histogram{padding:12px}.histogram>div{height:62px}.histogram em{font-size:8px}.availability,.exceptions{padding:12px}.exceptions>button{min-height:48px;padding:7px 5px}.exceptions em{font-size:8px}.truth-banner{padding:12px}.position-readout>div{padding:11px;font-size:10px}.telemetry-chart{padding:10px 12px}.telemetry-chart>header b{font-size:11px}.telemetry-chart>footer,.histogram>footer{font-size:8px}.subsystem-grid>div{padding:10px}.subsystem-grid b{font-size:10px}.configure-button{min-height:42px}
.selection-deck{grid-template-columns:minmax(0,1.25fr) minmax(460px,1fr);background:#0c1823}.unit-card-strip{gap:6px;padding:8px}.unit-card{min-width:124px;height:116px;padding:9px;background:#101e2b}.unit-card>svg{width:25px}.unit-card>span b{font-size:11px}.unit-card>em{width:18px;height:18px;border-radius:9px;font-size:8px}.card-meters small{font-size:9px}.ability-bar{grid-template-rows:42px 1fr}.ability-bar>header{padding:0 11px}.ability-bar>header>span:last-child{padding:3px 6px;font-size:8px}.ability-bar nav{gap:6px;padding:7px}.ability-bar button{gap:5px;font-size:9px;background:#132434}.ability-bar svg{width:20px;height:20px}.lower-drawer>header{height:36px}.lower-drawer>header nav button{min-height:35px;padding:0 12px;font-size:9px}.lower-drawer>header>span{font-size:8px}.lower-drawer.open{left:52px;right:300px;height:250px}.review-strip{left:52px;right:300px;bottom:36px}
.mode-palette{width:410px}.mode-palette>header{height:50px;padding:8px 11px}.mode-palette>section{padding:9px}.mode-palette>section>div{gap:6px}.mode-palette>section button{min-height:40px;padding:7px;font-size:10px}.compact-drawer{width:330px}.right-workbench{width:min(470px,62%)}
@media(max-width:1450px){.status-strip{display:flex}.spatial-scene{grid-template-columns:50px 220px minmax(410px,1fr) 286px;grid-template-rows:minmax(0,1fr) 130px 36px}.tool-rail button{width:49px;height:46px}.mission-id{flex-basis:205px}.strip-status{flex-basis:108px}.alert-status{flex-basis:122px}.clock{flex-basis:170px}.selection-deck{grid-template-columns:minmax(0,1fr) minmax(430px,.95fr)}.unit-card{min-width:116px;height:111px}.lower-drawer.open,.review-strip{left:50px;right:286px}.review-strip{bottom:36px}.force-row{height:63px}.roster-drawer>footer{font-size:8px}}
@media(min-width:2000px){.spatial-scene{grid-template-columns:54px 250px minmax(700px,1fr) 340px;grid-template-rows:minmax(0,1fr) 144px 38px}.tool-rail button{width:53px}.lower-drawer.open,.review-strip{left:54px;right:340px}.selection-inspector{font-size:13px}.unit-card{min-width:138px;height:124px}.status-strip{max-width:none}}
.force-row.health-nominal,.force-row.health-attention,.force-row.health-critical,.force-row.health-unavailable{color:var(--text)!important}.force-row.health-attention{border-color:#705a2b}.force-row.health-attention .force-identity>i{background:#3a2e13;color:var(--amber)}.force-row.health-attention .force-progress>i{background:var(--amber)}.force-row.selected .force-identity b{color:var(--cyan)}.app-tools button{color:#9cafbf!important}.app-tools button.active{color:var(--cyan)!important}
.draft-shape path,.draft-shape polygon,.draft-shape circle{stroke-width:.62}.draft-shape.review path,.draft-shape.review polygon,.draft-shape.review circle{stroke-width:.82;filter:drop-shadow(0 0 1px #43c4eb)}.draft-handle{r:1}.draft-shape text{font-size:1.35px;font-weight:700}.review-strip{background:#0b1925fc}.review-strip>header small{color:var(--cyan)}.review-strip>header b{font-size:11px}.review-metrics>div{padding-inline:10px}.review-metrics b{font-size:10px}
@media(max-width:1450px){.roster-drawer>header{height:52px}.force-roster{padding-block:6px}.force-row{height:55px;margin-bottom:4px;padding-top:5px}.force-identity>i{width:25px;height:25px}.force-identity small,.force-state small{font-size:9px}.smart-filters{padding:7px 9px}.smart-filters>header{height:24px}.smart-filters>div>button{height:40px;grid-template-columns:15px 1fr 18px;padding:4px}.smart-filters>div>button svg{width:14px}.smart-filters .deploy-entry{height:46px;margin-top:6px}.roster-drawer>footer{padding:7px 8px}}
@@ -0,0 +1,8 @@
:root {
color-scheme: dark;
--surface-0: #071019; --surface-1: #0b1621; --surface-2: #101d2a; --surface-3: #172636;
--border: #2b4056; --border-soft: #1d3042; --text: #e8eff5; --muted: #95a8b9;
--blue: #2d8ff0; --cyan: #43c4eb; --green: #5dcc7d; --amber: #e8b84b; --red: #ef5a62; --violet: #a77adf;
--font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
+51
View File
@@ -0,0 +1,51 @@
export type OpaqueId<T extends string> = string & { readonly __type: T };
export type EntityKind = 'mission' | 'task' | 'group' | 'vehicle' | 'operator' | 'constraint' | 'alert' | 'position' | 'source';
export type Health = 'nominal' | 'attention' | 'critical' | 'unavailable';
export type CommandState = 'draft' | 'validating' | 'validated' | 'queued' | 'dispatched' | 'acknowledged' | 'executing' | 'completed' | 'rejected' | 'cancelled' | 'failed' | 'expired';
export type Archetype = 'quadcopter' | 'fixed-wing' | 'tiltrotor-vtol' | 'helicopter' | 'rover' | 'surface-vessel';
export interface Position { x: number; y: number; lat: number; lon: number }
export interface NavigationTarget { kind: EntityKind; entityId?: string; position?: Position; zoom: 1 | 2 | 3 | 4; inspector: 'summary' | 'task' | 'vehicle' | 'engineering' | 'event' | 'authority' }
export interface Mission { id: string; name: string; code: string; revision: number; elapsedSeconds: number }
export interface Task { id: string; name: string; kind: string; progress: number; required: number; assignedGroupId?: string; state: 'planned' | 'active' | 'paused' | 'completed' | 'cancelled'; etaMinutes: number }
export interface Group { id: string; name: string; count: number; taskId?: string; operatorId: string; progress: number; health: Health; currentIntent: string; nextIntent: string; formation: string; attentionCount: number }
export interface Vehicle {
id: string; name: string; groupId: string; archetype: Archetype; capabilities: string[]; payload: string;
position: Position; heading: number; speed: number; altitude: number; energy: number; link: number;
health: Health; freshnessMs: number; available: boolean; binding: 'simulated-bound' | 'unbound'; taskId?: string;
}
export interface Operator { id: string; name: string; role: string; presence: 'online' | 'idle' | 'offline'; color: string; cursor?: Position; selectedEntityId?: string }
export interface AuthorityLease { id: string; entityId: string; holderId: string; state: 'held' | 'requested' | 'conflict' | 'unavailable'; requestedBy?: string; expiresAt: number; observers: string[] }
export interface Intent { id: string; title: string; detail: string; ownerId: string; groupId: string; taskId?: string; durationMinutes: number; state: CommandState; dependencies: string[]; resources: string[]; validation: 'valid' | 'warning' | 'invalid' }
export interface Alert { id: string; severity: 'info' | 'attention' | 'critical'; title: string; detail: string; entityId: string; acknowledged: boolean; resolved: boolean; target: NavigationTarget }
export interface ActivityEvent { id: string; timestamp: number; actor: string; category: 'system' | 'command' | 'operator' | 'task' | 'group' | 'vehicle' | 'alert' | 'source'; severity: 'info' | 'attention' | 'critical'; message: string; entityId?: string; correlationId?: string; acknowledged: boolean; target?: NavigationTarget }
export interface SourceHealth { state: 'disconnected' | 'connecting' | 'connected' | 'degraded'; message: string; invalidEvents: number; duplicates: number; outOfOrder: number; lastEventAt?: number }
export type LifecycleState = 'detected' | 'identity-verified' | 'bound' | 'configured' | 'calibrated' | 'validated' | 'staged' | 'mission-ready';
export interface VehicleCandidate { id: string; transport: 'usb' | 'serial' | 'radio' | 'ethernet' | 'wifi' | 'adapter'; hardwareUid: string; board: string; firmware: string; vehicleClass: Archetype; systemId: number; link: number; authentication: 'trusted' | 'untrusted' | 'unknown'; claim: 'claimed' | 'unclaimed' | 'conflict'; lastSeenAt: number }
export interface VehicleIdentity { id: string; logicalVehicleId: string; hardwareUid: string; callsign: string; systemId: number; verifiedAt?: number; provenance: string }
export interface VehicleBinding { id: string; logicalVehicleId: string; candidateId: string; state: 'draft' | 'bound' | 'quarantined' | 'replaced' | 'unbound'; revision: number }
export interface CapabilitySet { vehicleId: string; schemaVersion: string; capabilities: string[]; reportedAt: number }
export interface ConfigurationSchema { id: string; version: string; vehicleClass: Archetype; fields: Array<{ key: string; label: string; unit?: string; min?: number; max?: number; safetyCritical: boolean; scope: 'fleet' | 'device' | 'calibration' | 'identity' | 'secret' | 'read-only' }> }
export interface ConfigurationSnapshot { id: string; vehicleId: string; schemaId: string; createdAt: number; values: Record<string, unknown>; provenance: 'baseline' | 'readback' | 'checkpoint' }
export interface ConfigurationDraft { id: string; vehicleId: string; baselineId: string; desired: Record<string, unknown>; authorId: string; revision: number; state: 'editing' | 'validated' | 'applying' | 'applied' | 'rejected' }
export interface ChangeSet { id: string; exactTargetIds: readonly string[]; draftIds: string[]; correlationId: string; createdAt: number; state: CommandState }
export interface ValidationReport { id: string; changeSetId: string; createdAt: number; results: Array<{ code: string; severity: 'pass' | 'warning' | 'blocker'; message: string; vehicleId?: string }> }
export interface CalibrationRun { id: string; vehicleId: string; kind: string; startedAt: number; completedAt?: number; state: 'pending' | 'running' | 'passed' | 'failed' | 'cancelled'; samples: number }
export interface CommissioningSession { id: string; vehicleId: string; leaseId: string; lifecycle: LifecycleState; blockers: string[]; warnings: string[]; completedSections: string[] }
export interface CommissioningLease { id: string; vehicleId: string; holderId: string; expiresAt: number; state: 'held' | 'expired' | 'conflict' }
export interface BatchJob { id: string; templateVersion: string; exactTargetIds: readonly string[]; canaryIds: readonly string[]; waveSize: number; state: 'draft' | 'validating' | 'canary' | 'rolling' | 'paused' | 'complete' | 'partial' | 'failed'; results: Record<string, 'pending' | 'success' | 'warning' | 'rejected' | 'timeout' | 'rollback' | 'unknown'> }
export interface MapArtifact { id: string; kind: 'point' | 'ping' | 'note' | 'polyline' | 'arrow' | 'polygon' | 'circle' | 'task-area' | 'corridor' | 'measurement' | 'geofence' | 'home' | 'rally' | 'recovery' | 'landing' | 'staging'; semanticClass: 'annotation' | 'task' | 'safety' | 'command-preview' | 'mission-truth'; points: Position[]; label?: string; scope: 'private' | 'team' | 'mission'; authorId: string; createdAt: number; updatedAt: number; revision: number; expiresAt?: number }
export interface RecoveryCheckpoint { id: string; vehicleId: string; snapshotId: string; createdAt: number; reason: string; verified: boolean }
export interface MissionState {
mission: Mission; tasks: Record<string, Task>; groups: Record<string, Group>; vehicles: Record<string, Vehicle>;
operators: Record<string, Operator>; leases: Record<string, AuthorityLease>; queue: Intent[]; alerts: Alert[];
events: ActivityEvent[]; source: SourceHealth; simulated: true; simulation: { paused: boolean; speed: number; tick: number };
}
export const emptyMissionState = (): MissionState => ({
mission: { id: 'mission:none', name: 'No mission', code: '—', revision: 0, elapsedSeconds: 0 },
tasks: {}, groups: {}, vehicles: {}, operators: {}, leases: {}, queue: [], alerts: [], events: [],
source: { state: 'disconnected', message: 'Source disconnected', invalidEvents: 0, duplicates: 0, outOfOrder: 0 },
simulated: true, simulation: { paused: false, speed: 1, tick: 0 },
});
+116
View File
@@ -0,0 +1,116 @@
export type InteractionMode =
| 'select' | 'select-additive' | 'marquee' | 'lasso' | 'pan' | 'inspect' | 'move-intent' | 'route' | 'formation'
| 'marker' | 'ping' | 'note' | 'polyline' | 'arrow' | 'freehand'
| 'polygon' | 'circle' | 'task-area' | 'corridor'
| 'measure-distance' | 'measure-bearing' | 'measure-area'
| 'geofence-include' | 'geofence-exclude'
| 'home' | 'rally' | 'landing' | 'recovery' | 'staging'
| 'edit' | 'erase';
export type DraftPoint = { x: number; y: number };
export type DraftGeometry = {
id: string;
mode: InteractionMode;
points: DraftPoint[];
targetIds: string[];
phase: 'drawing' | 'complete' | 'review';
valid: boolean;
message: string;
};
export type ModeDefinition = {
label: string;
cursor: string;
targets: string;
pointer: string;
keyboard: string;
geometry: 'none' | 'point' | 'line' | 'polyline' | 'polygon' | 'circle' | 'freehand' | 'formation';
completion: string;
cancellation: string;
validation: string;
output: string;
};
const mode = (label: string, cursor: string, geometry: ModeDefinition['geometry'], output: string, overrides: Partial<ModeDefinition> = {}): ModeDefinition => ({
label, cursor, geometry, output,
targets: 'Map ground and selectable mission entities',
pointer: geometry === 'none' ? 'Click or drag the active target' : 'Click to add geometry; drag handles to adjust',
keyboard: 'Enter completes; Escape cancels; undo and redo affect local drafts',
completion: geometry === 'point' ? 'One valid map point' : geometry === 'none' ? 'Pointer release' : 'Enter or double-click after minimum vertices',
cancellation: 'Escape removes the current segment, then the draft, then returns to Select',
validation: 'Geographic extent, exact target set, freshness, authority, and safety constraints',
...overrides,
});
export const MODES: Record<InteractionMode, ModeDefinition> = {
select: mode('Select', 'default', 'none', 'local-selection', { pointer: 'Click selects; Shift toggles; empty drag creates a marquee', validation: 'Visible, selectable mission entities only' }),
'select-additive': mode('Additive select', 'default', 'none', 'local-selection', { pointer: 'Click toggles membership without clearing the local selection', validation: 'Visible, selectable mission entities only' }),
marquee: mode('Marquee select', 'crosshair', 'none', 'local-selection', { pointer: 'Drag a screen-space rectangle; entities are hit-tested in geographic world space', validation: 'Visible, selectable mission entities only' }),
lasso: mode('Lasso select', 'crosshair', 'freehand', 'local-selection', { pointer: 'Draw a closed geographic loop around entities', completion: 'Release closes the loop and applies local selection', validation: 'Visible vehicle positions inside the loop' }),
pan: mode('Pan', 'grab', 'none', 'camera-change', { pointer: 'Drag map; middle-drag and Space-drag temporarily activate', validation: 'Camera bounds' }),
inspect: mode('Inspect', 'help', 'point', 'inspector-focus'),
'move-intent': mode('Move intent', 'crosshair', 'line', 'command-draft'),
route: mode('Route', 'crosshair', 'polyline', 'command-draft', { pointer: 'Click adds a waypoint; Shift-click continues; drag final heading handle' }),
formation: mode('Formation', 'crosshair', 'formation', 'formation-draft', { pointer: 'Drag anchor, orientation, spacing, width, and depth handles' }),
marker: mode('Marker', 'crosshair', 'point', 'map-artifact'), ping: mode('Attention ping', 'crosshair', 'point', 'expiring-map-artifact'), note: mode('Text note', 'text', 'point', 'map-artifact'),
polyline: mode('Polyline', 'crosshair', 'polyline', 'map-artifact'), arrow: mode('Phase line', 'crosshair', 'polyline', 'map-artifact'), freehand: mode('Freehand', 'crosshair', 'freehand', 'map-artifact', { pointer: 'Press and draw a geographic stroke; release completes' }),
polygon: mode('Polygon', 'crosshair', 'polygon', 'map-artifact'), circle: mode('Circle', 'crosshair', 'circle', 'map-artifact', { pointer: 'Drag from center to radius' }), 'task-area': mode('Task area', 'crosshair', 'polygon', 'task-geometry'), corridor: mode('Corridor', 'crosshair', 'polyline', 'task-geometry'),
'measure-distance': mode('Distance', 'crosshair', 'line', 'measurement'), 'measure-bearing': mode('Bearing', 'crosshair', 'line', 'measurement'), 'measure-area': mode('Area', 'crosshair', 'polygon', 'measurement'),
'geofence-include': mode('Keep in', 'crosshair', 'polygon', 'safety-proposal'), 'geofence-exclude': mode('Keep out', 'not-allowed', 'polygon', 'safety-proposal'),
home: mode('Home', 'crosshair', 'point', 'location-proposal'), rally: mode('Rally', 'crosshair', 'point', 'location-proposal'), landing: mode('Landing', 'crosshair', 'point', 'location-proposal'), recovery: mode('Recovery', 'crosshair', 'point', 'location-proposal'), staging: mode('Staging', 'crosshair', 'point', 'location-proposal'),
edit: mode('Edit vertices', 'move', 'none', 'artifact-revision', { pointer: 'Drag vertices; click midpoint to insert; drag body to move', validation: 'Artifact-specific geometry and scope' }),
erase: mode('Erase', 'not-allowed', 'none', 'artifact-tombstone', { pointer: 'Click a local draft or editable artifact', validation: 'Edit permission and artifact revision' }),
};
export type InteractionState = {
mode: InteractionMode;
draft: DraftGeometry | null;
artifacts: DraftGeometry[];
past: DraftGeometry[][];
future: DraftGeometry[][];
};
export type InteractionAction =
| { type: 'mode'; mode: InteractionMode }
| { type: 'point'; point: DraftPoint; targetIds: string[] }
| { type: 'freehand'; point: DraftPoint }
| { type: 'complete' }
| { type: 'cancel' }
| { type: 'discard-draft' }
| { type: 'undo' }
| { type: 'redo' }
| { type: 'erase'; id: string };
export const initialInteraction: InteractionState = { mode: 'select', draft: null, artifacts: [], past: [], future: [] };
const minimumPoints = (mode: InteractionMode) => MODES[mode].geometry === 'polygon' ? 3 : ['polyline', 'line', 'circle', 'formation'].includes(MODES[mode].geometry) ? 2 : 1;
const snapshot = (state: InteractionState) => [...state.artifacts];
export function interactionReducer(state: InteractionState, action: InteractionAction): InteractionState {
if (action.type === 'mode') return { ...state, mode: action.mode, draft: null };
if (action.type === 'point') {
if (MODES[state.mode].geometry === 'none') return state;
const draft = state.draft?.mode === state.mode ? state.draft : { id: `draft:${Date.now()}`, mode: state.mode, points: [], targetIds: [...action.targetIds], phase: 'drawing' as const, valid: true, message: 'Local draft · not dispatched' };
const next = { ...draft, points: [...draft.points, action.point] };
if (MODES[state.mode].geometry === 'point') return interactionReducer({ ...state, draft: next }, { type: 'complete' });
return { ...state, draft: next };
}
if (action.type === 'freehand' && ['freehand', 'lasso'].includes(state.mode) && state.draft) return { ...state, draft: { ...state.draft, points: [...state.draft.points, action.point] } };
if (action.type === 'complete') {
if (!state.draft || state.draft.points.length < minimumPoints(state.draft.mode)) return state;
const complete = { ...state.draft, phase: (['move-intent', 'route', 'formation'].includes(state.draft.mode) ? 'review' : 'complete') as DraftGeometry['phase'] };
return { ...state, draft: complete.phase === 'review' ? complete : null, artifacts: complete.phase === 'complete' ? [...state.artifacts, complete] : state.artifacts, past: [...state.past, snapshot(state)], future: [], mode: complete.phase === 'review' ? state.mode : 'select' };
}
if (action.type === 'cancel') {
if (state.draft?.points.length && state.draft.points.length > 1) return { ...state, draft: { ...state.draft, points: state.draft.points.slice(0, -1) } };
if (state.draft) return { ...state, draft: null };
return { ...state, mode: 'select' };
}
if (action.type === 'discard-draft') return { ...state, draft: null, mode: 'select' };
if (action.type === 'undo' && state.past.length) return { ...state, artifacts: state.past.at(-1)!, past: state.past.slice(0, -1), future: [snapshot(state), ...state.future], draft: null };
if (action.type === 'redo' && state.future.length) return { ...state, artifacts: state.future[0], past: [...state.past, snapshot(state)], future: state.future.slice(1), draft: null };
if (action.type === 'erase') return { ...state, past: [...state.past, snapshot(state)], artifacts: state.artifacts.filter(item => item.id !== action.id), future: [] };
return state;
}
export const draftDistanceKm = (points: DraftPoint[]) => points.slice(1).reduce((total, point, index) => total + Math.hypot(point.x - points[index].x, point.y - points[index].y) * 0.18, 0);
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import { createSimulationRuntime } from './app/runtime';
import './design-system/tokens.css';
import './design-system/app.css';
const root = document.getElementById('root');
if (!root) throw new Error('Ground Suite root element is missing');
createRoot(root).render(<StrictMode><App plane={createSimulationRuntime()} /></StrictMode>);
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import { createScenario } from '../adapters/fake/scenario';
import { parseSmartQuery, replacementCandidates, smartSelect } from './selectors';
describe('smart selectors', () => {
const state = createScenario();
it('translates the documented grammar into visible filters', () => expect(parseSmartQuery('Available ISR aircraft within 20 km with more than 60% energy')).toEqual({ capability: 'ISR', available: true, maxDistanceKm: 20, minEnergy: 60, task: 'none' }));
it('reports filter distribution and rejection reasons', () => { const result = smartSelect(state, parseSmartQuery('Available ISR aircraft within 20 km with more than 60% energy')); expect(result.matches.length).toBeGreaterThan(0); expect(result.distribution[0]).toEqual({ label: 'Fleet', count: 50 }); expect(result.rejected.length + result.matches.length).toBe(50); });
it('ranks deterministic replacement candidates', () => { const one = replacementCandidates(state); const two = replacementCandidates(createScenario()); expect(one.map(item => item.vehicle.id)).toEqual(two.map(item => item.vehicle.id)); expect(one).toHaveLength(3); });
});
+23
View File
@@ -0,0 +1,23 @@
import type { MissionState, Vehicle } from '../domain/model';
export const selectVehiclesForGroup = (state: MissionState, groupId: string): Vehicle[] => Object.values(state.vehicles).filter(vehicle => vehicle.groupId === groupId);
export const fleetSummary = (state: MissionState) => ({ total: Object.keys(state.vehicles).length, healthy: Object.values(state.vehicles).filter(vehicle => vehicle.health === 'nominal').length, attention: Object.values(state.vehicles).filter(vehicle => vehicle.health !== 'nominal').length, activeTasks: Object.values(state.tasks).filter(task => task.state === 'active').length });
export interface SmartFilters { capability?: string; available?: boolean; maxDistanceKm?: number; minEnergy?: number; task?: 'none' | string }
export interface SmartResult { matches: Vehicle[]; rejected: Array<{ vehicle: Vehicle; reasons: string[] }>; distribution: Array<{ label: string; count: number }> }
export function parseSmartQuery(query: string): SmartFilters {
const normalized = query.toLowerCase();
const distance = normalized.match(/within\s+(\d+)\s*km/); const energy = normalized.match(/(?:more than|above|>)\s*(\d+)\s*%/);
return { capability: normalized.includes('isr') ? 'ISR' : undefined, available: normalized.includes('available') ? true : undefined, maxDistanceKm: distance ? Number(distance[1]) : undefined, minEnergy: energy ? Number(energy[1]) : undefined, task: normalized.includes('available') || normalized.includes('task: none') ? 'none' : undefined };
}
export function smartSelect(state: MissionState, filters: SmartFilters): SmartResult {
const all = Object.values(state.vehicles); const steps: SmartResult['distribution'] = [{ label: 'Fleet', count: all.length }]; let current = all;
const apply = (label: string, predicate: (vehicle: Vehicle) => boolean) => { current = current.filter(predicate); steps.push({ label, count: current.length }); };
if (filters.capability) apply(`Capability: ${filters.capability}`, vehicle => vehicle.capabilities.includes(filters.capability!));
if (filters.available) apply('Available', vehicle => vehicle.available);
if (filters.maxDistanceKm !== undefined) apply(`Distance < ${filters.maxDistanceKm} km`, vehicle => Math.hypot(vehicle.position.x - 45, vehicle.position.y - 35) / 4 < filters.maxDistanceKm!);
if (filters.minEnergy !== undefined) apply(`Energy > ${filters.minEnergy}%`, vehicle => vehicle.energy > filters.minEnergy!);
if (filters.task === 'none') apply('No assigned task', vehicle => !vehicle.taskId);
const matches = current; const matchIds = new Set(matches.map(vehicle => vehicle.id));
return { matches, distribution: steps, rejected: all.filter(vehicle => !matchIds.has(vehicle.id)).map(vehicle => ({ vehicle, reasons: [filters.available && !vehicle.available ? 'already assigned' : '', filters.minEnergy && vehicle.energy <= filters.minEnergy ? 'energy threshold' : '', filters.capability && !vehicle.capabilities.includes(filters.capability) ? 'capability mismatch' : ''].filter(Boolean) })) };
}
export const replacementCandidates = (state: MissionState) => Object.values(state.vehicles).filter(vehicle => vehicle.available && vehicle.capabilities.includes('ISR')).map(vehicle => ({ vehicle, distanceKm: Math.hypot(vehicle.position.x - 35, vehicle.position.y - 35) / 4, score: vehicle.energy * 0.45 + vehicle.link * 0.35 - Math.hypot(vehicle.position.x - 35, vehicle.position.y - 35) * 0.2, reasons: [`${Math.round(vehicle.energy)}% energy`, `${Math.round(vehicle.link)}% link`, 'ISR compatible'] })).sort((a, b) => b.score - a.score).slice(0, 3);
+8
View File
@@ -0,0 +1,8 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
localStorage.clear();
});
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -1,30 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ground_suite/core/observations/observed_event.dart';
import 'package:ground_suite/core/observations/vehicle_data_update.dart';
import 'package:ground_suite/core/transport/replay_vehicle_data_port.dart';
void main() {
test('replay covers identity, telemetry, log, error, and shutdown', () async {
const port = ReplayVehicleDataPort();
final events = [
for (final update in await port.observe().toList())
if (update case ObservationReceived(:final event)) event,
];
expect(events.whereType<HeartbeatEvent>(), hasLength(1));
expect(events.whereType<TelemetryEvent>(), hasLength(2));
final logs = events.whereType<LogEvent>().toList();
final errors = events.whereType<ErrorEvent>().toList();
expect(logs, hasLength(2));
expect(errors, hasLength(1));
expect(events.whereType<ShutdownEvent>(), hasLength(1));
expect(logs.first.isCoalesced, isTrue);
expect(errors.single.isTruncated, isTrue);
});
test('unknown wire enum values remain visible', () {
expect(const MavSeverity(99).label, 'Unknown (99)');
expect(const KaptanShutdownReason(99).label, 'Unknown (99)');
});
}
@@ -1,309 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ground_suite/features/command/presentation/tactical_command_page.dart';
import 'package:ground_suite/features/command/presentation/tactical_controller.dart';
import 'package:ground_suite/features/fleet/domain/fleet_unit.dart';
import 'package:ground_suite/features/vehicle_workspace/domain/vehicle_catalog.dart';
void main() {
ProviderContainer container() {
final value = ProviderContainer();
addTearDown(value.dispose);
return value;
}
test('selection and cursor modes have one deterministic owner', () {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.selectUnit('DEMO-1022');
expect(scope.read(tacticalControllerProvider).selectedUnitIds, {
'DEMO-1022',
});
controller.selectUnit('DEMO-1023', toggle: true);
expect(scope.read(tacticalControllerProvider).selectedUnitIds, {
'DEMO-1022',
'DEMO-1023',
});
controller.beginPencil();
expect(
scope.read(tacticalControllerProvider).mode,
isA<TacticalPencilMode>(),
);
expect(scope.read(tacticalControllerProvider).selectedUnitIds, isEmpty);
controller.showUnitHangar();
expect(
scope.read(tacticalControllerProvider).panel,
TacticalPanel.unitHangar,
);
expect(scope.read(tacticalControllerProvider).unitDraft, isNull);
final definition = supportedVehicleDefinitions.first;
controller.chooseUnitDefinition(definition, displayName: 'QUADCOPTER-01');
expect(
scope.read(tacticalControllerProvider).unitDraft?.definition,
definition,
);
controller.beginUnitSetup();
expect(
scope.read(tacticalControllerProvider).panel,
TacticalPanel.unitSetup,
);
controller.completeUnitPlacement('local-unit-1');
expect(scope.read(tacticalControllerProvider).selectedUnitIds, {
'local-unit-1',
});
expect(scope.read(tacticalControllerProvider).unitDraft, isNull);
controller.showUnitSetupForSelectedLocalUnit('local-unit-1');
expect(
scope.read(tacticalControllerProvider).panel,
TacticalPanel.unitSetup,
);
});
test(
'hangar drafts preserve the selected type and do not mutate map state',
() {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
final before = scope.read(tacticalControllerProvider);
controller.showUnitHangar();
controller.chooseUnitDefinition(
supportedVehicleDefinitions[1],
displayName: 'Fixed wing scout',
);
controller.beginUnitSetup();
controller.updateUnitDraftName('Fixed wing north');
final editing = scope.read(tacticalControllerProvider);
expect(
identical(editing.selectedUnitIds, before.selectedUnitIds),
isTrue,
);
expect(identical(editing.drawings, before.drawings), isTrue);
expect(identical(editing.markers, before.markers), isTrue);
expect(editing.panel, TacticalPanel.unitSetup);
controller.returnToUnitHangar();
final returned = scope.read(tacticalControllerProvider);
expect(returned.panel, TacticalPanel.unitHangar);
expect(returned.unitDraft?.definition, supportedVehicleDefinitions[1]);
expect(returned.unitDraft?.displayName, 'Fixed wing north');
controller.showUnitSetupForSelectedLocalUnit('local-unit-1');
expect(
scope.read(tacticalControllerProvider).panel,
TacticalPanel.unitHangar,
reason:
'A catalog draft cannot produce an unrelated blank setup panel.',
);
},
);
test(
'editing a hangar draft does not notify the tactical render projection',
() async {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
scope.read(tacticalSceneRenderStateProvider);
var renderNotifications = 0;
final subscription = scope.listen<TacticalSceneRenderState>(
tacticalSceneRenderStateProvider,
(previous, next) => renderNotifications += 1,
fireImmediately: true,
);
addTearDown(subscription.close);
renderNotifications = 0;
controller.showUnitHangar();
await scope.pump();
controller.chooseUnitDefinition(
supportedVehicleDefinitions.first,
displayName: 'Quad local',
);
await scope.pump();
controller.updateUnitDraftName('Quad north');
await scope.pump();
expect(
renderNotifications,
1,
reason: 'Opening the hangar changes its panel visibility.',
);
controller.updateUnitDraftName('Quad north one');
await scope.pump();
expect(
renderNotifications,
1,
reason: 'Typing only updates the isolated local setup draft.',
);
},
);
test(
'direct pencil strokes are session state and require complete geometry',
() {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.beginPencil();
expect(
controller.commitPencilStroke(const [LocalScenePoint(x: .1, y: .2)]),
isFalse,
);
expect(
controller.commitPencilStroke(const [
LocalScenePoint(x: .1, y: .2),
LocalScenePoint(x: .3, y: .4),
]),
isTrue,
);
final state = scope.read(tacticalControllerProvider);
expect(state.mode, isA<TacticalPencilMode>());
expect(state.drawings, hasLength(1));
expect(state.drawings.single.thickness, 3);
controller.clearSceneObjects();
expect(scope.read(tacticalControllerProvider).drawings, isEmpty);
},
);
test('view toggles remain separate from selection and pencil tools', () {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.beginPencil();
controller.toggleOverlay(TacticalMapOverlay.drones, false);
final state = scope.read(tacticalControllerProvider);
expect(state.mode, isA<TacticalPencilMode>());
expect(state.overlays, isNot(contains(TacticalMapOverlay.drones)));
});
test('fleet panel is separate from a local map mode', () {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.showFleetRoster();
expect(
scope.read(tacticalControllerProvider).panel,
TacticalPanel.fleetRoster,
);
expect(
scope.read(tacticalControllerProvider).mode,
isA<TacticalBrowseMode>(),
);
controller.beginPencil();
expect(
scope.read(tacticalControllerProvider).mode,
isA<TacticalPencilMode>(),
);
controller.closePanel();
expect(scope.read(tacticalControllerProvider).panel, isNull);
});
test('drag selection is local only', () {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.selectUnits(['DEMO-1021', 'DEMO-1023']);
final state = scope.read(tacticalControllerProvider);
expect(state.selectedUnitIds, {'DEMO-1021', 'DEMO-1023'});
expect(state.mode, isA<TacticalBrowseMode>());
});
test('direct strokes are bounded before local scene commit', () {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.beginPencil();
final stroke = [
for (var index = 0; index < 256; index++)
LocalScenePoint(x: index / 255, y: index.isEven ? .4 : .6),
];
expect(controller.commitPencilStroke(stroke), isTrue);
expect(
scope.read(tacticalControllerProvider).drawings.single.points.length,
lessThanOrEqualTo(256),
);
});
test(
'pencil style is retained for the current cursor and committed stroke',
() {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.beginPencil();
controller.configurePencil(
hue: 282,
saturation: .22,
brightness: .38,
thickness: 8,
);
expect(
controller.commitPencilStroke(const [
LocalScenePoint(x: .2, y: .2),
LocalScenePoint(x: .4, y: .4),
]),
isTrue,
);
final state = scope.read(tacticalControllerProvider);
expect(state.mode, isA<TacticalPencilMode>());
final mode = state.mode as TacticalPencilMode;
expect(mode.hue, 282);
expect(mode.saturation, .22);
expect(mode.brightness, .38);
expect(mode.thickness, 8);
expect(state.drawings.single.hue, 282);
expect(state.drawings.single.saturation, .22);
expect(state.drawings.single.brightness, .38);
expect(state.drawings.single.thickness, 8);
},
);
test('marker names and colors are independent from pencil strokes', () {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.beginPencil();
controller.configurePencil(hue: 282, thickness: 8);
controller.beginMarker();
controller.configureMarker(hue: 26, saturation: .41, brightness: .56);
controller.chooseMarkerPoint(const LocalScenePoint(x: .4, y: .6));
expect(controller.commitMarkerName('Landing review'), isTrue);
final state = scope.read(tacticalControllerProvider);
expect(state.markers, hasLength(1));
expect(state.markers.single.name, 'Landing review');
expect(state.markers.single.hue, 26);
expect(state.markers.single.saturation, .41);
expect(state.markers.single.brightness, .56);
expect((state.mode as TacticalMarkerMode).hue, 26);
expect(state.drawings, isEmpty);
});
test(
'blank marker labels and pending marker cancellation create no marker',
() {
final scope = container();
final controller = scope.read(tacticalControllerProvider.notifier);
controller.beginMarker();
controller.chooseMarkerPoint(const LocalScenePoint(x: .2, y: .2));
expect(controller.commitMarkerName(' '), isFalse);
expect(controller.cancelActiveTool(), isFalse);
expect(scope.read(tacticalControllerProvider).markers, isEmpty);
expect(controller.cancelActiveTool(), isTrue);
expect(
scope.read(tacticalControllerProvider).mode,
isA<TacticalBrowseMode>(),
);
},
);
}
@@ -1,174 +0,0 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ground_suite/core/observations/observed_event.dart';
import 'package:ground_suite/core/observations/vehicle_data_port.dart';
import 'package:ground_suite/core/observations/vehicle_data_update.dart';
import 'package:ground_suite/features/observability/presentation/observability_controller.dart';
void main() {
test('coalesces rapid critical accessibility announcements', () async {
final port = _ControlledDataPort();
final container = ProviderContainer(
overrides: [vehicleDataPortProvider.overrideWithValue(port)],
);
addTearDown(container.dispose);
container.read(observabilityControllerProvider);
await Future<void>.delayed(Duration.zero);
port.events.add(
ObservationReceived(
ErrorEvent(
receivedAt: DateTime.utc(2026, 8, 10),
path: 'kaptan_drivers::lidar_lite_v3::error::Error::TransferFault',
),
),
);
await Future<void>.delayed(Duration.zero);
expect(
container.read(observabilityControllerProvider).accessibilityAnnouncement,
'Error received: kaptan_drivers::lidar_lite_v3::error::Error::TransferFault',
);
port.events.add(
ObservationReceived(
ShutdownEvent(
receivedAt: DateTime.utc(2026, 8, 10, 0, 0, 1),
reason: KaptanShutdownReason.safety,
),
),
);
await Future<void>.delayed(Duration.zero);
final state = container.read(observabilityControllerProvider);
expect(state.accessibilityAnnouncement, isNull);
expect(state.coalescedAlertCount, 1);
});
test(
'retains expected parser and semantic drops without ending observation',
() async {
final port = _ControlledDataPort();
final container = ProviderContainer(
overrides: [vehicleDataPortProvider.overrideWithValue(port)],
);
addTearDown(container.dispose);
container.read(observabilityControllerProvider);
await Future<void>.delayed(Duration.zero);
port.events.add(
const ObservationDropped(kind: ObservationDropKind.parser, count: 2),
);
port.events.add(
const ObservationDropped(kind: ObservationDropKind.semantic),
);
await Future<void>.delayed(Duration.zero);
final state = container.read(observabilityControllerProvider);
expect(state.status, ObservationStatus.receiving);
expect(state.parserDropCount, 2);
expect(state.semanticDropCount, 1);
},
);
test('treats a transport stream failure as a disconnected link', () async {
final port = _ControlledDataPort();
final container = ProviderContainer(
overrides: [vehicleDataPortProvider.overrideWithValue(port)],
);
addTearDown(container.dispose);
container.read(observabilityControllerProvider);
await Future<void>.delayed(Duration.zero);
port.events.addError(StateError('serial endpoint closed'));
await Future<void>.delayed(Duration.zero);
final state = container.read(observabilityControllerProvider);
expect(state.status, ObservationStatus.disconnected);
expect(state.parserDropCount, 0);
expect(state.semanticDropCount, 0);
});
test(
'bounds diagnostics while telemetry stays outside the timeline',
() async {
final port = _ControlledDataPort();
final container = ProviderContainer(
overrides: [vehicleDataPortProvider.overrideWithValue(port)],
);
addTearDown(container.dispose);
container.read(observabilityControllerProvider);
await Future<void>.delayed(Duration.zero);
port.events.add(
ObservationReceived(
ErrorEvent(
receivedAt: DateTime.utc(2026, 8, 10),
path: 'kaptan_drivers::lidar_lite_v3::error::Error::TransferFault',
),
),
);
port.events.add(
ObservationReceived(
HeartbeatEvent(
receivedAt: DateTime.utc(2026, 8, 10, 0, 0, 1),
systemId: 7,
componentId: 42,
armed: false,
vehicleState: 'Standby',
),
),
);
for (var index = 0; index < 501; index++) {
port.events.add(
ObservationReceived(
TelemetryEvent(
receivedAt: DateTime.utc(2026, 8, 10, 0, 0, index % 60),
kind: TelemetryKind.rawImu,
),
),
);
}
for (var index = 0; index < 500; index++) {
port.events.add(
ObservationReceived(
LogEvent(
receivedAt: DateTime.utc(2026, 8, 10, 0, 1, index % 60),
level: MavSeverity.info,
message: 'Diagnostic $index',
),
),
);
}
final state = container.read(observabilityControllerProvider);
expect(state.events, hasLength(500));
expect(state.localTimelineEvictionCount, 1);
expect(state.events.whereType<ErrorEvent>(), hasLength(1));
expect(state.lastHeartbeat?.systemId, 7);
expect(state.observedTelemetryKinds, contains(TelemetryKind.rawImu));
},
);
}
class _ControlledDataPort implements VehicleDataPort {
final events = StreamController<VehicleDataUpdate>(sync: true);
@override
bool get isReplay => false;
@override
String get label => 'Test transport';
@override
Future<void> close() => events.close();
@override
Stream<VehicleDataUpdate> observe() => events.stream;
}
@@ -1,77 +0,0 @@
import 'dart:ui' show CheckedState;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ground_suite/features/vehicle_workspace/domain/vehicle_catalog.dart';
import 'package:ground_suite/features/vehicle_workspace/presentation/widgets/vehicle_catalog_card.dart';
void main() {
test('every local vehicle type has a clear bundled profile image', () {
expect(supportedVehicleDefinitions, hasLength(6));
expect(
supportedVehicleDefinitions.map((definition) => definition.name),
containsAll(<String>[
'Quadcopter',
'Fixed-wing',
'Tiltrotor VTOL',
'Helicopter',
'Ground rover',
'Surface vessel',
]),
);
for (final definition in supportedVehicleDefinitions) {
expect(definition.id, isNotEmpty);
expect(definition.name, isNotEmpty);
expect(definition.media.imageAssetPath, isNotEmpty);
}
});
testWidgets('catalog card is a single keyboard-activatable radio control', (
tester,
) async {
var selections = 0;
final definition = supportedVehicleDefinitions.first;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: Center(
child: SizedBox(
width: 760,
child: VehicleCatalogCard(
definition: definition,
selected: false,
onSelected: () => selections += 1,
),
),
),
),
),
),
);
final card = find.byType(VehicleCatalogCard);
final semantics = tester.getSemantics(card);
expect(semantics.flagsCollection.isChecked, CheckedState.isFalse);
expect(semantics.flagsCollection.isInMutuallyExclusiveGroup, isTrue);
expect(semantics.label, contains('local vehicle image, not telemetry'));
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(selections, 2);
});
testWidgets('definition images are bundled locally', (tester) async {
for (final definition in supportedVehicleDefinitions) {
final image = await rootBundle.load(definition.media.imageAssetPath);
expect(image.lengthInBytes, greaterThan(0));
}
});
}
@@ -1,136 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ground_suite/features/fleet/domain/fleet_unit.dart';
import 'package:ground_suite/features/vehicle_workspace/domain/vehicle_catalog.dart';
import 'package:ground_suite/features/vehicle_workspace/presentation/vehicle_workspace_controller.dart';
void main() {
test('a reviewed definition creates a selected local fleet unit', () {
final container = ProviderContainer();
addTearDown(container.dispose);
final controller = container.read(vehicleWorkspaceProvider.notifier);
expect(container.read(vehicleWorkspaceProvider).hasUnits, isFalse);
final definition = supportedVehicleDefinitions.first;
expect(
controller.addUnit(
definition: definition,
localPlacement: const LocalScenePoint(x: .35, y: .45),
displayName: 'North survey',
),
isTrue,
);
expect(container.read(vehicleWorkspaceProvider).hasUnits, isTrue);
expect(
container.read(vehicleWorkspaceProvider).units.single.definition,
definition,
);
expect(
container.read(vehicleWorkspaceProvider).units.single.localUnitId,
'local-unit-1',
);
expect(
container.read(vehicleWorkspaceProvider).units.single.displayName,
'North survey',
);
expect(
controller.addUnit(
definition: definition,
localPlacement: const LocalScenePoint(x: .72, y: .45),
),
isTrue,
);
expect(container.read(vehicleWorkspaceProvider).units, hasLength(2));
expect(
container.read(vehicleWorkspaceProvider).units.last.localUnitId,
'local-unit-2',
);
});
test(
'rejects overlapping local placements and keeps a bounded workspace',
() {
final container = ProviderContainer();
addTearDown(container.dispose);
final controller = container.read(vehicleWorkspaceProvider.notifier);
final definition = supportedVehicleDefinitions.first;
const first = LocalScenePoint(x: .2, y: .2);
expect(
controller.addUnit(definition: definition, localPlacement: first),
isTrue,
);
expect(
controller.addUnit(definition: definition, localPlacement: first),
isFalse,
);
expect(
controller.addUnit(
definition: definition,
localPlacement: const LocalScenePoint(x: .23, y: .2),
),
isFalse,
);
expect(
controller.addUnit(
definition: definition,
localPlacement: const LocalScenePoint(x: .65, y: .7),
),
isTrue,
);
},
);
test(
'catalog additions receive a scattered local presentation placement',
() {
final container = ProviderContainer();
addTearDown(container.dispose);
final controller = container.read(vehicleWorkspaceProvider.notifier);
final definition = supportedVehicleDefinitions.first;
expect(controller.addUnitAutomatically(definition: definition), isTrue);
expect(controller.addUnitAutomatically(definition: definition), isTrue);
final units = container.read(vehicleWorkspaceProvider).units;
expect(units, hasLength(2));
expect(units.first.localPlacement, isNot(units.last.localPlacement));
for (final unit in units) {
expect(unit.localPlacement.x, inInclusiveRange(0.0, 1.0));
expect(unit.localPlacement.y, inInclusiveRange(0.0, 1.0));
}
},
);
test(
'local unit names are bounded workspace data, not vehicle configuration',
() {
final container = ProviderContainer();
addTearDown(container.dispose);
final controller = container.read(vehicleWorkspaceProvider.notifier);
expect(
controller.addUnitAutomatically(
definition: supportedVehicleDefinitions.first,
),
isTrue,
);
expect(
controller.renameUnit('local-unit-1', ' Ridge observer '),
isTrue,
);
expect(
container.read(vehicleWorkspaceProvider).units.single.displayName,
'Ridge observer',
);
expect(controller.renameUnit('local-unit-1', ' '), isFalse);
expect(
container.read(vehicleWorkspaceProvider).units.single.displayName,
'Ridge observer',
);
},
);
}
-683
View File
@@ -1,683 +0,0 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ground_suite/app/app.dart';
import 'package:ground_suite/app/app_shell.dart';
import 'package:ground_suite/app/theme.dart';
import 'package:ground_suite/features/command/presentation/tactical_command_page.dart';
import 'package:ground_suite/features/command/presentation/tactical_controller.dart';
import 'package:ground_suite/features/vehicle_workspace/domain/vehicle_catalog.dart';
import 'package:ground_suite/features/vehicle_workspace/presentation/vehicle_workspace_controller.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
testWidgets('desktop command dashboard opens the grouped tactical controls', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
final app = tester.widget<MaterialApp>(find.byType(MaterialApp));
expect(app.themeMode, ThemeMode.dark);
expect(find.text('TACTICAL COMMAND'), findsOneWidget);
expect(find.text('SIMULATED CAMERA CONCEPT'), findsOneWidget);
expect(find.text('DEMO-1021'), findsAtLeastNWidgets(1));
expect(find.byKey(const Key('units-menu')), findsOneWidget);
expect(find.byKey(const Key('cursor-menu')), findsOneWidget);
expect(find.byKey(const Key('view-menu')), findsOneWidget);
expect(find.text('UNITS'), findsNothing);
expect(find.text('MODE: SELECTION'), findsNothing);
expect(find.text('VIEW'), findsNothing);
expect(
tester.getSize(find.byKey(const Key('units-menu'))),
const Size(48, 48),
);
expect(
tester.getSize(find.byKey(const Key('cursor-menu'))),
const Size(48, 48),
);
expect(
tester.getSize(find.byKey(const Key('view-menu'))),
const Size(48, 48),
);
await tester.tap(find.byKey(const Key('units-menu')));
await tester.pump();
expect(find.text('Add local unit'), findsOneWidget);
expect(
find.text('Requires verified source-to-unit binding'),
findsOneWidget,
);
await tester.tap(find.text('Add local unit'));
await tester.pumpAndSettle();
expect(find.text('UNIT HANGAR'), findsOneWidget);
expect(find.text('LOCAL SCENE'), findsAtLeastNWidgets(1));
expect(find.byKey(const Key('unit-hangar-strip')), findsOneWidget);
expect(
find.byKey(const Key('hangar-card-kaptan.vehicle.quadcopter.local')),
findsOneWidget,
);
expect(
find.byKey(const Key('hangar-card-kaptan.vehicle.fixed-wing.local')),
findsOneWidget,
);
expect(
find.byKey(const Key('hangar-card-kaptan.vehicle.tiltrotor-vtol.local')),
findsOneWidget,
);
expect(
find.byKey(const Key('hangar-card-kaptan.vehicle.helicopter.local')),
findsOneWidget,
);
expect(
find.byKey(const Key('hangar-card-kaptan.vehicle.rover.local')),
findsOneWidget,
);
expect(
find.byKey(const Key('hangar-card-kaptan.vehicle.surface-vessel.local')),
findsOneWidget,
);
await tester.tap(
find.byKey(const Key('hangar-card-kaptan.vehicle.quadcopter.local')),
);
await tester.pumpAndSettle();
expect(find.text('PREPARE UNIT'), findsOneWidget);
expect(find.text('QUADCOPTER-01'), findsNothing);
expect(
ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider).unitDraft,
isNotNull,
);
final rectangle = await tester.startGesture(const Offset(620, 500));
await rectangle.moveTo(const Offset(760, 560));
await rectangle.up();
await tester.pumpAndSettle();
final draftState = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider);
expect(draftState.panel, TacticalPanel.unitHangar);
expect(draftState.unitDraft?.definition.name, 'Quadcopter');
await tester.tap(find.text('PREPARE UNIT'));
await tester.pumpAndSettle();
expect(find.text('UNIT SETUP'), findsOneWidget);
expect(find.text('VEHICLE CONFIGURATION UNAVAILABLE'), findsOneWidget);
expect(
find.text(
'Unavailable until verified binding, compatible schema, command authority, and safe activation are available.',
),
findsOneWidget,
);
await tester.tap(find.text('ADD TO LOCAL SCENE'));
await tester.pumpAndSettle();
expect(
ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider).mode,
isA<TacticalBrowseMode>(),
);
expect(find.text('QUADCOPTER-01'), findsAtLeastNWidgets(1));
await tester.tap(find.text('SET UP'));
await tester.pumpAndSettle();
expect(find.text('LOCAL UNIT SETUP'), findsOneWidget);
expect(find.byKey(const Key('local-unit-name-field')), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('dark tactical theme is fixed and uses the palette tokens', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
final context = tester.element(find.byType(AppShell));
expect(
tester.widget<MaterialApp>(find.byType(MaterialApp)).themeMode,
ThemeMode.dark,
);
expect(Theme.of(context).brightness, Brightness.dark);
expect(Theme.of(context).cardColor, panelBackground);
expect(
tester
.widget<Text>(
find.text(
'LOCAL SCENE DATA · NO VEHICLE LINK · COMMAND AUTHORITY UNAVAILABLE',
),
)
.style
?.color,
accentOrange,
);
});
testWidgets('selection console stays local and exposes no vehicle action', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('unit-card-DEMO-1023')));
await tester.pumpAndSettle();
expect(
find.text('LOCAL VISUAL PREVIEW · NO VEHICLE ACTION'),
findsOneWidget,
);
expect(find.text('DEPLOY'), findsNothing);
expect(find.text('CONFIGURE'), findsNothing);
await tester.tap(find.text('EVENT STREAM'));
await tester.pumpAndSettle();
expect(find.text('Event timeline'), findsOneWidget);
await tester.tap(find.text('Tactical workspace'));
await tester.pumpAndSettle();
expect(find.text('TACTICAL COMMAND'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('pencil cursor draws directly on the map with its toolbar', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider.notifier).beginPencil();
await tester.pumpAndSettle();
expect(find.text('PENCIL CURSOR'), findsOneWidget);
expect(find.byKey(const Key('pencil-hue')), findsOneWidget);
expect(find.byKey(const Key('pencil-saturation')), findsOneWidget);
expect(find.byKey(const Key('pencil-brightness')), findsOneWidget);
expect(find.byKey(const Key('pencil-thickness')), findsOneWidget);
await tester.tap(find.byKey(const Key('pencil-swatch-282')));
await tester.drag(
find.byKey(const Key('pencil-thickness')),
const Offset(70, 0),
);
await tester.pump();
final pencil =
ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider).mode
as TacticalPencilMode;
expect(pencil.hue, 282);
expect(pencil.thickness, greaterThan(3));
final stroke = await tester.startGesture(const Offset(620, 500));
await stroke.moveTo(const Offset(680, 540));
await tester.pump(const Duration(milliseconds: 20));
await stroke.moveTo(const Offset(740, 490));
await tester.pump(const Duration(milliseconds: 20));
await stroke.up();
await tester.pumpAndSettle();
expect(find.text('PENCIL CURSOR'), findsOneWidget);
final container = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
);
expect(container.read(tacticalControllerProvider).drawings, hasLength(1));
expect(find.text('DRAWING'), findsNothing);
expect(
tester.getSize(find.byKey(const Key('tactical-map-overlay-painter'))),
isNot(Size.zero),
);
expect(tester.takeException(), isNull);
});
testWidgets('marker cursor names a clicked local marker separately', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('cursor-menu')));
await tester.pump();
await tester.tap(find.text('Marker cursor'));
await tester.pumpAndSettle();
expect(find.text('MARKER CURSOR'), findsOneWidget);
expect(find.byKey(const Key('marker-hue')), findsOneWidget);
expect(find.byKey(const Key('marker-saturation')), findsOneWidget);
expect(find.byKey(const Key('marker-brightness')), findsOneWidget);
await tester.tap(find.byKey(const Key('marker-swatch-208')));
await tester.tapAt(const Offset(700, 520));
await tester.pumpAndSettle();
expect(find.byKey(const Key('marker-name-field')), findsOneWidget);
await tester.enterText(
find.byKey(const Key('marker-name-field')),
'North staging note',
);
await tester.tap(find.text('SAVE MARKER'));
await tester.pumpAndSettle();
final state = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider);
expect(state.markers, hasLength(1));
expect(state.markers.single.name, 'North staging note');
expect(state.markers.single.hue, 208);
expect(state.drawings, isEmpty);
expect(find.text('North staging note'), findsAtLeastNWidgets(1));
expect(tester.takeException(), isNull);
});
testWidgets('a Pencil click alone never creates a marker or drawing', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
);
container.read(tacticalControllerProvider.notifier).beginPencil();
await tester.pumpAndSettle();
await tester.tapAt(const Offset(700, 520));
await tester.pumpAndSettle();
final state = container.read(tacticalControllerProvider);
expect(state.drawings, isEmpty);
expect(state.markers, isEmpty);
expect(find.byKey(const Key('marker-name-field')), findsNothing);
});
testWidgets('map marker selection synchronizes the local selection console', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('map-unit-DEMO-1023')));
await tester.pumpAndSettle();
expect(find.text('DEMO-1023'), findsAtLeastNWidgets(1));
expect(
ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider).selectedUnitIds,
{'DEMO-1023'},
);
});
testWidgets('dragging the tactical canvas creates a local group selection', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
final gesture = await tester.startGesture(const Offset(660, 520));
await gesture.moveTo(const Offset(960, 745));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
final selected = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider).selectedUnitIds;
expect(selected, containsAll(['DEMO-1021', 'DEMO-1023']));
expect(find.text('LOCAL GROUP SELECTION'), findsOneWidget);
});
testWidgets(
'map deselection and hidden demo overlays never select invisibly',
(tester) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('map-unit-DEMO-1023')));
await tester.pump();
await tester.tapAt(const Offset(600, 500));
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
);
expect(
container.read(tacticalControllerProvider).selectedUnitIds,
isEmpty,
);
await tester.tap(find.byKey(const Key('view-menu')));
await tester.pump();
await tester.tap(find.text('Hide Drones'));
await tester.pumpAndSettle();
final gesture = await tester.startGesture(const Offset(660, 520));
await gesture.moveTo(const Offset(960, 745));
await gesture.up();
await tester.pumpAndSettle();
expect(
container.read(tacticalControllerProvider).selectedUnitIds,
isEmpty,
);
},
);
testWidgets('selection cursor ends pencil mode and restores selection', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('cursor-menu')));
await tester.pump();
await tester.tap(find.text('Pencil cursor'));
await tester.pumpAndSettle();
expect(find.text('PENCIL CURSOR'), findsOneWidget);
await tester.tap(find.text('SELECTION CURSOR'));
await tester.pumpAndSettle();
expect(find.text('PENCIL CURSOR'), findsNothing);
expect(FocusManager.instance.primaryFocus, isNotNull);
expect(tester.takeException(), isNull);
});
testWidgets('hangar selection prepares a local unit before confirmation', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('units-menu')));
await tester.pump();
await tester.tap(find.text('Add local unit'));
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const Key('hangar-card-kaptan.vehicle.quadcopter.local')),
);
await tester.pumpAndSettle();
final scope = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
);
expect(scope.read(tacticalControllerProvider).unitDraft, isNotNull);
expect(scope.read(vehicleWorkspaceProvider).units, isEmpty);
await tester.tap(find.text('PREPARE UNIT'));
await tester.pumpAndSettle();
final name = find.byKey(const Key('local-unit-name-field'));
await tester.tap(name);
await tester.enterText(name, 'North survey');
await tester.pumpAndSettle();
await tester.tap(find.text('ADD TO LOCAL SCENE'));
await tester.pumpAndSettle();
expect(find.text('North survey'), findsAtLeastNWidgets(1));
expect(scope.read(vehicleWorkspaceProvider).units, hasLength(1));
expect(
scope.read(vehicleWorkspaceProvider).units.single.displayName,
'North survey',
);
expect(
ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider).mode,
isA<TacticalBrowseMode>(),
);
});
testWidgets('hangar keeps a draft on back and blocks an invalid local name', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('units-menu')));
await tester.pump();
await tester.tap(find.text('Add local unit'));
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const Key('hangar-card-kaptan.vehicle.fixed-wing.local')),
);
await tester.pumpAndSettle();
await tester.tap(find.text('PREPARE UNIT'));
await tester.pumpAndSettle();
final name = find.byKey(const Key('local-unit-name-field'));
await tester.tap(name);
await tester.enterText(name, ' ');
await tester.tap(find.text('ADD TO LOCAL SCENE'));
await tester.pumpAndSettle();
expect(
find.text('Enter a local unit name of up to 32 characters.'),
findsOneWidget,
);
final scope = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
);
expect(scope.read(vehicleWorkspaceProvider).units, isEmpty);
await tester.tap(find.text('BACK TO HANGAR'));
await tester.pumpAndSettle();
expect(find.text('FIXED-WING SELECTED'), findsOneWidget);
expect(
scope.read(tacticalControllerProvider).unitDraft?.definition.name,
'Fixed-wing',
);
});
testWidgets('hangar supports arrow selection and Escape focus return', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('units-menu')));
await tester.pump();
await tester.tap(find.text('Add local unit'));
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pumpAndSettle();
final scope = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
);
expect(
scope.read(tacticalControllerProvider).unitDraft?.definition.name,
'Fixed-wing',
);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.text('UNIT HANGAR'), findsNothing);
final trigger = find.descendant(
of: find.byKey(const Key('units-menu')),
matching: find.byType(TextButton),
);
expect(tester.widget<TextButton>(trigger).focusNode?.hasFocus, isTrue);
});
testWidgets('hangar and setup remain usable at 200 percent text scale', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1440, 960));
tester.binding.platformDispatcher.textScaleFactorTestValue = 2;
addTearDown(() {
tester.binding.setSurfaceSize(null);
tester.binding.platformDispatcher.clearTextScaleFactorTestValue();
});
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
final scope = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
);
final controller = scope.read(tacticalControllerProvider.notifier);
controller.showUnitHangar();
await tester.pumpAndSettle();
expect(find.byKey(const Key('unit-hangar-strip')), findsOneWidget);
controller.chooseUnitDefinition(
supportedVehicleDefinitions.first,
displayName: 'QUADCOPTER-01',
);
controller.beginUnitSetup();
await tester.pumpAndSettle();
expect(find.text('UNIT SETUP'), findsOneWidget);
expect(find.byKey(const Key('local-unit-name-field')), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('hangar stays available in the short desktop arrangement', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 760));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
final scope = ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
);
scope.read(tacticalControllerProvider.notifier).showUnitHangar();
await tester.pumpAndSettle();
expect(find.text('UNIT HANGAR'), findsOneWidget);
expect(find.byKey(const Key('unit-hangar-strip')), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('secondary click cancels the pencil cursor', (tester) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('cursor-menu')));
await tester.pump();
await tester.tap(find.text('Pencil cursor'));
await tester.pumpAndSettle();
expect(find.text('PENCIL CURSOR'), findsOneWidget);
final pointer = TestPointer(42, PointerDeviceKind.mouse);
await tester.sendEventToBinding(
pointer.down(const Offset(700, 520), buttons: kSecondaryButton),
);
await tester.sendEventToBinding(pointer.up());
await tester.pumpAndSettle();
expect(find.text('PENCIL CURSOR'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('keyboard pencil keeps a visible draft before finishing', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('cursor-menu')));
await tester.pump();
await tester.tap(find.text('Pencil cursor'));
await tester.pumpAndSettle();
for (var index = 0; index < 3; index++) {
await tester.tap(find.text('ADD LOCAL POINT'));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
}
await tester.pumpAndSettle();
expect(find.text('LOCAL POINTS: 3'), findsOneWidget);
expect(find.text('FINISH DRAWING'), findsOneWidget);
await tester.tap(find.text('FINISH DRAWING'));
await tester.pumpAndSettle();
expect(find.text('PENCIL CURSOR'), findsOneWidget);
});
testWidgets('compact command dashboard remains usable without overflow', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(480, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
expect(find.text('SIMULATED CAMERA CONCEPT'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('command dashboard handles 200 percent text scale', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1440, 960));
tester.binding.platformDispatcher.textScaleFactorTestValue = 2;
addTearDown(() {
tester.binding.setSurfaceSize(null);
tester.binding.platformDispatcher.clearTextScaleFactorTestValue();
});
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
expect(
find.text(
'LOCAL SCENE DATA · NO VEHICLE LINK · COMMAND AUTHORITY UNAVAILABLE',
),
findsOneWidget,
);
expect(tester.takeException(), isNull);
});
testWidgets('pencil toolbar reflows at 200 percent text scale', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1440, 960));
tester.binding.platformDispatcher.textScaleFactorTestValue = 2;
addTearDown(() {
tester.binding.setSurfaceSize(null);
tester.binding.platformDispatcher.clearTextScaleFactorTestValue();
});
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
ProviderScope.containerOf(
tester.element(find.byType(TacticalCommandPage)),
).read(tacticalControllerProvider.notifier).beginPencil();
await tester.pumpAndSettle();
expect(find.text('PENCIL CURSOR'), findsOneWidget);
expect(find.byKey(const Key('pencil-thickness')), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('short desktop window uses the non-overlapping command layout', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1366, 768));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
expect(find.text('SIMULATED CAMERA CONCEPT'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
@@ -0,0 +1,69 @@
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/');
await expect(page.getByText('ALPHA-17', { exact: true })).toBeVisible();
await expect(page.getByTestId('operational-map')).toBeVisible();
});
test('former workspaces preserve the same map, camera, selection, and live telemetry', async ({ page }) => {
await page.evaluate(() => { (document.querySelector('[data-testid="operational-map"]') as HTMLElement & { __persistent?: symbol }).__persistent = Symbol.for('persistent-map'); });
const originalCamera = await page.getByTestId('operational-map').getAttribute('data-camera');
await expect(page.getByRole('complementary', { name: 'Fleet roster and control groups' })).toBeVisible();
for (const tool of ['Queue', 'Events', 'Configuration', 'Recovery', 'Settings']) {
await page.getByRole('button', { name: tool, exact: true }).first().click();
await expect(page.getByTestId('operational-map')).toBeAttached();
expect(await page.evaluate(() => (document.querySelector('[data-testid="operational-map"]') as HTMLElement & { __persistent?: symbol }).__persistent === Symbol.for('persistent-map'))).toBe(true);
expect(await page.getByTestId('operational-map').getAttribute('data-camera')).toBe(originalCamera);
}
await expect(page.getByText(/12 VEHICLES/).first()).toBeVisible();
await expect(page.getByText('LIVE TELEMETRY CONTINUES')).toBeVisible();
});
test('control groups remain local and double recall frames selected units', async ({ page }) => {
await page.keyboard.press('Control+2');
await expect(page.getByRole('status')).toContainText('Control group 2 assigned');
await page.keyboard.press('2'); await page.keyboard.press('2');
await expect(page.getByTestId('operational-map')).toHaveAttribute('data-camera', /1\.50|2\.10/);
});
test('route geometry completes into review and can be cancelled without issuing', async ({ page }) => {
await page.getByRole('button', { name: 'Route', exact: true }).first().click();
const map = page.getByTestId('operational-map'); const box = await map.boundingBox(); if (!box) throw new Error('map has no bounds');
await page.mouse.click(box.x + box.width * .15, box.y + box.height * .68);
await page.mouse.click(box.x + box.width * .43, box.y + box.height * .74);
await page.mouse.click(box.x + box.width * .72, box.y + box.height * .69);
await page.keyboard.press('Enter');
await expect(page.getByLabel('Command draft review')).toContainText('3 WAYPOINTS');
await expect(page.getByLabel('Command draft review')).toContainText('LOCAL ROUTE DRAFT · NOT SENT');
await page.getByLabel('Command draft review').getByRole('button', { name: 'CANCEL' }).click();
await expect(page.getByLabel('Command draft review')).not.toBeAttached();
});
test('map annotations use draft history undo and redo', async ({ page }) => {
await page.getByRole('button', { name: 'Map creation tools' }).click();
await page.getByRole('button', { name: 'Marker' }).click();
const map = page.getByTestId('operational-map'); const box = await map.boundingBox(); if (!box) throw new Error('map has no bounds');
await page.mouse.click(box.x + box.width * .55, box.y + box.height * .42);
await page.keyboard.press('Control+z'); await page.keyboard.press('Control+y');
await expect(map.locator('.draft-shape.complete')).toHaveCount(1);
});
test('deploys fifty provisional simulated vehicles in one cohort', async ({ page }) => {
await page.keyboard.press('v'); await expect(page.getByText('VEHICLE DEPLOYMENT · PROVISIONAL')).toBeVisible();
await page.getByRole('button', { name: 'STAGE 50 GHOSTS' }).click();
await expect(page.getByRole('status')).toContainText('50 provisional'); await expect(page.getByText('50 STAGED')).toBeVisible();
});
test('commissioning remains docked over visible map and exposes calibration and diffs', async ({ page }) => {
await page.getByRole('button', { name: 'Configuration' }).click();
await expect(page.getByText('DOCKED COMMISSIONING')).toBeVisible(); await expect(page.getByTestId('operational-map')).toBeVisible();
await page.getByRole('button', { name: 'SENSORS', exact: true }).click(); await expect(page.getByText('GYRO RESIDUAL')).toBeVisible();
await page.getByRole('button', { name: 'PARAMETERS', exact: true }).click(); await expect(page.getByText('BASELINE → CURRENT → DESIRED')).toBeVisible();
});
for (const viewport of [{ width: 1366, height: 768 }, { width: 1920, height: 1080 }, { width: 3440, height: 1440 }]) test(`scene fits ${viewport.width}x${viewport.height}`, async ({ page }) => {
await page.setViewportSize(viewport); const map = page.getByTestId('operational-map'); await expect(map).toBeVisible();
const box = await map.boundingBox(); expect(box).not.toBeNull(); expect(box!.width).toBeGreaterThan(viewport.width * .3); expect(box!.height).toBeGreaterThan(viewport.height * .45);
const overflow = await page.evaluate(() => ({ x: document.documentElement.scrollWidth - innerWidth, y: document.documentElement.scrollHeight - innerHeight })); expect(overflow.x).toBeLessThanOrEqual(0); expect(overflow.y).toBeLessThanOrEqual(0);
});
@@ -0,0 +1,29 @@
import { expect, test, type Page, type TestInfo } from '@playwright/test';
const viewports = [{ width: 1366, height: 768 }, { width: 1920, height: 1080 }, { width: 3440, height: 1440 }];
const ready = async (page: Page) => { await page.goto('/'); await expect(page.getByTestId('operational-map')).toBeVisible(); };
for (const viewport of viewports) test(`renders the eight spatial compositions at ${viewport.width}x${viewport.height}`, async ({ page }, testInfo: TestInfo) => {
await page.setViewportSize(viewport);
const shot = (name: string) => page.screenshot({ path: testInfo.outputPath(`${name}-${viewport.width}x${viewport.height}.png`), animations: 'disabled' });
await ready(page); await shot('01-live-tactical');
await page.getByText('Low reserve', { exact: true }).click(); await expect(page.getByText(/VEHICLES/).first()).toBeVisible(); await shot('02-large-multiselection');
await ready(page); await page.getByRole('button', { name: 'Route', exact: true }).first().click();
{ const box = await page.getByTestId('operational-map').boundingBox(); if (!box) throw new Error('map has no bounds'); for (const [x, y] of [[.15,.68],[.43,.74],[.72,.69]]) await page.mouse.click(box.x + box.width * x, box.y + box.height * y); }
await page.keyboard.press('Enter'); await expect(page.getByLabel('Command draft review')).toBeVisible(); await shot('03-route-formation-draft');
await ready(page); await page.getByRole('button', { name: 'Map creation tools' }).click(); await page.getByRole('button', { name: 'Polygon', exact: true }).click();
{ const box = await page.getByTestId('operational-map').boundingBox(); if (!box) throw new Error('map has no bounds'); for (const [x, y] of [[.18,.62],[.38,.70],[.44,.48],[.24,.42]]) await page.mouse.click(box.x + box.width * x, box.y + box.height * y); }
await page.keyboard.press('Enter'); await shot('04-task-area-drawing');
await ready(page); await page.getByRole('button', { name: 'Deploy', exact: true }).click(); await page.getByRole('button', { name: 'DISCOVER', exact: true }).click(); await expect(page.getByText('KPT-F722-9A31')).toBeVisible(); await shot('05-deployment-discovery');
await ready(page); await page.getByRole('button', { name: 'Configuration' }).click(); await page.getByRole('button', { name: 'SENSORS', exact: true }).click(); await expect(page.getByText('GYRO RESIDUAL')).toBeVisible(); await shot('06-sensor-radio-calibration');
await page.getByRole('button', { name: 'PARAMETERS', exact: true }).click(); await expect(page.getByText('BASELINE → CURRENT → DESIRED')).toBeVisible(); await shot('07-parameter-configuration-diff');
await ready(page); await page.getByRole('button', { name: 'Recovery' }).click(); await expect(page.getByText('DEGRADED OPERATIONS')).toBeVisible(); await shot('08-degraded-recovery');
});
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"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",
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src", "tests", "vite.config.ts", "vitest.config.ts", "playwright.config.ts"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
cacheDir: '/tmp/kaptan-ground-suite-vite-cache',
server: { host: '127.0.0.1', port: 4173 },
preview: { host: '127.0.0.1', port: 4173 },
});
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
cacheDir: '/tmp/kaptan-ground-suite-vitest-cache',
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
restoreMocks: true,
},
});