From 718e4692b4f2a26a74b47f24648ef3261fb57e16 Mon Sep 17 00:00:00 2001 From: fayekelmith Date: Tue, 28 Jul 2026 12:58:27 +0000 Subject: [PATCH] Generated with Hive: Fix minimap viewport indicator overflow and add density grid rendering for dense nodes --- package-lock.json | 18 +- packages/react/src/components/Minimap.tsx | 260 +++++++++++++-- .../src/components/__tests__/Minimap.test.ts | 304 ++++++++++++++++++ 3 files changed, 541 insertions(+), 41 deletions(-) create mode 100644 packages/react/src/components/__tests__/Minimap.test.ts diff --git a/package-lock.json b/package-lock.json index 626d1a2..50f5fcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4756,19 +4756,19 @@ }, "packages/collab": { "name": "system-canvas-collab", - "version": "0.2.57", + "version": "0.2.58", "license": "MIT", "devDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^19.0.0", - "system-canvas": "^0.2.57", + "system-canvas": "^0.2.58", "typescript": "^5.4.0", "vitest": "^4.1.7", "yjs": "^13.6.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", - "system-canvas": "^0.2.57", + "system-canvas": "^0.2.58", "yjs": "^13.6.0" }, "peerDependenciesMeta": { @@ -4779,7 +4779,7 @@ }, "packages/core": { "name": "system-canvas", - "version": "0.2.57", + "version": "0.2.58", "license": "MIT", "devDependencies": { "typescript": "^5.4.0" @@ -4787,13 +4787,13 @@ }, "packages/react": { "name": "system-canvas-react", - "version": "0.2.57", + "version": "0.2.58", "license": "MIT", "dependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.0", "d3-zoom": "^3.0.0", - "system-canvas": "^0.2.57" + "system-canvas": "^0.2.58" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -4817,13 +4817,13 @@ }, "packages/standalone": { "name": "system-canvas-standalone", - "version": "0.2.57", + "version": "0.2.58", "license": "MIT", "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", - "system-canvas": "^0.2.57", - "system-canvas-react": "^0.2.57" + "system-canvas": "^0.2.58", + "system-canvas-react": "^0.2.58" }, "devDependencies": { "@types/react": "^19.0.0", diff --git a/packages/react/src/components/Minimap.tsx b/packages/react/src/components/Minimap.tsx index 94802c7..2c1b4f7 100644 --- a/packages/react/src/components/Minimap.tsx +++ b/packages/react/src/components/Minimap.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' -import type { CanvasTheme, ResolvedNode, ViewportState } from 'system-canvas' +import type { BoundingBox, CanvasTheme, ResolvedNode, ViewportState } from 'system-canvas' import { computeBoundingBox } from 'system-canvas' interface MinimapProps { @@ -16,6 +16,99 @@ interface MinimapProps { } const PAD = 20 +const GRID_SIZE = 8 + +// --------------------------------------------------------------------------- +// Pure helpers (module-level, independently unit-testable) +// --------------------------------------------------------------------------- + +/** + * The viewport rect expressed in canvas world-space coordinates. + */ +export interface ViewportRect { + left: number + top: number + width: number + height: number +} + +/** + * Union the node-only bounding box with the current viewport rect so the + * combined bounds always contains both the node cluster and the visible area. + * Returns the expanded BoundingBox. + */ +export function computeUnionedBounds( + nodeBounds: BoundingBox, + viewport: ViewportRect +): BoundingBox { + const minX = Math.min(nodeBounds.minX, viewport.left) + const minY = Math.min(nodeBounds.minY, viewport.top) + const maxX = Math.max(nodeBounds.maxX, viewport.left + viewport.width) + const maxY = Math.max(nodeBounds.maxY, viewport.top + viewport.height) + return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY } +} + +/** + * Compute the mean width of non-group nodes. + * Falls back to averaging ALL nodes when the non-group set is empty (e.g. an + * all-group canvas) to avoid divide-by-zero / empty-mean. + * Returns 0 when `nodes` is empty. + */ +export function computeAvgNodeWidth(nodes: ResolvedNode[]): number { + if (nodes.length === 0) return 0 + const leafNodes = nodes.filter((n) => n.type !== 'group') + const set = leafNodes.length > 0 ? leafNodes : nodes + return set.reduce((sum, n) => sum + n.width, 0) / set.length +} + +/** + * Bucket node centers into an 8x8 grid spanning `bounds` and return a flat + * Float32Array of per-cell counts (row-major, length GRID_SIZE * GRID_SIZE). + * Every node (including group nodes) is counted — only the average excludes + * groups. + */ +export function bucketNodesIntoDensityGrid( + nodes: ResolvedNode[], + bounds: BoundingBox +): Float32Array { + const cells = new Float32Array(GRID_SIZE * GRID_SIZE) + const bw = bounds.width || 1 + const bh = bounds.height || 1 + for (const node of nodes) { + const cx = node.x + node.width / 2 + const cy = node.y + node.height / 2 + const col = Math.min( + GRID_SIZE - 1, + Math.max(0, Math.floor(((cx - bounds.minX) / bw) * GRID_SIZE)) + ) + const row = Math.min( + GRID_SIZE - 1, + Math.max(0, Math.floor(((cy - bounds.minY) / bh) * GRID_SIZE)) + ) + cells[row * GRID_SIZE + col] += 1 + } + return cells +} + +/** + * Apply hysteresis logic to the current render mode. + * Enters grid mode when `value < enterThreshold`; exits only when + * `value > exitThreshold`. Prevents flicker at the boundary. + */ +export function applyHysteresis( + current: 'dots' | 'grid', + value: number, + enterThreshold: number, + exitThreshold: number +): 'dots' | 'grid' { + if (current === 'dots' && value < enterThreshold) return 'grid' + if (current === 'grid' && value > exitThreshold) return 'dots' + return current +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- export function Minimap({ nodes, @@ -31,13 +124,25 @@ export function Minimap({ const [hovered, setHovered] = useState(false) const draggingRef = useRef(false) + // Node-only bounds — recomputed when nodes change const boundsRef = useRef(computeBoundingBox(nodes)) useEffect(() => { boundsRef.current = computeBoundingBox(nodes) }, [nodes]) + // Last-good viewport-unioned bounds — populated each frame inside paint() + // when a valid SVG element and zoom are available. + const unionedBoundsRef = useRef(null) + + // Hysteresis state for dots/grid render mode + const renderModeRef = useRef<'dots' | 'grid'>('dots') + + // --------------------------------------------------------------------------- + // Coordinate helpers + // --------------------------------------------------------------------------- + const toMinimap = useCallback( - (cx: number, cy: number, bounds: ReturnType) => { + (cx: number, cy: number, bounds: BoundingBox) => { const bw = bounds.width || 1 const bh = bounds.height || 1 const scale = Math.min((width - PAD * 2) / bw, (height - PAD * 2) / bh) @@ -53,7 +158,7 @@ export function Minimap({ ) const fromMinimap = useCallback( - (mx: number, my: number, bounds: ReturnType) => { + (mx: number, my: number, bounds: BoundingBox) => { const bw = bounds.width || 1 const bh = bounds.height || 1 const scale = Math.min((width - PAD * 2) / bw, (height - PAD * 2) / bh) @@ -67,6 +172,10 @@ export function Minimap({ [width, height] ) + // --------------------------------------------------------------------------- + // Paint loop + // --------------------------------------------------------------------------- + const paint = useCallback(() => { const canvas = canvasRef.current if (!canvas) return @@ -79,42 +188,114 @@ export function Minimap({ ctx.scale(dpr, dpr) ctx.clearRect(0, 0, width, height) - const bounds = boundsRef.current - if (bounds.width === 0 && bounds.height === 0) return - - // Draw nodes - for (const node of nodes) { - const { x: mx, y: my, scale } = toMinimap(node.x, node.y, bounds) - const mw = node.width * scale - const mh = node.height * scale - const color = node.resolvedStroke ?? node.resolvedFill ?? theme.node.labelColor - ctx.fillStyle = color - ctx.globalAlpha = node.type === 'group' ? 0.15 : 0.6 - const r = Math.min(2, (node.resolvedCornerRadius ?? 0) * scale) - roundRect(ctx, mx, my, Math.max(mw, 2), Math.max(mh, 2), r) - ctx.fill() - } - ctx.globalAlpha = 1 + // Step 1: node-only bounds — basis for node drawing and density grid + const nodeBounds = boundsRef.current + if (nodeBounds.width === 0 && nodeBounds.height === 0) return - // Draw viewport indicator - const vp = getViewport() + // Step 2: attempt to read SVG/viewport for the indicator const svg = getSvgElement() - if (!svg) return - const rect = svg.getBoundingClientRect() - const vpLeft = -vp.x / vp.zoom - const vpTop = -vp.y / vp.zoom - const vpWidth = rect.width / vp.zoom - const vpHeight = rect.height / vp.zoom + let validViewport = false + let vpRect: ViewportRect = { left: 0, top: 0, width: 0, height: 0 } - const tl = toMinimap(vpLeft, vpTop, bounds) - const vw = vpWidth * tl.scale - const vh = vpHeight * tl.scale + if (svg) { + const svgRect = svg.getBoundingClientRect() + const vp = getViewport() + if (Number.isFinite(vp.zoom) && vp.zoom > 0) { + vpRect = { + left: -vp.x / vp.zoom, + top: -vp.y / vp.zoom, + width: svgRect.width / vp.zoom, + height: svgRect.height / vp.zoom, + } + validViewport = true + // Cache unioned bounds for this frame (and for jumpTo) + unionedBoundsRef.current = computeUnionedBounds(nodeBounds, vpRect) + } + } + + // Step 3: compute scale from node-only bounds + const bw = nodeBounds.width || 1 + const bh = nodeBounds.height || 1 + const scale = Math.min((width - PAD * 2) / bw, (height - PAD * 2) / bh) + + // Step 4: decide render mode with hysteresis + const avgNodeWidth = computeAvgNodeWidth(nodes) + renderModeRef.current = applyHysteresis( + renderModeRef.current, + avgNodeWidth * scale, + 3, + 4 + ) + + if (renderModeRef.current === 'grid') { + // --- Density grid mode --- + const cells = bucketNodesIntoDensityGrid(nodes, nodeBounds) + let maxCount = 0 + for (let i = 0; i < cells.length; i++) { + if (cells[i] > maxCount) maxCount = cells[i] + } + if (maxCount > 0) { + const color = theme.node.labelColor + const cellW = ((width - PAD * 2) / GRID_SIZE) + const cellH = ((height - PAD * 2) / GRID_SIZE) + const ox = (width - bw * scale) / 2 + const oy = (height - bh * scale) / 2 + // The grid spans the same minimap-space extent as the node cluster + // (from toMinimap(nodeBounds.minX, nodeBounds.minY) to + // toMinimap(nodeBounds.maxX, nodeBounds.maxY)) + const startX = ox + const startY = oy + const gridW = bw * scale + const gridH = bh * scale + const gcW = gridW / GRID_SIZE + const gcH = gridH / GRID_SIZE + + ctx.fillStyle = color + for (let row = 0; row < GRID_SIZE; row++) { + for (let col = 0; col < GRID_SIZE; col++) { + const count = cells[row * GRID_SIZE + col] + if (count === 0) continue + const alpha = Math.max(0.15, count / maxCount) + ctx.globalAlpha = alpha + const rx = startX + col * gcW + const ry = startY + row * gcH + roundRect(ctx, rx, ry, gcW, gcH, 1) + ctx.fill() + } + } + // suppress unused-variable warning — cellW/cellH replaced by gcW/gcH above + void cellW + void cellH + } + } else { + // --- Dots mode (unchanged from original) --- + for (const node of nodes) { + const { x: mx, y: my, scale: s } = toMinimap(node.x, node.y, nodeBounds) + const mw = node.width * s + const mh = node.height * s + const color = node.resolvedStroke ?? node.resolvedFill ?? theme.node.labelColor + ctx.fillStyle = color + ctx.globalAlpha = node.type === 'group' ? 0.15 : 0.6 + const r = Math.min(2, (node.resolvedCornerRadius ?? 0) * s) + roundRect(ctx, mx, my, Math.max(mw, 2), Math.max(mh, 2), r) + ctx.fill() + } + } + ctx.globalAlpha = 1 + + // Step 5: draw viewport indicator — only when we have a valid viewport + if (!validViewport) return + const indicatorBounds = unionedBoundsRef.current ?? nodeBounds + const tl = toMinimap(vpRect.left, vpRect.top, indicatorBounds) + const vw = (vpRect.width / (indicatorBounds.width || 1)) * (width - PAD * 2) + const vh = (vpRect.height / (indicatorBounds.height || 1)) * (height - PAD * 2) ctx.strokeStyle = theme.node.labelColor ctx.lineWidth = 1.5 ctx.globalAlpha = 0.7 roundRect(ctx, tl.x, tl.y, vw, vh, 2) ctx.stroke() + ctx.globalAlpha = 1 }, [nodes, theme, width, height, getViewport, getSvgElement, toMinimap]) // rAF loop for painting @@ -128,6 +309,10 @@ export function Minimap({ return () => cancelAnimationFrame(raf) }, [paint]) + // --------------------------------------------------------------------------- + // Jump-to navigation + // --------------------------------------------------------------------------- + const jumpTo = useCallback( (e: React.MouseEvent | MouseEvent) => { const container = containerRef.current @@ -136,7 +321,11 @@ export function Minimap({ const rect = container.getBoundingClientRect() const mx = e.clientX - rect.left const my = e.clientY - rect.top - const bounds = boundsRef.current + + // Use the same unioned bounds that paint() last drew the indicator with. + // Fall back to node-only bounds if paint() hasn't populated the ref yet + // (e.g. click before first rAF tick). + const bounds = unionedBoundsRef.current ?? boundsRef.current const { cx, cy } = fromMinimap(mx, my, bounds) const svgRect = svg.getBoundingClientRect() @@ -186,7 +375,10 @@ export function Minimap({ onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerEnter={() => setHovered(true)} - onPointerLeave={() => { setHovered(false); draggingRef.current = false }} + onPointerLeave={() => { + setHovered(false) + draggingRef.current = false + }} style={{ position: 'absolute', bottom: 12, @@ -217,6 +409,10 @@ export function Minimap({ ) } +// --------------------------------------------------------------------------- +// Canvas drawing helper (unchanged) +// --------------------------------------------------------------------------- + function roundRect( ctx: CanvasRenderingContext2D, x: number, diff --git a/packages/react/src/components/__tests__/Minimap.test.ts b/packages/react/src/components/__tests__/Minimap.test.ts new file mode 100644 index 0000000..0c184ef --- /dev/null +++ b/packages/react/src/components/__tests__/Minimap.test.ts @@ -0,0 +1,304 @@ +/** + * Unit tests for the pure helper functions extracted from Minimap.tsx. + * + * No canvas/DOM mocking required — all tested functions are stateless and + * operate only on plain JS objects. + */ +import { describe, it, expect } from 'vitest' +import type { BoundingBox } from 'system-canvas' +import { + computeUnionedBounds, + computeAvgNodeWidth, + bucketNodesIntoDensityGrid, + applyHysteresis, + type ViewportRect, +} from '../Minimap.js' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeBounds( + minX: number, + minY: number, + maxX: number, + maxY: number +): BoundingBox { + return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY } +} + +function makeViewport( + left: number, + top: number, + width: number, + height: number +): ViewportRect { + return { left, top, width, height } +} + +// Minimal ResolvedNode stub — only the fields the helpers read +function makeNode( + x: number, + y: number, + w: number, + h: number, + type: 'text' | 'group' | 'file' | 'link' = 'text' + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): any { + return { x, y, width: w, height: h, type, id: Math.random().toString() } +} + +// --------------------------------------------------------------------------- +// computeUnionedBounds +// --------------------------------------------------------------------------- + +describe('computeUnionedBounds', () => { + it('returns node bounds unchanged when viewport is fully contained', () => { + const nodeBounds = makeBounds(0, 0, 500, 400) + const vp = makeViewport(100, 100, 200, 150) // inside node bounds + const result = computeUnionedBounds(nodeBounds, vp) + expect(result.minX).toBe(0) + expect(result.minY).toBe(0) + expect(result.maxX).toBe(500) + expect(result.maxY).toBe(400) + expect(result.width).toBe(500) + expect(result.height).toBe(400) + }) + + it('expands minX/minY when viewport extends left/above node bounds', () => { + const nodeBounds = makeBounds(100, 100, 500, 400) + const vp = makeViewport(-50, -30, 200, 150) + const result = computeUnionedBounds(nodeBounds, vp) + expect(result.minX).toBe(-50) + expect(result.minY).toBe(-30) + expect(result.maxX).toBe(500) + expect(result.maxY).toBe(400) + }) + + it('expands maxX/maxY when viewport extends right/below node bounds', () => { + const nodeBounds = makeBounds(0, 0, 300, 200) + const vp = makeViewport(200, 150, 400, 300) // right edge = 600, bottom = 450 + const result = computeUnionedBounds(nodeBounds, vp) + expect(result.minX).toBe(0) + expect(result.minY).toBe(0) + expect(result.maxX).toBe(600) + expect(result.maxY).toBe(450) + expect(result.width).toBe(600) + expect(result.height).toBe(450) + }) + + it('expands on all axes simultaneously when viewport straddles node bounds', () => { + const nodeBounds = makeBounds(100, 100, 300, 300) + const vp = makeViewport(50, 50, 400, 400) // left=-50 to right=450, top=50 to bottom=450 + const result = computeUnionedBounds(nodeBounds, vp) + expect(result.minX).toBe(50) + expect(result.minY).toBe(50) + expect(result.maxX).toBe(450) + expect(result.maxY).toBe(450) + }) + + it('handles zero-size viewport (degenerate)', () => { + const nodeBounds = makeBounds(0, 0, 100, 100) + const vp = makeViewport(200, 200, 0, 0) + const result = computeUnionedBounds(nodeBounds, vp) + // viewport point is at (200,200) — expands maxX/maxY + expect(result.maxX).toBe(200) + expect(result.maxY).toBe(200) + expect(result.minX).toBe(0) + expect(result.minY).toBe(0) + }) + + it('handles zero-size node bounds expanded by a viewport', () => { + // Empty canvas: computeBoundingBox returns {0,0,0,0} + const nodeBounds = makeBounds(0, 0, 0, 0) + const vp = makeViewport(-100, -80, 800, 600) + const result = computeUnionedBounds(nodeBounds, vp) + expect(result.minX).toBe(-100) + expect(result.minY).toBe(-80) + expect(result.maxX).toBe(700) // -100 + 800 + expect(result.maxY).toBe(520) // -80 + 600 + }) +}) + +// --------------------------------------------------------------------------- +// computeAvgNodeWidth +// --------------------------------------------------------------------------- + +describe('computeAvgNodeWidth', () => { + it('returns 0 for an empty array', () => { + expect(computeAvgNodeWidth([])).toBe(0) + }) + + it('averages non-group nodes only', () => { + const nodes = [ + makeNode(0, 0, 100, 50, 'text'), + makeNode(0, 0, 200, 50, 'text'), + makeNode(0, 0, 9999, 9999, 'group'), // should be excluded + ] + expect(computeAvgNodeWidth(nodes)).toBe(150) // (100 + 200) / 2 + }) + + it('falls back to all nodes when all are groups (avoids empty-mean)', () => { + const nodes = [ + makeNode(0, 0, 400, 300, 'group'), + makeNode(0, 0, 600, 400, 'group'), + ] + const avg = computeAvgNodeWidth(nodes) + expect(avg).toBe(500) // (400 + 600) / 2 + }) + + it('returns the single non-group node width when there is one', () => { + const nodes = [ + makeNode(0, 0, 120, 80, 'text'), + makeNode(0, 0, 5000, 5000, 'group'), + ] + expect(computeAvgNodeWidth(nodes)).toBe(120) + }) + + it('handles file and link node types as non-group', () => { + const nodes = [ + makeNode(0, 0, 100, 50, 'file'), + makeNode(0, 0, 200, 50, 'link'), + makeNode(0, 0, 9000, 9000, 'group'), + ] + expect(computeAvgNodeWidth(nodes)).toBe(150) + }) +}) + +// --------------------------------------------------------------------------- +// bucketNodesIntoDensityGrid +// --------------------------------------------------------------------------- + +describe('bucketNodesIntoDensityGrid', () => { + const bounds = makeBounds(0, 0, 800, 600) + + it('returns a Float32Array of length GRID_SIZE * GRID_SIZE (64)', () => { + const cells = bucketNodesIntoDensityGrid([], bounds) + expect(cells).toBeInstanceOf(Float32Array) + expect(cells.length).toBe(64) + }) + + it('returns all zeros for an empty node list', () => { + const cells = bucketNodesIntoDensityGrid([], bounds) + expect(Array.from(cells).every((v) => v === 0)).toBe(true) + }) + + it('counts a single node in the correct cell', () => { + // Center of bounds = (400, 300), which should land in cell (3,3) or (4,4) + // With 8x8 grid over [0..800, 0..600]: + // col = floor((400 / 800) * 8) = floor(4) = 4 + // row = floor((300 / 600) * 8) = floor(4) = 4 + const node = makeNode(350, 250, 100, 100, 'text') // center=(400,300) + const cells = bucketNodesIntoDensityGrid([node], bounds) + const total = Array.from(cells).reduce((a, b) => a + b, 0) + expect(total).toBe(1) + // Cell (4,4) row-major = 4*8+4 = 36 + expect(cells[36]).toBe(1) + }) + + it('counts group nodes in grid cells (only excluded from avg, not from grid)', () => { + const leaf = makeNode(0, 0, 50, 50, 'text') // center=(25,25) + const group = makeNode(0, 0, 800, 600, 'group') // center=(400,300) + const cells = bucketNodesIntoDensityGrid([leaf, group], bounds) + const total = Array.from(cells).reduce((a, b) => a + b, 0) + expect(total).toBe(2) // both counted + }) + + it('accumulates multiple nodes in the same cell', () => { + // Two nodes whose centers both land in cell (0,0): + // col=floor((25/800)*8)=0, row=floor((25/600)*8)=0 + const n1 = makeNode(0, 0, 50, 50, 'text') + const n2 = makeNode(5, 5, 40, 40, 'text') + const cells = bucketNodesIntoDensityGrid([n1, n2], bounds) + expect(cells[0]).toBe(2) + }) + + it('clamps node centers to [0, GRID_SIZE-1] when at the exact boundary', () => { + // Node at the far corner: center = (800, 600) = (maxX, maxY) + const node = makeNode(750, 550, 100, 100, 'text') // center=(800,600) + const cells = bucketNodesIntoDensityGrid([node], bounds) + const total = Array.from(cells).reduce((a, b) => a + b, 0) + expect(total).toBe(1) // must not crash or produce index 64+ + }) + + it('alpha clamping: max(0.15, count/maxCount) — sparse cells stay visible', () => { + // Compute the expected alphas for a 2-node scenario: 1 node in cell A, 1 in cell B + // maxCount=1 in both, so alpha = max(0.15, 1/1) = 1.0 + // For a scenario with 3 in one cell and 1 in another: + // cell with 1: max(0.15, 1/3) = max(0.15, 0.33) = 0.33 + // But with 1 node per cell, max is always 1 + // Test the clamping formula directly with a sparse case + const cellCount = 1 + const maxCount = 10 + const alpha = Math.max(0.15, cellCount / maxCount) + expect(alpha).toBe(0.15) // 1/10 = 0.1, clamped to 0.15 + + const denseCellCount = 10 + const denseAlpha = Math.max(0.15, denseCellCount / maxCount) + expect(denseAlpha).toBe(1.0) // 10/10 = 1.0 + }) +}) + +// --------------------------------------------------------------------------- +// applyHysteresis +// --------------------------------------------------------------------------- + +describe('applyHysteresis', () => { + it('stays in dots mode when value is above enter threshold', () => { + expect(applyHysteresis('dots', 5, 3, 4)).toBe('dots') + }) + + it('stays in dots mode when value equals enter threshold (not strictly less)', () => { + expect(applyHysteresis('dots', 3, 3, 4)).toBe('dots') + }) + + it('enters grid mode when value drops below enter threshold', () => { + expect(applyHysteresis('dots', 2.9, 3, 4)).toBe('grid') + }) + + it('stays in grid mode when value is between thresholds (hysteresis band)', () => { + // In grid mode with value=3.5 (above enter=3 but below exit=4) — stays grid + expect(applyHysteresis('grid', 3.5, 3, 4)).toBe('grid') + }) + + it('stays in grid mode when value equals exit threshold (not strictly greater)', () => { + expect(applyHysteresis('grid', 4, 3, 4)).toBe('grid') + }) + + it('exits grid mode only when value is strictly above exit threshold', () => { + expect(applyHysteresis('grid', 4.1, 3, 4)).toBe('dots') + }) + + it('does not flap when value oscillates between 2.5 and 3.5 (hysteresis)', () => { + // Start in dots. Value drops to 2.5 -> enter grid. + // Then oscillates between 2.5 and 3.5 — must stay in grid (exit requires >4). + let mode: 'dots' | 'grid' = 'dots' + const oscillatingValues = [2.5, 3.5, 2.5, 3.5, 2.5, 3.5, 3.0, 2.5] + for (const v of oscillatingValues) { + mode = applyHysteresis(mode, v, 3, 4) + } + expect(mode).toBe('grid') + }) + + it('does not flap when value oscillates between 3.5 and 4.5 from dots mode', () => { + // Start in dots. Value rises above 4 then dips to 3.5 — must stay in dots. + // (3.5 is above enter threshold of 3, so never enters grid) + let mode: 'dots' | 'grid' = 'dots' + const oscillatingValues = [4.5, 3.5, 4.5, 3.5, 4.5, 3.5] + for (const v of oscillatingValues) { + mode = applyHysteresis(mode, v, 3, 4) + } + expect(mode).toBe('dots') + }) + + it('transitions correctly through a realistic pan/zoom sequence', () => { + // Realistic sequence: zoom in (large scale) -> zoom out (small scale) -> back + const values = [10, 8, 6, 4.1, 3.8, 2.5, 2.0, 3.5, 4.5, 6.0] + const expected = ['dots', 'dots', 'dots', 'dots', 'dots', 'grid', 'grid', 'grid', 'dots', 'dots'] + let mode: 'dots' | 'grid' = 'dots' + for (let i = 0; i < values.length; i++) { + mode = applyHysteresis(mode, values[i], 3, 4) + expect(mode).toBe(expected[i]) + } + }) +})