Merge branch 'main' into feat/alpr-visual-treatment

This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-14 18:13:10 -07:00
commit 27dec4e949
31 files changed
+4913 -65

No files matched your search

+32
View File
@@ -265,6 +265,7 @@ export class StyleManager {
// Bloom/sharpen state
this._globeResetPromise = null;
this._dataManager = null;
this._directionsShellModule = null;
this._windowResizeHandler = null;
this._cctvRequestFocusHandler = null;
@@ -929,6 +930,34 @@ export class StyleManager {
return this._runExplicitNavigation(noun, navigate, releaseOptions);
}
/**
* Hand the Directions layer the camera seams its FLY chip needs: the same
* immediate-navigation facade voice route flights go through, so there is
* one camera owner rather than a second one inside a data layer, the shared
* ground-floor read/warm the route dolly flies over, and the app's own toast
* so the layer can speak where the rest of the UI speaks.
* @returns {void}
*/
_connectDirectionsCamera() {
if (!this._dataManager) {
// Detaching: the layer outlives this shell, so it must not keep calling
// a facade whose viewer is going away.
this._directionsShellModule?.attachShellServices?.(null);
this._directionsShellModule = null;
return;
}
const directions = this._dataManager.layers?.get('directions')?.module;
if (typeof directions?.attachShellServices !== 'function') return;
this._directionsShellModule = directions;
directions.attachShellServices({
runNavigation: (navigate) =>
this.runImmediateNavigation('route', navigate),
floorFn: (lat, lon) => this.services.cachedGroundFloor(lat, lon),
warmFn: (cells) => this.services.warmGroundFloor(cells),
showToast: (message) => this._showToast(message),
});
}
/** Supersede deferred work when an owner-specific route handles release. */
supersedeDeferredNavigation() {
return this._stampNavigation();
@@ -1654,6 +1683,7 @@ export class StyleManager {
this._syncContextModeButtons();
this._cctvControls.connect();
this._radioControls.connect();
this._connectDirectionsCamera();
if (!this._awarenessSelectedHandler) {
this._awarenessSelectedHandler = (event) =>
this._persistAwarenessSelection(event, false);
@@ -3683,6 +3713,8 @@ export class StyleManager {
this._contextControls.disconnect();
this._dataManagerUnsubscribe?.();
this._dataManagerUnsubscribe = null;
this._directionsShellModule?.attachShellServices?.(null);
this._directionsShellModule = null;
if (this._windowResizeHandler) {
window.removeEventListener('resize', this._windowResizeHandler);
+103 -3
View File
@@ -206,7 +206,23 @@ export class LayerPanel {
this.setLayerParams(layer.id, chip.params, { origin: 'user' });
});
row.appendChild(controls);
this._syncRowControls(controls, layer);
// An ordered list below the chips, for a layer whose row carries a
// sequence (turn-by-turn directions). Its own delegated listener, its
// own container — the chip row stays a chip row.
const list = document.createElement('ol');
list.className = 'data-row-list';
list.hidden = true;
this._bind(list, 'click', (event) => {
const button = event.target?.closest?.('.data-row-list-item');
if (!button || button.disabled) return;
const item = this._rowControlsFor(layer.id)?.list?.items?.find(
(entry) => entry.id === button.dataset.listItemId,
);
if (item?.params)
this.setLayerParams(layer.id, item.params, { origin: 'user' });
});
row.appendChild(list);
this._syncRowControls(controls, layer, list);
}
this._toggleContainer.appendChild(row);
@@ -232,12 +248,14 @@ export class LayerPanel {
* are replaced freely.
* @param {HTMLElement|null} container The row's `.data-toggle-controls` node.
* @param {object} layer Registered layer entry.
* @param {HTMLElement|null} [listContainer] The row's `.data-row-list` node.
*/
_syncRowControls(container, layer) {
_syncRowControls(container, layer, listContainer = null) {
if (!container) return;
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
const chips = controls?.chips || [];
const legend = controls?.legend || [];
this._syncRowList(listContainer, controls?.list || null);
container.hidden = chips.length === 0 && legend.length === 0;
for (const node of [...container.children]) {
@@ -285,6 +303,84 @@ export class LayerPanel {
}
}
/**
* Render a row's ordered list (turn-by-turn directions).
*
* Each entry is a real `<button>` inside a real `<li>`, so Tab reaches it and
* Enter activates it with no key handling of our own, and the `<ol>` carries
* the ordering a screen reader announces. Items are reconciled in place,
* keyed by id, for the same reason chips are: this runs on every refresh —
* including the one a click on the list triggers — and replacing the node
* would drop keyboard focus mid-interaction.
* @param {HTMLElement|null} container The row's `.data-row-list` node.
* @param {{ariaLabel?: string, items?: Array<object>}|null} list Descriptor.
*/
_syncRowList(container, list) {
if (!container) return;
const items = list?.items || [];
container.hidden = items.length === 0;
if (list?.ariaLabel) container.setAttribute('aria-label', list.ariaLabel);
const stale = new Map();
for (const node of [...container.children]) {
if (node.dataset?.listItemId) stale.set(node.dataset.listItemId, node);
}
let previous = null;
let activeButton = null;
for (const item of items) {
let entry = stale.get(item.id);
stale.delete(item.id);
let button;
if (!entry) {
entry = document.createElement('li');
entry.dataset.listItemId = item.id;
button = document.createElement('button');
button.type = 'button';
button.className = 'data-row-list-item';
button.dataset.listItemId = item.id;
const lead = document.createElement('span');
lead.className = 'data-row-list-lead';
const text = document.createElement('span');
text.className = 'data-row-list-text';
button.append(lead, text);
entry.appendChild(button);
} else {
button = entry.querySelector('.data-row-list-item');
}
// Keep DOM order in step with descriptor order without rebuilding.
const anchor = previous ? previous.nextSibling : container.firstChild;
if (entry !== anchor) container.insertBefore(entry, anchor);
previous = entry;
if (!button) continue;
const lead = button.querySelector('.data-row-list-lead');
const text = button.querySelector('.data-row-list-text');
const leadText = String(item.lead ?? '');
const bodyText = String(item.text ?? '');
if (lead && lead.textContent !== leadText) lead.textContent = leadText;
if (text && text.textContent !== bodyText) text.textContent = bodyText;
button.disabled = Boolean(item.disabled);
button.classList.toggle('note', Boolean(item.disabled));
button.classList.toggle('active', Boolean(item.active));
button.classList.toggle('current', Boolean(item.current));
button.setAttribute('aria-current', item.current ? 'step' : 'false');
button.setAttribute('aria-pressed', item.active ? 'true' : 'false');
button.title = bodyText;
if (item.current) activeButton = button;
}
for (const node of stale.values()) node.remove();
// Follow the flight, but never steal a scroll the reader is making
// themselves: only when the step actually changed.
if (
activeButton &&
container.dataset.currentId !== activeButton.dataset.listItemId
) {
container.dataset.currentId = activeButton.dataset.listItemId;
activeButton.scrollIntoView?.({ block: 'nearest' });
} else if (!activeButton) {
delete container.dataset.currentId;
}
}
_refreshTogglePanel() {
if (this._destroyed || !this._toggleContainer) return;
// Skip DOM churn while hidden; visibilitychange (main.js) triggers one
@@ -314,7 +410,11 @@ export class LayerPanel {
meta.textContent = this._buildMetaText(layer);
}
this._syncRowControls(row.querySelector('.data-toggle-controls'), layer);
this._syncRowControls(
row.querySelector('.data-toggle-controls'),
layer,
row.querySelector('.data-row-list'),
);
}
}
+88
View File
@@ -800,3 +800,91 @@
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
flex: none;
}
/* Optional per-layer ordered list: the turn-by-turn directions under the
Directions row. Kept short and scrollable so a 40-step drive cannot push the
rest of the rail off screen. */
.data-row-list {
list-style: none;
margin: 4px 0 0;
padding: 0 0 0 26px;
max-height: 150px;
overflow-y: auto;
counter-reset: gev-step;
}
.data-row-list[hidden] {
display: none;
}
.data-row-list-item {
counter-increment: gev-step;
display: flex;
align-items: baseline;
gap: 6px;
width: 100%;
padding: 2px 4px;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.4px;
text-align: left;
color: var(--text-dim);
background: none;
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
outline: none;
}
.data-row-list-item::before {
content: counter(gev-step);
min-width: 13px;
text-align: right;
color: var(--text-dim);
opacity: 0.65;
flex: none;
}
.data-row-list-item:hover,
.data-row-list-item:focus-visible {
border-color: var(--glass-border-hover);
background: rgba(255, 255, 255, 0.05);
color: var(--text-secondary);
}
.data-row-list-item.active {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-dim);
}
/* The step the camera is on during FLY: the same accent, plus a rail on the
leading edge so it reads as "here", not "chosen". */
.data-row-list-item.current {
border-left-color: var(--accent);
border-left-width: 2px;
}
.data-row-list-lead {
min-width: 34px;
flex: none;
opacity: 0.85;
}
.data-row-list-text {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A list line that states a limit rather than offering an action. */
.data-row-list-item.note {
cursor: default;
opacity: 0.75;
font-style: italic;
}