diff --git a/README.md b/README.md index 3ec3602..b412f83 100644 --- a/README.md +++ b/README.md @@ -364,4 +364,5 @@ export default function Demo() { `InteractiveGraphics` accepts a `GraphicsObject` via the `graphics` prop. You can optionally handle clicks on objects with `onObjectClicked`, set the viewer height with `height`, or limit how many objects are drawn with -`objectLimit`. +`objectLimit`. The limit is applied globally after viewport, layer and step +filters, not separately per graphics object type. diff --git a/site/components/InteractiveGraphics/InteractiveGraphics.tsx b/site/components/InteractiveGraphics/InteractiveGraphics.tsx index de014e1..9fdf9ab 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" @@ -421,65 +422,101 @@ 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 filteredLinesWithoutLimit = 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 filteredInfiniteLinesWithoutLimit = useMemo( + () => filterObjects(graphics.infiniteLines, filterLayerAndStep), + [graphics.infiniteLines, filterLayerAndStep], ) - const filteredRects = useMemo( - () => sortRectsByArea(filterAndLimit(graphics.rects, filterRects)), - [graphics.rects, filterRects, objectLimit], + const filteredRectsWithoutLimit = useMemo( + () => sortRectsByArea(filterObjects(graphics.rects, filterRects)), + [graphics.rects, filterRects], ) - const filteredPolygons = useMemo( - () => filterAndLimit(graphics.polygons, filterPolygons), - [graphics.polygons, filterPolygons, objectLimit], + const filteredPolygonsWithoutLimit = useMemo( + () => filterObjects(graphics.polygons, filterPolygons), + [graphics.polygons, filterPolygons], ) - const filteredPoints = useMemo( - () => filterAndLimit(graphics.points, filterPoints), - [graphics.points, filterPoints, objectLimit], + const filteredPointsWithoutLimit = useMemo( + () => filterObjects(graphics.points, filterPoints), + [graphics.points, filterPoints], ) - const filteredCircles = useMemo( - () => filterAndLimit(graphics.circles, filterCircles), - [graphics.circles, filterCircles, objectLimit], + const filteredCirclesWithoutLimit = useMemo( + () => filterObjects(graphics.circles, filterCircles), + [graphics.circles, filterCircles], ) - const filteredTexts = useMemo( - () => filterAndLimit(graphics.texts, filterTexts), - [graphics.texts, filterTexts, objectLimit], + const filteredTextsWithoutLimit = useMemo( + () => filterObjects(graphics.texts, filterTexts), + [graphics.texts, filterTexts], ) - const filteredArrows = useMemo( - () => filterAndLimit(graphics.arrows, filterArrows), - [graphics.arrows, filterArrows, objectLimit], + const filteredArrowsWithoutLimit = 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 + filteredInfiniteLinesWithoutLimit.length + + filteredLinesWithoutLimit.length + + filteredRectsWithoutLimit.length + + filteredPolygonsWithoutLimit.length + + filteredPointsWithoutLimit.length + + filteredCirclesWithoutLimit.length + + filteredTextsWithoutLimit.length + + filteredArrowsWithoutLimit.length + + const limitedObjects = useMemo( + () => + applyObjectLimit( + { + arrows: filteredArrowsWithoutLimit, + infiniteLines: filteredInfiniteLinesWithoutLimit, + lines: filteredLinesWithoutLimit, + rects: filteredRectsWithoutLimit, + polygons: filteredPolygonsWithoutLimit, + circles: filteredCirclesWithoutLimit, + texts: filteredTextsWithoutLimit, + points: filteredPointsWithoutLimit, + }, + objectLimit, + ), + [ + filteredArrowsWithoutLimit, + filteredInfiniteLinesWithoutLimit, + filteredLinesWithoutLimit, + filteredRectsWithoutLimit, + filteredPolygonsWithoutLimit, + filteredCirclesWithoutLimit, + filteredTextsWithoutLimit, + filteredPointsWithoutLimit, + objectLimit, + ], + ) + + const filteredArrows = limitedObjects.arrows + const filteredInfiniteLines = limitedObjects.infiniteLines + const filteredLines = limitedObjects.lines + const filteredRects = limitedObjects.rects + const filteredPolygons = limitedObjects.polygons + const filteredCircles = limitedObjects.circles + const filteredTexts = limitedObjects.texts + const filteredPoints = limitedObjects.points + const isLimitReached = !!objectLimit && totalFilteredObjects > objectLimit return (
diff --git a/site/utils/applyObjectLimit.ts b/site/utils/applyObjectLimit.ts new file mode 100644 index 0000000..6a572e0 --- /dev/null +++ b/site/utils/applyObjectLimit.ts @@ -0,0 +1,38 @@ +type ObjectBuckets = Record + +type LimitedBuckets = { + [TKey in keyof TBuckets]: TBuckets[TKey] extends readonly (infer TItem)[] + ? TItem[] + : never +} + +export function applyObjectLimit( + buckets: TBuckets, + objectLimit?: number, +): LimitedBuckets { + const bucketKeys = Object.keys(buckets) as Array + const limitedBuckets = {} as LimitedBuckets + + for (const key of bucketKeys) { + limitedBuckets[key] = [] as unknown as LimitedBuckets[typeof key] + } + + if (!objectLimit || objectLimit <= 0) { + for (const key of bucketKeys) { + limitedBuckets[key] = [ + ...buckets[key], + ] as unknown as LimitedBuckets[typeof key] + } + return limitedBuckets + } + + const allObjects = bucketKeys.flatMap((key) => + buckets[key].map((object) => ({ key, object })), + ) + + for (const { key, object } of allObjects.slice(-objectLimit)) { + ;(limitedBuckets[key] as unknown[]).push(object) + } + + return limitedBuckets +} diff --git a/tests/apply-object-limit.test.ts b/tests/apply-object-limit.test.ts new file mode 100644 index 0000000..d8b1a04 --- /dev/null +++ b/tests/apply-object-limit.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import { applyObjectLimit } from "site/utils/applyObjectLimit" + +describe("applyObjectLimit", () => { + test("caps objects globally across buckets", () => { + const limited = applyObjectLimit( + { + lines: [{ id: "line-1" }, { id: "line-2" }], + rects: [{ id: "rect-1" }, { id: "rect-2" }], + points: [{ id: "point-1" }, { id: "point-2" }], + }, + 3, + ) + + const renderedObjects = [ + ...limited.lines, + ...limited.rects, + ...limited.points, + ].map((object) => object.id) + + expect(renderedObjects).toEqual(["rect-2", "point-1", "point-2"]) + expect(limited.lines).toHaveLength(0) + }) + + test("does not limit objects when objectLimit is omitted", () => { + const limited = applyObjectLimit({ + lines: [{ id: "line-1" }], + points: [{ id: "point-1" }], + }) + + expect(limited.lines).toHaveLength(1) + expect(limited.points).toHaveLength(1) + }) +})