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
42 changes: 41 additions & 1 deletion src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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();
}

}

Expand All @@ -138,6 +146,32 @@ 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();
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);
Expand Down Expand Up @@ -168,3 +202,9 @@ export class Application {
return app;
}
}

// Export Application to global scope for external controls
if (window) {
window.KleeApplication = Application;
}

87 changes: 86 additions & 1 deletion src/camera.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,117 @@
import { Canvas2D } from "./canvas";
import { Vector2 } from "./math/vector2";

export const StartingPositionType = {
CENTER: "center",
TOP_LEFT: "top-left"
};

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;
private _startingPositionType = StartingPositionType.CENTER;

constructor(canvas: Canvas2D) {
this._canvas = canvas;
this._position = new Vector2(0, 0);
this._startingPosition = new Vector2(0, 0);
}

public get position(): Vector2 {
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;
}

public set zoom(value: number) {
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);
}

moveRelative(value: Vector2) {
this._position = this._position.add(value);
}

centerAbsolutePosition(value: Vector2) {

this._position = new Vector2(
Math.round(value.x + this._canvas.width / 2),
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) {
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) {
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) {
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));
}

setStartingPosition(position: Vector2) {
this._startingPosition = position.copy();
this._position = position.copy();
}

resetZoom() {
this._zoom = 1.0;
this._position = this._startingPosition.copy();
}
}
5 changes: 5 additions & 0 deletions src/canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
85 changes: 79 additions & 6 deletions src/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,6 +33,7 @@ export class Scene {
private _interactables: Array<InteractableUserControl>;

private app;
private _cachedBounds: ContentBounds | null = null;

constructor(canvas: Canvas2D, app: Application) {
this.app = app;
Expand Down Expand Up @@ -108,6 +116,7 @@ export class Scene {
this._pins = new Array<PinControl>();
this._nodes = new Array<NodeControl>();
this._controls = new Array<Control>();
this._cachedBounds = null;
}

load(dataNodes: NodeControl[]) {
Expand Down Expand Up @@ -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;
Expand All @@ -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.
//
// <canvas id="{canvas_id}" data-top-left-x="{min_x}" data-top-left-y="{min_y}" {blueprint_source_text} />
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();
}
}
14 changes: 14 additions & 0 deletions src/types/window.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Type definitions for window globals
*/

import { Application } from '../application';

declare global {
interface Window {
KleeApplication: typeof Application;
}
}

export {};