Files
StarPilot/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js
T
2026-10-26 20:38:11 -04:00

271 lines
21 KiB
JavaScript

import { LEAD_INFO } from "./projection-layout.js"
export const PROFILES = ["large", "compact"]
const COLORS = ["cardFill", "cardBorder", "text"]
export const ROAD_COLORS = ["path", "pathEdge", "laneLines"]
// "default" is a legacy saved value that now renders as acceleration colors.
export const PATH_MODES = ["default", "color", "rainbow", "acceleration"]
export const HEX = /^#[0-9a-fA-F]{8}$/
const keysEqual = (value, keys) => value && typeof value === "object" && !Array.isArray(value) &&
Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key))
const positive = (value) => Number.isFinite(value) && value > 0 && value <= 8192
const object = (value) => value && typeof value === "object" && !Array.isArray(value)
const frontmostOrder = (order, profile) => {
const pinned = Object.entries(profile.widgets || {}).filter(([, widget]) => widget.frontmost === true).map(([id]) => id)
return pinned.length <= 1 && (!pinned.length || order.at(-1) === pinned[0])
}
// Display preferences ride along in the draft but are not part of the rendered layout.
export const layoutDocument = ({ display, ...document }) => document
export const previewDocument = document => ({ ...layoutDocument(document), developerSidebar: document.display?.DeveloperSidebar === "On" })
// The device sends them beside the layout; the editor keeps them in its draft so Save, undo and reload cover them.
export const withDisplay = (data) => !object(data?.display) || !Array.isArray(data.display.rows) ? data : {
...data, display: data.display.rows,
document: { ...data.document, display: { ...data.display.values } },
defaults: { ...data.defaults, display: { ...data.display.defaults } }
}
export function orderedWidgetIds(document, metadata, profile) {
return document.widgetOrder?.[profile] || metadata.profiles[profile].widgetOrder || Object.keys(metadata.profiles[profile].widgets)
}
export function widgetPalette(document, metadata, profile, id) {
const colors = metadata.profiles[profile].widgets[id].colors
return {
...Object.fromEntries(Object.entries(colors).map(([key, value]) => [key, value ?? document.palette[key]])),
...document.widgetColors[profile][id]
}
}
export const withAlpha = (color, factor) => color.slice(0, 7) + Math.floor(parseInt(color.slice(7), 16) * factor).toString(16).padStart(2, "0")
// Steering wheels resize as a square ("size"); box widgets such as the Android Auto map take a width and height.
export function widgetSize(widget, position) {
if (widget.iconSize && position?.display === "icons") return [widget.iconSize, widget.iconSize]
if (widget.box) return [position?.width ?? widget.width, position?.height ?? widget.height]
if (widget.resizable) return [position?.size ?? widget.width, position?.size ?? widget.height]
return [widget.width, widget.height]
}
export function placementLimits(profile, id, layout = null) {
const widget = profile.widgets[id], bounds = widget.bounds || profile.bounds
const [width, height] = widgetSize(widget, layout?.[id])
const limits = {
minX: bounds.x, maxX: bounds.x + bounds.width - width,
minY: bounds.y, maxY: bounds.y + bounds.height - height
}
const confirmation = widget.confirmation
if (confirmation && (layout?.[id]?.enabled ?? widget.default.enabled) &&
(layout?.speed_limit_actions?.enabled ?? profile.widgets.speed_limit_actions.default.enabled)) {
limits.minX = Math.max(limits.minX, bounds.x - confirmation.x)
limits.maxX = Math.min(limits.maxX, bounds.x + bounds.width - confirmation.x - confirmation.width)
limits.minY = Math.max(limits.minY, bounds.y - confirmation.y)
limits.maxY = Math.min(limits.maxY, bounds.y + bounds.height - confirmation.y - confirmation.height)
}
return limits
}
// The largest box that still fits from the widget's current corner, within its own limits.
export function clampBox(profile, id, position, width, height) {
const widget = profile.widgets[id], box = widget.box, bounds = widget.bounds || profile.bounds
if (!box || !Number.isFinite(width) || !Number.isFinite(height)) return null
return {
width: Math.round(Math.max(box.minWidth, Math.min(box.maxWidth, bounds.x + bounds.width - position.x, width))),
height: Math.round(Math.max(box.minHeight, Math.min(box.maxHeight, bounds.y + bounds.height - position.y, height)))
}
}
export function clampPlacement(profile, id, x, y, layout = null) {
if (!Number.isFinite(x) || !Number.isFinite(y) || !Object.hasOwn(profile.widgets, id)) return null
const { minX, maxX, minY, maxY } = placementLimits(profile, id, layout)
const position = { x: Math.max(minX, Math.min(maxX, Math.round(x))), y: Math.max(minY, Math.min(maxY, Math.round(y))) }
return overlapsReserved(profile, id, position.x, position.y, layout) ? null : position
}
export function overlapsReserved(profile, id, x, y, layout = null) {
const widget = profile.widgets[id]
const owner = Object.keys(profile.widgets).find(key => profile.widgets[key].confirmation)
const positions = layout || Object.fromEntries(Object.entries(profile.widgets).map(([key, item]) => [key, item.default]))
if (owner && positions[owner]?.enabled && positions.speed_limit_actions?.enabled) {
const area = profile.widgets[owner].confirmation, at = positions[owner]
const critical = owner === "speed_limit" ? ["max_speed", "torque_bar"] : ["torque_bar"]
const overlaps = (a, b) => a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y
const panel = { x: (id === owner ? x : at.x) + area.x, y: (id === owner ? y : at.y) + area.y, width: area.width, height: area.height }
const ownerHeight = profile.widgets[owner].height
if (ownerHeight > area.y + area.height) {
panel.x = id === owner ? x : at.x
panel.width = Math.max(area.x + area.width, profile.widgets[owner].width)
panel.height = ownerHeight - area.y
}
if (critical.some(key => {
if ((id !== owner && id !== key) || !positions[key]?.enabled) return false
const [width, height] = widgetSize(profile.widgets[key], positions[key])
const obstacle = profile.widgets[key].confirmationObstacle || { x: 0, y: 0, width, height }
return overlaps(panel, { x: (id === key ? x : positions[key].x) + obstacle.x,
y: (id === key ? y : positions[key].y) + obstacle.y, width: obstacle.width, height: obstacle.height })
})) return true
}
if (widget.layer === "underlay") return false
const [width, height] = widgetSize(widget, layout?.[id])
const intersects = (left, top, otherWidth, otherHeight) => x < left + otherWidth && x + width > left &&
y < top + otherHeight && y + height > top
if (profile.reservedZones.some((zone) => intersects(zone.x, zone.y, zone.width, zone.height))) return true
const actions = profile.widgets.speed_limit_actions
if (!actions || profile.protectedWidget !== "speed_limit_actions") return false
if (id === "speed_limit_actions") return Object.entries(profile.widgets).some(([key, other]) => {
const at = positions[key]
return key !== id && key !== "speed_limit" && key !== "stopped_timer" && other.layer !== "underlay" && at &&
at.enabled && Number.isFinite(at.x) && Number.isFinite(at.y) &&
intersects(at.x, at.y, ...widgetSize(other, at))
})
if (id === "speed_limit" || id === "stopped_timer") return false
const at = positions.speed_limit_actions
if (!at || !Number.isFinite(at.x) || !Number.isFinite(at.y)) return true
return intersects(at.x, at.y, actions.width, actions.height)
}
export function previewPoint(event, rect, profile) {
if (!rect?.width || !rect?.height) return null
return {
x: (event.clientX - rect.left) * profile.width / rect.width,
y: (event.clientY - rect.top) * profile.height / rect.height
}
}
// Legacy saved layouts retain this field even though the paired speed widget uses fixed alignment.
export const SPEED_ALIGNMENTS = ["left", "center", "right"]
// Android Auto display switches, independent of the comma screen's preferences.
export const PROJECTION_DISPLAY_SWITCHES = ["blindSpotBorder", "signalBorder", "blindSpotCamera", "hidePersonalityAlerts", "wheelPedalFeedback", "leadIndicator"]
export function validDocument(document, metadata) {
if (!object(document)) return false
const profiles = metadata?.projection === true ? ["large"] : PROFILES
if (!keysEqual(document, ["version", "palette", "layouts", "widgetColors", "roadColors", ...(Object.hasOwn(document, "widgetOrder") ? ["widgetOrder"] : []), ...(Object.hasOwn(document, "speedSources") ? ["speedSources"] : []), ...(Object.hasOwn(document, "speedSourcesCard") ? ["speedSourcesCard"] : []), ...(Object.hasOwn(document, "speedAlign") ? ["speedAlign"] : []), ...(Object.hasOwn(document, "clock24Hour") ? ["clock24Hour"] : []), ...(Object.hasOwn(document, "showMaxSpeed") ? ["showMaxSpeed"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "display") ? ["display"] : []), ...(metadata?.projection === true && Object.hasOwn(document, "largeUiGammaTrial") ? ["largeUiGammaTrial"] : []), ...(metadata?.projection === true ? PROJECTION_DISPLAY_SWITCHES.filter(key => Object.hasOwn(document, key)) : []), ...(metadata?.projection === true && Object.hasOwn(document, "leadInfo") ? ["leadInfo"] : [])]) || (metadata?.projection === true ? document.version !== 8 : ![11, 12].includes(document.version)) ||
(Object.hasOwn(document, "speedSources") && typeof document.speedSources !== "boolean") ||
(Object.hasOwn(document, "speedSourcesCard") && typeof document.speedSourcesCard !== "boolean") ||
(Object.hasOwn(document, "speedAlign") && !SPEED_ALIGNMENTS.includes(document.speedAlign)) ||
(Object.hasOwn(document, "clock24Hour") && typeof document.clock24Hour !== "boolean") ||
(Object.hasOwn(document, "showMaxSpeed") && typeof document.showMaxSpeed !== "boolean") ||
(Object.hasOwn(document, "display") && (!object(document.display) || !Object.values(document.display).every((value) => typeof value === "string"))) ||
(Object.hasOwn(document, "largeUiGammaTrial") && typeof document.largeUiGammaTrial !== "boolean") ||
PROJECTION_DISPLAY_SWITCHES.some(key => Object.hasOwn(document, key) && typeof document[key] !== "boolean") ||
(Object.hasOwn(document, "leadInfo") && !LEAD_INFO.includes(document.leadInfo)) ||
!keysEqual(document.palette, COLORS) || !COLORS.every((key) => HEX.test(document.palette[key])) ||
!keysEqual(document.layouts, profiles) || !keysEqual(document.widgetColors, profiles) || !keysEqual(document.roadColors, profiles)) return false
if (Object.hasOwn(document, "widgetOrder") && (!object(document.widgetOrder) ||
!Object.entries(document.widgetOrder).every(([name, order]) => profiles.includes(name) && Array.isArray(order) &&
order.length === Object.keys(metadata.profiles[name].widgets).length && new Set(order).size === order.length &&
order.every(id => typeof id === "string" && Object.hasOwn(metadata.profiles[name].widgets, id)) &&
frontmostOrder(order, metadata.profiles[name])))) return false
return profiles.every((name) => {
const profile = metadata.profiles[name], layout = document.layouts[name]
const road = document.roadColors[name]
if (!object(road) || !Object.entries(road).every(([key, value]) => key === "pathMode" ? PATH_MODES.includes(value) :
ROAD_COLORS.includes(key) && typeof value === "string" && HEX.test(value))) return false
const colors = document.widgetColors[name]
if (!object(colors) || !Object.entries(colors).every(([id, overrides]) => {
const fields = profile.widgets[id]?.colors
return fields && Object.keys(fields).length > 0 && object(overrides) && Object.entries(overrides).every(([key, value]) =>
Object.hasOwn(fields, key) && typeof value === "string" && HEX.test(value))
})) return false
return keysEqual(layout, Object.keys(profile.widgets)) && Object.entries(layout).every(([id, position]) => {
const widget = profile.widgets[id], limits = placementLimits(profile, id, layout)
const resizable = widget.resizable, box = widget.box, opacity = widget.opacity
return keysEqual(position, ["x", "y", "enabled", ...(resizable ? ["size"] : []), ...(box ? ["width", "height"] : []),
...(opacity ? ["opacity"] : []), ...(widget.iconSize ? ["display"] : []), ...(widget.backgroundToggle ? ["background"] : []), ...(widget.collapseToggle ? ["collapse"] : [])]) &&
(!widget.iconSize || ["words", "icons"].includes(position.display)) &&
(!widget.backgroundToggle || typeof position.background === "boolean") &&
(!widget.collapseToggle || typeof position.collapse === "boolean") &&
(!opacity || (Number.isInteger(position.opacity) && position.opacity >= opacity.min && position.opacity <= opacity.max)) &&
(!resizable || (Number.isInteger(position.size) && position.size >= resizable.min && position.size <= resizable.max)) &&
(!box || (Number.isInteger(position.width) && position.width >= box.minWidth && position.width <= box.maxWidth &&
Number.isInteger(position.height) && position.height >= box.minHeight && position.height <= box.maxHeight)) &&
typeof position.enabled === "boolean" && (!widget.required || position.enabled === true) &&
Number.isFinite(position.x) && Number.isFinite(position.y) &&
position.x >= limits.minX && position.x <= limits.maxX && position.y >= limits.minY && position.y <= limits.maxY &&
(!position.enabled || !overlapsReserved(profile, id, position.x, position.y, layout))
})
})
}
export function validSnapshot(data) {
const projection = data?.projection === true && data?.metadata?.projection === true
if ((data?.projection === true) !== (data?.metadata?.projection === true)) return false
const profiles = projection ? ["large"] : PROFILES
if (!data || typeof data.revision !== "string" || !data.revision || typeof data.editable !== "boolean" ||
typeof data.valid !== "boolean" || !keysEqual(data.metadata?.profiles, profiles) ||
!Array.isArray(data.metadata.paletteFields) || data.metadata.paletteFields.length !== COLORS.length ||
new Set(data.metadata.paletteFields.map((field) => field.id)).size !== COLORS.length ||
!data.metadata.paletteFields.every((field) => COLORS.includes(field.id) && typeof field.label === "string" && HEX.test(field.default)) ||
!Array.isArray(data.metadata.roadColorFields) || data.metadata.roadColorFields.length !== ROAD_COLORS.length ||
new Set(data.metadata.roadColorFields.map((field) => field.id)).size !== ROAD_COLORS.length ||
!data.metadata.roadColorFields.every((field) => ROAD_COLORS.includes(field.id) && typeof field.label === "string" && HEX.test(field.default)) ||
(data.activeProfile != null && !profiles.includes(data.activeProfile))) return false
for (const profile of Object.values(data.metadata.profiles)) {
if (profile.widgetOrder != null && (!Array.isArray(profile.widgetOrder) ||
profile.widgetOrder.length !== Object.keys(profile.widgets || {}).length ||
new Set(profile.widgetOrder).size !== profile.widgetOrder.length ||
!profile.widgetOrder.every(id => typeof id === "string" && Object.hasOwn(profile.widgets, id)) ||
!frontmostOrder(profile.widgetOrder, profile))) return false
if (profile.runtimePreviewWidgets != null && (!Array.isArray(profile.runtimePreviewWidgets) ||
new Set(profile.runtimePreviewWidgets).size !== profile.runtimePreviewWidgets.length ||
!profile.runtimePreviewWidgets.every(id => typeof id === "string" && Object.hasOwn(profile.widgets, id)))) return false
const bounds = profile.bounds
if (projection && (profile.widgets?.car_exit?.kind !== "car_exit" || profile.widgets.car_exit.required !== true)) return false
if (profile.protectedWidget != null && (profile.protectedWidget !== "speed_limit_actions" ||
profile.widgets?.[profile.protectedWidget]?.kind !== "speed_limit_actions")) return false
if (!positive(profile.width) || !positive(profile.height) || typeof profile.label !== "string" ||
!bounds || !Number.isFinite(bounds.x) || !Number.isFinite(bounds.y) || bounds.x < 0 || bounds.y < 0 ||
!positive(bounds.width) || !positive(bounds.height) || bounds.x + bounds.width > profile.width ||
bounds.y + bounds.height > profile.height || !profile.widgets || Array.isArray(profile.widgets) ||
Object.keys(profile.widgets).length > 32 || !Array.isArray(profile.reservedZones) || profile.reservedZones.length > 32) return false
for (const zone of profile.reservedZones) {
if (typeof zone.label !== "string" || !Number.isFinite(zone.x) || !Number.isFinite(zone.y) ||
!positive(zone.width) || !positive(zone.height) || zone.x < bounds.x || zone.y < bounds.y ||
zone.x + zone.width > bounds.x + bounds.width || zone.y + zone.height > bounds.y + bounds.height) return false
}
for (const widget of Object.values(profile.widgets)) {
for (const area of [widget.confirmation, widget.confirmationObstacle]) {
if (area != null && (!keysEqual(area, ["x", "y", "width", "height"]) ||
!Number.isFinite(area.x) || Math.abs(area.x) > profile.width ||
!Number.isFinite(area.y) || Math.abs(area.y) > profile.height ||
!positive(area.width) || !positive(area.height))) return false
}
if (widget.available != null && (!projection || widget.kind !== "jetlink_status" || typeof widget.available !== "boolean")) return false
const area = widget.bounds || bounds
if (widget.visualInsetTop != null && (!Number.isFinite(widget.visualInsetTop) || widget.visualInsetTop < 0 || widget.visualInsetTop > area.height)) return false
if (!object(widget.colors) || !Object.entries(widget.colors).every(([key, value]) =>
COLORS.includes(key) && (value === null || (typeof value === "string" && HEX.test(value))))) return false
if (!Number.isFinite(area.x) || !Number.isFinite(area.y) || area.x < (widget.expandable ? -300 : 0) || area.y < 0 ||
!positive(area.width) || !positive(area.height) || area.x + area.width > profile.width ||
area.y + area.height > profile.height || typeof widget.label !== "string" || typeof widget.kind !== "string" ||
!positive(widget.width) || !positive(widget.height) || widget.width > area.width || widget.height > area.height) return false
if (widget.box && (widget.kind !== "nav_map" || !object(widget.opacity) ||
![widget.box.minWidth, widget.box.maxWidth, widget.box.minHeight, widget.box.maxHeight].every(Number.isInteger) ||
widget.box.minWidth < 24 || widget.box.minHeight < 24 || widget.box.minWidth > widget.box.maxWidth ||
widget.box.minHeight > widget.box.maxHeight || widget.box.maxWidth > area.width || widget.box.maxHeight > area.height ||
![widget.opacity.min, widget.opacity.max, widget.opacity.default].every(Number.isInteger) ||
widget.opacity.min < 0 || widget.opacity.max > 100 || widget.opacity.min > widget.opacity.default ||
widget.opacity.default > widget.opacity.max)) return false
if (widget.opacity && (!(["nav_map", "jetlink_status"].includes(widget.kind) || projection && ["nav_home", "nav_work", "bookmark", "car_exit", "cruise_limits", "now_playing"].includes(widget.kind)) ||
!object(widget.opacity) || ![widget.opacity.min, widget.opacity.max, widget.opacity.default].every(Number.isInteger) ||
widget.opacity.min < 0 || widget.opacity.max > 100 || widget.opacity.min > widget.opacity.default ||
widget.opacity.default > widget.opacity.max)) return false
if (widget.collapseToggle != null && (!projection || widget.kind !== "now_playing" || widget.collapseToggle !== true)) return false
if (widget.backgroundToggle != null && (!projection || !["nav_home", "nav_work", "bookmark", "car_exit"].includes(widget.kind) ||
widget.backgroundToggle !== true || !widget.opacity)) return false
if (widget.resizable && (!["steering_wheel", "driver_monitor"].includes(widget.kind) || !Number.isInteger(widget.resizable.min) ||
!Number.isInteger(widget.resizable.default) || !Number.isInteger(widget.resizable.max) ||
widget.resizable.default !== widget.width || widget.width !== widget.height ||
widget.resizable.min < 24 || widget.resizable.max > area.width || widget.resizable.max > area.height ||
widget.resizable.min > widget.resizable.default || widget.resizable.default > widget.resizable.max)) return false
if (widget.iconSize != null && (!projection || !["nav_home", "nav_work", "bookmark"].includes(widget.kind) ||
!Number.isInteger(widget.iconSize) || widget.iconSize < 44 || widget.iconSize > Math.min(widget.width, widget.height))) return false
if (widget.required != null && (!projection || widget.kind !== "car_exit" || widget.required !== true)) return false
if (widget.frontmost != null && (!projection || widget.kind !== "car_exit" || widget.frontmost !== true)) return false
if (widget.kind === "car_exit" && (widget.required !== true || widget.frontmost !== true)) return false
}
}
return validDocument(data.document, data.metadata) && validDocument(data.defaults, data.metadata)
}