From c2321fe94218b98807c388b43503c6c30200981d Mon Sep 17 00:00:00 2001 From: stroblme Date: Sun, 16 Aug 2026 19:08:09 +0200 Subject: [PATCH] Give a dashboard the panel's own size, and put the dots on its grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A --- backend/app/flow/dashboards.py | 6 + frontend/src/client/schemas.gen.ts | 28 ++++ frontend/src/client/types.gen.ts | 4 + .../components/Dashboard/DashboardEditor.tsx | 126 +++++++++++------- .../components/Dashboard/DashboardView.tsx | 93 +++++++++++++ .../src/components/Dashboard/dashboard.css | 37 +++-- frontend/src/components/Dashboard/panels.tsx | 78 ++++++++++- frontend/src/routes/view.$name.tsx | 15 ++- 8 files changed, 308 insertions(+), 79 deletions(-) diff --git a/backend/app/flow/dashboards.py b/backend/app/flow/dashboards.py index a31c058..bddca63 100644 --- a/backend/app/flow/dashboards.py +++ b/backend/app/flow/dashboards.py @@ -180,6 +180,12 @@ class DashboardDef(BaseModel): #: How many columns the grid is cut into, so a dashboard can be matched to #: the panel it will hang on. 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) #: Bumped on every save; a save based on an older one is refused. version: int = 1 diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 9968d2a..15d3429 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -218,6 +218,20 @@ export const DashboardDef_InputSchema = { title: 'Columns', 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: { items: { '$ref': '#/components/schemas/PageDef-Input' @@ -255,6 +269,20 @@ export const DashboardDef_OutputSchema = { title: 'Columns', 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: { items: { '$ref': '#/components/schemas/PageDef-Output' diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 2b223f7..210dc5b 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -83,6 +83,8 @@ export type DashboardDef_Input = { name: string; title?: string; columns?: number; + canvas_width?: number; + canvas_height?: number; pages?: Array; version?: number; }; @@ -94,6 +96,8 @@ export type DashboardDef_Output = { name: string; title?: string; columns?: number; + canvas_width?: number; + canvas_height?: number; pages?: Array; version?: number; }; diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 3ebf315..2658c5d 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -9,8 +9,13 @@ import { Settings2, } from "lucide-react" import { motion } from "motion/react" -import { useEffect, useRef, useState } from "react" -import { GridLayout, type Layout, useContainerWidth } from "react-grid-layout" +import { type CSSProperties, useEffect, useRef, useState } from "react" +import { + GridLayout, + type Layout, + type Position, + setTransform, +} from "react-grid-layout" import "react-grid-layout/css/styles.css" import { @@ -39,6 +44,8 @@ import { slideUp, transitions } from "@/lib/motion" import { cn } from "@/lib/utils" import { handleError } from "@/utils" import { + CanvasSurface, + canvasOf, columnsOf, type Dashboard, DashboardView, @@ -47,6 +54,7 @@ import { pagesOf, placement, ROW_HEIGHT, + rowsOf, sectionsOf, widgetsOf, } 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) => a.length === b.length && a.every((item, index) => { @@ -204,7 +226,7 @@ export function DashboardEditor({ const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0] const section = page ? sectionsOf(page)[0] : undefined const widgets = section ? widgetsOf(section) : [] - const { width, containerRef, mounted } = useContainerWidth() + const canvas = canvasOf(draft) const updateWidgets = (next: WidgetDef[]) => { if (!page || !section) return @@ -293,63 +315,67 @@ export function DashboardEditor({

This dashboard has no pages yet.

- ) : !edit ? ( - - ) : widgets.length === 0 ? ( + ) : edit && widgets.length === 0 ? (

Nothing on this page yet. Add a widget from the bar below.

) : ( -
- {/* Measured first: laying out against a guessed width would place every - widget once and then move it. */} - {mounted ? ( - - {widgets.map((widget) => ( -
- { - if (!(event.target as Element).closest(INTERACTIVE)) { - setSettingsOpen(false) - setSelected(widget.id) - } - }} - > - - -
- ))} -
- ) : null} -
+ + {(scale) => + !edit ? ( + + ) : ( + + {widgets.map((widget) => ( +
+ { + if (!(event.target as Element).closest(INTERACTIVE)) { + setSettingsOpen(false) + setSelected(widget.id) + } + }} + > + + +
+ ))} +
+ ) + } +
) return ( <>
section.widgets ?? [] export const columnsOf = (dashboard: Dashboard) => 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(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 ( +
+ {/* Measured first: a guessed scale would place the whole panel once and + then move it. */} + {scale > 0 ? ( +
+ {children(scale)} +
+ ) : null} +
+ ) +} + export function placement(widget: WidgetDef): Placement { const layout = (widget.layout ?? {}) as Record return layout.lg ?? layout.md ?? layout.sm ?? {} diff --git a/frontend/src/components/Dashboard/dashboard.css b/frontend/src/components/Dashboard/dashboard.css index 28c0d25..00a53ad 100644 --- a/frontend/src/components/Dashboard/dashboard.css +++ b/frontend/src/components/Dashboard/dashboard.css @@ -6,8 +6,9 @@ * so nothing here touches the byte-identical token blocks. */ -/* The same dot grid the flow viewport paints, as plain CSS: a dashboard has no - zoom, so it needs none of React Flow's rescaling. */ +/* The placement grid, as plain CSS. One dot per cell corner: the surface hands + 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 { background-color: var(--background); background-image: radial-gradient( @@ -15,42 +16,34 @@ color-mix(in srgb, var(--muted-foreground) 30%, transparent) 1.5px, 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 - * panel can be matched to its own width. Below the large breakpoint the stored - * placement is meaningless — three columns cannot hold a twelve-column - * arrangement — so widgets stack full width and keep only their height. + * panel can be matched to its own width. There is no responsive fallback: the + * grid lives on a canvas of the panel's own pixel size, which is scaled to the + * viewport rather than reflowed into it. */ .widget-grid { display: grid; gap: 0.75rem; grid-auto-rows: 5rem; - grid-template-columns: minmax(0, 1fr); + grid-template-columns: repeat(var(--widget-cols), minmax(0, 1fr)); } .widget-cell { + grid-column: span var(--w); grid-row: span var(--h); min-width: 0; } -@media (min-width: 64rem) { - .widget-grid { - grid-template-columns: repeat(var(--widget-cols), minmax(0, 1fr)); - } - - .widget-cell { - grid-column: span var(--w); - } - - /* Only once something has actually been placed; an untouched dashboard has - every widget at 0,0 and would pile up on one cell. */ - .widget-grid[data-placed] .widget-cell { - grid-column: var(--x) / span var(--w); - grid-row: var(--y) / span var(--h); - } +/* Only once something has actually been placed; an untouched dashboard has + every widget at 0,0 and would pile up on one cell. */ +.widget-grid[data-placed] .widget-cell { + grid-column: var(--x) / span var(--w); + grid-row: var(--y) / span var(--h); } /* diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index 471b5bd..f05ee14 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -27,7 +27,13 @@ import { SelectValue, } from "@/components/ui/select" 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 { 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 grid size is the one that matters: a wall panel is a fixed width, and - * twelve columns on a seven-inch screen is a different dashboard than twelve - * on a television. + * The canvas is the one that matters: a wall panel is a fixed size, and twelve + * columns on a seven-inch screen is a different dashboard than twelve on a + * television. */ export function DashboardPanel({ open, @@ -337,6 +347,7 @@ export function DashboardPanel({ onClose: () => void }) { const [confirmOpen, setConfirmOpen] = useState(false) + const canvas = canvasOf(dashboard) return ( <> @@ -390,6 +401,65 @@ export function DashboardPanel({

+
+ Canvas + +
+ + onChange({ canvas_width: Number(event.target.value) || 0 }) + } + /> + + onChange({ canvas_height: Number(event.target.value) || 0 }) + } + /> +
+

+ The panel this is drawn for, in pixels. Editing and viewing both + scale that surface to fit, so the arrangement is the same + everywhere. +

+
+
Contents

diff --git a/frontend/src/routes/view.$name.tsx b/frontend/src/routes/view.$name.tsx index f07c28d..2240eea 100644 --- a/frontend/src/routes/view.$name.tsx +++ b/frontend/src/routes/view.$name.tsx @@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query" import { createFileRoute, redirect } from "@tanstack/react-router" 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 { useFlowSocket } from "@/components/Flow/useFlowSocket" import { isLoggedIn } from "@/hooks/useAuth" @@ -30,8 +33,14 @@ function PanelView() { const { data: dashboard } = useQuery(dashboardQueryOptions(name)) return ( -

- {dashboard ? : null} +
+ {dashboard ? ( + // The panel's own surface, scaled to whatever screen it landed on. No + // dots: nothing is being arranged here. + + {() => } + + ) : null}
) }