mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge upstream/main into pr/mapillary-layer
Twenty layers now (weather, wind, cyclones and recent imagery landed upstream); Street Level sits after Recent Imagery in the right rail and the Cameras group, and the rail CSS names it beside the other panels. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
commit
817716f0c1
234 files changed
+45441
-1430
No files matched your search
+194
-140
@@ -1,7 +1,10 @@
|
||||
import { syncChipGroup } from './chipGroup.js';
|
||||
import { syncRowList } from './rowList.js';
|
||||
import { layerFeedState } from '../data/feedState.js';
|
||||
export { layerFeedState } from '../data/feedState.js';
|
||||
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
import { createWeatherPanel } from './weatherPanel.js';
|
||||
const FEED_STATE_LABELS = Object.freeze({
|
||||
nominal: 'ON',
|
||||
loading: 'LOADING',
|
||||
@@ -28,11 +31,12 @@ const PANEL_GROUPS = [
|
||||
},
|
||||
{
|
||||
label: 'Cameras',
|
||||
ids: ['cctv', 'mapillary', 'alpr-cameras'],
|
||||
ids: ['cctv', 'recent-imagery', 'mapillary'],
|
||||
},
|
||||
{
|
||||
label: 'Infrastructure',
|
||||
ids: [
|
||||
'alpr-cameras',
|
||||
'military-installations',
|
||||
'local-datacenters',
|
||||
'telegeography-submarine-cables',
|
||||
@@ -43,6 +47,16 @@ const PANEL_GROUPS = [
|
||||
label: 'Events',
|
||||
ids: ['rocket-launches', 'earthquakes', 'local-firms'],
|
||||
},
|
||||
{
|
||||
label: 'Weather',
|
||||
ids: [
|
||||
'wind',
|
||||
'weather-radar',
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
'weather-cyclones',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Utilities',
|
||||
ids: ['directions', 'radio'],
|
||||
@@ -99,7 +113,9 @@ export class LayerPanel {
|
||||
hasRowControls,
|
||||
subscribeRowControls,
|
||||
onHiddenRefresh = () => {},
|
||||
weatherClock,
|
||||
}) {
|
||||
this.weatherClock = weatherClock;
|
||||
this.getAll = getLayers;
|
||||
this.isEnabled = isEnabled;
|
||||
this.setEnabled = setEnabled;
|
||||
@@ -111,25 +127,63 @@ export class LayerPanel {
|
||||
this._generation = 0;
|
||||
this._removers = [];
|
||||
this._destroyed = false;
|
||||
this._cancelRowControlsRefresh = null;
|
||||
this._recentImageryFactory = null;
|
||||
this._recentImageryPanel = null;
|
||||
}
|
||||
mount(container) {
|
||||
if (this._destroyed) return;
|
||||
this._releaseBindings();
|
||||
this._toggleContainer = container;
|
||||
this._weatherPanel?.destroy();
|
||||
this._weatherPanel = createWeatherPanel({
|
||||
clock: this.weatherClock,
|
||||
container:
|
||||
container?.ownerDocument?.getElementById?.('weather-panel-body'),
|
||||
setLayerParams: this.setLayerParams,
|
||||
});
|
||||
this._mountRecentImagery();
|
||||
this._renderToggles();
|
||||
}
|
||||
/**
|
||||
* Host the Recent Imagery readout in its rail body, like the weather
|
||||
* readout. The application supplies the factory once the layer, viewer and
|
||||
* box tool exist; a remount rebuilds the readout in place.
|
||||
* @param {((container: HTMLElement) => { destroy: () => void } | null) | null} factory
|
||||
*/
|
||||
attachRecentImagery(factory) {
|
||||
if (this._destroyed) return;
|
||||
this._recentImageryFactory = typeof factory === 'function' ? factory : null;
|
||||
this._mountRecentImagery();
|
||||
}
|
||||
_mountRecentImagery() {
|
||||
this._recentImageryPanel?.destroy();
|
||||
this._recentImageryPanel = null;
|
||||
const container = this._toggleContainer?.ownerDocument?.getElementById?.(
|
||||
'recent-imagery-panel-body',
|
||||
);
|
||||
if (container && this._recentImageryFactory)
|
||||
this._recentImageryPanel = this._recentImageryFactory(container) || null;
|
||||
}
|
||||
_bind(element, type, listener) {
|
||||
element.addEventListener(type, listener);
|
||||
this._removers.push(() => element.removeEventListener(type, listener));
|
||||
}
|
||||
_releaseBindings() {
|
||||
this._generation++;
|
||||
this._cancelRowControlsRefresh?.();
|
||||
this._cancelRowControlsRefresh = null;
|
||||
for (const remove of this._removers.splice(0)) remove();
|
||||
}
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
this._destroyed = true;
|
||||
this._releaseBindings();
|
||||
this._weatherPanel?.destroy();
|
||||
this._weatherPanel = null;
|
||||
this._recentImageryPanel?.destroy();
|
||||
this._recentImageryPanel = null;
|
||||
this._recentImageryFactory = null;
|
||||
this._toggleContainer = null;
|
||||
}
|
||||
_renderToggles() {
|
||||
@@ -211,7 +265,8 @@ export class LayerPanel {
|
||||
}
|
||||
});
|
||||
|
||||
right.appendChild(count);
|
||||
const readout = Boolean(this._rowControlsFor(layer.id)?.readout);
|
||||
if (!readout) right.appendChild(count);
|
||||
right.appendChild(toggle);
|
||||
topRow.appendChild(left);
|
||||
topRow.appendChild(right);
|
||||
@@ -231,46 +286,94 @@ export class LayerPanel {
|
||||
// that can also fail) pushes a re-render through this; nothing else
|
||||
// would repaint the row before its next scheduled refresh.
|
||||
const unsubscribe = this.subscribeRowControls(layer.id, () =>
|
||||
this._refreshTogglePanel(),
|
||||
this._scheduleRowControlsRefresh(),
|
||||
);
|
||||
if (unsubscribe) this._removers.push(unsubscribe);
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'data-toggle-controls';
|
||||
this._bind(controls, 'click', (event) => {
|
||||
const button = event.target?.closest?.('.data-toggle-chip');
|
||||
if (!button || button.disabled) return;
|
||||
// Re-read the live descriptor rather than trusting the rendered
|
||||
// chip, so a stale row can never apply an inverted toggle.
|
||||
const chip = this._rowControlsFor(layer.id)?.chips?.find(
|
||||
(entry) => entry.id === button.dataset.chipId,
|
||||
);
|
||||
if (!chip || chip.disabled || !this.isEnabled(layer.id)) return;
|
||||
if (typeof chip.onClick === 'function') chip.onClick();
|
||||
else if (chip.params)
|
||||
this.setLayerParams(layer.id, chip.params, { origin: 'user' });
|
||||
});
|
||||
row.appendChild(controls);
|
||||
// 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);
|
||||
if (!readout) {
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'data-toggle-controls';
|
||||
this._bind(controls, 'click', (event) => {
|
||||
const button = event.target?.closest?.('.data-toggle-chip');
|
||||
if (!button || button.disabled) return;
|
||||
// Re-read the live descriptor rather than trusting the rendered
|
||||
// chip, so a stale row can never apply an inverted toggle.
|
||||
const chip = this._rowControlsFor(layer.id)?.chips?.find(
|
||||
(entry) => entry.id === button.dataset.chipId,
|
||||
);
|
||||
if (!chip || chip.disabled || !this.isEnabled(layer.id)) return;
|
||||
if (typeof chip.onClick === 'function') chip.onClick();
|
||||
else if (chip.params)
|
||||
this.setLayerParams(layer.id, chip.params, { origin: 'user' });
|
||||
});
|
||||
row.appendChild(controls);
|
||||
// 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);
|
||||
}
|
||||
this._refreshWeatherPanel();
|
||||
}
|
||||
|
||||
_scheduleRowControlsRefresh() {
|
||||
if (this._destroyed || this._cancelRowControlsRefresh) return;
|
||||
const refresh = () => {
|
||||
this._cancelRowControlsRefresh = null;
|
||||
if (!this._destroyed) this._refreshTogglePanel();
|
||||
};
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
const frame = requestAnimationFrame(refresh);
|
||||
this._cancelRowControlsRefresh = () => cancelAnimationFrame(frame);
|
||||
} else {
|
||||
const timer = setTimeout(refresh, 0);
|
||||
this._cancelRowControlsRefresh = () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronously flush a pending row-controls refresh, including in tests. */
|
||||
_flushRowControlsRefresh() {
|
||||
if (this._destroyed || !this._cancelRowControlsRefresh) return;
|
||||
this._cancelRowControlsRefresh();
|
||||
this._cancelRowControlsRefresh = null;
|
||||
this._refreshTogglePanel();
|
||||
}
|
||||
|
||||
_refreshWeatherPanel() {
|
||||
this._weatherPanel?.update(
|
||||
this.getAll()
|
||||
.filter(
|
||||
(layer) =>
|
||||
layer.enabled &&
|
||||
[
|
||||
'wind',
|
||||
'weather-radar',
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
'weather-cyclones',
|
||||
].includes(layer.id),
|
||||
)
|
||||
.map((layer) => ({
|
||||
id: layer.id,
|
||||
icon: layer.icon,
|
||||
...this._rowControlsFor(layer.id),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Qualify a loaded count when it does not mean items currently on screen. */
|
||||
@@ -288,8 +391,8 @@ export class LayerPanel {
|
||||
* Chip BUTTONS are reconciled in place, keyed by chip id, rather than
|
||||
* rebuilt: this runs on every panel refresh — including the one the chip's
|
||||
* own click triggers — and replacing the node would drop keyboard focus
|
||||
* mid-interaction. Legend entries hold no focus and no listeners, so they
|
||||
* are replaced freely.
|
||||
* mid-interaction. Legend entries are rebuilt only when their content changes;
|
||||
* the info node is retained across refreshes.
|
||||
* @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.
|
||||
@@ -297,53 +400,65 @@ export class LayerPanel {
|
||||
_syncRowControls(container, layer, listContainer = null) {
|
||||
if (!container) return;
|
||||
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
|
||||
if (controls?.readout) {
|
||||
container.remove();
|
||||
listContainer?.remove();
|
||||
return;
|
||||
}
|
||||
const chips = controls?.chips || [];
|
||||
const legend = controls?.legend || [];
|
||||
const infoText = controls?.info;
|
||||
this._syncRowList(listContainer, controls?.list || null);
|
||||
container.hidden = chips.length === 0 && legend.length === 0;
|
||||
container.hidden = chips.length === 0 && legend.length === 0 && !infoText;
|
||||
|
||||
for (const node of [...container.children]) {
|
||||
if (
|
||||
String(node.className).split(/\s+/).includes('data-toggle-legend-item')
|
||||
)
|
||||
node.remove();
|
||||
}
|
||||
const info = container._rowControlsInfo || null;
|
||||
const firstLegend = container.querySelector('.data-toggle-legend-item');
|
||||
|
||||
const stale = new Map();
|
||||
for (const node of [...container.children]) {
|
||||
if (node.dataset?.chipId) stale.set(node.dataset.chipId, node);
|
||||
}
|
||||
syncChipGroup(container, chips, { before: firstLegend || info });
|
||||
|
||||
for (const chip of chips) {
|
||||
let button = stale.get(chip.id);
|
||||
stale.delete(chip.id);
|
||||
if (!button) {
|
||||
button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.dataset.chipId = chip.id;
|
||||
container.appendChild(button);
|
||||
const legendSignature = JSON.stringify(
|
||||
legend.map(({ label, color, count, blurb }) => [
|
||||
label,
|
||||
color,
|
||||
count,
|
||||
blurb,
|
||||
]),
|
||||
);
|
||||
if (container._legendSignature !== legendSignature) {
|
||||
for (const node of [...container.children]) {
|
||||
if (node.className === 'data-toggle-legend-item') node.remove();
|
||||
}
|
||||
const state = chip.state || (chip.active ? 'active' : 'idle');
|
||||
button.className = `data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`;
|
||||
if (button.textContent !== chip.label) button.textContent = chip.label;
|
||||
button.title = chip.title || '';
|
||||
button.disabled = Boolean(chip.disabled);
|
||||
button.setAttribute('aria-pressed', chip.active ? 'true' : 'false');
|
||||
button.setAttribute('aria-busy', chip.busy ? 'true' : 'false');
|
||||
for (const item of legend) {
|
||||
const entry = document.createElement('span');
|
||||
entry.className = 'data-toggle-legend-item';
|
||||
if (item.blurb) entry.title = item.blurb;
|
||||
const swatch = document.createElement('span');
|
||||
swatch.className = 'data-toggle-legend-swatch';
|
||||
swatch.style.background = item.color;
|
||||
const text = document.createElement('span');
|
||||
text.textContent =
|
||||
item.count == null
|
||||
? String(item.label)
|
||||
: `${item.label} ${this._formatCount(item.count)}`;
|
||||
entry.append(swatch, text);
|
||||
container.insertBefore(entry, info);
|
||||
}
|
||||
container._legendSignature = legendSignature;
|
||||
}
|
||||
for (const node of stale.values()) node.remove();
|
||||
|
||||
for (const item of legend) {
|
||||
const entry = document.createElement('span');
|
||||
entry.className = 'data-toggle-legend-item';
|
||||
if (item.blurb) entry.title = item.blurb;
|
||||
const swatch = document.createElement('span');
|
||||
swatch.className = 'data-toggle-legend-swatch';
|
||||
swatch.style.background = item.color;
|
||||
const text = document.createElement('span');
|
||||
text.textContent = `${item.label} ${this._formatCount(item.count)}`;
|
||||
entry.append(swatch, text);
|
||||
container.appendChild(entry);
|
||||
if (infoText || info) {
|
||||
const node = info || document.createElement('div');
|
||||
if (!info) {
|
||||
container.appendChild(node);
|
||||
container._rowControlsInfo = node;
|
||||
}
|
||||
const className = 'data-toggle-controls-info';
|
||||
if (node.className !== className) node.className = className;
|
||||
const text = infoText ? String(infoText) : '';
|
||||
const title = controls?.infoTitle || '';
|
||||
if (node.textContent !== text) node.textContent = text;
|
||||
if (node.title !== title) node.title = title;
|
||||
const hidden = !text;
|
||||
if (node.hidden !== hidden) node.hidden = hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,69 +475,7 @@ export class LayerPanel {
|
||||
* @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;
|
||||
}
|
||||
syncRowList(container, list);
|
||||
}
|
||||
|
||||
_refreshTogglePanel() {
|
||||
@@ -460,6 +513,7 @@ export class LayerPanel {
|
||||
row.querySelector('.data-row-list'),
|
||||
);
|
||||
}
|
||||
this._refreshWeatherPanel();
|
||||
}
|
||||
|
||||
_buildMetaText(layer) {
|
||||
|
||||
Reference in new issue
Block a user