theme config, layout preview, container alignment

Server: ThemeConfig entity + CRUD (GET/PUT /theme), SQLite persistence,
ThemeUpdate broadcast to ESP32 on save and initial connect.
Client: render engine uses theme colors, full-screen redraw on theme change.
SPA: theme page with color pickers + presets, layout preview with TS port
of layout engine, justify/align controls on containers.
DisplayHint refactored to struct (kind + h_align + v_align).
This commit is contained in:
2026-06-19 03:26:18 +02:00
parent 81a4167382
commit fe59b68c37
46 changed files with 1276 additions and 118 deletions

View File

@@ -0,0 +1,93 @@
import type { LayoutNode, LayoutChild } from "@/api/types"
export interface BoundingBox {
x: number
y: number
width: number
height: number
}
export function computeLayout(
layout: LayoutNode,
bounds: BoundingBox,
): Map<number, BoundingBox> {
const out = new Map<number, BoundingBox>()
computeNode(layout, bounds, out)
return out
}
function computeNode(
node: LayoutNode,
bounds: BoundingBox,
out: Map<number, BoundingBox>,
) {
if (node.type === "leaf") {
if (node.widget_id !== undefined) {
out.set(node.widget_id, bounds)
}
} else {
computeContainer(node, bounds, out)
}
}
function computeContainer(
container: LayoutNode,
bounds: BoundingBox,
out: Map<number, BoundingBox>,
) {
const pad = container.padding ?? 0
const inner: BoundingBox = {
x: bounds.x + pad,
y: bounds.y + pad,
width: Math.max(0, bounds.width - pad * 2),
height: Math.max(0, bounds.height - pad * 2),
}
const children = container.children ?? []
if (children.length === 0) return
const isRow = container.direction === "row"
const totalAxis = isRow ? inner.width : inner.height
const gap = container.gap ?? 0
const totalGap = gap * Math.max(0, children.length - 1)
const available = Math.max(0, totalAxis - totalGap)
let fixedTotal = 0
let flexTotal = 0
for (const child of children) {
if (child.sizing.type === "fixed") {
fixedTotal += child.sizing.value
} else {
flexTotal += child.sizing.value
}
}
const flexSpace = Math.max(0, available - fixedTotal)
const childSizes: number[] = children.map((child: LayoutChild) => {
if (child.sizing.type === "fixed") return child.sizing.value
if (flexTotal > 0) {
return Math.floor((flexSpace * child.sizing.value) / flexTotal)
}
return 0
})
const childrenTotal = childSizes.reduce((a, b) => a + b, 0)
const remaining = Math.max(0, totalAxis - childrenTotal - totalGap)
// Default justify: Start
let offset = 0
let justifyGap = gap
// SpaceBetween / SpaceEvenly could be added here if justify_content is on the DTO
void remaining // unused for now, Start justification
for (let i = 0; i < children.length; i++) {
const childSize = childSizes[i]
const childBounds: BoundingBox = isRow
? { x: inner.x + offset, y: inner.y, width: childSize, height: inner.height }
: { x: inner.x, y: inner.y + offset, width: inner.width, height: childSize }
computeNode(children[i].node, childBounds, out)
offset += childSize + justifyGap
}
}