Compare commits

...
Author SHA1 Message Date
Onur Özkan 6e15063502 save
Signed-off-by: Onur Özkan <work@onurozkan.dev>
2026-08-17 18:51:40 +03:00
122 changed files with 7848 additions and 9828 deletions

No files matched your search

+37 -42
View File
@@ -1,72 +1,67 @@
# Ground Suite
`ground-suite/` is Kaptan's desktop Flutter application. Its primary
workspace is the Tactical Command surface: a spatial local scene, local units,
and session-local drawings and markers. The decoded diagnostic event stream is
always reachable from the tactical header.
`ground-suite/` is Kaptan's web-based mission-control workspace. It is a
map-first React and TypeScript application for working from mission intent to
tasks, groups, roles, vehicles, and explicit engineering detail.
> **Simulation boundary:** the first release always displays deterministic
> simulated data. It has no live Kaptan or Cerebrum connection and cannot
> command a physical vehicle.
## Application model
The core model keeps these concepts independent:
The core model keeps these categories independent:
- **Vehicle definition** — immutable reviewed profile, catalog identity, and
bundled presentation media.
- **Local fleet unit** — an operator-created instance of a vehicle definition.
Local-unit IDs and in-memory planning placements belong to the workspace,
not the vehicle definition. Current placement is local presentation data and
is not yet persisted.
- **Received vehicle data** — transport input and diagnostic events. It cannot
create, position, identify, or authorize a local fleet unit.
- **Mission truth** — source-owned missions, tasks, groups, vehicles,
operators, authority, alerts, and command receipts.
- **Derived projections** — fleet health, task coverage, selection summaries,
and deterministic recommendations.
- **Local UI state and preferences** — panels, viewport, layers, density,
reduced motion, and configurable hotkeys.
- **Audit journal** — ordered source and command envelopes.
This separation gives the fleet interface a stable path from local planning to
future live operation without coupling visual selection to incoming telemetry.
## Tactical workspace
The default route is Tactical Command. Its map-edge `UNITS`, `CURSOR`, and
`VIEW` groups are local interactions, not MAVLink controls. `Selection cursor`
is the default mode for map and rectangle selection. `Pencil cursor` creates
session-local freehand strokes; its top toolbar controls hue, saturation,
brightness, and thickness. `Marker cursor` has an independent full-color
style and creates a named local marker only after a map click and explicit
save. The shared disclosure keeps local scene data,
vehicle link state, and command authority separate. Local drawings and markers
are never reported vehicle positions.
The central map uses an owned offline demonstration raster and local SVG/DOM
overlays. Four semantic levels progressively disclose group, formation,
vehicle telemetry, and engineering diagnostics. Click, Shift selection,
rectangle selection, structured smart filters, configurable group keys, and
accessible tables all operate on the same explicit selection model. Selection
never grants command authority.
`Add local unit` opens the horizontal local hangar. Choosing a definition only
prepares a local unit draft; the explicit setup confirmation creates and
selects a locally scattered scene unit, without asking the operator to click
the map. Local setup currently permits only the workspace label. Vehicle
configuration remains unavailable until verified binding, compatible schema,
command authority, and safe activation are available. This presentation
placement is temporary until reviewed MAVLink position data can drive a
distinct reported-position layer. The right inspector and bottom selection
console are available outside the map for keyboard and assistive-technology
access. These interactions create only session-local workspace data.
Consequential actions go through an exact-target preview covering authority,
energy, link, staleness, constraints, connectivity, next state, and failure
effect. The deterministic source then emits an acknowledgement or rejection
into the same journal used for live mission projection.
## Architecture
```text
VehicleDataPort -> typed observation updates -> observability state -> event stream
Vehicle catalog -> local-unit workspace state -> tactical scene and unit inspector
DataSource -> validation -> ordering/deduplication -> DataPlane journal
-> mission projection/selectors -> useSyncExternalStore -> React
UI intent -> preview/authority/safety -> confirmation -> DataSource receipt
-> DataPlane journal -> mission projection
```
The transport path and local-fleet path intentionally meet only through future
explicit bindings. A future MAVLink adapter combines pinned MAVLink Common and
Kaptan dialect definitions at the decoding boundary.
The fake and future Kaptan sources implement the same transport-neutral
contract. React never imports the fake adapter. See `ground-suite/docs/` for
the complete boundary and integration unknowns.
## Follow-up work
- TODO(gcs-fleet-storage): versioned persistence for local units and catalog
identities.
- TODO(gcs-source-binding): explicit source-to-unit binding.
- TODO(gcs-source-binding): authenticated explicit source-to-vehicle binding.
- TODO(gcs-map-pose): coordinate datum, timestamped pose, uncertainty, and
staleness handling for physical map placement.
- TODO(gcs-map-renderer): owned Linux GPU map host, pinned MapLibre assets,
and reviewed vector/DEM data source.
- TODO(gcs-platform-match): compare reported build/schema metadata to the
local definition.
- TODO(gcs-command-surface): add command interaction only with its reviewed
authority, target-binding, and safety contracts.
- TODO(gcs-command-surface): add a command transport only after its authority,
authentication, replay protection, target binding, and acknowledgement
contracts are reviewed.
- TODO(gcs-catalog): generate installed definitions and category metadata from
reviewed firmware build manifests.
+64
View File
@@ -10,8 +10,64 @@
"aarch64-linux"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
perSystem = system:
let
pkgs = nixpkgs.legacyPackages.${system};
groundSuite = pkgs.buildNpmPackage {
pname = "kaptan-ground-suite";
version = "0.1.0";
src = ./ground-suite;
npmDepsHash = "sha256-l217EpOz2oEBQK6bzJV0DSEdBkK7HIlB4a7JMF+FadA=";
npmBuildScript = "check";
PLAYWRIGHT_CHROMIUM_EXECUTABLE = "${pkgs.chromium}/bin/chromium";
installPhase = ''
runHook preInstall
mkdir -p $out/libexec/ground-suite
cp -R dist node_modules src public scripts tests package.json package-lock.json tsconfig.json tsconfig.app.json vite.config.ts vitest.config.ts playwright.config.ts $out/libexec/ground-suite/
runHook postInstall
'';
};
groundSuiteCommand = pkgs.writeShellApplication {
name = "ground-suite";
runtimeInputs = [ pkgs.nodejs pkgs.coreutils pkgs.chromium ];
text = ''
suite_root=${groundSuite}/libexec/ground-suite
if [ "''${1:-}" = test ]; then
shift
test_root=$(mktemp -d -t kaptan-ground-suite-test.XXXXXX)
trap 'rm -rf "$test_root"' EXIT
cp -R "$suite_root" "$test_root/suite"
chmod -R u+w "$test_root/suite"
cd "$test_root/suite"
export PLAYWRIGHT_CHROMIUM_EXECUTABLE=${pkgs.chromium}/bin/chromium
npm test -- "$@"
exit $?
fi
exec node "$suite_root/scripts/server.mjs" "$@"
'';
};
in { inherit pkgs groundSuite groundSuiteCommand; };
in
{
packages = forAllSystems (system:
let values = perSystem system; in {
ground-suite = values.groundSuite;
default = values.groundSuite;
});
apps = forAllSystems (system:
let values = perSystem system; in {
ground-suite = {
type = "app";
program = "${values.groundSuiteCommand}/bin/ground-suite";
};
});
checks = forAllSystems (system:
let values = perSystem system; in {
ground-suite = values.groundSuite;
});
devShells = forAllSystems (system:
let
pkgs = nixpkgs.legacyPackages.${system};
@@ -30,6 +86,14 @@
python
];
};
ground-suite = pkgs.mkShell {
packages = [ pkgs.nodejs ];
shellHook = ''
echo "Kaptan Ground Suite development shell (Node $(node --version), npm $(npm --version))"
echo "Run: cd ground-suite && npm ci && npm run dev"
echo "SIMULATED DATA ONLY"
'';
};
});
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

+360
View File
@@ -0,0 +1,360 @@
/* Atlas Mission Control POC — all telemetry and operators are fictional. */
const groups = [
{ id: 'alpha', name: 'ALPHA', count: 12, task: 'Area Search', operator: 'Alex', progress: 82, health: 'nominal', color: 'cyan', icon: 'A', current: 'Search area north', next: 'Return', lease: 'Alex' },
{ id: 'bravo', name: 'BRAVO', count: 10, task: 'Route Recon', operator: 'Sara', progress: 71, health: 'nominal', color: 'blue', icon: 'B', current: 'Coastal route', next: 'Survey pass', lease: 'Sara' },
{ id: 'charlie', name: 'CHARLIE', count: 8, task: 'Relay', operator: 'David', progress: 64, health: 'nominal', color: 'violet', icon: 'C', current: 'Relay station', next: 'Hold', lease: 'David' },
{ id: 'delta', name: 'DELTA', count: 12, task: 'Reserve', operator: 'Maria', progress: 35, health: 'attention', color: 'amber', icon: 'D', current: 'Ready reserve', next: 'Awaiting task', lease: 'Maria' },
{ id: 'reserve', name: 'RESERVE', count: 8, task: 'Available', operator: 'Ken', progress: 100, health: 'nominal', color: 'violet', icon: 'R', current: 'Base service', next: 'Available', lease: 'Ken' }
];
const operators = [
{ id: 'alex', name: 'Alex', role: 'Commanding Alpha', color: 'cyan', activity: 'selected Alpha' },
{ id: 'sara', name: 'Sara', role: 'Commanding Bravo', color: 'green', activity: 'updated Bravo search area' },
{ id: 'david', name: 'David', role: 'Planning', color: 'amber', activity: 'drafted Task-144' },
{ id: 'maria', name: 'Maria', role: 'Monitoring', color: 'violet', activity: 'added monitoring note' },
{ id: 'ken', name: 'Ken', role: 'Engineering', color: 'yellow', activity: 'updated comms plan' }
];
const alphaVehicles = [
['UAV-01', 18, 32, 92], ['UAV-02', 25, 39, 86], ['UAV-03', 33, 28, 43],
['UAV-04', 39, 44, 80], ['UAV-05', 45, 35, 74], ['UAV-06', 53, 45, 88],
['UAV-07', 60, 30, 74], ['UAV-08', 66, 42, 83], ['UAV-09', 72, 35, 79],
['UAV-10', 76, 51, 90], ['UAV-11', 55, 57, 82], ['UAV-12', 34, 57, 76]
].map((item, index) => ({ id: item[0], x: item[1], y: item[2], energy: item[3], heading: 20 + index * 16, status: item[0] === 'UAV-03' ? 'critical' : 'nominal' }));
const availableVehicles = [
['ALP-01', 18, 50, 92], ['ALP-02', 31, 47, 71], ['ALP-03', 17, 35, 82], ['ALP-04', 72, 42, 65],
['ALP-05', 71, 55, 80], ['ALP-06', 64, 69, 74], ['ALP-07', 35, 18, 78], ['ALP-08', 38, 72, 61]
].map(item => ({ id: item[0], x: item[1], y: item[2], energy: item[3], heading: 30, status: 'nominal' }));
const requestedView = new URLSearchParams(window.location.search).get('view');
const validViews = ['overview', 'zoom', 'selection', 'collaboration', 'recovery', 'queue', 'system'];
let state = {
view: validViews.includes(requestedView) ? requestedView : 'overview',
selectedGroup: 'alpha',
selectedVehicle: 'UAV-07',
selectedCandidate: 'UAV-19',
modal: null,
toast: '',
layersOpen: false,
reassigned: false,
queueDraft: false,
queue: [
{ title: 'Transit', detail: 'to Northern Sector', duration: '8 min', status: 'completed' },
{ title: 'Search Area A', detail: 'coverage pattern', duration: '18 min', status: 'current' },
{ title: 'Loiter', detail: '6 min', duration: '6 min', status: 'queued' },
{ title: 'Return', detail: 'staging point', duration: '12 min', status: 'queued' },
{ title: 'Recharge', detail: 'base service', duration: '35 min', status: 'queued' },
{ title: 'Search Area B', detail: 'coverage pattern', duration: '20 min', status: 'queued' }
]
};
const icons = {
menu: '<path d="M4 7h16M4 12h16M4 17h16"/>',
map: '<path d="m4 6 5-2 6 2 5-2v14l-5 2-6-2-5 2V6Z"/><path d="M9 4v14m6-12v14"/>',
layers: '<path d="m12 3 8 4.5-8 4.5-8-4.5L12 3Z"/><path d="m4 12 8 4.5 8-4.5M4 16.5l8 4.5 8-4.5"/>',
plane: '<path d="m12 2 2.4 7.1 5.6 2.1v1.6l-6.2.5-1.1 7-1.5.1-1.2-7-6.2-.5v-1.6l5.6-2.1L12 2Z"/>',
people: '<circle cx="9" cy="8" r="3"/><circle cx="16.5" cy="9" r="2.5"/><path d="M3.5 20c.5-3.5 2.4-5.3 5.5-5.3s5 1.8 5.5 5.3M14.2 15.2c3.1-.6 5.5 1.1 6.1 4.8"/>',
task: '<rect x="5" y="4" width="14" height="17" rx="1"/><path d="M8 4.5h8v3H8zM8 12h8M8 16h5"/>',
alert: '<path d="M12 3 21 20H3L12 3Z"/><path d="M12 9v5m0 3h.01"/>',
shield: '<path d="M12 3 20 6v5c0 5-3.2 8.2-8 10-4.8-1.8-8-5-8-10V6l8-3Z"/><path d="m8.5 12 2.2 2.2 4.8-5"/>',
chevron: '<path d="m8 10 4 4 4-4"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
close: '<path d="m6 6 12 12M18 6 6 18"/>',
target: '<circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="2"/><path d="M12 2v3m0 14v3M2 12h3m14 0h3"/>',
transfer: '<path d="M4 8h12l-3-3m3 3-3 3M20 16H8l3 3m-3-3 3-3"/>',
hold: '<path d="M8 5v14M16 5v14"/>',
move: '<path d="m4 11 16-7-7 16-2-7-7-2Z"/>',
clock: '<circle cx="12" cy="12" r="8"/><path d="M12 7v5l3 2"/>',
check: '<path d="m5 12 4.2 4.2L19 6.7"/>',
search: '<circle cx="10.5" cy="10.5" r="5.5"/><path d="m15 15 5 5"/>',
info: '<circle cx="12" cy="12" r="8"/><path d="M12 11v5m0-8h.01"/>',
bolt: '<path d="m13 2-8 12h6l-1 8 8-12h-6l1-8Z"/>',
route: '<path d="M5 18c3-8 6-12 14-12"/><circle cx="5" cy="18" r="2"/><path d="m16 4 3 2-3 2"/>',
settings: '<circle cx="12" cy="12" r="3"/><path d="M19 12a7.2 7.2 0 0 0-.1-1.2l2-1.5-2-3.4-2.4 1a7 7 0 0 0-2-1.1L14.2 3h-4.1l-.4 2.8a7 7 0 0 0-2 1.1l-2.4-1-2 3.4 2 1.5a7.2 7.2 0 0 0 0 2.4l-2 1.5 2 3.4 2.4-1a7 7 0 0 0 2 1.1l.4 2.8h4.1l.4-2.8a7 7 0 0 0 2-1.1l2.4 1 2-3.4-2-1.5c.1-.4.1-.8.1-1.2Z"/>'
};
function icon(name, extra = '') {
return '<svg class="icon ' + extra + '" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' + (icons[name] || icons.info) + '</svg>';
}
function groupById(id) {
return groups.find(group => group.id === id) || groups[0];
}
function statusTag(status, label) {
const text = label || ({ nominal: 'Nominal', attention: 'Attention', critical: 'Critical', planned: 'Planned' }[status] || status);
return '<span class="status ' + status + '"><span class="status-dot"></span>' + text + '</span>';
}
function button(label, action, options = {}) {
const cls = 'button ' + (options.kind || 'secondary') + (options.iconOnly ? ' icon-button' : '') + (options.className ? ' ' + options.className : '');
const attrs = (options.disabled ? ' disabled' : '') + (options.label ? ' aria-label="' + options.label + '"' : '') + (options.value ? ' data-value="' + options.value + '"' : '');
return '<button class="' + cls + '" data-action="' + action + '"' + attrs + '>' + (options.icon ? icon(options.icon) : '') + (options.iconOnly ? '' : '<span>' + label + '</span>') + '</button>';
}
function navItem(id, label, iconName) {
return '<button class="nav-item ' + (state.view === id ? 'is-active' : '') + '" data-action="set-view" data-value="' + id + '">' + icon(iconName) + '<span>' + label + '</span></button>';
}
function topBar() {
return '<header class="topbar">' +
'<div class="brand">' + button('', 'toggle-menu', { icon: 'menu', iconOnly: true, label: 'Open navigation' }) +
'<div class="brand-mark">A</div><span>ATLAS</span><span class="brand-divider"></span><span class="mission-name">Mission Alpha-17</span></div>' +
'<nav class="view-nav" aria-label="POC screens">' +
navItem('overview', 'Mission', 'map') + navItem('zoom', 'Zoom', 'target') + navItem('selection', 'Selection', 'search') +
navItem('collaboration', 'Collaboration', 'people') + navItem('recovery', 'Recovery', 'alert') + navItem('queue', 'Intent queue', 'route') + navItem('system', 'System', 'settings') +
'</nav>' +
'<div class="top-metrics">' +
'<div><small>Fleet</small><strong class="green">48 / 50 active</strong></div><div><small>Tasks</small><strong>7 active</strong></div><div><small>Alerts</small><strong class="amber">3</strong></div>' +
'<div><small>Operators</small><strong class="green">5 online</strong></div><div class="hide-at-wide"><small>Network</small><strong class="green">Healthy</strong></div>' +
'<div class="operator-avatar"><span>AX</span><div><strong>Alex</strong><small>Mission operator</small></div></div>' +
'</div></header>';
}
function mapBase() {
return '<svg class="map-art" viewBox="0 0 1000 700" preserveAspectRatio="none" aria-hidden="true">' +
'<defs><filter id="soft"><feGaussianBlur stdDeviation="9"/></filter><pattern id="grid" width="64" height="64" patternUnits="userSpaceOnUse"><path d="M0 0H64V64" fill="none" stroke="#6993b0" stroke-opacity=".11"/></pattern></defs>' +
'<rect width="1000" height="700" fill="url(#grid)"/><path class="land" d="M0 0H352C397 63 392 126 447 179c48 47 30 105 87 162 35 35 30 116-11 151-47 40-126 53-169 109-42 55-8 102-54 99H0Z"/>' +
'<path class="coast" d="M352 0c45 63 40 126 95 179 48 47 30 105 87 162 35 35 30 116-11 151-47 40-126 53-169 109-42 55-8 102-54 99"/><path class="river" d="M55 12c92 74 95 150 153 213 48 52 81 73 87 143 5 58-31 87-25 155 4 42 34 85 44 135"/>' +
'<path class="road" d="M5 520C111 460 148 440 216 376s121-73 177-147c54-70 87-54 158-73 92-24 115-112 233-119"/><path class="road thin" d="M92 52c24 85 85 117 148 161 49 34 45 85 96 121M64 612c84-14 136-81 223-84 97-3 117-68 194-75"/>' +
'<path class="hill" d="M62 104c56-48 121-47 164-16m-143 55c59-49 115-37 156-14M49 587c63-42 116-33 159-7"/><path class="hill" d="M120 245c34-26 71-31 108-20m-67 76c41-34 93-25 131-1"/>' +
'<g class="town"><circle cx="208" cy="381" r="20"/><circle cx="208" cy="381" r="7"/><circle cx="114" cy="526" r="13"/><circle cx="114" cy="526" r="4"/></g>' +
'</svg>' +
'<span class="map-label north-point" style="left:14%;top:20%">North Point</span><span class="map-label cliffside" style="left:58%;top:26%">Cliffside</span><span class="map-label seabridge" style="left:42%;top:68%">Seabridge</span><span class="map-label west-cove" style="left:16%;top:74%">West Cove</span>' +
'<div class="scale"><i></i><span>2 km</span></div>';
}
function mapControls() {
return '<div class="map-controls">' + button('', 'toggle-layers', { icon: 'layers', iconOnly: true, label: 'Toggle map layers' }) + button('', 'map-zoom-in', { icon: 'plus', iconOnly: true, label: 'Zoom map in' }) + button('−', 'map-zoom-out', { iconOnly: true, label: 'Zoom map out' }) + '</div>' +
(state.layersOpen ? '<div class="layers-popover"><strong>Map layers</strong><label><input type="checkbox" checked> Search areas</label><label><input type="checkbox" checked> Planned routes</label><label><input type="checkbox" checked> Constraints</label><label><input type="checkbox"> Map labels</label></div>' : '');
}
function taskArea(label = 'ALPHA\nSEARCH AREA', classes = '') {
return '<div class="task-area ' + classes + '"><div class="task-shape"></div><span>' + label.replace('\n', '<br>') + '</span></div>';
}
function groupMarker(group, x, y, active = false) {
return '<button class="group-marker ' + group.color + (active ? ' is-selected' : '') + '" style="left:' + x + '%;top:' + y + '%" data-action="select-group" data-value="' + group.id + '">' +
'<span class="marker-dot"><b>' + group.icon + '</b></span><span class="marker-card"><strong>' + group.name + '</strong><small>' + group.count + ' UAV · ' + group.task + ' · ' + group.progress + '%</small></span></button>';
}
function vehicleMarker(vehicle, selected = false, label = false) {
const critical = vehicle.status === 'critical';
return '<button class="vehicle-marker ' + (critical ? 'critical' : '') + (selected ? ' selected' : '') + '" style="left:' + vehicle.x + '%;top:' + vehicle.y + '%;--heading:' + vehicle.heading + 'deg" data-action="select-vehicle" data-value="' + vehicle.id + '" aria-label="' + vehicle.id + ', ' + vehicle.energy + '% energy">' +
icon('plane') + (label ? '<span>' + vehicle.id + '</span>' : '') + '</button>';
}
function routeLine(className = '') {
return '<svg class="route-line ' + className + '" viewBox="0 0 1000 700" preserveAspectRatio="none" aria-hidden="true"><path d="M180 535C195 463 238 445 282 384S367 280 433 256c76-27 100-91 179-102 82-11 124-55 217-37" fill="none"/><path class="planned-path" d="M610 142c86 55 127 80 166 168 27 59 72 67 151 104" fill="none"/></svg>';
}
function overviewMap() {
const g = groupById(state.selectedGroup);
return '<div class="map-canvas overview-map">' + mapBase() + mapControls() + routeLine() + taskArea(g.name + '\nAREA ' + (g.task === 'Area Search' ? 'SEARCH' : g.task.toUpperCase()), 'alpha-area') +
'<div class="coverage-ring"></div>' +
alphaVehicles.map(vehicle => vehicleMarker(vehicle, false, vehicle.id === 'UAV-03')).join('') +
groupMarker(groups[1], 65, 27) + groupMarker(groups[2], 69, 50) + groupMarker(groups[3], 73, 68) + groupMarker(groups[4], 57, 82) +
'<div class="geofence red-zone"></div>' +
'<div class="map-mini-exceptions"><strong>Exceptions (3)</strong><p>' + icon('alert') + ' UAV-27 · Navigation degraded</p><p>' + icon('info') + ' Alpha task behind schedule</p><a href="#" data-action="set-view" data-value="recovery">Review recovery</a></div>' +
'</div>';
}
function groupRail(compact = false) {
return '<aside class="group-rail ' + (compact ? 'compact' : '') + '"><div class="rail-heading"><span>Groups</span>' + button('', 'new-group', { icon: 'plus', iconOnly: true, label: 'Create a new group' }) + '</div>' +
'<div class="group-list">' + groups.map(group => {
const selected = state.selectedGroup === group.id;
return '<button class="group-card ' + (selected ? 'selected' : '') + '" data-action="select-group" data-value="' + group.id + '">' +
'<span class="group-badge ' + group.color + '">' + group.icon + '</span><span class="group-card-main"><strong>' + group.name + '</strong><small>' + group.count + ' UAV</small><small>' + group.task + '</small><span class="operator-line">' + group.operator + '</span></span>' +
'<span class="group-card-side">' + statusTag(group.health, '') + '<b>' + group.progress + '%</b><i class="mini-grid">' + Array(9).fill('<em></em>').join('') + '</i></span></button>';
}).join('') + '</div>' + button('New group', 'new-group', { icon: 'plus', className: 'full-width' }) + '</aside>';
}
function attentionRail() {
return '<aside class="attention-rail"><div class="rail-heading"><span>Attention</span>' + button('', 'view-alerts', { icon: 'alert', iconOnly: true, label: 'View all alerts' }) + '</div>' +
'<button class="attention-card warning" data-action="set-view" data-value="recovery">' + icon('alert') + '<span><strong>UAV-27</strong><small>Navigation degraded</small><small>Since 01:32:11</small></span><em>ALPHA</em></button>' +
'<button class="attention-card amber" data-action="set-view" data-value="recovery">' + icon('clock') + '<span><strong>Alpha task behind schedule</strong><small>ETA impact +00:08:45</small></span><em>ALPHA</em></button>' +
'<button class="attention-card amber" data-action="set-view" data-value="selection">' + icon('alert') + '<span><strong>Bravo approaching constraint</strong><small>R-210B coastal limit</small></span><em>BRAVO</em></button>' +
'<div class="fleet-nominal">' + icon('check') + '<div><b>47</b><span>vehicles nominal</span><small>No action required</small></div></div>' +
'</aside>';
}
function contextBar() {
const group = groupById(state.selectedGroup);
return '<section class="context-bar"><div class="context-summary"><span class="group-badge ' + group.color + '">' + group.icon + '</span><div><h2>' + group.name + '</h2><small>' + group.count + ' vehicles</small></div><dl><div><dt>Task</dt><dd>' + group.task + '</dd></div><div><dt>Progress</dt><dd>' + group.progress + '% complete <i class="progress"><b style="width:' + group.progress + '%"></b></i></dd></div><div><dt>Operator</dt><dd>' + group.operator + '</dd></div><div><dt>Status</dt><dd>' + statusTag(group.health) + '</dd></div><div class="wide-only"><dt>Current</dt><dd>' + group.current + '</dd></div><div class="wide-only"><dt>Next</dt><dd>' + group.next + '</dd></div></dl></div>' +
'<div class="context-actions">' + button('Task', 'open-preview', { icon: 'task' }) + button('Move', 'move-group', { icon: 'move' }) + button('Hold', 'hold-group', { icon: 'hold' }) + button('Group', 'group-vehicles', { icon: 'people' }) + button('Split', 'split-group', { icon: 'route' }) + button('Transfer', 'open-transfer', { icon: 'transfer' }) + button('Details', 'open-vehicle-detail', { icon: 'info' }) + '</div></section>';
}
function overviewView() {
return '<main class="workspace overview-workspace">' + groupRail() + '<section class="map-stage">' + overviewMap() + '</section>' + attentionRail() + contextBar() + '</main>';
}
function zoomSmallMap(kind) {
if (kind === 'strategic') {
return '<div class="zoom-map strategic">' + mapBase() + taskArea('TASK 143', 'small-task violet') + groupMarker(groups[0], 35, 33, true) + groupMarker(groups[1], 20, 57) + groupMarker(groups[2], 38, 75) + groupMarker(groups[3], 68, 61) + '<div class="zoom-fleet">' + groups.map(g => '<span><b>' + g.name + '</b><i style="width:' + g.progress + '%"></i></span>').join('') + '</div></div>';
}
if (kind === 'operational') {
return '<div class="zoom-map operational">' + mapBase() + taskArea('AREA SEARCH\n82%', 'wide alpha-area') + routeLine() + alphaVehicles.map(v => vehicleMarker(v)).join('') + '<div class="map-kpi"><small>Area search</small><b>82%</b><span class="ring"></span><em>Alex</em></div></div>';
}
return '<div class="zoom-map vehicle">' + mapBase() + taskArea('', 'wide alpha-area muted') + routeLine() + alphaVehicles.map(v => vehicleMarker(v, v.id === 'UAV-07')).join('') + '<aside class="vehicle-inspector"><div><strong>UAV-07</strong>' + button('', 'close-inspector', { icon: 'close', iconOnly: true, label: 'Close vehicle inspector' }) + '</div><small>ALPHA GROUP</small><dl><div><dt>Position</dt><dd>34.72245° N<br>18.28512° W</dd></div><div><dt>Heading</dt><dd>128°</dd></div><div><dt>Energy</dt><dd class="green">74%</dd></div><div><dt>Navigation</dt><dd class="green">Nominal</dd></div><div><dt>Communication</dt><dd class="green">Good</dd></div><div><dt>Payload</dt><dd>Imaging</dd></div></dl><hr><p><b>Current task</b>Search Area North</p><p><b>Next action</b>Sweep 4</p></aside></div>';
}
function zoomFooter(kind) {
const content = kind === 'strategic' ? '<h3>Groups and task intent</h3><p>High-level view of mission groups and task areas.</p>' : kind === 'operational' ? '<h3>Group composition and coverage</h3><p>Coordinated group view with coverage progress.</p>' : '<h3>Vehicle details on request</h3><p>Progressive disclosure for a selected vehicle.</p>';
return '<footer class="zoom-footer">' + content + '</footer>';
}
function zoomView() {
return '<main class="zoom-view"><header class="view-intro"><div><h1>Semantic zoom <span>— shared multi-UAV mission planner</span></h1><p>Seamless transition from strategic intent to operational control to individual vehicle detail.</p></div><div class="legend">' + statusTag('blue', 'Active') + statusTag('cyan', 'Selected') + statusTag('nominal', 'Nominal') + statusTag('attention', 'Attention') + statusTag('planned', 'Planned') + '</div></header><section class="zoom-grid">' +
'<article class="zoom-panel"><header><span class="letter">A</span><h2>Strategic</h2>' + button('', 'zoom-strategic', { icon: 'target', iconOnly: true, label: 'View strategic detail' }) + '</header>' + zoomSmallMap('strategic') + zoomFooter('strategic') + '</article>' +
'<article class="zoom-panel"><header><span class="letter">B</span><h2>Operational — Alpha</h2>' + button('', 'zoom-operational', { icon: 'target', iconOnly: true, label: 'View operational detail' }) + '</header>' + zoomSmallMap('operational') + zoomFooter('operational') + '</article>' +
'<article class="zoom-panel"><header><span class="letter">C</span><h2>Vehicle — UAV-07</h2>' + button('', 'zoom-vehicle', { icon: 'target', iconOnly: true, label: 'View vehicle detail' }) + '</header>' + zoomSmallMap('vehicle') + zoomFooter('vehicle') + '</article>' +
'</section></main>';
}
function filterChip(label, value) {
return '<div class="filter-chip"><span>' + label + '</span><b>' + value + '</b><button data-action="remove-filter" aria-label="Remove ' + label + ' filter">' + icon('close') + '</button></div>';
}
function selectionMap() {
return '<div class="map-canvas selection-map">' + mapBase() + '<div class="selection-task violet">' + taskArea('TASK-143\nAREA SEARCH', 'violet') + '<span class="task-pin"></span></div>' + routeLine('selection-routes') + availableVehicles.map(v => vehicleMarker(v, true, true)).join('') + '<div class="floating-task-card"><h3>TASK-143 <span>Area search</span></h3><dl><div><dt>Priority</dt><dd class="amber">High</dd></div><div><dt>Assigned</dt><dd class="blue">Alpha / 4</dd></div><div><dt>Area</dt><dd>Northern sector</dd></div><div><dt>Progress</dt><dd>63% <i class="progress"><b style="width:63%"></b></i></dd></div><div><dt>Required</dt><dd>4 UAV</dd></div><div><dt>Health</dt><dd class="green">Green</dd></div></dl><footer>' + icon('clock') + ' Estimated completion <b>18 min</b></footer></div>' + mapControls() + '</div>';
}
function smartSelectionView() {
return '<main class="selection-workspace"><aside class="smart-panel"><header><h1>Smart selection</h1>' + button('', 'show-filter-help', { icon: 'info', iconOnly: true, label: 'Selection help' }) + '</header><label class="form-label">Natural language query</label><div class="natural-query">' + icon('search') + '<p>Available ISR aircraft within 20 km of Task Bravo with more than 60% energy.</p></div><div class="filter-heading"><label class="form-label">Structured filters</label><button data-action="clear-filters">Clear all</button></div><div class="filter-stack">' + filterChip('Type', 'ISR') + filterChip('Availability', 'Available') + filterChip('Distance', '< 20 km') + filterChip('Energy', '> 60%') + filterChip('Task', 'None') + '</div><div class="selection-result"><small>Results</small><strong>8 <span>vehicles selected</span></strong><p>Filter distribution</p><dl><div><dt>Type (ISR)</dt><dd><i style="width:72%"></i>32</dd></div><div><dt>Availability</dt><dd><i style="width:42%"></i>12</dd></div><div><dt>Distance</dt><dd><i style="width:32%"></i>9</dd></div><div><dt>Energy</dt><dd><i style="width:28%"></i>8</dd></div></dl></div>' + button('Assign task', 'open-preview', { icon: 'task', kind: 'primary', className: 'full-width' }) + '<small class="updated">Last updated: 14:32:10 UTC</small></aside><section class="selection-map-stage">' + selectionMap() + '</section>' + assignPanel() + '</main>';
}
function validationRow(label, value, stateName = 'ok') {
return '<div class="validation-row">' + icon(stateName === 'warning' ? 'alert' : stateName === 'info' ? 'info' : 'check') + '<span>' + label + '</span><b class="' + (stateName === 'warning' ? 'amber' : '') + '">' + value + '</b>' + (stateName === 'ok' ? '<i class="round-check">' + icon('check') + '</i>' : '') + '</div>';
}
function assignPanel() {
return '<aside class="assign-panel"><header><div><h1>Assign task</h1><p>6 vehicles selected</p></div>' + button('', 'close-assign', { icon: 'close', iconOnly: true, label: 'Close assignment panel' }) + '</header><dl class="task-meta"><div><dt>Task</dt><dd class="violet">TASK-143 / AREA SEARCH</dd></div><div><dt>Area</dt><dd class="violet">Northern sector</dd></div></dl><h2>Validation summary</h2><div class="validation-list">' + validationRow('Estimated completion', '18 min') + validationRow('Energy reserve', 'All above minimum') + validationRow('Route conflicts', 'None detected') + validationRow('Constraint conflicts', 'None detected') + validationRow('Vehicle compatibility', '6 / 6 compatible') + validationRow('Communication link', 'All links good') + validationRow('Assignment considerations', '1 aircraft may require reassignment.', 'warning') + '</div><div class="info-callout">' + icon('info') + '<p>Review the preview to confirm routing, timing, and resource allocation before assigning.</p></div><footer>' + button('Cancel', 'cancel-modal') + button('Preview', 'open-preview', { kind: 'secondary' }) + button('Confirm', 'confirm-assignment', { icon: 'check', kind: 'primary' }) + '</footer></aside>';
}
function collabMap() {
return '<div class="map-canvas collaboration-map">' + mapBase() + '<div class="collab-alpha-boundary">' + taskArea('ALPHA\n3 UAV · 1 USV', 'alpha-area') + alphaVehicles.slice(0, 3).map(v => vehicleMarker(v)).join('') + '</div>' +
groupMarker(groups[1], 32, 23) + groupMarker(groups[2], 73, 23) + groupMarker(groups[3], 73, 53) + groupMarker(groups[4], 60, 75) +
operators.map((op, i) => '<span class="operator-cursor ' + op.color + '" style="left:' + [20, 36, 74, 38, 62][i] + '%;top:' + [69, 25, 20, 80, 80][i] + '%"><i>◢</i><b>' + op.name + '</b></span>').join('') +
'<article class="lease-card"><small>Command authority</small><h2>Alex</h2><p>lease expires <b>02:34</b></p><hr><small>Observers</small><p>' + statusTag('nominal', 'Sara') + statusTag('amber', 'David') + '</p></article>' +
'</div>';
}
function collaborationView() {
return '<main class="collaboration-workspace">' + groupRail(true) + '<section class="collab-map-stage">' + collabMap() + '</section><aside class="presence-panel"><header><h1>Mission presence</h1></header><div class="presence-list">' + operators.map(op => '<button class="presence-row" data-action="operator-detail" data-value="' + op.id + '"><i class="presence-dot ' + op.color + '"></i><span><strong>' + op.name + '</strong><small>' + op.role + '</small></span><b>⌄</b></button>').join('') + '</div><header class="subheading"><h2>Collaboration stream</h2><button data-action="view-history">View all</button></header><div class="collab-stream">' + operators.slice().reverse().map((op, i) => '<p><i class="presence-dot ' + op.color + '"></i><span>' + op.activity + '</span><small>' + (i * 2 + 2) + 'm ago</small></p>').join('') + '</div></aside><footer class="mission-events"><strong>Mission events</strong><div><span><i class="presence-dot cyan"></i>14:30:41Z&nbsp;&nbsp; Alex selected Alpha</span><span><i class="presence-dot amber"></i>14:28:15Z&nbsp;&nbsp; David drafted Task-144</span><span><i class="presence-dot green"></i>14:26:32Z&nbsp;&nbsp; Sara updated Bravo search area</span><span><i class="presence-dot violet"></i>14:24:10Z&nbsp;&nbsp; Maria added monitoring note</span></div><p>Shared awareness · authority changes are explicit and auditable.</p></footer></main>';
}
function recoveryMap() {
return '<div class="map-canvas recovery-map">' + mapBase() + taskArea('ALPHA\nSEARCH AREA', 'alpha-area') + routeLine() + alphaVehicles.slice(0, 4).map(v => vehicleMarker(v, false, true)).join('') + '<button class="failed-marker" style="left:71%;top:45%" data-action="show-failure"><span>!</span><b>UAV-03</b><small>Unavailable</small></button><article class="recovery-task-map-card">' + icon('people') + '<div><h2>Alpha Search</h2><p>Search and locate pattern</p></div><b class="amber">' + (state.reassigned ? '4 / 4' : '3 / 4') + '<small>assigned</small></b></article></div>';
}
function candidateCard(id, energy, distance, compatibility, selected) {
return '<button class="candidate-card ' + (selected ? 'selected' : '') + '" data-action="select-candidate" data-value="' + id + '"><header>' + icon('people') + '<span><strong>' + id + '</strong>' + statusTag('nominal', 'Ready') + '</span>' + (selected ? '<i class="round-check">' + icon('check') + '</i>' : '') + '</header><dl><div><dt>Energy</dt><dd>' + energy + '% ' + statusTag('nominal', '') + '</dd></div><div><dt>Distance to task</dt><dd>' + distance + ' km</dd></div><div><dt>Task compatibility</dt><dd class="' + (compatibility === 'Medium' ? 'amber' : 'green') + '">' + compatibility + '</dd></div><div><dt>Communication</dt><dd class="green">Strong</dd></div></dl>' + (selected ? '<footer>Recommended</footer>' : '') + '</button>';
}
function recoveryView() {
const active = state.selectedCandidate;
return '<main class="recovery-workspace"><aside class="recovery-left"><header><h1>Attention</h1></header><article class="recovery-alert critical">' + icon('alert') + '<div><h2>UAV-03</h2><p>Navigation unavailable</p><small>Since 14:33:02</small><b>High priority</b></div></article><article class="recovery-alert amber">' + icon('alert') + '<div><h2>Alpha search coverage below required</h2><p>Coverage gap detected</p><b>Requires attention</b></div></article><div class="fleet-summary"><h2>Fleet summary</h2><strong>47</strong><p>vehicles nominal</p><small>All other vehicles operating normally.</small>' + button('View fleet status', 'view-fleet', { icon: 'task' }) + '</div></aside><section class="recovery-map-stage">' + recoveryMap() + '</section><aside class="recovery-right"><header><h1>' + icon('shield') + ' Keep TASK-143 on plan</h1></header><section class="current-state"><h2>Current state</h2><dl><div><dt>Task</dt><dd>Alpha Search</dd></div><div><dt>Required</dt><dd>4 vehicles</dd></div><div><dt>Current</dt><dd class="amber">' + (state.reassigned ? '4 / 4' : '3 / 4') + '<small>assigned</small></dd></div><div><dt>Completion impact</dt><dd class="amber">' + (state.reassigned ? 'None' : '+11 minutes') + '</dd></div></dl></section><section class="recommendations"><h2>Recommended replacements</h2><p>' + icon('info') + ' Select a vehicle to review. This is a system recommendation for operator approval.</p><div class="candidate-grid">' + candidateCard('UAV-19', 78, 6.2, 'High', active === 'UAV-19') + candidateCard('UAV-27', 64, 9.8, 'High', active === 'UAV-27') + candidateCard('UAV-31', 82, 12.4, 'Medium', active === 'UAV-31') + '</div><p class="recommendation-note">' + icon('info') + ' Recommended because it can join before coverage gap exceeds threshold.</p></section><section class="local-preview"><h2>Local preview of reassignment</h2><div><span>' + icon('plane') + ' ' + active + '</span><i></i><b>ALPHA SEARCH AREA</b></div></section><footer>' + button('Cancel', 'cancel-modal') + button('Compare candidates', 'compare-candidates') + button(state.reassigned ? 'Recommendation accepted' : 'Accept recommendation', 'accept-replacement', { icon: 'shield', kind: 'primary', disabled: state.reassigned }) + '</footer></aside><footer class="recovery-events"><span>' + icon('alert') + ' <b>14:33:02</b> system detected UAV-03 unavailable</span><span>' + icon('shield') + ' <b>14:33:04</b> system evaluated replacement options</span><span>' + icon('info') + ' <b>14:33:17</b> operator reviewing recommendation</span></footer></main>';
}
function queueMap() {
return '<div class="queue-map">' + mapBase() + routeLine() + '<div class="base-service">⌾<span>Base<br>Service</span></div>' + taskArea('Search Area A', 'queue-search') + taskArea('Search Area B', 'queue-search future') + '<div class="queue-group-label"><span class="group-badge blue">A</span><strong>ALPHA</strong><small>12</small></div><article class="queue-group-card"><span class="group-badge blue">A</span><h2>Alpha</h2><p>' + icon('people') + '12 vehicles</p><p>' + icon('search') + 'Search</p><p>' + icon('people') + 'Operator&nbsp; Alex</p>' + statusTag('nominal') + '</article></div>';
}
function queueStep(step, index) {
return '<article class="queue-step ' + step.status + '"><button class="drag-handle" aria-label="Drag to reorder">⠿</button><span class="step-number">' + (index + 1) + '</span><div><h2>' + step.title + '</h2><p>' + step.detail + '</p>' + (step.status === 'current' ? '<i class="progress"><b style="width:82%"></b></i><small>82% complete</small>' : '') + '</div><aside><b>Alex</b><small>' + step.duration + '</small>' + statusTag(step.status === 'completed' ? 'nominal' : step.status === 'current' ? 'blue' : 'planned', step.status) + '</aside><div class="step-arrows">' + button('', 'queue-up', { icon: 'chevron', iconOnly: true, label: 'Move step up', value: index }) + button('', 'queue-down', { icon: 'chevron', iconOnly: true, label: 'Move step down', value: index }) + '</div></article>';
}
function queueView() {
const current = state.queue[1];
return '<main class="queue-workspace"><header class="queue-topline"><div><span>Mission</span><b>Coastal Survey</b></div><div><span>Time (local)</span><b>14:35:22</b></div><div><span>Weather</span><b>Overcast 18°C</b></div><div><span>Wind</span><b>12 kt NNE</b></div><div><span>Global status</span>' + statusTag('nominal') + '</div><div><span>Sync</span><b>3s ago</b></div></header><section class="queue-body"><section class="queue-map-stage">' + queueMap() + '</section><section class="queue-panel"><header><h1><span>Alpha</span> · Intent queue</h1><div>' + statusTag('planned', 'Draft changes') + button('', 'queue-menu', { icon: 'settings', iconOnly: true, label: 'Queue options' }) + '</div></header><div class="queue-content"><div class="step-list">' + state.queue.map(queueStep).join('') + '<div class="drop-zone">⠿&nbsp;&nbsp; Drop step here to reorder</div></div><aside class="step-inspector"><header><b>Step 2 of 6</b>' + button('', 'close-inspector', { icon: 'close', iconOnly: true, label: 'Close step inspector' }) + '</header><h2>Goal</h2><p>Search Area A with coverage pattern to identify objects of interest.</p><h2>Owner</h2><p>' + icon('people') + ' Alex</p><h2>Resources</h2><p>' + icon('people') + ' 12 vehicles</p><h2>Est. duration</h2><p>' + icon('clock') + ' 18 min</p><h2>Expected finish</h2><p>⚑ &nbsp;14:53 (local)</p><h2>Linked task</h2><p>⛓ &nbsp;TASK-143</p><h2>Local plan status</h2>' + statusTag('nominal', 'Plan valid') + '</aside></div><footer class="queue-actions">' + button('Add step', 'add-step', { icon: 'plus' }) + button('Reorder', 'reorder-queue', { icon: 'transfer' }) + button('Simulate timeline', 'simulate-queue', { icon: 'clock' }) + button('Review changes', 'review-queue', { kind: 'primary' }) + '</footer></section></section><footer class="queue-events"><strong>Activity timeline</strong><span><i class="presence-dot blue"></i>14:32&nbsp;&nbsp; <b>Alex</b>&nbsp;&nbsp; reordered Return after Search Area A</span><span><i class="presence-dot blue"></i>14:33&nbsp;&nbsp; <b>System</b>&nbsp;&nbsp; recomputed completion estimate</span></footer></main>';
}
function systemView() {
const color = (name, code, cls) => '<div class="swatch"><i class="' + cls + '"></i><b>' + name + '</b><small>' + code + '</small></div>';
return '<main class="system-view"><header class="system-title"><h1>Design system</h1><p>Multi-UAV Mission Control <span>· Screen 7</span></p></header><section class="system-grid"><article class="system-card colors"><h2>1. Color and status</h2><p>Surface (Graphite / Navy)</p><div class="swatches">' + color('Surface 0', '#0B0F14', 's0') + color('Surface 1', '#111724', 's1') + color('Surface 2', '#171D2B', 's2') + color('Surface 3', '#1D2536', 's3') + color('Border', '#2A3447', 'border') + '</div><p>Semantic status (meaning over decoration)</p><div class="swatches semantic">' + color('Blue', 'Active / Primary', 'blue') + color('Cyan', 'Selected', 'cyan') + color('Green', 'Nominal', 'green') + color('Amber', 'Attention', 'amber') + color('Red', 'Critical', 'red') + color('Violet', 'Planned', 'violet') + '</div><ul><li>Color communicates state and priority, not category.</li><li>Do not rely on color alone—use icon, text, and shape.</li><li>Maintain WCAG AA contrast for all text.</li></ul></article><article class="system-card type"><h2>2. Typography</h2><h1>H1 <b>Mission / Group Heading</b></h1><h2>H2 <b>Task Heading</b></h2><p>Body / UI Text <b>Readable action and mission information</b></p><small>Metadata / Secondary</small><pre>UAV-3F7A2C 08:43:21Z ALT 3250 ft</pre></article><article class="system-card components"><h2>3. Components</h2><div class="component-columns"><section><p>Buttons (44px height)</p>' + button('Create group', 'new-group', { icon: 'plus', kind: 'primary' }) + button('Assign task', 'open-preview', { icon: 'task' }) + button('Remove', 'remove', { icon: 'close', kind: 'danger' }) + '</section><section><p>Status chips</p><div class="chip-wrap">' + statusTag('nominal') + statusTag('attention') + statusTag('critical') + statusTag('cyan', 'Selected') + statusTag('planned') + '</div></section><section><p>Authority</p><div class="authority-demo">' + icon('shield') + '<span>Authority<br><b>Operator</b></span></div><div class="authority-demo muted">▣&nbsp;&nbsp; Read-only observer</div></section></div><div class="mini-components"><article class="mini-task"><b>ISR Sweep North</b>' + statusTag('nominal') + '<p>Type ISR</p><p>Area Sector 12</p><p>Assets 14 UAV</p></article><article class="mini-group"><span class="group-badge cyan">27</span><b>Group Alpha</b>' + statusTag('cyan', 'Selected') + '<p>Members 27 UAVs</p><p>Lead UAV-3F7A2C</p></article><article class="mini-alert">' + icon('alert') + '<span>' + statusTag('critical') + '<b>Link degraded on UAV-7B1C</b></span></article></div></article><article class="system-card interactions"><h2>4. Interaction states</h2><div class="multi-select-demo"><div class="select-map">' + alphaVehicles.slice(0, 9).map(v => vehicleMarker(v, v.id !== 'UAV-03')).join('') + '<i></i></div><div class="multi-summary">' + icon('check') + '<strong>27 vehicles selected</strong><span>ISR <b>14</b></span><span>Relay <b>7</b></span><span>Transport <b>6</b></span>' + button('Create group', 'new-group', { icon: 'plus', kind: 'primary' }) + button('Assign task', 'open-preview') + button('Add to group', 'group-vehicles') + '</div></div></article><article class="system-card state-cards"><h2>Selection state cards</h2><div class="selection-state-card selected"><span class="group-badge cyan">27</span><b>Group Alpha</b>' + statusTag('cyan', 'Selected') + '<small>Members · 27 UAVs</small></div><div class="selection-state-card"><span class="group-badge green">◈</span><b>ISR Sweep North</b>' + statusTag('nominal') + '<small>Start · 09:00Z · Assets 14 UAVs</small></div><div class="selection-state-card"><span class="group-badge blue">✈</span><b>UAV-3F7A2C</b>' + statusTag('cyan', 'Selected') + '<small>Type · ISR · Alt 3,250 ft</small></div><div class="selection-state-card muted"><span class="group-badge">□</span><b>No selection</b><small>Use selection tools to choose vehicles or groups.</small></div></article><article class="system-card responsive"><h2>5. Responsive composition</h2><p>Map-first layouts adapt by collapsing / expanding secondary panels.</p><div class="responsive-samples"><div><b>Laptop</b><i class="laptop"></i></div><div><b>27-inch</b><i class="monitor"></i></div><div><b>Ultrawide</b><i class="ultrawide"></i></div><div><b>4K wall</b><i class="wall"></i></div></div><h2>6. Density and accessibility</h2><div class="accessibility"><p><b>44px</b> minimum tap / click target</p><p class="focus-example">Assign task <kbd>Tab</kbd></p><p><b>Aa</b> high-contrast UI text</p><p>' + icon('info') + ' Semantic label and tooltip</p></div></article></section></main>';
}
function modal() {
if (!state.modal) return '';
let content = '';
if (state.modal === 'preview') {
content = '<div class="modal-dialog command-preview" role="dialog" aria-modal="true" aria-labelledby="preview-title"><header><div><small>Command preview</small><h1 id="preview-title">Assign TASK-143</h1></div>' + button('', 'cancel-modal', { icon: 'close', iconOnly: true, label: 'Close command preview' }) + '</header><p class="preview-lede">6 selected vehicles will be assigned to <b>Area Search — Northern Sector</b>.</p><div class="preview-route"><div><i></i><b>Current assignment</b></div><span>' + icon('route') + '</span><div class="target"><i></i><b>Proposed task</b></div></div><div class="preview-validation">' + validationRow('Estimated completion', '18 min') + validationRow('Energy reserve', 'All above minimum') + validationRow('Route conflicts', 'None detected') + validationRow('Geofence conflicts', 'None detected') + validationRow('Vehicle compatibility', '6 / 6') + validationRow('Potential issue', '1 aircraft may require reassignment', 'warning') + '</div><footer>' + button('Cancel', 'cancel-modal') + button('Confirm assignment', 'confirm-assignment', { icon: 'check', kind: 'primary' }) + '</footer></div>';
} else if (state.modal === 'transfer') {
content = '<div class="modal-dialog transfer-dialog" role="dialog" aria-modal="true" aria-labelledby="transfer-title"><header><div><small>Command lease</small><h1 id="transfer-title">Operator B requests command</h1></div>' + button('', 'cancel-modal', { icon: 'close', iconOnly: true, label: 'Close request dialog' }) + '</header><div class="transfer-authority"><span class="group-badge cyan">A</span><div><b>Alpha · command authority</b><p>Held by Alex · lease expires 02:34</p></div></div><label>Reason<textarea readonly>Emergency intervention</textarea></label><p class="notice">' + icon('info') + ' This action will transfer command authority from <b>Alex</b> to <b>Sara</b>. The change is logged for all operators.</p><footer>' + button('Deny', 'deny-transfer') + button('Transfer control', 'confirm-transfer', { icon: 'transfer', kind: 'danger' }) + '</footer></div>';
} else if (state.modal === 'help') {
content = '<div class="modal-dialog help-dialog" role="dialog" aria-modal="true"><header><h1>Smart selection</h1>' + button('', 'cancel-modal', { icon: 'close', iconOnly: true, label: 'Close help' }) + '</header><p>Use a natural-language request or structured filters to select a resource-ready group of aircraft. The POC derives the result from fictional vehicles and always validates the task before confirmation.</p><footer>' + button('Got it', 'cancel-modal', { kind: 'primary' }) + '</footer></div>';
}
return '<div class="modal-backdrop" data-action="cancel-modal"><div data-stop-propagation>' + content + '</div></div>';
}
function toast() {
return state.toast ? '<div class="toast">' + icon('check') + '<span>' + state.toast + '</span><button data-action="clear-toast" aria-label="Dismiss notification">' + icon('close') + '</button></div>' : '';
}
function app() {
const views = { overview: overviewView, zoom: zoomView, selection: smartSelectionView, collaboration: collaborationView, recovery: recoveryView, queue: queueView, system: systemView };
document.getElementById('app').innerHTML = topBar() + views[state.view]() + modal() + toast();
}
function setToast(message) {
state.toast = message;
window.clearTimeout(window.__atlasToast);
window.__atlasToast = window.setTimeout(() => { state.toast = ''; app(); }, 3600);
}
function clickAction(action, value) {
if (action === 'set-view') { state.view = value; state.modal = null; }
if (action === 'select-group') { state.selectedGroup = value; state.view = state.view === 'collaboration' ? 'collaboration' : 'overview'; setToast(groupById(value).name + ' selected — contextual controls updated.'); }
if (action === 'select-vehicle') { state.selectedVehicle = value; state.view = 'zoom'; setToast(value + ' selected — vehicle detail is shown on request.'); }
if (action === 'toggle-layers') state.layersOpen = !state.layersOpen;
if (action === 'open-preview') state.modal = 'preview';
if (action === 'open-transfer') state.modal = 'transfer';
if (action === 'cancel-modal' || action === 'close-assign' || action === 'close-inspector') state.modal = null;
if (action === 'show-filter-help') state.modal = 'help';
if (action === 'confirm-assignment') { state.modal = null; setToast('TASK-143 assigned after route, energy, and constraint validation.'); }
if (action === 'confirm-transfer') { state.modal = null; groups[0].lease = 'Sara'; groups[0].operator = 'Sara'; setToast('Command lease transferred to Sara. The audit event is visible to all operators.'); }
if (action === 'deny-transfer') { state.modal = null; setToast('Command request denied. Alex retains the Alpha command lease.'); }
if (action === 'move-group') setToast('Move intent opened. A route preview would be required before confirmation.');
if (action === 'hold-group') setToast('Hold intent queued for ' + groupById(state.selectedGroup).name + '.');
if (action === 'group-vehicles') setToast('Create / add to group workflow opened for the current selection.');
if (action === 'split-group') setToast('Split group is available after selecting a destination task.');
if (action === 'open-vehicle-detail') { state.view = 'zoom'; state.selectedVehicle = 'UAV-07'; }
if (action === 'select-candidate') state.selectedCandidate = value;
if (action === 'accept-replacement') { state.reassigned = true; setToast(state.selectedCandidate + ' assigned to Alpha Search. Task coverage returned to 4 / 4.'); }
if (action === 'show-failure') setToast('UAV-03 is unavailable. A replacement recommendation needs operator approval.');
if (action === 'queue-up' || action === 'queue-down') {
const index = Number(value); const direction = action === 'queue-up' ? -1 : 1; const next = index + direction;
if (next >= 0 && next < state.queue.length) { const copy = state.queue.slice(); [copy[index], copy[next]] = [copy[next], copy[index]]; state.queue = copy; state.queueDraft = true; setToast('Intent queue reordered locally. Review changes to commit.'); }
}
if (action === 'review-queue') { state.queueDraft = false; setToast('Queue review recorded. The plan remains valid.'); }
if (action === 'reorder-queue') { state.queueDraft = true; setToast('Reorder mode active — use the arrows on each intent step.'); }
if (action === 'simulate-queue') setToast('Simulation complete: no constraints conflict with the proposed intent queue.');
if (action === 'add-step') { state.queue.push({ title: 'New intent', detail: 'Configure task', duration: '—', status: 'queued' }); state.queueDraft = true; setToast('New draft intent added to the queue.'); }
if (action === 'new-group') setToast('New group creation starts with a multi-vehicle selection.');
if (action === 'remove-filter') setToast('Filter removed. The fictional result set has been recalculated.');
if (action === 'clear-filters') setToast('Structured filters cleared. Use a query to build a new selection.');
if (action === 'map-zoom-in') setToast('Map zoomed in — semantic detail increases as you select groups and vehicles.');
if (action === 'map-zoom-out') setToast('Map zoomed out — groups aggregate to protect situational awareness.');
if (action === 'view-alerts' || action === 'view-fleet' || action === 'view-history') setToast('This POC surfaces exceptions first; the nominal fleet stays intentionally quiet.');
if (action === 'compare-candidates') setToast('Candidate comparison keeps energy, task compatibility, and link quality visible.');
if (action === 'clear-toast') state.toast = '';
app();
}
document.addEventListener('click', event => {
const el = event.target.closest('[data-action]');
if (el) {
event.preventDefault();
clickAction(el.dataset.action, el.dataset.value);
return;
}
if (event.target.closest('[data-stop-propagation]')) event.stopPropagation();
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && state.modal) { state.modal = null; app(); }
});
app();
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

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+15
View File
@@ -0,0 +1,15 @@
<!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="#0b0f14" />
<meta name="description" content="Atlas multi-UAV mission control proof of concept" />
<title>Atlas — Mission Control POC</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="app" aria-live="polite"></div>
<script src="app.js" defer></script>
</body>
</html>
+459
View File
@@ -0,0 +1,459 @@
:root {
--surface-0: #0b0f14;
--surface-1: #111724;
--surface-2: #171d2b;
--surface-3: #1d2536;
--surface-4: #263247;
--border: #2a3447;
--border-soft: rgba(105, 133, 161, .22);
--text: #edf3f7;
--muted: #9aa8b7;
--dim: #6e7d90;
--blue: #227bed;
--cyan: #2ab9d6;
--green: #59c66f;
--amber: #f0b544;
--red: #ed5357;
--violet: #9a73d8;
--radius: 7px;
--shadow: 0 14px 36px rgba(0, 0, 0, .3);
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body, #app { min-height: 100%; margin: 0; }
body {
background: var(--surface-0);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
overflow-x: hidden;
}
button, input, textarea { font: inherit; }
button { color: inherit; }
button:focus-visible, input:focus-visible, textarea:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 2px;
}
h1, h2, h3, p { margin: 0; }
h1 { font-size: 20px; line-height: 1.2; }
h2 { font-size: 16px; line-height: 1.25; }
h3 { font-size: 14px; }
small { color: var(--muted); font-size: 11px; line-height: 1.3; }
a { color: var(--cyan); }
dl, dt, dd { margin: 0; }
.icon { width: 18px; height: 18px; flex: 0 0 auto; }
.green { color: var(--green) !important; }
.amber { color: var(--amber) !important; }
.blue { color: var(--blue) !important; }
.cyan { color: var(--cyan) !important; }
.violet { color: var(--violet) !important; }
.red { color: var(--red) !important; }
.topbar {
height: 68px;
display: flex;
align-items: stretch;
gap: 18px;
padding: 0 16px;
background: rgba(9, 14, 21, .97);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
}
.brand, .view-nav, .top-metrics, .operator-avatar {
display: flex;
align-items: center;
}
.brand { gap: 10px; min-width: max-content; font-weight: 700; letter-spacing: .08em; }
.brand-mark {
width: 28px; height: 28px; display: grid; place-items: center;
color: var(--cyan); border: 1px solid var(--blue); border-radius: 5px;
font-size: 13px; box-shadow: inset 0 0 12px rgba(35, 123, 237, .18);
}
.brand-divider { width: 1px; height: 28px; background: var(--border); margin-left: 4px; }
.mission-name { color: var(--muted); font-size: 12px; letter-spacing: .03em; }
.view-nav { gap: 2px; margin: 0 auto; overflow-x: auto; scrollbar-width: none; }
.view-nav::-webkit-scrollbar { display: none; }
.nav-item {
height: 68px; display: inline-flex; gap: 7px; align-items: center; padding: 0 9px;
background: none; border: 0; border-bottom: 2px solid transparent; color: var(--muted); cursor: pointer;
white-space: nowrap; font-size: 12px;
}
.nav-item:hover { color: var(--text); background: rgba(255,255,255,.025); }
.nav-item.is-active { color: var(--cyan); border-bottom-color: var(--blue); background: rgba(35,123,237,.06); }
.nav-item .icon { width: 16px; height: 16px; }
.top-metrics { gap: 16px; }
.top-metrics > div:not(.operator-avatar) { display: grid; gap: 2px; padding-right: 16px; border-right: 1px solid var(--border-soft); white-space: nowrap; }
.top-metrics small { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
.top-metrics strong { font-size: 12px; font-weight: 600; }
.operator-avatar { gap: 8px; min-width: 112px; }
.operator-avatar > span { width: 30px; height: 30px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 50%; color: var(--cyan); font-size: 10px; font-weight: 700; }
.operator-avatar div { display: grid; gap: 2px; }
.operator-avatar strong { text-transform: uppercase; font-size: 11px; }
.operator-avatar small { font-size: 9px; }
.button {
min-height: 40px; border: 1px solid var(--border); border-radius: 5px; padding: 0 13px;
background: rgba(23, 31, 45, .96); display: inline-flex; align-items: center; justify-content: center; gap: 8px;
cursor: pointer; font-weight: 600; font-size: 12px; transition: border-color .15s, background .15s, transform .15s;
}
.button:hover:not(:disabled) { border-color: #54708f; background: var(--surface-3); }
.button:active:not(:disabled) { transform: translateY(1px); }
.button.primary { background: var(--blue); border-color: #3b8af1; color: #fff; }
.button.primary:hover:not(:disabled) { background: #3288f4; }
.button.danger { background: #a92f37; border-color: #df555c; color: #fff; }
.button.danger:hover:not(:disabled) { background: #c43e46; }
.button:disabled { opacity: .48; cursor: not-allowed; }
.icon-button { width: 40px; min-width: 40px; padding: 0; }
.full-width { width: 100%; text-transform: uppercase; letter-spacing: .04em; }
.status { display: inline-flex; align-items: center; gap: 5px; text-transform: uppercase; color: var(--muted); font-size: 10px; font-weight: 600; white-space: nowrap; }
.status-dot, .presence-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--dim); display: inline-block; }
.status.nominal, .status.nominal .status-dot { color: var(--green); }
.status.nominal .status-dot, .presence-dot.green { background: var(--green); }
.status.attention, .status.attention .status-dot { color: var(--amber); }
.status.attention .status-dot, .presence-dot.amber, .presence-dot.yellow { background: var(--amber); }
.status.critical, .status.critical .status-dot { color: var(--red); }
.status.critical .status-dot { background: var(--red); }
.status.cyan, .status.cyan .status-dot { color: var(--cyan); }
.status.cyan .status-dot, .presence-dot.cyan { background: var(--cyan); }
.status.blue, .status.blue .status-dot { color: var(--blue); }
.status.blue .status-dot, .presence-dot.blue { background: var(--blue); }
.status.planned, .status.planned .status-dot { color: var(--violet); }
.status.planned .status-dot, .presence-dot.violet { background: var(--violet); }
.workspace { min-height: calc(100vh - 68px); display: grid; background: var(--surface-0); }
.overview-workspace { grid-template: minmax(580px, calc(100vh - 220px)) auto / 250px minmax(400px, 1fr) 250px; }
.group-rail, .attention-rail { background: var(--surface-1); border-right: 1px solid var(--border); padding: 12px 10px; min-width: 0; }
.attention-rail { border-right: 0; border-left: 1px solid var(--border); }
.rail-heading { height: 34px; padding: 0 7px 9px; display: flex; align-items: center; justify-content: space-between; color: #c1ccda; text-transform: uppercase; letter-spacing: .06em; font-size: 11px; }
.rail-heading .button { min-height: 28px; width: 28px; min-width: 28px; border: 0; background: none; }
.rail-heading .icon { width: 16px; height: 16px; }
.group-list { display: grid; gap: 8px; margin-bottom: 12px; }
.group-card {
width: 100%; display: grid; grid-template-columns: 38px 1fr auto; gap: 8px; text-align: left; min-height: 92px; padding: 10px;
background: rgba(23, 31, 43, .72); border: 1px solid rgba(82,104,129,.26); border-radius: 6px; cursor: pointer;
}
.group-card:hover { border-color: #4d6380; background: var(--surface-2); }
.group-card.selected { border-color: #2688db; box-shadow: inset 2px 0 0 var(--blue), 0 0 20px rgba(32, 120, 225, .08); }
.group-badge {
width: 34px; height: 34px; border: 1px solid #52667d; display: grid; place-items: center; border-radius: 50%;
font-weight: 700; color: #c9d4de; flex: 0 0 auto;
}
.group-badge.cyan { border-color: var(--cyan); color: var(--cyan); }
.group-badge.blue { border-color: var(--blue); color: #73b0ff; }
.group-badge.green { border-color: var(--green); color: var(--green); }
.group-badge.amber { border-color: var(--amber); color: var(--amber); }
.group-badge.violet { border-color: var(--violet); color: var(--violet); }
.group-card-main { min-width: 0; display: grid; align-content: start; gap: 2px; }
.group-card-main strong { font-size: 13px; }
.group-card-main small { font-size: 10px; }
.operator-line { margin-top: auto; color: var(--muted); font-size: 10px; }
.group-card-side { min-width: 34px; display: grid; justify-items: end; align-content: space-between; }
.group-card-side .status { font-size: 0; }
.group-card-side .status-dot { width: 8px; height: 8px; }
.group-card-side > b { color: var(--cyan); font-size: 12px; }
.mini-grid { display: grid; grid-template-columns: repeat(3, 3px); gap: 2px; padding-top: 2px; }
.mini-grid em { width: 3px; height: 3px; border-radius: 50%; background: var(--blue); }
.group-card:nth-child(4) .mini-grid em { background: var(--amber); }
.map-stage { min-width: 0; min-height: 0; position: relative; }
.map-canvas, .zoom-map, .queue-map {
min-height: 100%; overflow: hidden; position: relative; isolation: isolate;
background: #07111c;
}
.map-canvas::before, .zoom-map::before, .queue-map::before {
content: ""; position: absolute; inset: 0; z-index: -3; background:
radial-gradient(ellipse at 24% 35%, rgba(43, 94, 87, .48), transparent 38%),
radial-gradient(ellipse at 45% 72%, rgba(71, 91, 66, .26), transparent 32%),
linear-gradient(135deg, #122531 0%, #0a151e 45%, #06121e 76%);
}
.map-art { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -2; opacity: .9; }
.map-art .land { fill: rgba(45, 70, 65, .8); stroke: #4c6c6d; stroke-width: 2; }
.map-art .coast { fill: none; stroke: #a6c3bb; stroke-opacity: .4; stroke-width: 2; }
.map-art .river { fill: none; stroke: #5d91a4; stroke-opacity: .6; stroke-width: 3; }
.map-art .road { fill: none; stroke: #acb5a1; stroke-opacity: .28; stroke-width: 2; stroke-dasharray: 3 2; }
.map-art .road.thin { stroke-width: 1; stroke-dasharray: 5 5; }
.map-art .hill { fill: none; stroke: #778f75; stroke-opacity: .32; stroke-width: 3; }
.map-art .town { fill: #c0c9bb; fill-opacity: .22; stroke: #dfe4d6; stroke-opacity: .4; }
.map-label { position: absolute; color: #c4d1d4; text-shadow: 0 1px 3px #000; font-size: 11px; }
.scale { position: absolute; left: 16px; bottom: 16px; color: #d3dce3; font-family: var(--mono); font-size: 10px; display: grid; gap: 3px; }
.scale i { width: 84px; height: 7px; border-bottom: 2px solid #e4eced; border-left: 2px solid #e4eced; border-right: 2px solid #e4eced; }
.map-controls { position: absolute; right: 14px; top: 14px; z-index: 10; display: grid; gap: 5px; }
.map-controls .button { background: rgba(12, 21, 31, .88); }
.layers-popover { position: absolute; right: 60px; top: 14px; z-index: 11; padding: 12px; background: rgba(12,21,31,.98); border: 1px solid var(--border); border-radius: 6px; box-shadow: var(--shadow); display: grid; gap: 8px; min-width: 150px; }
.layers-popover strong { font-size: 12px; }
.layers-popover label { color: var(--muted); font-size: 11px; display: flex; gap: 7px; align-items: center; }
.task-area { position: absolute; inset: 19% 28% 19% 19%; z-index: 1; pointer-events: none; }
.task-shape { position: absolute; inset: 0; background: rgba(28, 122, 183, .16); clip-path: polygon(17% 0, 82% 7%, 100% 52%, 67% 100%, 8% 83%, 0 37%); border: 1px solid var(--cyan); }
.task-shape::after { content: ""; position: absolute; inset: 0; background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(68,181,215,.14) 7px 8px); }
.task-area::after { content: ""; position: absolute; inset: 0; clip-path: polygon(17% 0,82% 7%,100% 52%,67% 100%,8% 83%,0 37%); border: 1.4px solid var(--cyan); }
.task-area span { position: absolute; left: 50%; top: 39%; transform: translate(-50%, -50%); color: #61d4ea; text-align: center; font-size: 13px; line-height: 1.45; font-weight: 600; white-space: nowrap; }
.task-area.violet .task-shape { background: rgba(119, 75, 166, .22); }
.task-area.violet::after { border-color: var(--violet); }
.task-area.violet span { color: #be9bf3; }
.task-area.muted { opacity: .55; }
.route-line { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 2; pointer-events: none; }
.route-line path { stroke: #51a0ec; stroke-width: 3; stroke-opacity: .86; stroke-dasharray: 8 7; }
.route-line .planned-path { stroke: var(--violet); stroke-opacity: .68; stroke-width: 2.5; }
.vehicle-marker {
position: absolute; z-index: 4; transform: translate(-50%, -50%); background: none; border: 0; color: #bde6ff; padding: 7px; cursor: pointer;
}
.vehicle-marker .icon { transform: rotate(var(--heading)); width: 21px; height: 21px; filter: drop-shadow(0 1px 4px rgba(0,0,0,.8)); stroke-width: 2.2; }
.vehicle-marker::after { content: ""; position: absolute; left: 50%; bottom: 2px; height: 17px; border-left: 1px solid rgba(91, 194, 222, .55); transform: rotate(calc(var(--heading) + 180deg)); transform-origin: bottom; }
.vehicle-marker:hover .icon, .vehicle-marker.selected .icon { color: white; stroke: var(--cyan); filter: drop-shadow(0 0 6px rgba(42,185,214,.7)); }
.vehicle-marker.critical { color: var(--red); }
.vehicle-marker.critical::after { border-color: var(--red); }
.vehicle-marker > span { position: absolute; left: 20px; top: 4px; color: #fff; text-shadow: 0 1px 3px #000; font-size: 11px; white-space: nowrap; }
.coverage-ring { position: absolute; width: 145px; height: 145px; right: 23%; top: 25%; border: 1px dashed rgba(100, 179, 223, .55); border-radius: 50%; z-index: 1; }
.poi { position: absolute; z-index: 3; width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid currentColor; background: rgba(5,13,23,.75); font-weight: 600; }
.poi-blue { color: #6ab0ff; border-color: #398dde; }
.poi-violet { color: #c399ff; border-color: var(--violet); border-radius: 50%; }
.geofence { position: absolute; z-index: 0; width: 130px; height: 100px; right: 8%; top: 7%; transform: rotate(-10deg); background: repeating-linear-gradient(45deg, rgba(237,83,87,.1) 0 5px, transparent 5px 10px); border: 1px solid rgba(237,83,87,.56); }
.map-mini-exceptions { position: absolute; z-index: 8; right: 18px; bottom: 18px; padding: 12px; width: 215px; background: rgba(11,20,29,.94); border: 1px solid var(--border); box-shadow: var(--shadow); font-size: 11px; }
.map-mini-exceptions strong { text-transform: uppercase; color: #b9c5d3; font-size: 10px; letter-spacing: .05em; }
.map-mini-exceptions p { display: flex; gap: 6px; margin: 7px 0; color: #c8d0d6; }
.map-mini-exceptions .icon { width: 14px; height: 14px; color: var(--amber); }
.map-mini-exceptions a { font-size: 10px; text-transform: uppercase; }
.attention-card { width: 100%; padding: 12px 9px; display: grid; grid-template-columns: 23px 1fr; gap: 9px; text-align: left; background: rgba(29, 31, 25, .45); color: var(--text); border: 0; border-bottom: 1px solid var(--border-soft); cursor: pointer; }
.attention-card:hover { background: var(--surface-2); }
.attention-card .icon { color: var(--amber); margin-top: 2px; }
.attention-card.warning .icon { color: #f1b64b; }
.attention-card span { display: grid; gap: 3px; }
.attention-card strong { font-size: 12px; font-weight: 600; }
.attention-card em { grid-column: 2; color: var(--blue); font-size: 9px; font-style: normal; text-transform: uppercase; }
.fleet-nominal { margin: 16px 3px; padding: 15px; display: flex; gap: 12px; align-items: center; border: 1px solid var(--border); background: rgba(25,35,42,.8); }
.fleet-nominal .icon { color: var(--green); width: 21px; height: 21px; }
.fleet-nominal div { display: grid; grid-template-columns: auto 1fr; align-items: end; column-gap: 7px; }
.fleet-nominal b { grid-row: span 2; color: var(--green); font-size: 30px; line-height: .85; }
.fleet-nominal span { font-size: 10px; text-transform: uppercase; }
.fleet-nominal small { grid-column: span 2; margin-top: 7px; }
.context-bar { grid-column: 2 / 4; border-top: 1px solid var(--border); background: var(--surface-1); }
.context-summary { min-height: 70px; display: flex; align-items: center; gap: 10px; padding: 10px 18px; border-bottom: 1px solid var(--border-soft); }
.context-summary h2 { text-transform: uppercase; }
.context-summary > div:nth-child(2) { display: grid; gap: 2px; min-width: 90px; }
.context-summary > div:nth-child(2) small { color: var(--blue); text-transform: uppercase; }
.context-summary dl { display: grid; grid-template-columns: repeat(6, minmax(82px, 1fr)); gap: 16px; flex: 1; }
.context-summary dl div { display: grid; gap: 4px; min-width: 0; }
.context-summary dt { color: var(--dim); font-size: 9px; text-transform: uppercase; }
.context-summary dd { font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.progress { display: inline-flex; height: 4px; width: 68px; margin-left: 5px; vertical-align: middle; background: #283440; border-radius: 4px; overflow: hidden; }
.progress b { height: 100%; background: var(--blue); }
.context-actions { padding: 8px 18px; display: grid; grid-template-columns: repeat(7, 1fr); gap: 7px; }
.context-actions .button { text-transform: uppercase; font-size: 11px; }
.view-intro { padding: 24px 26px 18px; display: flex; justify-content: space-between; align-items: end; border-bottom: 1px solid var(--border); }
.view-intro h1 { font-size: 24px; text-transform: uppercase; letter-spacing: -.03em; }
.view-intro h1 span { font-size: 16px; color: var(--muted); font-weight: 500; }
.view-intro p { color: var(--muted); margin-top: 5px; }
.legend { display: flex; flex-wrap: wrap; gap: 14px; padding-bottom: 3px; }
.zoom-view { min-height: calc(100vh - 68px); background: var(--surface-0); }
.zoom-grid { display: grid; grid-template-columns: repeat(3, minmax(280px, 1fr)); gap: 12px; padding: 18px; }
.zoom-panel { min-width: 0; min-height: 670px; display: grid; grid-template-rows: 56px 1fr 92px; border: 1px solid var(--border); background: var(--surface-1); border-radius: 5px; overflow: hidden; }
.zoom-panel > header { display: flex; align-items: center; gap: 11px; padding: 0 14px; border-bottom: 1px solid var(--border); text-transform: uppercase; }
.zoom-panel > header h2 { font-size: 15px; }
.zoom-panel > header .button { margin-left: auto; min-height: 30px; width: 30px; min-width: 30px; }
.letter { width: 26px; height: 26px; display: grid; place-items: center; color: #fff; background: #245ea3; font-weight: 700; border-radius: 2px; }
.zoom-map { min-height: 0; }
.zoom-map .map-label { font-size: 9px; }
.zoom-map .task-area { inset: 20% 16% 20% 17%; }
.zoom-map .task-area.small-task { inset: 18% 12% 27% 48%; }
.zoom-map .task-area.wide { inset: 16% 8% 17% 7%; }
.zoom-map .task-area span { font-size: 11px; }
.zoom-map .vehicle-marker { transform: translate(-50%, -50%) scale(.72); }
.zoom-map .group-marker { transform: scale(.75); transform-origin: left top; }
.zoom-map .group-marker .marker-card { font-size: 12px; }
.zoom-map .route-line { opacity: .7; }
.zoom-fleet { position: absolute; left: 12px; right: 12px; bottom: 12px; display: grid; grid-template-columns: repeat(5, 1fr); gap: 5px; background: rgba(10, 18, 27, .83); border: 1px solid var(--border); padding: 8px; }
.zoom-fleet span { display: grid; gap: 5px; color: var(--muted); font-size: 8px; }
.zoom-fleet b { color: #cad7e0; font-size: 9px; }
.zoom-fleet i { display: block; height: 4px; background: #2b3543; border-radius: 3px; }
.zoom-fleet i::after { content: ""; display: block; width: 70%; height: 100%; background: var(--blue); border-radius: 3px; }
.map-kpi { position: absolute; left: 14px; top: 14px; padding: 12px; min-width: 132px; background: rgba(10,20,29,.9); border: 1px solid var(--border); border-left: 2px solid var(--green); display: grid; gap: 3px; }
.map-kpi b { color: var(--green); font-size: 22px; }
.map-kpi .ring { position: absolute; right: 14px; top: 18px; width: 31px; height: 31px; border: 4px solid rgba(89,198,111,.25); border-top-color: var(--green); border-radius: 50%; }
.map-kpi em { font-size: 10px; font-style: normal; color: var(--muted); }
.vehicle-inspector { position: absolute; right: 12px; top: 18px; z-index: 11; width: 184px; padding: 13px; background: rgba(13,23,34,.96); border: 1px solid var(--border); box-shadow: var(--shadow); }
.vehicle-inspector > div { display: flex; align-items: center; justify-content: space-between; }
.vehicle-inspector > div .button { min-height: 22px; width: 22px; min-width: 22px; }
.vehicle-inspector > div .icon { width: 14px; }
.vehicle-inspector > small { color: var(--green); display: block; padding: 4px 0 9px; border-bottom: 1px solid var(--border); }
.vehicle-inspector dl { display: grid; gap: 8px; padding: 10px 0; }
.vehicle-inspector dl div { display: flex; justify-content: space-between; gap: 6px; }
.vehicle-inspector dt { color: var(--muted); font-size: 9px; }
.vehicle-inspector dd { text-align: right; color: #e0e8ee; font-size: 10px; }
.vehicle-inspector hr { border: 0; border-top: 1px solid var(--border); }
.vehicle-inspector p { display: flex; justify-content: space-between; gap: 4px; margin-top: 8px; font-size: 10px; }
.vehicle-inspector p b { color: var(--muted); font-weight: 400; }
.zoom-footer { padding: 20px 12px; text-align: center; background: #101925; border-top: 1px solid var(--border); }
.zoom-footer h3 { font-size: 15px; }
.zoom-footer p { color: var(--muted); margin-top: 8px; font-size: 11px; }
.selection-workspace { min-height: calc(100vh - 68px); display: grid; grid-template-columns: 315px minmax(420px,1fr) 365px; }
.smart-panel, .assign-panel, .presence-panel { padding: 18px; background: var(--surface-1); border-right: 1px solid var(--border); min-width: 0; }
.smart-panel header, .assign-panel > header, .presence-panel > header, .queue-panel > header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 18px; border-bottom: 1px solid var(--border-soft); }
.smart-panel header .button, .assign-panel header .button { min-height: 30px; min-width: 30px; width: 30px; }
.form-label { color: #c7d1dd; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display: block; margin: 20px 0 9px; }
.natural-query { padding: 13px; display: flex; gap: 9px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 5px; }
.natural-query .icon { color: var(--cyan); width: 18px; }
.natural-query p { color: #e3eaf0; font-size: 12px; line-height: 1.55; }
.filter-heading { display: flex; justify-content: space-between; align-items: end; }
.filter-heading .form-label { margin-bottom: 9px; }
.filter-heading button, .subheading button { padding: 0; border: 0; background: none; color: var(--blue); cursor: pointer; font-size: 11px; }
.filter-stack { display: grid; gap: 6px; }
.filter-chip { min-height: 33px; display: grid; grid-template-columns: 1fr auto 24px; align-items: center; border: 1px solid var(--border); border-radius: 4px; background: rgba(25,34,47,.75); font-size: 11px; }
.filter-chip > span { color: #cbd6df; text-transform: uppercase; font-size: 10px; padding-left: 10px; }
.filter-chip > b { padding: 3px 8px; color: #72b0fd; background: rgba(35,123,237,.16); border-radius: 3px; font-weight: 500; }
.filter-chip > button { width: 24px; height: 28px; display: grid; place-items: center; border: 0; border-left: 1px solid var(--border); background: transparent; color: var(--muted); cursor: pointer; }
.filter-chip .icon { width: 13px; }
.selection-result { padding: 22px 0 14px; }
.selection-result > small, .selection-result > p { display: block; text-transform: uppercase; font-size: 10px; margin-bottom: 5px; }
.selection-result > strong { display: block; color: var(--blue); font-size: 38px; line-height: 1; margin-bottom: 18px; }
.selection-result > strong span { font-size: 14px; text-transform: uppercase; }
.selection-result dl { display: grid; gap: 8px; }
.selection-result dl div { display: grid; grid-template-columns: 1fr 94px; align-items: center; gap: 8px; }
.selection-result dt { color: var(--muted); font-size: 9px; text-transform: uppercase; }
.selection-result dd { color: #acbac7; font-family: var(--mono); font-size: 9px; }
.selection-result dd i { width: 58px; height: 5px; display: inline-block; background: linear-gradient(to right, var(--blue) 42%, #263647 42%); vertical-align: middle; margin-right: 4px; }
.updated { display: block; margin: 14px 0 0; color: var(--dim); }
.selection-map-stage { min-width: 0; }
.selection-map .selection-task { position: absolute; inset: 7% 16% 37% 32%; z-index: 2; }
.selection-map .task-area { inset: 0; }
.selection-map .task-area span { top: 33%; padding: 11px; background: rgba(37,23,60,.85); border: 1px solid var(--violet); border-radius: 7px; }
.task-pin { position: absolute; left: 53%; top: 52%; width: 10px; height: 10px; background: var(--violet); border-radius: 50%; box-shadow: 0 0 0 4px rgba(154,115,216,.25); }
.selection-map .vehicle-marker { color: #a8ecf8; }
.selection-map .vehicle-marker > span { padding: 4px 6px; left: 22px; top: -5px; background: rgba(12,21,31,.86); border: 1px solid #3b596d; border-radius: 10px; font-size: 9px; }
.floating-task-card { position: absolute; z-index: 9; left: 25%; bottom: 8%; width: 330px; padding: 16px; background: rgba(13,22,33,.96); border: 1px solid #3c4d63; box-shadow: var(--shadow); border-radius: 7px; }
.floating-task-card h3 { text-transform: uppercase; color: #b994f3; font-size: 17px; }
.floating-task-card h3 span { display: block; font-size: 14px; }
.floating-task-card dl { display: grid; grid-template-columns: 1fr 1fr; gap: 0; margin-top: 16px; border: 1px solid var(--border); }
.floating-task-card dl div { padding: 10px; display: grid; gap: 5px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.floating-task-card dt { color: var(--muted); font-size: 9px; }
.floating-task-card dd { font-size: 11px; }
.floating-task-card footer { display: flex; gap: 7px; align-items: center; padding-top: 12px; color: var(--muted); font-size: 10px; }
.floating-task-card footer .icon { width: 15px; }
.floating-task-card footer b { margin-left: auto; color: var(--text); font-size: 12px; }
.assign-panel { border-left: 1px solid var(--border); border-right: 0; padding: 18px 20px; }
.assign-panel header h1 { text-transform: uppercase; font-size: 17px; }
.assign-panel header p { color: var(--blue); text-transform: uppercase; font-size: 11px; margin-top: 5px; }
.task-meta { display: grid; gap: 12px; padding: 20px 0; }
.task-meta div { display: grid; grid-template-columns: 55px 1fr; gap: 10px; }
.task-meta dt, .assign-panel h2 { color: var(--muted); text-transform: uppercase; font-size: 10px; }
.task-meta dd { font-size: 12px; }
.assign-panel h2 { margin: 7px 0 12px; }
.validation-list { border: 1px solid var(--border); border-radius: 5px; overflow: hidden; }
.validation-row { min-height: 47px; display: grid; grid-template-columns: 21px 1fr minmax(86px,auto) 23px; gap: 8px; align-items: center; padding: 7px 11px; border-bottom: 1px solid var(--border-soft); }
.validation-row:last-child { border-bottom: 0; }
.validation-row > .icon { color: var(--muted); width: 17px; }
.validation-row > span { font-size: 12px; }
.validation-row > b { color: #d2dce5; font-size: 11px; font-weight: 500; text-align: right; }
.round-check { width: 19px; height: 19px; display: grid; place-items: center; border-radius: 50%; background: var(--green); color: #0e2615; }
.round-check .icon { width: 13px; height: 13px; stroke-width: 3; }
.info-callout { display: flex; gap: 8px; margin-top: 13px; padding: 10px; border: 1px solid rgba(35,123,237,.46); background: rgba(35,123,237,.06); color: #a9c2df; border-radius: 5px; }
.info-callout .icon { color: var(--blue); width: 17px; }
.info-callout p { font-size: 11px; line-height: 1.4; }
.assign-panel > footer { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; padding-top: 18px; }
.collaboration-workspace { min-height: calc(100vh - 68px); display: grid; grid-template: 1fr auto / 270px minmax(430px, 1fr) 315px; }
.group-rail.compact .group-card { min-height: 84px; }
.group-rail.compact .group-card-main small:nth-of-type(2), .group-rail.compact .group-card-side { display: none; }
.collab-map-stage { min-width: 0; position: relative; }
.collaboration-map .task-area { inset: 35% 43% 22% 21%; }
.collab-alpha-boundary { position: absolute; inset: 0; }
.collab-alpha-boundary .vehicle-marker { transform: translate(-50%,-50%) scale(.8); }
.group-marker { position: absolute; z-index: 7; display: flex; align-items: center; gap: 7px; border: 0; background: transparent; color: var(--text); padding: 0; cursor: pointer; text-align: left; }
.marker-dot { width: 38px; height: 38px; display: grid; place-items: center; border: 2px solid currentColor; border-radius: 50%; background: rgba(7,15,24,.72); }
.marker-dot b { font-size: 14px; }
.marker-card { padding: 6px 8px; display: grid; gap: 2px; background: rgba(9,16,25,.86); border: 1px solid rgba(80,104,130,.55); border-radius: 4px; box-shadow: 0 3px 10px rgba(0,0,0,.2); }
.marker-card strong { font-size: 12px; }
.marker-card small { color: #c5d0da; font-size: 9px; white-space: nowrap; }
.group-marker.cyan { color: var(--cyan); }.group-marker.blue { color: #4d93ff; }.group-marker.violet { color: #b18be8; }.group-marker.amber { color: var(--amber); }
.group-marker.is-selected .marker-dot { box-shadow: 0 0 0 5px rgba(42,185,214,.15); }
.operator-cursor { position: absolute; z-index: 12; display: grid; justify-items: center; gap: 2px; color: var(--cyan); font-size: 10px; }
.operator-cursor i { font-size: 27px; line-height: .75; font-style: normal; text-shadow: 0 1px 4px #000; }
.operator-cursor b { padding: 3px 5px; background: rgba(10,20,29,.9); border: 1px solid currentColor; border-radius: 3px; text-transform: uppercase; font-size: 8px; }
.operator-cursor.green { color: var(--green); }.operator-cursor.amber { color: #ef8e4c; }.operator-cursor.violet { color: var(--violet); }.operator-cursor.yellow { color: #eed16a; }
.lease-card { position: absolute; z-index: 12; left: 4%; top: 43%; width: 170px; padding: 14px; background: rgba(13,23,34,.95); border: 1px solid #336a8f; box-shadow: var(--shadow); }
.lease-card small { color: #72c8e4; text-transform: uppercase; font-size: 9px; }
.lease-card h2 { margin: 7px 0 2px; font-size: 19px; }
.lease-card p { color: var(--muted); font-size: 11px; }
.lease-card p b { color: var(--cyan); }.lease-card hr { margin: 12px 0; border: 0; border-top: 1px solid var(--border); }.lease-card .status { margin: 8px 9px 0 0; }
.presence-panel { border-left: 1px solid var(--border); border-right: 0; padding: 18px 13px; }
.presence-panel h1 { color: var(--muted); text-transform: uppercase; letter-spacing: .05em; font-size: 12px; }
.presence-list { border: 1px solid var(--border); margin-top: 12px; }
.presence-row { width: 100%; min-height: 62px; display: grid; grid-template-columns: 10px 1fr 20px; align-items: center; gap: 9px; text-align: left; padding: 8px 11px; background: rgba(23,31,43,.64); border: 0; border-bottom: 1px solid var(--border-soft); cursor: pointer; }
.presence-row:hover { background: var(--surface-3); }
.presence-row span { display: grid; gap: 3px; }.presence-row strong { text-transform: uppercase; font-size: 13px; }.presence-row b { color: var(--muted); font-size: 18px; }
.subheading { margin-top: 24px; padding: 0 0 9px !important; border: 0 !important; }.subheading h2 { color: var(--muted); text-transform: uppercase; letter-spacing: .05em; font-size: 11px; }
.collab-stream { border: 1px solid var(--border); }
.collab-stream p { min-height: 46px; display: grid; grid-template-columns: 8px 1fr auto; align-items: center; gap: 7px; padding: 8px; border-bottom: 1px solid var(--border-soft); color: #c6d2dc; font-size: 10px; }
.collab-stream small { color: var(--dim); font-size: 9px; }
.mission-events { grid-column: 1 / -1; display: grid; grid-template-columns: 140px 1fr; gap: 10px; padding: 12px 18px; border-top: 1px solid var(--border); background: #101822; }
.mission-events > strong { color: var(--muted); text-transform: uppercase; font-size: 10px; letter-spacing: .05em; }
.mission-events > div { display: flex; gap: 20px; overflow: auto; }
.mission-events span { min-width: max-content; color: #b7c4cf; font-size: 10px; }.mission-events .presence-dot { margin-right: 3px; }.mission-events p { grid-column: 1 / -1; color: var(--muted); text-align: center; margin-top: 6px; font-size: 11px; }
.recovery-workspace { min-height: calc(100vh - 68px); display: grid; grid-template: minmax(500px, 1fr) auto / 290px minmax(430px, 1fr) minmax(500px, 1.08fr); }
.recovery-left { padding: 15px 10px; background: var(--surface-1); border-right: 1px solid var(--border); }
.recovery-left > header { padding: 10px 6px 14px; border-bottom: 1px solid var(--border); }.recovery-left header h1, .recovery-right > header h1 { font-size: 15px; text-transform: uppercase; }
.recovery-alert { margin-top: 14px; padding: 20px 14px; display: flex; gap: 13px; border: 1px solid; border-radius: 6px; }
.recovery-alert.critical { border-color: rgba(237,83,87,.55); background: rgba(104,24,31,.4); }.recovery-alert.critical .icon { color: var(--red); }
.recovery-alert.amber { border-color: rgba(240,181,68,.52); background: rgba(97,74,19,.34); }.recovery-alert.amber .icon { color: var(--amber); }
.recovery-alert > .icon { width: 26px; height: 26px; margin-top: 4px; }.recovery-alert div { display: grid; gap: 6px; }.recovery-alert h2 { font-size: 14px; }.recovery-alert p { font-size: 12px; }.recovery-alert b { margin-top: 7px; width: max-content; padding: 5px 9px; font-size: 10px; font-weight: 500; border-radius: 3px; }.recovery-alert.critical b { background: rgba(237,83,87,.14); color: #fb9fa2; }.recovery-alert.amber b { background: rgba(240,181,68,.16); color: #f1c66d; }
.fleet-summary { margin: auto 3px 0; margin-top: 90px; padding: 20px; border: 1px solid var(--border); background: rgba(25,35,42,.8); text-align: center; }.fleet-summary h2 { color: #cdd7df; text-transform: uppercase; font-size: 12px; text-align: left; }.fleet-summary > strong { display: block; margin: 26px 0 2px; color: var(--green); font-size: 49px; line-height: .85; }.fleet-summary > p { font-size: 13px; }.fleet-summary > small { display: block; margin: 20px 0; }.fleet-summary .button { width: 100%; }
.recovery-map-stage { min-width: 0; }.recovery-map .task-area { inset: 20% 22% 28% 10%; }.recovery-map .vehicle-marker { transform: translate(-50%,-50%) scale(1.08); color: #79e394; }.recovery-map .route-line path { stroke: rgba(100,190,174,.65); }.failed-marker { position: absolute; z-index: 8; transform: translate(-50%,-50%); display: grid; grid-template-columns: 28px 1fr; column-gap: 7px; align-items: center; padding: 6px; color: #f77175; text-align: left; background: rgba(29,15,21,.9); border: 1px solid rgba(237,83,87,.62); border-radius: 20px; cursor: pointer; }.failed-marker span { grid-row: span 2; width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid currentColor; border-radius: 50%; font-weight: 700; }.failed-marker b { font-size: 11px; }.failed-marker small { color: #f47479; font-size: 9px; }
.recovery-task-map-card { position: absolute; z-index: 8; left: 15%; bottom: 11%; width: min(480px, 62%); display: flex; align-items: center; gap: 15px; padding: 19px; background: rgba(14,25,36,.96); border: 1px solid #405166; border-radius: 8px; box-shadow: var(--shadow); }.recovery-task-map-card > .icon { width: 36px; height: 36px; color: var(--blue); }.recovery-task-map-card div { display: grid; gap: 5px; }.recovery-task-map-card h2 { font-size: 18px; }.recovery-task-map-card p { color: var(--muted); font-size: 12px; }.recovery-task-map-card > b { margin-left: auto; display: grid; text-align: right; font-size: 24px; }.recovery-task-map-card > b small { font-size: 10px; }
.recovery-right { background: var(--surface-1); border-left: 1px solid var(--border); padding: 18px; min-width: 0; }.recovery-right > header { padding: 2px 0 17px; }.recovery-right > header h1 { display: flex; align-items: center; gap: 9px; color: #d9e4ec; }.recovery-right > header .icon { color: var(--blue); width: 29px; height: 29px; }
.current-state, .recommendations, .local-preview { border: 1px solid var(--border); background: rgba(24,34,46,.64); padding: 14px; margin-bottom: 10px; border-radius: 5px; }.current-state h2, .recommendations h2, .local-preview h2 { color: #c5d1dc; text-transform: uppercase; font-size: 11px; font-weight: 600; }.current-state dl { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 14px; }.current-state dl div { min-width: 0; display: grid; gap: 5px; padding-right: 7px; border-right: 1px solid var(--border-soft); }.current-state dt { color: var(--muted); font-size: 10px; }.current-state dd { font-size: 15px; }.current-state dd small { display: block; }
.recommendations > p { display: flex; gap: 6px; align-items: center; margin: 11px 0; color: var(--muted); font-size: 10px; }.recommendations > p .icon { width: 15px; }.candidate-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }.candidate-card { min-width: 0; padding: 10px; background: rgba(14,23,34,.84); color: #e2eaf0; border: 1px solid #354355; border-radius: 5px; text-align: left; cursor: pointer; }.candidate-card:hover { border-color: #6887a8; }.candidate-card.selected { border-color: var(--cyan); box-shadow: inset 0 0 0 1px var(--cyan), 0 0 15px rgba(42,185,214,.09); }.candidate-card header { display: flex; gap: 8px; align-items: center; }.candidate-card header > .icon { color: #c3d0da; width: 24px; height: 24px; }.candidate-card header span { display: grid; gap: 3px; }.candidate-card header strong { font-size: 12px; }.candidate-card .status { font-size: 9px; }.candidate-card header .round-check { margin-left: auto; }.candidate-card dl { display: grid; gap: 8px; margin-top: 13px; }.candidate-card dl div { display: flex; justify-content: space-between; gap: 5px; padding-bottom: 5px; border-bottom: 1px solid rgba(73,91,111,.32); }.candidate-card dt { color: var(--muted); font-size: 9px; }.candidate-card dd { font-size: 10px; }.candidate-card dd .status { font-size: 0; }.candidate-card dd .status-dot { display: inline-block; margin-left: 3px; }.candidate-card footer { margin: 10px -10px -10px; padding: 6px; border-top: 1px solid var(--cyan); color: var(--cyan); text-align: center; text-transform: uppercase; font-size: 9px; }
.recommendation-note { margin-bottom: 0 !important; }.local-preview div { position: relative; height: 70px; margin-top: 8px; display: flex; align-items: center; gap: 12px; color: var(--green); }.local-preview div .icon { width: 25px; height: 25px; }.local-preview div > span { display: flex; align-items: center; gap: 5px; color: #dbe4ea; font-size: 11px; }.local-preview div i { flex: 1; height: 0; border-top: 2px dashed var(--cyan); }.local-preview div b { padding: 15px; color: #5bcff0; font-size: 11px; border: 1px dashed var(--cyan); }.recovery-right > footer { display: grid; grid-template-columns: 1fr 1.3fr 1.7fr; gap: 8px; }.recovery-events { grid-column: 1 / -1; min-height: 72px; display: flex; align-items: center; gap: 30px; padding: 12px 24px; background: #101923; border-top: 1px solid var(--border); overflow: auto; }.recovery-events span { min-width: max-content; display: flex; align-items: center; gap: 8px; color: #b8c4ce; font-size: 11px; }.recovery-events .icon { color: var(--amber); }.recovery-events span:first-child .icon { color: var(--red); }
.queue-workspace { min-height: calc(100vh - 68px); display: grid; grid-template-rows: 55px 1fr 82px; }.queue-topline { display: flex; align-items: center; gap: 0; padding: 0 18px; border-bottom: 1px solid var(--border); background: #0e151f; }.queue-topline > div { padding: 0 34px; display: grid; gap: 3px; border-right: 1px solid var(--border-soft); }.queue-topline > div:last-child { margin-left: auto; }.queue-topline span { color: var(--muted); text-transform: uppercase; font-size: 9px; }.queue-topline b { font-size: 12px; text-transform: uppercase; }.queue-body { min-height: 0; display: grid; grid-template-columns: minmax(360px, 1.06fr) minmax(540px, 1fr); gap: 12px; padding: 12px; background: #0b1119; }.queue-map-stage { min-height: 0; }.queue-map { border: 1px solid var(--border-soft); border-radius: 6px; }.queue-map .task-area { inset: 25% 15% 42% 42%; }.queue-map .task-area.queue-search { inset: 29% 17% 45% 38%; }.queue-map .task-area.queue-search.future { inset: 58% 11% 18% 57%; }.queue-map .task-area.queue-search.future .task-shape { background: rgba(119,75,166,.18); }.queue-map .task-area.queue-search.future::after { border-color: var(--violet); }.queue-map .task-area.queue-search.future span { color: #c499f1; }.base-service { position: absolute; left: 28%; bottom: 21%; display: grid; justify-items: center; color: var(--green); font-size: 27px; }.base-service span { color: #d8e3e5; font-size: 11px; text-align: center; }.queue-group-label { position: absolute; left: 52%; top: 40%; display: grid; justify-items: center; color: #e3edf2; }.queue-group-label .group-badge { margin-bottom: 6px; }.queue-group-label strong { font-size: 13px; }.queue-group-label small { color: #d5dee5; }.queue-group-card { position: absolute; left: 18px; bottom: 18px; width: 185px; padding: 16px; border: 1px solid #40526a; background: rgba(12,23,34,.94); border-radius: 6px; }.queue-group-card .group-badge { float: left; margin-right: 12px; }.queue-group-card h2 { padding-top: 6px; text-transform: uppercase; font-size: 17px; }.queue-group-card p { clear: both; display: flex; gap: 7px; align-items: center; padding-top: 13px; color: #d6e0e5; font-size: 11px; }.queue-group-card p .icon { width: 15px; color: #b9c9d5; }.queue-group-card .status { margin-top: 13px; }
.queue-panel { min-width: 0; border: 1px solid var(--border); background: var(--surface-1); border-radius: 6px; display: grid; grid-template-rows: 54px 1fr 65px; overflow: hidden; }.queue-panel > header { padding: 0 14px; }.queue-panel > header h1 { text-transform: uppercase; font-size: 17px; }.queue-panel > header h1 span { color: var(--blue); }.queue-panel > header > div { display: flex; align-items: center; gap: 8px; }.queue-panel > header .button { min-height: 30px; width: 30px; min-width: 30px; }.queue-content { min-height: 0; display: grid; grid-template-columns: minmax(300px, 1fr) 270px; }.step-list { padding: 10px; overflow: auto; border-right: 1px solid var(--border); }.queue-step { min-height: 74px; display: grid; grid-template-columns: 18px 31px 1fr 77px 50px; align-items: center; gap: 9px; padding: 8px; margin-bottom: 7px; border: 1px solid #303e50; border-radius: 5px; background: rgba(30,41,55,.74); }.queue-step.current { border-color: var(--blue); box-shadow: inset 0 0 0 1px rgba(42,185,214,.75); }.queue-step.completed { opacity: .8; }.drag-handle { border: 0; background: none; color: var(--muted); cursor: grab; font-size: 18px; padding: 0; }.step-number { width: 28px; height: 28px; display: grid; place-items: center; background: #1d2b3c; border: 1px solid #405064; border-radius: 4px; font-weight: 600; }.queue-step > div:nth-of-type(1) { display: grid; gap: 3px; }.queue-step h2 { font-size: 13px; }.queue-step p { color: var(--muted); font-size: 10px; }.queue-step .progress { margin: 2px 0 0; width: 85px; }.queue-step > div small { color: var(--blue); text-transform: uppercase; font-size: 9px; }.queue-step > aside { display: grid; gap: 2px; justify-items: end; }.queue-step > aside b { font-size: 10px; }.queue-step > aside small { font-size: 9px; }.queue-step > aside .status { font-size: 8px; }.step-arrows { display: grid; grid-template-columns: 1fr 1fr; gap: 2px; }.step-arrows .button { min-height: 26px; min-width: 22px; width: 22px; padding: 0; }.step-arrows .button:first-child .icon { transform: rotate(180deg); }.step-arrows .icon { width: 13px; }.drop-zone { min-height: 48px; display: grid; place-items: center; color: var(--violet); border: 1px dashed rgba(154,115,216,.65); background: rgba(99,63,140,.09); font-size: 11px; }.step-inspector { padding: 15px; background: #101824; overflow: auto; }.step-inspector > header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 14px; border-bottom: 1px solid var(--border); }.step-inspector > header b, .step-inspector h2 { color: #a9bad0; text-transform: uppercase; font-size: 10px; }.step-inspector > header .button { min-height: 24px; min-width: 24px; width: 24px; }.step-inspector h2 { margin-top: 18px; }.step-inspector p { display: flex; gap: 7px; align-items: center; margin-top: 6px; color: #e0e7eb; font-size: 11px; line-height: 1.5; }.step-inspector p .icon { width: 15px; color: var(--muted); }.queue-actions { display: grid; grid-template-columns: 1fr 1fr 1.3fr 1.3fr; gap: 7px; padding: 10px; border-top: 1px solid var(--border); }.queue-actions .button { text-transform: uppercase; }.queue-events { display: flex; align-items: center; gap: 32px; padding: 12px 24px; border-top: 1px solid var(--border); background: #121b26; color: #b3c0ca; font-size: 11px; }.queue-events strong { color: #c6d0d9; text-transform: uppercase; font-size: 10px; }.queue-events b { color: var(--blue); }
.system-view { max-width: 1600px; margin: 0 auto; padding: 24px; }.system-title { margin-bottom: 12px; }.system-title h1 { font-size: 28px; text-transform: uppercase; }.system-title p { margin-top: 4px; color: var(--muted); }.system-title p span { color: var(--blue); }.system-grid { display: grid; grid-template-columns: 1.1fr .9fr 1.55fr; grid-template-rows: auto auto; border: 1px solid var(--border); }.system-card { min-width: 0; padding: 16px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); background: var(--surface-1); }.system-card h2 { margin-bottom: 15px; color: #e2e9ee; text-transform: uppercase; font-size: 12px; }.system-card > p { color: #c4d0d8; font-size: 11px; }.colors { grid-row: span 2; }.swatches { display: grid; grid-template-columns: repeat(5, 1fr); gap: 9px; margin: 11px 0 22px; }.swatches.semantic { grid-template-columns: repeat(6, 1fr); }.swatch { display: grid; gap: 5px; color: var(--muted); font-size: 9px; }.swatch i { height: 43px; border: 1px solid #364358; border-radius: 3px; background: var(--surface-0); }.swatch i.s0 { background: var(--surface-0); }.swatch i.s1 { background: var(--surface-1); }.swatch i.s2 { background: var(--surface-2); }.swatch i.s3 { background: var(--surface-3); }.swatch i.border { background: var(--border); }.swatch i.blue { background: var(--blue); }.swatch i.cyan { background: var(--cyan); }.swatch i.green { background: var(--green); }.swatch i.amber { background: var(--amber); }.swatch i.red { background: var(--red); }.swatch i.violet { background: var(--violet); }.swatch b { font-size: 10px; color: #d3dde5; }.colors ul { padding-left: 18px; color: var(--muted); font-size: 10px; line-height: 1.65; }.type { min-height: 275px; }.type h1 { display: flex; gap: 10px; align-items: baseline; font-size: 25px; margin: 17px 0; }.type h1 b { font-size: 20px; }.type h2 { display: flex; gap: 10px; font-size: 19px; }.type h2 b { font-size: 16px; }.type > p { display: flex; gap: 11px; margin: 25px 0; }.type > small { display: block; }.type pre { color: #d4e0e6; font-family: var(--mono); font-size: 10px; }.components { min-height: 275px; }.component-columns { display: grid; grid-template-columns: 1.1fr 1fr .9fr; gap: 15px; }.component-columns section { display: grid; align-content: start; gap: 7px; }.component-columns p { color: var(--muted); font-size: 10px; }.component-columns .button { min-height: 34px; justify-content: flex-start; font-size: 10px; }.chip-wrap { display: flex; flex-wrap: wrap; gap: 8px; }.authority-demo { padding: 8px; display: flex; gap: 8px; align-items: center; border: 1px solid var(--blue); color: var(--cyan); border-radius: 4px; font-size: 10px; text-transform: uppercase; }.authority-demo.muted { border-color: var(--border); color: var(--muted); }.mini-components { display: grid; grid-template-columns: 1fr 1fr 1.3fr; gap: 10px; margin-top: 18px; }.mini-components > article { min-height: 90px; padding: 10px; border: 1px solid var(--border); border-radius: 4px; font-size: 10px; }.mini-components > article > b { display: inline-block; margin-right: 6px; }.mini-components p { margin-top: 6px; color: var(--muted); }.mini-alert { display: flex; gap: 9px; align-items: center; background: rgba(91,24,31,.45); border-color: rgba(237,83,87,.47) !important; }.mini-alert .icon { color: var(--red); }.mini-alert span { display: grid; gap: 6px; }.interactions { grid-column: span 2; }.multi-select-demo { border: 1px solid var(--border); }.select-map { position: relative; height: 166px; overflow: hidden; background: #0b1723; }.select-map .vehicle-marker { transform: translate(-50%,-50%) scale(.7); }.select-map > i { position: absolute; left: 21%; top: 15%; right: 21%; bottom: 15%; border: 1px solid var(--cyan); background: rgba(42,185,214,.08); }.multi-summary { display: grid; grid-template-columns: 31px 1fr repeat(3, .5fr); gap: 8px; align-items: center; padding: 13px; }.multi-summary > .icon { color: #092438; background: var(--cyan); border-radius: 3px; padding: 4px; width: 29px; height: 29px; }.multi-summary strong { text-transform: uppercase; font-size: 15px; }.multi-summary > span { display: grid; gap: 2px; color: var(--muted); text-transform: uppercase; font-size: 9px; }.multi-summary > span b { color: var(--blue); font-size: 16px; }.multi-summary .button { min-height: 37px; grid-row: 2; }.state-cards { grid-row: span 1; }.selection-state-card { min-height: 58px; display: grid; grid-template-columns: 35px 1fr auto; gap: 7px; align-items: center; padding: 8px; margin-bottom: 7px; border: 1px solid var(--border); border-radius: 4px; }.selection-state-card.selected { border-color: var(--cyan); }.selection-state-card .group-badge { width: 27px; height: 27px; font-size: 10px; }.selection-state-card b { font-size: 11px; }.selection-state-card small { grid-column: 2 / 4; }.selection-state-card.muted { opacity: .65; }.responsive { grid-column: 2 / -1; }.responsive-samples { display: grid; grid-template-columns: repeat(4,1fr); gap: 9pxLine truncated
.multi-summary { grid-template-columns: 31px 1fr repeat(3, 1fr); }
.multi-summary .button:nth-of-type(1) { grid-column: 1 / 3; }
.multi-summary .button:nth-of-type(2) { grid-column: 3 / 5; }
.multi-summary .button:nth-of-type(3) { grid-column: 5 / 6; }
.modal-backdrop { position: fixed; z-index: 1000; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(2,6,10,.68); backdrop-filter: blur(3px); }.modal-dialog { width: min(560px, 100%); padding: 22px; background: #141d29; border: 1px solid #405169; box-shadow: 0 28px 70px rgba(0,0,0,.6); border-radius: 8px; }.modal-dialog > header { display: flex; align-items: start; justify-content: space-between; padding-bottom: 16px; border-bottom: 1px solid var(--border); }.modal-dialog > header small { color: var(--cyan); text-transform: uppercase; }.modal-dialog > header h1 { margin-top: 3px; font-size: 21px; }.modal-dialog > header .button { min-height: 30px; min-width: 30px; width: 30px; }.preview-lede { margin: 18px 0; color: #c4d1da; }.preview-route { min-height: 86px; display: grid; grid-template-columns: 1fr 38px 1fr; align-items: center; padding: 13px; background: #0d1721; border: 1px solid var(--border); }.preview-route div { display: grid; gap: 6px; justify-items: center; color: var(--muted); font-size: 11px; }.preview-route div i { width: 20px; height: 20px; border-radius: 50%; background: var(--blue); }.preview-route div.target i { background: var(--violet); }.preview-route > span { color: var(--cyan); text-align: center; }.preview-validation { margin-top: 16px; border: 1px solid var(--border); }.modal-dialog footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }.transfer-authority { display: flex; gap: 11px; align-items: center; padding: 17px 0; }.transfer-authority p { margin-top: 4px; color: var(--muted); font-size: 12px; }.transfer-dialog label { display: grid; gap: 8px; color: var(--muted); font-size: 11px; text-transform: uppercase; }.transfer-dialog textarea { min-height: 65px; padding: 10px; resize: none; color: #dbe4e8; background: #0b131c; border: 1px solid var(--border); border-radius: 4px; }.notice { display: flex; gap: 7px; margin-top: 15px; color: #aebed0; font-size: 12px; line-height: 1.45; }.notice .icon { color: var(--blue); }.help-dialog p { margin-top: 18px; color: #c4d0d8; line-height: 1.65; }
.toast { position: fixed; z-index: 1200; right: 20px; bottom: 20px; max-width: 430px; padding: 12px 11px 12px 14px; display: flex; align-items: center; gap: 10px; color: #dce8e9; background: #14232c; border: 1px solid #3b8291; box-shadow: var(--shadow); border-radius: 6px; font-size: 12px; }.toast > .icon { color: var(--green); }.toast button { margin-left: 5px; padding: 2px; display: grid; border: 0; background: transparent; color: var(--muted); cursor: pointer; }.toast button .icon { width: 15px; }
@media (min-width: 1900px) {
.overview-workspace { grid-template-columns: 280px minmax(900px, 1fr) 280px; }
.group-card { min-height: 102px; }
.attention-card { padding: 16px 12px; }
}
@media (max-width: 1320px) {
.top-metrics > div:nth-child(1), .top-metrics > div:nth-child(2), .top-metrics > div:nth-child(4) { display: none; }
.overview-workspace { grid-template-columns: 215px minmax(400px, 1fr) 215px; }
.group-card { grid-template-columns: 32px 1fr; min-height: 82px; }.group-card-side { display: none; }
.context-summary dl { grid-template-columns: repeat(4, minmax(72px,1fr)); }.context-summary dl .wide-only { display: none; }
.selection-workspace { grid-template-columns: 270px minmax(360px,1fr) 320px; }.smart-panel, .assign-panel { padding: 14px; }
.recovery-workspace { grid-template-columns: 240px minmax(380px, 1fr) minmax(410px,.95fr); }.candidate-card { padding: 8px; }.candidate-grid { gap: 6px; }
.collaboration-workspace { grid-template-columns: 225px minmax(400px,1fr) 275px; }
}
@media (max-width: 1050px) {
.topbar { gap: 7px; }.view-nav { margin: 0; }.nav-item { padding: 0 7px; }.nav-item span { display: none; }.top-metrics > div:not(.operator-avatar) { display: none; }.operator-avatar { min-width: auto; }.operator-avatar div { display: none; }
.overview-workspace { grid-template: minmax(520px,1fr) auto / 205px minmax(390px,1fr); }.attention-rail { display: none; }.context-bar { grid-column: 2; }.context-summary dl { grid-template-columns: repeat(3, minmax(80px,1fr)); }.context-actions { grid-template-columns: repeat(4,1fr); }
.selection-workspace { grid-template-columns: 250px minmax(350px,1fr); }.assign-panel { display: none; }
.collaboration-workspace { grid-template-columns: 210px minmax(420px,1fr); }.presence-panel { display: none; }.mission-events { grid-column: 1 / -1; }
.recovery-workspace { grid-template: minmax(470px,1fr) auto / 210px minmax(420px,1fr); }.recovery-right { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--border); }.fleet-summary { margin-top: 40px; }.recovery-events { display: none; }
.queue-body { grid-template-columns: 1fr; }.queue-map-stage { min-height: 330px; }.queue-workspace { grid-template-rows: 55px auto 80px; }.queue-panel { min-height: 480px; }
.system-grid { grid-template-columns: 1fr 1fr; }.colors { grid-row: auto; }.components, .responsive { grid-column: span 2; }.interactions { grid-column: span 1; }
}
@media (max-width: 720px) {
.topbar { height: 56px; padding: 0 8px; }.brand > span:not(.brand-mark), .brand-divider, .view-nav { display: none; }.top-metrics { margin-left: auto; }.operator-avatar > span { width: 28px; height: 28px; }
.workspace, .overview-workspace { min-height: calc(100vh - 56px); }.overview-workspace { grid-template: minmax(420px,1fr) auto / 1fr; }.group-rail { display: none; }.context-bar { grid-column: 1; }.context-summary { padding: 8px 10px; }.context-summary dl { grid-template-columns: repeat(2,1fr); gap: 8px; }.context-summary dl div:nth-child(n+3) { display: none; }.context-actions { padding: 7px 10px; grid-template-columns: repeat(3,1fr); }.context-actions .button { min-height: 42px; padding: 0 7px; font-size: 10px; }
.view-intro { padding: 17px 14px; }.view-intro h1 { font-size: 18px; }.view-intro h1 span { display: none; }.view-intro p { font-size: 11px; }.legend { display: none; }.zoom-grid { grid-template-columns: 1fr; padding: 10px; }.zoom-panel { min-height: 470px; grid-template-rows: 48px 1fr 74px; }
.selection-workspace { display: block; }.smart-panel { border-right: 0; }.selection-map-stage { height: 470px; }.floating-task-card { left: 8%; bottom: 8%; width: 84%; }.selection-map .map-controls { display: none; }
.collaboration-workspace { display: block; }.collaboration-workspace .group-rail { display: block; }.collab-map-stage { height: 510px; }.mission-events { display: block; }.mission-events > div { margin-top: 10px; }.mission-events p { margin-top: 9px; text-align: left; }
.recovery-workspace { display: block; }.recovery-left { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }.recovery-left > header { grid-column: 1 / -1; }.recovery-alert { margin-top: 0; padding: 12px; }.fleet-summary { display: none; }.recovery-map-stage { height: 430px; }.recovery-right { padding: 12px; }.current-state dl { grid-template-columns: 1fr 1fr; }.candidate-grid { grid-template-columns: 1fr; }.candidate-card dl { grid-template-columns: 1fr 1fr; }
.queue-topline { gap: 8px; overflow: auto; padding: 0 10px; }.queue-topline > div { min-width: max-content; padding: 0 10px; }.queue-topline > div:nth-child(n+4) { display: none; }.queue-body { padding: 7px; }.queue-map-stage { min-height: 290px; }.queue-content { grid-template-columns: 1fr; }.step-inspector { display: none; }.queue-panel { grid-template-rows: 54px 1fr 112px; }.queue-actions { grid-template-columns: 1fr 1fr; }.queue-events { display: grid; gap: 6px; padding: 10px; }
.system-view { padding: 13px; }.system-grid { display: block; }.system-card { border-right: 0; }.components, .responsive { min-height: auto; }.component-columns, .mini-components { grid-template-columns: 1fr; }.multi-summary { grid-template-columns: 31px 1fr 1fr 1fr; }.multi-summary .button { grid-column: span 2; }.responsive-samples { overflow: auto; grid-template-columns: repeat(4,92px); }.accessibility { grid-template-columns: 1fr 1fr; }
}
+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,
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

@@ -0,0 +1,4 @@
# Asset notice
Assets in this directory are project-supplied. Replacements require reviewed
ownership, provenance, and licensing.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

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']));
});
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more