Give a dashboard the panel's own size, and put the dots on its grid

A dashboard now carries the canvas it is drawn for (canvas_width /
canvas_height, presets plus two numbers in the settings panel). Editor and
wall panel render that surface at its true pixel size and scale it to fit,
so a side panel opening changes only the scale — never the arrangement
being made. With the width and column count known the dot pitch is exact,
(width + gap) / columns by row height + gap, so a dot sits where every
widget corner snaps. The wall panel shows no dots.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
2026-08-16 19:08:09 +02:00
co-authored by Claude Fable 5
parent fcd43e9ad9
commit c2321fe942
8 changed files with 308 additions and 79 deletions
+6
View File
@@ -180,6 +180,12 @@ class DashboardDef(BaseModel):
#: How many columns the grid is cut into, so a dashboard can be matched to #: How many columns the grid is cut into, so a dashboard can be matched to
#: the panel it will hang on. #: the panel it will hang on.
columns: int = Field(default=12, ge=1, le=48) columns: int = Field(default=12, ge=1, le=48)
#: The panel this dashboard is drawn for, in CSS pixels. Both the editor
#: and the wall panel scale that surface to fit whatever room they have, so
#: an arrangement does not depend on the window it was made in. Zero means
#: "unset" and the client falls back to its default.
canvas_width: int = Field(default=1920, ge=0, le=7680)
canvas_height: int = Field(default=1080, ge=0, le=4320)
pages: list[PageDef] = Field(default_factory=list) pages: list[PageDef] = Field(default_factory=list)
#: Bumped on every save; a save based on an older one is refused. #: Bumped on every save; a save based on an older one is refused.
version: int = 1 version: int = 1
+28
View File
@@ -218,6 +218,20 @@ export const DashboardDef_InputSchema = {
title: 'Columns', title: 'Columns',
default: 12 default: 12
}, },
canvas_width: {
type: 'integer',
maximum: 7680,
minimum: 0,
title: 'Canvas Width',
default: 1920
},
canvas_height: {
type: 'integer',
maximum: 4320,
minimum: 0,
title: 'Canvas Height',
default: 1080
},
pages: { pages: {
items: { items: {
'$ref': '#/components/schemas/PageDef-Input' '$ref': '#/components/schemas/PageDef-Input'
@@ -255,6 +269,20 @@ export const DashboardDef_OutputSchema = {
title: 'Columns', title: 'Columns',
default: 12 default: 12
}, },
canvas_width: {
type: 'integer',
maximum: 7680,
minimum: 0,
title: 'Canvas Width',
default: 1920
},
canvas_height: {
type: 'integer',
maximum: 4320,
minimum: 0,
title: 'Canvas Height',
default: 1080
},
pages: { pages: {
items: { items: {
'$ref': '#/components/schemas/PageDef-Output' '$ref': '#/components/schemas/PageDef-Output'
+4
View File
@@ -83,6 +83,8 @@ export type DashboardDef_Input = {
name: string; name: string;
title?: string; title?: string;
columns?: number; columns?: number;
canvas_width?: number;
canvas_height?: number;
pages?: Array<PageDef_Input>; pages?: Array<PageDef_Input>;
version?: number; version?: number;
}; };
@@ -94,6 +96,8 @@ export type DashboardDef_Output = {
name: string; name: string;
title?: string; title?: string;
columns?: number; columns?: number;
canvas_width?: number;
canvas_height?: number;
pages?: Array<PageDef_Output>; pages?: Array<PageDef_Output>;
version?: number; version?: number;
}; };
@@ -9,8 +9,13 @@ import {
Settings2, Settings2,
} from "lucide-react" } from "lucide-react"
import { motion } from "motion/react" import { motion } from "motion/react"
import { useEffect, useRef, useState } from "react" import { type CSSProperties, useEffect, useRef, useState } from "react"
import { GridLayout, type Layout, useContainerWidth } from "react-grid-layout" import {
GridLayout,
type Layout,
type Position,
setTransform,
} from "react-grid-layout"
import "react-grid-layout/css/styles.css" import "react-grid-layout/css/styles.css"
import { import {
@@ -39,6 +44,8 @@ import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { handleError } from "@/utils" import { handleError } from "@/utils"
import { import {
CanvasSurface,
canvasOf,
columnsOf, columnsOf,
type Dashboard, type Dashboard,
DashboardView, DashboardView,
@@ -47,6 +54,7 @@ import {
pagesOf, pagesOf,
placement, placement,
ROW_HEIGHT, ROW_HEIGHT,
rowsOf,
sectionsOf, sectionsOf,
widgetsOf, widgetsOf,
} from "./DashboardView" } from "./DashboardView"
@@ -127,6 +135,20 @@ function layoutOf(widgets: WidgetDef[], columns: number): Layout {
}) })
} }
/**
* How the grid positions and drags items on a CSS-scaled surface.
*
* The library's own `createScaledStrategy` divides the drag delta by the scale
* but drops the container offset while doing it, so a widget jumps on pick-up.
* Leaving `calcDragPosition` unset keeps its parent-relative maths, which
* already divides by `scale` — the only thing that needed saying.
*/
const scaledStrategy = (scale: number) => ({
type: "transform" as const,
scale,
calcStyle: setTransform as (pos: Position) => CSSProperties,
})
const same = (a: Layout, b: Layout) => const same = (a: Layout, b: Layout) =>
a.length === b.length && a.length === b.length &&
a.every((item, index) => { a.every((item, index) => {
@@ -204,7 +226,7 @@ export function DashboardEditor({
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0] const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
const section = page ? sectionsOf(page)[0] : undefined const section = page ? sectionsOf(page)[0] : undefined
const widgets = section ? widgetsOf(section) : [] const widgets = section ? widgetsOf(section) : []
const { width, containerRef, mounted } = useContainerWidth() const canvas = canvasOf(draft)
const updateWidgets = (next: WidgetDef[]) => { const updateWidgets = (next: WidgetDef[]) => {
if (!page || !section) return if (!page || !section) return
@@ -293,26 +315,29 @@ export function DashboardEditor({
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
This dashboard has no pages yet. This dashboard has no pages yet.
</p> </p>
) : !edit ? ( ) : edit && widgets.length === 0 ? (
<DashboardView dashboard={draft} pageId={page.id} />
) : widgets.length === 0 ? (
<p className="text-sm text-muted-foreground" data-testid="dashboard-empty"> <p className="text-sm text-muted-foreground" data-testid="dashboard-empty">
Nothing on this page yet. Add a widget from the bar below. Nothing on this page yet. Add a widget from the bar below.
</p> </p>
) : ( ) : (
<div ref={containerRef}> <CanvasSurface dashboard={draft} dots={edit}>
{/* Measured first: laying out against a guessed width would place every {(scale) =>
widget once and then move it. */} !edit ? (
{mounted ? ( <DashboardView dashboard={draft} pageId={page.id} />
) : (
<GridLayout <GridLayout
width={width} width={canvas.width}
layout={layout} layout={layout}
onLayoutChange={applyLayout} onLayoutChange={applyLayout}
positionStrategy={scaledStrategy(scale)}
gridConfig={{ gridConfig={{
cols: columns, cols: columns,
rowHeight: ROW_HEIGHT, rowHeight: ROW_HEIGHT,
margin: [GRID_GAP, GRID_GAP], margin: [GRID_GAP, GRID_GAP],
containerPadding: [0, 0], containerPadding: [0, 0],
// The canvas is the constraint: nothing may be dragged off the
// panel it is being drawn for.
maxRows: rowsOf(draft),
}} }}
// Only the header moves a widget, so a slider under the cursor still // Only the header moves a widget, so a slider under the cursor still
// slides and a switch still flips while the dashboard is being edited. // slides and a switch still flips while the dashboard is being edited.
@@ -341,15 +366,16 @@ export function DashboardEditor({
</div> </div>
))} ))}
</GridLayout> </GridLayout>
) : null} )
</div> }
</CanvasSurface>
) )
return ( return (
<> <>
<div <div
className={cn( className={cn(
"dot-canvas absolute inset-0 overflow-y-auto px-4 pb-24 pt-20 transition-[padding] duration-200", "absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200",
panelOpen && "md:pr-[27rem]", panelOpen && "md:pr-[27rem]",
)} )}
data-testid="dashboard-canvas" data-testid="dashboard-canvas"
@@ -1,3 +1,5 @@
import { useEffect, useRef, useState } from "react"
import type { import type {
DashboardDef_Output, DashboardDef_Output,
PageDef_Output, PageDef_Output,
@@ -17,12 +19,27 @@ export const DEFAULT_COLUMNS = 12
/** What a grid size setting may be set to; a panel is matched to one of these. */ /** What a grid size setting may be set to; a panel is matched to one of these. */
export const COLUMN_CHOICES = [6, 8, 12, 16, 24] export const COLUMN_CHOICES = [6, 8, 12, 16, 24]
/** The panel a dashboard is drawn for, if the document does not say. */
export const DEFAULT_CANVAS = { width: 1920, height: 1080 }
/** Panels worth a name; anything else is typed in as two numbers. */
export const CANVAS_PRESETS = [
{ label: '7" panel', width: 1024, height: 600 },
{ label: '10" tablet', width: 1280, height: 800 },
{ label: "Full HD", width: 1920, height: 1080 },
{ label: '10" tablet, portrait', width: 800, height: 1280 },
{ label: "Full HD, portrait", width: 1080, height: 1920 },
]
/** One grid row, in pixels — the unit widget heights are multiples of. */ /** One grid row, in pixels — the unit widget heights are multiples of. */
export const ROW_HEIGHT = 80 export const ROW_HEIGHT = 80
/** The gap between widgets, in pixels. Matches the `gap-3` view mode uses. */ /** The gap between widgets, in pixels. Matches the `gap-3` view mode uses. */
export const GRID_GAP = 12 export const GRID_GAP = 12
/** Distance between two row origins — what a widget's `y` is counted in. */
export const ROW_PITCH = ROW_HEIGHT + GRID_GAP
/** /**
* The generated client marks every list optional, because the server fills * The generated client marks every list optional, because the server fills
* them in. These three keep that from spreading through the components. * them in. These three keep that from spreading through the components.
@@ -34,6 +51,82 @@ export const widgetsOf = (section: SectionDef_Output) => section.widgets ?? []
export const columnsOf = (dashboard: Dashboard) => export const columnsOf = (dashboard: Dashboard) =>
dashboard.columns || DEFAULT_COLUMNS dashboard.columns || DEFAULT_COLUMNS
export const canvasOf = (dashboard: Dashboard) => ({
width: dashboard.canvas_width || DEFAULT_CANVAS.width,
height: dashboard.canvas_height || DEFAULT_CANVAS.height,
})
/** How many rows of the grid fit in the canvas; the rest is off the panel. */
export const rowsOf = (dashboard: Dashboard) =>
Math.max(1, Math.floor((canvasOf(dashboard).height + GRID_GAP) / ROW_PITCH))
/**
* The dashboard's own surface: exactly the panel's pixel size, scaled to fit
* whatever room is left around it.
*
* Scaling rather than reflowing is the point. A side panel opening, or a
* narrower screen, changes only the scale — the arrangement being designed,
* and the dot grid under it, stay the layout the panel will actually show.
*/
export function CanvasSurface({
dashboard,
dots,
children,
}: {
dashboard: Dashboard
/** Draw the placement grid. A wall panel is not being arranged, so: no. */
dots?: boolean
children: (scale: number) => React.ReactNode
}) {
const ref = useRef<HTMLDivElement>(null)
const [box, setBox] = useState({ width: 0, height: 0 })
useEffect(() => {
const element = ref.current
if (!element) return
const observer = new ResizeObserver(([entry]) =>
setBox({
width: entry.contentRect.width,
height: entry.contentRect.height,
}),
)
observer.observe(element)
return () => observer.disconnect()
}, [])
const { width, height } = canvasOf(dashboard)
const scale = Math.min(box.width / width, box.height / height)
return (
<div ref={ref} className="relative size-full overflow-hidden">
{/* Measured first: a guessed scale would place the whole panel once and
then move it. */}
{scale > 0 ? (
<div
className={cn("absolute overflow-hidden", dots && "dot-canvas")}
data-testid="canvas-surface"
style={
{
width,
height,
left: (box.width - width * scale) / 2,
top: (box.height - height * scale) / 2,
transform: `scale(${scale})`,
transformOrigin: "top left",
// One dot per cell corner. The column pitch the grid snaps to is
// (width + gap) / columns; a row is one row plus the gap.
"--dot-x": `${(width + GRID_GAP) / columnsOf(dashboard)}px`,
"--dot-y": `${ROW_PITCH}px`,
} as React.CSSProperties
}
>
{children(scale)}
</div>
) : null}
</div>
)
}
export function placement(widget: WidgetDef): Placement { export function placement(widget: WidgetDef): Placement {
const layout = (widget.layout ?? {}) as Record<string, Placement> const layout = (widget.layout ?? {}) as Record<string, Placement>
return layout.lg ?? layout.md ?? layout.sm ?? {} return layout.lg ?? layout.md ?? layout.sm ?? {}
+10 -17
View File
@@ -6,8 +6,9 @@
* so nothing here touches the byte-identical token blocks. * so nothing here touches the byte-identical token blocks.
*/ */
/* The same dot grid the flow viewport paints, as plain CSS: a dashboard has no /* The placement grid, as plain CSS. One dot per cell corner: the surface hands
zoom, so it needs none of React Flow's rescaling. */ in the pitch its widgets actually snap to (`--dot-x` / `--dot-y`), and the
negative offset puts a dot centre on the grid's own origin. */
.dot-canvas { .dot-canvas {
background-color: var(--background); background-color: var(--background);
background-image: radial-gradient( background-image: radial-gradient(
@@ -15,34 +16,27 @@
color-mix(in srgb, var(--muted-foreground) 30%, transparent) 1.5px, color-mix(in srgb, var(--muted-foreground) 30%, transparent) 1.5px,
transparent 0 transparent 0
); );
background-size: 24px 24px; background-size: var(--dot-x, 24px) var(--dot-y, 24px);
background-position: -1px -1px;
} }
/* /*
* View mode's grid. Column count is per dashboard (`--widget-cols`), so a wall * View mode's grid. Column count is per dashboard (`--widget-cols`), so a wall
* panel can be matched to its own width. Below the large breakpoint the stored * panel can be matched to its own width. There is no responsive fallback: the
* placement is meaningless — three columns cannot hold a twelve-column * grid lives on a canvas of the panel's own pixel size, which is scaled to the
* arrangement — so widgets stack full width and keep only their height. * viewport rather than reflowed into it.
*/ */
.widget-grid { .widget-grid {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
grid-auto-rows: 5rem; grid-auto-rows: 5rem;
grid-template-columns: minmax(0, 1fr);
}
.widget-cell {
grid-row: span var(--h);
min-width: 0;
}
@media (min-width: 64rem) {
.widget-grid {
grid-template-columns: repeat(var(--widget-cols), minmax(0, 1fr)); grid-template-columns: repeat(var(--widget-cols), minmax(0, 1fr));
} }
.widget-cell { .widget-cell {
grid-column: span var(--w); grid-column: span var(--w);
grid-row: span var(--h);
min-width: 0;
} }
/* Only once something has actually been placed; an untouched dashboard has /* Only once something has actually been placed; an untouched dashboard has
@@ -51,7 +45,6 @@
grid-column: var(--x) / span var(--w); grid-column: var(--x) / span var(--w);
grid-row: var(--y) / span var(--h); grid-row: var(--y) / span var(--h);
} }
}
/* /*
* uPlot, routed through the tokens. Its own legend is the hover readout as * uPlot, routed through the tokens. Its own legend is the hover readout as
+74 -4
View File
@@ -27,7 +27,13 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { MAX_SERIES } from "./ChartWidget" import { MAX_SERIES } from "./ChartWidget"
import { COLUMN_CHOICES, columnsOf, type Dashboard } from "./DashboardView" import {
CANVAS_PRESETS,
COLUMN_CHOICES,
canvasOf,
columnsOf,
type Dashboard,
} from "./DashboardView"
import { messageCatalogQueryOptions } from "./queries" import { messageCatalogQueryOptions } from "./queries"
import { import {
acceptsDtype, acceptsDtype,
@@ -314,12 +320,16 @@ export function WidgetPanel({
) )
} }
/** Presets are matched on their size, so a custom one simply matches none. */
const sizeKey = (size: { width: number; height: number }) =>
`${size.width}x${size.height}`
/** /**
* The dashboard's own settings, in the panel its widgets use. * The dashboard's own settings, in the panel its widgets use.
* *
* The grid size is the one that matters: a wall panel is a fixed width, and * The canvas is the one that matters: a wall panel is a fixed size, and twelve
* twelve columns on a seven-inch screen is a different dashboard than twelve * columns on a seven-inch screen is a different dashboard than twelve on a
* on a television. * television.
*/ */
export function DashboardPanel({ export function DashboardPanel({
open, open,
@@ -337,6 +347,7 @@ export function DashboardPanel({
onClose: () => void onClose: () => void
}) { }) {
const [confirmOpen, setConfirmOpen] = useState(false) const [confirmOpen, setConfirmOpen] = useState(false)
const canvas = canvasOf(dashboard)
return ( return (
<> <>
@@ -390,6 +401,65 @@ export function DashboardPanel({
</p> </p>
</div> </div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Canvas</span>
<Select
value={
CANVAS_PRESETS.some(
(preset) => sizeKey(preset) === sizeKey(canvas),
)
? sizeKey(canvas)
: ""
}
onValueChange={(value) => {
const preset = CANVAS_PRESETS.find(
(candidate) => sizeKey(candidate) === value,
)
if (preset)
onChange({
canvas_width: preset.width,
canvas_height: preset.height,
})
}}
>
<SelectTrigger data-testid="dashboard-canvas-size">
<SelectValue placeholder="Custom" />
</SelectTrigger>
<SelectContent>
{CANVAS_PRESETS.map((preset) => (
<SelectItem key={sizeKey(preset)} value={sizeKey(preset)}>
{preset.label} {preset.width}×{preset.height}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2">
<Input
type="number"
aria-label="Canvas width"
data-testid="canvas-width"
value={canvas.width}
onChange={(event) =>
onChange({ canvas_width: Number(event.target.value) || 0 })
}
/>
<Input
type="number"
aria-label="Canvas height"
data-testid="canvas-height"
value={canvas.height}
onChange={(event) =>
onChange({ canvas_height: Number(event.target.value) || 0 })
}
/>
</div>
<p className="text-sm text-muted-foreground">
The panel this is drawn for, in pixels. Editing and viewing both
scale that surface to fit, so the arrangement is the same
everywhere.
</p>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<span className={PANEL_SECTION}>Contents</span> <span className={PANEL_SECTION}>Contents</span>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
+12 -3
View File
@@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router" import { createFileRoute, redirect } from "@tanstack/react-router"
import type { Dashboard } from "@/components/Dashboard/DashboardView" import type { Dashboard } from "@/components/Dashboard/DashboardView"
import { DashboardView } from "@/components/Dashboard/DashboardView" import {
CanvasSurface,
DashboardView,
} from "@/components/Dashboard/DashboardView"
import { dashboardQueryOptions } from "@/components/Dashboard/queries" import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import { useFlowSocket } from "@/components/Flow/useFlowSocket" import { useFlowSocket } from "@/components/Flow/useFlowSocket"
import { isLoggedIn } from "@/hooks/useAuth" import { isLoggedIn } from "@/hooks/useAuth"
@@ -30,8 +33,14 @@ function PanelView() {
const { data: dashboard } = useQuery(dashboardQueryOptions(name)) const { data: dashboard } = useQuery(dashboardQueryOptions(name))
return ( return (
<main className="dot-canvas min-h-svh w-full overflow-y-auto p-4"> <main className="h-svh w-full overflow-hidden p-4">
{dashboard ? <DashboardView dashboard={dashboard as Dashboard} /> : null} {dashboard ? (
// The panel's own surface, scaled to whatever screen it landed on. No
// dots: nothing is being arranged here.
<CanvasSurface dashboard={dashboard as Dashboard}>
{() => <DashboardView dashboard={dashboard as Dashboard} />}
</CanvasSurface>
) : null}
</main> </main>
) )
} }