diff --git a/apps/web/src/components/dashboard/dashboard-grid.tsx b/apps/web/src/components/dashboard/dashboard-grid.tsx index efd545b6..309986dc 100644 --- a/apps/web/src/components/dashboard/dashboard-grid.tsx +++ b/apps/web/src/components/dashboard/dashboard-grid.tsx @@ -20,8 +20,12 @@ import 'react-grid-layout/css/styles.css' import { Button } from '@/components/ui/button' import type { ServerMetrics } from '@/hooks/use-servers-ws' import { cn } from '@/lib/utils' -import type { DashboardWidget } from '@/lib/widget-types' +import type { DashboardWidget, SizingStrategy, WidgetTypeDefinition } from '@/lib/widget-types' +import { WIDGET_TYPES } from '@/lib/widget-types' import { layoutToPatch, widgetsToLayout } from './dashboard-layout' +import { COLS, MARGIN, MARGIN_Y, ROW_HEIGHT, SCALE } from './grid-constants' +import { applyCoarsePatch, applyStrategy, snapOnRelease } from './sizing-strategies' +import { normalizeRenderItem } from './sizing-strategies/normalize' import { VisibilityGate } from './visibility-gate' import { WidgetRenderer } from './widget-renderer' @@ -43,26 +47,11 @@ function isWidgetStatic(configJson: string): boolean { } } -const COLS = 12 -// Vertical fine-grain factor: persisted (coarse) grid rows are split into SCALE -// finer rows so content-sized widgets quantize to ~ROW_HEIGHT px instead of a -// whole coarse row. Invariant for pixel-identical legacy widgets: the legacy -// per-row step (80 + 16) must equal SCALE * (ROW_HEIGHT + MARGIN_Y) → 4*(8+16)=96. -const SCALE = 4 -const ROW_HEIGHT = 8 -const MARGIN: [number, number] = [16, 16] -const MARGIN_Y = MARGIN[1] // Legacy coarse row pixel height, used only for the mobile single-column min-height. const MOBILE_ROW_PX = 80 const MOBILE_BREAKPOINT = 768 -// Widgets whose grid cell height should follow their measured content height -// instead of a fixed/estimated number of rows. -const AUTO_HEIGHT_TYPES = new Set(['top-n']) - -// Widgets that must stay square (1:1 in coarse grid units). Width and height -// are locked together during resize so the radial visual stays balanced. -const SQUARE_TYPES = new Set(['gauge']) +const WIDGET_TYPE_MAP = new Map(WIDGET_TYPES.map((widget) => [widget.id, widget])) function pxToGridUnits(px: number): number { return Math.max(2, Math.ceil((px + MARGIN_Y) / (ROW_HEIGHT + MARGIN_Y))) @@ -189,9 +178,18 @@ export function DashboardGrid({ setAutoUnits((prev) => (prev[id] === units ? prev : { ...prev, [id]: units })) }, []) - const squareIdSet = useMemo( - () => new Set(widgets.filter((w) => SQUARE_TYPES.has(w.widget_type)).map((w) => w.id)), - [widgets] + const widgetById = useMemo(() => new Map(widgets.map((w) => [w.id, w])), [widgets]) + + const getStrategy = useCallback( + (itemId: string): SizingStrategy => { + const widget = widgetById.get(itemId) + if (!widget) { + return { kind: 'free' } + } + const def = WIDGET_TYPE_MAP.get(widget.widget_type) + return def?.sizing ?? { kind: 'free' } + }, + [widgetById] ) // Persisted grid units are coarse (1 row == ROW_HEIGHT*SCALE px). The grid @@ -209,22 +207,33 @@ export function DashboardGrid({ if (item.maxH !== undefined) { item.maxH *= SCALE } - const units = autoUnits[item.i] - if (units !== undefined) { - // Auto-height widgets fit their content exactly: height is locked to the - // measured content and cannot be adjusted. Only horizontal resize stays. - item.minH = units - item.maxH = units - item.h = units - item.resizeHandles = ['e'] + + const strategy = getStrategy(item.i) + const measured = autoUnits[item.i] + + // Layer A: idle h / minH / maxH per strategy. + const normalized = normalizeRenderItem(item, strategy, { + containerWidth: width, + autoMeasuredFineH: measured + }) + item.h = normalized.h + item.minH = normalized.minH + item.maxH = normalized.maxH + + // Layer B: resize-time constraints, handles, resizability. + const desc = applyStrategy(strategy, measured) + if (desc.constraints.length > 0) { + item.constraints = desc.constraints + } + if (desc.resizeHandles) { + item.resizeHandles = desc.resizeHandles } - if (squareIdSet.has(item.i)) { - // Square widgets resize on the corner only and snap to w === h on commit. - item.resizeHandles = ['se'] + if (!desc.isResizable) { + item.isResizable = false } } return deoverlapLayout(layout) - }, [widgets, autoUnits, squareIdSet]) + }, [widgets, autoUnits, width, getStrategy]) const [liveLayout, setLiveLayout] = useState(baseLayout) const [interactionState, setInteractionState] = useState('idle') @@ -241,11 +250,6 @@ export function DashboardGrid({ } const widgetServers = shouldFreeze ? frozenServersRef.current : servers - const autoIdSet = useMemo( - () => new Set(widgets.filter((w) => AUTO_HEIGHT_TYPES.has(w.widget_type)).map((w) => w.id)), - [widgets] - ) - // No auto-compaction (widgets stay exactly where dropped, so items in // different columns can be aligned freely) and preventCollision blocks // dropping onto another widget (it snaps back), so widgets never overlap. @@ -255,19 +259,20 @@ export function DashboardGrid({ // whole coarse rows (SCALE-aligned) while dragging/resizing. Otherwise the // dropped fine position gets rounded on commit and the widget visibly snaps // back, then ping-pongs with RGL's onLayoutChange echo (the "jitter"). - // Auto-height widgets keep their measured fine height untouched. + // content-height widgets keep their measured fine height untouched. const updateLiveLayout = useCallback( (nextLayout: Layout) => { const snapped = nextLayout.map((item) => { + const strategy = getStrategy(item.i) const base = { ...item, - y: Math.round(item.y / SCALE) * SCALE, - h: autoIdSet.has(item.i) ? item.h : Math.max(SCALE, Math.round(item.h / SCALE) * SCALE) + y: Math.round(item.y / SCALE) * SCALE } - if (squareIdSet.has(item.i)) { - // Lock height to width (in fine units, h = w * SCALE) so the gauge - // stays a coarse-unit square no matter which dimension the user drags. - base.h = base.w * SCALE + // Snap h to SCALE multiples only for strategies that operate at coarse h. + // aspect-square: h is fine pixel-square (RGL constraints handle resize); leave it. + // content-height: h locked to measurement; leave it. + if (strategy.kind === 'free' || strategy.kind === 'fixed') { + base.h = Math.max(SCALE, Math.round(item.h / SCALE) * SCALE) } return base }) @@ -276,7 +281,7 @@ export function DashboardGrid({ // raw (overlapping) persisted positions over the de-overlapped layout. setLiveLayout(deoverlapLayout(snapped)) }, - [autoIdSet, squareIdSet] + [getStrategy] ) // Resync the rendered layout to the widgets-derived one the moment `widgets` @@ -305,25 +310,33 @@ export function DashboardGrid({ const commitLayoutChange = useCallback( (finalLayout: Layout) => { setInteractionState('idle') - // Convert the fine grid back to coarse persisted units. Auto-height widgets - // persist their height too so a user-grown size sticks (the measured - // content height still acts as the floor on the next render). - // Snap to coarse rows then resolve any residual penetration. preventCollision - // can block the move so no patch is emitted; without this the live layout - // would keep the penetrating drag position until the next widgets change. + + // Per-strategy snap. For free/fixed: snap h to coarse multiples. For + // aspect-square: apply snapOnRelease's coarse SnapPatch via applyCoarsePatch + // (sets w and re-derives fine h via SCALE). Then re-normalize so the live + // layout matches what the next baseLayout render will produce. const snapped = finalLayout.map((item) => { - const base = { + const strategy = getStrategy(item.i) + const measured = autoUnits[item.i] + + let base = { ...item, - y: Math.round(item.y / SCALE) * SCALE, - h: autoIdSet.has(item.i) ? item.h : Math.max(SCALE, Math.round(item.h / SCALE) * SCALE) + y: Math.round(item.y / SCALE) * SCALE } - if (squareIdSet.has(item.i)) { - base.h = base.w * SCALE + if (strategy.kind === 'free' || strategy.kind === 'fixed') { + base.h = Math.max(SCALE, Math.round(item.h / SCALE) * SCALE) } - return base + + const snap = snapOnRelease(base, strategy, { containerWidth: width }) + base = applyCoarsePatch(base, snap) + return normalizeRenderItem(base, strategy, { + containerWidth: width, + autoMeasuredFineH: measured + }) }) const resolved = deoverlapLayout(snapped) setLiveLayout(resolved) + const coarseLayout = resolved.map((item) => ({ ...item, y: Math.round(item.y / SCALE), @@ -334,7 +347,7 @@ export function DashboardGrid({ onLayoutChange(patch) } }, - [autoIdSet, squareIdSet, onLayoutChange, widgets] + [autoUnits, getStrategy, onLayoutChange, widgets, width] ) const sortedWidgets = useMemo(() => { @@ -345,7 +358,7 @@ export function DashboardGrid({ return (
{sortedWidgets.map((widget) => { - const isAuto = AUTO_HEIGHT_TYPES.has(widget.widget_type) + const isAuto = getStrategy(widget.id).kind === 'content-height' return (
{isEditing && ( @@ -393,7 +406,7 @@ export function DashboardGrid({ width={width} > {widgets.map((widget) => { - const isAuto = AUTO_HEIGHT_TYPES.has(widget.widget_type) + const isAuto = getStrategy(widget.id).kind === 'content-height' return (
{isEditing && ( diff --git a/apps/web/src/components/dashboard/dashboard-layout.test.ts b/apps/web/src/components/dashboard/dashboard-layout.test.ts index e497d11e..139a3e28 100644 --- a/apps/web/src/components/dashboard/dashboard-layout.test.ts +++ b/apps/web/src/components/dashboard/dashboard-layout.test.ts @@ -2,6 +2,23 @@ import { describe, expect, it } from 'vitest' import type { DashboardWidget } from '@/lib/widget-types' import { layoutToPatch, mergeLayoutPatch, normalizeNewWidgetPlacement, widgetsToLayout } from './dashboard-layout' +function makeWidget(overrides: Partial): DashboardWidget { + return { + id: 'w1', + dashboard_id: 'd1', + widget_type: 'gauge', + grid_x: 0, + grid_y: 0, + grid_w: 2, + grid_h: 2, + sort_order: 0, + title: null, + config_json: '{}', + created_at: '2026-05-28T00:00:00Z', + ...overrides + } +} + const widgets: DashboardWidget[] = [ { id: 'w-1', @@ -31,6 +48,41 @@ const widgets: DashboardWidget[] = [ } ] +describe('widgetsToLayout', () => { + describe('aspect-square clamp', () => { + it('snaps a non-square gauge to the nearest tier (uses max of w/h)', () => { + // grid_w=3, grid_h=5 → max=5 → nearest tier in [2,3,4,5,6] is 5 + const widget = makeWidget({ grid_w: 3, grid_h: 5 }) + const [item] = widgetsToLayout([widget]) + expect(item.w).toBe(5) + expect(item.h).toBe(5) + }) + + it('preserves a gauge already at a legal tier', () => { + const widget = makeWidget({ grid_w: 4, grid_h: 4 }) + const [item] = widgetsToLayout([widget]) + expect(item.w).toBe(4) + expect(item.h).toBe(4) + }) + + it('clamps an oversized gauge to maxW tier', () => { + // grid_w=10 → clamped to maxW=6 by existing logic → tier=6 + const widget = makeWidget({ grid_w: 10, grid_h: 10 }) + const [item] = widgetsToLayout([widget]) + expect(item.w).toBe(6) + expect(item.h).toBe(6) + }) + + it('does not snap non-aspect-square widgets', () => { + // metric-card is free; should keep grid_w/h as-is (within min/max) + const widget = makeWidget({ widget_type: 'metric-card', grid_w: 4, grid_h: 3 }) + const [item] = widgetsToLayout([widget]) + expect(item.w).toBe(4) + expect(item.h).toBe(3) + }) + }) +}) + describe('dashboard-layout', () => { it('widgetsToLayout adds min and max constraints from widget definitions', () => { const layout = widgetsToLayout(widgets) diff --git a/apps/web/src/components/dashboard/dashboard-layout.ts b/apps/web/src/components/dashboard/dashboard-layout.ts index f0de2424..0a9019b9 100644 --- a/apps/web/src/components/dashboard/dashboard-layout.ts +++ b/apps/web/src/components/dashboard/dashboard-layout.ts @@ -1,6 +1,7 @@ import type { Layout, LayoutItem } from 'react-grid-layout' import type { DashboardWidget, WidgetTypeDefinition } from '@/lib/widget-types' import { WIDGET_TYPES } from '@/lib/widget-types' +import { nearestTier } from './sizing-strategies' export interface LayoutPatch { grid_h: number @@ -42,6 +43,17 @@ export function widgetsToLayout(widgets: DashboardWidget[]): Layout { if (maxH !== undefined) { h = Math.min(h, maxH) } + + // Aspect-square widgets persist as w_coarse === h_coarse. If historical data + // somehow has them out of sync (or grid_w not at a legal tier), snap here so + // the cell renders square on first load. Next user drag/resize writes back. + const sizing = WIDGET_TYPE_MAP.get(widget.widget_type)?.sizing + if (sizing?.kind === 'aspect-square') { + const tier = nearestTier(Math.max(w, h), sizing.tiers) + w = tier + h = tier + } + const item: LayoutItem = { i: widget.id, x: widget.grid_x, @@ -57,9 +69,6 @@ export function widgetsToLayout(widgets: DashboardWidget[]): Layout { if (maxH !== undefined) { item.maxH = maxH } - if (maxW !== undefined && maxH !== undefined && minW === maxW && minH === maxH) { - item.isResizable = false - } if (isWidgetStatic(widget.config_json)) { item.static = true } diff --git a/apps/web/src/components/dashboard/grid-constants.ts b/apps/web/src/components/dashboard/grid-constants.ts new file mode 100644 index 00000000..fe24ea96 --- /dev/null +++ b/apps/web/src/components/dashboard/grid-constants.ts @@ -0,0 +1,11 @@ +// Grid layout primitives shared between dashboard-grid.tsx and sizing-strategies/. +// Persisted (coarse) grid rows are split into SCALE finer rows so content-sized +// widgets quantize to ~ROW_HEIGHT px instead of a whole coarse row. +// Invariant for pixel-identical legacy widgets: legacy per-row step +// (80 + 16) must equal SCALE * (ROW_HEIGHT + MARGIN_Y) → 4 * (8 + 16) = 96. +export const COLS = 12 +export const SCALE = 4 +export const ROW_HEIGHT = 8 +export const MARGIN: readonly [number, number] = [16, 16] +export const MARGIN_X = MARGIN[0] +export const MARGIN_Y = MARGIN[1] diff --git a/apps/web/src/components/dashboard/sizing-strategies/index.test.ts b/apps/web/src/components/dashboard/sizing-strategies/index.test.ts new file mode 100644 index 00000000..16268a2a --- /dev/null +++ b/apps/web/src/components/dashboard/sizing-strategies/index.test.ts @@ -0,0 +1,179 @@ +import type { LayoutItem } from 'react-grid-layout' +import { describe, expect, it } from 'vitest' +import type { SizingStrategy } from '@/lib/widget-types' +import { applyCoarsePatch, applyStrategy, nearestTier, type SnapPatch } from './index' + +describe('nearestTier', () => { + const tiers = [2, 3, 4, 5, 6] as const + + it('returns the exact tier when value matches', () => { + expect(nearestTier(3, tiers)).toBe(3) + }) + + it('rounds down when between two tiers, closer to lower', () => { + expect(nearestTier(3.4, tiers)).toBe(3) + }) + + it('rounds up when between two tiers, closer to upper', () => { + expect(nearestTier(3.6, tiers)).toBe(4) + }) + + it('picks the first tier on exact ties (conservative)', () => { + expect(nearestTier(3.5, [3, 4])).toBe(3) + expect(nearestTier(4.5, tiers)).toBe(4) + }) + + it('clamps to minimum tier for values below the range', () => { + expect(nearestTier(0, tiers)).toBe(2) + expect(nearestTier(-5, tiers)).toBe(2) + }) + + it('clamps to maximum tier for values above the range', () => { + expect(nearestTier(100, tiers)).toBe(6) + }) +}) + +describe('applyCoarsePatch', () => { + const baseItem: LayoutItem = { + i: 'gauge-1', + x: 2, + y: 0, + w: 2, + h: 8 // fine units (= 2 coarse) + } + + it('returns item unchanged when patch is empty', () => { + const result = applyCoarsePatch(baseItem, {}) + expect(result.w).toBe(2) + expect(result.h).toBe(8) + }) + + it('applies coarse w directly (w lives in coarse units throughout)', () => { + const patch: SnapPatch = { w: 3 } + const result = applyCoarsePatch(baseItem, patch) + expect(result.w).toBe(3) + expect(result.h).toBe(8) // unchanged + }) + + it('converts coarse h to fine by multiplying by SCALE (4)', () => { + const patch: SnapPatch = { h: 3 } + const result = applyCoarsePatch(baseItem, patch) + expect(result.w).toBe(2) + expect(result.h).toBe(12) // 3 * 4 = 12 fine + }) + + it('applies both w and h', () => { + const patch: SnapPatch = { w: 4, h: 4 } + const result = applyCoarsePatch(baseItem, patch) + expect(result.w).toBe(4) + expect(result.h).toBe(16) + }) + + it('does not mutate the input item', () => { + const patch: SnapPatch = { w: 5, h: 5 } + applyCoarsePatch(baseItem, patch) + expect(baseItem.w).toBe(2) + expect(baseItem.h).toBe(8) + }) +}) + +describe('applyStrategy', () => { + it('free returns no constraints and undefined resize handles', () => { + const strategy: SizingStrategy = { kind: 'free' } + const desc = applyStrategy(strategy) + expect(desc.constraints).toEqual([]) + expect(desc.resizeHandles).toBeUndefined() + expect(desc.isResizable).toBe(true) + }) + + it('fixed returns no constraints, empty resize handles, isResizable false', () => { + const strategy: SizingStrategy = { kind: 'fixed' } + const desc = applyStrategy(strategy) + expect(desc.constraints).toEqual([]) + expect(desc.resizeHandles).toEqual([]) + expect(desc.isResizable).toBe(false) + }) + + it('aspect-square returns aspectRatio(1) constraint and SE handle', () => { + const strategy: SizingStrategy = { kind: 'aspect-square', tiers: [2, 3, 4, 5, 6] } + const desc = applyStrategy(strategy) + expect(desc.constraints).toHaveLength(1) + expect(desc.constraints[0].name).toBe('aspectRatio(1)') + expect(desc.resizeHandles).toEqual(['se']) + expect(desc.isResizable).toBe(true) + }) + + it('content-height returns lockHeight constraint when measured', () => { + const strategy: SizingStrategy = { kind: 'content-height' } + const desc = applyStrategy(strategy, 11) + expect(desc.constraints).toHaveLength(1) + expect(desc.constraints[0].name).toBe('lockHeight(11)') + expect(desc.resizeHandles).toEqual(['e']) + expect(desc.isResizable).toBe(true) + }) + + it('content-height returns no constraint when unmeasured', () => { + const strategy: SizingStrategy = { kind: 'content-height' } + const desc = applyStrategy(strategy) + expect(desc.constraints).toEqual([]) + expect(desc.resizeHandles).toEqual(['e']) + expect(desc.isResizable).toBe(true) + }) + + it('lockHeight constraint locks h to the measured value', () => { + const strategy: SizingStrategy = { kind: 'content-height' } + const desc = applyStrategy(strategy, 11) + const constraint = desc.constraints[0] + // Call the constraint directly — params: item, w, h, handle, context + const result = constraint.constrainSize?.({ i: 'x', x: 0, y: 0, w: 4, h: 99 }, 4, 99, 'e', { + cols: 12, + containerWidth: 1000, + maxRows: Number.POSITIVE_INFINITY, + rowHeight: 8, + margin: [16, 16], + layout: [] + } as any) + expect(result).toEqual({ w: 4, h: 11 }) + }) +}) + +import { snapOnRelease } from './index' + +describe('snapOnRelease', () => { + const baseItem: LayoutItem = { + i: 'gauge-1', + x: 0, + y: 0, + w: 3, + h: 11 // fine units + } + + it('aspect-square snaps w to the nearest tier and returns coarse h = w', () => { + const strategy: SizingStrategy = { kind: 'aspect-square', tiers: [2, 3, 4, 5, 6] } + const result = snapOnRelease({ ...baseItem, w: 4 }, strategy, { containerWidth: 1004 }) + expect(result).toEqual({ w: 4, h: 4 }) + }) + + it('aspect-square snaps non-tier w to nearest', () => { + const strategy: SizingStrategy = { kind: 'aspect-square', tiers: [2, 3, 4, 5, 6] } + // base.w shouldn't be fractional in practice, but commitLayoutChange + // may receive a w just above a tier boundary during dragging — verify snap. + const result = snapOnRelease({ ...baseItem, w: 3 }, strategy, { containerWidth: 1004 }) + expect(result).toEqual({ w: 3, h: 3 }) + }) + + it('free returns empty patch', () => { + const strategy: SizingStrategy = { kind: 'free' } + expect(snapOnRelease(baseItem, strategy, { containerWidth: 1004 })).toEqual({}) + }) + + it('fixed returns empty patch', () => { + const strategy: SizingStrategy = { kind: 'fixed' } + expect(snapOnRelease(baseItem, strategy, { containerWidth: 1004 })).toEqual({}) + }) + + it('content-height returns empty patch', () => { + const strategy: SizingStrategy = { kind: 'content-height' } + expect(snapOnRelease(baseItem, strategy, { containerWidth: 1004 })).toEqual({}) + }) +}) diff --git a/apps/web/src/components/dashboard/sizing-strategies/index.ts b/apps/web/src/components/dashboard/sizing-strategies/index.ts new file mode 100644 index 00000000..12073d34 --- /dev/null +++ b/apps/web/src/components/dashboard/sizing-strategies/index.ts @@ -0,0 +1,80 @@ +import type { LayoutItem } from 'react-grid-layout' +import type { LayoutConstraint, ResizeHandleAxis } from 'react-grid-layout/core' +import { aspectRatio } from 'react-grid-layout/core' +import type { SizingStrategy } from '@/lib/widget-types' +import { SCALE } from '../grid-constants' + +// Returns the tier closest to `value`. Ties pick the first (smaller) tier — +// conservative bias avoids accidental upgrades when the user releases mid-drag. +export function nearestTier(value: number, tiers: readonly number[]): number { + return tiers.reduce((best, t) => (Math.abs(t - value) < Math.abs(best - value) ? t : best), tiers[0]) +} + +// A coarse-unit override returned by `snapOnRelease`. The grid layer applies +// it to fine-unit layout items via `applyCoarsePatch`. +export interface SnapPatch { + h?: number // coarse units (will be multiplied by SCALE when applied) + w?: number // coarse units +} + +export function applyCoarsePatch(item: LayoutItem, patch: SnapPatch): LayoutItem { + return { + ...item, + w: patch.w ?? item.w, + h: patch.h !== undefined ? patch.h * SCALE : item.h + } +} + +export interface StrategyDescriptor { + // Constraints attached to LayoutItem.constraints. Applied by RGL inside + // applySizeConstraints during the resize pipeline. NOT applied at idle + // layout sync — use `normalizeRenderItem` for idle h. + constraints: LayoutConstraint[] + + // Whether the item participates in resize at all. + isResizable: boolean + + // Which resize handles to render. undefined = RGL default (all 8 corners/edges). + resizeHandles?: ResizeHandleAxis[] +} + +function lockHeight(fineH: number): LayoutConstraint { + return { + name: `lockHeight(${fineH})`, + constrainSize(_item, w) { + return { w, h: fineH } + } + } +} + +export interface SnapContext { + containerWidth: number +} + +export function snapOnRelease(item: LayoutItem, strategy: SizingStrategy, _ctx: SnapContext): SnapPatch { + switch (strategy.kind) { + case 'aspect-square': { + const tier = nearestTier(item.w, strategy.tiers) + return { w: tier, h: tier } + } + default: + return {} + } +} + +export function applyStrategy(strategy: SizingStrategy, measuredFineH?: number): StrategyDescriptor { + switch (strategy.kind) { + case 'fixed': + return { constraints: [], resizeHandles: [], isResizable: false } + case 'aspect-square': + return { constraints: [aspectRatio(1)], resizeHandles: ['se'], isResizable: true } + case 'content-height': + return { + constraints: measuredFineH !== undefined ? [lockHeight(measuredFineH)] : [], + resizeHandles: ['e'], + isResizable: true + } + default: + return { constraints: [], resizeHandles: undefined, isResizable: true } + } +} diff --git a/apps/web/src/components/dashboard/sizing-strategies/normalize.test.ts b/apps/web/src/components/dashboard/sizing-strategies/normalize.test.ts new file mode 100644 index 00000000..6c805a34 --- /dev/null +++ b/apps/web/src/components/dashboard/sizing-strategies/normalize.test.ts @@ -0,0 +1,100 @@ +import type { LayoutItem } from 'react-grid-layout' +import { describe, expect, it } from 'vitest' +import type { SizingStrategy } from '@/lib/widget-types' +import { normalizeRenderItem, pixelSquareFineH } from './normalize' + +const W_AT_1004 = 1004 // a representative container width + +describe('pixelSquareFineH', () => { + it('returns wCoarse * SCALE when containerWidth is non-positive', () => { + expect(pixelSquareFineH(2, 0)).toBe(8) + expect(pixelSquareFineH(3, -100)).toBe(12) + }) + + it('returns pixel-square fine h at typical container width', () => { + // At 1004px: colStepPx = (1004+16)/12 = 85; fineRowStepPx = 8+16 = 24 + // pixelSquareFineH(2) = round(2 * 85 / 24) = round(7.083) = 7 + expect(pixelSquareFineH(2, W_AT_1004)).toBe(7) + // pixelSquareFineH(3) = round(3 * 85 / 24) = round(10.625) = 11 + expect(pixelSquareFineH(3, W_AT_1004)).toBe(11) + // pixelSquareFineH(6) = round(6 * 85 / 24) = round(21.25) = 21 + expect(pixelSquareFineH(6, W_AT_1004)).toBe(21) + }) + + it('never returns less than 1', () => { + expect(pixelSquareFineH(0, W_AT_1004)).toBe(1) + }) +}) + +describe('normalizeRenderItem', () => { + const baseItem: LayoutItem = { + i: 'widget-1', + x: 0, + y: 0, + w: 2, + h: 8, // fine units + minW: 2, + minH: 8, + maxW: 6, + maxH: 24 + } + + it('returns aspect-square h derived from w', () => { + const strategy: SizingStrategy = { kind: 'aspect-square', tiers: [2, 3, 4, 5, 6] } + const result = normalizeRenderItem(baseItem, strategy, { containerWidth: W_AT_1004 }) + expect(result.h).toBe(7) // pixelSquareFineH(2, 1004) + expect(result.minH).toBe(7) // pixelSquareFineH(minW=2, 1004) + expect(result.maxH).toBe(21) // pixelSquareFineH(maxW=6, 1004) + }) + + it('aspect-square leaves w/x/y/minW/maxW unchanged', () => { + const strategy: SizingStrategy = { kind: 'aspect-square', tiers: [2, 3, 4, 5, 6] } + const result = normalizeRenderItem(baseItem, strategy, { containerWidth: W_AT_1004 }) + expect(result.w).toBe(2) + expect(result.x).toBe(0) + expect(result.minW).toBe(2) + expect(result.maxW).toBe(6) + }) + + it('content-height uses measured fine h when present', () => { + const strategy: SizingStrategy = { kind: 'content-height' } + const result = normalizeRenderItem(baseItem, strategy, { + containerWidth: W_AT_1004, + autoMeasuredFineH: 11 + }) + expect(result.h).toBe(11) + expect(result.minH).toBe(11) + expect(result.maxH).toBe(11) + }) + + it('content-height returns item unchanged when measurement is missing', () => { + const strategy: SizingStrategy = { kind: 'content-height' } + const result = normalizeRenderItem(baseItem, strategy, { containerWidth: W_AT_1004 }) + expect(result.h).toBe(8) // unchanged + expect(result.minH).toBe(8) + expect(result.maxH).toBe(24) + }) + + it('free returns item unchanged', () => { + const strategy: SizingStrategy = { kind: 'free' } + const result = normalizeRenderItem(baseItem, strategy, { containerWidth: W_AT_1004 }) + expect(result.h).toBe(8) + expect(result.minH).toBe(8) + expect(result.maxH).toBe(24) + }) + + it('fixed returns item unchanged', () => { + const strategy: SizingStrategy = { kind: 'fixed' } + const result = normalizeRenderItem(baseItem, strategy, { containerWidth: W_AT_1004 }) + expect(result.h).toBe(8) + expect(result.minH).toBe(8) + expect(result.maxH).toBe(24) + }) + + it('aspect-square handles undefined maxW gracefully', () => { + const strategy: SizingStrategy = { kind: 'aspect-square', tiers: [2, 3, 4, 5, 6] } + const itemNoMaxW: LayoutItem = { ...baseItem, maxW: undefined, maxH: undefined } + const result = normalizeRenderItem(itemNoMaxW, strategy, { containerWidth: W_AT_1004 }) + expect(result.maxH).toBeUndefined() + }) +}) diff --git a/apps/web/src/components/dashboard/sizing-strategies/normalize.ts b/apps/web/src/components/dashboard/sizing-strategies/normalize.ts new file mode 100644 index 00000000..e7c06514 --- /dev/null +++ b/apps/web/src/components/dashboard/sizing-strategies/normalize.ts @@ -0,0 +1,43 @@ +import type { LayoutItem } from 'react-grid-layout' +import type { SizingStrategy } from '@/lib/widget-types' +import { COLS, MARGIN_X, MARGIN_Y, ROW_HEIGHT, SCALE } from '../grid-constants' + +export interface NormalizeContext { + autoMeasuredFineH?: number // populated for content-height widgets via ResizeObserver + containerWidth: number +} + +// Same formula RGL's aspectRatio(1) uses internally (see +// react-grid-layout@2.2.2 internals). Replicated here so we can +// apply it at idle render time — RGL constraints only run during resize. +export function pixelSquareFineH(wCoarse: number, containerWidth: number): number { + if (containerWidth <= 0) { + return wCoarse * SCALE + } + const colStepPx = (containerWidth + MARGIN_X) / COLS + const fineRowStepPx = ROW_HEIGHT + MARGIN_Y + return Math.max(1, Math.round((wCoarse * colStepPx) / fineRowStepPx)) +} + +// Applies strategy-specific overrides to a fine-unit layout item. +// Operates entirely in fine units (caller has already done `h *= SCALE`). +// Returns a NEW item — never mutates the input. +export function normalizeRenderItem(item: LayoutItem, strategy: SizingStrategy, ctx: NormalizeContext): LayoutItem { + switch (strategy.kind) { + case 'aspect-square': { + const h = pixelSquareFineH(item.w, ctx.containerWidth) + const minH = pixelSquareFineH(item.minW ?? 2, ctx.containerWidth) + const maxH = item.maxW !== undefined ? pixelSquareFineH(item.maxW, ctx.containerWidth) : undefined + return { ...item, h, minH, maxH } + } + case 'content-height': { + const measured = ctx.autoMeasuredFineH + if (measured !== undefined) { + return { ...item, h: measured, minH: measured, maxH: measured } + } + return item + } + default: + return item + } +} diff --git a/apps/web/src/components/dashboard/widget-config-dialog.tsx b/apps/web/src/components/dashboard/widget-config-dialog.tsx index bad124ff..bc436f57 100644 --- a/apps/web/src/components/dashboard/widget-config-dialog.tsx +++ b/apps/web/src/components/dashboard/widget-config-dialog.tsx @@ -128,7 +128,9 @@ function ServerSelect({ value={value} > - + + {(v: string | null) => servers.find((s) => s.id === v)?.name ?? placeholder} + {servers.map((s) => ( @@ -160,7 +162,9 @@ function MetricSelect({