diff --git a/.changeset/canvas-viewport-persistence.md b/.changeset/canvas-viewport-persistence.md new file mode 100644 index 00000000000..293b38c3f81 --- /dev/null +++ b/.changeset/canvas-viewport-persistence.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Remember the canvas viewport per net, so switching between nets or reloading returns to the same position and zoom. diff --git a/libs/@hashintel/petrinaut/docs/drawing-a-net.md b/libs/@hashintel/petrinaut/docs/drawing-a-net.md index 9e6363a8c21..fedd5733889 100644 --- a/libs/@hashintel/petrinaut/docs/drawing-a-net.md +++ b/libs/@hashintel/petrinaut/docs/drawing-a-net.md @@ -117,6 +117,8 @@ The editor has two cursor modes, toggled from the bottom toolbar dropdown: | **Pan** | H | Click and drag to pan the canvas. This is the default. | | **Select** | V | Click and drag to draw a selection box around nodes. | +The canvas remembers where you left each net. Switching to another net and back, or reloading the app, brings back the same position and zoom; a net you open for the first time is fitted to the screen. + With a selection, you can: - **Move** -- drag selected nodes to reposition them. diff --git a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx index db71698d40b..3ff0553f115 100644 --- a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx @@ -24,6 +24,7 @@ import { PlaybackProvider } from "./playback/provider"; import { SDCPNProvider } from "./sdcpn-provider"; import { SimulationProvider } from "./simulation/provider"; import { ActiveNetProvider } from "./state/active-net-provider"; +import { CanvasViewportProvider } from "./state/canvas-viewport-provider"; import { EditorProvider } from "./state/editor-provider"; import { UndoRedoContext } from "./state/undo-redo-context"; import { UserSettingsProvider } from "./state/user-settings-provider"; @@ -89,24 +90,26 @@ export const PetrinautProvider: React.FC = ({ {/* Above SimulationProvider: the simulation provider reads the Ad-hoc scenarios setting to gate the inline definition. */} - - - - - - - - {children} - - - - - - - + + + + + + + + + {children} + + + + + + + + diff --git a/libs/@hashintel/petrinaut/src/react/state/canvas-viewport-context.ts b/libs/@hashintel/petrinaut/src/react/state/canvas-viewport-context.ts new file mode 100644 index 00000000000..b62f6dcc4bc --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/state/canvas-viewport-context.ts @@ -0,0 +1,34 @@ +import { createContext } from "react"; + +/** Where the canvas looks: screen = scene × zoom + (x, y), in canvas pixels. */ +export type CanvasViewport = { x: number; y: number; zoom: number }; + +/** + * A viewport as it is persisted. The stamp is what orders the entries for the + * cap: object key order does not, because a document id that reads as an + * integer is enumerated numerically rather than in insertion order. + */ +export type SavedCanvasViewport = CanvasViewport & { + /** Absent on entries written before viewports carried a stamp. */ + savedAt?: number; +}; + +export type CanvasViewportContextValue = { + /** + * The viewport last saved for the active document, or null when the + * document has never been viewed. Renderers read it when they mount and fit + * the net when it is null. + */ + savedViewport: CanvasViewport | null; + /** + * Saves the viewport for the active document. Renderers call it once a move + * has settled, so each call is one write and a reload straight after a move + * still comes back to it. + */ + rememberViewport: (viewport: CanvasViewport) => void; +}; + +export const CanvasViewportContext = createContext({ + savedViewport: null, + rememberViewport: () => {}, +}); diff --git a/libs/@hashintel/petrinaut/src/react/state/canvas-viewport-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/canvas-viewport-provider.tsx new file mode 100644 index 00000000000..47c7bdb2f33 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/state/canvas-viewport-provider.tsx @@ -0,0 +1,41 @@ +import { use, type FC, type PropsWithChildren } from "react"; + +import { + CanvasViewportContext, + type CanvasViewport, +} from "./canvas-viewport-context"; +import { SDCPNContext } from "./sdcpn-context"; +import { UserSettingsContext } from "./user-settings-context"; + +/** + * Keeps the canvas viewport per document in the user settings, so a net + * reopens where it was left, whether after switching documents or reloading. + * + * Every report is written as it arrives. Renderers report a settled viewport + * rather than each frame of a gesture, so there is nothing to coalesce here, + * and nothing left pending to lose when the page goes away. + */ +export const CanvasViewportProvider: FC = ({ children }) => { + const { petriNetId } = use(SDCPNContext); + const { canvasViewports, setCanvasViewport } = use(UserSettingsContext); + + const rememberViewport = (viewport: CanvasViewport) => { + if (!petriNetId) { + return; + } + setCanvasViewport(petriNetId, viewport); + }; + + return ( + + {children} + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts index 46169f18fd6..a8cdf2d7cf6 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts @@ -6,6 +6,10 @@ import { DEFAULT_PROPERTIES_PANEL_WIDTH, } from "./panel-defaults"; +import type { + CanvasViewport, + SavedCanvasViewport, +} from "./canvas-viewport-context"; import type { BottomPanelTab, CursorMode, @@ -91,6 +95,8 @@ export type UserSettings = { */ enableOptimizationSurface: boolean; subViewPanels: SubViewPanelsSettings; + /** Where each document's canvas was last left, keyed by document id. */ + canvasViewports: Record; }; export type UserSettingsActions = { @@ -123,6 +129,7 @@ export type UserSettingsActions = { sectionId: string, update: Partial, ) => void; + setCanvasViewport: (petriNetId: string, viewport: CanvasViewport) => void; }; export type UserSettingsContextValue = UserSettings & UserSettingsActions; @@ -153,6 +160,7 @@ export const defaultUserSettings: UserSettings = { enableParameterSweeps: false, enableOptimizationSurface: false, subViewPanels: {}, + canvasViewports: {}, }; const DEFAULT_CONTEXT_VALUE: UserSettingsContextValue = { @@ -182,6 +190,7 @@ const DEFAULT_CONTEXT_VALUE: UserSettingsContextValue = { setEnableParameterSweeps: () => {}, setEnableOptimizationSurface: () => {}, updateSubViewSection: () => {}, + setCanvasViewport: () => {}, }; export const UserSettingsContext = createContext( diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx index 86b490e7a2f..711b3ff67a9 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx @@ -4,7 +4,9 @@ import { defaultUserSettings, UserSettingsContext, } from "./user-settings-context"; +import { rememberCanvasViewport } from "./user-settings-provider/remember-canvas-viewport"; +import type { CanvasViewport } from "./canvas-viewport-context"; import type { BottomPanelTab, CursorMode, @@ -112,6 +114,19 @@ export const UserSettingsProvider: React.FC = ({ setState((prev) => ({ ...prev, enableParameterSweeps: value })), setEnableOptimizationSurface: (value: boolean) => setState((prev) => ({ ...prev, enableOptimizationSurface: value })), + setCanvasViewport: (petriNetId: string, viewport: CanvasViewport) => { + // Stamped out here: an updater runs more than once and has to be pure. + const savedAt = Date.now(); + setState((prev) => ({ + ...prev, + canvasViewports: rememberCanvasViewport( + prev.canvasViewports, + petriNetId, + viewport, + savedAt, + ), + })); + }, updateSubViewSection: ( containerName: string, sectionId: string, diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider/remember-canvas-viewport.test.ts b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider/remember-canvas-viewport.test.ts new file mode 100644 index 00000000000..c467a99cf74 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider/remember-canvas-viewport.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { rememberCanvasViewport } from "./remember-canvas-viewport"; + +const viewport = (zoom: number) => ({ x: 0, y: 0, zoom }); +const saved = (zoom: number, savedAt: number) => ({ + ...viewport(zoom), + savedAt, +}); + +describe("rememberCanvasViewport", () => { + it("adds and replaces the viewport of a document", () => { + const once = rememberCanvasViewport({}, "a", viewport(1), 10); + expect(once).toEqual({ a: saved(1, 10) }); + expect(rememberCanvasViewport(once, "a", viewport(2), 20)).toEqual({ + a: saved(2, 20), + }); + }); + + it("keeps the other documents and stamps the saved one", () => { + expect( + rememberCanvasViewport( + { a: saved(1, 10), b: saved(2, 20) }, + "a", + viewport(3), + 30, + ), + ).toEqual({ b: saved(2, 20), a: saved(3, 30) }); + }); + + it("drops the least recently saved documents past the limit", () => { + const result = rememberCanvasViewport( + { a: saved(1, 30), b: saved(2, 10), c: saved(3, 20) }, + "d", + viewport(4), + 40, + 3, + ); + expect(Object.keys(result).sort()).toEqual(["a", "c", "d"]); + }); + + it("evicts by save time when document ids read as integers", () => { + const result = rememberCanvasViewport( + { 1: saved(1, 30), 2: saved(2, 10) }, + "3", + viewport(3), + 40, + 2, + ); + expect(Object.keys(result).sort()).toEqual(["1", "3"]); + }); + + it("treats entries saved before stamping as the oldest", () => { + const result = rememberCanvasViewport( + { unstamped: viewport(1), recent: saved(2, 50) }, + "current", + viewport(3), + 60, + 2, + ); + expect(Object.keys(result).sort()).toEqual(["current", "recent"]); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider/remember-canvas-viewport.ts b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider/remember-canvas-viewport.ts new file mode 100644 index 00000000000..f8c024d6088 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider/remember-canvas-viewport.ts @@ -0,0 +1,30 @@ +import type { + CanvasViewport, + SavedCanvasViewport, +} from "../canvas-viewport-context"; + +/** How many documents keep a saved viewport before the oldest is dropped. */ +export const rememberedViewportLimit = 50; + +const savedAtOf = (entry: SavedCanvasViewport) => entry.savedAt ?? 0; + +/** + * The viewports record with `viewport` stamped and saved for `petriNetId`, + * capped so settings do not grow with every net ever opened. The least + * recently saved entries go first, read off the stamps: key order cannot say + * which those are, because JavaScript enumerates integer-like keys numerically + * and a document id is an unrestricted string. + */ +export const rememberCanvasViewport = ( + viewports: Record, + petriNetId: string, + viewport: CanvasViewport, + savedAt: number, + limit = rememberedViewportLimit, +): Record => { + const others = Object.entries(viewports) + .filter(([id]) => id !== petriNetId) + .sort(([, first], [, second]) => savedAtOf(first) - savedAtOf(second)); + const kept = others.slice(Math.max(0, others.length - (limit - 1))); + return Object.fromEntries([...kept, [petriNetId, { ...viewport, savedAt }]]); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/lib/viewport.test.ts b/libs/@hashintel/petrinaut/src/ui/lib/viewport.test.ts deleted file mode 100644 index 5b76dbda623..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/lib/viewport.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - getInitialViewport, - MAX_FIT_ZOOM, - recenterToFitViewport, -} from "./viewport"; - -import type { NodeType } from "../views/SDCPN/reactflow-types"; - -/** Nodes are positioned by their center point (`nodeOrigin` [0.5, 0.5]). */ -const makeNode = ( - centerX: number, - centerY: number, - width: number, - height: number, -) => - ({ - id: `node-${centerX}-${centerY}`, - position: { x: centerX, y: centerY }, - data: {}, - width, - height, - measured: { width, height }, - }) as NodeType; - -const viewport = { x: 0, y: 0, width: 500, height: 400 }; - -describe("recenterToFitViewport", () => { - it("returns undefined when nodes are fully inside viewport", () => { - const nodes = [makeNode(100, 90, 100, 80)]; - expect(recenterToFitViewport(viewport, nodes)).toBeUndefined(); - }); - - it("returns undefined when there are no nodes", () => { - expect(recenterToFitViewport(viewport, [])).toBeUndefined(); - }); - - it("returns adjustment when nodes overflow to the right", () => { - const nodes = [makeNode(500, 90, 100, 80)]; - // Node right edge is 550, viewport right is 500 → overflow right by 50 - const result = recenterToFitViewport(viewport, nodes); - expect(result).toBeDefined(); - expect(result!.x).toBe(50); - expect(result!.y).toBe(0); - }); - - it("returns adjustment when nodes overflow to the left", () => { - const nodes = [makeNode(-20, 90, 20, 80)]; - // Node left edge is -30, viewport left is 0 → overflow left by 30 - const result = recenterToFitViewport(viewport, nodes); - expect(result).toBeDefined(); - expect(result!.x).toBe(-30); - expect(result!.y).toBe(0); - }); - - it("returns adjustment when nodes overflow the bottom", () => { - const nodes = [makeNode(90, 400, 80, 100)]; - // Node bottom edge is 450, viewport bottom is 400 → overflow bottom by 50 - const result = recenterToFitViewport(viewport, nodes); - expect(result).toBeDefined(); - expect(result!.x).toBe(0); - expect(result!.y).toBe(50); - }); - - it("returns adjustment when nodes overflow the top", () => { - const nodes = [makeNode(90, -30, 80, 20)]; - // Node top edge is -40, viewport top is 0 → overflow top by 40 - const result = recenterToFitViewport(viewport, nodes); - expect(result).toBeDefined(); - expect(result!.x).toBe(0); - expect(result!.y).toBe(-40); - }); - - it("returns adjustment for diagonal overflow (right + bottom)", () => { - const nodes = [makeNode(470, 380, 100, 100)]; - // Right overflow: 520-500=20, Bottom overflow: 430-400=30 - const result = recenterToFitViewport(viewport, nodes); - expect(result).toBeDefined(); - expect(result!.x).toBe(20); - expect(result!.y).toBe(30); - }); - - it("returns undefined when nodes are too large to fit", () => { - const nodes = [makeNode(300, 250, 600, 500)]; - // 600 > 500 width, 500 > 400 height — can't fit - expect(recenterToFitViewport(viewport, nodes)).toBeUndefined(); - }); - - it("returns undefined when nodes exactly match viewport size", () => { - // canFitInViewport uses strict <, so equal size means it can't fit - const nodes = [makeNode(240, 190, 500, 400)]; - expect(recenterToFitViewport(viewport, nodes)).toBeUndefined(); - }); - - it("handles multiple nodes whose combined bounds overflow", () => { - const nodes = [makeNode(0, 70, 40, 40), makeNode(495, 70, 30, 40)]; - // Combined bounds: x=-20..510, y=50..90 → width=530 > 500, won't fit - expect(recenterToFitViewport(viewport, nodes)).toBeUndefined(); - }); - - it("handles multiple nodes that fit but are partially offscreen", () => { - const nodes = [makeNode(0, 70, 40, 40), makeNode(215, 70, 30, 40)]; - // Combined bounds: x=-20..230, y=50..90 → width=250, height=40 — fits - // Left overflow: -20 - const result = recenterToFitViewport(viewport, nodes); - expect(result).toBeDefined(); - expect(result!.x).toBe(-20); - expect(result!.y).toBe(0); - }); -}); - -describe("getInitialViewport", () => { - const container = { width: 1000, height: 500 }; - - it("falls back to the origin at zoom 1 when there is nothing to fit", () => { - expect(getInitialViewport(null, container)).toEqual({ - x: 0, - y: 0, - zoom: 1, - }); - expect( - getInitialViewport({ x: 10, y: 10, width: 0, height: 0 }, container), - ).toEqual({ x: 0, y: 0, zoom: 1 }); - }); - - it("centers the bounds in the container", () => { - const bounds = { x: 100, y: 200, width: 4000, height: 1000 }; - const { x, y, zoom } = getInitialViewport(bounds, container); - - const boundsCenterX = bounds.x + bounds.width / 2; - const boundsCenterY = bounds.y + bounds.height / 2; - expect(boundsCenterX * zoom + x).toBeCloseTo(container.width / 2); - expect(boundsCenterY * zoom + y).toBeCloseTo(container.height / 2); - }); - - it("caps the zoom for small nets", () => { - const bounds = { x: 0, y: 0, width: 180, height: 50 }; - expect(getInitialViewport(bounds, container).zoom).toBe(MAX_FIT_ZOOM); - }); - - it("zooms out far enough to show a large net in full", () => { - const bounds = { x: 0, y: 0, width: 10_000, height: 1000 }; - const { x, y, zoom } = getInitialViewport(bounds, container); - - // Every corner of the bounds lands inside the container. - expect(bounds.x * zoom + x).toBeGreaterThanOrEqual(0); - expect(bounds.y * zoom + y).toBeGreaterThanOrEqual(0); - expect((bounds.x + bounds.width) * zoom + x).toBeLessThanOrEqual( - container.width, - ); - expect((bounds.y + bounds.height) * zoom + y).toBeLessThanOrEqual( - container.height, - ); - }); -}); diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx index fff461c9861..c628607d697 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx @@ -1,7 +1,6 @@ import "@fontsource-variable/inter"; import "@fontsource-variable/inter-tight"; import "@fontsource-variable/jetbrains-mono"; -import "@xyflow/react/dist/style.css"; import "./index.css"; import { type FunctionComponent, useEffect, useMemo, useRef } from "react"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx index 9807446739d..c6a2d2e7bf7 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx @@ -8,6 +8,7 @@ import { EditorContext } from "../../../../../react/state/editor-context"; import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../react/state/use-is-read-only"; import { UserSettingsContext } from "../../../../../react/state/user-settings-context"; +import { writeDraggedNodeKind } from "../../../shared/canvas-node-drag"; import { ToolbarButton } from "./toolbar-button"; import { ToolbarDivider } from "./toolbar-divider"; @@ -202,7 +203,7 @@ export const ToolbarModes: React.FC = ({ onDragStart={(event) => { // eslint-disable-next-line no-param-reassign event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("application/reactflow", "place"); + writeDraggedNodeKind(event.dataTransfer, "place"); }} > @@ -216,7 +217,7 @@ export const ToolbarModes: React.FC = ({ onDragStart={(event) => { // eslint-disable-next-line no-param-reassign event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("application/reactflow", "transition"); + writeDraggedNodeKind(event.dataTransfer, "transition"); }} > diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx index 4b1bf84f76e..6bd9572f759 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx @@ -146,6 +146,7 @@ const TestProviders = ({ setShowCompilationOutput: () => {}, setEnableParameterSweeps: () => {}, setEnableOptimizationSurface: () => {}, + setCanvasViewport: () => {}, updateSubViewSection: () => {}, }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-renderer.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-renderer.ts new file mode 100644 index 00000000000..40905368c1a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-renderer.ts @@ -0,0 +1,60 @@ +/** + * The contract between the canvas view and a renderer. A renderer draws a + * {@link CanvasScene}, owns the viewport, and publishes a + * {@link CanvasController} for the shared overlays and hooks around it. + * Interaction semantics live in the shared `useCanvasInteractions`, so a + * renderer only turns its own hit testing and gestures into those calls. + */ + +import { createContext, use } from "react"; + +import type { CanvasViewport } from "../../../react/state/canvas-viewport-context"; +import type { ViewportAction } from "../../types/viewport-action"; +import type { CanvasPoint, CanvasScene } from "./canvas-scene"; +import type { Size } from "@hashintel/petrinaut-core"; + +/** The viewport type is owned by the React layer, where it is persisted. */ +export type { CanvasViewport }; + +export type CanvasController = { + getViewport: () => CanvasViewport; + /** `animate` eases the move when the renderer supports it. */ + setViewport: ( + viewport: CanvasViewport, + options?: { animate?: boolean }, + ) => void; + zoomIn: () => void; + zoomOut: () => void; + /** Client (viewport-relative screen) coordinates to scene coordinates. */ + screenToScene: (point: CanvasPoint) => CanvasPoint; + sceneToScreen: (point: CanvasPoint) => CanvasPoint; +}; + +export const CanvasControllerContext = createContext( + null, +); + +/** The controller of the renderer this component is rendered inside. */ +export const useCanvasController = (): CanvasController => { + const controller = use(CanvasControllerContext); + if (!controller) { + throw new Error( + "useCanvasController must be used inside a canvas renderer", + ); + } + return controller; +}; + +export type CanvasRendererProps = { + scene: CanvasScene; + /** Settled size of the canvas container. */ + containerSize: Size; + /** Extra buttons hosts add to the viewport controls. */ + viewportActions?: ViewportAction[]; +}; + +export type CanvasRenderer = React.FC; + +export const canvasRendererNames = ["react-flow"] as const; + +export type CanvasRendererName = (typeof canvasRendererNames)[number]; diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-renderers.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-renderers.ts new file mode 100644 index 00000000000..331aed8258f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-renderers.ts @@ -0,0 +1,10 @@ +import { ReactFlowCanvas } from "./renderers/react-flow/react-flow-canvas"; + +import type { CanvasRenderer, CanvasRendererName } from "./canvas-renderer"; + +/** Every renderer the canvas view can mount, by name. */ +export const canvasRenderers: Record = { + "react-flow": ReactFlowCanvas, +}; + +export const defaultCanvasRenderer: CanvasRendererName = "react-flow"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-scene.test.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-scene.test.ts new file mode 100644 index 00000000000..54dcaf8d269 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-scene.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; + +import { + compactNodeDimensions, + DEFAULT_PETRINAUT_EXTENSIONS, + generateArcId, + getArcEndpointKey, +} from "@hashintel/petrinaut-core"; + +import { buildCanvasScene, type CanvasSceneInput } from "./canvas-scene"; + +import type { Place, SDCPN, Transition } from "@hashintel/petrinaut-core"; + +const place = (id: string, x: number, y: number): Place => ({ + id, + name: id, + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x, + y, +}); + +const transitionX = 300; +const transitionY = 40; + +const transition = ( + id: string, + inputs: string[], + outputs: string[], +): Transition => ({ + id, + name: id, + inputArcs: inputs.map((placeId) => ({ placeId, weight: 2, type: "read" })), + outputArcs: outputs.map((placeId) => ({ placeId, weight: 1 })), + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: transitionX, + y: transitionY, +}); + +const sdcpn: SDCPN = { + places: [place("p1", 0, 0), place("p2", 600, 0)], + transitions: [transition("t1", ["p1"], ["p2"])], + types: [], + differentialEquations: [], + parameters: [], + componentInstances: [], +}; + +const placeKey = (placeId: string) => + getArcEndpointKey({ kind: "place", placeId }); +const inputArcId = generateArcId({ inputId: placeKey("p1"), outputId: "t1" }); +const outputArcId = generateArcId({ inputId: "t1", outputId: placeKey("p2") }); + +const input: CanvasSceneInput = { + net: { ...sdcpn, componentInstances: [] }, + sdcpn, + extensions: DEFAULT_PETRINAUT_EXTENSIONS, + dimensions: compactNodeDimensions, + draggingStateByNodeId: {}, + isSelected: () => false, + isHovered: () => false, + isDimmed: () => false, +}; + +describe("buildCanvasScene", () => { + it("sizes nodes by kind and centres them on their stored position", () => { + const { nodes } = buildCanvasScene(input); + const p1 = nodes.find((node) => node.id === "p1")!; + const t1 = nodes.find((node) => node.id === "t1")!; + + expect(p1).toMatchObject({ + kind: "place", + position: { x: 0, y: 0 }, + ...compactNodeDimensions.place, + dragging: false, + }); + expect(t1).toMatchObject({ + kind: "transition", + position: { x: transitionX, y: transitionY }, + ...compactNodeDimensions.transition, + }); + }); + + it("follows the drag preview while a node is dragged", () => { + const { nodes } = buildCanvasScene({ + ...input, + draggingStateByNodeId: { + p1: { dragging: true, position: { x: 50, y: 60 } }, + }, + }); + expect(nodes.find((node) => node.id === "p1")).toMatchObject({ + position: { x: 50, y: 60 }, + dragging: true, + }); + }); + + it("carries selection, hover and dimming per item", () => { + const { nodes, arcs } = buildCanvasScene({ + ...input, + isSelected: (id) => id === "p1", + isHovered: (id) => id === "t1", + isDimmed: (id) => id === "p2" || id === outputArcId, + }); + expect(nodes.find((node) => node.id === "p1")?.selected).toBe(true); + expect(nodes.find((node) => node.id === "t1")?.hovered).toBe(true); + expect(nodes.find((node) => node.id === "p2")?.dimmed).toBe(true); + expect(arcs.find((arc) => arc.id === outputArcId)?.dimmed).toBe(true); + expect(arcs.find((arc) => arc.id === inputArcId)?.dimmed).toBe(false); + }); + + it("builds one arc per input and output arc, oriented through the transition", () => { + const { arcs } = buildCanvasScene(input); + expect(arcs).toHaveLength(2); + + const inputArc = arcs.find((arc) => arc.targetId === "t1")!; + expect(inputArc).toMatchObject({ + id: inputArcId, + sourceId: "p1", + transitionId: "t1", + kind: "read", + weight: 2, + sourcePortId: null, + targetPortId: null, + }); + + const outputArc = arcs.find((arc) => arc.sourceId === "t1")!; + expect(outputArc).toMatchObject({ + id: outputArcId, + targetId: "p2", + kind: "standard", + weight: 1, + }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-scene.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-scene.ts new file mode 100644 index 00000000000..3e7739ad17b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-scene.ts @@ -0,0 +1,275 @@ +/** + * The renderer-agnostic picture of the net: the nodes and arcs any canvas + * renderer draws, carrying the interaction state that changes how they look. + * Time-varying simulation state (token counts, firings) stays out of it, so a + * playback frame never rebuilds the scene; renderers read it from the + * execution frame source. + */ + +import { + generateArcId, + getArcEndpoint, + getArcEndpointKey, + getArcEndpointNodeId, + getComponentInstanceHeight, + getEffectiveTransitionLambdaType, + getTransitionLogicAvailability, +} from "@hashintel/petrinaut-core"; + +import { arcStrokeColor } from "./styles/type-colors"; + +import type { ActiveNetDefinition } from "../../../react/state/active-net-context"; +import type { DraggingStateByNodeId } from "../../../react/state/editor-context"; +import type { + ArcEndpoint, + InputArcType, + PetrinautExtensionSettings, + RenderNodeDimensions, + SDCPN, +} from "@hashintel/petrinaut-core"; + +export type CanvasPoint = { x: number; y: number }; + +type CanvasNodeBase = { + id: string; + /** + * Centre of the node in scene coordinates. Follows the drag preview while + * the node is being dragged, and the committed position otherwise. + */ + position: CanvasPoint; + width: number; + height: number; + label: string; + dragging: boolean; + selected: boolean; + hovered: boolean; + /** + * Lightened because it is neither hovered, selected, nor connected to the + * hovered or selected items. + */ + dimmed: boolean; +}; + +export type CanvasPlaceNode = CanvasNodeBase & { + kind: "place"; + dynamicsEnabled: boolean; + hasColorType: boolean; + /** Whether the place defines custom visualizer code. */ + hasVisualizer: boolean; + /** Display colour of the place's token type, when it has one. */ + typeColor: string | undefined; +}; + +export type CanvasTransitionNode = CanvasNodeBase & { + kind: "transition"; + lambdaType: "none" | "predicate" | "stochastic"; +}; + +export type CanvasPort = { id: string; name: string }; + +export type CanvasComponentInstanceNode = CanvasNodeBase & { + kind: "componentInstance"; + subnetName: string; + ports: CanvasPort[]; +}; + +export type CanvasNode = + | CanvasPlaceNode + | CanvasTransitionNode + | CanvasComponentInstanceNode; + +export type CanvasNodeKind = CanvasNode["kind"]; + +export type CanvasArc = { + id: string; + kind: InputArcType; + weight: number; + sourceId: string; + targetId: string; + /** The port place at either end when that end is a component instance. */ + sourcePortId: string | null; + targetPortId: string | null; + /** The transition whose firings animate this arc. */ + transitionId: string; + /** Stroke colour before any dimming is applied. */ + color: string; + selected: boolean; + dimmed: boolean; +}; + +export type CanvasScene = { + nodes: CanvasNode[]; + arcs: CanvasArc[]; + dimensions: RenderNodeDimensions; +}; + +export type CanvasSceneInput = { + net: ActiveNetDefinition; + /** The whole document, for subnet lookups. */ + sdcpn: SDCPN; + extensions: PetrinautExtensionSettings; + dimensions: RenderNodeDimensions; + draggingStateByNodeId: DraggingStateByNodeId; + isSelected: (id: string) => boolean; + isHovered: (id: string) => boolean; + isDimmed: (id: string) => boolean; +}; + +const positionOf = ( + item: { id: string; x: number; y: number }, + draggingStateByNodeId: DraggingStateByNodeId, +): { position: CanvasPoint; dragging: boolean } => { + const draggingState = draggingStateByNodeId[item.id]; + return draggingState?.dragging + ? { position: draggingState.position, dragging: true } + : { position: { x: item.x, y: item.y }, dragging: false }; +}; + +export const buildCanvasScene = ({ + net, + sdcpn, + extensions, + dimensions, + draggingStateByNodeId, + isSelected, + isHovered, + isDimmed, +}: CanvasSceneInput): CanvasScene => { + const interaction = (id: string) => ({ + selected: isSelected(id), + hovered: isHovered(id), + dimmed: isDimmed(id), + }); + + const typeOf = (colorId: string | null) => + extensions.colors && colorId + ? net.types.find((type) => type.id === colorId) + : undefined; + + const nodes: CanvasNode[] = []; + + for (const place of net.places) { + const placeType = typeOf(place.colorId); + nodes.push({ + kind: "place", + id: place.id, + label: place.name, + ...dimensions.place, + ...positionOf(place, draggingStateByNodeId), + ...interaction(place.id), + dynamicsEnabled: + extensions.colors && extensions.dynamics && place.dynamicsEnabled, + hasColorType: (placeType?.elements.length ?? 0) > 0, + hasVisualizer: !!place.visualizerCode, + typeColor: placeType?.displayColor, + }); + } + + for (const transition of net.transitions) { + const logicAvailability = getTransitionLogicAvailability( + transition, + sdcpn, + extensions, + net, + ); + nodes.push({ + kind: "transition", + id: transition.id, + label: transition.name, + ...dimensions.transition, + ...positionOf(transition, draggingStateByNodeId), + ...interaction(transition.id), + lambdaType: logicAvailability.lambda + ? getEffectiveTransitionLambdaType(transition, logicAvailability) + : "none", + }); + } + + for (const instance of net.componentInstances) { + const subnet = (sdcpn.subnets ?? []).find( + ({ id }) => id === instance.subnetId, + ); + const ports = (subnet?.places ?? []) + .filter((place) => place.isPort) + .map((place) => ({ id: place.id, name: place.name })); + nodes.push({ + kind: "componentInstance", + id: instance.id, + label: instance.name, + width: dimensions.componentInstance.width, + height: getComponentInstanceHeight(dimensions, ports.length), + ...positionOf(instance, draggingStateByNodeId), + ...interaction(instance.id), + subnetName: subnet?.name ?? "Unknown subnet", + ports, + }); + } + + const endpointColor = (endpoint: ArcEndpoint): string | undefined => { + if (endpoint.kind === "place") { + const place = net.places.find(({ id }) => id === endpoint.placeId); + return typeOf(place?.colorId ?? null)?.displayColor; + } + const instance = net.componentInstances.find( + ({ id }) => id === endpoint.componentInstanceId, + ); + const subnet = (sdcpn.subnets ?? []).find( + ({ id }) => id === instance?.subnetId, + ); + const port = subnet?.places.find(({ id }) => id === endpoint.portPlaceId); + return extensions.colors && port?.colorId + ? subnet?.types.find((type) => type.id === port.colorId)?.displayColor + : undefined; + }; + + const portId = (endpoint: ArcEndpoint): string | null => + endpoint.kind === "componentPort" ? endpoint.portPlaceId : null; + + const arcs: CanvasArc[] = []; + + for (const transition of net.transitions) { + for (const inputArc of transition.inputArcs) { + const endpoint = getArcEndpoint(inputArc); + const id = generateArcId({ + inputId: getArcEndpointKey(endpoint), + outputId: transition.id, + }); + arcs.push({ + id, + kind: inputArc.type, + weight: inputArc.weight, + sourceId: getArcEndpointNodeId(endpoint), + targetId: transition.id, + sourcePortId: portId(endpoint), + targetPortId: null, + transitionId: transition.id, + color: arcStrokeColor(endpointColor(endpoint)), + selected: isSelected(id), + dimmed: isDimmed(id), + }); + } + + for (const outputArc of transition.outputArcs) { + const endpoint = getArcEndpoint(outputArc); + const id = generateArcId({ + inputId: transition.id, + outputId: getArcEndpointKey(endpoint), + }); + arcs.push({ + id, + kind: "standard", + weight: outputArc.weight, + sourceId: transition.id, + targetId: getArcEndpointNodeId(endpoint), + sourcePortId: null, + targetPortId: portId(endpoint), + transitionId: transition.id, + color: arcStrokeColor(endpointColor(endpoint)), + selected: isSelected(id), + dimmed: isDimmed(id), + }); + } + } + + return { nodes, arcs, dimensions }; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-viewport.test.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-viewport.test.ts new file mode 100644 index 00000000000..ab47e5a3388 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-viewport.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; + +import { + fitViewportToBounds, + getInitialViewport, + MAX_FIT_ZOOM, + recenterToFitViewport, +} from "./canvas-viewport"; + +/** Nodes are positioned by their centre point. */ +const makeNode = ( + centerX: number, + centerY: number, + width: number, + height: number, +) => ({ + position: { x: centerX, y: centerY }, + width, + height, +}); + +const viewport = { x: 0, y: 0, width: 500, height: 400 }; + +describe("recenterToFitViewport", () => { + it("returns undefined when nodes are fully inside viewport", () => { + const nodes = [makeNode(100, 90, 100, 80)]; + expect(recenterToFitViewport(viewport, nodes)).toBeUndefined(); + }); + + it("returns undefined when there are no nodes", () => { + expect(recenterToFitViewport(viewport, [])).toBeUndefined(); + }); + + it("returns adjustment when nodes overflow to the right", () => { + const nodes = [makeNode(500, 90, 100, 80)]; + // Node right edge is 550, viewport right is 500 → overflow right by 50 + const result = recenterToFitViewport(viewport, nodes); + expect(result).toBeDefined(); + expect(result!.x).toBe(50); + expect(result!.y).toBe(0); + }); + + it("returns adjustment when nodes overflow to the left", () => { + const nodes = [makeNode(-20, 90, 20, 80)]; + // Node left edge is -30, viewport left is 0 → overflow left by 30 + const result = recenterToFitViewport(viewport, nodes); + expect(result).toBeDefined(); + expect(result!.x).toBe(-30); + expect(result!.y).toBe(0); + }); + + it("returns adjustment when nodes overflow the bottom", () => { + const nodes = [makeNode(90, 400, 80, 100)]; + // Node bottom edge is 450, viewport bottom is 400 → overflow bottom by 50 + const result = recenterToFitViewport(viewport, nodes); + expect(result).toBeDefined(); + expect(result!.x).toBe(0); + expect(result!.y).toBe(50); + }); + + it("returns adjustment when nodes overflow the top", () => { + const nodes = [makeNode(90, -30, 80, 20)]; + // Node top edge is -40, viewport top is 0 → overflow top by 40 + const result = recenterToFitViewport(viewport, nodes); + expect(result).toBeDefined(); + expect(result!.x).toBe(0); + expect(result!.y).toBe(-40); + }); + + it("returns adjustment for diagonal overflow (right + bottom)", () => { + const nodes = [makeNode(470, 380, 100, 100)]; + // Right overflow: 520-500=20, Bottom overflow: 430-400=30 + const result = recenterToFitViewport(viewport, nodes); + expect(result).toBeDefined(); + expect(result!.x).toBe(20); + expect(result!.y).toBe(30); + }); + + it("returns undefined when the nodes cannot fit in the viewport", () => { + const nodes = [makeNode(0, 0, 100, 100), makeNode(1000, 1000, 100, 100)]; + expect(recenterToFitViewport(viewport, nodes)).toBeUndefined(); + }); +}); + +describe("fitViewportToBounds", () => { + it("centres the bounds and leaves the padding free", () => { + const result = fitViewportToBounds( + { x: 0, y: 0, width: 200, height: 100 }, + { width: 600, height: 600 }, + 0.1, + 10, + 0.5, + ); + // Width limits: 600 / (200 * 1.5) = 2 + expect(result.zoom).toBe(2); + // Bounds centre (100, 50) lands on the container centre (300, 300) + expect(result.x).toBe(300 - 100 * 2); + expect(result.y).toBe(300 - 50 * 2); + }); + + it("clamps the zoom to the given range", () => { + const bounds = { x: 0, y: 0, width: 10, height: 10 }; + const container = { width: 1000, height: 1000 }; + expect(fitViewportToBounds(bounds, container, 0.1, 1.5, 0).zoom).toBe(1.5); + expect( + fitViewportToBounds( + { x: 0, y: 0, width: 10_000, height: 10_000 }, + container, + 0.3, + 1.5, + 0, + ).zoom, + ).toBe(0.3); + }); +}); + +describe("getInitialViewport", () => { + it("returns the origin at zoom 1 when there is nothing to fit", () => { + expect(getInitialViewport(null, { width: 500, height: 400 })).toEqual({ + x: 0, + y: 0, + zoom: 1, + }); + }); + + it("never zooms in past the fit ceiling", () => { + const result = getInitialViewport( + { x: 0, y: 0, width: 10, height: 10 }, + { width: 1000, height: 1000 }, + ); + expect(result.zoom).toBe(MAX_FIT_ZOOM); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/lib/viewport.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-viewport.ts similarity index 51% rename from libs/@hashintel/petrinaut/src/ui/lib/viewport.ts rename to libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-viewport.ts index 8d5c0b3f3a2..3017e4c4f48 100644 --- a/libs/@hashintel/petrinaut/src/ui/lib/viewport.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-viewport.ts @@ -1,4 +1,7 @@ -import { getViewportForBounds } from "@xyflow/react"; +/** + * Viewport arithmetic shared by every renderer: fitting the net into the + * container and keeping selected nodes visible when panels open. + */ import { getBoundsOfCenteredBoxes, @@ -6,36 +9,62 @@ import { ZOOM_PADDING, } from "@hashintel/petrinaut-core"; -import type { NodeType } from "../views/SDCPN/reactflow-types"; +import type { CanvasViewport } from "./canvas-renderer"; +import type { CanvasNode } from "./canvas-scene"; import type { Rect, Size } from "@hashintel/petrinaut-core"; -type Viewport = { - x: number; - y: number; - width: number; - height: number; -}; +/** The part of the scene a viewport shows, in scene coordinates. */ +export type VisibleSceneRect = Rect & { zoom: number }; /** The canvas never zooms in past this when fitting the net into view. */ export const MAX_FIT_ZOOM = 1.1; +const clamp = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, value)); + /** - * The viewport centered on the given net bounds, respecting the same zoom + * The viewport that fits `bounds` into `container`: `padding` is the fraction + * of the bounds left free around them, the zoom is clamped to the given + * range, and the bounds sit centred. + */ +export const fitViewportToBounds = ( + bounds: Rect, + container: Size, + minZoom: number, + maxZoom: number, + padding: number, +): CanvasViewport => { + const zoom = clamp( + Math.min( + container.width / (bounds.width * (1 + padding)), + container.height / (bounds.height * (1 + padding)), + ), + minZoom, + maxZoom, + ); + return { + zoom, + x: container.width / 2 - (bounds.x + bounds.width / 2) * zoom, + y: container.height / 2 - (bounds.y + bounds.height / 2) * zoom, + }; +}; + +/** + * The viewport centred on the given net bounds, respecting the same zoom * limits as the rest of the canvas. Top-left origin at zoom 1 when there is * nothing to fit. */ export const getInitialViewport = ( bounds: Rect | null, container: Size, -): { x: number; y: number; zoom: number } => { +): CanvasViewport => { if (!bounds || bounds.width === 0 || bounds.height === 0) { return { x: 0, y: 0, zoom: 1 }; } - return getViewportForBounds( + return fitViewportToBounds( bounds, - container.width, - container.height, + container, getMinZoomForBounds(bounds, container), MAX_FIT_ZOOM, ZOOM_PADDING, @@ -43,7 +72,7 @@ export const getInitialViewport = ( }; // returns the amount offscreen as a positive integer for each direction -const getOffscreenAmount = (bounds: Rect, viewport: Viewport) => ({ +const getOffscreenAmount = (bounds: Rect, viewport: Rect) => ({ left: Math.max(viewport.x - bounds.x, 0), right: Math.max(bounds.x + bounds.width - (viewport.x + viewport.width), 0), top: Math.max(viewport.y - bounds.y, 0), @@ -53,20 +82,22 @@ const getOffscreenAmount = (bounds: Rect, viewport: Viewport) => ({ ), }); -const isOffscreen = (bounds: Rect, viewport: Viewport) => { +const isOffscreen = (bounds: Rect, viewport: Rect) => { const { left, right, top, bottom } = getOffscreenAmount(bounds, viewport); return left > 0 || right > 0 || top > 0 || bottom > 0; }; -const canFitInViewport = (bounds: Rect, viewport: Viewport) => +const canFitInViewport = (bounds: Rect, viewport: Rect) => bounds.width < viewport.width && bounds.height < viewport.height; -// If looking to recenter an edge you should pass the nodes it connects instead -// Since we don't actually hold the xy coordinates of the edge, this is the best we can do for now without -// either measuring the bounding box in the dom or doing math to plot out the bezier curve +/** + * The scene-space translation that brings `nodes` back into `viewport`, or + * undefined when they are already visible or too large to fit. To recenter an + * arc, pass the nodes it connects: arcs have no stored geometry. + */ export const recenterToFitViewport = ( - viewport: Viewport, - nodes: NodeType[], + viewport: Rect, + nodes: Pick[], ) => { const bounds = getBoundsOfCenteredBoxes(nodes); if (!bounds) return; @@ -77,16 +108,20 @@ export const recenterToFitViewport = ( return { x: left > 0 ? left * -1 : right, y: top > 0 ? top * -1 : bottom }; }; +/** + * The scene rectangle visible through the viewport once the given overlay + * insets (panels covering the canvas edges, in pixels) are removed. + */ export const getViewportRect = ( canvasSize: Size, - viewport: { x: number; y: number; zoom: number }, + viewport: CanvasViewport, overlays: { left?: number; right?: number; top?: number; bottom?: number } = { left: 0, right: 0, top: 0, bottom: 0, }, -) => { +): VisibleSceneRect => { return { width: (canvasSize.width - (overlays.left ?? 0) - (overlays.right ?? 0)) / diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx index 4fd50141285..4f32dac3d42 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx @@ -1,4 +1,3 @@ -import { useReactFlow } from "@xyflow/react"; import { use } from "react"; import { Button } from "@hashintel/ds-components"; @@ -7,6 +6,7 @@ import { cx, css, cva } from "@hashintel/ds-helpers/css"; import { usePetrinautNavigation } from "../../../../react/navigation"; import { EditorContext } from "../../../../react/state/editor-context"; import { PANEL_MARGIN } from "../../../constants/ui"; +import { useCanvasController } from "../canvas-renderer"; import { ViewportSettingsDialog } from "./viewport-settings-dialog"; import type { ViewportAction } from "../../../types/viewport-action"; @@ -45,7 +45,7 @@ export const ViewportControls: React.FC<{ { cause: "user", action: "overlay" }, ); }; - const { zoomIn, zoomOut } = useReactFlow(); + const { zoomIn, zoomOut } = useCanvasController(); const { collapseAllPanels, hasSelection, @@ -77,8 +77,7 @@ export const ViewportControls: React.FC<{ tooltipOptions={{ position: "left" }} iconName="plus" className={blurredBackground} - // eslint-disable-next-line @typescript-eslint/no-misused-promises - onClick={() => zoomIn()} + onClick={zoomIn} />