Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/system/camera.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
127 changes: 127 additions & 0 deletions e2e/tests/camera/camera-block-scale-level.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ export * from "./graph";
export type { TGraphColors, TGraphConstants, TMouseWheelBehavior } from "./graphConfig";
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";
Expand Down
32 changes: 16 additions & 16 deletions src/services/camera/CameraService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions src/services/camera/cameraScaleEnums.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum ECameraScaleLevel {
Minimalistic = 100,
Schematic = 200,
Detailed = 300,
}
67 changes: 67 additions & 0 deletions src/services/camera/defaultGetCameraBlockScaleLevel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Graph } from "../../graph";
import { initGraphConstants } from "../../graphConfig";

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 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);
});
});
});
28 changes: 28 additions & 0 deletions src/services/camera/defaultGetCameraBlockScaleLevel.ts
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 13 additions & 1 deletion src/store/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { TConnection } from "./connection/ConnectionState";

Expand Down Expand Up @@ -61,6 +63,11 @@ export type TGraphSettingsConfig<Block extends TBlock = TBlock, Connection exten
* Default: false
*/
emulateMouseEventsOnCameraChange?: boolean;
/**
* Maps camera state to block zoom tier (minimalistic / schematic / detailed).
* Always set at runtime; `setupSettings` falls back to the exported `defaultGetCameraBlockScaleLevel` when omitted.
*/
getCameraBlockScaleLevel: TGetCameraBlockScaleLevel;
};

export const DefaultSettings: TGraphSettingsConfig = {
Expand All @@ -78,6 +85,7 @@ export const DefaultSettings: TGraphSettingsConfig = {
connectivityComponentOnClickRaise: true,
showConnectionLabels: false,
blockComponents: {},
getCameraBlockScaleLevel: defaultGetCameraBlockScaleLevel,
};

export class GraphEditorSettings {
Expand All @@ -98,7 +106,11 @@ export class GraphEditorSettings {
constructor(public rootStore: RootStore) {}

public setupSettings(config: Partial<TGraphSettingsConfig>) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now setupSettings are called in setupGraph only if there is config.settings

In case it is a graph without settings - there will be no setupSettings call

In this case, will the call to getCameraBlockScaleLevel() be treated as "not a function"?

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<K extends keyof TGraphSettingsConfig>(flagPath: K, value: TGraphSettingsConfig[K]) {
Expand Down
Loading