mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-10 06:23:43 +08:00
1292 lines
76 KiB
JavaScript
1292 lines
76 KiB
JavaScript
import { ONROAD_LAYOUT_TEMPLATE } from "./onroad-layout-template.js"
|
||
import { GxState } from "./state.js"
|
||
import { guardUnload } from "./unload-guard.js"
|
||
import { PollTimer, connectionError } from "./polling.js"
|
||
import { reactive, watch } from "../vendor/vue/vue.esm-browser.js"
|
||
import { LayoutPreviewFeed, PREVIEW_SCENES, PreviewQueue, previewScene, sceneHidesWidget } from "./layout-preview.js"
|
||
import { LEAD_INFO, PROJECTION_WORDMARK, editorSnapshot, projectionPayload } from "./projection-layout.js"
|
||
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 { showDraftChange } from "./save-feedback.js"
|
||
import { snapTargets, snapMove, snapResize, snapThreshold } from "./layout-snap.js"
|
||
|
||
import { PROFILES, ROAD_COLORS, PATH_MODES, HEX, PROJECTION_DISPLAY_SWITCHES, layoutDocument, previewDocument, withDisplay, orderedWidgetIds, widgetPalette, withAlpha, widgetSize, placementLimits, clampBox, clampPlacement, overlapsReserved, previewPoint, validDocument, validSnapshot } from "./onroad-layout-document.js"
|
||
export { layoutDocument, previewDocument, withDisplay, orderedWidgetIds, widgetPalette, withAlpha, widgetSize, placementLimits, clampBox, clampPlacement, overlapsReserved, previewPoint, validDocument, validSnapshot } from "./onroad-layout-document.js"
|
||
|
||
const previewVisibilityKey = projection => "gx-layout-preview-visibility-v2:" + (projection ? "starport" : "comma")
|
||
const oldProjectionVisibilityKey = "gx-layout-preview-visibility-v2:android-auto"
|
||
function loadPreviewVisibility(projection) {
|
||
const hidden = { large: {}, compact: {} }
|
||
try {
|
||
const currentKey = previewVisibilityKey(projection)
|
||
const raw = localStorage.getItem(currentKey) ?? (projection ? localStorage.getItem(oldProjectionVisibilityKey) : null)
|
||
const legacy = raw === null
|
||
const saved = JSON.parse(raw ?? localStorage.getItem(currentKey.replace('-v2:', '-v1:')))
|
||
for (const profile of PROFILES) {
|
||
const widgets = saved?.[profile]
|
||
if (!widgets || typeof widgets !== "object" || Array.isArray(widgets)) continue
|
||
hidden[profile] = {
|
||
...hidden[profile], ...Object.fromEntries(Object.entries(widgets)
|
||
.filter(([id, value]) => typeof value === "boolean" && !(projection && id === "car_exit") && !(legacy && id === "stopped_timer")))
|
||
}
|
||
}
|
||
} catch { /* Preview controls still work when browser storage is unavailable. */ }
|
||
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 }
|
||
}
|
||
const aidsPreferenceKey = "gx-layout-aids-v1"
|
||
function loadAidsPreference() {
|
||
try { return localStorage.getItem(aidsPreferenceKey) !== "0" } catch { return true }
|
||
}
|
||
// The backdrop hides every movable widget, so only the set of placed widgets, not their positions, changes it.
|
||
const backdropKey = (document) => ({ ...document, widgetOrder: undefined,
|
||
layouts: Object.fromEntries(Object.entries(document.layouts).map(([profile, widgets]) => [profile, Object.keys(widgets).sort()])) })
|
||
|
||
// Open maker pages; a route switch briefly mounts the next editor before the last one leaves.
|
||
let makerPagesOpen = 0
|
||
|
||
// Screen pixels a pointer may wobble before a press on a preview widget becomes a drag.
|
||
const DRAG_SLOP = 6
|
||
|
||
export class OnroadLayoutFeed {
|
||
constructor({ publish, projection = false, unauthorized = () => { }, fetcher = (...args) => fetch(...args),
|
||
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
|
||
Object.assign(this, { publish, projection, unauthorized, fetcher, later, cancelTimer })
|
||
this.active = false
|
||
this.generation = 0
|
||
this.request = this.timer = this.data = null
|
||
this.poller = new PollTimer({ read: () => this.load(true), interval: 5000, later, cancel: cancelTimer })
|
||
this.error = ""
|
||
this.needsReload = false
|
||
}
|
||
|
||
stop() {
|
||
this.poller.stop()
|
||
this.active = false
|
||
this.generation++
|
||
this.request?.abort()
|
||
if (this.timer !== null) this.cancelTimer(this.timer)
|
||
this.request = this.timer = null
|
||
}
|
||
|
||
start() { this.stop(); this.active = true; this.data = null; this.needsReload = false; this.poller.start(); return this.load() }
|
||
load(background = false) { return this.run(null, { background }) }
|
||
save(document) {
|
||
if (!this.data?.editable || this.needsReload || !validDocument(document, this.data.metadata)) return false
|
||
return this.run({ revision: this.data.revision, document: clone(document) })
|
||
}
|
||
|
||
async run(body = null, { background = false } = {}) {
|
||
if (!this.active) return
|
||
if (this.request) {
|
||
// Polls yield to anything in flight; an explicit reload replaces a pending poll.
|
||
if (background || this.saving) return
|
||
this.request.abort(); this.generation++; this.cancelTimer(this.timer)
|
||
this.request = this.timer = null
|
||
}
|
||
const saving = body !== null, generation = ++this.generation, request = new AbortController()
|
||
this.request = request
|
||
this.saving = saving
|
||
if (saving || !background || !this.data && !this.error) this.publish({ status: saving ? "saving" : "loading", notice: "" })
|
||
const fail = (message) => {
|
||
this.error = message
|
||
this.needsReload ||= saving
|
||
this.publish({ status: this.data ? "ready" : "unavailable", error: message, needsReload: this.needsReload })
|
||
}
|
||
this.timer = this.later(() => {
|
||
if (!this.active || generation !== this.generation) return
|
||
request.abort()
|
||
this.generation++
|
||
this.request = this.timer = null
|
||
fail(saving ? "Saving timed out. Your edits are kept here, but the saved result is unknown. Reload before saving again." :
|
||
"Reading colors and layouts timed out. Try reloading.")
|
||
}, 5000)
|
||
try {
|
||
const response = await this.fetcher(this.projection ? "./api/starport/layout" : "./api/ui/layout", {
|
||
credentials: "same-origin", cache: "no-store", signal: request.signal,
|
||
...(saving ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(this.projection ? projectionPayload(body, this.data.metadata) : body) } : {})
|
||
})
|
||
if (!this.active || generation !== this.generation) return
|
||
let data = await response.json().catch(() => null)
|
||
if (!this.active || generation !== this.generation) return
|
||
if (response.status === 401 || ["access_unavailable", "setup_required"].includes(data?.code)) {
|
||
this.stop(); this.unauthorized(); return
|
||
}
|
||
if (response.status === 409) throw new Error("Saved values or parked status changed. Your edits are kept here. Reload before saving again.")
|
||
if (!response.ok) throw new Error(saving ? "Colors and layout could not be saved. Your edits are kept here. Reload before trying again." :
|
||
"Colors and layout are unavailable. Try reloading.")
|
||
const mapMoved = saving && data.mapDisabledElsewhere === true
|
||
data = this.projection ? editorSnapshot(data) : withDisplay(data)
|
||
if (!validSnapshot(data)) throw new Error("The device returned an unsupported colors and layout document. Reload to try again.")
|
||
const initial = !this.data
|
||
this.data = data
|
||
this.error = ""
|
||
this.needsReload = false
|
||
this.publish({
|
||
status: "ready", data, ...((saving || initial || !background) ? { draft: clone(data.document) } : {}), error: "", needsReload: false,
|
||
notice: saving ? (this.projection ? "StarPort layout saved." : data.failed?.length ?
|
||
"Layout saved, but these display options did not change: " + data.failed.join(", ") + ". Reload and try again." :
|
||
"Colors and both layouts saved.") + (mapMoved ? " The map overlay is now off in the " + (this.projection ? "Big screen" : "StarPort") + " layout." : "") :
|
||
!background && !initial ? "Reloaded the saved layout." : ""
|
||
})
|
||
return true
|
||
} catch (error) {
|
||
if (this.active && generation === this.generation) fail(connectionError(error))
|
||
return false
|
||
} finally {
|
||
if (this.request === request) {
|
||
if (this.timer !== null) this.cancelTimer(this.timer)
|
||
this.request = this.timer = null
|
||
this.saving = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
export const LayoutWidgetPreview = {
|
||
props: ["widget", "palette", "profile", "scene", "clock24Hour", "native", "art"],
|
||
methods: {
|
||
withAlpha,
|
||
modeLines(scene, profile) {
|
||
const { mode, experimental } = previewScene(scene), aol = scene === "aol"
|
||
if (profile === "compact" && (aol || experimental)) return aol ? ["Always On", "Lateral"] : ["ACC -", "Experimental"]
|
||
return [mode]
|
||
},
|
||
pending(scene) { return scene === "general" || scene === "slc_pending" },
|
||
},
|
||
template: `
|
||
<g aria-hidden="true" class="gx-layout-widget-art" :fill="palette.text || '#ffffff'">
|
||
<template v-if="native">
|
||
<svg v-if="art?.url && art.sprites?.sprites?.[widget.id]"
|
||
:x="art.sprites.sprites[widget.id].offsetX || 0" :y="art.sprites.sprites[widget.id].offsetY || 0"
|
||
:width="art.sprites.sprites[widget.id].width" :height="art.sprites.sprites[widget.id].height"
|
||
:viewBox="[art.sprites.sprites[widget.id].x, art.sprites.sprites[widget.id].y, art.sprites.sprites[widget.id].width, art.sprites.sprites[widget.id].height].join(' ')"
|
||
preserveAspectRatio="xMinYMin meet" data-preview-source="runtime">
|
||
<image :href="art.url" :width="art.sprites.width" :height="art.sprites.height" />
|
||
</svg>
|
||
<g v-else data-preview-source="placeholder">
|
||
<rect x="1" y="1" :width="widget.width - 2" :height="widget.height - 2" fill="#0c182044" stroke="#a6dfbe66" stroke-dasharray="5 5" />
|
||
<text :x="widget.width / 2" :y="widget.height / 2" text-anchor="middle" :font-size="Math.min(24, widget.width / 10)">{{ widget.label }}</text>
|
||
</g>
|
||
</template>
|
||
<template v-else>
|
||
<template v-if="widget.kind === 'driving_mode_descriptions'">
|
||
<text :x="widget.width / 2" :y="widget.height / 2" text-anchor="middle" dominant-baseline="middle"
|
||
:font-size="profile === 'large' ? 48 : 21" font-weight="600" style="filter: drop-shadow(2px 2px 1px #000000aa)">
|
||
<tspan v-for="(line, index) in modeLines(scene, profile)" :key="index" :x="widget.width / 2"
|
||
:y="widget.height / 2 + (index - (modeLines(scene, profile).length - 1) / 2) * 25">{{ line }}</tspan>
|
||
</text>
|
||
</template>
|
||
<template v-else-if="widget.kind === 'stopped_timer' && ['general', 'stopped'].includes(scene)">
|
||
<text :x="widget.width / 2" :y="widget.height * 53 / 112" text-anchor="middle" dominant-baseline="middle" :font-size="widget.height * 64 / 112" font-weight="700" fill="#b67129"
|
||
style="filter: drop-shadow(2px 2px 1px #000000aa)">2 minutes</text>
|
||
<text :x="widget.width / 2" :y="widget.height * 101 / 112" text-anchor="middle" dominant-baseline="middle" :font-size="widget.height * 30 / 112" font-weight="500" fill="#fffffff2"
|
||
style="filter: drop-shadow(2px 2px 1px #000000aa)">14 seconds</text>
|
||
</template>
|
||
<template v-else-if="widget.kind === 'clock'">
|
||
<text :x="widget.width / 2" :y="widget.height / 2" text-anchor="middle" dominant-baseline="middle"
|
||
:font-size="profile === 'large' ? 48 : 21" font-weight="600"
|
||
style="filter: drop-shadow(2px 2px 1px #000000aa)">{{ clock24Hour ? '18:00' : '12:00 PM' }}</text>
|
||
</template>
|
||
<g v-else-if="widget.kind === 'now_playing'">
|
||
<rect x="0" y="0" :width="widget.width" :height="widget.height" :rx="widget.height / 2" fill="#0f0d17" :fill-opacity="(widget.opacity ?? 85) / 100 * .92" />
|
||
<circle :cx="widget.height / 2" :cy="widget.height / 2" r="30" fill="#c7aef7" />
|
||
<rect :x="widget.height / 2 - 9" :y="widget.height / 2 - 12" width="6" height="24" rx="3" fill="#0f0d17" />
|
||
<rect :x="widget.height / 2 + 3" :y="widget.height / 2 - 12" width="6" height="24" rx="3" fill="#0f0d17" />
|
||
<text :x="widget.height + 4" y="40" font-size="30" font-weight="600" fill="#f5f2fc">Midnight City</text>
|
||
<text :x="widget.height + 4" y="68" font-size="22" fill="#b8b2c8">M83</text>
|
||
<rect :x="widget.height + 4" :y="widget.height - 16" :width="widget.width - widget.height * 1.5 - 4" height="4" rx="2" fill="#ffffff" fill-opacity=".2" />
|
||
<rect :x="widget.height + 4" :y="widget.height - 16" :width="(widget.width - widget.height * 1.5 - 4) * .38" height="4" rx="2" fill="#c7aef7" />
|
||
</g>
|
||
<g v-else-if="widget.kind === 'jetlink_status'">
|
||
<rect x="0" y="0" :width="widget.width" :height="widget.height" :rx="widget.height / 2" fill="#0f0d17" :fill-opacity="(widget.opacity ?? 85) / 100 * .92" />
|
||
<circle :cx="widget.height / 2" :cy="widget.height / 2" r="9" fill="#46c86e" />
|
||
<text :x="widget.height - 6" y="38" font-size="28" font-weight="600" fill="#f5f2fc">Link up</text>
|
||
<text :x="widget.height - 6" y="66" font-size="22" fill="#b8b2c8">Jetlink • iPhone</text>
|
||
<text :x="widget.width - 70" :y="widget.height / 2 + 12" text-anchor="end" font-size="34" font-weight="600" fill="#f5f2fc">24.6</text>
|
||
<text :x="widget.width - 30" :y="widget.height / 2 + 12" text-anchor="end" font-size="22" fill="#b8b2c8">ms</text>
|
||
</g>
|
||
<template v-else-if="widget.kind === 'speed_limit'">
|
||
<text x="0" y="10" font-size="24" font-weight="500">LIMIT</text>
|
||
<path v-if="pending(scene)" d="M90 12 V-8 M84 -2 L90 -8 L96 -2" fill="none" :stroke="palette.text || '#ffffff'" stroke-width="2" />
|
||
<text x="0" y="66" font-size="58" font-weight="700">45<tspan dx="10" font-size="36" font-weight="500">+5</tspan></text>
|
||
</template>
|
||
<template v-else-if="widget.kind === 'speed_limit_actions'">
|
||
<template v-if="pending(scene)">
|
||
<line :x1="widget.width / 2" :x2="widget.width / 2" :y1="profile === 'large' ? 46 : 6" :y2="profile === 'large' ? 98 : 42" stroke="#9bb2adaa" />
|
||
<text v-for="(label, index) in ['Accept', 'Decline']" :key="label" :x="widget.width * (index ? .75 : .25)"
|
||
:y="profile === 'large' ? 72 : 24" text-anchor="middle" dominant-baseline="middle"
|
||
:font-size="profile === 'large' ? 64 : 50" font-weight="500">{{ label }}</text>
|
||
</template>
|
||
<text v-else-if="profile === 'large'" :x="widget.width / 2" y="72" text-anchor="middle" dominant-baseline="middle" font-size="64">Use limit 49</text>
|
||
</template>
|
||
<g v-else-if="widget.kind === 'torque_bar'" fill="none" stroke="white" stroke-linecap="round">
|
||
<path :d="'M 12 ' + widget.height * .8 + ' Q ' + widget.width / 2 + ' ' + widget.height * .55 + ' ' + (widget.width - 12) + ' ' + widget.height * .8" :stroke-width="widget.height * .18" opacity=".3" />
|
||
<path :d="'M ' + widget.width / 2 + ' ' + widget.height * .68 + ' Q ' + widget.width * .65 + ' ' + widget.height * .68 + ' ' + widget.width * .8 + ' ' + widget.height * .74" :stroke-width="widget.height * .18" opacity=".9" />
|
||
</g>
|
||
<g v-else-if="widget.kind === 'pip_camera'">
|
||
<circle :cx="widget.width / 2" :cy="widget.height / 2" :r="widget.width / 2 - 3" fill="#152c39" stroke="#5ee5ee" stroke-width="3" />
|
||
<text :x="widget.width / 2" :y="widget.height / 2" text-anchor="middle" :font-size="widget.height / 10">{{ widget.label }}</text>
|
||
</g>
|
||
<g v-else-if="['nav_home', 'nav_work', 'bookmark'].includes(widget.kind)" :opacity="(widget.opacity ?? 100) / 100">
|
||
<rect v-if="widget.background !== false" x="1" y="1" :width="widget.width - 2" :height="widget.height - 2" rx="17" fill="#0f0d17f5" stroke="#c7aef7" stroke-width="3" />
|
||
<g v-if="widget.display === 'icons'" transform="translate(23 23) scale(2.6667)" fill="none" stroke="#c7aef7" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||
<path v-if="widget.kind === 'nav_home'" d="M3 11 L12 3 L21 11 M5 9 V21 H10 V14 H14 V21 H19 V9" />
|
||
<path v-else-if="widget.kind === 'bookmark'" d="M6 3 H18 V21 L12 16 L6 21 Z" />
|
||
<path v-else d="M8 7 V4 H16 V7 M3 7 H21 V21 H3 Z M3 12 L10 15 H14 L21 12 M10 13 V17 H14 V13" />
|
||
</g>
|
||
<template v-else>
|
||
<text :x="widget.width / 2" :y="widget.kind === 'bookmark' ? 48 : 43" text-anchor="middle" :font-size="widget.kind === 'bookmark' ? 34 : 25" :font-weight="widget.kind === 'bookmark' ? 600 : 400" fill="#c7aef7">{{ widget.kind === 'bookmark' ? widget.label : 'Navigate' }}</text>
|
||
<text :x="widget.width / 2" :y="widget.kind === 'bookmark' ? 89 : 88" text-anchor="middle" :font-size="widget.kind === 'bookmark' ? 25 : 34" :font-weight="widget.kind === 'bookmark' ? 400 : 600" fill="#c7aef7">{{ widget.kind === 'bookmark' ? 'Save event' : widget.label }}</text>
|
||
</template>
|
||
</g>
|
||
<g v-else-if="widget.kind === 'car_exit'" :opacity="(widget.opacity ?? 100) / 100">
|
||
<rect v-if="widget.background !== false" x="1" y="1" :width="widget.width - 2" :height="widget.height - 2" rx="15" fill="#0f0d17a6" stroke="#c7aef7c8" stroke-width="3" />
|
||
<g data-preview-icon="door-exit" fill="none" stroke="#c7aef7e6" stroke-width="5" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M54 20 H24 V76 H54" />
|
||
<path d="M38 48 H78 M67 37 L78 48 L67 59" />
|
||
</g>
|
||
</g>
|
||
<g v-else-if="widget.kind === 'jetlink_status'">
|
||
<rect :width="widget.width" :height="widget.height" :rx="widget.height / 2" fill="#0f0d17" :fill-opacity="235 / 255 * (widget.opacity ?? 85) / 100" />
|
||
<circle :cx="widget.height / 2" :cy="widget.height / 2" r="9" fill="#46c86e" />
|
||
<text :x="widget.height - 6" y="40" font-size="28" font-weight="600" fill="#f5f2fc">Link up</text>
|
||
<text :x="widget.height - 6" y="68" font-size="22" fill="#b8b2c8">Jetlink • iPhone</text>
|
||
<text :x="widget.width - 70" y="52" text-anchor="end" font-size="34" font-weight="600" fill="#f5f2fc">24.6</text>
|
||
<text :x="widget.width - 30" y="52" text-anchor="end" font-size="22" fill="#b8b2c8">ms</text>
|
||
</g>
|
||
<g v-else-if="widget.kind === 'nav_card'">
|
||
<rect x="1" y="1" :width="widget.width - 2" :height="widget.height - 2" rx="39" fill="#0f0d17f5" stroke="#a07edcbe" stroke-width="2" />
|
||
<g transform="translate(24 47) scale(1.7)" fill="none" stroke="white" stroke-width="7" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M16 56 V30 Q16 18 28 18 H46" /><path d="M38 6 L50 18 L38 30" />
|
||
</g>
|
||
<text x="148" y="78" font-size="40" font-weight="600" fill="white">Turn right onto</text>
|
||
<text x="148" y="121" font-size="40" font-weight="600" fill="white">Las Vegas Blvd</text>
|
||
<text x="148" y="170" font-size="30" font-weight="700" fill="#c7aef7">500 ft</text>
|
||
<text :x="widget.width - 24" y="170" text-anchor="end" font-size="22" fill="#bebbc5">4.2 mi - 9 min</text>
|
||
</g>
|
||
<g v-else-if="widget.kind === 'nav_map'">
|
||
<defs>
|
||
<radialGradient id="gx-nav-map-fade" cx=".5" cy=".55" r=".75"><stop offset=".55" stop-color="white" /><stop offset="1" stop-color="white" stop-opacity="0" /></radialGradient>
|
||
<mask id="gx-nav-map-mask"><rect :width="widget.width" :height="widget.height" :rx="Math.min(widget.width, widget.height) * .08" fill="url(#gx-nav-map-fade)" /></mask>
|
||
</defs>
|
||
<g mask="url(#gx-nav-map-mask)" :transform="'scale(' + widget.width / 600 + ' ' + widget.height / 450 + ')'" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||
<g :opacity="(widget.opacity ?? 50) / 100">
|
||
<rect width="600" height="450" fill="#0b0f16" opacity=".45" />
|
||
<g stroke="#000000a8" vector-effect="non-scaling-stroke">
|
||
<path d="M-20 120 Q200 150 640 90 M90 -20 L130 480 M430 -20 Q400 200 470 480 M-20 330 L640 360 M260 -20 L250 480" stroke-width="11" vector-effect="non-scaling-stroke" />
|
||
</g>
|
||
<path d="M-20 120 Q200 150 640 90 M-20 330 L640 360" stroke="#ebf0f5" stroke-width="6" vector-effect="non-scaling-stroke" />
|
||
<path d="M90 -20 L130 480 M430 -20 Q400 200 470 480" stroke="#bec8d2" stroke-width="4" vector-effect="non-scaling-stroke" />
|
||
<path d="M260 -20 L250 480" stroke="#ffc45c" stroke-width="8" vector-effect="non-scaling-stroke" />
|
||
</g>
|
||
<path d="M300 330 Q258 300 254 250 L262 -20" stroke="#8f6bff55" stroke-width="20" vector-effect="non-scaling-stroke" />
|
||
<path d="M300 330 Q258 300 254 250 L262 -20" stroke="#a98bff" stroke-width="9" vector-effect="non-scaling-stroke" />
|
||
</g>
|
||
<g :transform="'translate(' + widget.width / 2 + ' ' + widget.height * .7 + ')'">
|
||
<circle r="22" fill="#8f6bff40" /><path d="M0 -17 L13 13 L0 6 L-13 13 Z" fill="white" stroke="#6e4fe0" stroke-width="3" stroke-linejoin="round" />
|
||
</g>
|
||
</g>
|
||
<template v-else>
|
||
<rect :width="widget.width" :height="widget.height" :fill="palette.cardFill" :stroke="palette.cardBorder" />
|
||
<text :x="widget.width / 2" :y="widget.height / 2" text-anchor="middle" :font-size="Math.min(32, widget.width / 10)">{{ widget.label }}</text>
|
||
</template>
|
||
</template>
|
||
</g>`,
|
||
}
|
||
|
||
export const OnroadLayoutPage = {
|
||
components: { GxState, LayoutWidgetPreview, GalaxySelect, GxNotice, GxDialog, GalaxySettingRow },
|
||
props: {
|
||
projection: { type: Boolean, default: false }, mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
|
||
initialPanel: { type: String, default: "widgets" }
|
||
},
|
||
emits: ["close", "target"],
|
||
setup(props) {
|
||
const state = reactive({
|
||
status: "idle", data: null, draft: null, needsReload: false, error: "", notice: "",
|
||
profile: "large", scene: "general", 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", showFavoriteZones: false, showEditingAids: loadAidsPreference(),
|
||
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 }, backdrop: { status: "idle", error: "", url: null },
|
||
hiddenPreviewWidgets: loadPreviewVisibility(props.projection), sceneHiddenPreviewWidgets: {}, snapEnabled: loadSnapPreference(), snapGuides: [], history: { undo: [], redo: [], group: null }
|
||
})
|
||
let deferred = null
|
||
const apply = (update) => {
|
||
if (state.drag || state.layerDrag) { deferred = { ...deferred, ...update }; return }
|
||
if (deferred) {
|
||
update = { ...deferred, ...update }
|
||
deferred = null
|
||
if (state.draft && state.data && JSON.stringify(state.draft) !== JSON.stringify(state.data.document)) {
|
||
update = { ...update }
|
||
delete update.draft
|
||
if (update.data && update.data.revision !== state.data.revision) {
|
||
update.needsReload = true
|
||
update.error = "Saved values changed while editing. Your edits are kept here. Reload before saving again."
|
||
}
|
||
}
|
||
}
|
||
|
||
if (update.data && state.data && !Object.hasOwn(update, "draft") && JSON.stringify(update.data.document) !== JSON.stringify(state.data.document)) {
|
||
if (JSON.stringify(state.draft) !== JSON.stringify(state.data.document)) {
|
||
update.needsReload = true
|
||
update.error = "Saved values changed while editing. Your edits are kept here. Reload saved to review them."
|
||
} else update.draft = clone(update.data.document)
|
||
}
|
||
if (state.needsReload && update.data && !Object.hasOwn(update, "draft")) {
|
||
update.needsReload = true
|
||
update.error = state.error
|
||
}
|
||
if (update.data) {
|
||
if (!state.data && update.data.activeProfile) state.profile = update.data.activeProfile
|
||
if (!update.data.metadata.profiles[state.profile]) state.profile = Object.keys(update.data.metadata.profiles)[0]
|
||
if (!Object.hasOwn(update.data.metadata.profiles[state.profile].widgets, state.selected))
|
||
state.selected = Object.keys(update.data.metadata.profiles[state.profile].widgets)[0] || null
|
||
state.colorError = ""
|
||
state.placementError = ""
|
||
}
|
||
const draftChanged = Object.hasOwn(update, "draft") && JSON.stringify(state.draft) !== JSON.stringify(update.draft)
|
||
for (const [key, value] of Object.entries(update)) {
|
||
if (JSON.stringify(state[key]) !== JSON.stringify(value)) state[key] = value
|
||
}
|
||
if (draftChanged || ["Colors and both layouts saved.", "StarPort layout saved."].some(saved => update.notice?.startsWith(saved))) state.history = { undo: [], redo: [], group: null }
|
||
}
|
||
const feed = new OnroadLayoutFeed({ projection: props.projection, unauthorized: props.unauthorized, publish: apply })
|
||
watch(() => !!state.drag || !!state.layerDrag, (dragging) => { if (!dragging && deferred) apply({}) }, { flush: "sync" })
|
||
const queue = new PreviewQueue()
|
||
const backdropFeed = new LayoutPreviewFeed({ backdrop: true, unauthorized: props.unauthorized, queue,
|
||
publish: update => Object.assign(state.backdrop, update) })
|
||
const artFeed = new LayoutPreviewFeed({ art: true, unauthorized: props.unauthorized, queue,
|
||
publish: update => Object.assign(state.art, update) })
|
||
watch(() => [state.draft && JSON.stringify(state.draft), state.profile, state.scene, !!state.drag,
|
||
!!state.layerDrag, state.data?.revision, state.data?.editable], () => {
|
||
if (props.mode !== "local" || !state.data?.editable || !state.draft) {
|
||
artFeed.stop()
|
||
return
|
||
}
|
||
if (!artFeed.active) artFeed.start()
|
||
const document = props.projection ? projectionPayload({document: state.draft}, state.data.metadata).document : layoutDocument(state.draft)
|
||
artFeed.update(document, props.projection ? "projection" : state.profile, state.scene, !!state.drag || !!state.layerDrag)
|
||
}, {flush: "sync"})
|
||
// The real screen behind the arrangement: status border, road, alerts and sidebar, without the movable widgets.
|
||
watch(() => [state.draft && JSON.stringify(backdropKey(state.draft)), state.profile, state.scene,
|
||
state.data?.revision, state.data?.editable], () => {
|
||
if (props.mode !== "local" || !state.data?.editable || !state.draft) {
|
||
backdropFeed.stop()
|
||
return
|
||
}
|
||
if (!backdropFeed.active) backdropFeed.start()
|
||
const document = props.projection ? projectionPayload({document: state.draft}, state.data.metadata).document : previewDocument(state.draft)
|
||
backdropFeed.update(document, props.projection ? "projection" : state.profile, state.scene, false, backdropKey(state.draft))
|
||
}, {flush: "sync"})
|
||
watch(() => props.mode, (mode) => {
|
||
if (mode !== "local") { backdropFeed.stop(); artFeed.stop() }
|
||
})
|
||
return { state, feed, backdropFeed, artFeed, scenes: PREVIEW_SCENES, projectionWordmark: PROJECTION_WORDMARK }
|
||
},
|
||
computed: {
|
||
busy() { return ["loading", "saving"].includes(this.state.status) },
|
||
editAvailable() { return !!this.state.data?.editable && !this.state.error && !this.state.needsReload && !this.state.discard },
|
||
editable() { return this.editAvailable && !this.busy },
|
||
dirty() { return !!this.state.draft && JSON.stringify(this.state.draft) !== JSON.stringify(this.state.data.document) },
|
||
stockChanged() { return !!this.state.draft && JSON.stringify(this.state.draft) !== JSON.stringify(this.state.data.defaults) },
|
||
canUndo() { return this.state.history.undo.length > 0 },
|
||
canRedo() { return this.state.history.redo.length > 0 },
|
||
pendingLeave() { return typeof this.state.discard === "function" || ["back", "device", "projection"].includes(this.state.discard) },
|
||
inlineLeavePrompt() { return (this.pendingLeave || this.state.discard === "reload") && this.state.leavePresentation !== "modal" },
|
||
modalLeavePrompt() { return this.pendingLeave && this.state.leavePresentation === "modal" },
|
||
saveAvailable() { return !!this.state.data?.editable && !this.state.error && !this.state.needsReload && this.dirty && !this.state.drag && !this.state.layerDrag },
|
||
canSavePending() { return this.saveAvailable && !this.busy },
|
||
profile() {
|
||
const profile = this.state.data?.metadata.profiles[this.state.profile], cruise = profile?.widgets?.cruise_limits
|
||
// Speed-limit sources draw inside the cruise card or as a small card under it, so its box grows to match.
|
||
if (!cruise?.sourcesExtent || this.state.draft?.speedSources !== true) return profile
|
||
const grow = this.state.draft.speedSourcesCard === true ? cruise.sourcesExtent.card : cruise.sourcesExtent.inline
|
||
return { ...profile, widgets: { ...profile.widgets, cruise_limits: { ...cruise, height: cruise.height + grow } } }
|
||
},
|
||
// Only the scenes that look different on this screen.
|
||
availableScenes() {
|
||
const screen = this.projection ? "projection" : this.state.profile
|
||
return this.scenes.filter(scene => !scene.hiddenOn?.includes(screen))
|
||
},
|
||
sceneGroups() {
|
||
return [["Editing", scene => scene.id === "general"], ["Driving", scene => scene.id !== "general" && !/^(cem|slc)_/.test(scene.id)], ["Conditional experimental", scene => scene.id.startsWith("cem_")],
|
||
["Speed limit", scene => scene.id.startsWith("slc_")]].map(([label, test]) => ({ label, scenes: this.availableScenes.filter(test) }))
|
||
},
|
||
sceneBorder() { return previewScene(this.state.scene).border },
|
||
expandedRoad() { return !this.projection && this.state.profile === "large" && this.displayValue("DeveloperSidebar") !== "On" },
|
||
roadBounds() { return this.expandedRoad ? { ...this.profile.bounds, width: this.profile.bounds.width + 300 } : this.profile.bounds },
|
||
// Widgets reach the whole screen only while the developer sidebar is hidden; otherwise the road beside it.
|
||
limitProfile() {
|
||
if (this.expandedRoad || this.projection || !this.profile) return this.profile
|
||
return { ...this.profile, widgets: Object.fromEntries(Object.entries(this.profile.widgets).map(([id, widget]) => {
|
||
if (!widget.expandable) return [id, widget]
|
||
const { bounds, ...narrow } = widget
|
||
return [id, narrow]
|
||
})) }
|
||
},
|
||
// The last screen for this profile stays up while the next scene renders, instead of flashing the sketch.
|
||
nativeBackdropReady() { return !!this.state.backdrop.url && this.state.backdrop.profile === (this.projection ? "projection" : this.state.profile) },
|
||
layout() { return this.state.draft?.layouts[this.state.profile] },
|
||
projectionHasPipWidget() {
|
||
return ["pip_left", "pip_right"].some(id => this.state.draft?.layouts?.large?.[id]?.enabled === true)
|
||
},
|
||
widgets() { return Object.entries(this.profile?.widgets || {})
|
||
.filter(([id, widget]) => widget.available !== false || this.layout?.[id]?.enabled)
|
||
.map(([id, widget]) => ({ id, ...widget })) },
|
||
layerWidgets() {
|
||
return orderedWidgetIds(this.state.draft, this.state.data.metadata, this.state.profile)
|
||
.slice().reverse().map(id => this.widgets.find(widget => widget.id === id)).filter(widget => widget && this.layout[widget.id].enabled)
|
||
},
|
||
listWidgets() { return this.state.layerDrag ? this.state.layerDrag.rows.map(id => this.widgets.find(widget => widget.id === id)) : this.layerWidgets },
|
||
selectionBox() {
|
||
if (!this.state.layerDrag) return null
|
||
const widget = this.renderWidgets.find(widget => widget.id === this.state.selected)
|
||
if (!widget) return null
|
||
return {
|
||
x: this.previewX(this.state.selected), y: this.selectedPosition.y - widget.visualInsetTop,
|
||
width: widget.width, height: widget.height + widget.visualInsetTop + widget.visualInsetBottom
|
||
}
|
||
},
|
||
activeWidgets() { return this.widgets.filter(({ id }) => this.layout[id].enabled) },
|
||
renderWidgets() {
|
||
return this.layerWidgets.filter(item => !this.previewHidden(item.id)).reverse().map((item) => {
|
||
const below = item.visualInsetTop && this.layout[item.id].y < item.visualInsetTop
|
||
const widget = {
|
||
...item, visualInsetTop: below ? 0 : item.visualInsetTop || 0,
|
||
visualInsetBottom: below ? item.visualInsetTop : 0, visualHeaderY: below ? item.height + 8 : -(item.visualInsetTop || 0)
|
||
}
|
||
const [width, height] = widgetSize(widget, this.layout[widget.id])
|
||
return {
|
||
...widget, width, height, ...(widget.iconSize ? { display: this.layout[widget.id].display } : {}),
|
||
...(widget.opacity ? { opacity: this.layout[widget.id].opacity } : {}),
|
||
...(widget.backgroundToggle ? { background: this.layout[widget.id].background } : {}),
|
||
...(widget.collapseToggle ? { collapse: this.layout[widget.id].collapse } : {})
|
||
}
|
||
})
|
||
},
|
||
inactiveWidgets() { return this.widgets.filter(({ id, available }) => available !== false &&
|
||
(!this.layout[id].enabled || (this.state.drag?.fromTray && this.state.drag.id === id))) },
|
||
availableProfiles() { return this.state.data?.supportedProfiles || (this.state.data?.activeProfile ? [this.state.data.activeProfile] : []) },
|
||
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] },
|
||
// A finger covers the preview chip, so touch drags show the alignment in the sticky save bar.
|
||
snapInBar() { return !!(this.state.drag?.touch && this.state.snapGuides.length) },
|
||
snapSummary() {
|
||
const guides = this.state.snapGuides, centered = new Set(guides.filter(guide => guide.kind === "center").map(guide => guide.axis))
|
||
if (centered.size === 2) return "Centered on the screen"
|
||
// A centered axis is described once; the other axis still says what it lines up with.
|
||
const labels = [...new Set(guides.filter(guide => !centered.has(guide.axis)).map(guide => guide.label))]
|
||
const others = labels.filter(label => label !== "the edge").slice(0, 2)
|
||
const text = [centered.has("x") ? "centered across" : centered.has("y") ? "centered 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) =>
|
||
Object.hasOwn(this.selectedWidget?.colors || {}, field.id))
|
||
},
|
||
roadStyle() { return this.state.draft?.roadColors[this.state.profile] || {} },
|
||
roadMode() { const mode = this.roadStyle.pathMode || 'acceleration'; return mode === 'default' ? 'acceleration' : mode },
|
||
roadColors() { return { ...Object.fromEntries((this.state.data?.metadata.roadColorFields || []).map(field => [field.id, field.default])), ...this.roadStyle } },
|
||
roadGradient() {
|
||
if (this.roadMode === 'rainbow') return Array.from({ length: 12 }, (_, i) => ({
|
||
offset: i / 11,
|
||
color: 'hsl(' + i / 11 * 120 + ',100%,50%)', alpha: .5 - .4 * i / 11
|
||
}))
|
||
if (this.roadMode === 'color') return [1, .55, .10].map((factor, i) => ({
|
||
offset: 1 - i / 2,
|
||
color: this.roadColors.path.slice(0, 7), alpha: Math.floor(this.alpha(this.roadColors.path) * factor) / 255
|
||
}))
|
||
return [{ offset: 0, color: '#72ff5c', alpha: 0 }, { offset: .5, color: '#72ff5c', alpha: 89 / 255 },
|
||
{ offset: 1, color: '#0df87a', alpha: 102 / 255 }]
|
||
},
|
||
limits() { return this.selectedWidget ? placementLimits(this.limitProfile, this.state.selected, this.layout) : null },
|
||
displayGroups() {
|
||
const groups = []
|
||
for (const row of this.state.data?.display || []) {
|
||
if (row.group === "widget") continue
|
||
const group = groups.find(item => item.name === row.group) || groups[groups.push({ name: row.group, rows: [] }) - 1]
|
||
group.rows.push(row)
|
||
}
|
||
return groups
|
||
},
|
||
stockConfidence() { return this.displayValue("StockConfidenceBallWidget") === "Off" },
|
||
},
|
||
mounted() {
|
||
if (this.mode === "local") this.feed.start()
|
||
this._stopUnloadGuard = guardUnload(() => this.dirty || this.state.status === "saving")
|
||
// Double-tap zoom stays off across the whole page, app bar included, while a maker page is open.
|
||
if (makerPagesOpen++ === 0) document.documentElement.classList.add("gx-maker-open")
|
||
this.observePinned()
|
||
},
|
||
updated() { this.observePinned() },
|
||
beforeUnmount() { this.cancelDrag(); this.feed.stop(); this.backdropFeed.stop(); this.artFeed.stop(); this._stopUnloadGuard(); this._pinObserver?.disconnect(); this.unwatchRelease(); this._closeFloating?.()
|
||
if (--makerPagesOpen === 0) document.documentElement.classList.remove("gx-maker-open")
|
||
if (this._onPinScroll) window.removeEventListener("scroll", this._onPinScroll, { capture: true })
|
||
},
|
||
methods: {
|
||
previewShift(id) {
|
||
return this.expandedRoad ? (this.profile.widgets[id]?.anchorShift || 0) * 300 : 0
|
||
},
|
||
// A position saved for the wider screen is shown pulled inside the road that is showing, as the device does.
|
||
previewX(id) {
|
||
const x = this.layout[id].x + this.previewShift(id), widget = this.profile.widgets[id]
|
||
if (!widget?.expandable) return x
|
||
const road = this.roadBounds
|
||
return Math.max(road.x, Math.min(x, road.x + road.width - widgetSize(widget, this.layout[id])[0]))
|
||
},
|
||
previewSnapTargets(id) {
|
||
if (!this.expandedRoad) return snapTargets(this.limitProfile, this.layout, id, key => !this.previewHidden(key))
|
||
const widget = this.profile.widgets[id], bounds = widget.bounds || this.profile.bounds, shift = this.previewShift(id)
|
||
const profile = { ...this.profile, widgets: { ...this.profile.widgets, [id]: { ...widget, bounds: { ...bounds, x: bounds.x + shift } } } }
|
||
const layout = Object.fromEntries(Object.entries(this.layout).map(([key, at]) => [key, { ...at, x: this.previewX(key) }]))
|
||
return snapTargets(profile, layout, id, key => !this.previewHidden(key))
|
||
.map(target => ({ ...target, value: target.value - (target.axis === "x" ? shift : 0) }))
|
||
},
|
||
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 { }
|
||
},
|
||
toggleEditingAids() {
|
||
this.state.showEditingAids = !this.state.showEditingAids
|
||
try { localStorage.setItem(aidsPreferenceKey, this.state.showEditingAids ? "1" : "0") } catch { }
|
||
},
|
||
sceneHidden(id) { return sceneHidesWidget(this.state.scene, this.state.profile, id, this.layout, this.profile?.widgets) },
|
||
previewHidden(id) {
|
||
if (this.projection && id === "car_exit") return false
|
||
if (this.sceneHidden(id)) return true
|
||
const hidden = this.state.scene === "general" ? this.state.hiddenPreviewWidgets : this.state.sceneHiddenPreviewWidgets[this.state.scene]
|
||
return hidden?.[this.state.profile]?.[id] === true
|
||
},
|
||
visibilityTitle(id, label) {
|
||
return this.sceneHidden(id) ? label + ' is hidden in this scene. Choose General edit to arrange it.' :
|
||
(this.previewHidden(id) ? 'Show ' : 'Hide ') + label + ' in this preview only'
|
||
},
|
||
togglePreviewVisibility(id) {
|
||
if (this.state.drag || this.state.layerDrag || (this.projection && id === "car_exit") || this.sceneHidden(id)) return
|
||
const preferences = this.state.scene === "general" ? this.state.hiddenPreviewWidgets :
|
||
(this.state.sceneHiddenPreviewWidgets[this.state.scene] ||= {})
|
||
const hidden = preferences[this.state.profile] ||= {}
|
||
hidden[id] = !hidden[id]
|
||
if (this.state.scene === "general") {
|
||
try { localStorage.setItem(previewVisibilityKey(this.projection), JSON.stringify(this.state.hiddenPreviewWidgets)) } catch { }
|
||
}
|
||
},
|
||
selectWidget(id) {
|
||
this.state.selected = id
|
||
this.state.inspectorPanel = "edit"
|
||
},
|
||
toggleColor(key) {
|
||
this.finishColorEdit()
|
||
this.state.openColor = this.state.openColor === key ? null : key
|
||
// Bring an opened color's picker fully into view.
|
||
if (this.state.openColor) this.$nextTick?.(() => this.$el?.querySelector?.(".gx-layout__color.is-open")?.scrollIntoView?.({ block: "nearest", behavior: "smooth" }))
|
||
},
|
||
// The more menu and the widget help close on any press outside them, including the app bar.
|
||
toggleFloating(key, ref) {
|
||
this.state[key] = !this.state[key]
|
||
this._closeFloating?.()
|
||
if (!this.state[key] || typeof document === "undefined") return
|
||
const outside = (event) => { if (!this.$refs[ref]?.contains(event.target)) { this.state[key] = false; this._closeFloating?.() } }
|
||
document.addEventListener("pointerdown", outside, true)
|
||
this._closeFloating = () => { document.removeEventListener("pointerdown", outside, true); this._closeFloating = null }
|
||
},
|
||
closeFloating(key) {
|
||
this.state[key] = false
|
||
this._closeFloating?.()
|
||
},
|
||
// Keeps a focused editor field clear of the pinned save bar, preview and tabs once the keyboard opens.
|
||
revealField(event) {
|
||
const field = event.target
|
||
if (!field?.matches?.("input:not([type=range]):not([type=checkbox]), .gx-field")) return
|
||
setTimeout(() => {
|
||
const tabs = this.$el?.querySelector?.(".gx-layout__sections")?.getBoundingClientRect()
|
||
const view = window.visualViewport, top = (tabs?.bottom || 0) + 12
|
||
const rect = field.getBoundingClientRect(), bottom = (view ? view.height + view.offsetTop : window.innerHeight) - 12
|
||
if (rect.top < top) window.scrollBy(0, rect.top - top)
|
||
else if (rect.bottom > bottom) window.scrollBy(0, rect.bottom - bottom)
|
||
}, 350)
|
||
},
|
||
// Narrow screens pin the save bar and preview; the editor tabs stick just below their measured height.
|
||
observePinned() {
|
||
if (typeof ResizeObserver === "undefined" || !this.$el?.style) return
|
||
this._pinObserver ||= new ResizeObserver(() => {
|
||
for (const [name, ref] of [["--layout-bar-h", "leaveBar"], ["--layout-pin-h", "pinned"]])
|
||
if (this.$refs[ref]) this.$el.style.setProperty(name, this.$refs[ref].getBoundingClientRect().height + "px")
|
||
})
|
||
// Page-colored fills only appear while a piece is actually pinned, so they never show over the backdrop otherwise.
|
||
if (!this._onPinScroll) {
|
||
this._onPinScroll = () => {
|
||
for (const ref of ["leaveBar", "pinned", "sections"]) {
|
||
const element = this.$refs[ref], style = element && getComputedStyle(element)
|
||
const stuck = style?.position === "sticky" && element.getBoundingClientRect().top <= (parseFloat(style.top) || 0) + 1
|
||
if (this.state.stuck[ref] !== stuck) this.state.stuck[ref] = stuck
|
||
}
|
||
}
|
||
window.addEventListener("scroll", this._onPinScroll, { passive: true, capture: true })
|
||
this._onPinScroll()
|
||
}
|
||
for (const ref of ["leaveBar", "pinned"]) {
|
||
const element = this.$refs[ref]
|
||
if (element && this._pinned?.[ref] !== element) {
|
||
if (this._pinned?.[ref]) this._pinObserver.unobserve(this._pinned[ref])
|
||
this._pinObserver.observe(element)
|
||
this._pinned = { ...this._pinned, [ref]: element }
|
||
}
|
||
}
|
||
},
|
||
reorderLayer(id, target) {
|
||
if (!this.editable || this.state.drag || id === target ||
|
||
this.profile.widgets[id]?.frontmost || this.profile.widgets[target]?.frontmost) return
|
||
const order = [...orderedWidgetIds(this.state.draft, this.state.data.metadata, this.state.profile)]
|
||
const from = order.indexOf(id), to = order.indexOf(target)
|
||
if (from < 0 || to < 0) return
|
||
const before = clone(this.state.draft)
|
||
order.splice(from, 1)
|
||
order.splice(to, 0, id)
|
||
this.state.draft.widgetOrder ||= {}
|
||
this.state.draft.widgetOrder[this.state.profile] = order
|
||
this.state.selected = id
|
||
this.recordChange(before)
|
||
},
|
||
startLayerDrag(event, id) {
|
||
if (!this.editable || this.state.drag || this.state.layerDrag || event.button !== 0 || this.profile.widgets[id]?.frontmost) return
|
||
this._suppressLayerClick = false
|
||
event.preventDefault()
|
||
event.currentTarget.setPointerCapture(event.pointerId)
|
||
const rect = event.currentTarget.closest("[data-layer-id]").getBoundingClientRect()
|
||
this.state.selected = id
|
||
this.state.layerDrag = {
|
||
id, pointerId: event.pointerId, target: null, startX: event.clientX, startY: event.clientY,
|
||
x: rect.x, y: rect.y, width: rect.width, offsetX: event.clientX - rect.x, offsetY: event.clientY - rect.y,
|
||
rows: this.layerWidgets.map(widget => widget.id), before: clone(this.state.draft), moved: false
|
||
}
|
||
this._layerTarget = event.currentTarget
|
||
this.watchRelease()
|
||
},
|
||
// A lost pointerup (capture dropped, native gesture, re-render) must never leave a drag active,
|
||
// since an active drag disables the tabs, preview switcher and navigation.
|
||
watchRelease() {
|
||
if (this._onRelease || typeof window === "undefined") return
|
||
this._onRelease = (event) => {
|
||
if (this.state.layerDrag?.pointerId === event.pointerId) this.endLayerDrag(event, event.type === "pointercancel")
|
||
if (this.state.drag?.pointerId === event.pointerId) event.type === "pointercancel" ? this.cancelDrag() : this.endDrag(event)
|
||
if (!this.state.drag && !this.state.layerDrag) this.unwatchRelease()
|
||
}
|
||
window.addEventListener("pointerup", this._onRelease)
|
||
window.addEventListener("pointercancel", this._onRelease)
|
||
},
|
||
unwatchRelease() {
|
||
if (!this._onRelease) return
|
||
window.removeEventListener("pointerup", this._onRelease)
|
||
window.removeEventListener("pointercancel", this._onRelease)
|
||
this._onRelease = null
|
||
},
|
||
moveLayerDrag(event) {
|
||
const drag = this.state.layerDrag
|
||
if (!drag || drag.pointerId !== event.pointerId) return
|
||
if (!drag.moved && Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) < 8) return
|
||
drag.moved = true
|
||
drag.x = event.clientX - drag.offsetX
|
||
drag.y = event.clientY - drag.offsetY
|
||
this._layerPoint = [event.clientX, event.clientY]
|
||
this.retargetLayer(event.clientX, event.clientY)
|
||
this.autoScrollLayer()
|
||
},
|
||
retargetLayer(clientX, clientY) {
|
||
const drag = this.state.layerDrag
|
||
if (!drag) return
|
||
// A finger held over the pinned tabs or preview targets the first row visible under them.
|
||
const tabs = this.$el?.querySelector?.(".gx-layout__sections")?.getBoundingClientRect()
|
||
const row = document.elementFromPoint(clientX, tabs ? Math.max(clientY, tabs.bottom + 6) : clientY)?.closest('[data-layer-id]')
|
||
const target = row?.dataset.layerId
|
||
if (target && target !== drag.target && !this.profile.widgets[target]?.frontmost) {
|
||
drag.target = target
|
||
const order = [...orderedWidgetIds(drag.before, this.state.data.metadata, this.state.profile)]
|
||
const from = order.indexOf(drag.id), to = order.indexOf(target)
|
||
drag.after = drag.rows.indexOf(target) > drag.rows.indexOf(drag.id)
|
||
order.splice(from, 1)
|
||
order.splice(to, 0, drag.id)
|
||
this.state.draft.widgetOrder ||= {}
|
||
this.state.draft.widgetOrder[this.state.profile] = order
|
||
}
|
||
},
|
||
// Scrolls while a dragged row is held near the visible edges; the top edge is the bottom of the pinned tabs.
|
||
autoScrollLayer() {
|
||
if (this._layerScroll || typeof requestAnimationFrame === "undefined") return
|
||
const step = () => {
|
||
this._layerScroll = null
|
||
const drag = this.state.layerDrag, point = this._layerPoint
|
||
if (!drag?.moved || !point) return
|
||
const tabs = this.$el?.querySelector?.(".gx-layout__sections")?.getBoundingClientRect()
|
||
const top = Math.max(60, tabs ? tabs.bottom : 0) + 40, bottom = window.innerHeight - 60
|
||
const speed = point[1] < top ? -Math.min(24, 6 + (top - point[1]) / 3) : point[1] > bottom ? Math.min(24, 6 + (point[1] - bottom) / 3) : 0
|
||
if (!speed) return
|
||
const before = window.scrollY
|
||
window.scrollBy(0, speed)
|
||
if (window.scrollY !== before) this.retargetLayer(point[0], point[1])
|
||
this._layerScroll = requestAnimationFrame(step)
|
||
}
|
||
this._layerScroll = requestAnimationFrame(step)
|
||
},
|
||
endLayerDrag(event, cancel = false) {
|
||
const drag = this.state.layerDrag
|
||
if (!drag || drag.pointerId !== event.pointerId) return
|
||
if (cancel) this.state.draft = drag.before
|
||
else if (drag.moved) this.recordChange(drag.before)
|
||
this.state.layerDrag = null
|
||
this._layerPoint = null
|
||
if (this._layerScroll) { cancelAnimationFrame(this._layerScroll); this._layerScroll = null }
|
||
if (this._layerTarget?.hasPointerCapture(event.pointerId)) this._layerTarget.releasePointerCapture(event.pointerId)
|
||
this._layerTarget = null
|
||
this._suppressLayerClick = cancel || drag.moved
|
||
},
|
||
layerClick(event, id) {
|
||
if (event.detail === 0 || !this._suppressLayerClick) this.selectWidget(id)
|
||
this._suppressLayerClick = false
|
||
},
|
||
layerKey(event, id) {
|
||
if (event.key === 'Escape' && this.state.layerDrag) {
|
||
event.preventDefault()
|
||
this.endLayerDrag({ pointerId: this.state.layerDrag.pointerId }, true)
|
||
return
|
||
}
|
||
if (this.state.layerDrag) return
|
||
const direction = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0
|
||
if (!direction) return
|
||
event.preventDefault()
|
||
const index = this.layerWidgets.findIndex(widget => widget.id === id)
|
||
const target = this.layerWidgets[index + direction]
|
||
if (target) this.reorderLayer(id, target.id)
|
||
},
|
||
finishColorEdit() { this.state.history.group = null },
|
||
recordChange(before, group = null) {
|
||
if (JSON.stringify(before) === JSON.stringify(this.state.draft)) return
|
||
const history = this.state.history
|
||
if (!group || history.group !== group) {
|
||
history.undo.push(before)
|
||
if (history.undo.length > 50) history.undo.shift()
|
||
}
|
||
history.redo = []
|
||
history.group = group
|
||
},
|
||
undo() {
|
||
if (!this.editable || this.state.drag || !this.canUndo) return
|
||
this.finishColorEdit()
|
||
const history = this.state.history
|
||
history.redo.push(clone(this.state.draft))
|
||
this.state.draft = history.undo.pop()
|
||
this.state.colorError = this.state.placementError = this.state.notice = ""
|
||
},
|
||
redo() {
|
||
if (!this.editable || this.state.drag || !this.canRedo) return
|
||
this.finishColorEdit()
|
||
const history = this.state.history
|
||
history.undo.push(clone(this.state.draft))
|
||
this.state.draft = history.redo.pop()
|
||
this.state.colorError = this.state.placementError = this.state.notice = ""
|
||
},
|
||
resetToStock() {
|
||
if (!this.editable || this.state.drag || !this.stockChanged) return
|
||
const before = clone(this.state.draft)
|
||
this.state.draft = clone(this.state.data.defaults)
|
||
this.recordChange(before)
|
||
this.state.colorError = this.state.placementError = ""
|
||
this.state.notice = "Stock StarPilot colors and both layouts are restored in your draft. Save to apply them."
|
||
},
|
||
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
|
||
this.state.placementError = ""
|
||
if (!this.availableScenes.some(scene => scene.id === this.state.scene)) this.state.scene = "general"
|
||
},
|
||
changePosition(id, x, y) {
|
||
if (!this.editable) return
|
||
const position = clampPlacement(this.limitProfile, id, x, y, this.layout)
|
||
if (position) {
|
||
const before = this.state.drag ? null : clone(this.state.draft)
|
||
Object.assign(this.layout[id], position)
|
||
if (before) this.recordChange(before)
|
||
this.state.placementError = ""
|
||
} else this.state.placementError = "Keep widgets within the screen and clear of Speed limit actions. The last valid position is kept."
|
||
this.state.notice = ""
|
||
},
|
||
add(id) {
|
||
if (!this.editable || this.state.drag || !Object.hasOwn(this.profile.widgets, id) || this.profile.widgets[id].available === false) return
|
||
const before = clone(this.state.draft)
|
||
this.layout[id].enabled = true
|
||
if (this.sceneHidden(id)) this.state.scene = "general"
|
||
this.recordChange(before)
|
||
this.state.selected = id
|
||
this.state.notice = ""
|
||
},
|
||
remove(id = this.state.selected) {
|
||
if (!this.editable || this.state.drag || !Object.hasOwn(this.profile.widgets, id) || this.profile.widgets[id].required) return
|
||
const before = clone(this.state.draft)
|
||
this.layout[id].enabled = false
|
||
this.recordChange(before)
|
||
this.state.notice = ""
|
||
},
|
||
requestRemove(id = this.state.selected) {
|
||
if (!this.editable || this.state.drag || this.state.layerDrag || !this.layout?.[id]?.enabled ||
|
||
!Object.hasOwn(this.profile.widgets, id) || this.profile.widgets[id].required) return
|
||
this.state.selected = id
|
||
this.state.removeConfirm = id
|
||
},
|
||
cancelRemove() { this.state.removeConfirm = null },
|
||
confirmRemove() {
|
||
const id = this.state.removeConfirm
|
||
this.state.removeConfirm = null
|
||
this.remove(id)
|
||
},
|
||
setClock24Hour(event) {
|
||
if (!this.editable || this.state.drag || this.state.layerDrag || this.selectedWidget?.kind !== "clock") return
|
||
const before = clone(this.state.draft)
|
||
this.state.draft.clock24Hour = event.target.checked
|
||
this.recordChange(before)
|
||
this.state.notice = ""
|
||
showDraftChange()
|
||
},
|
||
displayRow(key) { return this.state.data?.display?.find(row => row.key === key) },
|
||
displayValue(key) { return this.state.draft?.display?.[key] },
|
||
displayAvailable(row) { return !!row?.available && (!row.requires || this.displayValue(row.requires) === "On") },
|
||
displaySettingRow(row) {
|
||
const blocked = row.requires && this.displayValue(row.requires) !== "On"
|
||
return {
|
||
label: row.label, value: this.displayValue(row.key), choices: row.choices, available: this.displayAvailable(row),
|
||
reason: blocked ? "Turn on Lead Indicator first" : row.reason, action: true
|
||
}
|
||
},
|
||
setDisplay(key, value) {
|
||
const row = this.displayRow(key)
|
||
if (!this.editable || this.state.drag || this.state.layerDrag || !this.displayAvailable(row) || !row.choices.includes(value)) return
|
||
const before = clone(this.state.draft)
|
||
this.state.draft.display[key] = value
|
||
this.recordChange(before)
|
||
this.state.notice = ""
|
||
},
|
||
setShowMaxSpeed(event) {
|
||
if (!this.editable || this.state.drag || this.state.layerDrag || this.selectedWidget?.kind !== "cruise_limits") return
|
||
const before = clone(this.state.draft)
|
||
this.state.draft.showMaxSpeed = event.target.checked
|
||
this.recordChange(before)
|
||
this.state.notice = ""
|
||
showDraftChange()
|
||
},
|
||
setSpeedSources(key, value) {
|
||
if (!this.editable || this.state.drag || this.state.layerDrag || this.selectedWidget?.kind !== "cruise_limits" ||
|
||
!["speedSources", "speedSourcesCard"].includes(key)) return
|
||
const before = clone(this.state.draft), oldHeight = this.profile.widgets.cruise_limits.height
|
||
const widgets = this.state.data.metadata.profiles[this.state.profile].widgets
|
||
const dx = widgets.speed_limit_actions.default.x - widgets.cruise_limits.default.x
|
||
const gap = widgets.speed_limit_actions.default.y - widgets.cruise_limits.default.y - widgets.cruise_limits.height
|
||
const sourceGap = widgets.cruise_limits.sourcesExtent?.actionGap ?? gap
|
||
const oldGap = this.state.draft.speedSources === true ? sourceGap : gap
|
||
this.state.draft[key] = value === true
|
||
const card = this.layout.cruise_limits, actions = this.layout.speed_limit_actions, height = this.profile.widgets.cruise_limits.height
|
||
// Actions that sat right under the card follow its new bottom; both stay on screen.
|
||
if (actions && actions.x === card.x + dx && actions.y === card.y + oldHeight + oldGap) {
|
||
const nextGap = this.state.draft.speedSources === true ? sourceGap : gap
|
||
card.y = Math.min(card.y, placementLimits(this.limitProfile, "speed_limit_actions", this.layout).maxY - height - nextGap)
|
||
actions.y = card.y + height + nextGap
|
||
}
|
||
for (const id of ["cruise_limits", "speed_limit_actions"].filter(id => this.layout[id])) {
|
||
const limits = placementLimits(this.limitProfile, id, this.layout)
|
||
this.layout[id].y = Math.max(limits.minY, Math.min(this.layout[id].y, limits.maxY))
|
||
}
|
||
this.recordChange(before)
|
||
this.state.notice = ""
|
||
},
|
||
setLargeUiGammaTrial(value) {
|
||
if (!this.projection || !this.editable || this.state.drag || this.state.layerDrag) return
|
||
const before = clone(this.state.draft)
|
||
this.state.draft.largeUiGammaTrial = value === "On"
|
||
this.recordChange(before)
|
||
this.state.notice = "Save to apply the camera effect on StarPort."
|
||
},
|
||
setProjectionDisplay(key, value) {
|
||
if (!this.projection || !this.editable || this.state.drag || this.state.layerDrag || !PROJECTION_DISPLAY_SWITCHES.includes(key)) return
|
||
if (key === "blindSpotCamera" && !this.projectionHasPipWidget) return
|
||
const before = clone(this.state.draft)
|
||
this.state.draft[key] = value === "On"
|
||
this.recordChange(before)
|
||
this.state.notice = "Save to apply the screen options on StarPort."
|
||
},
|
||
setProjectionLeadInfo(value) {
|
||
if (!this.projection || !this.editable || this.state.drag || this.state.layerDrag ||
|
||
this.state.draft.leadIndicator !== true || !LEAD_INFO.includes(value)) return
|
||
const before = clone(this.state.draft)
|
||
this.state.draft.leadInfo = value
|
||
this.recordChange(before)
|
||
this.state.notice = "Save to apply the screen options on StarPort."
|
||
},
|
||
projectionLeadInfoRow() {
|
||
const marker = this.state.draft.leadIndicator === true
|
||
return { label: "Lead Info", value: LEAD_INFO.includes(this.state.draft.leadInfo) ? this.state.draft.leadInfo : "Off",
|
||
choices: LEAD_INFO, available: marker, reason: marker ? "" : "Turn on Lead Indicator first", action: true }
|
||
},
|
||
setFavoriteDisplay(display) {
|
||
if (!this.editable || this.state.drag || this.state.layerDrag || !this.selectedWidget?.iconSize ||
|
||
!["words", "icons"].includes(display)) return
|
||
this.finishColorEdit()
|
||
const before = clone(this.state.draft)
|
||
const [oldWidth] = widgetSize(this.selectedWidget, this.selectedPosition)
|
||
this.selectedPosition.display = display
|
||
if (["nav_home", "nav_work"].includes(this.selectedWidget.kind)) {
|
||
const [newWidth] = widgetSize(this.selectedWidget, this.selectedPosition)
|
||
this.selectedPosition.x += (oldWidth - newWidth) / 2
|
||
}
|
||
Object.assign(this.selectedPosition, clampPlacement(this.limitProfile, this.state.selected,
|
||
this.selectedPosition.x, this.selectedPosition.y, this.layout))
|
||
this.recordChange(before)
|
||
this.state.notice = ""
|
||
},
|
||
positionInput(axis, event) {
|
||
const number = event.target.value.trim() === "" ? NaN : Number(event.target.value)
|
||
if (Number.isFinite(number)) this.changePosition(this.state.selected,
|
||
axis === "x" ? number : this.selectedPosition.x, axis === "y" ? number : this.selectedPosition.y)
|
||
event.target.value = this.selectedPosition[axis]
|
||
},
|
||
resizeSelected(event) {
|
||
if (!this.editable || this.state.drag || !this.selectedWidget?.resizable) return
|
||
const size = Number(event.target.value), range = this.selectedWidget.resizable
|
||
if (!Number.isInteger(size) || size < range.min || size > range.max) {
|
||
event.target.value = this.selectedPosition.size
|
||
return
|
||
}
|
||
const before = clone(this.state.draft), position = this.selectedPosition
|
||
position.size = size
|
||
const next = clampPlacement(this.limitProfile, this.state.selected, position.x, position.y, this.layout)
|
||
if (next) {
|
||
Object.assign(position, next)
|
||
this.recordChange(before)
|
||
this.state.placementError = ""
|
||
} else {
|
||
Object.assign(position, before.layouts[this.state.profile][this.state.selected])
|
||
this.state.placementError = "That size would cover Speed limit actions. The last valid size is kept."
|
||
}
|
||
event.target.value = position.size
|
||
},
|
||
onKey(id, event) {
|
||
if (!this.editable) return
|
||
const movement = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]
|
||
if (movement) {
|
||
event.preventDefault()
|
||
const step = event.shiftKey ? 10 : 1, position = this.layout[id]
|
||
this.changePosition(id, position.x + movement[0] * step, position.y + movement[1] * step)
|
||
} else if (["Delete", "Backspace"].includes(event.key) && !this.profile.widgets[id]?.required) { event.preventDefault(); this.requestRemove(id) }
|
||
else if (["Enter", " "].includes(event.key)) { event.preventDefault(); this.state.selected = id }
|
||
},
|
||
startDrag(id, event, fromTray = false) {
|
||
if (!this.editable || event.button > 0 || this.state.drag || !Object.hasOwn(this.profile.widgets, id)) return
|
||
if (fromTray && this.profile.widgets[id].available === false) return
|
||
if (fromTray && this.sceneHidden(id)) this.state.scene = "general"
|
||
const point = previewPoint(event, this.$refs.preview.getBoundingClientRect(), this.profile)
|
||
if (!point) return
|
||
const position = this.layout[id], widget = this.profile.widgets[id]
|
||
const [width, height] = widgetSize(widget, position)
|
||
this.state.selected = id
|
||
this.finishColorEdit()
|
||
this.state.drag = {
|
||
id, pointerId: event.pointerId, touch: event.pointerType === "touch", fromTray, before: { ...position }, historyBefore: clone(this.state.draft), inside: !fromTray,
|
||
startX: event.clientX, startY: event.clientY, moved: false,
|
||
offsetX: fromTray ? width / 2 + this.previewShift(id) : point.x - position.x,
|
||
offsetY: fromTray ? height / 2 : point.y - position.y
|
||
}
|
||
this._dragTarget = this.$refs.preview
|
||
this.watchRelease()
|
||
try { this._dragTarget.setPointerCapture?.(event.pointerId) } catch { }
|
||
event.preventDefault()
|
||
},
|
||
startResize(id, event) {
|
||
const widget = this.profile.widgets[id]
|
||
if (!this.editable || event.button > 0 || this.state.drag || !widget?.box) return
|
||
const point = previewPoint(event, this.$refs.preview.getBoundingClientRect(), this.profile)
|
||
if (!point) return
|
||
const position = this.layout[id]
|
||
this.state.selected = id
|
||
this.finishColorEdit()
|
||
this.state.drag = {
|
||
id, pointerId: event.pointerId, touch: event.pointerType === "touch", resize: true, before: { ...position }, historyBefore: clone(this.state.draft),
|
||
inside: true, offsetX: point.x - position.width, offsetY: point.y - position.height
|
||
}
|
||
this._dragTarget = this.$refs.preview
|
||
this.watchRelease()
|
||
try { this._dragTarget.setPointerCapture?.(event.pointerId) } catch { }
|
||
event.stopPropagation()
|
||
event.preventDefault()
|
||
},
|
||
resizeBox(id, width, height, group = null) {
|
||
if (!this.editable) return false
|
||
const position = this.layout[id], next = clampBox(this.limitProfile, id, position, width, height)
|
||
if (!next) return false
|
||
const before = this.state.drag ? null : clone(this.state.draft)
|
||
Object.assign(position, next)
|
||
if (before) this.recordChange(before, group)
|
||
this.state.placementError = this.state.notice = ""
|
||
return true
|
||
},
|
||
boxSlider(axis, event) {
|
||
const number = Number(event.target.value)
|
||
if (!Number.isFinite(number) || this.state.drag || this.state.layerDrag) return
|
||
this.resizeBox(this.state.selected, axis === "width" ? number : this.selectedPosition.width,
|
||
axis === "height" ? number : this.selectedPosition.height, this.state.profile + ":" + this.state.selected + ":" + axis)
|
||
},
|
||
sizeSlider(event) {
|
||
const size = Number(event.target.value), range = this.selectedWidget?.resizable
|
||
if (!this.editable || this.state.drag || this.state.layerDrag || !range || !Number.isInteger(size) || size < range.min || size > range.max) return
|
||
const before = clone(this.state.draft), position = this.selectedPosition, previous = position.size
|
||
position.size = size
|
||
const next = clampPlacement(this.limitProfile, this.state.selected, position.x, position.y, this.layout)
|
||
if (next) {
|
||
Object.assign(position, next)
|
||
this.recordChange(before, this.state.profile + ":" + this.state.selected + ":size")
|
||
this.state.placementError = ""
|
||
} else {
|
||
position.size = previous
|
||
event.target.value = previous
|
||
this.state.placementError = "That size would cover Speed limit actions. The last valid size is kept."
|
||
}
|
||
},
|
||
boxInput(axis, event) {
|
||
const number = event.target.value.trim() === "" ? NaN : Number(event.target.value)
|
||
if (Number.isFinite(number)) this.resizeBox(this.state.selected, axis === "width" ? number : this.selectedPosition.width,
|
||
axis === "height" ? number : this.selectedPosition.height)
|
||
event.target.value = this.selectedPosition[axis]
|
||
},
|
||
opacityInput(event) {
|
||
const value = Number(event.target.value), range = this.selectedWidget?.opacity
|
||
if (!this.editable || this.state.drag || this.state.layerDrag || !range || !Number.isInteger(value) || value < range.min || value > range.max) return
|
||
const before = clone(this.state.draft)
|
||
this.selectedPosition.opacity = value
|
||
this.recordChange(before, this.state.profile + ":" + this.state.selected + ":opacity")
|
||
},
|
||
setWidgetBackground(value) {
|
||
if (!this.editable || this.state.drag || this.state.layerDrag || !this.selectedWidget?.backgroundToggle) return
|
||
this.finishColorEdit()
|
||
const before = clone(this.state.draft)
|
||
this.selectedPosition.background = value === 'On'
|
||
this.recordChange(before)
|
||
this.state.notice = ""
|
||
},
|
||
setWidgetCollapse(value) {
|
||
if (!this.editable || this.state.drag || this.state.layerDrag || !this.selectedWidget?.collapseToggle) return
|
||
this.finishColorEdit()
|
||
const before = clone(this.state.draft)
|
||
this.selectedPosition.collapse = value === 'On'
|
||
this.recordChange(before)
|
||
this.state.notice = ""
|
||
},
|
||
moveDrag(event) {
|
||
const drag = this.state.drag
|
||
if (!drag || drag.pointerId !== event.pointerId) return
|
||
const rect = this.$refs.preview.getBoundingClientRect(), point = previewPoint(event, rect, this.profile)
|
||
this.state.snapGuides = []
|
||
if (!point) 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 ||= this.previewSnapTargets(drag.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 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 ||= this.previewSnapTargets(drag.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.limitProfile, 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.limitProfile, 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.limitProfile, 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
|
||
},
|
||
endDrag(event) {
|
||
const drag = this.state.drag
|
||
if (!drag || drag.pointerId !== event.pointerId) return
|
||
this.moveDrag(event)
|
||
if (drag.fromTray && !drag.inside) Object.assign(this.layout[drag.id], drag.before)
|
||
const before = drag.historyBefore, position = this.layout[drag.id]
|
||
if (!drag.fromTray && !drag.resize && !drag.moved) this.state.inspectorPanel = "edit"
|
||
this.releaseDrag()
|
||
this.recordChange(before)
|
||
},
|
||
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
|
||
},
|
||
cancelDrag() {
|
||
if (this.state.layerDrag) this.endLayerDrag({ pointerId: this.state.layerDrag.pointerId }, true)
|
||
const drag = this.state.drag
|
||
if (drag) Object.assign(this.layout[drag.id], drag.before)
|
||
this.releaseDrag()
|
||
},
|
||
resetLayout() {
|
||
if (!this.editable || this.state.drag) return
|
||
const before = clone(this.state.draft)
|
||
this.state.draft.layouts[this.state.profile] = clone(this.state.data.defaults.layouts[this.state.profile])
|
||
if (this.state.draft.widgetOrder) {
|
||
delete this.state.draft.widgetOrder[this.state.profile]
|
||
if (!Object.keys(this.state.draft.widgetOrder).length) delete this.state.draft.widgetOrder
|
||
}
|
||
this.recordChange(before)
|
||
this.state.placementError = ""
|
||
this.state.notice = "This layout is reset in your draft. Save to apply it."
|
||
},
|
||
resetColors() {
|
||
if (!this.editable || this.state.drag || !this.colorFields.length) return
|
||
const before = clone(this.state.draft)
|
||
const profile = this.state.profile, id = this.state.selected
|
||
delete this.state.draft.widgetColors[profile][id]
|
||
const inherited = this.selectedColors
|
||
const stock = widgetPalette(this.state.data.defaults, this.state.data.metadata, profile, id)
|
||
const overrides = Object.fromEntries(Object.entries(stock).filter(([key, value]) => value !== inherited[key]))
|
||
if (Object.keys(overrides).length) this.state.draft.widgetColors[profile][id] = overrides
|
||
this.recordChange(before)
|
||
this.state.colorError = ""
|
||
this.state.notice = "This widget’s colors are reset in your draft. Save to apply them."
|
||
},
|
||
roadPoints(points) {
|
||
const r = this.roadBounds
|
||
return points.map(([x, y]) => (r.x + x * r.width) + ',' + (r.y + y * r.height)).join(' ')
|
||
},
|
||
roadLabel(id) {
|
||
if (id === 'pathEdge') return this.state.profile === 'large' ? 'Path border' : 'Closest lane markings'
|
||
if (id === 'laneLines') return this.state.profile === 'large' ? 'Lane lines' : 'Outer lane lines'
|
||
return 'Path'
|
||
},
|
||
setRoadMode(mode) {
|
||
if (!this.editable || this.state.drag || !PATH_MODES.includes(mode)) return
|
||
const before = clone(this.state.draft)
|
||
this.state.draft.roadColors[this.state.profile].pathMode = mode
|
||
this.recordChange(before)
|
||
},
|
||
roadColor(id, value, group = null) {
|
||
if (!this.editable || this.state.drag || !ROAD_COLORS.includes(id)) return
|
||
if (!HEX.test(value)) { this.state.colorError = "Use eight hex digits, for example #30FF9CFF."; return }
|
||
const before = clone(this.state.draft)
|
||
this.state.draft.roadColors[this.state.profile][id] = value.toUpperCase()
|
||
if (id === 'path') this.state.draft.roadColors[this.state.profile].pathMode = 'color'
|
||
this.recordChange(before, group && this.state.profile + ':road:' + group)
|
||
this.state.colorError = this.state.notice = ''
|
||
},
|
||
roadRgb(id, event) {
|
||
const alpha = this.roadColors[id].slice(7)
|
||
this.roadColor(id, event.target.value + (alpha === '00' ? 'FF' : alpha), 'rgb:' + id)
|
||
},
|
||
roadAlpha(id, event) {
|
||
const value = Number(event.target.value)
|
||
if (Number.isInteger(value) && value >= 0 && value <= 255)
|
||
this.roadColor(id, this.roadColors[id].slice(0, 7) + value.toString(16).padStart(2, '0'), 'alpha:' + id)
|
||
},
|
||
resetRoad() {
|
||
if (!this.editable || this.state.drag) return
|
||
const before = clone(this.state.draft)
|
||
this.state.draft.roadColors[this.state.profile] = {}
|
||
this.recordChange(before)
|
||
this.state.colorError = ''
|
||
},
|
||
widgetColors(id) { return widgetPalette(this.state.draft, this.state.data.metadata, this.state.profile, id) },
|
||
color(id, value, group = null) {
|
||
if (!this.editable || this.state.drag || !Object.hasOwn(this.selectedWidget?.colors || {}, id)) return
|
||
if (!HEX.test(value)) { this.state.colorError = "Use eight hex digits, for example #000000A6. The last two digits set opacity."; return }
|
||
const before = clone(this.state.draft)
|
||
const profile = this.state.profile, widget = this.state.selected
|
||
this.state.draft.widgetColors[profile][widget] ||= {}
|
||
this.state.draft.widgetColors[profile][widget][id] = value.toUpperCase()
|
||
this.recordChange(before, group && profile + ':' + widget + ':' + group)
|
||
this.state.colorError = this.state.notice = ""
|
||
},
|
||
colorText(id, event) { this.color(id, event.target.value.trim()); this.finishColorEdit(); event.target.value = this.selectedColors[id] },
|
||
colorRgb(id, event) {
|
||
const alpha = this.selectedColors[id]?.slice(7)
|
||
if (alpha) this.color(id, event.target.value + (alpha === '00' ? (id === 'cardFill' ? 'A6' : 'FF') : alpha), "rgb:" + id)
|
||
},
|
||
colorAlpha(id, event) {
|
||
const value = Number(event.target.value)
|
||
if (Number.isInteger(value) && value >= 0 && value <= 255)
|
||
this.color(id, this.selectedColors[id].slice(0, 7) + value.toString(16).padStart(2, "0"), "alpha:" + id)
|
||
},
|
||
alpha(color) { return parseInt(color.slice(7), 16) },
|
||
flashLeavePrompt() {
|
||
const bar = this.$refs.leaveBar
|
||
if (!bar) return
|
||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||
bar.scrollIntoView({ block: 'nearest', behavior: reduced ? 'instant' : 'smooth' })
|
||
bar.querySelector('button')?.focus({ preventScroll: true })
|
||
},
|
||
requestLeave(action, presentation = "inline") {
|
||
if (this.inlineLeavePrompt) { this.flashLeavePrompt(); return }
|
||
// Saving must finish first; a load or a stray drag must not swallow navigation.
|
||
if (this.state.status === "saving") return
|
||
if (this.state.drag || this.state.layerDrag) this.cancelDrag()
|
||
this.state.removeConfirm = null
|
||
if (this.dirty) {
|
||
this.state.discard = action
|
||
this.state.leavePresentation = (typeof action === "function" || ["back", "device", "projection"].includes(action)) ? presentation : "inline"
|
||
}
|
||
else this.leave(action)
|
||
},
|
||
cancelLeave() {
|
||
if (this.busy) return
|
||
this.state.discard = null
|
||
this.state.leavePresentation = null
|
||
},
|
||
leave(action) {
|
||
this.state.discard = null
|
||
this.state.leavePresentation = null
|
||
if (typeof action === "function") action()
|
||
else if (action === "back") this.$emit("close")
|
||
else if (["projection", "device"].includes(action)) this.$emit("target", action)
|
||
else this.feed.load()
|
||
},
|
||
save() { if (this.editable && this.dirty && !this.state.drag && !this.state.layerDrag) return this.feed.save(this.state.draft) },
|
||
async saveAndLeave() {
|
||
if (!this.canSavePending || !this.pendingLeave) return false
|
||
const action = this.state.discard
|
||
const saved = await this.feed.save(this.state.draft)
|
||
if (saved && this.state.discard === action) this.leave(action)
|
||
return saved
|
||
},
|
||
},
|
||
template: ONROAD_LAYOUT_TEMPLATE,
|
||
}
|