From 1c19fb70ff9f0a907c10fba4d196ef2f4af86157 Mon Sep 17 00:00:00 2001 From: zeppnyc <167238929+zeppnyc@users.noreply.github.com> Date: Wed, 13 May 2026 19:29:35 +0800 Subject: [PATCH] Fix objectLimit to cap total objects globally, not per type Previously, `filterAndLimit` applied `slice(-objectLimit)` to each object type bucket independently (8 buckets: arrows, infiniteLines, lines, rects, polygons, circles, texts, points), so a graph containing N object types could display up to `N * objectLimit` objects. This contradicted the issue spec ("limits the number of objects displayed") and the existing "Display limited to X. Received: Y" UI message, which already assumed a global cap. Extract a pure `applyObjectLimit` helper that distributes the limit across all object buckets, filling from the last bucket in render order backwards (preserving the previous "last N" semantic from `slice(-objectLimit)`). `totalFilteredObjects` now reports the true pre-limit total so the `isLimitReached` indicator triggers correctly. Refs #42 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../InteractiveGraphics.tsx | 74 +++++++++--- site/utils/applyObjectLimit.ts | 29 +++++ tests/applyObjectLimit.test.ts | 113 ++++++++++++++++++ 3 files changed, 198 insertions(+), 18 deletions(-) create mode 100644 site/utils/applyObjectLimit.ts create mode 100644 tests/applyObjectLimit.test.ts diff --git a/site/components/InteractiveGraphics/InteractiveGraphics.tsx b/site/components/InteractiveGraphics/InteractiveGraphics.tsx index de014e1..f6e355a 100644 --- a/site/components/InteractiveGraphics/InteractiveGraphics.tsx +++ b/site/components/InteractiveGraphics/InteractiveGraphics.tsx @@ -1,6 +1,7 @@ import useResizeObserver from "@react-hook/resize-observer" import { useCallback, useEffect, useMemo, useState } from "react" import { SuperGrid } from "react-supergrid" +import { applyObjectLimit } from "site/utils/applyObjectLimit" import { getGraphicsBounds } from "site/utils/getGraphicsBounds" import { getMaxStep } from "site/utils/getMaxStep" import { sortRectsByArea } from "site/utils/sortRectsByArea" @@ -426,10 +427,9 @@ export const InteractiveGraphics = ({ 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( @@ -439,35 +439,73 @@ export const InteractiveGraphics = ({ (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], + [graphics.infiniteLines, filterLayerAndStep], ) const filteredRects = useMemo( () => sortRectsByArea(filterAndLimit(graphics.rects, filterRects)), - [graphics.rects, filterRects, objectLimit], + [graphics.rects, filterRects], ) const filteredPolygons = useMemo( () => filterAndLimit(graphics.polygons, filterPolygons), - [graphics.polygons, filterPolygons, objectLimit], + [graphics.polygons, filterPolygons], ) const filteredPoints = useMemo( () => filterAndLimit(graphics.points, filterPoints), - [graphics.points, filterPoints, objectLimit], + [graphics.points, filterPoints], ) const filteredCircles = useMemo( () => filterAndLimit(graphics.circles, filterCircles), - [graphics.circles, filterCircles, objectLimit], + [graphics.circles, filterCircles], ) const filteredTexts = useMemo( () => filterAndLimit(graphics.texts, filterTexts), - [graphics.texts, filterTexts, objectLimit], + [graphics.texts, filterTexts], ) const filteredArrows = useMemo( () => filterAndLimit(graphics.arrows, filterArrows), - [graphics.arrows, filterArrows, objectLimit], + [graphics.arrows, filterArrows], + ) + + const limitedBuckets = useMemo( + () => + applyObjectLimit( + { + arrows: filteredArrows, + infiniteLines: filteredInfiniteLines, + lines: filteredLines, + rects: filteredRects, + polygons: filteredPolygons, + circles: filteredCircles, + texts: filteredTexts, + points: filteredPoints, + }, + [ + "arrows", + "infiniteLines", + "lines", + "rects", + "polygons", + "circles", + "texts", + "points", + ], + objectLimit, + ), + [ + filteredArrows, + filteredInfiniteLines, + filteredLines, + filteredRects, + filteredPolygons, + filteredCircles, + filteredTexts, + filteredPoints, + objectLimit, + ], ) const totalFilteredObjects = @@ -598,7 +636,7 @@ export const InteractiveGraphics = ({ onContextMenu={handleContextMenu} > - {filteredArrows.map((arrow) => ( + {limitedBuckets.arrows.map((arrow) => ( ))} - {filteredInfiniteLines.map((infiniteLine) => ( + {limitedBuckets.infiniteLines.map((infiniteLine) => ( ))} - {filteredLines.map((line) => ( + {limitedBuckets.lines.map((line) => ( ))} - {filteredRects.map((rect) => ( + {limitedBuckets.rects.map((rect) => ( ))} - {filteredPolygons.map((polygon) => ( + {limitedBuckets.polygons.map((polygon) => ( ))} - {filteredCircles.map((circle) => ( + {limitedBuckets.circles.map((circle) => ( ))} - {filteredTexts.map((txt) => ( + {limitedBuckets.texts.map((txt) => ( ))} - {filteredPoints.map((point) => ( + {limitedBuckets.points.map((point) => ( >( + buckets: T, + renderOrder: ReadonlyArray, + limit: number | undefined, +): T => { + if (!limit || limit <= 0) return buckets + + let total = 0 + for (const key of renderOrder) total += buckets[key]?.length ?? 0 + if (total <= limit) return buckets + + const result = { ...buckets } + for (const key of renderOrder) (result as any)[key] = [] + + let budget = limit + for (let i = renderOrder.length - 1; i >= 0; i--) { + if (budget <= 0) break + const key = renderOrder[i] + const arr = buckets[key] ?? [] + if (arr.length <= budget) { + ;(result as any)[key] = arr + budget -= arr.length + } else { + ;(result as any)[key] = arr.slice(-budget) + budget = 0 + } + } + return result +} diff --git a/tests/applyObjectLimit.test.ts b/tests/applyObjectLimit.test.ts new file mode 100644 index 0000000..17fb3da --- /dev/null +++ b/tests/applyObjectLimit.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test" +import { applyObjectLimit } from "site/utils/applyObjectLimit" + +const order = [ + "arrows", + "infiniteLines", + "lines", + "rects", + "polygons", + "circles", + "texts", + "points", +] as const + +const emptyBuckets = () => ({ + arrows: [] as number[], + infiniteLines: [] as number[], + lines: [] as number[], + rects: [] as number[], + polygons: [] as number[], + circles: [] as number[], + texts: [] as number[], + points: [] as number[], +}) + +describe("applyObjectLimit", () => { + test("returns buckets unchanged when limit is undefined", () => { + const buckets = { ...emptyBuckets(), lines: [1, 2, 3], points: [4, 5] } + const result = applyObjectLimit(buckets, order, undefined) + expect(result).toBe(buckets) + }) + + test("returns buckets unchanged when limit is zero", () => { + const buckets = { ...emptyBuckets(), lines: [1, 2, 3] } + const result = applyObjectLimit(buckets, order, 0) + expect(result).toBe(buckets) + }) + + test("returns buckets unchanged when total does not exceed limit", () => { + const buckets = { ...emptyBuckets(), lines: [1, 2], points: [3] } + const result = applyObjectLimit(buckets, order, 5) + expect(result).toBe(buckets) + }) + + test("caps total objects at limit when only one bucket exceeds it", () => { + const buckets = { ...emptyBuckets(), points: [1, 2, 3, 4, 5] } + const result = applyObjectLimit(buckets, order, 3) + expect(result.points).toEqual([3, 4, 5]) + }) + + test("fills budget from the last bucket backwards across types", () => { + const buckets = { + ...emptyBuckets(), + arrows: [10, 11, 12], + lines: [20, 21], + points: [30], + } + const result = applyObjectLimit(buckets, order, 4) + expect(result.points).toEqual([30]) + expect(result.lines).toEqual([20, 21]) + expect(result.arrows).toEqual([12]) + expect(result.infiniteLines).toEqual([]) + expect(result.rects).toEqual([]) + expect(result.polygons).toEqual([]) + expect(result.circles).toEqual([]) + expect(result.texts).toEqual([]) + }) + + test("earlier buckets are dropped entirely when later buckets exhaust budget", () => { + const buckets = { + ...emptyBuckets(), + arrows: [1, 2], + lines: [3, 4], + points: [5, 6, 7], + } + const result = applyObjectLimit(buckets, order, 3) + expect(result.points).toEqual([5, 6, 7]) + expect(result.lines).toEqual([]) + expect(result.arrows).toEqual([]) + }) + + test("does not mutate the input buckets", () => { + const buckets = { + ...emptyBuckets(), + arrows: [1, 2, 3], + points: [4, 5], + } + const snapshot = { + ...buckets, + arrows: [...buckets.arrows], + points: [...buckets.points], + } + applyObjectLimit(buckets, order, 2) + expect(buckets.arrows).toEqual(snapshot.arrows) + expect(buckets.points).toEqual(snapshot.points) + }) + + test("limit equal to total returns all objects", () => { + const buckets = { ...emptyBuckets(), lines: [1, 2], points: [3, 4] } + const result = applyObjectLimit(buckets, order, 4) + expect(result.lines).toEqual([1, 2]) + expect(result.points).toEqual([3, 4]) + }) + + test("preserves order within each kept bucket", () => { + const buckets = { + ...emptyBuckets(), + lines: [100, 200, 300, 400], + } + const result = applyObjectLimit(buckets, order, 2) + expect(result.lines).toEqual([300, 400]) + }) +})