mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(directions): keyless A→B directions with turn-by-turn steps and FLY (#564)
Supersedes #219 by @spcpza, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Co-authored-by: spcpza <[email protected]>
This commit is contained in:
1 parent
9fc8d1966b
commit
d78696dc17
29 files changed
+4658
-65
No files matched your search
@@ -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
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user