From 362e23ee45397d80b2bc25e5a319db6ab9213466 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:49:41 -0700 Subject: [PATCH] Add widget snapping functionality to onroad layout editor --- .../galaxy/tests/test_layout_snap.mjs | 75 ++++++++ .../galaxy/tests/test_onroad_layout.mjs | 175 ++++++++++++++++++ .../starpilot/galaxy/web/css/settings.css | 5 + .../starpilot/galaxy/web/js/layout-snap.js | 75 ++++++++ .../starpilot/galaxy/web/js/onroad-layout.js | 72 ++++++- 5 files changed, 396 insertions(+), 6 deletions(-) create mode 100644 openpilot/starpilot/galaxy/tests/test_layout_snap.mjs create mode 100644 openpilot/starpilot/galaxy/web/js/layout-snap.js diff --git a/openpilot/starpilot/galaxy/tests/test_layout_snap.mjs b/openpilot/starpilot/galaxy/tests/test_layout_snap.mjs new file mode 100644 index 0000000000..48c4fe72fd --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_layout_snap.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict" +import { SNAP_DISTANCE, snapTargets, snapMove, snapResize, snapThreshold } from "../web/js/layout-snap.js" + +const copy = value => structuredClone(value) +// Every helper call also checks that its inputs are unchanged. +function pure(fn, ...args) { + const before = args.map(arg => typeof arg === "function" ? arg : copy(arg)) + const result = fn(...args) + assert.deepEqual(args, before) + return result +} +const profile = { width: 1000, height: 600, bounds: { x: 30, y: 30, width: 900, height: 540 }, reservedZones: [], widgets: { + moving: { label: "Moving", kind: "clock", width: 100, height: 60 }, + home: { label: "Home", kind: "favorite", width: 100, height: 60 }, +} } +const layout = { moving: { x: 200, y: 200, enabled: true }, home: { x: 300, y: 350, enabled: true } } +const targets = pure(snapTargets, profile, layout, "moving", () => true) +const bounds = targets.filter(target => target.kind !== "widget") +const box = { x: 34, y: 113, width: 100, height: 60 } +let result = pure(snapMove, box, bounds, 6) +assert.equal(result.x, 30) +assert.equal(result.y, 113) +assert.deepEqual(result.guides, [{ axis: "x", value: 30, kind: "bounds", label: "the edge" }]) +assert.deepEqual(pure(snapMove, { ...box, x: 37 }, bounds, 6), { x: 37, y: 113, guides: [] }) +result = pure(snapMove, { ...box, x: 433 }, bounds, 6) +assert.equal(result.x, 430) +assert.equal(result.guides[0].kind, "center") +result = pure(snapMove, { ...box, x: 404 }, targets, 6) +assert.equal(result.x, 400) // Start to another widget's end. +assert.equal(result.guides[0].label, "Home") +assert.deepEqual(pure(snapMove, { ...box, x: 253 }, targets, 6), { x: 253, y: 113, guides: [] }) +assert.deepEqual(pure(snapMove, box, bounds, 0), { x: 34, y: 113, guides: [] }) +const target = (value, line = "start", kind = "widget", axis = "x", label = "Home") => ({ axis, value, line, kind, label }) +result = pure(snapMove, { ...box, x: 100 }, [target(104), target(146, "center", "center")], 6) +assert.equal(result.x, 96) // Equal distance prefers bounds centre. +assert.equal(result.guides[0].kind, "center") +for (const kinds of [["zone", "widget"], ["widget", "bounds"], ["bounds", "center"]]) { + const first = target(104, "start", kinds[0]), second = target(96, "start", kinds[1]) + assert.equal(pure(snapMove, { ...box, x: 100 }, [first, second], 6).x, 96) +} +assert.equal(pure(snapMove, { ...box, x: 100 }, [target(104), target(96)], 6).x, 104) +result = pure(snapMove, { ...box, x: 104 }, [target(100), target(200, "end"), target(100, "end", "bounds")], 6) +assert.deepEqual(result.guides, [ + { axis: "x", value: 100, kind: "bounds", label: "Home" }, + { axis: "x", value: 200, kind: "widget", label: "Home" }, +]) +const exclusions = copy(profile), placements = copy(layout) +for (const [id, kind, enabled] of [["disabled", "clock", false], ["hidden", "clock", true], ["torque", "torque_bar", true], + ["pip", "pip_camera", true], ["invalid", "clock", true]]) { + exclusions.widgets[id] = { label: id, kind, layer: "underlay", width: 60, height: 60 } + placements[id] = { x: id === "invalid" ? Infinity : 100, y: 100, enabled } +} +exclusions.widgets.missing = { label: "missing", kind: "clock", width: 60, height: 60 } +const filtered = pure(snapTargets, exclusions, placements, "moving", id => id !== "hidden") +assert.deepEqual([...new Set(filtered.filter(item => item.kind === "widget").map(item => item.label))], ["Home", "pip"]) +assert.deepEqual(pure(snapTargets, profile, layout, "absent", () => true), []) +const sidebar = copy(profile) +sidebar.widgets.moving.bounds = { x: 940, y: 0, width: 60, height: 600 } +assert.deepEqual(pure(snapTargets, sidebar, layout, "moving", () => false).filter(item => item.axis === "x").map(item => item.value), [940, 1000, 970]) +const zones = copy(profile) +zones.reservedZones.push({ x: 150, y: 160, width: 100, height: 80 }) +assert.deepEqual(pure(snapTargets, zones, layout, "moving", () => false).filter(item => item.kind === "zone").map(item => item.value), [150, 250, 160, 240]) +const resizeBox = { x: 100, y: 150, width: 97, height: 146 } +result = pure(snapResize, resizeBox, [target(200), target(300, "end", "widget", "y")], 6) +assert.deepEqual(result, { width: 100, height: 150, guides: [ + { axis: "x", value: 200, kind: "widget", label: "Home" }, + { axis: "y", value: 300, kind: "widget", label: "Home" }, +] }) +assert.deepEqual(pure(snapResize, resizeBox, [target(200, "center")], 6), { width: 97, height: 146, guides: [] }) +assert.deepEqual(SNAP_DISTANCE, { touch: 10, fine: 6 }) +for (const [pointer, expected] of [["touch", 60], ["mouse", 36], ["pen", 36]]) + assert.equal(pure(snapThreshold, { width: 2160 }, { width: 360 }, pointer), expected) +assert.equal(pure(snapThreshold, profile, { width: 0 }, "touch"), 0) +assert.equal(pure(snapThreshold, profile, { width: -1 }, "touch"), 0) +console.log("Layout snapping: targets, matching, priority, guides, resize, scaling and purity passed") diff --git a/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs b/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs index 6304835bc1..869b7fcd85 100644 --- a/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs +++ b/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs @@ -145,6 +145,181 @@ function editor() { return { vm, event, emitted, calls, previewCalls, captures, publish } } +// Snapping changes pointer placement only; a drag remains one history entry. +const snappedDrag = editor(), snapVm = snappedDrag.vm +const largePoint = (x, y, options = {}) => ({ ...snappedDrag.event(100 + x / 2, 100 + y / 2), pointerType: "mouse", ...options }) +snapVm.startDrag("current_speed", largePoint(640, 30)) +snapVm.moveDrag(largePoint(34, 430)) +assert.equal(snapVm.layout.current_speed.x, 30) +assert.ok(snapVm.state.snapGuides.some(guide => guide.axis === "x" && guide.kind === "bounds")) +assert.equal(snapVm.snapSummary, "At the edge") +const cachedTargets = snapVm.state.drag.targets +snapVm.moveDrag(largePoint(35, 430)) +assert.equal(snapVm.state.drag.targets, cachedTargets) +snapVm.endDrag(largePoint(34, 430)) +assert.deepEqual(snapVm.state.snapGuides, []) +assert.equal(snapVm.state.history.undo.length, 1) +snapVm.undo() +assert.deepEqual(snapVm.layout.current_speed, snapshot().document.layouts.large.current_speed) +for (const bypass of ["alt", "disabled"]) { + const free = editor().vm + if (bypass === "disabled") free.state.snapEnabled = false + free.startDrag("current_speed", largePoint(640, 30)) + free.endDrag(largePoint(34, 430, { altKey: bypass === "alt" })) + assert.equal(free.layout.current_speed.x, 34) + assert.deepEqual(free.state.snapGuides, []) +} +snapVm.startDrag("current_speed", largePoint(640, 30)) +snapVm.moveDrag(largePoint(34, 430)) +assert.ok(snapVm.state.snapGuides.length) +snapVm.cancelDrag() +assert.deepEqual(snapVm.layout.current_speed, snapshot().document.layouts.large.current_speed) +assert.deepEqual(snapVm.state.snapGuides, []) +snapVm.changePosition("current_speed", 34, 430) +snapVm.onKey("current_speed", { key: "ArrowLeft", preventDefault() {} }) +assert.equal(snapVm.layout.current_speed.x, 33) +snapVm.onKey("current_speed", { key: "ArrowRight", shiftKey: true, preventDefault() {} }) +assert.equal(snapVm.layout.current_speed.x, 43) +assert.deepEqual(snapVm.state.snapGuides, []) + +for (const pointerType of ["mouse", "touch"]) { + const phone = editor().vm + phone.$refs.preview.getBoundingClientRect = () => ({ left: 0, top: 0, width: 360, height: 180 }) + for (const id of Object.keys(phone.layout)) if (id !== "current_speed") phone.state.hiddenPreviewWidgets.large[id] = true + const phonePoint = (x, y) => ({ ...snappedDrag.event(x / 6, y / 6), pointerType }) + phone.startDrag("current_speed", phonePoint(640, 30)) + phone.endDrag(phonePoint(75, 430)) + assert.equal(phone.layout.current_speed.x, pointerType === "touch" ? 30 : 75) +} +const targetVisibility = editor().vm +targetVisibility.profile.widgets.pip = { ...widget("Side camera", "pip_camera", 60, 60, 400, 300), layer: "underlay" } +targetVisibility.layout.pip = { x: 400, y: 300, enabled: true } +targetVisibility.startDrag("current_speed", largePoint(640, 30)) +targetVisibility.moveDrag(largePoint(464, 430)) +assert.equal(targetVisibility.layout.current_speed.x, 460) +assert.ok(targetVisibility.state.snapGuides.some(guide => guide.label === "Side camera")) +targetVisibility.cancelDrag() +targetVisibility.togglePreviewVisibility("pip") +targetVisibility.startDrag("current_speed", largePoint(640, 30)) +targetVisibility.moveDrag(largePoint(464, 430)) +assert.equal(targetVisibility.layout.current_speed.x, 464) +targetVisibility.cancelDrag() +targetVisibility.startDrag("current_speed", largePoint(640, 30)) +targetVisibility.moveDrag(largePoint(443, 430)) +assert.equal(targetVisibility.layout.current_speed.x, 443, "torque arc footprint must not attract other widgets") +targetVisibility.cancelDrag() + +const invalidSnap = editor().vm +invalidSnap.selectProfile("compact") +invalidSnap.$refs.preview.getBoundingClientRect = () => ({ left: 0, top: 0, width: 536, height: 240 }) +invalidSnap.state.hiddenPreviewWidgets.compact.speed_limit_actions = true +const smallPoint = (x, y) => ({ ...snappedDrag.event(x, y), pointerType: "mouse" }) +invalidSnap.startDrag("max_speed", smallPoint(0, 0)) +invalidSnap.moveDrag(smallPoint(100, 18)) // Speed limit's top attracts to y=20, overlapping protected actions. +assert.equal(clampPlacement(invalidSnap.profile, "max_speed", 100, 20, invalidSnap.layout), null) +assert.deepEqual(invalidSnap.layout.max_speed, { x: 100, y: 18, enabled: true }) +assert.deepEqual(invalidSnap.state.snapGuides, []) +assert.equal(invalidSnap.state.placementError, "") +invalidSnap.moveDrag(smallPoint(100, 100)) +assert.deepEqual(invalidSnap.layout.max_speed, { x: 100, y: 18, enabled: true }) +assert.ok(invalidSnap.state.placementError) +invalidSnap.cancelDrag() + +const traySnap = editor().vm +traySnap.layout.current_speed.enabled = false +traySnap.startDrag("current_speed", largePoint(2400, 0), true) +traySnap.moveDrag(largePoint(324, 550)) // Widget centre follows the tray pointer. +assert.equal(traySnap.layout.current_speed.x, 30) +assert.equal(traySnap.layout.current_speed.enabled, true) +assert.ok(traySnap.state.snapGuides.length) +traySnap.moveDrag(largePoint(2400, 550)) +assert.equal(traySnap.layout.current_speed.enabled, false) +assert.deepEqual(traySnap.state.snapGuides, []) +traySnap.cancelDrag() +assert.equal(traySnap.layout.current_speed.enabled, false) + +const clampedSnap = editor().vm +clampedSnap.profile.widgets.driver_monitor.bounds = { x: 30, y: 30, width: 300, height: 1020 } +clampedSnap.layout.current_speed.x = 184 +clampedSnap.startDrag("driver_monitor", largePoint(88, 808)) +clampedSnap.moveDrag(largePoint(187, 400)) // Current speed at 184 is beyond the mover's own bounds. +assert.equal(clampedSnap.layout.driver_monitor.x, 138) +assert.deepEqual(clampedSnap.state.snapGuides.filter(guide => guide.axis === "x"), + [{ axis: "x", value: 330, kind: "bounds", label: "the edge" }], "a clamped widget reports the edge it rests on, not the target it missed") +clampedSnap.moveDrag(largePoint(400, 400)) // Pushed far past the edge, it still reports resting on it. +assert.equal(clampedSnap.layout.driver_monitor.x, 138) +assert.ok(clampedSnap.state.snapGuides.some(guide => guide.axis === "x" && guide.value === 330 && guide.kind === "bounds")) +clampedSnap.cancelDrag() + +const resizeSnap = editor().vm +resizeSnap.profile.widgets.map = { ...widget("Map", "nav_map", 200, 200, 300, 300), + box: { minWidth: 100, maxWidth: 1000, minHeight: 100, maxHeight: 1000 } } +resizeSnap.layout.map = { x: 300, y: 300, width: 200, height: 200, enabled: true } +const resizePoint = (x, y, options = {}) => ({ ...largePoint(x, y, options), stopPropagation() {} }) +resizeSnap.startResize("map", resizePoint(500, 500)) +resizeSnap.moveDrag(resizePoint(564, 671)) +assert.deepEqual([resizeSnap.layout.map.width, resizeSnap.layout.map.height], [268, 375]) +assert.deepEqual([resizeSnap.layout.map.x, resizeSnap.layout.map.y], [300, 300]) +assert.equal(resizeSnap.state.snapGuides.length, 2) +resizeSnap.endDrag(resizePoint(564, 671)) +assert.deepEqual(resizeSnap.state.snapGuides, []) +assert.equal(resizeSnap.state.history.undo.length, 1) +resizeSnap.undo() +resizeSnap.startResize("map", resizePoint(500, 500)) +resizeSnap.moveDrag(resizePoint(564, 671, { altKey: true })) +assert.deepEqual([resizeSnap.layout.map.width, resizeSnap.layout.map.height], [264, 371]) +assert.deepEqual(resizeSnap.state.snapGuides, []) +resizeSnap.cancelDrag() +resizeSnap.profile.widgets.map.box.maxWidth = 250 +resizeSnap.startResize("map", resizePoint(500, 500)) +resizeSnap.moveDrag(resizePoint(564, 671)) +assert.equal(resizeSnap.layout.map.width, 250) +assert.deepEqual(resizeSnap.state.snapGuides, []) +resizeSnap.cancelDrag() + +const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"), preferences = new Map() +try { + Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { + getItem: key => preferences.get(key) ?? null, setItem: (key, value) => preferences.set(key, value) + } }) + const preference = editor().vm + assert.equal(preference.state.snapEnabled, true) + preference.toggleSnap() + assert.equal(preferences.get("gx-layout-snap-v1"), "0") + assert.equal(editor().vm.state.snapEnabled, false) + assert.equal(OnroadLayoutPage.setup({ projection: true }).state.snapEnabled, false) + preference.toggleSnap() + assert.equal(preferences.get("gx-layout-snap-v1"), "1") + assert.equal(editor().vm.state.snapEnabled, true) + Object.defineProperty(globalThis, "localStorage", { configurable: true, get() { throw new Error("Storage blocked") } }) + const blocked = editor().vm + assert.equal(blocked.state.snapEnabled, true) + blocked.toggleSnap() + assert.equal(blocked.state.snapEnabled, false) +} finally { + if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage) + else delete globalThis.localStorage +} +for (const [guides, expected] of [ + [[{ axis: "x", kind: "center" }, { axis: "y", kind: "center" }], "Centred on the screen"], + [[{ axis: "x", kind: "center" }], "Centred across"], + [[{ axis: "y", kind: "center" }], "Centred top to bottom"], + [[{ label: "Home" }, { label: "Home" }, { label: "Work" }], "Lined up with Home and Work"], + [[{ label: "the edge" }, { label: "Home" }], "At the edge, lined up with Home"], + [[{ axis: "x", kind: "center", label: "the centre" }, { axis: "y", kind: "bounds", label: "the edge" }], "Centred across, at the edge"], + [[{ axis: "y", kind: "center", label: "the centre" }, { axis: "x", kind: "widget", label: "Home" }, { axis: "y", kind: "widget", label: "Work" }], + "Centred top to bottom, lined up with Home"], +]) { + snapVm.state.snapGuides = guides + assert.equal(snapVm.snapSummary, expected) +} +for (const clear of [() => snapVm.selectProfile("compact"), () => snapVm.selectPreview("device"), () => snapVm.hideDevicePreview()]) { + snapVm.state.snapGuides = [{ axis: "x", value: 30, kind: "bounds", label: "the edge" }] + clear() + assert.deepEqual(snapVm.state.snapGuides, []) +} +console.log("Layout snapping integration: drag, touch, targets, fallback, resize, history, preference and keyboard passed") + // Layer visibility is local to the arrangement preview, including required widgets. for (const projection of [false, true]) { const visibility = editor().vm diff --git a/openpilot/starpilot/galaxy/web/css/settings.css b/openpilot/starpilot/galaxy/web/css/settings.css index a9c4f62559..df5f1d2bac 100644 --- a/openpilot/starpilot/galaxy/web/css/settings.css +++ b/openpilot/starpilot/galaxy/web/css/settings.css @@ -656,6 +656,11 @@ input[type="range"].gx-slider:active::-moz-range-thumb { /* Tap-area overlay label sits on the preview while the grid toggle is on. */ .gx-layout__preview-card > .gx-layout__stage { position: relative; margin-inline: 0; min-width: 0; } .gx-layout__stage-chip { position: absolute; top: 6px; right: 6px; z-index: 1; display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border: 1px solid #b799ff88; border-radius: var(--radius-full); background: rgba(22, 18, 31, .82); color: #d8c8ff; font-size: var(--fs-xs); font-weight: var(--fw-bold); white-space: nowrap; pointer-events: none; } +.gx-layout__stage-chip--start { right: auto; left: 6px; max-width: calc(100% - 12px); overflow: hidden; text-overflow: ellipsis; display: block; } +.gx-layout__stage-chip--with-tap-areas { max-width: calc(100% - 120px); } +.gx-layout__guide { stroke: #b799ff; stroke-width: 1.5; } +.gx-layout__guide--center { stroke-dasharray: 6 4; } +.gx-layout__guide--bounds, .gx-layout__guide--zone { stroke: #8495a8; } /* On narrow editors a pinned save bar also covers the strip behind the app bar, so the editor never scrolls up behind the search. The fill stays inside the editor column and follows the bar's rounded corners. Its outline is diff --git a/openpilot/starpilot/galaxy/web/js/layout-snap.js b/openpilot/starpilot/galaxy/web/js/layout-snap.js new file mode 100644 index 0000000000..1cb8a83a50 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/layout-snap.js @@ -0,0 +1,75 @@ +import { widgetSize } from "./onroad-layout-document.js" + +export const SNAP_DISTANCE = { touch: 10, fine: 6 } +const NON_TARGET_KINDS = ["torque_bar"] +const PRIORITY = { center: 0, bounds: 1, widget: 2, zone: 3 } +const EPSILON = 1e-6 + +function lines(box, axis) { + const start = box[axis], size = box[axis === "x" ? "width" : "height"] + return { start, center: start + size / 2, end: start + size } +} + +export function snapTargets(profile, layout, id, visible) { + if (!Object.hasOwn(profile.widgets, id)) return [] + const targets = [] + const add = (box, kind, label, names) => { + for (const axis of ["x", "y"]) { + const values = lines(box, axis) + // Keep the line role so widget centres cannot attract moving edges. + for (const line of names) targets.push({ axis, value: values[line], kind, label, line }) + } + } + const bounds = profile.widgets[id].bounds || profile.bounds + add(bounds, "bounds", "the edge", ["start", "end"]) + add(bounds, "center", "the centre", ["center"]) + for (const [key, widget] of Object.entries(profile.widgets)) { + const at = layout[key] + if (key === id || !at?.enabled || !Number.isFinite(at.x) || !Number.isFinite(at.y) || + !visible(key) || NON_TARGET_KINDS.includes(widget.kind)) continue + const [width, height] = widgetSize(widget, at) + add({ x: at.x, y: at.y, width, height }, "widget", widget.label, ["start", "center", "end"]) + } + for (const zone of profile.reservedZones || []) add(zone, "zone", "a reserved area", ["start", "end"]) + return targets +} + +function snapAxis(box, targets, threshold, axis, resize) { + if (!(threshold > 0)) return { delta: 0, guides: [] } + const moving = lines(box, axis), names = resize ? ["end"] : ["start", "center", "end"] + const matches = (name, target) => (name === "center") === (target.line === "center") + let best = null + for (const target of targets) { + if (target.axis !== axis) continue + for (const name of names) { + if (!matches(name, target)) continue + const delta = target.value - moving[name], distance = Math.abs(delta) + if (distance <= threshold && (!best || distance < best.distance || + distance === best.distance && PRIORITY[target.kind] < PRIORITY[best.target.kind])) best = { delta, distance, target } + } + } + if (!best) return { delta: 0, guides: [] } + const guides = [] + for (const target of targets) { + if (target.axis !== axis || !names.some(name => matches(name, target) && Math.abs(moving[name] + best.delta - target.value) <= EPSILON)) continue + const guide = { axis, value: target.value, kind: target.kind, label: target.label } + const index = guides.findIndex(item => Math.abs(item.value - guide.value) <= EPSILON) + if (index < 0) guides.push(guide) + else if (PRIORITY[guide.kind] < PRIORITY[guides[index].kind]) guides[index] = guide + } + return { delta: best.delta, guides } +} + +export function snapMove(box, targets, threshold) { + const x = snapAxis(box, targets, threshold, "x", false), y = snapAxis(box, targets, threshold, "y", false) + return { x: box.x + x.delta, y: box.y + y.delta, guides: [...x.guides, ...y.guides] } +} + +export function snapResize(box, targets, threshold) { + const x = snapAxis(box, targets, threshold, "x", true), y = snapAxis(box, targets, threshold, "y", true) + return { width: box.width + x.delta, height: box.height + y.delta, guides: [...x.guides, ...y.guides] } +} + +export function snapThreshold(profile, rect, pointerType) { + return rect.width > 0 ? (pointerType === "touch" ? SNAP_DISTANCE.touch : SNAP_DISTANCE.fine) * profile.width / rect.width : 0 +} diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout.js b/openpilot/starpilot/galaxy/web/js/onroad-layout.js index 3615f99d86..1e7e7d5995 100644 --- a/openpilot/starpilot/galaxy/web/js/onroad-layout.js +++ b/openpilot/starpilot/galaxy/web/js/onroad-layout.js @@ -8,6 +8,7 @@ import { GalaxySelect } from "./galaxy-select.js" import { GxNotice } from "./notice.js" import { GxDialog } from "./dialog.js" import { GalaxySettingRow, switchRow } from "./galaxy-setting-row.js" +import { snapTargets, snapMove, snapResize, snapThreshold } from "./layout-snap.js" import { PROFILES, ROAD_COLORS, PATH_MODES, HEX, PROJECTION_DISPLAY_SWITCHES, layoutDocument, withDisplay, orderedWidgetIds, widgetPalette, withAlpha, widgetSize, placementLimits, clampBox, clampPlacement, overlapsReserved, previewPoint, validDocument, validSnapshot } from "./onroad-layout-document.js" export { layoutDocument, withDisplay, orderedWidgetIds, widgetPalette, withAlpha, widgetSize, placementLimits, clampBox, clampPlacement, overlapsReserved, previewPoint, validDocument, validSnapshot } from "./onroad-layout-document.js" @@ -29,6 +30,10 @@ function loadPreviewVisibility(projection) { return hidden } const clone = (value) => JSON.parse(JSON.stringify(value)) +const snapPreferenceKey = "gx-layout-snap-v1" +function loadSnapPreference() { + try { return localStorage.getItem(snapPreferenceKey) !== "0" } catch { return true } +} // Open maker pages; a route switch briefly mounts the next editor before the last one leaves. let makerPagesOpen = 0 @@ -269,7 +274,7 @@ export const OnroadLayoutPage = { profile: "large", scene: "engaged", selected: null, layerDrag: null, drag: null, discard: null, leavePresentation: null, removeConfirm: null, colorError: "", placementError: "", inspectorPanel: ["widgets", "edit", "road", "display"].includes(props.initialPanel) && !props.projection ? props.initialPanel : "widgets", previewMode: "layout", showFavoriteZones: false, devicePreviewOpen: false, aboutOpen: false, tipsOpen: false, moreOpen: false, widgetsHelpOpen: false, stuck: { leaveBar: false, pinned: false, sections: false }, openColor: null, art: { status: "idle", error: "", url: null, sprites: null }, preview: { status: "idle", error: "", url: null }, - hiddenPreviewWidgets: loadPreviewVisibility(props.projection), history: { undo: [], redo: [], group: null } + hiddenPreviewWidgets: loadPreviewVisibility(props.projection), snapEnabled: loadSnapPreference(), snapGuides: [], history: { undo: [], redo: [], group: null } }) let deferred = null const apply = (update) => { @@ -399,6 +404,16 @@ export const OnroadLayoutPage = { selectedWidget() { return this.profile?.widgets[this.state.selected] }, selectedPosition() { return this.layout?.[this.state.selected] }, selectedSize() { return this.selectedWidget ? widgetSize(this.selectedWidget, this.selectedPosition) : [0, 0] }, + snapSummary() { + const guides = this.state.snapGuides, centred = new Set(guides.filter(guide => guide.kind === "center").map(guide => guide.axis)) + if (centred.size === 2) return "Centred on the screen" + // A centred axis is described once; the other axis still says what it lines up with. + const labels = [...new Set(guides.filter(guide => !centred.has(guide.axis)).map(guide => guide.label))] + const others = labels.filter(label => label !== "the edge").slice(0, 2) + const text = [centred.has("x") ? "centred across" : centred.has("y") ? "centred top to bottom" : "", + labels.includes("the edge") ? "at the edge" : "", others.length ? "lined up with " + others.join(" and ") : ""].filter(Boolean).join(", ") + return text && text[0].toUpperCase() + text.slice(1) + }, selectedColors() { return this.selectedWidget ? this.widgetColors(this.state.selected) : {} }, colorFields() { return (this.state.data?.metadata.paletteFields || []).filter((field) => @@ -446,6 +461,11 @@ export const OnroadLayoutPage = { methods: { switchRow, runtimeWidget(id) { return this.profile?.runtimePreviewWidgets?.includes(id) === true }, + toggleSnap() { + if (this.state.drag || this.state.layerDrag) return + this.state.snapEnabled = !this.state.snapEnabled + try { localStorage.setItem(snapPreferenceKey, this.state.snapEnabled ? "1" : "0") } catch { } + }, previewHidden(id) { return !(this.projection && id === "car_exit") && this.state.hiddenPreviewWidgets[this.state.profile]?.[id] === true }, togglePreviewVisibility(id) { if (this.state.drag || this.state.layerDrag || (this.projection && id === "car_exit")) return @@ -518,6 +538,7 @@ export const OnroadLayoutPage = { }, selectPreview(mode) { if (this.state.drag) return + this.state.snapGuides = [] this.state.previewMode = mode if (mode === "device") this.showDevicePreview() }, @@ -692,6 +713,7 @@ export const OnroadLayoutPage = { } }, hideDevicePreview() { + this.state.snapGuides = [] this.state.previewMode = "layout" this.state.devicePreviewOpen = false if (this.previewFeed.active) this.previewFeed.stop() @@ -699,6 +721,7 @@ export const OnroadLayoutPage = { }, selectProfile(profile) { if (!this.availableProfiles.includes(profile) || this.state.drag) return + this.state.snapGuides = [] this.finishColorEdit() this.state.profile = profile this.state.selected = Object.keys(this.profile.widgets)[0] || null @@ -947,17 +970,45 @@ export const OnroadLayoutPage = { moveDrag(event) { const drag = this.state.drag if (!drag || drag.pointerId !== event.pointerId) return - const point = previewPoint(event, this.$refs.preview.getBoundingClientRect(), this.profile) + const rect = this.$refs.preview.getBoundingClientRect(), point = previewPoint(event, rect, this.profile) + this.state.snapGuides = [] if (!point) return - if (drag.resize) { this.resizeBox(drag.id, point.x - drag.offsetX, point.y - drag.offsetY); return } + const snapping = this.state.snapEnabled && !event.altKey + if (drag.resize) { + const raw = { width: point.x - drag.offsetX, height: point.y - drag.offsetY }, position = this.layout[drag.id] + if (snapping) { + drag.targets ||= snapTargets(this.profile, this.layout, drag.id, id => !this.previewHidden(id)) + const snapped = snapResize({ x: position.x, y: position.y, ...raw }, drag.targets, snapThreshold(this.profile, rect, event.pointerType)) + if (this.resizeBox(drag.id, snapped.width, snapped.height)) { + if (position.width === snapped.width && position.height === snapped.height) this.state.snapGuides = snapped.guides + return + } + } + this.resizeBox(drag.id, raw.width, raw.height) + return + } // A finger wobbles a few pixels on every tap; only a deliberate move drags the widget. if (!drag.moved && Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) < DRAG_SLOP) return drag.moved = true drag.inside = point.x >= 0 && point.y >= 0 && point.x <= this.profile.width && point.y <= this.profile.height if (!drag.fromTray || drag.inside) { - const x = point.x - drag.offsetX, y = point.y - drag.offsetY - if (drag.fromTray) drag.inside = clampPlacement(this.profile, drag.id, x, y, this.layout) !== null - this.changePosition(drag.id, x, y) + const raw = { x: point.x - drag.offsetX, y: point.y - drag.offsetY } + let next = raw + if (snapping) { + const [width, height] = widgetSize(this.profile.widgets[drag.id], this.layout[drag.id]) + drag.targets ||= snapTargets(this.profile, this.layout, drag.id, id => !this.previewHidden(id)) + // Pushing past an edge parks the widget on it, so snap from where the clamp will leave it. + const { minX, maxX, minY, maxY } = placementLimits(this.profile, drag.id, this.layout) + const inside = { x: Math.max(minX, Math.min(maxX, raw.x)), y: Math.max(minY, Math.min(maxY, raw.y)) } + const snapped = snapMove({ ...inside, width, height }, drag.targets, snapThreshold(this.profile, rect, event.pointerType)) + const clamped = clampPlacement(this.profile, drag.id, snapped.x, snapped.y, this.layout) + if (clamped) { + next = snapped + if (clamped.x === Math.round(snapped.x) && clamped.y === Math.round(snapped.y)) this.state.snapGuides = snapped.guides + } + } + if (drag.fromTray) drag.inside = clampPlacement(this.profile, drag.id, next.x, next.y, this.layout) !== null + this.changePosition(drag.id, next.x, next.y) } if (drag.fromTray) this.layout[drag.id].enabled = drag.inside }, @@ -973,6 +1024,7 @@ export const OnroadLayoutPage = { }, releaseDrag() { const id = this.state.drag?.pointerId + this.state.snapGuides = [] this.state.drag = null if (this._dragTarget?.hasPointerCapture?.(id)) this._dragTarget.releasePointerCapture(id) this._dragTarget = null @@ -1167,6 +1219,7 @@ export const OnroadLayoutPage = { + @@ -1176,6 +1229,7 @@ export const OnroadLayoutPage = {

Widget preview unavailable. Placement controls remain available.

+ +
@@ -1249,6 +1308,7 @@ export const OnroadLayoutPage = {

Drag widgets in Arrange widgets to place them. Readings are samples.

+

Widgets snap to the screen edges, the centre and other widgets. Turn snapping off with the magnet button, or hold Option (Alt) while dragging.

Add Stopped timer to preview its minutes and seconds. Device preview renders the same widget as the driving screen; choose Stopped timer for a sample at rest.

The steering wheel can be resized in each layout{{ projection ? ', and the map overlay takes any size and opacity' : '' }}. Existing display preferences and driving state still control when widgets appear.

Small speed-limit signs can overlap confirmation actions. Live confirmations hide an overlapping sign until the decision ends. Select either widget in the list to edit it.