From 2673caab860e2a61f1172ff55248bda3a489de99 Mon Sep 17 00:00:00 2001 From: radiantjade Date: Fri, 15 May 2026 13:31:16 +0900 Subject: [PATCH] Cap rendered graphics after filters The objectLimit prop was already present, but it limited each object bucket separately and calculated the warning from the already-limited count. This makes the cap apply once across the rendered object groups after layer, step, and viewport filtering while preserving the pre-limit count for the red warning. Constraint: Issue #42 requires layer/step filters to run before counting objects toward the display limit. Rejected: Keep per-type slicing | it can still render more than objectLimit total objects. Confidence: high Scope-risk: narrow Tested: bun run format Tested: bun test Tested: bun run build --- .../InteractiveGraphics.tsx | 119 ++++++++++++------ .../InteractiveGraphics/object-limit.ts | 26 ++++ tests/interactive-object-limit.test.ts | 41 ++++++ 3 files changed, 147 insertions(+), 39 deletions(-) create mode 100644 site/components/InteractiveGraphics/object-limit.ts create mode 100644 tests/interactive-object-limit.test.ts diff --git a/site/components/InteractiveGraphics/InteractiveGraphics.tsx b/site/components/InteractiveGraphics/InteractiveGraphics.tsx index de014e1..8fe18c2 100644 --- a/site/components/InteractiveGraphics/InteractiveGraphics.tsx +++ b/site/components/InteractiveGraphics/InteractiveGraphics.tsx @@ -38,6 +38,7 @@ import { useFilterTexts, useIsPointOnScreen, } from "./hooks" +import { normalizeObjectLimit, takeObjectLimit } from "./object-limit" import { tooltipLayerZIndex } from "./tooltipLayer" export type GraphicsObjectClickEvent = { @@ -421,65 +422,105 @@ export const InteractiveGraphics = ({ filterLayerAndStep, }) - const filterAndLimit = ( + const filterObjects = ( objects: T[] | undefined, filterFn: (obj: T) => boolean, ): (T & { originalIndex: number })[] => { if (!objects) return [] - const filtered = objects + return objects .map((obj, index) => ({ ...obj, originalIndex: index })) .filter(filterFn) - return objectLimit ? filtered.slice(-objectLimit) : filtered } - const filteredLines = useMemo( + const filteredLinesBeforeLimit = useMemo( () => - filterAndLimit(graphics.lines, filterLines).sort( + filterObjects(graphics.lines, filterLines).sort( (a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0) || a.originalIndex - b.originalIndex, ), - [graphics.lines, filterLines, objectLimit], + [graphics.lines, filterLines], ) - const filteredInfiniteLines = useMemo( - () => filterAndLimit(graphics.infiniteLines, filterLayerAndStep), - [graphics.infiniteLines, filterLayerAndStep, objectLimit], + const filteredInfiniteLinesBeforeLimit = useMemo( + () => filterObjects(graphics.infiniteLines, filterLayerAndStep), + [graphics.infiniteLines, filterLayerAndStep], ) - const filteredRects = useMemo( - () => sortRectsByArea(filterAndLimit(graphics.rects, filterRects)), - [graphics.rects, filterRects, objectLimit], + const filteredRectsBeforeLimit = useMemo( + () => sortRectsByArea(filterObjects(graphics.rects, filterRects)), + [graphics.rects, filterRects], ) - const filteredPolygons = useMemo( - () => filterAndLimit(graphics.polygons, filterPolygons), - [graphics.polygons, filterPolygons, objectLimit], + const filteredPolygonsBeforeLimit = useMemo( + () => filterObjects(graphics.polygons, filterPolygons), + [graphics.polygons, filterPolygons], ) - const filteredPoints = useMemo( - () => filterAndLimit(graphics.points, filterPoints), - [graphics.points, filterPoints, objectLimit], + const filteredPointsBeforeLimit = useMemo( + () => filterObjects(graphics.points, filterPoints), + [graphics.points, filterPoints], ) - const filteredCircles = useMemo( - () => filterAndLimit(graphics.circles, filterCircles), - [graphics.circles, filterCircles, objectLimit], + const filteredCirclesBeforeLimit = useMemo( + () => filterObjects(graphics.circles, filterCircles), + [graphics.circles, filterCircles], ) - const filteredTexts = useMemo( - () => filterAndLimit(graphics.texts, filterTexts), - [graphics.texts, filterTexts, objectLimit], + const filteredTextsBeforeLimit = useMemo( + () => filterObjects(graphics.texts, filterTexts), + [graphics.texts, filterTexts], ) - const filteredArrows = useMemo( - () => filterAndLimit(graphics.arrows, filterArrows), - [graphics.arrows, filterArrows, objectLimit], + const filteredArrowsBeforeLimit = useMemo( + () => filterObjects(graphics.arrows, filterArrows), + [graphics.arrows, filterArrows], ) - const totalFilteredObjects = - filteredInfiniteLines.length + - filteredLines.length + - filteredRects.length + - filteredPolygons.length + - filteredPoints.length + - filteredCircles.length + - filteredTexts.length + - filteredArrows.length - const isLimitReached = objectLimit && totalFilteredObjects > objectLimit + const normalizedObjectLimit = normalizeObjectLimit(objectLimit) + + const [ + filteredArrows, + filteredInfiniteLines, + filteredLines, + filteredRects, + filteredPolygons, + filteredCircles, + filteredTexts, + filteredPoints, + ] = useMemo( + () => + takeObjectLimit( + [ + filteredArrowsBeforeLimit, + filteredInfiniteLinesBeforeLimit, + filteredLinesBeforeLimit, + filteredRectsBeforeLimit, + filteredPolygonsBeforeLimit, + filteredCirclesBeforeLimit, + filteredTextsBeforeLimit, + filteredPointsBeforeLimit, + ] as const, + normalizedObjectLimit, + ), + [ + filteredArrowsBeforeLimit, + filteredInfiniteLinesBeforeLimit, + filteredLinesBeforeLimit, + filteredRectsBeforeLimit, + filteredPolygonsBeforeLimit, + filteredCirclesBeforeLimit, + filteredTextsBeforeLimit, + filteredPointsBeforeLimit, + normalizedObjectLimit, + ], + ) + + const totalFilteredObjectsBeforeLimit = + filteredInfiniteLinesBeforeLimit.length + + filteredLinesBeforeLimit.length + + filteredRectsBeforeLimit.length + + filteredPolygonsBeforeLimit.length + + filteredPointsBeforeLimit.length + + filteredCirclesBeforeLimit.length + + filteredTextsBeforeLimit.length + + filteredArrowsBeforeLimit.length + const isLimitReached = + normalizedObjectLimit !== null && + totalFilteredObjectsBeforeLimit > normalizedObjectLimit return (
@@ -554,8 +595,8 @@ export const InteractiveGraphics = ({ {isLimitReached && ( - Display limited to {objectLimit} objects. Received:{" "} - {totalFilteredObjects}. + Display limited to {normalizedObjectLimit} objects. Received:{" "} + {totalFilteredObjectsBeforeLimit}. )}
diff --git a/site/components/InteractiveGraphics/object-limit.ts b/site/components/InteractiveGraphics/object-limit.ts new file mode 100644 index 0000000..6ddd907 --- /dev/null +++ b/site/components/InteractiveGraphics/object-limit.ts @@ -0,0 +1,26 @@ +export const normalizeObjectLimit = (objectLimit: number | undefined) => { + if (objectLimit === undefined || !Number.isFinite(objectLimit)) return null + return Math.max(0, Math.floor(objectLimit)) +} + +export const takeObjectLimit = ( + objectGroups: T, + objectLimit: number | null, +): { [K in keyof T]: T[K][number][] } => { + if (objectLimit === null) { + return objectGroups.map((group) => [...group]) as { + [K in keyof T]: T[K][number][] + } + } + + let remainingObjects = objectLimit + + return objectGroups.map((group) => { + if (remainingObjects <= 0) return [] + + const limitedGroup = group.slice(0, remainingObjects) + remainingObjects -= limitedGroup.length + + return limitedGroup + }) as { [K in keyof T]: T[K][number][] } +} diff --git a/tests/interactive-object-limit.test.ts b/tests/interactive-object-limit.test.ts new file mode 100644 index 0000000..1f0cf68 --- /dev/null +++ b/tests/interactive-object-limit.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" +import { + normalizeObjectLimit, + takeObjectLimit, +} from "site/components/InteractiveGraphics/object-limit" + +describe("InteractiveGraphics objectLimit", () => { + test("applies one object budget across render groups", () => { + const [arrows, lines, rects] = takeObjectLimit( + [["arrow-1", "arrow-2"], ["line-1", "line-2"], ["rect-1"]] as const, + 3, + ) + + expect(arrows).toEqual(["arrow-1", "arrow-2"]) + expect(lines).toEqual(["line-1"]) + expect(rects).toEqual([]) + }) + + test("counts zero as a real limit", () => { + const [points] = takeObjectLimit([["point-1"]] as const, 0) + + expect(points).toEqual([]) + }) + + test("leaves all groups unchanged when no limit is provided", () => { + const [points, circles] = takeObjectLimit( + [["point-1"], ["circle-1"]] as const, + null, + ) + + expect(points).toEqual(["point-1"]) + expect(circles).toEqual(["circle-1"]) + }) + + test("normalizes fractional and invalid limits", () => { + expect(normalizeObjectLimit(2.9)).toBe(2) + expect(normalizeObjectLimit(-1)).toBe(0) + expect(normalizeObjectLimit(Number.NaN)).toBe(null) + expect(normalizeObjectLimit(undefined)).toBe(null) + }) +})