From 02e59596a1552725ae31f80d019ce832b97be829 Mon Sep 17 00:00:00 2001 From: dave-aa Date: Thu, 2 Oct 2025 10:00:30 -0600 Subject: [PATCH 1/7] add methods to zoom and scale the canvas --- src/application.ts | 18 ++++++++++++++++++ src/camera.ts | 41 ++++++++++++++++++++++++++++++++++++++++- src/canvas.ts | 5 +++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/application.ts b/src/application.ts index a42f4be..4eae06f 100644 --- a/src/application.ts +++ b/src/application.ts @@ -138,6 +138,24 @@ export class Application { return true; } + zoomIn() { + this._scene.camera.zoomIn(); + this.refresh(); + return true; + } + + zoomOut() { + this._scene.camera.zoomOut(); + this.refresh(); + return true; + } + + resetZoom() { + this._scene.camera.resetZoom(); + this.refresh(); + return true; + } + static registerInstance(element: HTMLCanvasElement, app: Application) { element.setAttribute("data-klee-instance", Application.instances.length.toString()); Application.instances.push(app); diff --git a/src/camera.ts b/src/camera.ts index a988389..afbc8d9 100644 --- a/src/camera.ts +++ b/src/camera.ts @@ -5,6 +5,9 @@ export class Camera { private _canvas: Canvas2D; private _position: Vector2; + private _zoom: number = 1.0; + private _minZoom: number = 0.5; + private _maxZoom: number = 2.0; constructor(canvas: Canvas2D) { this._canvas = canvas; @@ -15,8 +18,17 @@ export class Camera { return this._position; } + public get zoom(): number { + return this._zoom; + } + + public set zoom(value: number) { + this._zoom = Math.max(this._minZoom, Math.min(this._maxZoom, value)); + } + prepareViewport() { this._canvas.translate(Math.round(this._position.x), Math.round(this._position.y)); + this._canvas.scale(this._zoom, this._zoom); } moveRelative(value: Vector2) { @@ -24,9 +36,36 @@ export class Camera { } centerAbsolutePosition(value: Vector2) { - this._position = new Vector2( Math.round(value.x + this._canvas.width / 2), Math.round(value.y + this._canvas.height / 2)); } + + zoomIn(factor: number = 1.2) { + const centerX = this._canvas.width / 2; + const centerY = this._canvas.height / 2; + this.zoomAtPoint(factor, centerX, centerY); + } + + zoomOut(factor: number = 0.8) { + const centerX = this._canvas.width / 2; + const centerY = this._canvas.height / 2; + this.zoomAtPoint(factor, centerX, centerY); + } + + private zoomAtPoint(factor: number, centerX: number, centerY: number) { + const oldZoom = this._zoom; + this.zoom = this._zoom * factor; + const actualFactor = this._zoom / oldZoom; + + // Adjust position so zoom appears centered at the specified point + const offsetX = (centerX - this._position.x) * (1 - actualFactor); + const offsetY = (centerY - this._position.y) * (1 - actualFactor); + + this._position = this._position.add(new Vector2(offsetX, offsetY)); + } + + resetZoom() { + this._zoom = 1.0; + } } diff --git a/src/canvas.ts b/src/canvas.ts index 738b909..23786c5 100644 --- a/src/canvas.ts +++ b/src/canvas.ts @@ -126,6 +126,11 @@ export class Canvas2D { return this; } + scale(x: number, y: number) { + this._context.scale(x, y); + return this; + } + fillRect(x: number, y: number, width: number, height: number) { this._context.fillRect(x, y, width, height); return this; From f309b4988bd489dcf9edd06e27199b39854819ac Mon Sep 17 00:00:00 2001 From: dave-aa Date: Mon, 6 Oct 2025 14:56:20 -0600 Subject: [PATCH 2/7] camera - add functions to check min/max zoom --- src/camera.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/camera.ts b/src/camera.ts index afbc8d9..00c1f30 100644 --- a/src/camera.ts +++ b/src/camera.ts @@ -26,6 +26,14 @@ export class Camera { this._zoom = Math.max(this._minZoom, Math.min(this._maxZoom, value)); } + public get isAtMinZoom(): boolean { + return this._zoom <= this._minZoom; + } + + public get isAtMaxZoom(): boolean { + return this._zoom >= this._maxZoom; + } + prepareViewport() { this._canvas.translate(Math.round(this._position.x), Math.round(this._position.y)); this._canvas.scale(this._zoom, this._zoom); From 874df536729848ae83d392ab74e6d0830cf355f4 Mon Sep 17 00:00:00 2001 From: dave-aa Date: Wed, 8 Oct 2025 09:51:23 -0600 Subject: [PATCH 3/7] expose app in global scope so callers can find it and call zoom functions --- src/application.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/application.ts b/src/application.ts index 4eae06f..47bb817 100644 --- a/src/application.ts +++ b/src/application.ts @@ -186,3 +186,9 @@ export class Application { return app; } } + +// Export Application to global scope for external controls +if (window) { + (window as any).KleeApplication = Application; +} + From 006a38402b4620008e037d320bc01a99db8fa98d Mon Sep 17 00:00:00 2001 From: dave-aa Date: Thu, 16 Oct 2025 11:09:43 -0600 Subject: [PATCH 4/7] add type definition for KleeApplication on the global window --- src/application.ts | 2 +- src/types/window.d.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 src/types/window.d.ts diff --git a/src/application.ts b/src/application.ts index 47bb817..98ef9fa 100644 --- a/src/application.ts +++ b/src/application.ts @@ -189,6 +189,6 @@ export class Application { // Export Application to global scope for external controls if (window) { - (window as any).KleeApplication = Application; + window.KleeApplication = Application; } diff --git a/src/types/window.d.ts b/src/types/window.d.ts new file mode 100644 index 0000000..bc345cd --- /dev/null +++ b/src/types/window.d.ts @@ -0,0 +1,14 @@ +/** + * Type definitions for window globals + */ + +import { Application } from '../application'; + +declare global { + interface Window { + KleeApplication: typeof Application; + } +} + +export {}; + From 3fa75e5bd98491405669f86ee4296f4484c5d22f Mon Sep 17 00:00:00 2001 From: dave-aa Date: Mon, 6 Oct 2025 14:56:40 -0600 Subject: [PATCH 5/7] camera - set starting position --- src/camera.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/camera.ts b/src/camera.ts index 00c1f30..2fbf1da 100644 --- a/src/camera.ts +++ b/src/camera.ts @@ -5,6 +5,7 @@ export class Camera { private _canvas: Canvas2D; private _position: Vector2; + private _startingPosition: Vector2; private _zoom: number = 1.0; private _minZoom: number = 0.5; private _maxZoom: number = 2.0; @@ -12,6 +13,7 @@ export class Camera { constructor(canvas: Canvas2D) { this._canvas = canvas; this._position = new Vector2(0, 0); + this._startingPosition = new Vector2(0, 0); } public get position(): Vector2 { @@ -49,6 +51,12 @@ export class Camera { Math.round(value.y + this._canvas.height / 2)); } + positionAt(value: Vector2) { + this._position = new Vector2( + Math.round(value.x), + Math.round(value.y)); + } + zoomIn(factor: number = 1.2) { const centerX = this._canvas.width / 2; const centerY = this._canvas.height / 2; @@ -73,7 +81,13 @@ export class Camera { this._position = this._position.add(new Vector2(offsetX, offsetY)); } + setStartingPosition(position: Vector2) { + this._startingPosition = position.copy(); + this._position = position.copy(); + } + resetZoom() { this._zoom = 1.0; + this._position = this._startingPosition.copy(); } } From e9824894b01a050c4246dcefea1284ed9a2404b0 Mon Sep 17 00:00:00 2001 From: dave-aa Date: Mon, 6 Oct 2025 15:43:06 -0600 Subject: [PATCH 6/7] start and return to top left of blueprint --- src/application.ts | 18 +++++++++++++++++- src/camera.ts | 36 ++++++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/application.ts b/src/application.ts index 98ef9fa..6aebadc 100644 --- a/src/application.ts +++ b/src/application.ts @@ -2,6 +2,7 @@ import { Canvas2D } from "./canvas"; import { Controller } from "./controller"; import { BlueprintParser } from "./parser/blueprint-parser"; import { Scene } from "./scene"; +import { StartingPositionType } from "./camera"; export class Application { @@ -127,7 +128,14 @@ export class Application { this._scene.load(nodes); this.refresh(); - this.recenterCamera(); + const cameraPosition = this._element.getAttribute('data-camera-position'); + if (cameraPosition && cameraPosition === 'top-left') { + this._scene.camera.startingPosition = StartingPositionType.TOP_LEFT; + this.positionCameraTopLeft(); + } + else { + this.recenterCamera(); + } } @@ -138,6 +146,14 @@ export class Application { return true; } + positionCameraTopLeft() { + const topLeftPoint = this._scene.calculateTopLeftPoint(); + this._scene.camera.positionAt(topLeftPoint); + this._scene.camera.setStartingPosition(topLeftPoint); + this.refresh(); + return true; + } + zoomIn() { this._scene.camera.zoomIn(); this.refresh(); diff --git a/src/camera.ts b/src/camera.ts index 2fbf1da..111fa5b 100644 --- a/src/camera.ts +++ b/src/camera.ts @@ -1,6 +1,11 @@ import { Canvas2D } from "./canvas"; import { Vector2 } from "./math/vector2"; +export const StartingPositionType = { + CENTER: "center", + TOP_LEFT: "top-left" +}; + export class Camera { private _canvas: Canvas2D; @@ -9,6 +14,7 @@ export class Camera { private _zoom: number = 1.0; private _minZoom: number = 0.5; private _maxZoom: number = 2.0; + private _startingPositionType = StartingPositionType.CENTER; constructor(canvas: Canvas2D) { this._canvas = canvas; @@ -20,6 +26,14 @@ export class Camera { return this._position; } + public get startingPosition(): string { + return this._startingPositionType; + } + + public set startingPosition(position: string) { + this._startingPositionType = position; + } + public get zoom(): number { return this._zoom; } @@ -58,15 +72,25 @@ export class Camera { } zoomIn(factor: number = 1.2) { - const centerX = this._canvas.width / 2; - const centerY = this._canvas.height / 2; - this.zoomAtPoint(factor, centerX, centerY); + if (this._startingPositionType === StartingPositionType.TOP_LEFT) { + this.zoomAtPoint(factor, this._position.x, this._position.y); + } + else { + const centerX = this._canvas.width / 2; + const centerY = this._canvas.height / 2; + this.zoomAtPoint(factor, centerX, centerY); + } } zoomOut(factor: number = 0.8) { - const centerX = this._canvas.width / 2; - const centerY = this._canvas.height / 2; - this.zoomAtPoint(factor, centerX, centerY); + if (this._startingPositionType === StartingPositionType.TOP_LEFT) { + this.zoomAtPoint(factor, this._position.x, this._position.y); + } + else { + const centerX = this._canvas.width / 2; + const centerY = this._canvas.height / 2; + this.zoomAtPoint(factor, centerX, centerY); + } } private zoomAtPoint(factor: number, centerX: number, centerY: number) { From 58ebc0bd8cd01589d464887bce5b83de44048248 Mon Sep 17 00:00:00 2001 From: dave-aa Date: Mon, 6 Oct 2025 15:44:32 -0600 Subject: [PATCH 7/7] calculate top left. cache node calculations --- src/scene.ts | 85 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/src/scene.ts b/src/scene.ts index 203d02e..99fece4 100644 --- a/src/scene.ts +++ b/src/scene.ts @@ -15,6 +15,13 @@ import { InteractableControl, isInteractableControl } from "./controls/interface import { InteractableUserControl } from "./controls/interactable-user-control"; import { Application } from "./application"; +export interface ContentBounds { + x: number; + y: number; + width: number; + height: number; +} + export class Scene { private _canvas: Canvas2D; @@ -26,6 +33,7 @@ export class Scene { private _interactables: Array; private app; + private _cachedBounds: ContentBounds | null = null; constructor(canvas: Canvas2D, app: Application) { this.app = app; @@ -108,6 +116,7 @@ export class Scene { this._pins = new Array(); this._nodes = new Array(); this._controls = new Array(); + this._cachedBounds = null; } load(dataNodes: NodeControl[]) { @@ -228,9 +237,16 @@ export class Scene { return new Vector2(centroid.x / this.nodes.length, centroid.y / this.nodes.length); } - calculateCenterPoint() { - if (this.nodes.length == 0) - return new Vector2(0, 0); + // calculate and cache the bounds for this canvas + private calculateBounds(): ContentBounds { + if (this._cachedBounds !== null) { + return this._cachedBounds; + } + + if (this.nodes.length == 0) { + this._cachedBounds = { x: 0, y: 0, width: 0, height: 0 }; + return this._cachedBounds; + } let xMin = Number.MAX_SAFE_INTEGER; let xMax = Number.MIN_SAFE_INTEGER; @@ -244,9 +260,66 @@ export class Scene { yMax = Math.max(node.position.y + node.size.y, yMax); }); - let width = xMax - xMin; - let height = yMax - yMin; + this._cachedBounds = { + x: xMin, + y: yMin, + width: xMax - xMin, + height: yMax - yMin + }; + + return this._cachedBounds; + } + + calculateCenterPoint() { + const bounds = this.calculateBounds(); + if (bounds.width === 0 && bounds.height === 0) { + return new Vector2(0, 0); + } + + return new Vector2(-bounds.width * 0.5 - bounds.x, -bounds.height * 0.5 - bounds.y); + } + + // calculate the top-left point for this blueprint, based on the x and y position for all nodes. + // + // if you prefer to calculate this at build time - you can provide the data attributes + // data-top-left-x + // data-top-left-y + // on your html canvas element to automatically read and detect the top-left point + // and avoid calculating it in javascript at runtime. + // e.g. + // + // + calculateTopLeftPoint :() => Vector2 = () => { + let yOffset = parseInt(this.app._element.getAttribute('data-camera-position-height-offset'), 10); // px + if (isNaN(yOffset)) { + yOffset = 0; + } + + const dataTopLeftX = this.app._element.getAttribute('data-top-left-x'); + const dataTopLeftY = this.app._element.getAttribute('data-top-left-y'); + + if (dataTopLeftX !== null && dataTopLeftY !== null) { + const x = parseInt(dataTopLeftX, 10); + const y = parseInt(dataTopLeftY, 10); + if (!isNaN(x) && !isNaN(y)) { + // used cached value + this.app._element.setAttribute('data-top-left-x', x); + this.app._element.setAttribute('data-top-left-y', y); + return new Vector2(-x, -y + yOffset); + } + } + + // Fallback to JavaScript calculation + + const bounds = this.calculateBounds(); + if (bounds.width === 0 && bounds.height === 0) { + return new Vector2(0, 0); + } + + return new Vector2(-bounds.x, -bounds.y + yOffset); + } - return new Vector2(-width * 0.5 -xMin , -height * 0.5 -yMin); + calculateContentBounds(): ContentBounds { + return this.calculateBounds(); } }