Add widget snapping functionality to onroad layout editor

This commit is contained in:
dirwin31
2026-10-13 16:49:41 -07:00
parent 34658746a8
commit 362e23ee45
5 changed files with 396 additions and 6 deletions
@@ -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")
@@ -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
@@ -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
@@ -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
}
@@ -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 = {
<button class="gx-btn gx-btn--tonal" :aria-pressed="state.previewMode === 'layout'" :disabled="!!state.drag" @click="selectPreview('layout')"><span>Arrange<span class="gx-layout__long"> widgets</span></span></button>
<button class="gx-btn gx-btn--tonal" :aria-pressed="state.previewMode === 'device'" :disabled="!!state.drag" @click="selectPreview('device')"><span>Device<span class="gx-layout__long"> preview</span></span></button>
</nav>
<button v-if="state.previewMode === 'layout'" class="gx-icon-btn gx-layout__bar-toggle" type="button" :aria-pressed="state.snapEnabled" :disabled="!!state.drag || !!state.layerDrag" aria-label="Snap to edges and centres" title="Snap to edges and centres" @click="toggleSnap"><i class="bi bi-magnet" aria-hidden="true"></i></button>
<button v-if="!projection && profile.inputZones?.length" class="gx-icon-btn gx-layout__bar-toggle" type="button" :aria-pressed="state.showFavoriteZones" aria-label="Show Favorite tap areas" title="Show Favorite tap areas" @click="state.showFavoriteZones = !state.showFavoriteZones"><i class="bi bi-grid-3x3-gap" aria-hidden="true"></i></button>
<button class="gx-icon-btn gx-layout__bar-toggle" type="button" :aria-expanded="state.tipsOpen" aria-controls="gx-layout-tips" aria-label="Preview tips and display behavior" title="Preview tips and display behavior" @click="state.tipsOpen = !state.tipsOpen"><i class="bi bi-question-circle" aria-hidden="true"></i></button>
</div>
@@ -1176,6 +1229,7 @@ export const OnroadLayoutPage = {
</div>
<p v-if="state.previewMode === 'layout' && state.art.status === 'unavailable'" class="gx-note" role="status">Widget preview unavailable. Placement controls remain available.</p>
<div class="gx-layout__stage">
<span v-if="state.snapGuides.length" class="gx-layout__stage-chip gx-layout__stage-chip--start" :class="{'gx-layout__stage-chip--with-tap-areas': !projection && state.showFavoriteZones}" aria-hidden="true">{{ snapSummary }}</span>
<span v-if="!projection && state.showFavoriteZones && state.previewMode === 'layout'" class="gx-layout__stage-chip" aria-hidden="true"><i class="bi bi-grid-3x3-gap"></i>Tap areas</span>
<svg v-show="state.previewMode === 'layout'" ref="preview" class="gx-layout__preview" :viewBox="'0 0 ' + profile.width + ' ' + profile.height" :width="profile.width" :height="profile.height"
@pointermove="moveDrag" @pointerup="endDrag" @pointercancel="cancelDrag" @lostpointercapture="cancelDrag"
@@ -1230,6 +1284,11 @@ export const OnroadLayoutPage = {
<path d="M-6 6 L6 -6 M0 6 L6 0" stroke="#16121f" stroke-width="3" stroke-linecap="round" />
</g>
</g>
<g v-if="state.snapGuides.length" class="gx-layout__guides" aria-hidden="true" pointer-events="none">
<line v-for="guide in state.snapGuides" :key="guide.axis + guide.value" :class="'gx-layout__guide gx-layout__guide--' + guide.kind"
:x1="guide.axis === 'x' ? guide.value : 0" :x2="guide.axis === 'x' ? guide.value : profile.width"
:y1="guide.axis === 'y' ? guide.value : 0" :y2="guide.axis === 'y' ? guide.value : profile.height" vector-effect="non-scaling-stroke" />
</g>
<rect v-if="selectionBox" class="gx-layout__outline" :x="selectionBox.x" :y="selectionBox.y" :width="selectionBox.width" :height="selectionBox.height" rx="4" fill="none" stroke="#d4baff" stroke-width="2" vector-effect="non-scaling-stroke" />
</svg>
</div>
@@ -1249,6 +1308,7 @@ export const OnroadLayoutPage = {
</section>
<div v-show="state.tipsOpen" id="gx-layout-tips" class="gx-layout__tips">
<p class="gx-note">Drag widgets in Arrange widgets to place them. Readings are samples.</p>
<p class="gx-note">Widgets snap to the screen edges, the centre and other widgets. Turn snapping off with the magnet button, or hold Option (Alt) while dragging.</p>
<p class="gx-note">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.</p>
<p class="gx-note">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.</p>
<p v-if="!projection" class="gx-note">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.</p>