mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-12 15:33:45 +08:00
Add widget snapping functionality to onroad layout editor
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user