From 22797c244f21294ea9f55db57f07813ef462738f Mon Sep 17 00:00:00 2001 From: mercanet Date: Sat, 16 May 2026 00:11:53 -0500 Subject: [PATCH] Fix InteractiveGraphics object limit --- .../InteractiveGraphics.tsx | 103 +++++++++++------- .../InteractiveGraphics/apply-object-limit.ts | 52 +++++++++ tests/apply-object-limit.test.ts | 50 +++++++++ 3 files changed, 168 insertions(+), 37 deletions(-) create mode 100644 site/components/InteractiveGraphics/apply-object-limit.ts create mode 100644 tests/apply-object-limit.test.ts diff --git a/site/components/InteractiveGraphics/InteractiveGraphics.tsx b/site/components/InteractiveGraphics/InteractiveGraphics.tsx index de014e1..0479ff8 100644 --- a/site/components/InteractiveGraphics/InteractiveGraphics.tsx +++ b/site/components/InteractiveGraphics/InteractiveGraphics.tsx @@ -27,6 +27,7 @@ import { Polygon } from "./Polygon" import { Rect } from "./Rect" import { Text } from "./Text" import { Tooltip } from "./Tooltip" +import { applyObjectLimit } from "./apply-object-limit" import { useDoesLineIntersectViewport, useFilterArrows, @@ -421,65 +422,93 @@ 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 allFilteredLines = 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 allFilteredInfiniteLines = useMemo( + () => filterObjects(graphics.infiniteLines, filterLayerAndStep), + [graphics.infiniteLines, filterLayerAndStep], ) - const filteredRects = useMemo( - () => sortRectsByArea(filterAndLimit(graphics.rects, filterRects)), - [graphics.rects, filterRects, objectLimit], + const allFilteredRects = useMemo( + () => sortRectsByArea(filterObjects(graphics.rects, filterRects)), + [graphics.rects, filterRects], ) - const filteredPolygons = useMemo( - () => filterAndLimit(graphics.polygons, filterPolygons), - [graphics.polygons, filterPolygons, objectLimit], + const allFilteredPolygons = useMemo( + () => filterObjects(graphics.polygons, filterPolygons), + [graphics.polygons, filterPolygons], ) - const filteredPoints = useMemo( - () => filterAndLimit(graphics.points, filterPoints), - [graphics.points, filterPoints, objectLimit], + const allFilteredPoints = useMemo( + () => filterObjects(graphics.points, filterPoints), + [graphics.points, filterPoints], ) - const filteredCircles = useMemo( - () => filterAndLimit(graphics.circles, filterCircles), - [graphics.circles, filterCircles, objectLimit], + const allFilteredCircles = useMemo( + () => filterObjects(graphics.circles, filterCircles), + [graphics.circles, filterCircles], ) - const filteredTexts = useMemo( - () => filterAndLimit(graphics.texts, filterTexts), - [graphics.texts, filterTexts, objectLimit], + const allFilteredTexts = useMemo( + () => filterObjects(graphics.texts, filterTexts), + [graphics.texts, filterTexts], ) - const filteredArrows = useMemo( - () => filterAndLimit(graphics.arrows, filterArrows), - [graphics.arrows, filterArrows, objectLimit], + const allFilteredArrows = 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 { + buckets: limitedObjectBuckets, + totalFilteredObjects, + isLimitReached, + } = useMemo( + () => + applyObjectLimit( + { + arrows: allFilteredArrows, + infiniteLines: allFilteredInfiniteLines, + lines: allFilteredLines, + rects: allFilteredRects, + polygons: allFilteredPolygons, + circles: allFilteredCircles, + texts: allFilteredTexts, + points: allFilteredPoints, + }, + objectLimit, + ), + [ + allFilteredArrows, + allFilteredInfiniteLines, + allFilteredLines, + allFilteredRects, + allFilteredPolygons, + allFilteredCircles, + allFilteredTexts, + allFilteredPoints, + objectLimit, + ], + ) + const filteredArrows = limitedObjectBuckets.arrows + const filteredInfiniteLines = limitedObjectBuckets.infiniteLines + const filteredLines = limitedObjectBuckets.lines + const filteredRects = limitedObjectBuckets.rects + const filteredPolygons = limitedObjectBuckets.polygons + const filteredCircles = limitedObjectBuckets.circles + const filteredTexts = limitedObjectBuckets.texts + const filteredPoints = limitedObjectBuckets.points return (
diff --git a/site/components/InteractiveGraphics/apply-object-limit.ts b/site/components/InteractiveGraphics/apply-object-limit.ts new file mode 100644 index 0000000..797a60e --- /dev/null +++ b/site/components/InteractiveGraphics/apply-object-limit.ts @@ -0,0 +1,52 @@ +type IndexedObject = { originalIndex: number } + +export type ObjectBucketMap = Record + +const getNormalizedObjectLimit = (objectLimit?: number): number | null => { + if (typeof objectLimit !== "number" || !Number.isFinite(objectLimit)) { + return null + } + + return Math.max(0, Math.floor(objectLimit)) +} + +export const applyObjectLimit = ( + buckets: T, + objectLimit?: number, +): { + buckets: T + totalFilteredObjects: number + isLimitReached: boolean +} => { + const entries = Object.entries(buckets) + const slots = entries.flatMap(([bucketName, objects]) => + objects.map((object) => ({ + key: `${bucketName}:${object.originalIndex}`, + })), + ) + const totalFilteredObjects = slots.length + const normalizedLimit = getNormalizedObjectLimit(objectLimit) + + if (normalizedLimit === null || totalFilteredObjects <= normalizedLimit) { + return { buckets, totalFilteredObjects, isLimitReached: false } + } + + const firstAllowedIndex = totalFilteredObjects - normalizedLimit + const allowedKeys = new Set( + slots.slice(firstAllowedIndex).map((slot) => slot.key), + ) + const limitedBuckets = Object.fromEntries( + entries.map(([bucketName, objects]) => [ + bucketName, + objects.filter((object) => + allowedKeys.has(`${bucketName}:${object.originalIndex}`), + ), + ]), + ) as T + + return { + buckets: limitedBuckets, + totalFilteredObjects, + isLimitReached: true, + } +} diff --git a/tests/apply-object-limit.test.ts b/tests/apply-object-limit.test.ts new file mode 100644 index 0000000..129d846 --- /dev/null +++ b/tests/apply-object-limit.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test" +import { applyObjectLimit } from "site/components/InteractiveGraphics/apply-object-limit" + +test("object limit caps the total filtered objects across buckets", () => { + const result = applyObjectLimit( + { + lines: [{ originalIndex: 0 }, { originalIndex: 1 }], + rects: [{ originalIndex: 0 }, { originalIndex: 1 }], + points: [{ originalIndex: 0 }, { originalIndex: 1 }], + }, + 3, + ) + + expect(result.totalFilteredObjects).toBe(6) + expect(result.isLimitReached).toBe(true) + expect(result.buckets.lines).toEqual([]) + expect(result.buckets.rects).toEqual([{ originalIndex: 1 }]) + expect(result.buckets.points).toEqual([ + { originalIndex: 0 }, + { originalIndex: 1 }, + ]) +}) + +test("object limit leaves buckets unchanged when the total is below the cap", () => { + const buckets = { + lines: [{ originalIndex: 0 }], + rects: [{ originalIndex: 0 }], + } + + const result = applyObjectLimit(buckets, 3) + + expect(result.totalFilteredObjects).toBe(2) + expect(result.isLimitReached).toBe(false) + expect(result.buckets).toBe(buckets) +}) + +test("object limit can intentionally hide every object", () => { + const result = applyObjectLimit( + { + lines: [{ originalIndex: 0 }], + rects: [{ originalIndex: 0 }], + }, + 0, + ) + + expect(result.totalFilteredObjects).toBe(2) + expect(result.isLimitReached).toBe(true) + expect(result.buckets.lines).toEqual([]) + expect(result.buckets.rects).toEqual([]) +})