Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e15063502 |
No files matched your search
@@ -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.
|
||||
@@ -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"
|
||||
'';
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 193 KiB |
@@ -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 Alex selected Alpha</span><span><i class="presence-dot amber"></i>14:28:15Z David drafted Task-144</span><span><i class="presence-dot green"></i>14:26:32Z Sara updated Bravo search area</span><span><i class="presence-dot violet"></i>14:24:10Z 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 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">⠿ 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>⚑ 14:53 (local)</p><h2>Linked task</h2><p>⛓ 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 <b>Alex</b> reordered Return after Search Area A</span><span><i class="presence-dot blue"></i>14:33 <b>System</b> 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">▣ 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();
|
||||
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"]`.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
flutter/ephemeral
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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_
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
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.
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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"
|
||||
@@ -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
|
||||
@@ -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() }; }
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>; }
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 {(28.98 + cursorPoint.x * .0019).toFixed(5)}°E</span>}
|
||||
</section>;
|
||||
}
|
||||
@@ -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']));
|
||||
});
|
||||