Merge pull request #398 from bilawalsidhu/refactor/radio-controls

refactor: isolate Radio controls and tuner presentation
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-13 00:28:52 -07:00
commit bd14d6c301
22 files changed
+2207 -1107

No files matched your search

+4
View File
@@ -13,6 +13,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
- Restore UI observer, resize-listener and CCTV subscription cleanup after Location extraction.
- Extract Radio controls and tuner presentation with explicit actions and complete listener/subscription cleanup.
- Extract Location controls and cancellable search presentation; preserve navigation handoff and prevent delayed POI expansion after closing the row.
- Separate Layers panel presentation and clear-control bindings from layer lifecycle operations; revoke listeners and subscriptions on replacement or teardown.
+8
View File
@@ -287,3 +287,11 @@ the existing location-status formatter. Callers supply city data, search and
navigation operations. The component owns DOM listeners and pending expansion;
it imports no geocoder, camera engine, layer or application bootstrap. Existing
camera authority and search providers remain supplied by the application.
### Radio controls
`ui/radio` owns Radio input, disclosures, tuner state and presentation. It
receives the existing Radio port and explicit layer/layout actions, without
importing the renderer or station providers. Pure tuner calculations retain
compatibility exports from the data layer. Disposal revokes DOM listeners and
subscriptions before ending the active tuning interaction.
+16
View File
@@ -1,5 +1,21 @@
# God's Eye View Current State
## UI disposal
UI disposal releases the CCTV subscription, command-dock observer, legacy drag
resize observer and window resize listener. These remain independent of the
Location component and are included in whole-UI browser teardown acceptance.
## Radio controls
Radio panel, compact controls and tuner presentation have a dedicated owner.
It receives playback, layer actions and layout callbacks; station ingestion,
marker placement, audio playback and camera policy stay with their existing
owners. Tuner calculations have a pure entry, with existing layer exports
preserved. Destruction removes listeners and state subscriptions before ending
tuning; a delayed Enable result cannot reveal or refocus removed controls.
## Location control ownership
City/POI rows, search/reset bindings, location readouts and the orbit indicator
+2 -1
View File
@@ -129,6 +129,7 @@
"./ui/maps": "./src/ui/mapSource.js",
"./ui/layers": "./src/ui/layers.js",
"./ui/layers/feedback": "./src/loadingFeedback.js",
"./ui/location": "./src/ui/location.js"
"./ui/location": "./src/ui/location.js",
"./ui/radio": "./src/ui/radio.js"
}
}
+8 -1
View File
@@ -166,5 +166,12 @@
"src/locationStatus.js",
"src/ui/locationControls.test.mjs",
"src/ui/locationSearch.test.mjs",
"scripts/qa-location-controls.mjs"
"scripts/qa-location-controls.mjs",
"src/ui/radio.js",
"src/ui/radioControls.js",
"src/ui/radioBindings.js",
"src/ui/radioPresentation.js",
"src/ui/radioTunerModel.js",
"src/ui/radioControls.test.mjs",
"scripts/qa-ui-disposal.mjs"
]
+11
View File
@@ -346,5 +346,16 @@
"src/locationStatus.js"
],
"external": []
},
"radio-controls": {
"exports": ["./ui/radio"],
"modules": [
"src/ui/radio.js",
"src/ui/radioControls.js",
"src/ui/radioBindings.js",
"src/ui/radioPresentation.js",
"src/ui/radioTunerModel.js"
],
"external": []
}
}
+141 -70
View File
@@ -25,6 +25,10 @@ const option = (name, fallback) => {
};
const APP_URL = option('--url', process.env.QA_BASE_URL || 'http://localhost:4173');
const APP_ORIGIN = new URL(APP_URL).origin;
// Radio owns this harness's keyboard and pointer actions; the welcome dialog
// has its own acceptance harness and must not intercept those gestures.
const RADIO_URL = new URL(APP_URL);
RADIO_URL.searchParams.set('welcome', '0');
const HEADFUL = args.includes('--headful');
const chromeCandidates = [
@@ -76,6 +80,22 @@ function check(name, ok, detail = '') {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Native pick buffers and clustered overlay entries settle on rendered frames,
// not on wall-clock sleeps, particularly with software rendering.
async function settleRadioFrames(page) {
await page.evaluate(() => new Promise((resolve, reject) => {
const scene = window.__godsEyeView.viewer.scene;
let remaining = 4;
const timeout = setTimeout(() => { remove(); reject(new Error('Radio rendered-frame deadline exceeded')); }, 10_000);
const remove = scene.postRender.addEventListener(() => {
if (--remaining === 0) { remove(); clearTimeout(timeout); resolve(); }
else requestAnimationFrame(() => scene.requestRender());
});
scene.requestRender();
}));
}
async function main() {
const response = await fetch(APP_URL).catch(() => null);
if (!response?.ok) {
@@ -87,7 +107,10 @@ async function main() {
headless: HEADFUL ? false : 'new',
...(chrome ? { executablePath: chrome } : {}),
args: [
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle',
'--no-sandbox', '--disable-setuid-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
'--disable-dev-shm-usage', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--window-size=1440,900',
],
@@ -213,7 +236,7 @@ async function main() {
}
});
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.goto(RADIO_URL.href, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60_000 });
await page.waitForFunction(() => window.__godsEyeView?.styleManager?._dataManager?.layers?.has('radio'), { timeout: 60_000 });
await page.waitForFunction(
@@ -281,7 +304,7 @@ async function main() {
fullPlayEnabled: !document.getElementById('radio-play-btn').disabled,
fullPlayLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'),
tunerVisible: !document.getElementById('radio-tuner').hidden,
tunerCount: window.__godsEyeView.styleManager._radioTunerStations.length,
tunerCount: window.__godsEyeView.styleManager._radioControls._radioTunerStations.length,
tunerLabel: document.getElementById('radio-tuner-band-label').textContent,
};
});
@@ -332,19 +355,19 @@ async function main() {
const staticResult = module.setTuningStatic(true);
const filterBefore = module.getUIState().filter;
const filterControl = document.getElementById('radio-filter');
const originalPinned = style._radioTunerBandPinnedForNavigation;
const originalPool = style._radioTunerPool;
style._radioTunerBandPinnedForNavigation = true;
const originalPinned = style._radioControls._radioTunerBandPinnedForNavigation;
const originalPool = style._radioControls._radioTunerPool;
style._radioControls._radioTunerBandPinnedForNavigation = true;
filterControl.value = filterBefore === 'news' ? 'all' : 'news';
filterControl.dispatchEvent(new Event('change', { bubbles: true }));
const rejectedUIFilter = {
moduleFilter: module.getUIState().filter,
controlFilter: filterControl.value,
pinned: style._radioTunerBandPinnedForNavigation,
poolIdentityPreserved: style._radioTunerPool === originalPool,
pinned: style._radioControls._radioTunerBandPinnedForNavigation,
poolIdentityPreserved: style._radioControls._radioTunerPool === originalPool,
};
style._radioTunerBandPinnedForNavigation = originalPinned;
style._radioTunerPool = originalPool;
style._radioControls._radioTunerBandPinnedForNavigation = originalPinned;
style._radioControls._radioTunerPool = originalPool;
const filterResult = module.setFilter('news');
const toggleResult = await module.togglePlayback({ origin: 'user' });
const directSelect = module.selectStation(stationId, {
@@ -571,7 +594,14 @@ async function main() {
await page.evaluate(() => window.__qaReleaseRadioEnable?.());
await page.waitForFunction(() => window.__godsEyeView.dataManager.isEnabled('radio'));
await page.evaluate(() => window.__qaRestoreRadioEnable?.());
await sleep(650);
await page.waitForFunction(() => {
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
const viewport = scroller.getBoundingClientRect();
const directory = document.querySelector('#radio-panel .radio-directory-row').getBoundingClientRect();
const play = document.getElementById('radio-play-btn').getBoundingClientRect();
return directory.top >= viewport.top && directory.bottom <= viewport.bottom
&& play.top >= viewport.top && play.bottom <= viewport.bottom;
}, { timeout: 10_000 });
const explicitRevealAfter = await page.evaluate(() => {
const gev = window.__godsEyeView;
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
@@ -638,6 +668,7 @@ async function main() {
viewer.scene.requestRender();
}, { lon: spec.lon, height: view.height });
await sleep(700);
await settleRadioFrames(page);
const sample = await page.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
@@ -1320,12 +1351,9 @@ async function main() {
&& clusterBadge?.rect?.h > 0,
JSON.stringify(clusterBadge),
);
const highGlobalClusterLabels = await page.evaluate(async () => {
await page.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
const ellipsoid = viewer.scene.globe.ellipsoid;
const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: ellipsoid.cartographicToCartesian({
@@ -1337,6 +1365,14 @@ async function main() {
});
viewer.scene.requestRender();
await new Promise((resolve) => setTimeout(resolve, 500));
});
await settleRadioFrames(page);
const highGlobalClusterLabels = await page.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
const ellipsoid = viewer.scene.globe.ellipsoid;
const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
const source = radio.getOverlayDiagnostics();
const cameraPosition = viewer.camera.positionWC;
const cameraRadius = Math.hypot(cameraPosition.x, cameraPosition.y, cameraPosition.z);
@@ -1347,11 +1383,23 @@ async function main() {
const points = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
.find((item) => item.name === 'Radio stations')?.clustering?._clusterPointCollection;
let clusterPoint = null;
// Pool order is not visibility order after a camera change. Choose an
// on-screen point independently of the native pick result being tested.
const visiblePoints = [];
for (let index = 0; index < (points?.length || 0); index += 1) {
const point = points.get(index);
if (!point?.show || !Array.isArray(point.id) || !point.id.length) continue;
const anchor = viewer.scene.cartesianToCanvasCoordinates(point.position);
if (!anchor) continue;
if (anchor.x < 0 || anchor.y < 0 || anchor.x > viewer.canvas.clientWidth || anchor.y > viewer.canvas.clientHeight) continue;
visiblePoints.push({ point, anchor });
}
visiblePoints.sort((a, b) => (
Math.hypot(a.anchor.x - viewer.canvas.clientWidth / 2, a.anchor.y - viewer.canvas.clientHeight / 2)
- Math.hypot(b.anchor.x - viewer.canvas.clientWidth / 2, b.anchor.y - viewer.canvas.clientHeight / 2)
));
if (visiblePoints.length) {
const { point, anchor } = visiblePoints[0];
const exactPick = (viewer.scene.drillPick(anchor, 16) || []).find((picked) => (
picked?.primitive === point && picked?.id === point.id
));
@@ -1359,7 +1407,6 @@ async function main() {
maxDistance: point.distanceDisplayCondition?.far ?? null,
pickable: Boolean(exactPick),
};
break;
}
return {
source,
@@ -1875,7 +1922,10 @@ async function main() {
hudVariant: manager.hud.getVariant(),
focusId: document.activeElement?.id || null,
};
const waitForLayout = () => new Promise((resolve) => setTimeout(resolve, 320));
const waitForLayout = async () => {
await new Promise((resolve) => setTimeout(resolve, 320));
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const cases = [];
manager.setPanelCollapsed('global-context-panel', true);
@@ -1952,7 +2002,10 @@ async function main() {
// owns the real tracked-aircraft camera session. Hold the frame update so
// the intentionally synthetic Cockpit shell is not auto-exited mid-check.
const realCockpitUpdate = manager.cockpitView.update;
const waitForLayout = () => new Promise((resolve) => setTimeout(resolve, 320));
const waitForLayout = async () => {
await new Promise((resolve) => setTimeout(resolve, 320));
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const prior = {
cockpit: document.body.classList.contains('cockpit-mode'),
cockpitActive: manager.cockpitView.active,
@@ -2057,7 +2110,10 @@ async function main() {
};
// The Intel HUD fades over 400ms and keeps its readout rects for the
// whole transition, so both lanes are measured only once it has settled.
const waitForHudSettle = () => new Promise((resolve) => setTimeout(resolve, 560));
const waitForHudSettle = async () => {
await new Promise((resolve) => setTimeout(resolve, 560));
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
for (let index = 0; index < 5; index += 1) {
recordLayoutStep();
if (index < 4) {
@@ -2208,14 +2264,17 @@ async function main() {
const stableTop = utility.getBoundingClientRect().top;
const transitionTops = [];
let sampling = true;
let sampleFrame = null;
const sampleTop = () => {
if (!sampling) return;
transitionTops.push(utility.getBoundingClientRect().top);
if (sampling) requestAnimationFrame(sampleTop);
sampleFrame = requestAnimationFrame(sampleTop);
};
requestAnimationFrame(sampleTop);
sampleFrame = requestAnimationFrame(sampleTop);
await manager._setMapStack('osm', { syncShare: false });
await waitForLayout();
sampling = false;
cancelAnimationFrame(sampleFrame);
mapProviderUtilityStable = transitionTops.length > 1
&& transitionTops.every((top) => Math.abs(top - stableTop) < 1);
}
@@ -2652,9 +2711,15 @@ async function main() {
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
await radio.play();
const beforeSpace = radio.getUIState().audioState;
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, code: 'Space', key: ' ' }));
// Simulate an already-started hold-Space session; click-started open mic
// deliberately ignores Space takeover, and short taps never claim voice.
const priorPushToTalkMode = voice.pushToTalkMode;
voice.pushToTalkMode = true;
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, code: 'Space', key: ' ' }));
await new Promise((resolve) => setTimeout(resolve, 700));
const afterSpace = radio.getUIState().audioState;
document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, code: 'Space', key: ' ' }));
document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, code: 'Space', key: ' ' }));
voice.pushToTalkMode = priorPushToTalkMode;
await radio.play();
voice.setVoiceSpeaker('ai');
return { beforeSpace, afterSpace, afterAi: radio.getUIState().audioState };
@@ -2719,7 +2784,7 @@ async function main() {
const dialRect = document.querySelector('.radio-tuner-dial').getBoundingClientRect();
const sliderRect = slider.getBoundingClientRect();
const panelRect = document.querySelector('.radio-panel-inner').getBoundingClientRect();
const count = style._radioTunerStations.length;
const count = style._radioControls._radioTunerStations.length;
const usable = sliderRect.width - 14;
const xFor = (coordinate) => sliderRect.left + 7 + usable * coordinate / Math.max(1, count - 1);
const pointer = (type, x, pointerId = 71) => slider.dispatchEvent(new PointerEvent(type, {
@@ -2745,7 +2810,7 @@ async function main() {
};
};
const playsBefore = window.__qaRadioPlayCalls.length;
const frozenSignature = style._radioTunerBandSignature;
const frozenSignature = style._radioControls._radioTunerBandSignature;
pointer('pointerdown', xFor(0));
const left = snapshot();
const centerCoordinate = (count - 1) / 2;
@@ -2765,14 +2830,14 @@ async function main() {
visible: !document.getElementById('radio-tuner').hidden,
stationCount: count,
sliderMax: Number(slider.max),
bandFrozen: style._radioTunerBandSignature === frozenSignature,
bandFrozen: style._radioControls._radioTunerBandSignature === frozenSignature,
left,
center,
shifted,
right,
leftExpectedId: style._radioTunerStations[0]?.id || null,
centerExpectedId: style._radioTunerStations[Math.floor(centerCoordinate + 0.5)]?.id || null,
rightExpectedId: style._radioTunerStations[count - 1]?.id || null,
leftExpectedId: style._radioControls._radioTunerStations[0]?.id || null,
centerExpectedId: style._radioControls._radioTunerStations[Math.floor(centerCoordinate + 0.5)]?.id || null,
rightExpectedId: style._radioControls._radioTunerStations[count - 1]?.id || null,
centerExpected: Math.floor(centerCoordinate + 0.5),
tapeDelta,
needleDelta,
@@ -2817,7 +2882,7 @@ async function main() {
const slider = document.getElementById('radio-tuner-slider');
// Capture a fresh gesture's complete presentation anchor before moving
// to a different absolute directory station.
const beforeStations = [...style._radioTunerStations];
const beforeStations = [...style._radioControls._radioTunerStations];
const sliderRect = slider.getBoundingClientRect();
const xFor = (index) => sliderRect.left + 7
+ (sliderRect.width - 14) * index / Math.max(1, beforeStations.length - 1);
@@ -2834,7 +2899,7 @@ async function main() {
const flyToCalls = [];
camera.flyTo = (options) => flyToCalls.push(options);
const before = {
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
ids: beforeStations.map((station) => station.id),
slot: Number(slider.value),
selectedId: radio.getUIState().selected?.id || null,
@@ -2848,10 +2913,10 @@ async function main() {
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
const flyToCallsBeforeCancel = flyToCalls.length;
pointer('pointercancel', targetIndex);
const afterStations = style._radioTunerStations;
const afterStations = style._radioControls._radioTunerStations;
const afterState = radio.getUIState();
const after = {
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
ids: afterStations.map((station) => station.id),
slot: Number(slider.value),
selectedId: afterState.selected?.id || null,
@@ -2972,7 +3037,7 @@ async function main() {
const tunerRefreshTarget = await page.evaluate(() => {
const gev = window.__godsEyeView;
const slider = document.getElementById('radio-tuner-slider');
const stations = gev.styleManager._radioTunerStations;
const stations = gev.styleManager._radioControls._radioTunerStations;
const targetIndex = 1;
const rect = slider.getBoundingClientRect();
const clientX = rect.left + 7 + (rect.width - 14) * targetIndex / Math.max(1, stations.length - 1);
@@ -3055,7 +3120,7 @@ async function main() {
const max = Number(slider.max);
const direction = startSlot < max ? 1 : -1;
const targetIndex = startSlot + direction;
const targetId = gev.styleManager._radioTunerStations[targetIndex]?.id || null;
const targetId = gev.styleManager._radioControls._radioTunerStations[targetIndex]?.id || null;
const rect = slider.getBoundingClientRect();
const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, max);
const pointer = (type, index) => slider.dispatchEvent(new PointerEvent(type, {
@@ -3106,18 +3171,18 @@ async function main() {
const slider = document.getElementById('radio-tuner-slider');
const targetIndex = 11;
slider.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true, pointerId: 1 }));
const station = gev.styleManager._radioTunerStations[targetIndex];
const station = gev.styleManager._radioControls._radioTunerStations[targetIndex];
const rect = slider.getBoundingClientRect();
const currentRatio = Number(slider.value) / Math.max(1, Number(slider.max));
window.__qaRadioDelayNextPlay = true;
return {
id: station.id,
targetIndex,
count: gev.styleManager._radioTunerStations.length,
frozenSignature: gev.styleManager._radioTunerBandSignature,
count: gev.styleManager._radioControls._radioTunerStations.length,
frozenSignature: gev.styleManager._radioControls._radioTunerBandSignature,
startX: rect.left + 7 + (rect.width - 14) * currentRatio,
targetX: rect.left + 7 + (rect.width - 14) * targetIndex
/ Math.max(1, gev.styleManager._radioTunerStations.length - 1),
/ Math.max(1, gev.styleManager._radioControls._radioTunerStations.length - 1),
y: rect.top + rect.height / 2,
};
});
@@ -3135,11 +3200,11 @@ async function main() {
audioState: state.audioState,
tuningStatic: state.tuningStatic,
awaiting: state.tuningAwaitingStationId,
signatureStable: gev.styleManager._radioTunerBandSignature === target.frozenSignature,
selectedIndex: gev.styleManager._radioTunerStations.findIndex((item) => item.id === state.selected?.id),
signatureStable: gev.styleManager._radioControls._radioTunerBandSignature === target.frozenSignature,
selectedIndex: gev.styleManager._radioControls._radioTunerStations.findIndex((item) => item.id === state.selected?.id),
sliderSlot: Number(slider.value),
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
pinned: gev.styleManager._radioTunerBandPinnedForNavigation,
pinned: gev.styleManager._radioControls._radioTunerBandPinnedForNavigation,
};
}, tunerCommitTarget);
check(
@@ -3183,7 +3248,7 @@ async function main() {
y: rect.top + rect.height / 2,
ratio,
usableWidth: rect.width - 14,
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
};
});
const microDragSamples = [];
@@ -3199,7 +3264,7 @@ async function main() {
pixelDelta,
slot: Number(slider.value),
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
};
}, delta));
}
@@ -3238,9 +3303,9 @@ async function main() {
const gev = window.__godsEyeView;
const style = gev.styleManager;
const before = {
signature: style._radioTunerBandSignature,
ids: style._radioTunerStations.map((station) => station.id),
selectedIndex: style._radioTunerStations.findIndex((station) => station.id === selectedId),
signature: style._radioControls._radioTunerBandSignature,
ids: style._radioControls._radioTunerStations.map((station) => station.id),
selectedIndex: style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId),
needle: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
};
const current = gev.viewer.camera.positionCartographic;
@@ -3256,11 +3321,11 @@ async function main() {
});
gev.viewer.camera.changed.raiseEvent();
await new Promise((resolve) => setTimeout(resolve, 400));
const stations = gev.styleManager._radioTunerStations;
const stations = gev.styleManager._radioControls._radioTunerStations;
const selectedIndex = stations.findIndex((station) => station.id === selectedId);
return {
before,
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
ids: stations.map((station) => station.id),
count: stations.length,
selectedIndex,
@@ -3279,14 +3344,14 @@ async function main() {
const nextNeedleBefore = await page.evaluate(() => {
const style = window.__godsEyeView.styleManager;
const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id;
const selectedIndex = style._radioTunerStations.findIndex((station) => station.id === selectedId);
const selectedPoolIndex = style._radioTunerPool.findIndex((station) => station.id === selectedId);
const expectedPoolIndex = (selectedPoolIndex + 1) % style._radioTunerPool.length;
const selectedIndex = style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId);
const selectedPoolIndex = style._radioControls._radioTunerPool.findIndex((station) => station.id === selectedId);
const expectedPoolIndex = (selectedPoolIndex + 1) % style._radioControls._radioTunerPool.length;
return {
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
selectedIndex,
count: style._radioTunerStations.length,
expectedId: style._radioTunerPool[expectedPoolIndex]?.id || null,
count: style._radioControls._radioTunerStations.length,
expectedId: style._radioControls._radioTunerPool[expectedPoolIndex]?.id || null,
};
});
await page.$eval('#radio-next-btn', (button) => button.click());
@@ -3294,15 +3359,15 @@ async function main() {
const nextNeedleAfter = await page.evaluate(() => {
const style = window.__godsEyeView.styleManager;
const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id;
const selectedIndex = style._radioTunerStations.findIndex((station) => station.id === selectedId);
const selectedIndex = style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId);
return {
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
selectedId,
selectedIndex,
count: style._radioTunerStations.length,
count: style._radioControls._radioTunerStations.length,
sliderSlot: Number(document.getElementById('radio-tuner-slider').value),
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
pinned: style._radioTunerBandPinnedForNavigation,
pinned: style._radioControls._radioTunerBandPinnedForNavigation,
};
});
check(
@@ -3322,13 +3387,14 @@ async function main() {
const slider = document.getElementById('radio-tuner-slider');
const key = (type, value) => slider.dispatchEvent(new KeyboardEvent(type, {
bubbles: true,
cancelable: true,
key: value,
}));
const markerId = () => gev.viewer.entities.values
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
const startIndex = Number(slider.value);
const expectedIndex = Math.min(style._radioTunerStations.length - 1, startIndex + 1);
const expectedId = style._radioTunerStations[expectedIndex]?.id || null;
const expectedIndex = Math.min(style._radioControls._radioTunerStations.length - 1, startIndex + 1);
const expectedId = style._radioControls._radioTunerStations[expectedIndex]?.id || null;
const playsBefore = window.__qaRadioPlayCalls.length;
key('keydown', 'ArrowRight');
const preview = {
@@ -3353,7 +3419,7 @@ async function main() {
tuningActive: radio.getUIState().tuningActive,
playDelta: window.__qaRadioPlayCalls.length - committedPlays,
};
const count = style._radioTunerStations.length;
const count = style._radioControls._radioTunerStations.length;
const pageStep = Math.max(1, Math.round((count - 1) / 10));
const previewAndCancel = (value, expectedSlot) => {
const beforePlays = window.__qaRadioPlayCalls.length;
@@ -3376,7 +3442,8 @@ async function main() {
previewAndCancel('PageUp', Math.min(count - 1, base + pageStep)),
previewAndCancel('PageDown', Math.max(0, base - pageStep)),
];
return { startIndex, expectedIndex, expectedId, preview, committed, cancelled, navigation };
return { startIndex, expectedIndex, expectedId, preview, committed, cancelled, navigation,
panelStayedOpen: !document.getElementById('radio-panel').classList.contains('collapsed') };
});
check(
'keyboard preview is silent, key release commits once, and Escape restores the committed station',
@@ -3388,7 +3455,8 @@ async function main() {
&& !tunerKeyboard.committed.tuningActive && tunerKeyboard.committed.playDelta === 1
&& tunerKeyboard.cancelled.selectedId === tunerKeyboard.expectedId
&& tunerKeyboard.cancelled.slot === tunerKeyboard.expectedIndex
&& !tunerKeyboard.cancelled.tuningActive && tunerKeyboard.cancelled.playDelta === 0,
&& !tunerKeyboard.cancelled.tuningActive && tunerKeyboard.cancelled.playDelta === 0
&& tunerKeyboard.panelStayedOpen,
JSON.stringify(tunerKeyboard),
);
check(
@@ -3405,7 +3473,7 @@ async function main() {
const radio = gev.dataManager.layers.get('radio').module;
const slider = document.getElementById('radio-tuner-slider');
const rect = slider.getBoundingClientRect();
const count = style._radioTunerStations.length;
const count = style._radioControls._radioTunerStations.length;
const centerIndex = Math.floor((count - 1) / 2 + 0.5);
const clientX = rect.left + rect.width / 2;
slider.dispatchEvent(new PointerEvent('pointerdown', {
@@ -3425,7 +3493,7 @@ async function main() {
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
markerId: gev.viewer.entities.values
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null,
expectedId: style._radioTunerStations[centerIndex]?.id || null,
expectedId: style._radioControls._radioTunerStations[centerIndex]?.id || null,
};
slider.dispatchEvent(new PointerEvent('pointercancel', {
bubbles: true,
@@ -3450,7 +3518,7 @@ async function main() {
const fullPoolNavigation = await page.evaluate(() => {
const gev = window.__godsEyeView;
const radio = gev.dataManager.layers.get('radio').module;
const pool = gev.styleManager._radioTunerPool;
const pool = gev.styleManager._radioControls._radioTunerPool;
const ids = pool.map((station) => station.id);
const selectWithoutPlayback = (index) => radio.selectStation(ids[index], {
autoplay: false,
@@ -3500,7 +3568,7 @@ async function main() {
const gev = window.__godsEyeView;
const slider = document.getElementById('radio-tuner-slider');
const rect = slider.getBoundingClientRect();
const count = gev.styleManager._radioTunerStations.length;
const count = gev.styleManager._radioControls._radioTunerStations.length;
const current = Number(slider.value);
const target = Math.min(count - 1, current + 4);
const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, count - 1);
@@ -3695,7 +3763,7 @@ async function main() {
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
const pool = gev.styleManager._radioTunerPool;
const pool = gev.styleManager._radioControls._radioTunerPool;
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
window.__qaRadioExpectedFallback = pool[(selectedIndex + 2) % pool.length];
@@ -4390,7 +4458,10 @@ async function main() {
});
await page.click('#context-radio-toggle-btn');
await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed'));
await sleep(450);
await page.waitForFunction((priorScroll) => (
document.activeElement?.getAttribute('data-collapse-target') === 'radio-panel'
&& document.querySelector('#global-context-panel .global-context-panel-inner').scrollTop > priorScroll
), { timeout: 10_000 }, expandedContextRadioBefore.scrollTop);
const expandedContextRadioAfter = await page.evaluate(() => {
const gev = window.__godsEyeView;
gev.styleManager._renderRadioState(gev.dataManager.layers.get('radio').module.getUIState());
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env node
/** Exercise the real UI disposal path, including unrelated resource owners. */
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failures = 0;
function check(name, ok) {
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
if (!ok) failures++;
}
try {
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.styleManager?._dataManager &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
const result = await page.evaluate(async () => {
const ui = window.__godsEyeView.styleManager;
const counts = {};
const watchObserver = (name) => {
const observer = ui[name];
if (!observer) return false;
const disconnect = observer.disconnect.bind(observer);
counts[name] = 0;
observer.disconnect = () => {
counts[name]++;
return disconnect();
};
return true;
};
const observed = [
'_commandDockTrayObserver',
'_draggableResizeObserver',
'_leftStackResizeObserver',
'_rightStackResizeObserver',
'_leftStackMutationObserver',
'_rightStackMutationObserver',
].filter(watchObserver);
const cctvUnsubscribe = ui._cctvUnsubscribe;
counts.cctv = 0;
if (cctvUnsubscribe)
ui._cctvUnsubscribe = () => {
counts.cctv++;
return cctvUnsubscribe();
};
const resizeHandler = ui._windowResizeHandler;
const removeEventListener = window.removeEventListener;
counts.resize = 0;
window.removeEventListener = function (type, callback, options) {
if (type === 'resize' && callback === resizeHandler) counts.resize++;
return removeEventListener.call(this, type, callback, options);
};
try {
await ui.dispose();
const once = JSON.stringify(counts);
await ui.dispose();
window.dispatchEvent(new Event('resize'));
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
return {
connected: Boolean(
cctvUnsubscribe &&
resizeHandler &&
observed.includes('_commandDockTrayObserver'),
),
observersReleased: observed.every(
(name) => counts[name] === 1 && ui[name] === null,
),
subscriptionReleased: counts.cctv === 1 && ui._cctvUnsubscribe === null,
resizeReleased: counts.resize === 1 && ui._windowResizeHandler === null,
controlsReleased:
ui._radioControls.destroyed && ui._locationControls.destroyed,
idempotent: once === JSON.stringify(counts),
};
} finally {
window.removeEventListener = removeEventListener;
}
});
check(
'real UI has the expected live resources before disposal',
result.connected,
);
check(
'UI disposal disconnects each active panel observer once',
result.observersReleased,
);
check(
'UI disposal releases the CCTV state subscription',
result.subscriptionReleased,
);
check(
'UI disposal removes the registered window resize handler',
result.resizeReleased,
);
check(
'Radio and Location controls are disposed with the UI',
result.controlsReleased,
);
check('repeated UI disposal is inert', result.idempotent);
check(
'disposal and subsequent resize produce no uncaught browser errors',
errors.length === 0,
);
} finally {
await browser.close();
}
if (failures) process.exitCode = 1;
+25 -27
View File
@@ -1,3 +1,6 @@
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
@@ -248,8 +251,8 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="display"\]\)[\s\S]*?box-shadow:\s*0 8px 32px rgba\(0, 0, 0, \.42\)/);
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-launcher\s*\{[\s\S]*?border:\s*0;[\s\S]*?background:\s*transparent;[\s\S]*?box-shadow:\s*none;/);
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(90deg, rgb\(0 212 255 \/ 28%\), rgba\(0, 212, 255, 0\.18\) 58%, transparent\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, \.22\);/);
assert.match(ui, /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/);
assert.match(ui, /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/);
assert.match(radioBindings, /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/);
assert.match(radioBindings, /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/);
assert.match(css, /#cockpit-display-panel\s*\{[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*7px;[\s\S]*?padding:\s*0;[\s\S]*?border-top:\s*0;/);
assert.doesNotMatch(ui, /--cockpit-display-tab-width/);
// These two widths size Cockpit Radio and nothing else. They were named for
@@ -307,7 +310,7 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat
assert.match(ui, /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/);
assert.match(ui, /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/);
assert.match(ui, /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/);
assert.match(ui, /!this\._preservePanelStateDuringLayerClear[\s\S]*?this\.setPanelCollapsed\('radio-panel', true\)/);
assert.match(radioPresentation, /!this\.actions\.preservePanelStateDuringClear\(\)[\s\S]*?this\.actions\.setPanelCollapsed\('radio-panel', true\)/);
assert.match(ui, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
});
@@ -320,16 +323,13 @@ test('Cockpit Display portal retains both scroll owners across round trips', ()
});
test('Cockpit side surfaces behave as two single-expanded accordions', () => {
assert.match(
ui,
/if \(displayOpen \|\| radioOpen\) this\.cockpitView\?\.setSignalCollapsed\(true\);/,
assert.match(radioBindings,
/if \(displayOpen \|\| radioOpen\) this\.actions\.setSignalCollapsed\(true\);/,
);
assert.match(
ui,
assert.match(radioBindings,
/'gev:cockpit-signal-expanded'[\s\S]*?setCockpitDisclosure\('display', false\);/,
);
assert.match(
ui,
assert.match(radioBindings,
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);/,
);
assert.match(
@@ -341,13 +341,11 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => {
/nextCollapsed && this\.cockpitView\?\.active && panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
'closing Data Layers must restore Contact only after an automatic collapse',
);
assert.match(
ui,
assert.match(ui,
/const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/,
'Contact expansion must notify only on a collapsed-to-expanded transition',
);
assert.match(
ui,
assert.match(ui,
/const wasCollapsed = this\.signalCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.signalCollapsed[\s\S]*?'gev:cockpit-signal-expanded'/,
'Live Signals expansion must notify only on a collapsed-to-expanded transition',
);
@@ -356,13 +354,13 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => {
/setSignalCollapsed\(collapsed, \{ user = false \} = \{\}\)[\s\S]*?if \(user\) this\.signalUserCollapsed = this\.signalCollapsed/,
);
assert.match(
ui,
/!displayOpen && !radioOpen[\s\S]*?this\.cockpitView\?\.active[\s\S]*?!this\.cockpitView\.signalUserCollapsed[\s\S]*?setSignalCollapsed\(false\)/,
radioBindings,
/!displayOpen\s*&&\s*!radioOpen[\s\S]*?this\.actions\.isCockpitActive\(\)[\s\S]*?!this\.actions\.signalUserCollapsed\(\)[\s\S]*?setSignalCollapsed\(false\)/,
'Live Signals should reopen only after both utility panels close and no manual collapse is retained',
);
assert.match(
ui,
/event\.target\?\.closest\?\.\('#left-panel-stack, #cockpit-context'\)\) return;[\s\S]*?setCockpitDisclosure\('display', false\);/,
radioBindings,
/event\.target\?\.closest\?\.\('#left-panel-stack, #cockpit-context'\)\)\s*return;[\s\S]*?setCockpitDisclosure\('display', false\);/,
'left-side interactions must not collapse the independent Cockpit utilities',
);
});
@@ -505,18 +503,18 @@ test('Location navigation releases immediate routes before flight and deferred r
});
test('Cockpit Radio station changes preserve first-person camera ownership', () => {
const cycleHelper = ui.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/);
const cycleHelper = radioBindings.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/);
assert.ok(cycleHelper, 'shared Radio cycle helper is missing');
assert.match(cycleHelper[1], /cycleStation\(direction, \{[\s\S]*?rotate,/);
assert.match(ui, /_radioPrevBtn\?\.addEventListener\('click', \(\) => cycleRadio\(-1\)\)/);
assert.match(ui, /_contextRadioMiniNextBtn\?\.addEventListener\('click', \(\) => cycleRadio\(1\)\)/);
assert.match(radioBindings, /listen\(this\._radioPrevBtn, 'click', \(\) => cycleRadio\(-1\)\)/);
assert.match(radioBindings, /listen\(this\._contextRadioMiniNextBtn, 'click', \(\) => cycleRadio\(1\)\)/);
assert.match(
ui,
/_cockpitRadioPrevBtn\?\.addEventListener\('click', \(\) => cycleRadio\(-1, \{ rotate: false \}\)\)/,
radioBindings,
/listen\(this\._cockpitRadioPrevBtn, 'click', \(\) =>\s*cycleRadio\(-1, \{ rotate: false \}\),?\s*\)/,
);
assert.match(
ui,
/_cockpitRadioNextBtn\?\.addEventListener\('click', \(\) => cycleRadio\(1, \{ rotate: false \}\)\)/,
radioBindings,
/listen\(this\._cockpitRadioNextBtn, 'click', \(\) =>\s*cycleRadio\(1, \{ rotate: false \}\),?\s*\)/,
);
});
@@ -653,9 +651,9 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
assert.equal((html.match(/id="param-slider-panel"/g) || []).length, 1);
assert.match(html, /data-cockpit-display-slot="detection"[\s\S]*?data-cockpit-display-slot="parameters"[\s\S]*?data-cockpit-display-slot="models3d"/);
assert.match(ui, /_revealStyleParameters\(\) \{[\s\S]*?if \(this\._cockpitDisplayPortalActive\) return;/);
assert.match(ui, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/);
assert.match(radioBindings, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/);
assert.match(ui, /_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/);
assert.match(ui, /if \(displayOpen\) this\._revealCockpitStyleParameters\(\);/);
assert.match(radioBindings, /if \(displayOpen\) this\.actions\.revealStyleParameters\(\);/);
assert.match(ui, /setVisionMode\(modes\[nextIndex\], \{ revealParameters: true \}\);/);
assert.match(css, /\.cockpit-display-slot\s*\{[\s\S]*?display:\s*block;[\s\S]*?min-width:\s*0;/);
assert.match(css, /#cockpit-display-panel \.pp-toggle-group\s*\{\s*width:\s*100%/);
+15 -10
View File
@@ -1,3 +1,7 @@
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
// Source-contract pins for StyleManager's Context-session handler ordering.
// ui.js cannot be imported headlessly (it touches the DOM at module scope), so
// these read the shipped source — the idiom established by cockpitMarkup /
@@ -173,23 +177,24 @@ test('every user-facing Context exit route settles through the failure surface',
});
test('the Radio chip catches lifecycle rejection and semantic false through the toast wrapper', () => {
const radioControls = src.slice(
src.indexOf('const toggleRadio = async (trigger) => {'),
src.indexOf("this._radioFilter?.addEventListener('change'"),
const radioControls = radioBindings.slice(
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
);
assert.match(radioControls, /await this\._runUserFacingContextAction\(/);
assert.match(radioControls, /await this\.actions\.runUserAction\(/);
assert.match(radioControls, /Radio could not \$\{enabling \? 'start' : 'stop'\} cleanly/);
assert.match(radioControls, /if \(toggled === false\) return/);
assert.match(radioControls, /if \(this\.destroyed \|\| toggled === false\) return/);
});
test('only the expanded Radio Enable gesture requests the contained post-enable reveal', () => {
const radioControls = src.slice(
src.indexOf('const toggleRadio = async (trigger) => {'),
src.indexOf("this._radioFilter?.addEventListener('change'"),
const radioControls = radioBindings.slice(
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
);
assert.match(radioControls, /revealAfterEnable = enabling && trigger === this\._radioEnableBtn/);
assert.match(radioControls, /if \(revealAfterEnable\) await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
assert.equal((src.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 2);
assert.match(radioControls, /if \(revealAfterEnable\)\s*await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
assert.equal((radioBindings.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 1);
assert.match(radioControlsSource, /async _revealRadioControlsAfterExplicitEnable\(trigger\)/);
});
test('right-rail context entry is transactional: activation result gates the mode', () => {
+6 -8
View File
@@ -1,3 +1,7 @@
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -136,14 +140,8 @@ test('Context async action buttons remain focused while busy', () => {
ui.indexOf('_initGlobalContextPanel() {'),
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
);
const radio = ui.slice(
ui.indexOf('const toggleRadio = async (trigger) => {'),
ui.indexOf('this._contextRadioToggleBtn?.addEventListener', ui.indexOf('const toggleRadio = async (trigger) => {')),
);
const radioSync = ui.slice(
ui.indexOf('_renderRadioState(state) {'),
ui.indexOf('if (this._radioFilter)', ui.indexOf('_renderRadioState(state) {')),
);
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,'));
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
const clear = ui.slice(
ui.indexOf('clearSelectedLayers() {'),
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
+1 -87
View File
@@ -1,3 +1,4 @@
export { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from '../ui/radioTunerModel.js';
/**
* Internet-radio station directory and direct-playback layer.
*
@@ -945,93 +946,6 @@ export function radioStationIdFromPick(picked) {
return null;
}
/** Map an integer tuner slot directly to one available directory station. */
export function radioTunerSlot(value, stationCount) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
if (!count) return { slot: 0, max: 0, locked: false, stationIndex: -1, leftIndex: -1, rightIndex: -1 };
const max = Math.max(0, count - 1);
const slot = Math.min(max, Math.max(0, Math.round(Number(value) || 0)));
return {
slot,
max,
locked: true,
stationIndex: slot,
leftIndex: slot,
rightIndex: slot,
};
}
/** Snap a tuner release to the nearest available directory station. */
export function radioTunerCommitSlot(value, stationCount) {
return radioTunerSlot(value, stationCount);
}
/** Map one pointer coordinate to continuous absolute directory progress. */
export function radioTunerPointerPosition(clientX, left, width, stationCount, insetPx = 7) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
if (!count) return { ratio: 0, coordinate: 0, stationIndex: -1 };
if (count === 1) return { ratio: 0.5, coordinate: 0, stationIndex: 0 };
const inset = Math.max(0, Number(insetPx) || 0);
const usableWidth = Math.max(1, (Number(width) || 0) - inset * 2);
const ratio = Math.min(1, Math.max(0, ((Number(clientX) || 0) - (Number(left) || 0) - inset) / usableWidth));
const coordinate = ratio * (count - 1);
return {
ratio,
coordinate,
stationIndex: Math.min(count - 1, Math.max(0, Math.floor(coordinate + 0.5))),
};
}
/** Build a bounded virtual tuner tape around one continuous directory coordinate. */
export function buildRadioTunerTicks(coordinate, stationCount, width, {
insetPx = 7,
minPitchPx = 14,
speedFactor = 5,
overscan = 2,
labelStep = 6,
} = {}) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
const dialWidth = Math.max(0, Number(width) || 0);
const inset = Math.max(0, Number(insetPx) || 0);
const usableWidth = Math.max(0, dialWidth - inset * 2);
if (!count) return { ticks: [], needleX: inset, pitchPx: 0, ratio: 0 };
const value = Math.min(count - 1, Math.max(0, Number(coordinate) || 0));
if (count === 1) {
return {
ticks: [{ stationIndex: 0, channel: 1, xPx: inset + usableWidth / 2, current: true, label: '01' }],
needleX: inset + usableWidth / 2,
pitchPx: Math.max(1, Number(minPitchPx) || 14),
ratio: 0.5,
};
}
const directoryStep = usableWidth / (count - 1);
const pitchPx = Math.max(
Math.max(1, Number(minPitchPx) || 14),
directoryStep * Math.max(1, Number(speedFactor) || 5),
);
const needleX = inset + directoryStep * value;
const overscanPx = pitchPx * Math.max(0, Number(overscan) || 0);
const first = Math.max(0, Math.ceil(value + (-overscanPx - needleX) / pitchPx));
const last = Math.min(count - 1, Math.floor(value + (dialWidth + overscanPx - needleX) / pitchPx));
const currentIndex = Math.min(count - 1, Math.max(0, Math.floor(value + 0.5)));
const majorEvery = Math.max(1, Math.floor(Number(labelStep) || 6));
const labelWidth = Math.max(2, String(count).length);
const ticks = [];
for (let stationIndex = first; stationIndex <= last; stationIndex += 1) {
const channel = stationIndex + 1;
const current = stationIndex === currentIndex;
const labelled = current || stationIndex === 0 || stationIndex === count - 1 || channel % majorEvery === 0;
ticks.push({
stationIndex,
channel,
xPx: needleX + pitchPx * (stationIndex - value),
current,
label: labelled ? String(channel).padStart(labelWidth, '0') : '',
});
}
return { ticks, needleX, pitchPx, ratio: value / (count - 1) };
}
/** Plan a station-centered camera move that preserves altitude and view angle. */
export function radioStationCameraPlan(station, cameraState = {}) {
const targetLat = Number(station?.lat);
+2 -2
View File
@@ -263,14 +263,14 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
});
test('one ESC does one thing — the radio disclosure stops the launcher outright', () => {
const ui = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const ui = fs.readFileSync(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
// stopPropagation() does NOT stop later listeners on the SAME document, so the
// disclosure's earlier capture handler closed the disclosure and the launcher
// dismissed itself off the same key. The earlier listener is the only one that
// can stop the later one — and only the immediate form does it.
const radioEsc = ui.slice(ui.indexOf("if (event.key !== 'Escape' || !this._contextRadioDock"));
const radioEsc = ui.slice(ui.indexOf("event.key !== 'Escape' ||"));
const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false'));
assert.match(claim, /event\.preventDefault\(\);/);
assert.match(claim, /event\.stopImmediatePropagation\(\);/);
+8 -4
View File
@@ -1,3 +1,7 @@
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
import { bindPanelDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -155,16 +159,16 @@ test('Cockpit Escape collapses Contact or Live Signals before exiting Cockpit',
test('Cockpit utility Escape leaves an expanded nested Parameters panel to the shared handler', () => {
assert.match(
source,
/const nestedPanel = event\.target\?\.closest\?\.\('\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)'\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/,
radioBindings,
/const nestedPanel = event\.target\?\.closest\?\.\(\s*'\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)',?\s*\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/,
);
assert.match(
source,
radioBindings,
/const kind = displayOpen \? 'display' : 'radio';[\s\S]*?escapedFromDisclosure[\s\S]*?returnFocus: !escapedFromDisclosure[\s\S]*?disclosure\?\.blur/,
'Cockpit utility disclosures clear their own focus when Escape closes them',
);
assert.match(
source,
radioBindings,
/_contextRadioDock\?\.classList\.contains\('disclosure-open'\)[\s\S]*?escapedFromDisclosure[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: !escapedFromDisclosure \}\)[\s\S]*?_contextRadioToggleBtn\?\.blur/,
'compact Radio disclosure clears its own focus when Escape closes it',
);
+33 -33
View File
@@ -1,3 +1,7 @@
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
@@ -249,19 +253,19 @@ test('Radio is nested inside Context with separate disclosure and power controls
assert.match(css, /--radio-broadcast-opacity: \.17/);
assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.radio-tuner-needle,[\s\S]*?\.radio-tuner-tick\s*\{\s*transition: none;/);
assert.doesNotMatch(ui, /_radioTunerCameraRemove = this\.viewer\?\.camera\?\.changed/);
assert.match(ui, /classList\.toggle\('radio-broadcasting', state\.audioState === 'playing'\)/);
assert.match(ui, /cycleStation\(direction, \{[\s\S]*?rotate,[\s\S]*?stationIds:/);
const cycleStart = ui.indexOf('const cycleRadio = (direction, { rotate = true } = {}) =>');
const cycleMethod = ui.slice(cycleStart, ui.indexOf('const toggleRadio', cycleStart));
assert.match(radioPresentation, /classList\.toggle\('radio-broadcasting', state\.audioState === 'playing'\)/);
assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?rotate,[\s\S]*?stationIds:/);
const cycleStart = radioBindings.indexOf('const cycleRadio = (direction, { rotate = true } = {}) =>');
const cycleMethod = radioBindings.slice(cycleStart, radioBindings.indexOf('const toggleRadio', cycleStart));
assert.doesNotMatch(cycleMethod, /refreshTunerBand/);
assert.match(ui, /_radioTunerBandPinnedForNavigation = true/);
assert.match(ui, /viewer\?\.canvas\?\.addEventListener\('pointerdown', releaseNavigationBand/);
assert.match(ui, /previewTuningStation\(station\?\.id \|\| null, \{ rotate \}\)/);
assert.match(ui, /tunerPreview\(\{ coordinate: this\._radioTunerCoordinate, rotate: commit \}\)/);
assert.match(ui, /radioLayer\.cancelTuning\(\)/);
assert.match(ui, /classList\.remove\('radio-broadcasting'\)/);
assert.match(ui, /radioLayer\.getTunerStations\(750\)/);
assert.match(ui, /radioTunerPointerPosition\(/);
assert.match(radioBindings, /_radioTunerBandPinnedForNavigation = true/);
assert.match(radioBindings, /listen\(\s*this\.canvas,\s*'pointerdown',\s*releaseNavigationBand/);
assert.match(radioBindings, /previewTuningStation\(station\?\.id \|\| null, \{ rotate \}\)/);
assert.match(radioBindings, /tunerPreview\(\{\s*coordinate: this\._radioTunerCoordinate,\s*rotate: commit,?\s*\}\)/);
assert.match(radioBindings, /this\.radio\.cancelTuning\(\)/);
assert.match(radioControlsSource, /classList\.remove\('radio-broadcasting'\)/);
assert.match(radioBindings, /this\.radio\.getTunerStations\(750\)/);
assert.match(radioBindings, /radioTunerPointerPosition\(/);
assert.doesNotMatch(css, /#right-context-rail\s*>\s*#radio-panel/);
assert.match(css, /#global-context-panel #radio-panel\.collapsed/);
assert.doesNotMatch(css, /\.context-radio-dock\.active:hover \.context-radio-mini/);
@@ -277,16 +281,15 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
assert.match(voice, /'radio'/);
assert.match(voice, /name:\s*'control_radio'/);
assert.deepEqual(realtimeTools().find(tool => tool.name === 'control_radio').parameters.properties.action.enum, ['enable', 'disable', 'play', 'resume', 'pause', 'stop', 'next', 'previous', 'volume', 'select', 'status']);
const enableStart = ui.lastIndexOf('\n _initRadioPanel()');
const enableMethod = ui.slice(enableStart, ui.indexOf('\n _renderRadioState(state)', enableStart));
const enableMethod = radioBindings;
assert.doesNotMatch(enableMethod, /playSelectedRadio|togglePlayback\(\).*radio-enable/i);
assert.match(enableMethod, /contextRadioToggleBtn/);
assert.match(enableMethod, /contextRadioMiniEnableBtn/);
assert.match(enableMethod, /contextRadioDetailsBtn/);
assert.match(enableMethod, /contextRadioMiniCloseBtn/);
assert.match(enableMethod, /contextRadioMiniPlayBtn/);
const disclosureStart = enableMethod.indexOf('this._contextRadioToggleBtn?.addEventListener');
const disclosureEnd = enableMethod.indexOf("this._radioFilter?.addEventListener", disclosureStart);
const disclosureStart = enableMethod.indexOf('this.listen(this._contextRadioToggleBtn,');
const disclosureEnd = enableMethod.indexOf("this.listen(this._radioFilter,", disclosureStart);
const disclosureBindings = enableMethod.slice(disclosureStart, disclosureEnd);
assert.ok(disclosureStart >= 0 && disclosureEnd > disclosureStart, 'compact Radio disclosure bindings are missing');
assert.doesNotMatch(disclosureBindings, /toggleRadio\(this\._contextRadioToggleBtn\)/);
@@ -304,32 +307,29 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
});
test('expanded Context routes its Radio icon to the embedded section and keeps collapsed Context compact', () => {
const revealStart = ui.indexOf('\n async _revealRadioPanelInsideContext');
const revealEnd = ui.indexOf('\n /**', revealStart + 10);
const revealMethod = ui.slice(revealStart, revealEnd);
const revealStart = radioControlsSource.indexOf('\n async _revealRadioPanelInsideContext');
const revealEnd = radioControlsSource.indexOf('\n /**', revealStart + 10);
const revealMethod = radioControlsSource.slice(revealStart, revealEnd);
assert.ok(revealStart >= 0 && revealEnd > revealStart, 'embedded Radio reveal helper is missing');
assert.match(revealMethod, /requestAnimationFrame\(\(\) => requestAnimationFrame/);
assert.match(revealMethod, /scroller\.scrollTo\(\{ top: next, behavior: reducedMotion \? 'auto' : 'smooth' \}\)/);
assert.match(revealMethod, /scroller\.scrollTo\(\{\s*top: next,\s*behavior: reducedMotion \? 'auto' : 'smooth',?\s*\}\)/);
assert.match(revealMethod, /focus\?\.\(\{ preventScroll: true \}\)/);
assert.doesNotMatch(revealMethod, /setEnabled|togglePlayback|selectStation|setContextMode/);
const syncStart = ui.indexOf('\n _syncContextRadioLauncherState()');
const syncEnd = ui.indexOf('\n /**', syncStart + 10);
const syncMethod = ui.slice(syncStart, syncEnd);
const syncStart = radioControlsSource.indexOf('\n _syncContextRadioLauncherState()');
const syncEnd = radioControlsSource.indexOf('\n destroy()', syncStart + 10);
const syncMethod = radioControlsSource.slice(syncStart, syncEnd);
assert.ok(syncStart >= 0 && syncEnd > syncStart, 'Context Radio launcher state sync is missing');
assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded', String\(radioExpanded\)/);
assert.match(syncMethod, /aria-controls', 'context-radio-mini'[\s\S]*?aria-expanded', String\(compactOpen\)/);
const renderStart = ui.indexOf('\n _renderRadioState(state)');
const renderMethod = ui.slice(renderStart, ui.indexOf('\n _renderRadioTuner', renderStart));
assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded',\s*String\(radioExpanded\)/);
assert.match(syncMethod, /aria-controls',\s*'context-radio-mini'[\s\S]*?aria-expanded',\s*String\(compactOpen\)/);
const renderMethod = radioPresentation;
assert.match(renderMethod, /this\._syncContextRadioLauncherState\(\)/);
assert.doesNotMatch(renderMethod, /compact Radio controls/);
assert.doesNotMatch(renderMethod, /_contextRadioToggleBtn\.setAttribute\('aria-(?:controls|expanded|label)'/);
});
test('Radio disclosure is explicit, starts closed while off, and preserves playback state', () => {
const renderStart = ui.indexOf('\n _renderRadioState(state)');
const renderMethod = ui.slice(renderStart, ui.indexOf('\n _renderRadioTuner', renderStart));
assert.ok(renderStart >= 0, 'Radio render method is missing');
const renderMethod = radioPresentation;
assert.doesNotMatch(renderMethod, /setPanelCollapsed\('radio-panel', true\).*stopPlayback/s);
assert.doesNotMatch(renderMethod, /_radioMiniExpanded\s*=\s*false.*audioState === 'playing'/s);
assert.match(ui, /contextRadioDetailsBtn/);
@@ -346,8 +346,8 @@ test('successful explicit user playback hands the speaker from voice to Radio',
assert.ok(confirmedPlaying >= 0 && takeoverSignal > confirmedPlaying);
assert.match(radio, /startPlayback: \(\) => playSelectedRadio\(\{ origin: 'voice', attemptId: options\.attemptId \}\)/);
assert.match(radio, /selectRadioStation\(stationId, \{ autoplay: true, origin: 'user' \}\)/);
assert.match(ui, /togglePlayback\(\{ origin: 'user' \}\)/);
assert.match(ui, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/);
assert.match(ui, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/);
assert.match(radioBindings, /togglePlayback\(\{ origin: 'user' \}\)/);
assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/);
assert.match(radioBindings, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/);
assert.match(realtime, /event\.origin === 'user' && event\.action === 'play' && this\.isActive\(\)[\s\S]*?this\.stop\(\{ preserveRadioPlayback: true \}\)/);
});
+86 -864
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -0,0 +1,7 @@
export { RadioControls } from './radioControls.js';
export {
radioTunerSlot,
radioTunerCommitSlot,
radioTunerPointerPosition,
buildRadioTunerTicks,
} from './radioTunerModel.js';
+810
View File
@@ -0,0 +1,810 @@
import {
radioTunerSlot,
radioTunerCommitSlot,
radioTunerPointerPosition,
buildRadioTunerTicks,
} from './radioTunerModel.js';
/** Bind Radio input using a RadioControls receiver and supplied actions. */
export function bindRadioControls() {
if (!this._radioPanel) return;
const tunerListenerOptions = {};
const setRadioDisclosure = (expanded, { returnFocus = false } = {}) => {
if (this.destroyed) return;
const open = Boolean(expanded);
this._contextRadioDock?.classList.toggle('disclosure-open', open);
if (this._contextRadioMini) this._contextRadioMini.hidden = !open;
this._syncContextRadioLauncherState();
if (!open && returnFocus)
this._contextRadioToggleBtn?.focus({ preventScroll: true });
};
this._setRadioDisclosure = setRadioDisclosure;
const setCockpitDisclosure = (
kind,
expanded,
{ returnFocus = false } = {},
) => {
if (this.destroyed) return;
const displayOpen = kind === 'display' && Boolean(expanded);
const radioOpen = kind === 'radio' && Boolean(expanded);
if (displayOpen || radioOpen) this.actions.setSignalCollapsed(true);
if (this._cockpitDisplayPanel)
this._cockpitDisplayPanel.hidden = !displayOpen;
if (this._cockpitRadioPanel) this._cockpitRadioPanel.hidden = !radioOpen;
this._cockpitDisplayToggleBtn
?.closest('.cockpit-utility-control')
?.classList.toggle('is-expanded', displayOpen);
this._cockpitRadioToggleBtn
?.closest('.cockpit-utility-control')
?.classList.toggle('is-expanded', radioOpen);
this._cockpitDisplayToggleBtn?.setAttribute(
'aria-expanded',
String(displayOpen),
);
this._cockpitRadioToggleBtn?.setAttribute(
'aria-expanded',
String(radioOpen),
);
if (displayOpen) this.actions.revealStyleParameters();
if (this._cockpitDisplayToggleBtn) {
const action = displayOpen ? 'Collapse' : 'Expand';
this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀';
this._cockpitDisplayToggleBtn.setAttribute(
'aria-label',
`${action} Cockpit display options`,
);
this._cockpitDisplayToggleBtn.title = `${action} Cockpit display options`;
}
if (this._cockpitRadioToggleBtn) {
const action = radioOpen ? 'Collapse' : 'Expand';
this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀';
this._cockpitRadioToggleBtn.setAttribute(
'aria-label',
`${action} Cockpit Radio controls`,
);
this._cockpitRadioToggleBtn.title = `${action} Cockpit Radio controls`;
}
if (!expanded && returnFocus) {
(kind === 'display'
? this._cockpitDisplayToggleBtn
: this._cockpitRadioToggleBtn
)?.focus({ preventScroll: true });
}
if (
!displayOpen &&
!radioOpen &&
this.actions.isCockpitActive() &&
!this.actions.signalUserCollapsed()
) {
this.actions.setSignalCollapsed(false);
}
this.actions.layoutCockpit();
};
this._setCockpitDisclosure = setCockpitDisclosure;
const syncTunerTape = (coordinate) => {
const scale = this._radioTuner?.querySelector('.radio-tuner-scale');
const dial = this._radioTuner?.querySelector('.radio-tuner-dial');
if (!scale || !dial) return null;
const model = buildRadioTunerTicks(
coordinate,
this._radioTunerStations.length,
dial.getBoundingClientRect().width,
);
while (scale.children.length < model.ticks.length) {
const tick = document.createElement('span');
tick.className = 'radio-tuner-tick';
scale.append(tick);
}
while (scale.children.length > model.ticks.length)
scale.lastElementChild?.remove();
model.ticks.forEach((entry, index) => {
const tick = scale.children[index];
tick.style.left = `${entry.xPx}px`;
tick.textContent = entry.label;
tick.dataset.stationIndex = String(entry.stationIndex);
tick.classList.toggle('is-current', entry.current);
});
scale.style.setProperty('--radio-tuner-tick-pitch', `${model.pitchPx}px`);
return model;
};
const tunerPreview = ({
coordinate = this._radioTunerCoordinate,
syncStatic = true,
rotate = true,
} = {}) => {
const slot = radioTunerSlot(
this._radioTunerSlider?.value,
this._radioTunerStations.length,
);
const station = slot.locked
? this._radioTunerStations[slot.stationIndex] || null
: null;
const resolvedCoordinate =
this._radioTunerStations.length <= 1
? 0
: Math.min(
this._radioTunerStations.length - 1,
Math.max(0, Number(coordinate) || 0),
);
const ratio =
this._radioTunerStations.length === 1
? 0.5
: resolvedCoordinate / Math.max(1, this._radioTunerStations.length - 1);
this._radioTunerCoordinate = resolvedCoordinate;
this._radioTuner?.style.setProperty('--radio-tuner-ratio', String(ratio));
this._radioTuner?.classList.toggle(
'is-static',
syncStatic ? false : Boolean(this._radioState?.tuningStatic),
);
syncTunerTape(resolvedCoordinate);
if (this._radioTunerValue) {
this._radioTunerValue.textContent = station
? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}`
: 'NO STATIONS';
}
if (this._radioTunerStation)
this._radioTunerStation.textContent =
station?.name || 'NO STATION AVAILABLE';
if (this._radioTunerSlider) {
this._radioTunerSlider.setAttribute(
'aria-valuetext',
station
? `${station.name}, station ${slot.stationIndex + 1} of ${this._radioTunerStations.length}`
: 'No station available',
);
}
if (syncStatic)
this.radio.previewTuningStation(station?.id || null, { rotate });
return station;
};
const setTunerDirectory = (pool) => {
this._radioTunerPool = [...pool];
this._radioTunerStations = [...pool];
this._radioTunerBandSignature = this._radioTunerStations
.map((station) => station.id)
.join('|');
if (this._radioTunerSlider) {
this._radioTunerSlider.min = '0';
this._radioTunerSlider.max = String(
Math.max(0, this._radioTunerStations.length - 1),
);
this._radioTunerSlider.step = '1';
}
};
const refreshTunerBand = ({ force = false } = {}) => {
if (
this._radioTunerDragging ||
this._radioTuner?.hidden ||
this._radioTunerSlider?.disabled
)
return false;
const selectedId = this._radioState?.selected?.id || null;
const pool = this.radio.getTunerStations(750);
const poolSignature = pool.map((station) => station.id).join('|');
const currentPoolSignature = this._radioTunerPool
.map((station) => station.id)
.join('|');
if (
!force &&
poolSignature === currentPoolSignature &&
selectedId === this._radioTunerSelectedId
)
return false;
setTunerDirectory(pool);
this._radioTunerSelectedId = selectedId;
const selectedPoolIndex = pool.findIndex(
(station) => station.id === selectedId,
);
const slot = radioTunerSlot(
selectedPoolIndex >= 0 ? selectedPoolIndex : 0,
this._radioTunerStations.length,
);
this._radioTunerSlider.value = String(slot.slot);
this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0;
tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false });
return true;
};
this._refreshRadioTunerBand = refreshTunerBand;
const beginTuner = () => {
if (this._radioTunerDragging || this._radioTunerSlider?.disabled)
return false;
refreshTunerBand();
if (!this._radioTunerStations.length || !this.radio.beginTuning())
return false;
// A tuner-owned camera preview must never replace the frozen directory.
// Only an explicit globe pointer/wheel gesture releases camera pinning.
this._radioTunerBandPinnedForNavigation = true;
this._radioTunerDragging = true;
this._radioTuner?.classList.add('is-dragging');
const selectedIndex = this._radioTunerStations.findIndex(
(station) => station.id === this._radioState?.selected?.id,
);
const slot = radioTunerSlot(
selectedIndex >= 0 ? selectedIndex : 0,
this._radioTunerStations.length,
);
this._radioTunerSlider.value = String(slot.slot);
this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0;
this._radioTunerDragStartSlot = slot.slot;
this._radioTunerDragSnapshot = {
stations: [...this._radioTunerStations],
bandSignature: this._radioTunerBandSignature,
selectedId: this._radioTunerSelectedId,
slot: slot.slot,
coordinate: this._radioTunerCoordinate,
};
this._radioTunerLastSlot = slot.slot;
this._radioTunerDragDirection = 0;
tunerPreview({ coordinate: this._radioTunerCoordinate });
return true;
};
const finishTuner = (commit) => {
if (!this._radioTunerDragging) return;
const dragSnapshot = this._radioTunerDragSnapshot;
if (!commit && dragSnapshot) {
this._radioTunerStations = [...dragSnapshot.stations];
this._radioTunerPool = [...dragSnapshot.stations];
this._radioTunerBandSignature = dragSnapshot.bandSignature;
this._radioTunerSelectedId = dragSnapshot.selectedId;
if (this._radioTunerSlider) {
const startSlot = radioTunerSlot(
dragSnapshot.slot,
this._radioTunerStations.length,
);
this._radioTunerSlider.max = String(startSlot.max);
this._radioTunerSlider.value = String(startSlot.slot);
}
this._radioTunerCoordinate = Number.isFinite(dragSnapshot.coordinate)
? dragSnapshot.coordinate
: dragSnapshot.slot;
} else if (!commit && this._radioTunerSlider) {
this._radioTunerSlider.value = String(this._radioTunerDragStartSlot);
this._radioTunerCoordinate = this._radioTunerDragStartSlot;
}
let station = tunerPreview({
coordinate: this._radioTunerCoordinate,
rotate: commit,
});
if (commit && !station && this._radioTunerSlider) {
const snapped = radioTunerCommitSlot(
this._radioTunerSlider.value,
this._radioTunerStations.length,
);
this._radioTunerSlider.value = String(snapped.slot);
this._radioTunerCoordinate = snapped.stationIndex;
station = tunerPreview({ coordinate: this._radioTunerCoordinate });
}
let result = null;
if (commit && station) {
// Keep the exact band used by the drag so the selected channel cannot
// jump to a refreshed catalog slot while its camera flight settles.
this._radioTunerBandPinnedForNavigation = true;
result = this.radio.commitTuningStation(station.id, { origin: 'user' });
} else if (!commit) {
this.radio.cancelTuning();
} else {
this.radio.endTuning();
}
// Radio emits selection/tuning state synchronously. Keep both the logical
// drag and the no-transition class active until that state has settled,
// then restore the exact selected slot before permitting CSS motion.
this._radioTunerDragging = false;
this._radioTunerPointerId = null;
this._radioTunerKeyboardKey = null;
if (commit && (!result || result.ok)) refreshTunerBand();
this._radioTunerDragSnapshot = null;
// Flush the snapped position while transitions are still disabled so
// removing the drag class cannot interpolate from the released gap.
void this._radioTunerNeedle?.offsetLeft;
this._radioTuner?.classList.remove('is-dragging');
if (result && !result.ok) {
this._radioTunerBandPinnedForNavigation = false;
if (result.reason === 'station-unavailable') {
if (this._radioTunerValue)
this._radioTunerValue.textContent = 'OFF AIR';
if (this._radioTunerStation)
this._radioTunerStation.textContent = 'STATION UNAVAILABLE';
this._radioTunerSlider?.setAttribute(
'aria-valuetext',
'Station unavailable after directory refresh',
);
}
}
};
const cycleRadio = (direction, { rotate = true } = {}) => {
this._radioTunerBandPinnedForNavigation = true;
const pool = this._radioTunerPool.length
? this._radioTunerPool
: this._radioTunerStations;
const cycled = this.radio.cycleStation(direction, {
rotate,
stationIds: pool.map((station) => station.id),
origin: 'user',
});
if (!cycled) {
this._radioTunerBandPinnedForNavigation = false;
return;
}
};
const toggleRadio = async (trigger) => {
if (!this.actions.isRegistered()) return;
if (trigger.getAttribute('aria-busy') === 'true') return;
const enabling = !this.actions.isEnabled();
const revealAfterEnable = enabling && trigger === this._radioEnableBtn;
trigger.setAttribute('aria-disabled', 'true');
trigger.setAttribute('aria-busy', 'true');
try {
const toggled = await this.actions.runUserAction(
(notificationToken) =>
this.actions.setEnabled(enabling, {
origin: 'user',
notificationToken,
}),
`Radio could not ${enabling ? 'start' : 'stop'} cleanly`,
);
if (this.destroyed || toggled === false) return;
if (
enabling &&
trigger === this._radioEnableBtn &&
!document
.getElementById('global-context-panel')
?.classList.contains('collapsed')
) {
this.actions.setPanelCollapsed('radio-panel', false, {
explicit: true,
});
}
if (revealAfterEnable)
await this._revealRadioControlsAfterExplicitEnable(trigger);
} finally {
if (!this.destroyed) {
trigger.setAttribute('aria-disabled', 'false');
trigger.setAttribute('aria-busy', 'false');
if (revealAfterEnable && trigger.isConnected)
trigger.focus({ preventScroll: true });
}
}
};
this.listen(
this._radioEnableBtn,
'click',
() => void toggleRadio(this._radioEnableBtn),
);
this.listen(
this._contextRadioMiniEnableBtn,
'click',
() => void toggleRadio(this._contextRadioMiniEnableBtn),
);
this.listen(
this._cockpitRadioEnableBtn,
'click',
() => void toggleRadio(this._cockpitRadioEnableBtn),
);
this.listen(this._contextRadioToggleBtn, 'click', () => {
const contextPanel = document.getElementById('global-context-panel');
if (contextPanel && !contextPanel.classList.contains('collapsed')) {
setRadioDisclosure(false);
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
void this._revealRadioPanelInsideContext({
focusTarget: this._radioPanel?.querySelector(
'[data-collapse-target="radio-panel"]',
),
});
return;
}
setRadioDisclosure(
!this._contextRadioDock?.classList.contains('disclosure-open'),
);
});
this.listen(this._contextRadioMiniCloseBtn, 'click', () => {
setRadioDisclosure(false, { returnFocus: true });
});
this.listen(this._contextRadioDetailsBtn, 'click', () => {
if (!this.actions.isCockpitActive())
this.actions.setPanelCollapsed('global-context-panel', false, {
explicit: true,
});
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
setRadioDisclosure(false);
this._radioEnableBtn?.focus({ preventScroll: true });
});
this.listen(this._cockpitRadioToggleBtn, 'click', () => {
const open =
this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true';
setCockpitDisclosure('radio', !open);
});
this.listen(
document,
'pointerdown',
(event) => {
if (!this._contextRadioDock?.classList.contains('disclosure-open'))
return;
if (event.target?.closest?.('#context-radio-dock')) return;
setRadioDisclosure(false);
},
tunerListenerOptions,
);
this.listen(
document,
'pointerdown',
(event) => {
if (
!this._cockpitUtilityControls ||
event.target?.closest?.('#cockpit-utility-controls')
)
return;
if (event.target?.closest?.('.cockpit-vision-controls')) return;
if (event.target?.closest?.('#left-panel-stack, #cockpit-context'))
return;
setCockpitDisclosure('display', false);
setCockpitDisclosure('radio', false);
},
tunerListenerOptions,
);
this.listen(
document,
'keydown',
(event) => {
if (
event.key !== 'Escape' ||
!this._contextRadioDock?.classList.contains('disclosure-open')
)
return;
event.preventDefault();
// Immediate: a plain stopPropagation() still lets every LATER listener on
// this same document run, so closing the disclosure ALSO dismissed the
// first-run launcher — one key, two actions. Matches the cockpit
// disclosure handler directly below.
event.stopImmediatePropagation();
const escapedFromDisclosure =
event.target === this._contextRadioToggleBtn ||
this._contextRadioToggleBtn?.contains?.(event.target);
setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure });
if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.();
},
{ capture: true },
);
this.listen(
document,
'keydown',
(event) => {
if (event.key !== 'Escape') return;
const displayOpen =
this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true';
const radioOpen =
this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true';
if (!displayOpen && !radioOpen) return;
const nestedPanel = event.target?.closest?.(
'.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)',
);
if (nestedPanel) return;
event.preventDefault();
event.stopImmediatePropagation();
const kind = displayOpen ? 'display' : 'radio';
const disclosure = displayOpen
? this._cockpitDisplayToggleBtn
: this._cockpitRadioToggleBtn;
const escapedFromDisclosure =
event.target === disclosure || disclosure?.contains?.(event.target);
setCockpitDisclosure(kind, false, {
returnFocus: !escapedFromDisclosure,
});
if (escapedFromDisclosure) disclosure?.blur?.();
},
{ capture: true },
);
this.listen(
window,
'gev:cockpit-mode-changed',
(event) => {
if (event?.detail?.active) return;
setCockpitDisclosure('display', false);
setCockpitDisclosure('radio', false);
},
tunerListenerOptions,
);
this.listen(
window,
'gev:cockpit-signal-expanded',
() => {
setCockpitDisclosure('display', false);
},
tunerListenerOptions,
);
this.listen(
window,
'gev:cockpit-context-expanded',
() => {
this.actions.setPanelCollapsed('data-panel', true);
},
tunerListenerOptions,
);
this.listen(this._radioFilter, 'change', () => {
const presentation = this.radio.getUIState();
if (!presentation.presentationActive) {
this._radioFilter.value = presentation.filter;
return;
}
if (this._radioTunerDragging) finishTuner(false);
if (
!this.actions.setParams(
{
filter: this._radioFilter.value,
},
{ origin: 'user' },
)
) {
this._radioFilter.value = this.radio.getUIState().filter;
return;
}
this._radioTunerBandPinnedForNavigation = false;
this._radioTunerPool = [];
refreshTunerBand({ force: true });
});
this.listen(this._radioPrevBtn, 'click', () => cycleRadio(-1));
this.listen(this._radioNextBtn, 'click', () => cycleRadio(1));
this.listen(
this._radioPlayBtn,
'click',
() => void this.radio.togglePlayback({ origin: 'user' }),
);
this.listen(this._radioStopBtn, 'click', () =>
this.radio.stopPlayback({ origin: 'user' }),
);
this.listen(this._radioVolume, 'input', () => {
const value = Number(this._radioVolume.value);
if (this._radioVolumeValue)
this._radioVolumeValue.textContent = `${value}%`;
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
});
this.listen(this._contextRadioMiniPrevBtn, 'click', () => cycleRadio(-1));
this.listen(this._contextRadioMiniNextBtn, 'click', () => cycleRadio(1));
this.listen(
this._contextRadioMiniPlayBtn,
'click',
() => void this.radio.togglePlayback({ origin: 'user' }),
);
// Cockpit owns the Cesium camera even though it intentionally clears
// viewer.trackedEntity. Station changes must never start the map-view
// rotation/fallback flights that would compete with its preUpdate pose.
this.listen(this._cockpitRadioPrevBtn, 'click', () =>
cycleRadio(-1, { rotate: false }),
);
this.listen(this._cockpitRadioNextBtn, 'click', () =>
cycleRadio(1, { rotate: false }),
);
this.listen(
this._cockpitRadioPlayBtn,
'click',
() => void this.radio.togglePlayback({ origin: 'user' }),
);
this.listen(this._contextRadioMiniVolume, 'input', () => {
const value = Number(this._contextRadioMiniVolume.value);
if (this._contextRadioMiniVolumeValue)
this._contextRadioMiniVolumeValue.textContent = `${value}%`;
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
});
this.listen(this._cockpitRadioVolume, 'input', () => {
const value = Number(this._cockpitRadioVolume.value);
if (this._cockpitRadioVolumeValue)
this._cockpitRadioVolumeValue.textContent = `${value}%`;
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
});
const updateTunerFromPointer = (event) => {
const rect = this._radioTunerSlider?.getBoundingClientRect();
if (!rect || !this._radioTunerStations.length) return false;
const position = radioTunerPointerPosition(
event.clientX,
rect.left,
rect.width,
this._radioTunerStations.length,
);
if (position.stationIndex > this._radioTunerLastSlot)
this._radioTunerDragDirection = 1;
else if (position.stationIndex < this._radioTunerLastSlot)
this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = position.stationIndex;
this._radioTunerCoordinate = position.coordinate;
this._radioTunerSlider.value = String(position.stationIndex);
tunerPreview({ coordinate: position.coordinate });
return true;
};
this.listen(
this._radioTunerSlider,
'pointerdown',
(event) => {
if (!beginTuner()) return;
this._radioTunerPointerId = event.pointerId;
this._radioTunerSlider.focus({ preventScroll: true });
try {
this._radioTunerSlider.setPointerCapture(event.pointerId);
} catch {
/* capture is best effort */
}
updateTunerFromPointer(event);
event.preventDefault();
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'pointermove',
(event) => {
if (
!this._radioTunerDragging ||
this._radioTunerPointerId !== event.pointerId
)
return;
updateTunerFromPointer(event);
event.preventDefault();
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'input',
() => {
if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey)
return;
if (!this._radioTunerDragging && !beginTuner()) return;
const inputSlot = radioTunerSlot(
this._radioTunerSlider.value,
this._radioTunerStations.length,
);
if (inputSlot.slot > this._radioTunerLastSlot)
this._radioTunerDragDirection = 1;
else if (inputSlot.slot < this._radioTunerLastSlot)
this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = inputSlot.slot;
this._radioTunerCoordinate = inputSlot.stationIndex;
tunerPreview({ coordinate: this._radioTunerCoordinate });
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'change',
() => {
if (this._radioTunerPointerId === null && !this._radioTunerKeyboardKey)
finishTuner(true);
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'pointerup',
(event) => {
if (this._radioTunerPointerId !== event.pointerId) return;
updateTunerFromPointer(event);
finishTuner(true);
try {
this._radioTunerSlider.releasePointerCapture(event.pointerId);
} catch {
/* already released */
}
event.preventDefault();
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'pointercancel',
(event) => {
if (
this._radioTunerPointerId !== null &&
this._radioTunerPointerId !== event.pointerId
)
return;
try {
this._radioTunerSlider.releasePointerCapture(event.pointerId);
} catch {
/* already released */
}
finishTuner(false);
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'lostpointercapture',
(event) => {
if (
this._radioTunerDragging &&
this._radioTunerPointerId === event.pointerId
)
finishTuner(false);
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'keydown',
(event) => {
if (event.key === 'Escape' && this._radioTunerDragging) {
event.preventDefault();
finishTuner(false);
return;
}
if (
![
'ArrowLeft',
'ArrowRight',
'Home',
'End',
'PageUp',
'PageDown',
].includes(event.key)
)
return;
if (!this._radioTunerDragging && !beginTuner()) return;
event.preventDefault();
this._radioTunerKeyboardKey = event.key;
const max = Math.max(0, this._radioTunerStations.length - 1);
const current = radioTunerSlot(
this._radioTunerSlider.value,
this._radioTunerStations.length,
).slot;
const page = Math.max(1, Math.round(max / 10));
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? max
: event.key === 'PageUp'
? current + page
: event.key === 'PageDown'
? current - page
: current + (event.key === 'ArrowRight' ? 1 : -1);
const slot = radioTunerSlot(next, this._radioTunerStations.length);
if (slot.slot > this._radioTunerLastSlot)
this._radioTunerDragDirection = 1;
else if (slot.slot < this._radioTunerLastSlot)
this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = slot.slot;
this._radioTunerCoordinate = slot.stationIndex;
this._radioTunerSlider.value = String(slot.slot);
tunerPreview({ coordinate: this._radioTunerCoordinate });
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'keyup',
(event) => {
if (
!this._radioTunerKeyboardKey ||
event.key !== this._radioTunerKeyboardKey
)
return;
event.preventDefault();
finishTuner(true);
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'blur',
() => finishTuner(true),
tunerListenerOptions,
);
const releaseNavigationBand = () => {
this._radioTunerBandPinnedForNavigation = false;
};
this.listen(
this.canvas,
'pointerdown',
releaseNavigationBand,
tunerListenerOptions,
);
this.listen(
this.canvas,
'wheel',
releaseNavigationBand,
tunerListenerOptions,
);
// The directory order is catalog/filter authority, not camera authority.
// Globe motion therefore never rebuilds or re-ranks the frequency band.
this._radioSelectedHandler = () =>
this.actions.setPanelCollapsed('radio-panel', false);
this.listen(document, 'gev:radio-selected', this._radioSelectedHandler);
}
+225
View File
@@ -0,0 +1,225 @@
import { bindRadioControls } from './radioBindings.js';
import { renderRadioState } from './radioPresentation.js';
/** Own Radio DOM, tuner interaction and subscription; receive playback and application actions. */
export class RadioControls {
constructor({ elements, radio, actions, canvas }) {
Object.assign(this, elements);
this.radio = radio;
this.actions = actions;
this.canvas = canvas;
this.destroyed = false;
this.listeners = new AbortController();
this._radioUnsubscribe = null;
this._radioState = null;
this._radioCategorySignature = '';
this._radioTunerStations = [];
this._radioTunerPool = [];
this._radioTunerDragging = false;
this._radioTunerDragStartSlot = 0;
this._radioTunerDragSnapshot = null;
this._radioTunerLastSlot = 0;
this._radioTunerDragDirection = 0;
this._radioTunerCoordinate = 0;
this._radioTunerPointerId = null;
this._radioTunerKeyboardKey = null;
this._radioTunerBandSignature = '';
this._radioTunerSelectedId = null;
this._radioTunerBandPinnedForNavigation = false;
this._refreshRadioTunerBand = null;
bindRadioControls.call(this);
}
listen(target, type, handler, options = {}) {
target?.addEventListener(type, handler, {
...options,
signal: this.listeners.signal,
});
}
connect() {
this._radioUnsubscribe?.();
this._radioUnsubscribe = null;
if (this.destroyed) return;
this._radioUnsubscribe = this.radio.subscribe?.((state) =>
this._renderRadioState(state),
);
}
_renderRadioState(state) {
renderRadioState.call(this, state);
}
/**
* Reveal the newly enabled directory and transport inside Context without
* moving focus, the page, or the globe. Only the expanded Enable path calls
* this helper.
* @param {HTMLElement} trigger Initiating Radio Enable button.
* @returns {Promise<boolean>} Whether the internal scroller moved.
*/
async _revealRadioControlsAfterExplicitEnable(trigger) {
const contextPanel = document.getElementById('global-context-panel');
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
const directory = this._radioPanel?.querySelector('.radio-directory-row');
const transport = this._radioPanel?.querySelector('.radio-transport');
if (
!contextPanel ||
contextPanel.classList.contains('collapsed') ||
!scroller ||
!directory ||
!transport ||
!this._radioState?.enabled
)
return false;
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
if (this.destroyed || !this._radioState?.enabled || !trigger?.isConnected)
return false;
const viewport = scroller.getBoundingClientRect();
const directoryRect = directory.getBoundingClientRect();
const transportRect = transport.getBoundingClientRect();
const margin = 10;
const minimum =
scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin);
const maximum =
scroller.scrollTop + directoryRect.top - (viewport.top + margin);
const desired =
minimum <= maximum
? Math.min(Math.max(scroller.scrollTop, minimum), maximum)
: minimum;
const next = Math.min(
Math.max(0, scroller.scrollHeight - scroller.clientHeight),
Math.max(0, desired),
);
if (Math.abs(next - scroller.scrollTop) < 1) return false;
const reducedMotion = window.matchMedia?.(
'(prefers-reduced-motion: reduce)',
)?.matches;
scroller.scrollTo({
top: next,
behavior: reducedMotion ? 'auto' : 'smooth',
});
return true;
}
/**
* Bring the embedded Radio section into the expanded Context scroller.
* This never changes Radio power, playback, selection, or Context mode.
* @param {{focusTarget?: HTMLElement|null}} [options]
* @returns {Promise<boolean>} Whether the internal scroller moved.
*/
async _revealRadioPanelInsideContext({ focusTarget = null } = {}) {
const contextPanel = document.getElementById('global-context-panel');
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
if (
!contextPanel ||
contextPanel.classList.contains('collapsed') ||
!scroller ||
!this._radioPanel ||
this._radioPanel.classList.contains('collapsed')
)
return false;
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
if (
this.destroyed ||
contextPanel.classList.contains('collapsed') ||
this._radioPanel.classList.contains('collapsed')
)
return false;
const viewport = scroller.getBoundingClientRect();
const radioRect = this._radioPanel.getBoundingClientRect();
const desired = scroller.scrollTop + radioRect.top - viewport.top - 10;
const next = Math.min(
Math.max(0, scroller.scrollHeight - scroller.clientHeight),
Math.max(0, desired),
);
const moved = Math.abs(next - scroller.scrollTop) >= 1;
if (moved) {
const reducedMotion = window.matchMedia?.(
'(prefers-reduced-motion: reduce)',
)?.matches;
scroller.scrollTo({
top: next,
behavior: reducedMotion ? 'auto' : 'smooth',
});
}
focusTarget?.focus?.({ preventScroll: true });
return moved;
}
/** Keep the Context header Radio shortcut truthful for its current route. */
_syncContextRadioLauncherState() {
if (this.destroyed || !this._contextRadioToggleBtn) return;
const contextPanel = document.getElementById('global-context-panel');
const contextExpanded = Boolean(
contextPanel && !contextPanel.classList.contains('collapsed'),
);
if (contextExpanded) {
const radioExpanded = Boolean(
this._radioPanel && !this._radioPanel.classList.contains('collapsed'),
);
this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel');
this._contextRadioToggleBtn.setAttribute(
'aria-expanded',
String(radioExpanded),
);
const label = radioExpanded
? 'Go to expanded Radio section'
: 'Expand Radio section in Context';
this._contextRadioToggleBtn.setAttribute('aria-label', label);
this._contextRadioToggleBtn.title = label;
return;
}
const compactOpen = Boolean(
this._contextRadioDock?.classList.contains('disclosure-open'),
);
this._contextRadioToggleBtn.setAttribute(
'aria-controls',
'context-radio-mini',
);
this._contextRadioToggleBtn.setAttribute(
'aria-expanded',
String(compactOpen),
);
const action = compactOpen ? 'Close' : 'Open';
this._contextRadioToggleBtn.setAttribute(
'aria-label',
`${action} compact Radio controls`,
);
this._contextRadioToggleBtn.title = `${action} compact Radio controls`;
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.listeners.abort();
this._radioUnsubscribe?.();
this._radioUnsubscribe = null;
const pointer = this._radioTunerPointerId;
this._radioTunerPointerId = null;
this._radioTunerDragging = false;
this._radioTunerKeyboardKey = null;
this._radioTunerDragSnapshot = null;
this._radioTunerPool = [];
this._radioTunerStations = [];
this._radioTunerBandPinnedForNavigation = false;
try {
if (pointer !== null)
this._radioTunerSlider?.releasePointerCapture(pointer);
} catch {
/* capture already released */
}
this._radioTuner?.classList.remove('is-dragging', 'is-static');
document
.getElementById('title-bar')
?.classList.remove('radio-broadcasting');
this.radio.endTuning();
}
}
+174
View File
@@ -0,0 +1,174 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { RadioControls } from './radioControls.js';
class Element extends EventTarget {
constructor() {
super();
this.attrs = new Map();
this.classes = new Set();
this.focusCount = 0;
this.isConnected = true;
this.classList = {
add: (...names) => names.forEach((n) => this.classes.add(n)),
remove: (...names) => names.forEach((n) => this.classes.delete(n)),
contains: (name) => this.classes.has(name),
toggle: (name, enabled) =>
enabled ? this.classes.add(name) : this.classes.delete(name),
};
}
setAttribute(name, value) {
this.attrs.set(name, String(value));
}
getAttribute(name) {
return this.attrs.get(name);
}
querySelector() {
return null;
}
focus() {
this.focusCount++;
}
}
function fixture() {
const prior = { document: globalThis.document, window: globalThis.window };
const document = new EventTarget();
document.getElementById = () => null;
globalThis.document = document;
globalThis.window = new EventTarget();
const panel = new Element();
const enable = new Element();
const next = new Element();
const calls = [];
const subscribers = [];
const radio = {
subscribe(callback) {
subscribers.push(callback);
calls.push('subscribe');
return () => calls.push('unsubscribe');
},
endTuning() {
calls.push('endTuning');
},
cycleStation(direction) {
calls.push(['cycle', direction]);
return true;
},
};
const actions = {
isRegistered: () => true,
isEnabled: () => false,
runUserAction: (operation) => operation(),
setEnabled: async () => true,
setPanelCollapsed: (...args) => calls.push(['panel', ...args]),
layoutCockpit() {},
isCockpitActive: () => false,
scheduleLayout() {},
getLifecycle: () => null,
preservePanelStateDuringClear: () => false,
};
const controls = new RadioControls({
elements: {
_radioPanel: panel,
_radioEnableBtn: enable,
_radioNextBtn: next,
},
radio,
actions,
canvas: new Element(),
});
return {
controls,
enable,
next,
actions,
calls,
subscribers,
document,
cleanup() {
controls.destroy();
Object.assign(globalThis, prior);
},
};
}
test('Radio listeners and subscriptions are revoked once before tuning teardown', () => {
const f = fixture();
try {
f.controls.connect();
f.next.dispatchEvent(new Event('click'));
assert.deepEqual(f.calls, ['subscribe', ['cycle', 1]]);
f.controls.destroy();
f.controls.destroy();
const snapshot = structuredClone(f.calls);
f.controls._setRadioDisclosure(true);
f.controls._setCockpitDisclosure('display', true);
f.next.dispatchEvent(new Event('click'));
f.document.dispatchEvent(new Event('gev:radio-selected'));
f.subscribers[0]({ enabled: true });
f.controls.connect();
assert.deepEqual(f.calls, snapshot);
assert.deepEqual(f.calls.slice(-2), ['unsubscribe', 'endTuning']);
} finally {
f.cleanup();
}
});
test('Radio reconnect removes its previous state subscription', () => {
const f = fixture();
try {
f.controls.connect();
f.controls.connect();
assert.deepEqual(f.calls, ['subscribe', 'unsubscribe', 'subscribe']);
} finally {
f.cleanup();
}
});
test('a delayed Radio enable cannot reveal or refocus controls after destruction', async () => {
const f = fixture();
let resolve;
const pending = new Promise((done) => {
resolve = done;
});
try {
f.actions.setEnabled = () => pending;
f.enable.dispatchEvent(new Event('click'));
assert.equal(f.enable.getAttribute('aria-busy'), 'true');
f.controls.destroy();
const attrs = new Map(f.enable.attrs);
resolve(true);
await pending;
await new Promise((done) => setImmediate(done));
assert.deepEqual(f.enable.attrs, attrs);
assert.equal(f.enable.focusCount, 0);
assert.equal(
f.calls.some((call) => Array.isArray(call) && call[0] === 'panel'),
false,
);
} finally {
f.cleanup();
}
});
test('destruction releases an active tuner pointer after revoking listeners', () => {
const f = fixture();
try {
const released = [];
f.controls._radioTunerSlider = {
releasePointerCapture(id) {
assert.equal(f.controls.listeners.signal.aborted, true);
released.push(id);
},
};
f.controls._radioTunerPointerId = 7;
f.controls._radioTunerDragging = true;
f.controls.destroy();
assert.deepEqual(released, [7]);
assert.equal(f.controls._radioTunerDragging, false);
assert.equal(f.controls._radioTunerPointerId, null);
} finally {
f.cleanup();
}
});
+367
View File
@@ -0,0 +1,367 @@
/** Render Radio state without making playback or Context decisions. */
export function renderRadioState(state) {
if (this.destroyed || !state || !this._radioPanel) return;
const lifecycle = this.actions.getLifecycle() || null;
const lifecycleState =
lifecycle?.lifecycleState || (state.enabled ? 'enabled' : 'disabled');
state = {
...state,
enabled: lifecycle ? lifecycle.enabled : state.enabled,
lifecycleState,
lifecycleUncertain: lifecycle?.uncertain || false,
};
this._radioState = state;
const enabled = Boolean(state.enabled);
const transitioning =
lifecycleState === 'enabling' || lifecycleState === 'disabling';
const uncertain = Boolean(state.lifecycleUncertain);
const interactive = enabled && !transitioning && !uncertain;
const selected = state.selected || null;
const hasStations = state.filteredCount > 0;
const activePlayback = ['playing', 'buffering'].includes(state.audioState);
document
.getElementById('title-bar')
?.classList.toggle('radio-broadcasting', state.audioState === 'playing');
this._radioPanel.classList.toggle('radio-enabled', enabled);
this._radioPanel.classList.toggle('lifecycle-uncertain', uncertain);
this._contextRadioDock?.classList.toggle('active', enabled);
if (this._contextRadioToggleBtn) {
this._contextRadioToggleBtn.classList.toggle('active', enabled);
}
this._syncContextRadioLauncherState();
this._radioLayerState?.classList.toggle('active', enabled);
if (this._radioLayerState) {
this._radioLayerState.textContent = transitioning
? lifecycleState.toUpperCase()
: uncertain
? 'UNCERTAIN'
: state.loading
? 'SYNC'
: enabled
? `${state.filteredCount}/${state.stationCount}`
: 'OFF';
}
if (this._radioEnableBtn) {
this._radioEnableBtn.classList.toggle('active', enabled);
this._radioEnableBtn.setAttribute('aria-pressed', String(enabled));
this._radioEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase()
: uncertain
? 'RECONCILE'
: enabled
? 'DISABLE'
: 'ENABLE';
this._radioEnableBtn.setAttribute(
'aria-label',
uncertain
? 'Reconcile Radio — lifecycle uncertain'
: `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._radioEnableBtn.disabled = false;
this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning));
this._radioEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._contextRadioMiniEnableBtn) {
this._contextRadioMiniEnableBtn.classList.toggle('active', enabled);
this._contextRadioMiniEnableBtn.setAttribute(
'aria-pressed',
String(enabled),
);
this._contextRadioMiniEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase()
: uncertain
? 'RECONCILE'
: enabled
? 'DISABLE'
: 'ENABLE';
this._contextRadioMiniEnableBtn.setAttribute(
'aria-label',
uncertain
? 'Reconcile Radio — lifecycle uncertain'
: `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._contextRadioMiniEnableBtn.disabled = false;
this._contextRadioMiniEnableBtn.setAttribute(
'aria-disabled',
String(transitioning),
);
this._contextRadioMiniEnableBtn.setAttribute(
'aria-busy',
String(transitioning),
);
}
if (this._cockpitRadioEnableBtn) {
this._cockpitRadioEnableBtn.classList.toggle('active', enabled);
this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled));
this._cockpitRadioEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase()
: uncertain
? 'RECONCILE'
: enabled
? 'DISABLE'
: 'ENABLE';
this._cockpitRadioEnableBtn.setAttribute(
'aria-label',
uncertain
? 'Reconcile Radio — lifecycle uncertain'
: `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._cockpitRadioEnableBtn.disabled = false;
this._cockpitRadioEnableBtn.setAttribute(
'aria-disabled',
String(transitioning),
);
this._cockpitRadioEnableBtn.setAttribute(
'aria-busy',
String(transitioning),
);
}
if (this._radioFilter) {
const prior = state.filter || 'all';
const categorySignature = state.categories
.map((category) => `${category.id}:${category.count}:${category.color}`)
.join('|');
if (categorySignature !== this._radioCategorySignature) {
this._radioFilter.replaceChildren(
...state.categories.map((category) => {
const option = document.createElement('option');
option.value = category.id;
option.textContent = `● ${category.label} (${category.count})`;
option.dataset.radioColor = category.color;
option.style.color = category.color;
option.setAttribute(
'aria-label',
`${category.label} (${category.count})`,
);
return option;
}),
);
this._radioCategorySignature = categorySignature;
}
this._radioFilter.value = prior;
const activeCategory = state.categories.find(
(category) => category.id === prior,
);
this._radioFilter.style.color = activeCategory?.color || '';
this._radioFilter.disabled = !interactive || !state.stationCount;
}
const tunerAvailable = interactive && state.filteredCount > 0;
if (this._radioTuner) this._radioTuner.hidden = !tunerAvailable;
if (this._radioTunerSlider) this._radioTunerSlider.disabled = !tunerAvailable;
if (this._radioTunerBandLabel) {
const activeCategory = state.categories.find(
(category) => category.id === state.filter,
);
this._radioTunerBandLabel.textContent =
state.filter === 'all'
? 'DIRECTORY BAND'
: `${String(activeCategory?.label || state.filter).toUpperCase()} BAND`;
}
this._radioTuner?.classList.toggle('is-static', Boolean(state.tuningStatic));
if (tunerAvailable) this._refreshRadioTunerBand?.();
if (!tunerAvailable && this._radioTunerDragging) {
this._radioTunerDragging = false;
this._radioTunerDragSnapshot = null;
this._radioTunerStations = [];
this._radioTuner?.classList.remove('is-static', 'is-dragging');
}
if (!tunerAvailable) {
this._radioTunerStations = [];
this._radioTunerPool = [];
this._radioTunerBandSignature = '';
this._radioTunerSelectedId = null;
}
if (this._radioStationName)
this._radioStationName.textContent =
selected?.name || 'NO STATION SELECTED';
if (this._radioStationMeta) {
const place = selected
? [selected.state, selected.countryCode].filter(Boolean).join(' · ')
: '';
const signal = selected
? [selected.codec, selected.bitrate ? `${selected.bitrate} kbps` : '']
.filter(Boolean)
.join(' · ')
: '';
this._radioStationMeta.textContent = selected
? [place, signal].filter(Boolean).join(' / ') ||
'Directory metadata only'
: state.loading
? 'Loading station directory…'
: 'Choose a globe marker or use next.';
}
if (this._radioStationTags) {
const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : [];
this._radioStationTags.textContent = tags.length
? `TAGS · ${tags.join(' · ')}`
: '';
}
if (this._radioStationHomepage) {
const homepage = selected?.homepage || '';
this._radioStationHomepage.hidden = !homepage;
if (homepage) this._radioStationHomepage.href = homepage;
else this._radioStationHomepage.removeAttribute('href');
}
if (this._radioPrevBtn)
this._radioPrevBtn.disabled = !interactive || !hasStations;
if (this._radioNextBtn)
this._radioNextBtn.disabled = !interactive || !hasStations;
if (this._contextRadioMiniPrevBtn)
this._contextRadioMiniPrevBtn.disabled = !interactive || !hasStations;
if (this._contextRadioMiniNextBtn)
this._contextRadioMiniNextBtn.disabled = !interactive || !hasStations;
if (this._cockpitRadioPrevBtn)
this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations;
if (this._cockpitRadioNextBtn)
this._cockpitRadioNextBtn.disabled = !interactive || !hasStations;
if (this._radioPlayBtn) {
const action = activePlayback
? 'Pause'
: state.audioState === 'paused'
? 'Resume'
: 'Play';
this._radioPlayBtn.disabled = !interactive || !hasStations;
this._radioPlayBtn.classList.toggle('active', activePlayback);
this._radioPlayBtn.textContent = action.toUpperCase();
this._radioPlayBtn.setAttribute(
'aria-label',
`${action} ${selected ? 'selected' : 'nearest'} radio station`,
);
}
if (this._contextRadioMiniPlayBtn) {
const action = activePlayback
? 'Pause'
: state.audioState === 'paused'
? 'Resume'
: 'Play';
this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations;
this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback);
this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
this._contextRadioMiniPlayBtn.setAttribute(
'aria-label',
`${action} ${selected ? 'selected' : 'nearest'} radio station`,
);
this._contextRadioMiniPlayBtn.title = action;
}
if (this._cockpitRadioPlayBtn) {
const action = activePlayback
? 'Pause'
: state.audioState === 'paused'
? 'Resume'
: 'Play';
this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations;
this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback);
this._cockpitRadioPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
this._cockpitRadioPlayBtn.setAttribute(
'aria-label',
`${action} ${selected ? 'selected' : 'nearest'} radio station`,
);
this._cockpitRadioPlayBtn.title = action;
}
if (this._radioStopBtn)
this._radioStopBtn.disabled =
!interactive || state.audioState === 'stopped';
if (this._radioVolume) this._radioVolume.disabled = !interactive;
if (this._radioVolume && document.activeElement !== this._radioVolume) {
this._radioVolume.value = String(Math.round(state.volume * 100));
if (this._radioVolumeValue)
this._radioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
}
if (
this._contextRadioMiniVolume &&
document.activeElement !== this._contextRadioMiniVolume
) {
this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100));
}
if (this._contextRadioMiniVolume)
this._contextRadioMiniVolume.disabled = !interactive;
if (this._contextRadioMiniVolumeValue) {
this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
}
if (
this._cockpitRadioVolume &&
document.activeElement !== this._cockpitRadioVolume
) {
this._cockpitRadioVolume.value = String(Math.round(state.volume * 100));
}
if (this._cockpitRadioVolume)
this._cockpitRadioVolume.disabled = !interactive;
if (this._cockpitRadioVolumeValue) {
this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
}
if (this._contextRadioMiniStation) {
this._contextRadioMiniStation.textContent = uncertain
? 'RADIO STATE UNCERTAIN'
: selected?.name || (state.loading ? 'SYNCING DIRECTORY' : 'RADIO READY');
}
if (this._cockpitRadioStation) {
this._cockpitRadioStation.textContent = uncertain
? 'UNCERTAIN'
: selected?.name || (state.loading ? 'SYNCING' : 'READY');
}
if (this._radioPlaybackState) {
const catalogSuffix = state.degraded
? state.stale
? ' · stale/degraded directory'
: ' · degraded directory'
: state.stale
? ' · stale directory'
: '';
const outsideFilter =
selected && state.selectedIndex < 0 ? ' · outside current filter' : '';
const messages = {
stopped: enabled
? 'Ready — playback starts only from your action'
: 'Radio off',
loading: 'Connecting directly to broadcaster…',
buffering: 'Buffering broadcaster stream…',
playing: `Playing ${selected?.name || 'station'}`,
paused: `Paused ${selected?.name || 'station'}`,
error: state.audioError || 'Broadcaster stream unavailable',
};
const voiceSuffix = state.voiceDucked
? ' · muted during voice interaction'
: state.voiceRestoring
? ' · restoring volume after voice'
: '';
const tuningSuffix = state.tuningAwaitingStationId
? state.audioState === 'error'
? ' · static indicates no broadcaster audio'
: ' · tuning static until broadcaster starts'
: '';
const unavailable = state.tuningUnavailableStationId
? 'Station unavailable after directory refresh — choose another channel'
: null;
const lifecycleMessage = transitioning
? lifecycleState === 'enabling'
? 'Radio is enabling…'
: 'Radio is disabling…'
: null;
const uncertainMessage = uncertain
? 'Radio lifecycle is uncertain — use Enable or Disable to reconcile'
: null;
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || 'Ready'}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
this._radioPlaybackState.classList.toggle(
'error',
Boolean(
uncertainMessage ||
unavailable ||
state.error ||
state.audioState === 'error',
),
);
}
if (
!enabled &&
!transitioning &&
!this.actions.preservePanelStateDuringClear() &&
!this._radioPanel.classList.contains('collapsed')
) {
this.actions.setPanelCollapsed('radio-panel', true);
}
this.actions.scheduleLayout();
}
+135
View File
@@ -0,0 +1,135 @@
/** Map an integer tuner slot directly to one available directory station. */
export function radioTunerSlot(value, stationCount) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
if (!count)
return {
slot: 0,
max: 0,
locked: false,
stationIndex: -1,
leftIndex: -1,
rightIndex: -1,
};
const max = Math.max(0, count - 1);
const slot = Math.min(max, Math.max(0, Math.round(Number(value) || 0)));
return {
slot,
max,
locked: true,
stationIndex: slot,
leftIndex: slot,
rightIndex: slot,
};
}
/** Snap a tuner release to the nearest available directory station. */
export function radioTunerCommitSlot(value, stationCount) {
return radioTunerSlot(value, stationCount);
}
/** Map one pointer coordinate to continuous absolute directory progress. */
export function radioTunerPointerPosition(
clientX,
left,
width,
stationCount,
insetPx = 7,
) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
if (!count) return { ratio: 0, coordinate: 0, stationIndex: -1 };
if (count === 1) return { ratio: 0.5, coordinate: 0, stationIndex: 0 };
const inset = Math.max(0, Number(insetPx) || 0);
const usableWidth = Math.max(1, (Number(width) || 0) - inset * 2);
const ratio = Math.min(
1,
Math.max(
0,
((Number(clientX) || 0) - (Number(left) || 0) - inset) / usableWidth,
),
);
const coordinate = ratio * (count - 1);
return {
ratio,
coordinate,
stationIndex: Math.min(
count - 1,
Math.max(0, Math.floor(coordinate + 0.5)),
),
};
}
/** Build a bounded virtual tuner tape around one continuous directory coordinate. */
export function buildRadioTunerTicks(
coordinate,
stationCount,
width,
{
insetPx = 7,
minPitchPx = 14,
speedFactor = 5,
overscan = 2,
labelStep = 6,
} = {},
) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
const dialWidth = Math.max(0, Number(width) || 0);
const inset = Math.max(0, Number(insetPx) || 0);
const usableWidth = Math.max(0, dialWidth - inset * 2);
if (!count) return { ticks: [], needleX: inset, pitchPx: 0, ratio: 0 };
const value = Math.min(count - 1, Math.max(0, Number(coordinate) || 0));
if (count === 1) {
return {
ticks: [
{
stationIndex: 0,
channel: 1,
xPx: inset + usableWidth / 2,
current: true,
label: '01',
},
],
needleX: inset + usableWidth / 2,
pitchPx: Math.max(1, Number(minPitchPx) || 14),
ratio: 0.5,
};
}
const directoryStep = usableWidth / (count - 1);
const pitchPx = Math.max(
Math.max(1, Number(minPitchPx) || 14),
directoryStep * Math.max(1, Number(speedFactor) || 5),
);
const needleX = inset + directoryStep * value;
const overscanPx = pitchPx * Math.max(0, Number(overscan) || 0);
const first = Math.max(
0,
Math.ceil(value + (-overscanPx - needleX) / pitchPx),
);
const last = Math.min(
count - 1,
Math.floor(value + (dialWidth + overscanPx - needleX) / pitchPx),
);
const currentIndex = Math.min(
count - 1,
Math.max(0, Math.floor(value + 0.5)),
);
const majorEvery = Math.max(1, Math.floor(Number(labelStep) || 6));
const labelWidth = Math.max(2, String(count).length);
const ticks = [];
for (let stationIndex = first; stationIndex <= last; stationIndex += 1) {
const channel = stationIndex + 1;
const current = stationIndex === currentIndex;
const labelled =
current ||
stationIndex === 0 ||
stationIndex === count - 1 ||
channel % majorEvery === 0;
ticks.push({
stationIndex,
channel,
xPx: needleX + pitchPx * (stationIndex - value),
current,
label: labelled ? String(channel).padStart(labelWidth, '0') : '',
});
}
return { ticks, needleX, pitchPx, ratio: value / (count - 1) };
}