diff --git a/site/components/InteractiveGraphics/InteractiveGraphics.tsx b/site/components/InteractiveGraphics/InteractiveGraphics.tsx index de014e1..f4e4cb1 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 { getFilteredAndLimitedGraphics } from "./object-limit" import { useDoesLineIntersectViewport, useFilterArrows, @@ -421,65 +422,61 @@ export const InteractiveGraphics = ({ filterLayerAndStep, }) - const filterAndLimit = ( - objects: T[] | undefined, - filterFn: (obj: T) => boolean, - ): (T & { originalIndex: number })[] => { - if (!objects) return [] - const filtered = objects - .map((obj, index) => ({ ...obj, originalIndex: index })) - .filter(filterFn) - return objectLimit ? filtered.slice(-objectLimit) : filtered - } + const { + lines: filteredLinesUnsorted, + infiniteLines: filteredInfiniteLines, + rects: filteredRectsUnsorted, + polygons: filteredPolygons, + points: filteredPoints, + circles: filteredCircles, + texts: filteredTexts, + arrows: filteredArrows, + totalFilteredObjects, + isLimitReached, + } = useMemo( + () => + getFilteredAndLimitedGraphics( + graphics, + { + lines: filterLines, + infiniteLines: filterLayerAndStep, + rects: filterRects, + polygons: filterPolygons, + points: filterPoints, + circles: filterCircles, + texts: filterTexts, + arrows: filterArrows, + }, + objectLimit, + ), + [ + graphics, + filterLines, + filterLayerAndStep, + filterRects, + filterPolygons, + filterPoints, + filterCircles, + filterTexts, + filterArrows, + objectLimit, + ], + ) const filteredLines = useMemo( () => - filterAndLimit(graphics.lines, filterLines).sort( + filteredLinesUnsorted.sort( (a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0) || a.originalIndex - b.originalIndex, ), - [graphics.lines, filterLines, objectLimit], - ) - const filteredInfiniteLines = useMemo( - () => filterAndLimit(graphics.infiniteLines, filterLayerAndStep), - [graphics.infiniteLines, filterLayerAndStep, objectLimit], + [filteredLinesUnsorted], ) + const filteredRects = useMemo( - () => sortRectsByArea(filterAndLimit(graphics.rects, filterRects)), - [graphics.rects, filterRects, objectLimit], + () => sortRectsByArea(filteredRectsUnsorted), + [filteredRectsUnsorted], ) - const filteredPolygons = useMemo( - () => filterAndLimit(graphics.polygons, filterPolygons), - [graphics.polygons, filterPolygons, objectLimit], - ) - const filteredPoints = useMemo( - () => filterAndLimit(graphics.points, filterPoints), - [graphics.points, filterPoints, objectLimit], - ) - const filteredCircles = useMemo( - () => filterAndLimit(graphics.circles, filterCircles), - [graphics.circles, filterCircles, objectLimit], - ) - const filteredTexts = useMemo( - () => filterAndLimit(graphics.texts, filterTexts), - [graphics.texts, filterTexts, objectLimit], - ) - const filteredArrows = useMemo( - () => filterAndLimit(graphics.arrows, filterArrows), - [graphics.arrows, filterArrows, objectLimit], - ) - - const totalFilteredObjects = - filteredInfiniteLines.length + - filteredLines.length + - filteredRects.length + - filteredPolygons.length + - filteredPoints.length + - filteredCircles.length + - filteredTexts.length + - filteredArrows.length - const isLimitReached = objectLimit && totalFilteredObjects > objectLimit return (
diff --git a/site/components/InteractiveGraphics/object-limit.ts b/site/components/InteractiveGraphics/object-limit.ts new file mode 100644 index 0000000..679abb1 --- /dev/null +++ b/site/components/InteractiveGraphics/object-limit.ts @@ -0,0 +1,152 @@ +import type { + Arrow, + Circle, + GraphicsObject, + InfiniteLine, + Line, + Point, + Polygon, + Rect, + Text, +} from "../../../lib/types" + +export type GraphicsObjectType = + | "lines" + | "infiniteLines" + | "rects" + | "polygons" + | "points" + | "circles" + | "texts" + | "arrows" + +export type IndexedGraphicsObject = T & { + originalIndex: number +} + +export type LimitedGraphicsCollections = { + lines: IndexedGraphicsObject[] + infiniteLines: IndexedGraphicsObject[] + rects: IndexedGraphicsObject[] + polygons: IndexedGraphicsObject[] + points: IndexedGraphicsObject[] + circles: IndexedGraphicsObject[] + texts: IndexedGraphicsObject[] + arrows: IndexedGraphicsObject[] + totalFilteredObjects: number + totalDisplayedObjects: number + isLimitReached: boolean +} + +const GRAPHICS_OBJECT_TYPES: GraphicsObjectType[] = [ + "lines", + "infiniteLines", + "rects", + "polygons", + "points", + "circles", + "texts", + "arrows", +] + +type FilterMap = { + lines: (obj: Line) => boolean + infiniteLines: (obj: InfiniteLine) => boolean + rects: (obj: Rect) => boolean + polygons: (obj: Polygon) => boolean + points: (obj: Point) => boolean + circles: (obj: Circle) => boolean + texts: (obj: Text) => boolean + arrows: (obj: Arrow) => boolean +} + +export const getFilteredAndLimitedGraphics = ( + graphics: GraphicsObject, + filters: FilterMap, + objectLimit?: number, +): LimitedGraphicsCollections => { + const filteredCollections = { + lines: (graphics.lines ?? []) + .map((obj, originalIndex) => ({ ...obj, originalIndex })) + .filter(filters.lines), + infiniteLines: (graphics.infiniteLines ?? []) + .map((obj, originalIndex) => ({ ...obj, originalIndex })) + .filter(filters.infiniteLines), + rects: (graphics.rects ?? []) + .map((obj, originalIndex) => ({ ...obj, originalIndex })) + .filter(filters.rects), + polygons: (graphics.polygons ?? []) + .map((obj, originalIndex) => ({ ...obj, originalIndex })) + .filter(filters.polygons), + points: (graphics.points ?? []) + .map((obj, originalIndex) => ({ ...obj, originalIndex })) + .filter(filters.points), + circles: (graphics.circles ?? []) + .map((obj, originalIndex) => ({ ...obj, originalIndex })) + .filter(filters.circles), + texts: (graphics.texts ?? []) + .map((obj, originalIndex) => ({ ...obj, originalIndex })) + .filter(filters.texts), + arrows: (graphics.arrows ?? []) + .map((obj, originalIndex) => ({ ...obj, originalIndex })) + .filter(filters.arrows), + } + + const totalFilteredObjects = Object.values(filteredCollections).reduce( + (sum, objects) => sum + objects.length, + 0, + ) + + if (!objectLimit || objectLimit <= 0 || totalFilteredObjects <= objectLimit) { + return { + ...filteredCollections, + totalFilteredObjects, + totalDisplayedObjects: totalFilteredObjects, + isLimitReached: false, + } + } + + const keptKeys = new Set() + const flattened = GRAPHICS_OBJECT_TYPES.flatMap((type) => + filteredCollections[type].map((obj) => ({ + type, + key: `${type}:${obj.originalIndex}`, + })), + ) + const limitedObjects = flattened.slice(-objectLimit) + for (const entry of limitedObjects) keptKeys.add(entry.key) + + const limitedCollections = { + lines: filteredCollections.lines.filter((obj) => + keptKeys.has(`lines:${obj.originalIndex}`), + ), + infiniteLines: filteredCollections.infiniteLines.filter((obj) => + keptKeys.has(`infiniteLines:${obj.originalIndex}`), + ), + rects: filteredCollections.rects.filter((obj) => + keptKeys.has(`rects:${obj.originalIndex}`), + ), + polygons: filteredCollections.polygons.filter((obj) => + keptKeys.has(`polygons:${obj.originalIndex}`), + ), + points: filteredCollections.points.filter((obj) => + keptKeys.has(`points:${obj.originalIndex}`), + ), + circles: filteredCollections.circles.filter((obj) => + keptKeys.has(`circles:${obj.originalIndex}`), + ), + texts: filteredCollections.texts.filter((obj) => + keptKeys.has(`texts:${obj.originalIndex}`), + ), + arrows: filteredCollections.arrows.filter((obj) => + keptKeys.has(`arrows:${obj.originalIndex}`), + ), + } + + return { + ...limitedCollections, + totalFilteredObjects, + totalDisplayedObjects: objectLimit, + isLimitReached: true, + } +} diff --git a/tests/objectLimit.test.ts b/tests/objectLimit.test.ts new file mode 100644 index 0000000..3b666ea --- /dev/null +++ b/tests/objectLimit.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test" +import type { GraphicsObject } from "../lib" +import { getFilteredAndLimitedGraphics } from "../site/components/InteractiveGraphics/object-limit" + +const createGraphics = (): GraphicsObject => ({ + rects: [ + { + center: { x: 200, y: 100 }, + width: 50, + height: 100, + layer: "layer1", + step: 0, + }, + { + center: { x: 250, y: 100 }, + width: 50, + height: 100, + layer: "layer1", + step: 1, + }, + { + center: { x: 300, y: 100 }, + width: 50, + height: 100, + layer: "layer1", + step: 2, + }, + ], + points: [ + { + x: 0, + y: 0, + layer: "layer1", + }, + { + x: 50, + y: 0, + layer: "layer2", + }, + { + x: 100, + y: 0, + layer: "layer3", + }, + ], + circles: [ + { + center: { x: 400, y: 100 }, + radius: 25, + fill: "blue", + stroke: "black", + layer: "layer1", + step: 0, + label: "Circle 1", + }, + ], +}) + +describe("getFilteredAndLimitedGraphics", () => { + test("limits across all object types after filters are applied", () => { + const graphics = createGraphics() + + const result = getFilteredAndLimitedGraphics( + graphics, + { + lines: () => true, + infiniteLines: () => true, + rects: (rect) => rect.layer === "layer1" && rect.step === 0, + polygons: () => true, + points: (point) => point.layer === "layer1", + circles: (circle) => circle.layer === "layer1" && circle.step === 0, + texts: () => true, + arrows: () => true, + }, + 3, + ) + + expect(result.totalFilteredObjects).toBe(3) + expect(result.isLimitReached).toBe(false) + expect(result.rects).toHaveLength(1) + expect(result.points).toHaveLength(1) + expect(result.circles).toHaveLength(1) + }) + + test("enforces a single shared limit across categories using post-filter ordering", () => { + const graphics = createGraphics() + + const result = getFilteredAndLimitedGraphics( + graphics, + { + lines: () => true, + infiniteLines: () => true, + rects: () => true, + polygons: () => true, + points: () => true, + circles: () => true, + texts: () => true, + arrows: () => true, + }, + 3, + ) + + expect(result.totalFilteredObjects).toBe(7) + expect(result.isLimitReached).toBe(true) + expect(result.rects).toHaveLength(0) + expect(result.points.map((point) => point.originalIndex)).toEqual([1, 2]) + expect(result.circles.map((circle) => circle.originalIndex)).toEqual([0]) + }) +})