From 236b558c76bafda3120586e4b0ccbbd585aa7f99 Mon Sep 17 00:00:00 2001 From: taweechok Date: Sun, 17 May 2026 06:15:16 +0700 Subject: [PATCH] fix: apply objectLimit globally across all object buckets after filtering Previously objectLimit was applied per-type bucket separately (via slice(-objectLimit)), meaning objectLimit={3} could still render up to 3*8=24 objects across all buckets. This fix: - Renames filterAndLimit to filterOnly (filter only, no per-type limit) - Adds applyObjectLimit helper that distributes a global cap across all buckets from the last bucket backwards - Adds limitedBuckets useMemo that applies the global limit once - Updates totalPreLimitObjects to count pre-limit filtered objects - Updates isLimitReached to compare totalPreLimitObjects vs objectLimit - Updates JSX to render from limitedXxx instead of filteredXxx Fixes #42 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../InteractiveGraphics.tsx | 104 +++++++++++++----- site/utils/applyObjectLimit.ts | 36 ++++++ tests/apply-object-limit.test.ts | 68 ++++++++++++ 3 files changed, 179 insertions(+), 29 deletions(-) create mode 100644 site/utils/applyObjectLimit.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..06c85f3 100644 --- a/site/components/InteractiveGraphics/InteractiveGraphics.tsx +++ b/site/components/InteractiveGraphics/InteractiveGraphics.tsx @@ -4,6 +4,7 @@ import { SuperGrid } from "react-supergrid" import { getGraphicsBounds } from "site/utils/getGraphicsBounds" import { getMaxStep } from "site/utils/getMaxStep" import { sortRectsByArea } from "site/utils/sortRectsByArea" +import { applyObjectLimit } from "site/utils/applyObjectLimit" import { applyToPoint, compose, @@ -421,7 +422,7 @@ export const InteractiveGraphics = ({ filterLayerAndStep, }) - const filterAndLimit = ( + const filterOnly = ( objects: T[] | undefined, filterFn: (obj: T) => boolean, ): (T & { originalIndex: number })[] => { @@ -429,48 +430,48 @@ export const InteractiveGraphics = ({ const filtered = objects .map((obj, index) => ({ ...obj, originalIndex: index })) .filter(filterFn) - return objectLimit ? filtered.slice(-objectLimit) : filtered + return filtered } const filteredLines = useMemo( () => - filterAndLimit(graphics.lines, filterLines).sort( + filterOnly(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], + () => filterOnly(graphics.infiniteLines, filterLayerAndStep), + [graphics.infiniteLines, filterLayerAndStep], ) const filteredRects = useMemo( - () => sortRectsByArea(filterAndLimit(graphics.rects, filterRects)), - [graphics.rects, filterRects, objectLimit], + () => sortRectsByArea(filterOnly(graphics.rects, filterRects)), + [graphics.rects, filterRects], ) const filteredPolygons = useMemo( - () => filterAndLimit(graphics.polygons, filterPolygons), - [graphics.polygons, filterPolygons, objectLimit], + () => filterOnly(graphics.polygons, filterPolygons), + [graphics.polygons, filterPolygons], ) const filteredPoints = useMemo( - () => filterAndLimit(graphics.points, filterPoints), - [graphics.points, filterPoints, objectLimit], + () => filterOnly(graphics.points, filterPoints), + [graphics.points, filterPoints], ) const filteredCircles = useMemo( - () => filterAndLimit(graphics.circles, filterCircles), - [graphics.circles, filterCircles, objectLimit], + () => filterOnly(graphics.circles, filterCircles), + [graphics.circles, filterCircles], ) const filteredTexts = useMemo( - () => filterAndLimit(graphics.texts, filterTexts), - [graphics.texts, filterTexts, objectLimit], + () => filterOnly(graphics.texts, filterTexts), + [graphics.texts, filterTexts], ) const filteredArrows = useMemo( - () => filterAndLimit(graphics.arrows, filterArrows), - [graphics.arrows, filterArrows, objectLimit], + () => filterOnly(graphics.arrows, filterArrows), + [graphics.arrows, filterArrows], ) - const totalFilteredObjects = + const totalPreLimitObjects = filteredInfiniteLines.length + filteredLines.length + filteredRects.length + @@ -479,7 +480,52 @@ export const InteractiveGraphics = ({ filteredCircles.length + filteredTexts.length + filteredArrows.length - const isLimitReached = objectLimit && totalFilteredObjects > objectLimit + + const [ + limitedLines, + limitedInfiniteLines, + limitedRects, + limitedPolygons, + limitedPoints, + limitedCircles, + limitedTexts, + limitedArrows, + ] = useMemo(() => { + const buckets = [ + filteredLines, + filteredInfiniteLines, + filteredRects, + filteredPolygons, + filteredPoints, + filteredCircles, + filteredTexts, + filteredArrows, + ] + const typedBuckets = buckets as [ + typeof filteredLines, + typeof filteredInfiniteLines, + typeof filteredRects, + typeof filteredPolygons, + typeof filteredPoints, + typeof filteredCircles, + typeof filteredTexts, + typeof filteredArrows, + ] + if (!objectLimit || totalPreLimitObjects <= objectLimit) return typedBuckets + return applyObjectLimit(buckets, objectLimit) as typeof typedBuckets + }, [ + filteredLines, + filteredInfiniteLines, + filteredRects, + filteredPolygons, + filteredPoints, + filteredCircles, + filteredTexts, + filteredArrows, + objectLimit, + totalPreLimitObjects, + ]) + const isLimitReached = objectLimit && totalPreLimitObjects > objectLimit return (
@@ -555,7 +601,7 @@ export const InteractiveGraphics = ({ {isLimitReached && ( Display limited to {objectLimit} objects. Received:{" "} - {totalFilteredObjects}. + {totalPreLimitObjects}. )}
@@ -598,7 +644,7 @@ export const InteractiveGraphics = ({ onContextMenu={handleContextMenu} > - {filteredArrows.map((arrow) => ( + {limitedArrows.map((arrow) => ( ))} - {filteredInfiniteLines.map((infiniteLine) => ( + {limitedInfiniteLines.map((infiniteLine) => ( ))} - {filteredLines.map((line) => ( + {limitedLines.map((line) => ( ))} - {filteredRects.map((rect) => ( + {limitedRects.map((rect) => ( ))} - {filteredPolygons.map((polygon) => ( + {limitedPolygons.map((polygon) => ( ))} - {filteredCircles.map((circle) => ( + {limitedCircles.map((circle) => ( ))} - {filteredTexts.map((txt) => ( + {limitedTexts.map((txt) => ( ))} - {filteredPoints.map((point) => ( + {limitedPoints.map((point) => ( [[L1], [R1,R2,R3]] (last 3 across all buckets) + */ +export function applyObjectLimit( + buckets: T[], + limit: number, +): T[] { + if (!limit) { + return buckets.map(() => [] as unknown as T) + } + + const total = buckets.reduce((sum, b) => sum + b.length, 0) + if (total <= limit) return buckets + + let remaining = limit + const result: T[] = new Array(buckets.length) + + for (let i = buckets.length - 1; i >= 0; i--) { + const take = Math.min(buckets[i].length, remaining) + result[i] = take > 0 ? (buckets[i].slice(-take) as T) : ([] as unknown as T) + remaining -= take + if (remaining <= 0) { + for (let j = i - 1; j >= 0; j--) { + result[j] = [] as unknown as T + } + return result + } + } + + return result +} diff --git a/tests/apply-object-limit.test.ts b/tests/apply-object-limit.test.ts new file mode 100644 index 0000000..689e534 --- /dev/null +++ b/tests/apply-object-limit.test.ts @@ -0,0 +1,68 @@ +import { expect, test, describe } from "bun:test" +import { applyObjectLimit } from "site/utils/applyObjectLimit" + +describe("applyObjectLimit", () => { + test("returns buckets unchanged when total <= limit", () => { + const buckets = [[1, 2], [3, 4], [5]] + const result = applyObjectLimit(buckets, 10) + expect(result).toBe(buckets) + }) + + test("applies global limit across buckets from the end", () => { + const buckets = [[1, 2, 3], [4, 5, 6], [7]] + const result = applyObjectLimit(buckets, 3) + // limit=3: take 1 from [7], then 2 from [4,5,6] -> [[],[5,6],[7]] + expect(result[0]).toEqual([]) + expect(result[1]).toEqual([5, 6]) + expect(result[2]).toEqual([7]) + const total = result.reduce((sum, b) => sum + b.length, 0) + expect(total).toBe(3) + }) + + test("single bucket gets capped to limit", () => { + const buckets = [[1, 2, 3, 4, 5]] + const result = applyObjectLimit(buckets, 3) + expect(result[0]).toEqual([3, 4, 5]) + }) + + test("zero limit empties all buckets", () => { + const buckets = [ + [1, 2], + [3, 4], + ] + const result = applyObjectLimit(buckets, 0) + expect(result[0]).toEqual([]) + expect(result[1]).toEqual([]) + }) + + test("preserves order within buckets", () => { + const buckets = [ + [10, 20, 30], + [40, 50], + ] + const result = applyObjectLimit(buckets, 3) + // last 3: take all [40,50] + last 1 from [10,20,30]=[30] -> [[30],[40,50]] + expect(result[0]).toEqual([30]) + expect(result[1]).toEqual([40, 50]) + }) + + test("limit exactly equals total returns original buckets", () => { + const buckets = [[1, 2], [3]] + const result = applyObjectLimit(buckets, 3) + expect(result).toBe(buckets) + }) + + test("first buckets become empty when limit fits only last buckets", () => { + const buckets = [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ] + const result = applyObjectLimit(buckets, 2) + expect(result[0]).toEqual([]) + expect(result[1]).toEqual([]) + expect(result[2]).toEqual([8, 9]) + const total = result.reduce((sum, b) => sum + b.length, 0) + expect(total).toBe(2) + }) +})