From df3133b930c7534bc08e0f214f5eada024ff735b Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 20 Mar 2026 16:29:22 +0300 Subject: [PATCH 1/2] feat(Camera): add setting to override default scale resolver --- docs/system/camera.md | 2 +- .../camera/camera-block-scale-level.spec.ts | 127 ++++++++++++++++++ src/index.ts | 6 +- src/services/camera/CameraService.ts | 32 ++--- src/services/camera/cameraScaleEnums.ts | 5 + .../defaultGetCameraBlockScaleLevel.test.ts | 67 +++++++++ .../camera/defaultGetCameraBlockScaleLevel.ts | 28 ++++ src/store/settings.ts | 14 +- 8 files changed, 262 insertions(+), 19 deletions(-) create mode 100644 e2e/tests/camera/camera-block-scale-level.spec.ts create mode 100644 src/services/camera/cameraScaleEnums.ts create mode 100644 src/services/camera/defaultGetCameraBlockScaleLevel.test.ts create mode 100644 src/services/camera/defaultGetCameraBlockScaleLevel.ts diff --git a/docs/system/camera.md b/docs/system/camera.md index 4a3c0451..4763e10a 100644 --- a/docs/system/camera.md +++ b/docs/system/camera.md @@ -45,7 +45,7 @@ export type TCameraState = { - `zoom(x, y, scale)` – zoom anchored to a screen-space point `(x, y)`. - `getCameraRect()` – screen-space camera rect. - `getCameraScale()` – scale value. -- `getCameraBlockScaleLevel(scale?)` – qualitative zoom tiers for switching rendering modes. +- `getCameraBlockScaleLevel(scale?)` – qualitative zoom tiers for switching rendering modes. The mapping is configurable via settings `getCameraBlockScaleLevel(graph, cameraState)` (default: `defaultGetCameraBlockScaleLevel` using `graphConstants.block.SCALES`). ### Mouse wheel behavior diff --git a/e2e/tests/camera/camera-block-scale-level.spec.ts b/e2e/tests/camera/camera-block-scale-level.spec.ts new file mode 100644 index 00000000..d67075ed --- /dev/null +++ b/e2e/tests/camera/camera-block-scale-level.spec.ts @@ -0,0 +1,127 @@ +import { test, expect } from "@playwright/test"; + +import { ECameraScaleLevel } from "../../../src/services/camera/cameraScaleEnums"; +import { GraphPageObject } from "../../page-objects/GraphPageObject"; + +const BLOCK = { + id: "block-1", + is: "Block" as const, + x: 100, + y: 100, + width: 200, + height: 100, + name: "Block 1", + anchors: [], + selected: false, +}; + +test.describe("getCameraBlockScaleLevel setting", () => { + test.describe("default strategy", () => { + let graphPO: GraphPageObject; + + test.beforeEach(async ({ page }) => { + graphPO = new GraphPageObject(page); + await graphPO.initialize({ blocks: [BLOCK], connections: [] }); + }); + + test("settings hook should reference the exported defaultGetCameraBlockScaleLevel", async ({ + page, + }) => { + const same = await page.evaluate(() => { + const { defaultGetCameraBlockScaleLevel } = window.GraphModule; + return ( + window.graph.rootStore.settings.$settings.value.getCameraBlockScaleLevel === + defaultGetCameraBlockScaleLevel + ); + }); + expect(same).toBe(true); + }); + + test("should map camera scale to Minimalistic / Schematic / Detailed using block SCALES", async () => { + const camera = graphPO.getCamera(); + + await camera.zoomToScale(0.05); + await graphPO.waitForFrames(3); + let level = await graphPO.page.evaluate(() => + window.graph.cameraService.getCameraBlockScaleLevel(), + ); + expect(level).toBe(ECameraScaleLevel.Minimalistic); + + await camera.zoomToScale(0.3); + await graphPO.waitForFrames(3); + level = await graphPO.page.evaluate(() => window.graph.cameraService.getCameraBlockScaleLevel()); + expect(level).toBe(ECameraScaleLevel.Schematic); + + await camera.zoomToScale(0.85); + await graphPO.waitForFrames(3); + level = await graphPO.page.evaluate(() => window.graph.cameraService.getCameraBlockScaleLevel()); + expect(level).toBe(ECameraScaleLevel.Detailed); + }); + }); + + test.describe("custom strategy (defined in browser)", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/base.html"); + await page.waitForFunction(() => window.graphLibraryLoaded === true); + + await page.evaluate(() => { + const { Graph, ECameraScaleLevel } = window.GraphModule; + const rootEl = document.getElementById("root"); + if (!rootEl) { + throw new Error("Root element not found"); + } + const blocks = [ + { + id: "block-1", + is: "Block" as const, + x: 100, + y: 100, + width: 200, + height: 100, + name: "Block 1", + anchors: [], + selected: false, + }, + ]; + const graph = new Graph( + { + blocks, + connections: [], + settings: { + getCameraBlockScaleLevel: () => ECameraScaleLevel.Detailed, + }, + }, + rootEl, + ); + graph.start(); + graph.zoomTo("center"); + window.graph = graph; + window.graphInitialized = true; + }); + + await page.waitForFunction(() => window.graphInitialized === true); + const graphPO = new GraphPageObject(page); + await graphPO.waitForFrames(3); + }); + + test("should return Detailed at low scale when default would be Minimalistic", async ({ page }) => { + const graphPO = new GraphPageObject(page); + const camera = graphPO.getCamera(); + + await camera.zoomToScale(0.05); + await graphPO.waitForFrames(3); + + const level = await page.evaluate(() => window.graph.cameraService.getCameraBlockScaleLevel()); + expect(level).toBe(ECameraScaleLevel.Detailed); + + const notDefault = await page.evaluate(() => { + const { defaultGetCameraBlockScaleLevel } = window.GraphModule; + return ( + window.graph.rootStore.settings.$settings.value.getCameraBlockScaleLevel !== + defaultGetCameraBlockScaleLevel + ); + }); + expect(notDefault).toBe(true); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 63efb114..429c1501 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,11 @@ export type { TResolveWheelDevice } from "./utils/functions/isTrackpadDetector"; export { defaultResolveWheelDevice, EWheelDeviceKind } from "./utils/functions/isTrackpadDetector"; export { type UnwrapGraphEventsDetail, type SelectionEvent } from "./graphEvents"; export * from "./plugins"; -export { ECameraScaleLevel } from "./services/camera/CameraService"; +export { + defaultGetCameraBlockScaleLevel, + ECameraScaleLevel, + type TGetCameraBlockScaleLevel, +} from "./services/camera/CameraService"; export * from "./services/Layer"; export * from "./store"; export { EAnchorType } from "./store/anchor/Anchor"; diff --git a/src/services/camera/CameraService.ts b/src/services/camera/CameraService.ts index 5034e8fa..fa191d57 100644 --- a/src/services/camera/CameraService.ts +++ b/src/services/camera/CameraService.ts @@ -5,6 +5,11 @@ import { Emitter } from "../../utils/Emitter"; import { clamp } from "../../utils/functions/clamp"; import { TRect } from "../../utils/types/shapes"; +import { ECameraScaleLevel } from "./cameraScaleEnums"; + +export { ECameraScaleLevel } from "./cameraScaleEnums"; +export { defaultGetCameraBlockScaleLevel } from "./defaultGetCameraBlockScaleLevel"; + export type TCameraState = { x: number; y: number; @@ -34,11 +39,7 @@ export type TCameraState = { autoPanningEnabled: boolean; }; -export enum ECameraScaleLevel { - Minimalistic = 100, - Schematic = 200, - Detailed = 300, -} +export type { TGetCameraBlockScaleLevel } from "./defaultGetCameraBlockScaleLevel"; export const getInitCameraState = (): TCameraState => { return { @@ -157,17 +158,16 @@ export class CameraService extends Emitter { return this.state.scale; } - public getCameraBlockScaleLevel(cameraScale = this.getCameraScale()) { - const scales = this.graph.graphConstants.block.SCALES; - let scaleLevel = ECameraScaleLevel.Minimalistic; - if (cameraScale >= scales[1]) { - scaleLevel = ECameraScaleLevel.Schematic; - } - if (cameraScale >= scales[2]) { - scaleLevel = ECameraScaleLevel.Detailed; - } - - return scaleLevel; + /** + * Qualitative zoom tier for blocks. Delegates to `settings.getCameraBlockScaleLevel` (always set; defaults to + * the exported `defaultGetCameraBlockScaleLevel` strategy). + * @param cameraScale Optional scale override; defaults to current camera scale + */ + public getCameraBlockScaleLevel(cameraScale = this.getCameraScale()): ECameraScaleLevel { + return this.graph.rootStore.settings.$settings.value.getCameraBlockScaleLevel(this.graph, { + ...this.state, + scale: cameraScale, + }); } public getCameraState(): TCameraState { diff --git a/src/services/camera/cameraScaleEnums.ts b/src/services/camera/cameraScaleEnums.ts new file mode 100644 index 00000000..3d49a36c --- /dev/null +++ b/src/services/camera/cameraScaleEnums.ts @@ -0,0 +1,5 @@ +export enum ECameraScaleLevel { + Minimalistic = 100, + Schematic = 200, + Detailed = 300, +} diff --git a/src/services/camera/defaultGetCameraBlockScaleLevel.test.ts b/src/services/camera/defaultGetCameraBlockScaleLevel.test.ts new file mode 100644 index 00000000..81cb86c9 --- /dev/null +++ b/src/services/camera/defaultGetCameraBlockScaleLevel.test.ts @@ -0,0 +1,67 @@ +import type { Graph } from "../../graph"; + +import { getInitCameraState } from "./CameraService"; +import type { TCameraState } from "./CameraService"; +import { ECameraScaleLevel } from "./cameraScaleEnums"; +import { defaultGetCameraBlockScaleLevel } from "./defaultGetCameraBlockScaleLevel"; + +function createGraphWithBlockScales(scales: [number, number, number]): Graph { + return { + graphConstants: { + block: { SCALES: scales }, + }, + } as unknown as Graph; +} + +function cameraStateWithScale(scale: number): TCameraState { + return { ...getInitCameraState(), scale }; +} + +describe("defaultGetCameraBlockScaleLevel", () => { + describe("uses block.SCALES[1] and block.SCALES[2] as thresholds", () => { + const SCALES: [number, number, number] = [0.01, 0.2, 0.6]; + const graph = createGraphWithBlockScales(SCALES); + const [, s1, s2] = SCALES; + + it("returns Minimalistic when scale < SCALES[1]", () => { + expect(defaultGetCameraBlockScaleLevel(graph, cameraStateWithScale(s1 - 1e-6))).toBe( + ECameraScaleLevel.Minimalistic + ); + expect(defaultGetCameraBlockScaleLevel(graph, cameraStateWithScale(0.05))).toBe(ECameraScaleLevel.Minimalistic); + }); + + it("returns Schematic when scale >= SCALES[1] and scale < SCALES[2]", () => { + expect(defaultGetCameraBlockScaleLevel(graph, cameraStateWithScale(s1))).toBe(ECameraScaleLevel.Schematic); + expect(defaultGetCameraBlockScaleLevel(graph, cameraStateWithScale((s1 + s2) / 2))).toBe( + ECameraScaleLevel.Schematic + ); + expect(defaultGetCameraBlockScaleLevel(graph, cameraStateWithScale(s2 - 1e-6))).toBe(ECameraScaleLevel.Schematic); + }); + + it("returns Detailed when scale >= SCALES[2]", () => { + expect(defaultGetCameraBlockScaleLevel(graph, cameraStateWithScale(s2))).toBe(ECameraScaleLevel.Detailed); + expect(defaultGetCameraBlockScaleLevel(graph, cameraStateWithScale(1))).toBe(ECameraScaleLevel.Detailed); + }); + + it("does not use SCALES[0] for tier resolution (only [1] and [2])", () => { + const graphLowS0 = createGraphWithBlockScales([0.99, s1, s2]); + const graphHighS0 = createGraphWithBlockScales([0.001, s1, s2]); + const state = cameraStateWithScale(0.1); + expect(defaultGetCameraBlockScaleLevel(graphLowS0, state)).toBe( + defaultGetCameraBlockScaleLevel(graphHighS0, state) + ); + }); + }); + + describe("matches initGraphConstants.block.SCALES", () => { + it("uses library default SCALES [0.125, 0.225, 0.7] semantics", async () => { + const { initGraphConstants } = await import("../../graphConfig"); + const graph = createGraphWithBlockScales(initGraphConstants.block.SCALES); + + expect(defaultGetCameraBlockScaleLevel(graph, cameraStateWithScale(0.1))).toBe(ECameraScaleLevel.Minimalistic); + expect(defaultGetCameraBlockScaleLevel(graph, cameraStateWithScale(0.225))).toBe(ECameraScaleLevel.Schematic); + expect(defaultGetCameraBlockScaleLevel(graph, cameraStateWithScale(0.5))).toBe(ECameraScaleLevel.Schematic); + expect(defaultGetCameraBlockScaleLevel(graph, cameraStateWithScale(0.7))).toBe(ECameraScaleLevel.Detailed); + }); + }); +}); diff --git a/src/services/camera/defaultGetCameraBlockScaleLevel.ts b/src/services/camera/defaultGetCameraBlockScaleLevel.ts new file mode 100644 index 00000000..0ee24515 --- /dev/null +++ b/src/services/camera/defaultGetCameraBlockScaleLevel.ts @@ -0,0 +1,28 @@ +import type { Graph } from "../../graph"; + +import type { TCameraState } from "./CameraService"; +import { ECameraScaleLevel } from "./cameraScaleEnums"; + +/** + * Resolves qualitative zoom tier for block rendering (Canvas detail level, React activation, etc.). + * Override via graph settings `getCameraBlockScaleLevel`. + */ +export type TGetCameraBlockScaleLevel = (graph: Graph, cameraState: TCameraState) => ECameraScaleLevel; + +/** + * Default block zoom-tier strategy: uses `graphConstants.block.SCALES` thresholds. + * Same function reference as `DefaultSettings.getCameraBlockScaleLevel` and the default `getCameraBlockScaleLevel` + * in graph settings — use this export when you need the built-in strategy by identity (e.g. `===` or explicit config). + */ +export function defaultGetCameraBlockScaleLevel(graph: Graph, cameraState: TCameraState): ECameraScaleLevel { + const scales = graph.graphConstants.block.SCALES; + const cameraScale = cameraState.scale; + let scaleLevel = ECameraScaleLevel.Minimalistic; + if (cameraScale >= scales[1]) { + scaleLevel = ECameraScaleLevel.Schematic; + } + if (cameraScale >= scales[2]) { + scaleLevel = ECameraScaleLevel.Detailed; + } + return scaleLevel; +} diff --git a/src/store/settings.ts b/src/store/settings.ts index 0b71eca9..374b0c6c 100644 --- a/src/store/settings.ts +++ b/src/store/settings.ts @@ -4,6 +4,8 @@ import cloneDeep from "lodash/cloneDeep"; import type { Block, TBlock } from "../components/canvas/blocks/Block"; import { BlockConnection } from "../components/canvas/connections/BlockConnection"; import { Component } from "../lib"; +import { defaultGetCameraBlockScaleLevel } from "../services/camera/defaultGetCameraBlockScaleLevel"; +import type { TGetCameraBlockScaleLevel } from "../services/camera/defaultGetCameraBlockScaleLevel"; import type { EWheelDeviceKind, TResolveWheelDevice } from "../utils/functions/isTrackpadDetector"; import { defaultResolveWheelDevice } from "../utils/functions/isTrackpadDetector"; @@ -70,6 +72,11 @@ export type TGraphSettingsConfig) { - this.$settings.value = Object.assign({}, this.$settings.value, config); + const merged = Object.assign({}, this.$settings.value, config); + this.$settings.value = { + ...merged, + getCameraBlockScaleLevel: merged.getCameraBlockScaleLevel ?? defaultGetCameraBlockScaleLevel, + }; } public setConfigFlag(flagPath: K, value: TGraphSettingsConfig[K]) { From d40e8714c28f62c578ccb013e77d9ea176b43d84 Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 20 Mar 2026 16:54:26 +0300 Subject: [PATCH 2/2] ... --- src/services/camera/defaultGetCameraBlockScaleLevel.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/camera/defaultGetCameraBlockScaleLevel.test.ts b/src/services/camera/defaultGetCameraBlockScaleLevel.test.ts index 81cb86c9..e28dbc5f 100644 --- a/src/services/camera/defaultGetCameraBlockScaleLevel.test.ts +++ b/src/services/camera/defaultGetCameraBlockScaleLevel.test.ts @@ -1,4 +1,5 @@ import type { Graph } from "../../graph"; +import { initGraphConstants } from "../../graphConfig"; import { getInitCameraState } from "./CameraService"; import type { TCameraState } from "./CameraService"; @@ -55,7 +56,6 @@ describe("defaultGetCameraBlockScaleLevel", () => { describe("matches initGraphConstants.block.SCALES", () => { it("uses library default SCALES [0.125, 0.225, 0.7] semantics", async () => { - const { initGraphConstants } = await import("../../graphConfig"); const graph = createGraphWithBlockScales(initGraphConstants.block.SCALES); expect(defaultGetCameraBlockScaleLevel(graph, cameraStateWithScale(0.1))).toBe(ECameraScaleLevel.Minimalistic);