mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
refactor: separate Cockpit controller and presentation components
This commit is contained in:
1 parent
43db5c19bb
commit
8075c70e6e
24 files changed
+2258
-1928
No files matched your search
@@ -0,0 +1,174 @@
|
||||
/** Reconcile actionable signal rows without disturbing native keyboard focus. */
|
||||
|
||||
|
||||
export function renderCockpitSignals() {
|
||||
if (!this.signalList) return;
|
||||
const existing = [...this.signalList.children];
|
||||
const focusedEntry = existing.find((entry) => entry.contains(document.activeElement));
|
||||
const entriesByKey = new Map();
|
||||
for (const entry of existing) {
|
||||
const key = entry.dataset.signalKey;
|
||||
if (!entriesByKey.has(key)) entriesByKey.set(key, []);
|
||||
entriesByKey.get(key).push(entry);
|
||||
}
|
||||
const setText = (element, value) => {
|
||||
if (element.textContent !== value) element.textContent = value;
|
||||
};
|
||||
const entries = this.signalItems.map((item) => {
|
||||
// Identity includes the action target: a reused status key must never
|
||||
// silently turn a focused flight button into a different selection.
|
||||
const key = JSON.stringify([
|
||||
item.key, Boolean(item.target), item.target?.layerId || '', String(item.target?.id ?? ''),
|
||||
]);
|
||||
let entry = entriesByKey.get(key)?.shift();
|
||||
if (!entry) {
|
||||
entry = document.createElement('li');
|
||||
entry.dataset.signalKey = key;
|
||||
const time = document.createElement('time');
|
||||
const body = document.createElement('div');
|
||||
const heading = document.createElement(item.target ? 'button' : 'strong');
|
||||
if (item.target) {
|
||||
heading.type = 'button';
|
||||
heading.className = 'cockpit-signal-target';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'cockpit-signal-target-label';
|
||||
const rule = document.createElement('span');
|
||||
rule.className = 'cockpit-signal-target-rule';
|
||||
rule.setAttribute('aria-hidden', 'true');
|
||||
const chevron = document.createElement('span');
|
||||
chevron.className = 'material-symbols-outlined cockpit-signal-target-chevron';
|
||||
chevron.setAttribute('aria-hidden', 'true');
|
||||
chevron.textContent = 'chevron_right';
|
||||
heading.append(label, rule, chevron);
|
||||
}
|
||||
body.append(heading, document.createElement('span'));
|
||||
entry.append(time, body);
|
||||
}
|
||||
const [time, body] = entry.children;
|
||||
const [heading, copy] = body.children;
|
||||
const className = item.target ? `${item.tone} actionable` : item.tone;
|
||||
if (entry.className !== className) entry.className = className;
|
||||
setText(time, new Date(item.timestamp).toISOString().slice(11, 19) + 'Z');
|
||||
if (item.target) {
|
||||
heading.dataset.signalLayer = item.target.layerId;
|
||||
heading.dataset.signalId = item.target.id;
|
||||
const label = `Select flight ${item.title}`;
|
||||
if (heading.getAttribute('aria-label') !== label) heading.setAttribute('aria-label', label);
|
||||
setText(heading.children[0], item.title);
|
||||
} else {
|
||||
setText(heading, item.title);
|
||||
}
|
||||
setText(copy, item.detail);
|
||||
return entry;
|
||||
});
|
||||
const retainedFocus = entries.includes(focusedEntry) ? focusedEntry : null;
|
||||
if (focusedEntry && !retainedFocus) {
|
||||
// A departed contact cannot remain selectable. Continue from the stable
|
||||
// briefing footer instead of dropping keyboard traversal to the page top.
|
||||
(this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({ preventScroll: true });
|
||||
}
|
||||
for (const entry of existing) if (!entries.includes(entry)) entry.remove();
|
||||
|
||||
// Ordinary insertBefore moves disconnect an element and lose its focus.
|
||||
// Reorder the other rows around the focused row, which stays connected.
|
||||
const focusIndex = retainedFocus ? entries.indexOf(retainedFocus) : -1;
|
||||
if (retainedFocus) {
|
||||
let anchor = retainedFocus;
|
||||
for (let index = focusIndex - 1; index >= 0; index -= 1) {
|
||||
const entry = entries[index];
|
||||
if (entry.nextElementSibling !== anchor) this.signalList.insertBefore(entry, anchor);
|
||||
anchor = entry;
|
||||
}
|
||||
}
|
||||
let anchor = retainedFocus ? retainedFocus.nextElementSibling : this.signalList.firstElementChild;
|
||||
for (let index = focusIndex + 1; index < entries.length; index += 1) {
|
||||
const entry = entries[index];
|
||||
if (entry === anchor) anchor = anchor.nextElementSibling;
|
||||
else this.signalList.insertBefore(entry, anchor);
|
||||
}
|
||||
this.scheduleContextLayout();
|
||||
}
|
||||
|
||||
export function pushCockpitSignal(key, tone, title, detail, target = null) {
|
||||
if (!this.signalList || !title || !detail) return;
|
||||
const signature = `${title}|${detail}|${target?.layerId || ''}|${target?.id || ''}`;
|
||||
if (this.signalSignatures.get(key) === signature) return;
|
||||
this.signalSignatures.set(key, signature);
|
||||
this.signalItems.unshift({ key, tone, title, detail, target, timestamp: Date.now() });
|
||||
this.signalItems = this.signalItems.slice(0, 5);
|
||||
this.renderCockpitSignals();
|
||||
}
|
||||
|
||||
export function updateCockpitSignals(snapshot, unknownCount) {
|
||||
const previous = new Map(this.signalItems.map((item) => [item.key, item]));
|
||||
const contacts = [];
|
||||
const subject = snapshot.subject;
|
||||
if (['flights', 'military'].includes(subject?.layerId) && subject?.id) {
|
||||
contacts.push({
|
||||
key: `flight:${subject.layerId}:${subject.id}`,
|
||||
tone: 'track',
|
||||
title: subject.label || subject.id,
|
||||
detail: `${subject.layerId === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · CURRENT`,
|
||||
target: { layerId: subject.layerId, id: String(subject.id) },
|
||||
distanceM: -1,
|
||||
});
|
||||
}
|
||||
for (const cohort of snapshot.cohorts) {
|
||||
if (!['flights', 'military'].includes(cohort.id)) continue;
|
||||
for (const item of cohort.nearest) {
|
||||
const id = item.icao24 || item.id;
|
||||
if (!id) continue;
|
||||
contacts.push({
|
||||
key: `flight:${cohort.id}:${id}`,
|
||||
tone: cohort.id === 'military' ? 'nearby military' : 'nearby',
|
||||
// `id` above is IDENTITY (the ICAO hex). Display must not reuse it:
|
||||
// the row's own `id` already carries the layer's label convention
|
||||
// (callsign → registration → hex), so a callsign-less enriched
|
||||
// contact reads as its registration here too. Same helper the
|
||||
// Context panel's nearest list uses.
|
||||
title: this.services.formatAwarenessLabel(item),
|
||||
detail: `${cohort.id === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · ${
|
||||
Number.isFinite(item.distanceM)
|
||||
? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM`
|
||||
: 'DISTANCE UNKNOWN'
|
||||
}`,
|
||||
target: { layerId: cohort.id, id: String(id) },
|
||||
distanceM: item.distanceM ?? Infinity,
|
||||
});
|
||||
}
|
||||
}
|
||||
contacts.sort((a, b) => a.distanceM - b.distanceM);
|
||||
const nextItems = contacts.slice(0, 5).map((item) => ({
|
||||
...item,
|
||||
timestamp: previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(),
|
||||
}));
|
||||
if (unknownCount) {
|
||||
const sources = snapshot.cohorts
|
||||
.filter((cohort) => cohort.count === null)
|
||||
.map((cohort) => cohort.source)
|
||||
.join(' · ');
|
||||
nextItems.splice(4, Math.max(0, nextItems.length - 4), {
|
||||
key: 'input-status',
|
||||
tone: 'warning',
|
||||
title: `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN`,
|
||||
detail: sources || 'SOURCE STATUS UNAVAILABLE',
|
||||
target: null,
|
||||
timestamp: previous.get('input-status')?.timestamp || snapshot.evaluatedAt || Date.now(),
|
||||
});
|
||||
}
|
||||
this.signalItems = nextItems;
|
||||
this.signalSignatures.clear();
|
||||
this.renderCockpitSignals();
|
||||
}
|
||||
|
||||
export function handleSignalClick(event) {
|
||||
if (this.destroyed) return;
|
||||
const target = event.target.closest('button[data-signal-layer][data-signal-id]');
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
this.services.militaryAwarenessLayer.focusTarget?.(
|
||||
target.dataset.signalLayer,
|
||||
target.dataset.signalId,
|
||||
{ origin: 'user' },
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user