feat(dashboard): auf jedem Bildschirm dasselbe Bild – Leinwand je Reiter, massstaeblich skaliert
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m20s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m54s

Je Reiter wird die Flaeche des ersten Desktop-Bildschirms als __canvas im
Layout-JSON gespeichert (ohne API/DB-Aenderung, GRID_VERSION bleibt 3).
Das Raster rendert in Leinwandbreite und wird per transform: scale(min(bw/cw, bh/ch))
eingepasst, Schrift eingeschlossen, ohne Scrollen; unter 768 px wie bisher.
Ziehen/Groesse aendern unter Skalierung ueber eine eigene Positionsstrategie
(createScaledStrategy aus react-grid-layout 2.2.3 rechnet den Rasterversatz falsch).
Im Browser nachgewiesen: 1920x1080 -> 1366x768 (Faktor 0,66), Ziehen +200 px
folgt der Maus, 700 px ohne Skalierung.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 11:19:27 +02:00
parent bd73fa5e74
commit af87c2c112
9 changed files with 653 additions and 79 deletions
@@ -1,8 +1,14 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { Children, isValidElement } from 'react';
import { createScaledStrategy, defaultPositionStrategy } from 'react-grid-layout/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { stubResizeObserver } from '@/test/fake-resize-observer';
import { RESIZE_AXIS_FALLBACK } from './dashboard-grid';
import {
computeCanvasScale,
DASHBOARD_BOTTOM_GAP_PX,
RESIZE_AXIS_FALLBACK,
scaledPositionStrategy,
} from './dashboard-grid';
// Mock CSS imports that vitest cannot resolve
vi.mock('react-grid-layout/css/styles.css', () => ({}));
@@ -652,3 +658,139 @@ describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 3, 9, 'se', context)).toEqual({ w: 3, h: 9 });
});
});
describe('Leinwand: Dashboard wie ein Bild mitskalieren', () => {
const ONE = {
layouts: { lg: [{ i: 'inst-1', x: 0, y: 0, w: 8, h: 4 }], md: [], sm: [], xs: [], xxs: [] },
widgets: [{ id: 'inst-1', widgetType: 'clock', config: {} }],
};
// jsdom: innerHeight 768, getBoundingClientRect -> 0 -> verfuegbare Hoehe 748.
const JSDOM_AVAILABLE_H = 768 - DASHBOARD_BOTTOM_GAP_PX;
function setInnerHeight(value: number) {
Object.defineProperty(window, 'innerHeight', { value, configurable: true, writable: true });
}
afterEach(() => setInnerHeight(768));
async function renderGrid(props: {
canvas?: { w: number; h: number } | null;
onCaptureCanvas?: (area: { w: number; h: number }) => void;
}) {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
const view = render(
<DashboardGrid
{...ONE}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
{...props}
/>,
);
const outer = view.container.firstElementChild as HTMLElement;
const inner = outer.firstElementChild as HTMLElement;
return { outer, inner };
}
describe('computeCanvasScale', () => {
it('breitenbegrenzt: freier Platz unten', () => {
expect(computeCanvasScale({ w: 1280, h: 900 }, { w: 1600, h: 800 })).toBe(0.8);
});
it('hoehenbegrenzt: freier Platz rechts', () => {
expect(computeCanvasScale({ w: 1920, h: 600 }, { w: 1600, h: 800 })).toBe(0.75);
});
it('Vergroesserung bei groesserem Bildschirm (s > 1)', () => {
expect(computeCanvasScale({ w: 2400, h: 1300 }, { w: 1600, h: 800 })).toBe(1.5);
});
it('gleiche Flaeche -> 1', () => {
expect(computeCanvasScale({ w: 1600, h: 800 }, { w: 1600, h: 800 })).toBe(1);
});
it('unbrauchbare Hoehe begrenzt nicht, dann zaehlt nur die Breite', () => {
expect(computeCanvasScale({ w: 800, h: 0 }, { w: 1600, h: 800 })).toBe(0.5);
expect(computeCanvasScale({ w: 800, h: Number.NaN }, { w: 1600, h: 800 })).toBe(0.5);
});
});
describe('scaledPositionStrategy (Ziehen/Groesse aendern unter transform)', () => {
it('s = 1 ist die Standardstrategie', () => {
expect(scaledPositionStrategy(1)).toBe(defaultPositionStrategy);
});
it('s != 1: transform-Strategie mit scale, OHNE calcDragPosition (RGL rechnet dann relativ zum Raster)', () => {
const strategy = scaledPositionStrategy(0.5);
expect(strategy.type).toBe('transform');
expect(strategy.scale).toBe(0.5);
expect(strategy.calcDragPosition).toBeUndefined();
expect(strategy.calcStyle({ left: 10, top: 20, width: 30, height: 40 })).toEqual(
defaultPositionStrategy.calcStyle({ left: 10, top: 20, width: 30, height: 40 }),
);
});
it('Grund: createScaledStrategy rechnet den Ziehstart in Fensterkoordinaten (ohne Raster-Versatz)', () => {
// Kachel sichtbar bei clientX 300 (Raster beginnt bei 250 neben der
// Seitenleiste, Kachel also 50 px im Raster), Maus 10 px in der Kachel.
const pos = createScaledStrategy(0.5).calcDragPosition?.(310, 110, 10, 10);
// Richtig waere (300 - 250) / 0.5 = 100; geliefert wird 300 / 0.5 = 600.
expect(pos?.left).toBe(600);
});
});
it('skalierter Modus: virtuelle Breite canvas.w, transform scale(s) links oben, sichtbare Hoehe = Rasterhoehe x s', async () => {
stubResizeObserver({ width: 1000, height: 800 });
const { outer, inner } = await renderGrid({ canvas: { w: 2000, h: 800 } });
// s = min(1000 / 2000, 748 / 800) = 0.5
expect(captured.props?.width).toBe(2000);
expect(inner.style.width).toBe('2000px');
expect(inner.style.transform).toBe('scale(0.5)');
expect(inner.style.transformOrigin).toBe('top left');
expect(outer.style.height).toBe('400px');
expect(outer.style.overflow).toBe('hidden');
const strategy = captured.props?.positionStrategy as { scale: number };
expect(strategy.scale).toBe(0.5);
});
it('skalierter Modus hoehenbegrenzt und vergroessernd', async () => {
stubResizeObserver({ width: 1000, height: 800 });
setInnerHeight(420); // verfuegbar 400
const first = await renderGrid({ canvas: { w: 1000, h: 800 } });
expect(first.inner.style.transform).toBe('scale(0.5)');
cleanup();
setInnerHeight(768);
const second = await renderGrid({ canvas: { w: 800, h: 374 } });
// min(1000 / 800, 748 / 374) = 1.25
expect(second.inner.style.transform).toBe('scale(1.25)');
expect(captured.props?.width).toBe(800);
});
it('schmal (< 768): wie bisher — Containerbreite, kein transform, keine Erfassung', async () => {
stubResizeObserver({ width: 600, height: 800 });
const onCaptureCanvas = vi.fn();
const { outer, inner } = await renderGrid({ canvas: { w: 1600, h: 800 }, onCaptureCanvas });
expect(captured.props?.width).toBe(600);
expect(inner.style.transform).toBe('');
expect(outer.style.height).toBe('');
expect(captured.props?.positionStrategy).toBe(defaultPositionStrategy);
cleanup();
await renderGrid({ canvas: null, onCaptureCanvas });
expect(onCaptureCanvas).not.toHaveBeenCalled();
});
it('ohne Leinwand am Desktop: unskaliert und die verfuegbare Flaeche wird zum Erfassen gemeldet', async () => {
stubResizeObserver({ width: 1000, height: 800 });
const onCaptureCanvas = vi.fn();
const { inner } = await renderGrid({ canvas: null, onCaptureCanvas });
expect(captured.props?.width).toBe(1000);
expect(inner.style.transform).toBe('');
expect(onCaptureCanvas).toHaveBeenCalledWith({ w: 1000, h: JSDOM_AVAILABLE_H });
});
it('mit Leinwand wird nicht erneut erfasst', async () => {
stubResizeObserver({ width: 1000, height: 800 });
const onCaptureCanvas = vi.fn();
await renderGrid({ canvas: { w: 1000, h: 748 }, onCaptureCanvas });
expect(onCaptureCanvas).not.toHaveBeenCalled();
});
});
@@ -3,10 +3,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
import { noCompactor, Responsive } from 'react-grid-layout';
import { defaultConstraints, type LayoutConstraint } from 'react-grid-layout/core';
import {
defaultConstraints,
defaultPositionStrategy,
type LayoutConstraint,
type PositionStrategy,
transformStrategy,
} from 'react-grid-layout/core';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { useTranslations } from 'next-intl';
import { CANVAS_MIN_HEIGHT, CANVAS_MIN_WIDTH, type GridCanvas } from '@/lib/grid-layout-migration';
import {
clampWidthToCols,
GRID_COLS,
@@ -101,6 +108,66 @@ const GRID_CONSTRAINTS: LayoutConstraint[] = [...defaultConstraints, RESIZE_AXIS
const GRID_MARGIN = 12;
/**
* Dashboard wie ein Bild mitskalieren (Leinwand).
*
* Wunsch: an PC 1 eingerichtet, an PC 2 mit anderer Aufloesung GENAU gleich
* aussehen — alles (auch die Schrift) gleichmaessig kleiner oder groesser,
* nie scrollen; passt die Bildschirmform nicht, bleibt rechts oder unten
* Platz frei.
*
* Umsetzung: Jeder Reiter hat eine Leinwand `{ w, h }` (gespeichert als
* `__canvas`, siehe grid-layout-migration.ts) — die verfuegbare Flaeche, auf
* der er eingerichtet wurde (erfasst beim ersten Oeffnen am Desktop, danach
* fest). Wo die Leinwand erfasst wurde, sieht das Dashboard 1:1 aus. Auf
* jedem Desktop-Bildschirm zeichnet react-grid-layout das Raster mit der
* VIRTUELLEN Breite `canvas.w` — Breakpoint und Spaltenbreite sind damit
* ueberall identisch — und ein umgebendes Element verkleinert/vergroessert
* das Ergebnis per `transform: scale(s)` mit
* `s = min(Breite / canvas.w, Hoehe / canvas.h)` (computeCanvasScale),
* links oben ausgerichtet (nicht zentriert, bewusst entfernt in bc4c011).
*
* Unter CANVAS_MIN_WIDTH (Handy/schmal) bleibt alles wie zuvor: Breite =
* gemessene Containerbreite, kein transform, keine Erfassung.
*/
/** Abstand unter dem Raster bis zur Fensterkante: main p-3 (12) + Wrapper p-2 (8). */
export const DASHBOARD_BOTTOM_GAP_PX = 20;
/**
* Skalierungsfaktor der Leinwand fuer die verfuegbare Flaeche: das kleinere
* der beiden Verhaeltnisse, damit alles hineinpasst (freier Platz rechts
* ODER unten). Eine unbrauchbare Hoehe (<= 0, nicht endlich) begrenzt nicht —
* dann entscheidet nur die Breite.
*/
export function computeCanvasScale(available: GridCanvas, canvas: GridCanvas): number {
const byWidth = available.w / canvas.w;
const byHeight = available.h / canvas.h;
const heightUsable = Number.isFinite(byHeight) && byHeight > 0;
const s = heightUsable ? Math.min(byWidth, byHeight) : byWidth;
return Number.isFinite(s) && s > 0 ? s : 1;
}
/**
* Positionsstrategie fuer ein per `transform: scale(s)` skaliertes Raster.
*
* Bewusst NICHT `createScaledStrategy(s)` aus react-grid-layout 2.2.3: deren
* `calcDragPosition` rechnet `(clientX - offsetX) / scale` =
* `clientRect.left / scale` (chunk-KDANGDDL.mjs:315-320) — eine FENSTER-
* Koordinate ohne Abzug der Raster-Oberkante/-Linkskante. Im Portal liegt das
* Raster rechts neben der Seitenleiste; die Kachel spraenge beim Ziehstart um
* (Seitenleiste + Abstand) / s Pixel nach rechts unten. Ohne
* `calcDragPosition` nimmt react-grid-layout den eigenen Weg
* (chunk-WGL5FSZH.mjs:146-163): `(clientRect.left - parentRect.left) /
* transformScale` — relativ zum Raster und korrekt entskaliert. Die
* Mausbewegungen teilen `DraggableCore` (Prop `scale`) bzw. `Resizable`
* (`deltaX / transformScale`) selbst durch `scale`; beide lesen ihn aus
* `positionStrategy.scale` (chunk-WGL5FSZH.mjs:675, 512, 527).
*/
export function scaledPositionStrategy(scale: number): PositionStrategy {
if (scale === 1) return defaultPositionStrategy;
return { type: 'transform', scale, calcStyle: transformStrategy.calcStyle };
}
interface LayoutItemShape {
i: string;
x: number;
@@ -121,6 +188,10 @@ interface DashboardGridProps {
onOpenCatalog?: () => void;
/** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */
onQuickAdd?: (type: WidgetType) => void;
/** Leinwand des aktiven Reiters; null = noch nicht erfasst (dann unskaliert). */
canvas?: GridCanvas | null;
/** Wird mit der verfuegbaren Flaeche gerufen, solange `canvas` fehlt (nur Desktop). */
onCaptureCanvas?: (area: GridCanvas) => void;
}
/** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */
@@ -224,9 +295,15 @@ export function DashboardGrid({
onRemoveWidget,
onOpenCatalog,
onQuickAdd,
canvas = null,
onCaptureCanvas,
}: DashboardGridProps) {
const t = useTranslations('widgets');
const [width, setWidth] = useState(1200);
// Verfuegbare Hoehe bis zur unteren Fensterkante (Leinwand). 0 = unbekannt.
const [availableHeight, setAvailableHeight] = useState(0);
// Unskalierte Hoehe des Rasters (inneres Element) fuer die sichtbare Hoehe.
const [innerHeight, setInnerHeight] = useState(0);
// quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf),
// nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste.
@@ -265,6 +342,16 @@ export function DashboardGrid({
}
}, []);
// Leinwand: Hoehe = Fensterhoehe minus Oberkante des Rasters im DOKUMENT
// (rect.top + scrollY, also unabhaengig vom Scrollstand) minus Abstand
// unten. Das aeussere Element ist selbst nicht transformiert, seine
// Oberkante ist also die echte.
const applyHeight = useCallback((node: HTMLElement) => {
const top = node.getBoundingClientRect().top + window.scrollY;
const next = window.innerHeight - top - DASHBOARD_BOTTOM_GAP_PX;
setAvailableHeight(Number.isFinite(next) && next > 0 ? Math.floor(next) : 0);
}, []);
const measureRef = useCallback(
(node: HTMLDivElement | null) => {
// Ein eventuell laufender Beobachter zuerst trennen — auch der
@@ -277,27 +364,64 @@ export function DashboardGrid({
// Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen.
applyWidth(node.getBoundingClientRect().width);
applyHeight(node);
const observer = new ResizeObserver((entries) => {
applyWidth(entries[0].contentRect.width);
applyHeight(node);
});
observer.observe(node);
observerRef.current = observer;
// Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den
// Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf.
},
[applyWidth],
[applyWidth, applyHeight],
);
useEffect(() => {
const onResize = () => {
if (nodeRef.current) {
applyWidth(nodeRef.current.getBoundingClientRect().width);
applyHeight(nodeRef.current);
}
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [applyWidth]);
}, [applyWidth, applyHeight]);
// Unskalierte Hoehe des inneren Elements (react-grid-layout setzt die
// Rasterhoehe selbst). `contentRect`/`offsetHeight` sind Layout-Masse und
// vom transform unberuehrt — genau das Gewuenschte.
const innerObserverRef = useRef<ResizeObserver | null>(null);
const innerRef = useCallback((node: HTMLDivElement | null) => {
innerObserverRef.current?.disconnect();
innerObserverRef.current = null;
if (!node) return;
if (node.offsetHeight > 0) setInnerHeight(node.offsetHeight);
const observer = new ResizeObserver((entries) => {
const h = entries[0].contentRect.height;
if (Number.isFinite(h) && h > 0) setInnerHeight(h);
});
observer.observe(node);
innerObserverRef.current = observer;
}, []);
// Desktop = verfuegbare Breite ab CANVAS_MIN_WIDTH; nur dort Leinwand.
const isDesktop = width >= CANVAS_MIN_WIDTH;
const scaled = isDesktop && canvas !== null;
const scale = scaled ? computeCanvasScale({ w: width, h: availableHeight }, canvas) : 1;
const positionStrategy = useMemo(() => scaledPositionStrategy(scale), [scale]);
// Leinwand erfassen: nur mit eingehaengtem Raster (also mit Kacheln), am
// Desktop und mit brauchbarer Hoehe. Der Store uebernimmt sie einmal und
// ignoriert weitere Aufrufe; danach ist `canvas` gesetzt und dieser
// Effekt tut nichts mehr.
const hasWidgets = widgets.length > 0;
useEffect(() => {
if (!onCaptureCanvas || canvas !== null || !hasWidgets) return;
if (!isDesktop || availableHeight < CANVAS_MIN_HEIGHT) return;
onCaptureCanvas({ w: width, h: availableHeight });
}, [onCaptureCanvas, canvas, hasWidgets, isDesktop, width, availableHeight]);
// quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
@@ -374,56 +498,72 @@ export function DashboardGrid({
);
}
// Gleiche DOM-Struktur in beiden Modi, damit react-grid-layout beim
// Wechsel (etwa direkt nach dem Erfassen, s = 1) nicht neu einhaengt.
// Aeusseres Element: volle Breite (Messung), im skalierten Modus die
// sichtbare Hoehe = unskalierte Rasterhoehe x s und `overflow: hidden` —
// der transform aendert die Layout-Box nicht, ohne beides bliebe toter
// Scrollraum (s < 1) bzw. ragte die 1:1-Breite seitlich hinaus.
const outerStyle: React.CSSProperties | undefined = scaled
? { overflow: 'hidden', ...(innerHeight > 0 ? { height: innerHeight * scale } : {}) }
: undefined;
const innerStyle: React.CSSProperties | undefined = scaled
? { width: canvas.w, transform: `scale(${scale})`, transformOrigin: 'top left' }
: undefined;
return (
<div ref={measureRef}>
<Responsive
width={width}
breakpoints={BREAKPOINTS}
cols={COLS}
layouts={effectiveLayouts as ResponsiveLayouts}
rowHeight={20}
margin={[GRID_MARGIN, GRID_MARGIN] as [number, number]}
constraints={GRID_CONSTRAINTS}
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
dragConfig={{
enabled: isEditMode,
handle: WIDGET_DRAG_HANDLE_SELECTOR,
cancel: WIDGET_DRAG_CANCEL_SELECTOR,
threshold: 3,
}}
resizeConfig={{
enabled: isEditMode,
}}
compactor={FREE_PLACEMENT_COMPACTOR}
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
>
{widgets.map((widget, index) => {
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
return (
<div
key={widget.id}
data-grid={{
...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? {
x: 0,
y: 0,
w: constraints?.defaultW ?? 8,
h: constraints?.defaultH ?? 4,
}),
minW: constraints?.minW ?? 8,
minH: constraints?.minH ?? 4,
}}
>
<WidgetWrapper
widget={widget}
isEditMode={isEditMode}
onRemove={onRemoveWidget}
enterIndex={index}
/>
</div>
);
})}
</Responsive>
<div ref={measureRef} style={outerStyle} data-canvas-scale={scaled ? scale : undefined}>
<div ref={innerRef} style={innerStyle}>
<Responsive
width={scaled ? canvas.w : width}
positionStrategy={positionStrategy}
breakpoints={BREAKPOINTS}
cols={COLS}
layouts={effectiveLayouts as ResponsiveLayouts}
rowHeight={20}
margin={[GRID_MARGIN, GRID_MARGIN] as [number, number]}
constraints={GRID_CONSTRAINTS}
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
dragConfig={{
enabled: isEditMode,
handle: WIDGET_DRAG_HANDLE_SELECTOR,
cancel: WIDGET_DRAG_CANCEL_SELECTOR,
threshold: 3,
}}
resizeConfig={{
enabled: isEditMode,
}}
compactor={FREE_PLACEMENT_COMPACTOR}
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
>
{widgets.map((widget, index) => {
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
return (
<div
key={widget.id}
data-grid={{
...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? {
x: 0,
y: 0,
w: constraints?.defaultW ?? 8,
h: constraints?.defaultH ?? 4,
}),
minW: constraints?.minW ?? 8,
minH: constraints?.minH ?? 4,
}}
>
<WidgetWrapper
widget={widget}
isEditMode={isEditMode}
onRemove={onRemoveWidget}
enterIndex={index}
/>
</div>
);
})}
</Responsive>
</div>
</div>
);
}