Merge pr/panel-resize into pr/mapillary-layer

Bring in portable, resizable rail panels. The Street Level rail rules get
the same :not(.panel-floating) guard as CCTV's so the panel can float too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-09-25 19:49:01 +01:00
commit 768f256720
19 files changed
+1042 -35

No files matched your search

+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
- Rail panels can opt into a portable mode: dragging the CCTV panel by its
header lifts it out of the right rail into a floating window that resizes
from any edge or corner, remembers its position and size, and snaps back on
a header double-click.
- Street Level: a provider-neutral street-level imagery layer modelled on the iD editor's photo overlay. One panel in the right rail carries a chip per imagery provider, shared 360°/flat and captured-since filters, coverage drawn on the globe from orbit down to a single street, image cones per sequence, and one embedded viewer whose adapter follows the provider of the open image; each active provider credits its imagery on the globe, and share links carry the provider switches and the filter. Mapillary is the first provider (free client token; CC BY-SA 4.0 imagery); Google Street View, KartaView and Panoramax are next. - Street Level: a provider-neutral street-level imagery layer modelled on the iD editor's photo overlay. One panel in the right rail carries a chip per imagery provider, shared 360°/flat and captured-since filters, coverage drawn on the globe from orbit down to a single street, image cones per sequence, and one embedded viewer whose adapter follows the provider of the open image; each active provider credits its imagery on the globe, and share links carry the provider switches and the filter. Mapillary is the first provider (free client token; CC BY-SA 4.0 imagery); Google Street View, KartaView and Panoramax are next.
- CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived
+2 -2
View File
@@ -4094,8 +4094,8 @@ easier to meet (detection is now on more often), but does not create it.
samples and constant elevation during E/N drag; one shared-floor resolution on samples and constant elevation during E/N drag; one shared-floor resolution on
release; late one-shot shared-cell work is permitted during viewshed idle. The release; late one-shot shared-cell work is permitted during viewshed idle. The
A+B harness intentionally excludes citywide LOD assertions. A+B harness intentionally excludes citywide LOD assertions.
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`, but the current rails lay panels out adaptively and write none. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default). - Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`. The rails lay docked panels out adaptively and write nothing for them; a portable panel (currently CCTV) persists `{ left, top, width, height, floating: true }` under that key once a header drag lifts it out of the rail, and a header double-click removes the record again. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default).
- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`. - Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them unless they describe a portable panel's floating window; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`.
- Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker. - Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker.
- Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`. - Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`.
- Annotation resolver behavior is pinned by `src/annotations/annotationResolver.test.mjs`; re-run that suite before changing place-resolution scoring. - Annotation resolver behavior is pinned by `src/annotations/annotationResolver.test.mjs`; re-run that suite before changing place-resolution scoring.
+4 -3
View File
@@ -57,9 +57,10 @@ Related keys (current versions):
- Panel collapsed state: `godsEyeView.v6.panelCollapsed.<panel-id>` — `'0'` open, - Panel collapsed state: `godsEyeView.v6.panelCollapsed.<panel-id>` — `'0'` open,
`'1'` closed, absent means the panel's own default. A view opened from a share `'1'` closed, absent means the panel's own default. A view opened from a share
link ignores the stored value entirely. link ignores the stored value entirely.
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — the versioned name for a - Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — written only for a
stored position. The current layout writes none, so deleting one changes portable panel (currently CCTV) lifted out of its rail, as
nothing. `{ left, top, width, height, floating: true }`. Deleting it, or double-clicking
the panel header, returns that panel to the rail; docked panels store nothing.
- CCTV calibration: `godsEyeView.cctv.calibration.v2` - CCTV calibration: `godsEyeView.cctv.calibration.v2`
--- ---
+1
View File
@@ -42,6 +42,7 @@
"opensky:import": "./scripts/opensky-import-client.sh", "opensky:import": "./scripts/opensky-import-client.sh",
"preview": "vite preview", "preview": "vite preview",
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs", "qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
"qa:panel-resize": "node scripts/qa-panel-resize.mjs",
"qa:street-level": "node scripts/qa-street-level.mjs", "qa:street-level": "node scripts/qa-street-level.mjs",
"qa:transit": "node scripts/qa-transit.mjs", "qa:transit": "node scripts/qa-transit.mjs",
"test": "node scripts/run-unit-tests.mjs", "test": "node scripts/run-unit-tests.mjs",
+3
View File
@@ -26,6 +26,8 @@
"src/main.js", "src/main.js",
"docs/APPLICATION.md", "docs/APPLICATION.md",
"scripts/qa-application.mjs", "scripts/qa-application.mjs",
"scripts/qa-panel-resize.mjs",
"src/tooling/panelResizeQa.test.mjs",
"src/app/application.js", "src/app/application.js",
"src/app/application.test.mjs", "src/app/application.test.mjs",
"src/app/mapLifecycle.test.mjs", "src/app/mapLifecycle.test.mjs",
@@ -152,6 +154,7 @@
"src/ui/panelRails.js", "src/ui/panelRails.js",
"src/ui/leftPanelRail.js", "src/ui/leftPanelRail.js",
"src/ui/rightPanelRail.js", "src/ui/rightPanelRail.js",
"src/ui/panelResize.test.mjs",
"src/ui/panelMeasurement.js", "src/ui/panelMeasurement.js",
"src/ui/panelRailGeometry.js", "src/ui/panelRailGeometry.js",
"src/ui/panelRails.test.mjs", "src/ui/panelRails.test.mjs",
+3
View File
@@ -598,6 +598,7 @@
"src/ui/panelLayoutController.js", "src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js", "src/ui/panelMeasurement.js",
"src/ui/panelPositionControls.js", "src/ui/panelPositionControls.js",
"src/ui/panelResize.js",
"src/ui/panelRailGeometry.js", "src/ui/panelRailGeometry.js",
"src/ui/panelRails.js", "src/ui/panelRails.js",
"src/ui/radio.js", "src/ui/radio.js",
@@ -1820,6 +1821,7 @@
"src/ui/panelLayoutController.js", "src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js", "src/ui/panelMeasurement.js",
"src/ui/panelPositionControls.js", "src/ui/panelPositionControls.js",
"src/ui/panelResize.js",
"src/ui/panelRailGeometry.js", "src/ui/panelRailGeometry.js",
"src/ui/panelRails.js", "src/ui/panelRails.js",
"src/ui/radio.js", "src/ui/radio.js",
@@ -2652,6 +2654,7 @@
"src/ui/panelLayoutController.js", "src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js", "src/ui/panelMeasurement.js",
"src/ui/panelPositionControls.js", "src/ui/panelPositionControls.js",
"src/ui/panelResize.js",
"src/ui/panelRailGeometry.js", "src/ui/panelRailGeometry.js",
"src/ui/panelRails.js", "src/ui/panelRails.js",
"src/ui/rightPanelRail.js", "src/ui/rightPanelRail.js",
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env node
/**
* Prove the portable CCTV panel in a real browser: a header drag lifts it out
* of the right rail, every resize handle moves only its own edges, minimum
* sizes hold, the window survives a reload and a header double-click snaps
* it back and forgets the stored position.
*/
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js';
export const PANEL_ID = 'cctv-panel';
export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`;
export const MIN_SIZE = { width: 300, height: 160 };
const VIEWPORT = { width: 1400, height: 900 };
const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' };
/** Box edges a resize in `dir` must leave exactly where they were. */
export function pinnedEdges(dir) {
return Object.entries(EDGE_BY_SIDE)
.filter(([side]) => !dir.includes(side))
.map(([, edge]) => edge);
}
/** Pointer travel that grows a panel by `amount` on every edge named in `dir`. */
export function growthDelta(dir, amount) {
return {
dx: dir.includes('e') ? amount : dir.includes('w') ? -amount : 0,
dy: dir.includes('s') ? amount : dir.includes('n') ? -amount : 0,
};
}
/**
* Viewport point inside the handle for `dir`, given the panel's client rect.
* Edge strips straddle the border by 3px; corners and the grip sit inside.
*/
export function handlePoint(rect, dir) {
const inset = dir.length === 2 ? 4 : 1;
const x = dir.includes('w')
? rect.left + inset
: dir.includes('e')
? rect.right - inset
: rect.left + rect.width / 2;
const y = dir.includes('n')
? rect.top + inset
: dir.includes('s')
? rect.bottom - inset
: rect.top + rect.height / 2;
return { x, y };
}
/** Edges of `after` that moved although a resize in `dir` should have pinned them. */
export function driftedEdges(before, after, dir, tolerance = 1.5) {
const edges = (box) => ({
left: box.left,
top: box.top,
right: box.left + box.width,
bottom: box.top + box.height,
});
const a = edges(before);
const b = edges(after);
return pinnedEdges(dir).filter(
(edge) => Math.abs(a[edge] - b[edge]) > tolerance,
);
}
function near(actual, expected, tolerance, label) {
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected} ± ${tolerance}, got ${actual}`,
);
}
async function main() {
const { default: puppeteer } = await import('puppeteer');
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
(await puppeteer.executablePath()),
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
try {
const page = await browser.newPage();
await page.setViewport(VIEWPORT);
const errors = [];
page.on('pageerror', (error) => errors.push(error.stack || error.message));
const boot = async () => {
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.viewer, {
timeout: 90_000,
});
await page.waitForSelector(`#${PANEL_ID} .panel-header`);
// The first-run dialog sits over the globe until a choice is made.
await page.evaluate(() => {
document.querySelector('.first-run-explore')?.click();
});
await page.keyboard.press('Escape');
await page.evaluate(() => {
document.getElementById('first-run-launcher')?.remove();
for (const node of document.querySelectorAll('[class*=first-run]'))
node.remove();
});
await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2);
await new Promise((resolve) => setTimeout(resolve, 400));
};
const readPanel = () =>
page.evaluate((id) => {
const panel = document.getElementById(id);
const rect = panel.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
floating: panel.classList.contains('panel-floating'),
collapsed: panel.classList.contains('collapsed'),
inRail: panel.parentElement?.id === 'right-context-rail',
allocated: panel.style.getPropertyValue(
'--right-panel-allocated-height',
),
inlineWidth: panel.style.width,
inlineHeight: panel.style.height,
stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`),
};
}, PANEL_ID);
const headerPoint = async () => {
const rect = await page.evaluate((id) => {
const title = document.querySelector(
`#${id} .panel-header .panel-title`,
);
const r = title.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}, PANEL_ID);
return rect;
};
const drag = async (from, dx, dy) => {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps: 6 });
await page.mouse.move(from.x + dx, from.y + dy, { steps: 6 });
await page.mouse.up();
await new Promise((resolve) => setTimeout(resolve, 120));
};
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
await page.evaluate((key) => {
localStorage.removeItem(key);
localStorage.removeItem('godsEyeView.v8.panelFloatHintShown');
}, STORAGE_KEY);
await boot();
// Expand CCTV so the panel has a body worth resizing.
await page.evaluate((id) => {
const panel = document.getElementById(id);
if (panel.classList.contains('collapsed'))
document
.querySelector(`.panel-collapse-btn[data-collapse-target="${id}"]`)
.click();
}, PANEL_ID);
await page.waitForFunction(
(id) => !document.getElementById(id).classList.contains('collapsed'),
{},
PANEL_ID,
);
await new Promise((resolve) => setTimeout(resolve, 600));
const docked = await readPanel();
assert.equal(docked.floating, false);
assert.equal(docked.inRail, true);
// Lift out: drag the header 200px left and 100px up.
await drag(await headerPoint(), -200, -100);
const lifted = await readPanel();
assert.equal(lifted.floating, true, 'a header drag lifts the panel out');
assert.equal(
lifted.collapsed,
false,
'the drag-ending click must not collapse',
);
near(lifted.left, Math.max(6, docked.left - 200), 2, 'lifted left');
near(lifted.top, Math.max(6, docked.top - 100), 2, 'lifted top');
near(lifted.width, docked.width, 2, 'lifted width');
await new Promise((resolve) => setTimeout(resolve, 700));
const afterLayout = await readPanel();
assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it');
const railExcludes = await page.evaluate((id) => {
const rail = document.getElementById('right-context-rail');
const counted = [...rail.children].filter((panel) =>
panel.matches('[data-panel-id]:not(.panel-floating):not(.collapsed)'),
);
return {
expandedCount: rail.dataset.expandedCount,
counted: counted.length,
includesPanel: counted.some((panel) => panel.id === id),
};
}, PANEL_ID);
assert.equal(railExcludes.includesPanel, false);
if (railExcludes.expandedCount !== undefined)
assert.equal(Number(railExcludes.expandedCount), railExcludes.counted);
const storedAfterLift = JSON.parse(afterLayout.stored);
assert.equal(storedAfterLift.floating, true);
assert.equal(
'height' in storedAfterLift,
false,
'a drag alone must not freeze the measured height',
);
console.log('PASS: header drag lifts CCTV out of the rail');
// Every handle moves only its own edges.
const limits = {
minWidth: MIN_SIZE.width,
minHeight: MIN_SIZE.height,
viewportWidth: VIEWPORT.width,
viewportHeight: VIEWPORT.height,
};
for (const dir of RESIZE_DIRECTIONS) {
const before = await readPanel();
const { dx, dy } = growthDelta(dir, 40);
await drag(handlePoint(before, dir), dx, dy);
const after = await readPanel();
const expected = resizeBox(before, dir, dx, dy, limits);
for (const key of ['left', 'top', 'width', 'height'])
near(after[key], expected[key], 2, `${dir} ${key}`);
assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`);
}
console.log('PASS: eight resize handles keep the opposite edge pinned');
// Minimum size stops the moving edge.
let before = await readPanel();
await drag(handlePoint(before, 'se'), -2000, -2000);
let after = await readPanel();
near(after.width, MIN_SIZE.width, 2, 'min width (se)');
near(after.height, MIN_SIZE.height, 2, 'min height (se)');
assert.deepEqual(driftedEdges(before, after, 'se', 2), []);
before = after;
await drag(handlePoint(before, 'nw'), 2000, 2000);
after = await readPanel();
near(after.width, MIN_SIZE.width, 2, 'min width (nw)');
near(after.height, MIN_SIZE.height, 2, 'min height (nw)');
assert.deepEqual(driftedEdges(before, after, 'nw', 2), []);
console.log('PASS: minimum size holds from both corners');
// Reload restores the window.
const saved = await readPanel();
const record = JSON.parse(saved.stored);
assert.equal(record.floating, true);
await boot();
const restored = await readPanel();
assert.equal(restored.floating, true, 'floating survives a reload');
for (const key of ['left', 'top', 'width', 'height'])
near(restored[key], record[key], 2, `restored ${key}`);
console.log('PASS: reload restores position and size');
// Double-click the header to snap back.
const point = await headerPoint();
await page.mouse.click(point.x, point.y, { clickCount: 1 });
await page.mouse.click(point.x, point.y, { clickCount: 2 });
await new Promise((resolve) => setTimeout(resolve, 300));
const snapped = await readPanel();
assert.equal(snapped.floating, false, 'double-click snaps back');
assert.equal(snapped.inRail, true);
assert.equal(snapped.inlineWidth, '');
assert.equal(snapped.inlineHeight, '');
assert.equal(snapped.stored, null, 'the storage key is gone');
console.log('PASS: header double-click returns the panel to the rail');
assert.deepEqual(errors, []);
} finally {
await browser.close();
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});
}
+5 -1
View File
@@ -45,7 +45,11 @@ test('desktop Display participates in Tactical exclusivity without changing mobi
/panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/, /panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/,
); );
assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/);
assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); // A panel floating out of the rail (portable mode) is exempt from exclusivity.
assert.match(
css,
/#right-context-rail\.layout-exclusive\s*> \[data-panel-id\]\.collapsed:not\(\.panel-floating\) \{/,
);
}); });
test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => { test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => {
+57
View File
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
driftedEdges,
growthDelta,
handlePoint,
MIN_SIZE,
pinnedEdges,
STORAGE_KEY,
} from '../../scripts/qa-panel-resize.mjs';
import { RESIZE_DIRECTIONS } from '../ui/panelResize.js';
test('the harness targets the key and minimums the controls use', () => {
assert.equal(STORAGE_KEY, 'godsEyeView.v8.panelPos.cctv-panel');
assert.deepEqual(MIN_SIZE, { width: 300, height: 160 });
});
test('pinned edges are exactly the sides a direction does not name', () => {
assert.deepEqual(pinnedEdges('n'), ['bottom', 'right', 'left']);
assert.deepEqual(pinnedEdges('se'), ['top', 'left']);
assert.deepEqual(pinnedEdges('nw'), ['bottom', 'right']);
for (const dir of RESIZE_DIRECTIONS)
assert.equal(pinnedEdges(dir).length, 4 - dir.length);
});
test('growth deltas push each named edge outward', () => {
assert.deepEqual(growthDelta('n', 40), { dx: 0, dy: -40 });
assert.deepEqual(growthDelta('e', 40), { dx: 40, dy: 0 });
assert.deepEqual(growthDelta('sw', 40), { dx: -40, dy: 40 });
assert.deepEqual(growthDelta('se', 40), { dx: 40, dy: 40 });
});
test('handle points land inside the strip or corner for every direction', () => {
const rect = { left: 100, top: 200, width: 400, height: 300 };
rect.right = rect.left + rect.width;
rect.bottom = rect.top + rect.height;
assert.deepEqual(handlePoint(rect, 'n'), { x: 300, y: 201 });
assert.deepEqual(handlePoint(rect, 'w'), { x: 101, y: 350 });
assert.deepEqual(handlePoint(rect, 'se'), { x: 496, y: 496 });
assert.deepEqual(handlePoint(rect, 'nw'), { x: 104, y: 204 });
for (const dir of RESIZE_DIRECTIONS) {
const { x, y } = handlePoint(rect, dir);
assert.ok(x >= rect.left && x <= rect.right, `${dir} x inside`);
assert.ok(y >= rect.top && y <= rect.bottom, `${dir} y inside`);
}
});
test('drift detection reports only pinned edges that moved', () => {
const before = { left: 100, top: 100, width: 400, height: 300 };
const grewEast = { left: 100, top: 100, width: 440, height: 300 };
assert.deepEqual(driftedEdges(before, grewEast, 'e'), []);
const slidWest = { left: 60, top: 100, width: 440, height: 300 };
assert.deepEqual(driftedEdges(before, slidWest, 'e'), ['left']);
assert.deepEqual(driftedEdges(before, slidWest, 'w'), []);
const wobble = { left: 101, top: 100, width: 439, height: 300 };
assert.deepEqual(driftedEdges(before, wobble, 'e'), []);
});
+10
View File
@@ -105,6 +105,7 @@ export class StyleManager extends ShellFacade {
_setRadioDisclosure: (...args) => this._setRadioDisclosure(...args), _setRadioDisclosure: (...args) => this._setRadioDisclosure(...args),
_syncCctvPanelViewport: (...args) => _syncCctvPanelViewport: (...args) =>
this._syncCctvPanelViewport(...args), this._syncCctvPanelViewport(...args),
_onPanelResized: (...args) => this._onPanelResized(...args),
_syncContextRadioLauncherState: (...args) => _syncContextRadioLauncherState: (...args) =>
this._syncContextRadioLauncherState(...args), this._syncContextRadioLauncherState(...args),
_showToast: (...args) => this._showToast(...args), _showToast: (...args) => this._showToast(...args),
@@ -1495,6 +1496,15 @@ export class StyleManager extends ShellFacade {
this._initCockpitDisplayPortal(); this._initCockpitDisplayPortal();
} }
/**
* A portable panel finished a resize or snapped back to its rail. Nothing
* in the shell depends on it yet; a panel with a viewport of its own can
* refit here.
* @param {string} _panelId
* @returns {void}
*/
_onPanelResized(_panelId) {}
/** /**
* Recalculates the CCTV panel max-height based on its current top position * Recalculates the CCTV panel max-height based on its current top position
* and the window height, enabling internal scroll without viewport overflow. * and the window height, enabling internal scroll without viewport overflow.
+2
View File
@@ -63,6 +63,7 @@ export class PanelChrome {
layoutRightPanels: () => this._layoutRightPanels(), layoutRightPanels: () => this._layoutRightPanels(),
syncCctvPanelViewport: () => this._syncCctvPanelViewport(), syncCctvPanelViewport: () => this._syncCctvPanelViewport(),
showToast: (message) => this._showToast(message), showToast: (message) => this._showToast(message),
onPanelResized: (panelId) => this._onPanelResized(panelId),
}); });
this._panelLayout = new PanelLayoutController({ this._panelLayout = new PanelLayoutController({
readHud: () => ({ readHud: () => ({
@@ -146,6 +147,7 @@ export class PanelChrome {
this._initCommandDockPins(); this._initCommandDockPins();
this._initCommandDockTrayMetrics(); this._initCommandDockTrayMetrics();
this._maybeNotifyLayoutReset(); this._maybeNotifyLayoutReset();
this._panelPosition._initPanelDrag();
} }
_collapsePanelOnEscape(event, panelId) { _collapsePanelOnEscape(event, panelId) {
+374 -10
View File
@@ -1,4 +1,6 @@
/** Own panel position preferences, viewport clamping and drag listeners. */ /** Own panel position preferences, viewport clamping and drag listeners. */
import { RESIZE_DIRECTIONS, resizeBox } from './panelResize.js';
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ /** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
const PANEL_LAYOUT_STORAGE_VERSION = 'v6'; const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
/** /**
@@ -10,41 +12,94 @@ const PANEL_POSITION_STORAGE_VERSION = 'v8';
/** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */ /** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */
const PANEL_Z_BASE = 100; const PANEL_Z_BASE = 100;
const PANEL_Z_MAX = 139; const PANEL_Z_MAX = 139;
/** Pointer travel before a header press becomes a drag that lifts a portable panel out. */
const DRAG_THRESHOLD_PX = 4;
/** Viewport margin every positioned panel keeps clear. */
const VIEWPORT_MARGIN_PX = 6;
/** One-time hint shown the first time a portable panel leaves its rail. */
const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`;
const PANEL_FLOAT_HINT = 'Double-click the header to snap the panel back';
/** Header children whose own interaction wins over a drag or a snap-back. */
const INTERACTIVE_SELECTOR =
'input, select, option, textarea, button, a, [role="button"]';
/** Inline geometry a snap-back clears so the rail owns placement again. */
const FLOATING_STYLE_PROPERTIES = [
'left',
'top',
'right',
'bottom',
'width',
'height',
];
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
export class PanelPositionControls { export class PanelPositionControls {
/**
* @param {object} options
* @param {Function} options.syncPanelCollapseButton
* @param {Function} options.layoutRightPanels
* @param {Function} options.syncCctvPanelViewport
* @param {Function} options.showToast
* @param {(panelId: string) => void} [options.onPanelResized] Fires after a
* portable panel's resize gesture ends or the panel snaps back to its rail.
*/
constructor({ constructor({
syncPanelCollapseButton, syncPanelCollapseButton,
layoutRightPanels, layoutRightPanels,
syncCctvPanelViewport, syncCctvPanelViewport,
showToast, showToast,
onPanelResized = null,
}) { }) {
this._syncPanelCollapseButton = syncPanelCollapseButton; this._syncPanelCollapseButton = syncPanelCollapseButton;
this._layoutRightPanels = layoutRightPanels; this._layoutRightPanels = layoutRightPanels;
this._syncCctvPanelViewport = syncCctvPanelViewport; this._syncCctvPanelViewport = syncCctvPanelViewport;
this._showToast = showToast; this._showToast = showToast;
this._onPanelResized = onPanelResized;
this._ppToggles = document.getElementById('pp-toggles'); this._ppToggles = document.getElementById('pp-toggles');
this._panelZCounter = PANEL_Z_BASE + 10; this._panelZCounter = PANEL_Z_BASE + 10;
this._draggableResizeObserver = null; this._draggableResizeObserver = null;
this._cancelDrag = null; this._cancelDrag = null;
/** Portable panels by id: `{ panel, min: { width, height } }`. */
this._portablePanels = new Map();
this._resizeHandles = [];
this._dragInitialized = false;
this.removers = []; this.removers = [];
this.destroyed = false; this.destroyed = false;
} }
listen(target, type, callback) { listen(target, type, callback, options) {
if (this.destroyed || !target) return; if (this.destroyed || !target) return;
const listener = (event) => { const listener = (event) => {
if (!this.destroyed) callback(event); if (!this.destroyed) callback(event);
}; };
target.addEventListener(type, listener); target.addEventListener(type, listener, options);
this.removers.push(() => target.removeEventListener(type, listener)); this.removers.push(() =>
target.removeEventListener(type, listener, options),
);
} }
_reclampDraggablePanels() { _reclampDraggablePanels() {
if (this.destroyed) return; if (this.destroyed) return;
const el = this._ppToggles; const el = this._ppToggles;
if (!el || !el.style.top || el.style.top === 'auto') return; if (el && el.style.top && el.style.top !== 'auto') {
const top = parseInt(el.style.top, 10); const top = parseInt(el.style.top, 10);
if (!Number.isFinite(top)) return; if (Number.isFinite(top)) {
el.style.top = `${this._clampToViewport(0, top, el).top}px`; el.style.top = `${this._clampToViewport(0, top, el).top}px`;
this._pinPanelToRight(el); this._pinPanelToRight(el);
} }
}
// Floating panels only move back on-screen; their size is the user's.
for (const { panel } of this._portablePanels.values()) {
if (!panel.classList.contains('panel-floating')) continue;
const left = parseInt(panel.style.left, 10);
const top = parseInt(panel.style.top, 10);
if (!Number.isFinite(left) || !Number.isFinite(top)) continue;
const next = this._clampToViewport(left, top, panel);
panel.style.left = `${next.left}px`;
panel.style.top = `${next.top}px`;
}
}
_maybeNotifyLayoutReset() { _maybeNotifyLayoutReset() {
try { try {
@@ -65,19 +120,41 @@ export class PanelPositionControls {
} }
_initPanelDrag() { _initPanelDrag() {
if (this.destroyed) return; if (this.destroyed || this._dragInitialized) return;
this._dragInitialized = true;
const cctvPanel = document.getElementById('cctv-panel');
// A `portable` spec lifts out of its rail on a header drag, resizes from
// every edge and remembers its window; the others only reposition in
// place. Another panel opts in by adding a spec here.
const dragSpecs = [ const dragSpecs = [
{ {
id: 'pp-toggles', id: 'pp-toggles',
panel: this._ppToggles, panel: this._ppToggles,
handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'), handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'),
}, },
{
id: 'cctv-panel',
panel: cctvPanel,
handle: cctvPanel?.querySelector('.panel-header'),
portable: true,
min: { width: 300, height: 160 },
},
].filter(Boolean); ].filter(Boolean);
for (const spec of dragSpecs) { for (const spec of dragSpecs) {
if (!spec.panel || !spec.handle) continue; if (!spec.panel || !spec.handle) continue;
if (spec.portable) {
this._portablePanels.set(spec.id, {
panel: spec.panel,
min: { width: 300, height: 160, ...spec.min },
});
spec.panel.classList.add('panel-portable');
}
this._restorePanelPosition(spec.id, spec.panel); this._restorePanelPosition(spec.id, spec.panel);
this._makePanelDraggable(spec.id, spec.panel, spec.handle); this._makePanelDraggable(spec.id, spec.panel, spec.handle);
if (spec.portable) {
this._makePanelResizable(spec.id, spec.panel, spec.min);
}
} }
// Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its // Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its
// full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and // full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and
@@ -88,6 +165,9 @@ export class PanelPositionControls {
); );
this._draggableResizeObserver.observe(this._ppToggles); this._draggableResizeObserver.observe(this._ppToggles);
} }
if (this._portablePanels.size) {
this.listen(window, 'resize', () => this._reclampDraggablePanels());
}
} }
_panelStorageKey(panelId) { _panelStorageKey(panelId) {
@@ -159,6 +239,27 @@ export class PanelPositionControls {
const pos = JSON.parse(raw); const pos = JSON.parse(raw);
if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number') if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number')
return; return;
// A floating record only means something to a panel that can float; a
// portable panel in turn only ever stores floating records.
const portable = this._portablePanels.get(panelId);
if (Boolean(pos.floating) !== Boolean(portable)) return;
if (portable) {
panelEl.classList.add('panel-floating', 'panel-draggable');
if (Number.isFinite(pos.width)) {
panelEl.style.width = `${clamp(
Math.round(pos.width),
portable.min.width,
Math.max(portable.min.width, window.innerWidth - 12),
)}px`;
}
if (Number.isFinite(pos.height)) {
panelEl.style.height = `${clamp(
Math.round(pos.height),
portable.min.height,
Math.max(portable.min.height, window.innerHeight - 12),
)}px`;
}
}
// Clamp to the viewport: a position saved at one window size would otherwise land off-screen at // Clamp to the viewport: a position saved at one window size would otherwise land off-screen at
// another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too. // another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too.
const { left, top } = this._clampToViewport( const { left, top } = this._clampToViewport(
@@ -190,13 +291,21 @@ export class PanelPositionControls {
_savePanelPosition(panelId, panelEl) { _savePanelPosition(panelId, panelEl) {
const rect = panelEl.getBoundingClientRect(); const rect = panelEl.getBoundingClientRect();
const record = {
left: Math.round(rect.left),
top: Math.round(rect.top),
};
if (this._portablePanels.has(panelId)) {
// Only an explicitly set size is remembered: freezing a collapsed
// panel's measured height would stop it from ever expanding again.
if (panelEl.style.width) record.width = Math.round(rect.width);
if (panelEl.style.height) record.height = Math.round(rect.height);
record.floating = true;
}
try { try {
localStorage.setItem( localStorage.setItem(
this._panelStorageKey(panelId), this._panelStorageKey(panelId),
JSON.stringify({ JSON.stringify(record),
left: Math.round(rect.left),
top: Math.round(rect.top),
}),
); );
} catch { } catch {
// storage unavailable // storage unavailable
@@ -219,11 +328,78 @@ export class PanelPositionControls {
panelEl.style.zIndex = String(this._panelZCounter); panelEl.style.zIndex = String(this._panelZCounter);
} }
_maybeShowFloatHint() {
try {
if (localStorage.getItem(PANEL_FLOAT_HINT_STORAGE_KEY)) return;
localStorage.setItem(PANEL_FLOAT_HINT_STORAGE_KEY, '1');
} catch {
// storage unavailable: still worth saying once this session
if (this._floatHintShown) return;
this._floatHintShown = true;
}
this._showToast(PANEL_FLOAT_HINT);
}
/**
* Switch a portable panel from rail flow to a fixed window at the place it
* currently occupies. Width is frozen so a `width: 100%` rail panel keeps
* its size once fixed-positioned; height stays natural until a resize.
*/
_liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) {
if (panelEl.classList.contains('panel-floating')) return false;
panelEl.style.left = `${Math.round(rect.left)}px`;
panelEl.style.top = `${Math.round(rect.top)}px`;
panelEl.style.right = 'auto';
panelEl.style.bottom = 'auto';
if (!panelEl.classList.contains('collapsed')) {
panelEl.style.width = `${Math.round(rect.width)}px`;
}
// Rail bookkeeping no longer applies to a window the rail does not allocate.
panelEl.style.removeProperty('--right-panel-allocated-height');
panelEl.removeAttribute('aria-hidden');
panelEl.classList.add('panel-floating', 'panel-draggable');
this._promotePanelZ(panelEl);
this._layoutRightPanels();
this._maybeShowFloatHint();
return true;
}
/** Snap a floating panel back into its rail and forget its window. */
_resetPanelPosition(panelId) {
const portable = this._portablePanels.get(panelId);
if (!portable) return;
const panelEl = portable.panel;
this._cancelDrag?.();
panelEl.classList.remove(
'panel-floating',
'panel-draggable',
'panel-dragging',
'panel-resizing',
);
for (const property of FLOATING_STYLE_PROPERTIES) {
panelEl.style.removeProperty(property);
}
try {
localStorage.removeItem(this._panelStorageKey(panelId));
} catch {
// storage unavailable
}
this._layoutRightPanels();
if (panelId === 'cctv-panel') {
this._syncCctvPanelViewport();
}
this._onPanelResized?.(panelId);
}
_makePanelDraggable(panelId, panelEl, handleEl) { _makePanelDraggable(panelId, panelEl, handleEl) {
// Z-order promotion: bring clicked panel to front of the stacking context // Z-order promotion: bring clicked panel to front of the stacking context
this.listen(panelEl, 'pointerdown', () => { this.listen(panelEl, 'pointerdown', () => {
this._promotePanelZ(panelEl); this._promotePanelZ(panelEl);
}); });
if (this._portablePanels.has(panelId)) {
this._makePortablePanelDraggable(panelId, panelEl, handleEl);
return;
}
this.listen(handleEl, 'pointerdown', (event) => { this.listen(handleEl, 'pointerdown', (event) => {
if (event.button !== 0) return; if (event.button !== 0) return;
@@ -291,11 +467,199 @@ export class PanelPositionControls {
window.addEventListener('pointercancel', onUp); window.addEventListener('pointercancel', onUp);
}); });
} }
/**
* Portable drag: a press on the header only becomes a drag after a few
* pixels of travel, at which point the panel lifts out of its rail. The
* collapse button still toggles on a plain click, and the click that ends
* a real drag is swallowed so it cannot toggle by accident. Double-clicking
* the header snaps the panel back.
*/
_makePortablePanelDraggable(panelId, panelEl, handleEl) {
let swallowNextClick = false;
this.listen(
handleEl,
'click',
(event) => {
if (!swallowNextClick) return;
swallowNextClick = false;
event.stopImmediatePropagation();
event.preventDefault();
},
{ capture: true },
);
this.listen(handleEl, 'dblclick', (event) => {
if (event.target.closest?.(INTERACTIVE_SELECTOR)) return;
if (!panelEl.classList.contains('panel-floating')) return;
event.preventDefault();
this._resetPanelPosition(panelId);
});
this.listen(handleEl, 'pointerdown', (event) => {
if (event.button !== 0) return;
const interactive = event.target.closest?.(INTERACTIVE_SELECTOR);
if (interactive && !interactive.matches('.panel-collapse-btn')) return;
this._cancelDrag?.();
// A header press must not start a text selection across the page.
event.preventDefault();
const startX = event.clientX;
const startY = event.clientY;
let dragging = false;
let offsetX = 0;
let offsetY = 0;
let width = 0;
let height = 0;
const onMove = (moveEvent) => {
if (!dragging) {
if (
Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) <
DRAG_THRESHOLD_PX
)
return;
dragging = true;
const rect = panelEl.getBoundingClientRect();
offsetX = startX - rect.left;
offsetY = startY - rect.top;
width = rect.width;
height = rect.height;
this._liftPanelOut(panelId, panelEl, rect);
panelEl.classList.add('panel-dragging');
this._promotePanelZ(panelEl);
}
moveEvent.preventDefault();
const maxLeft = Math.max(
VIEWPORT_MARGIN_PX,
window.innerWidth - width - VIEWPORT_MARGIN_PX,
);
const maxTop = Math.max(
VIEWPORT_MARGIN_PX,
window.innerHeight - height - VIEWPORT_MARGIN_PX,
);
panelEl.style.left = `${clamp(moveEvent.clientX - offsetX, VIEWPORT_MARGIN_PX, maxLeft)}px`;
panelEl.style.top = `${clamp(moveEvent.clientY - offsetY, VIEWPORT_MARGIN_PX, maxTop)}px`;
};
const cancel = () => {
panelEl.classList.remove('panel-dragging');
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
this._cancelDrag = null;
};
const onUp = () => {
cancel();
if (!dragging) return;
swallowNextClick = true;
setTimeout(() => {
swallowNextClick = false;
}, 0);
this._savePanelPosition(panelId, panelEl);
if (panelId === 'cctv-panel') {
this._syncCctvPanelViewport();
}
};
this._cancelDrag = cancel;
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
});
}
/**
* Append a resize handle for every edge and corner. Seven are invisible
* strips; the bottom-right corner shows a grip. Resizing a docked panel
* lifts it out first, so the handles work in both states.
*/
_makePanelResizable(panelId, panelEl, min = {}) {
const limits = { width: 300, height: 160, ...min };
for (const dir of RESIZE_DIRECTIONS) {
const handle = document.createElement('div');
handle.className =
dir === 'se' ? 'panel-resize-grip' : 'panel-resize-edge';
handle.dataset.dir = dir;
handle.setAttribute('aria-hidden', 'true');
if (dir === 'se') {
handle.title = 'Drag to resize · double-click the header to snap back';
}
panelEl.appendChild(handle);
this._resizeHandles.push(handle);
this.listen(handle, 'pointerdown', (event) =>
this._startPanelResize(event, panelId, panelEl, dir, limits),
);
}
}
_startPanelResize(event, panelId, panelEl, dir, limits) {
if (event.button !== 0) return;
event.preventDefault();
this._cancelDrag?.();
const rect = panelEl.getBoundingClientRect();
this._liftPanelOut(panelId, panelEl, rect);
const startBox = {
left: Math.round(rect.left),
top: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
const startX = event.clientX;
const startY = event.clientY;
const apply = ({ left, top, width, height }) => {
panelEl.style.left = `${left}px`;
panelEl.style.top = `${top}px`;
panelEl.style.width = `${width}px`;
panelEl.style.height = `${height}px`;
};
panelEl.classList.add('panel-resizing');
this._promotePanelZ(panelEl);
apply(startBox);
const onMove = (moveEvent) => {
moveEvent.preventDefault();
apply(
resizeBox(
startBox,
dir,
moveEvent.clientX - startX,
moveEvent.clientY - startY,
{
minWidth: limits.width,
minHeight: limits.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
},
),
);
};
const cancel = () => {
panelEl.classList.remove('panel-resizing');
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
this._cancelDrag = null;
};
const onUp = () => {
cancel();
this._savePanelPosition(panelId, panelEl);
if (panelId === 'cctv-panel') {
this._syncCctvPanelViewport();
}
this._onPanelResized?.(panelId);
};
this._cancelDrag = cancel;
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
}
destroy() { destroy() {
if (this.destroyed) return; if (this.destroyed) return;
this.destroyed = true; this.destroyed = true;
this._cancelDrag?.(); this._cancelDrag?.();
for (const remove of this.removers.splice(0)) remove(); for (const remove of this.removers.splice(0)) remove();
for (const handle of this._resizeHandles.splice(0)) handle.remove();
this._draggableResizeObserver?.disconnect(); this._draggableResizeObserver?.disconnect();
this._draggableResizeObserver = null; this._draggableResizeObserver = null;
} }
+26 -1
View File
@@ -113,7 +113,10 @@ function element(
getBoundingClientRect() { getBoundingClientRect() {
return this.rect; return this.rect;
}, },
matches: (selector) => selector === '[data-panel-id]', matches: (selector) =>
selector === '[data-panel-id]' ||
(selector === '[data-panel-id]:not(.panel-floating)' &&
!classes.has('panel-floating')),
contains(target) { contains(target) {
return ( return (
target === this || this.children.some((child) => child.contains(target)) target === this || this.children.some((child) => child.contains(target))
@@ -322,6 +325,28 @@ test('right layout retains Display allocation during measurement and caps restor
); );
}); });
test('right rail neither measures nor allocates a panel floating out of it', () => {
const f = fixture('right');
f.expand(f.first, 900);
f.expand(f.second, 300);
f.first.classList.add('panel-floating');
f.first.style.setProperty('--right-panel-allocated-height', '500px');
f.first.writes.length = 0;
f.run();
assert.equal(f.stack.dataset.expandedCount, '1');
assert.deepEqual(
f.first.writes,
[],
'a floating panel keeps whatever inline style it left the rail with',
);
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
assert.equal(f.first.classList.contains('layout-auto-collapsed'), false);
assert.ok(
f.second.style.getPropertyValue('--right-panel-allocated-height'),
'the docked panel still receives its allocation',
);
});
test('right rail aligns to the current left rail and excludes hidden obstacles', () => { test('right rail aligns to the current left rail and excludes hidden obstacles', () => {
const f = fixture('right'); const f = fixture('right');
f.options.leftStack.rect.top = 200; f.options.leftStack.rect.top = 200;
+65
View File
@@ -0,0 +1,65 @@
/**
* Pure geometry for resizing a floating panel from any edge or corner. The
* DOM wiring lives in PanelPositionControls; this module only decides boxes.
*/
/** Viewport margin a resized panel keeps clear on every side. */
const EDGE_MARGIN_PX = 6;
/** Resize directions: compass edges first, then corners. */
export const RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'sw', 'se'];
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
/**
* Work out the box a resize gesture produces. Edges named in `dir` move with
* the pointer; the opposite edge stays put, so growing from the left keeps
* the right side pinned. Minimum size and the viewport margin win over the
* pointer.
* @param {{left: number, top: number, width: number, height: number}} box Box at gesture start.
* @param {string} dir One of RESIZE_DIRECTIONS.
* @param {number} dx Pointer travel since gesture start.
* @param {number} dy Pointer travel since gesture start.
* @param {object} [limits]
* @param {number} limits.minWidth
* @param {number} limits.minHeight
* @param {number} [limits.viewportWidth=Infinity]
* @param {number} [limits.viewportHeight=Infinity]
* @param {number} [limits.margin=6]
* @returns {{left: number, top: number, width: number, height: number}}
*/
export function resizeBox(
box,
dir,
dx,
dy,
{
minWidth,
minHeight,
viewportWidth = Infinity,
viewportHeight = Infinity,
margin = EDGE_MARGIN_PX,
} = {},
) {
let { left, top, width, height } = box;
const right = box.left + box.width;
const bottom = box.top + box.height;
if (dir.includes('e'))
width = clamp(box.width + dx, minWidth, viewportWidth - margin - box.left);
if (dir.includes('s'))
height = clamp(
box.height + dy,
minHeight,
viewportHeight - margin - box.top,
);
if (dir.includes('w')) {
width = clamp(box.width - dx, minWidth, right - margin);
left = right - width;
}
if (dir.includes('n')) {
height = clamp(box.height - dy, minHeight, bottom - margin);
top = bottom - height;
}
return { left, top, width, height };
}
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resizeBox } from './panelResize.js';
const box = { left: 100, top: 100, width: 400, height: 300 };
const limits = {
minWidth: 200,
minHeight: 150,
viewportWidth: 1000,
viewportHeight: 800,
};
test('east and south edges grow away from the anchored corner', () => {
assert.deepEqual(resizeBox(box, 'se', 50, 20, limits), {
left: 100,
top: 100,
width: 450,
height: 320,
});
assert.deepEqual(resizeBox(box, 'e', -30, 999, limits), {
left: 100,
top: 100,
width: 370,
height: 300,
});
});
test('west and north edges keep the opposite edge pinned', () => {
assert.deepEqual(resizeBox(box, 'w', -40, 0, limits), {
left: 60,
top: 100,
width: 440,
height: 300,
});
assert.deepEqual(resizeBox(box, 'nw', 40, 30, limits), {
left: 140,
top: 130,
width: 360,
height: 270,
});
assert.deepEqual(
resizeBox(box, 'n', -50, 0, limits).top,
100,
'horizontal motion does not move a north edge',
);
});
test('minimum size stops the moving edge, not the anchored one', () => {
const small = resizeBox(box, 'w', 350, 0, limits);
assert.deepEqual(small, { left: 300, top: 100, width: 200, height: 300 });
assert.equal(resizeBox(box, 'ne', 0, 400, limits).height, 150);
assert.equal(resizeBox(box, 'ne', 0, 400, limits).top, 250);
});
test('the viewport margin caps growth in every direction', () => {
assert.equal(resizeBox(box, 'e', 2000, 0, limits).width, 1000 - 6 - 100);
assert.equal(resizeBox(box, 's', 0, 2000, limits).height, 800 - 6 - 100);
const west = resizeBox(box, 'w', -2000, 0, limits);
assert.equal(west.left, 6);
assert.equal(west.left + west.width, 500);
const north = resizeBox(box, 'n', 0, -2000, limits);
assert.equal(north.top, 6);
assert.equal(north.top + north.height, 400);
});
+4 -1
View File
@@ -47,8 +47,11 @@ export function layoutRightPanelRail({
// A collapse may schedule one follow-up, which only measures and allocates. // A collapse may schedule one follow-up, which only measures and allocates.
const isCollapseRetry = pendingCollapseRetries.delete(stack); const isCollapseRetry = pendingCollapseRetries.delete(stack);
// A panel lifted out of the rail (PanelPositionControls' portable mode)
// is a fixed window the rail neither measures nor allocates.
const panels = [...stack.children].filter( const panels = [...stack.children].filter(
(panel) => panel.matches('[data-panel-id]') && !panel.hidden, (panel) =>
panel.matches('[data-panel-id]:not(.panel-floating)') && !panel.hidden,
); );
if (!hud.visible || hud.variant !== 'tactical') { if (!hud.visible || hud.variant !== 'tactical') {
for (const panel of panels.filter((item) => for (const panel of panels.filter((item) =>
+110
View File
@@ -103,6 +103,116 @@
opacity: 0.96; opacity: 0.96;
} }
/* ── Portable rail panels ────────────────────
A header drag lifts a portable panel (PanelPositionControls) out of its
rail into a fixed window that remembers its place and size. The rail's
own placement rules in layers.css are guarded with :not(.panel-floating),
so these only have to beat a panel's base placement (one id). */
.panel-portable .panel-header {
cursor: grab;
}
.panel-portable.panel-dragging .panel-header {
cursor: grabbing;
}
#right-context-rail > .panel-floating {
position: fixed;
inset: auto;
max-height: calc(100vh - 12px);
margin: 0;
flex: none;
pointer-events: auto;
}
/* A collapsed window keeps its stored height for later but shows only its
header strip, so the resize handles have nothing to attach to. */
#right-context-rail > .panel-floating.collapsed {
max-height: max-content;
}
.panel-floating[style*='height'] > .cctv-panel-inner {
box-sizing: border-box;
height: 100%;
max-height: none;
}
.panel-resize-edge,
.panel-resize-grip {
position: absolute;
z-index: 3;
touch-action: none;
}
.panel-resize-edge[data-dir='n'],
.panel-resize-edge[data-dir='s'] {
left: 10px;
right: 10px;
height: 7px;
cursor: ns-resize;
}
.panel-resize-edge[data-dir='n'] {
top: -3px;
}
.panel-resize-edge[data-dir='s'] {
bottom: -3px;
}
.panel-resize-edge[data-dir='e'],
.panel-resize-edge[data-dir='w'] {
top: 10px;
bottom: 10px;
width: 7px;
cursor: ew-resize;
}
.panel-resize-edge[data-dir='e'] {
right: -3px;
}
.panel-resize-edge[data-dir='w'] {
left: -3px;
}
.panel-resize-edge[data-dir='ne'],
.panel-resize-edge[data-dir='nw'],
.panel-resize-edge[data-dir='sw'] {
width: 14px;
height: 14px;
}
.panel-resize-edge[data-dir='ne'] {
top: -3px;
right: -3px;
cursor: nesw-resize;
}
.panel-resize-edge[data-dir='nw'] {
top: -3px;
left: -3px;
cursor: nwse-resize;
}
.panel-resize-edge[data-dir='sw'] {
bottom: -3px;
left: -3px;
cursor: nesw-resize;
}
.panel-resize-grip {
right: 3px;
bottom: 3px;
width: 14px;
height: 14px;
cursor: nwse-resize;
opacity: 0.55;
clip-path: polygon(100% 0, 100% 100%, 0 100%);
background: repeating-linear-gradient(
135deg,
transparent 0 3px,
rgba(0, 212, 255, 0.6) 3px 4px
);
transition: opacity var(--transition-fast);
}
.panel-portable:hover .panel-resize-grip,
.panel-resizing .panel-resize-grip {
opacity: 1;
}
.panel-collapsible.collapsed > .panel-resize-edge,
.panel-collapsible.collapsed > .panel-resize-grip {
display: none;
}
.panel-resizing,
.panel-resizing * {
user-select: none;
}
.panel-title { .panel-title {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 9px; font-size: 9px;
+18 -13
View File
@@ -161,8 +161,8 @@
max-height var(--transition-fast); max-height var(--transition-fast);
} }
#right-context-rail > #pp-toggles, #right-context-rail > #pp-toggles,
#right-context-rail > #cctv-panel, #right-context-rail > #cctv-panel:not(.panel-floating),
#right-context-rail > #street-level-panel, #right-context-rail > #street-level-panel:not(.panel-floating),
#right-context-rail > #weather-panel, #right-context-rail > #weather-panel,
#right-context-rail > #recent-imagery-panel, #right-context-rail > #recent-imagery-panel,
#right-context-rail > #global-context-panel { #right-context-rail > #global-context-panel {
@@ -176,8 +176,8 @@
max-height: 100%; max-height: 100%;
pointer-events: auto; pointer-events: auto;
} }
#right-context-rail #cctv-panel, #right-context-rail #cctv-panel:not(.panel-floating),
#right-context-rail #street-level-panel, #right-context-rail #street-level-panel:not(.panel-floating),
#right-context-rail #weather-panel, #right-context-rail #weather-panel,
#right-context-rail #recent-imagery-panel { #right-context-rail #recent-imagery-panel {
width: 100%; width: 100%;
@@ -192,8 +192,8 @@
flex-shrink: 1; flex-shrink: 1;
min-height: 0; min-height: 0;
} }
#right-context-rail #cctv-panel.collapsed, #right-context-rail #cctv-panel.collapsed:not(.panel-floating),
#right-context-rail #street-level-panel.collapsed, #right-context-rail #street-level-panel.collapsed:not(.panel-floating),
#right-context-rail #weather-panel.collapsed, #right-context-rail #weather-panel.collapsed,
#right-context-rail #recent-imagery-panel.collapsed { #right-context-rail #recent-imagery-panel.collapsed {
width: var(--right-display-collapsed-width); width: var(--right-display-collapsed-width);
@@ -257,19 +257,24 @@
#right-context-rail.layout-focus { #right-context-rail.layout-focus {
height: var(--right-stack-max-height); height: var(--right-stack-max-height);
} }
#right-context-rail.layout-exclusive > [data-panel-id].collapsed { #right-context-rail.layout-exclusive
> [data-panel-id].collapsed:not(.panel-floating) {
display: none; display: none;
} }
#right-context-rail.layout-focus > [data-panel-id].collapsed { #right-context-rail.layout-focus
> [data-panel-id].collapsed:not(.panel-floating) {
display: block; display: block;
flex: 0 0 auto; flex: 0 0 auto;
} }
#right-context-rail.layout-focus.layout-exclusive > [data-panel-id].collapsed { #right-context-rail.layout-focus.layout-exclusive
> [data-panel-id].collapsed:not(.panel-floating) {
display: none; display: none;
} }
#right-context-rail.layout-focus > #pp-toggles:not(.collapsed), #right-context-rail.layout-focus > #pp-toggles:not(.collapsed),
#right-context-rail.layout-focus > #cctv-panel:not(.collapsed), #right-context-rail.layout-focus
#right-context-rail.layout-focus > #street-level-panel:not(.collapsed), > #cctv-panel:not(.collapsed):not(.panel-floating),
#right-context-rail.layout-focus
> #street-level-panel:not(.collapsed):not(.panel-floating),
#right-context-rail.layout-focus > #weather-panel:not(.collapsed), #right-context-rail.layout-focus > #weather-panel:not(.collapsed),
#right-context-rail.layout-focus > #recent-imagery-panel:not(.collapsed), #right-context-rail.layout-focus > #recent-imagery-panel:not(.collapsed),
#right-context-rail.layout-focus > #global-context-panel:not(.collapsed) { #right-context-rail.layout-focus > #global-context-panel:not(.collapsed) {
@@ -288,10 +293,10 @@
> #weather-panel:not(.collapsed) > #weather-panel:not(.collapsed)
.weather-panel-inner, .weather-panel-inner,
#right-context-rail.layout-focus #right-context-rail.layout-focus
> #cctv-panel:not(.collapsed) > #cctv-panel:not(.collapsed):not(.panel-floating)
.cctv-panel-inner, .cctv-panel-inner,
#right-context-rail.layout-focus #right-context-rail.layout-focus
> #street-level-panel:not(.collapsed) > #street-level-panel:not(.collapsed):not(.panel-floating)
.street-level-panel-inner, .street-level-panel-inner,
#right-context-rail.layout-focus #right-context-rail.layout-focus
> #recent-imagery-panel:not(.collapsed) > #recent-imagery-panel:not(.collapsed)
+4 -4
View File
@@ -216,10 +216,10 @@
} }
#right-context-rail #pp-toggles.collapsed .pp-header-row, #right-context-rail #pp-toggles.collapsed .pp-header-row,
#right-context-rail #cctv-panel, #right-context-rail #cctv-panel:not(.panel-floating),
#right-context-rail #street-level-panel, #right-context-rail #cctv-panel.collapsed:not(.panel-floating),
#right-context-rail #cctv-panel.collapsed, #right-context-rail #street-level-panel:not(.panel-floating),
#right-context-rail #street-level-panel.collapsed, #right-context-rail #street-level-panel.collapsed:not(.panel-floating),
#right-context-rail #recent-imagery-panel, #right-context-rail #recent-imagery-panel,
#right-context-rail #recent-imagery-panel.collapsed, #right-context-rail #recent-imagery-panel.collapsed,
#right-context-rail #radio-panel, #right-context-rail #radio-panel,