diff --git a/.gitignore b/.gitignore
index 61c8ac19..37baceb8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ node_modules
dist
*.tgz
src/tests/**/__screenshots__
+.gstack/
diff --git a/README.md b/README.md
index a73c2e5a..34d358fd 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,8 @@ Therefore, to use this, an understanding of the following two libraries is essen
- [asset](#asset)
- [focus(ids, opts)](#focusids-opts)
- [fit(ids, options)](#fitids-options)
+ - [setCanvasBounds(bounds)](#setcanvasboundsbounds)
+ - [createMinimap(container, options)](#createminimapcontainer-options)
- [rotation](#rotation)
- [flip](#flip)
- [selector(path)](#selectorpath)
@@ -121,6 +123,9 @@ await patchmap.init(el, {
viewport: {
plugins: { decelerate: { disabled: true } }
},
+ canvas: {
+ bounds: {}
+ },
theme: {
primary: { default: '#c2410c' }
}
@@ -160,6 +165,45 @@ Customize the rendering behavior using the following options:
}
```
+- `canvas`
+ - `bounds` - Optional finite canvas area in world coordinates. When omitted, PATCH MAP keeps the existing infinite canvas behavior. When provided, viewport pan/zoom and focus/fit are clamped to the finite area. Programmatic `draw()` and `update()` remain permissive for migration-friendly data loading.
+
+ ```js
+ await patchmap.init(el, {
+ canvas: {
+ bounds: { x: 0, y: 0, width: 5000, height: 3000 },
+ },
+ });
+
+ console.log(patchmap.canvas.bounds);
+ ```
+
+ Finite canvas mode also enables minimap creation:
+
+ ```js
+ const minimap = patchmap.createMinimap(document.querySelector('#minimap'), {
+ style: {
+ objectFill: '#94a3b8',
+ viewportFill: 'rgba(12, 115, 191, 0.08)',
+ viewportStroke: '#0c73bf',
+ viewportStrokeWidth: 2,
+ },
+ });
+
+ minimap.destroy();
+ ```
+
+ PATCH MAP does not position or decorate the minimap host. Provide a sized
+ container element where the minimap canvas should be injected and style that
+ element in your application. The injected canvas fills the container and is
+ transparent, so the host element's background, border, radius, and shadow
+ remain visible.
+
+ The minimap renders `item`, `grid`, and `rect` elements. `image`, `text`,
+ `relations`, and `group` elements are omitted so the minimap stays readable
+ and lightweight. Render order follows the canvas z-order, so overlapping
+ minimap silhouettes match the main canvas stacking order.
+
- `theme` - Theme options
Default:
```js
@@ -471,6 +515,66 @@ patchmap.fit(['item-1', 'item-2'], {
+### `setCanvasBounds(bounds)`
+Updates the finite canvas bounds at runtime. Pass `null` to return to infinite
+canvas behavior and clear the active bounds.
+
+```js
+// Auto-size finite canvas from rendered content with built-in padding.
+patchmap.setCanvasBounds({});
+
+// Fix every axis explicitly.
+patchmap.setCanvasBounds({ x: -500, y: -300, width: 5000, height: 3000 });
+
+// Return to infinite canvas behavior.
+patchmap.setCanvasBounds(null);
+```
+
+`x`, `y`, `width`, and `height` are optional. Missing fields are resolved from
+the rendered content bounds. For example, `{}` creates a finite canvas centered
+around the current objects with internal padding and a built-in minimum size of
+`5000 x 3000`. `{ width: 5000, height: 3000 }` keeps the size fixed while
+centering the bounds on the current objects. When no content exists yet, missing
+fields fall back to `x: 0`, `y: 0`, `width: 5000`, `height: 3000`.
+
+When bounds are changed, PATCH MAP emits `patchmap:canvas-bounds-changed`.
+
+
+
+### `createMinimap(container, options)`
+Creates a minimap for the current finite canvas. `canvas.bounds` must be set
+before creating a minimap. The first argument must be the container element that
+will receive the minimap canvas.
+
+```js
+const minimap = patchmap.createMinimap(document.querySelector('#minimap'), {
+ style: {
+ objectFill: '#94a3b8',
+ viewportFill: 'rgba(12, 115, 191, 0.08)',
+ viewportStroke: '#0c73bf',
+ viewportStrokeWidth: 2,
+ },
+});
+```
+
+PATCH MAP only injects and renders the minimap canvas. The container controls
+size, layout, and visual chrome such as position, background, border, border
+radius, and shadow. The minimap canvas fills the container and its background is
+transparent, so the container background shows through.
+
+Supported options:
+
+- `style.objectFill`: fill used for `item`, `grid`, and most `rect`
+ silhouettes. Standalone `rect` uses a lighter default fill unless this option
+ is explicitly overridden.
+- `style.viewportFill`, `style.viewportStroke`, `style.viewportStrokeWidth`:
+ viewport indicator style.
+
+The returned minimap object has `destroy()` and should be destroyed when the UI
+that owns it is removed.
+
+
+
### `rotation`
Rotation controller for world view. Use degrees.
@@ -791,6 +895,7 @@ This is the list of events that can be subscribed to with this update. You can s
* `patchmap:initialized`: Fired when `patchmap.init()` completes successfully.
* `patchmap:draw`: Fired when new data is rendered via `patchmap.draw()`.
* `patchmap:updated`: Fired when elements are updated via `patchmap.update()`.
+ * `patchmap:canvas-bounds-changed`: Fired when finite canvas bounds are changed via `patchmap.setCanvasBounds()`.
* `patchmap:destroyed`: Fired when the instance is destroyed by calling `patchmap.destroy()`.
#### `UndoRedoManager`
diff --git a/README_KR.md b/README_KR.md
index 539b6197..2966d716 100644
--- a/README_KR.md
+++ b/README_KR.md
@@ -28,6 +28,8 @@ PATCH MAP은 PATCH 서비스의 요구 사항을 충족시키기 위해 `pixi.js
- [asset](#asset)
- [focus(ids, opts)](#focusids-opts)
- [fit(ids, options)](#fitids-options)
+ - [setCanvasBounds(bounds)](#setcanvasboundsbounds)
+ - [createMinimap(container, options)](#createminimapcontainer-options)
- [rotation](#rotation)
- [flip](#flip)
- [selector(path)](#selectorpath)
@@ -128,6 +130,9 @@ await patchmap.init(el, {
viewport: {
plugins: { decelerate: { disabled: true } }
},
+ canvas: {
+ bounds: {}
+ },
theme: {
primary: { default: '#c2410c' }
}
@@ -168,6 +173,44 @@ await patchmap.init(el, {
}
```
+- `canvas`
+ - `bounds` - world 좌표 기준의 유한 캔버스 영역입니다. 생략하면 기존과 동일하게 무한 캔버스로 동작합니다. 값을 지정하면 viewport pan/zoom과 focus/fit이 해당 영역 안으로 제한됩니다. 마이그레이션 친화성을 위해 programmatic `draw()`와 `update()`는 out-of-bounds 데이터를 허용합니다.
+
+ ```js
+ await patchmap.init(el, {
+ canvas: {
+ bounds: { x: 0, y: 0, width: 5000, height: 3000 },
+ },
+ });
+
+ console.log(patchmap.canvas.bounds);
+ ```
+
+ 유한 캔버스 모드에서는 미니맵도 생성할 수 있습니다:
+
+ ```js
+ const minimap = patchmap.createMinimap(document.querySelector('#minimap'), {
+ style: {
+ objectFill: '#94a3b8',
+ viewportFill: 'rgba(12, 115, 191, 0.08)',
+ viewportStroke: '#0c73bf',
+ viewportStrokeWidth: 2,
+ },
+ });
+
+ minimap.destroy();
+ ```
+
+ PATCH MAP은 미니맵 host의 위치나 장식을 제어하지 않습니다. 크기가 정해진
+ container element를 전달하고, 해당 element의 위치, 배경, border, radius,
+ shadow는 애플리케이션에서 직접 스타일링하세요. 주입되는 canvas는 container를
+ 꽉 채우고 배경은 transparent라 host element의 배경이 그대로 보입니다.
+
+ 미니맵은 `item`, `grid`, `rect` element를 표시합니다. 가독성과 성능을
+ 위해 `image`, `text`, `relations`, `group`은 표시하지 않습니다. 렌더 순서는
+ 캔버스 z-order를 따르므로 객체가 겹칠 때도 메인 캔버스의 쌓임 순서와
+ 일치합니다.
+
- `theme`
Default:
```js
@@ -477,6 +520,66 @@ patchmap.fit(['item-1', 'item-2'], {
+### `setCanvasBounds(bounds)`
+런타임에 유한 캔버스 영역을 갱신합니다. `null`을 전달하면 다시 무한 캔버스
+동작으로 돌아가고 활성 bounds가 제거됩니다.
+
+```js
+// 렌더된 객체를 기준으로 padding이 포함된 유한 캔버스를 자동 산출합니다.
+patchmap.setCanvasBounds({});
+
+// 모든 축을 명시적으로 고정합니다.
+patchmap.setCanvasBounds({ x: -500, y: -300, width: 5000, height: 3000 });
+
+// 다시 무한 캔버스로 되돌립니다.
+patchmap.setCanvasBounds(null);
+```
+
+`x`, `y`, `width`, `height`는 optional입니다. 빠진 필드는 렌더된 객체 bounds를
+기준으로 산출됩니다. 예를 들어 `{}`는 현재 객체들을 중심으로 내부 padding과
+기본 최소 크기 `5000 x 3000`을 적용한 유한 캔버스를 만들고,
+`{ width: 5000, height: 3000 }`은 크기는 고정하되 현재 객체들을 중심으로
+bounds를 배치합니다. 아직 객체가 없다면 빠진 필드는 `x: 0`, `y: 0`,
+`width: 5000`, `height: 3000`을 fallback으로 사용합니다.
+
+bounds가 변경되면 PATCH MAP은 `patchmap:canvas-bounds-changed` 이벤트를
+발생시킵니다.
+
+
+
+### `createMinimap(container, options)`
+현재 유한 캔버스를 기준으로 미니맵을 생성합니다. 미니맵을 만들기 전에
+`canvas.bounds`가 설정되어 있어야 합니다. 첫 번째 인자는 미니맵 canvas가
+주입될 container element여야 합니다.
+
+```js
+const minimap = patchmap.createMinimap(document.querySelector('#minimap'), {
+ style: {
+ objectFill: '#94a3b8',
+ viewportFill: 'rgba(12, 115, 191, 0.08)',
+ viewportStroke: '#0c73bf',
+ viewportStrokeWidth: 2,
+ },
+});
+```
+
+PATCH MAP은 미니맵 canvas 주입과 렌더링만 담당합니다. 크기, 위치, 배경,
+border, border radius, shadow 같은 시각적 chrome은 container element에서
+제어하세요. 미니맵 canvas는 container를 꽉 채우고 배경은 transparent라
+container 배경이 그대로 보입니다.
+
+지원 옵션:
+
+- `style.objectFill`: `item`, `grid`, 대부분의 `rect` silhouette에 쓰는 fill입니다.
+ standalone `rect`는 이 옵션을 명시적으로 덮어쓰지 않으면 더 밝은 기본 fill을 사용합니다.
+- `style.viewportFill`, `style.viewportStroke`, `style.viewportStrokeWidth`:
+ viewport 표시 영역의 스타일입니다.
+
+반환된 minimap 객체는 `destroy()`를 제공하며, 미니맵을 소유한 UI가 제거될 때
+함께 destroy해야 합니다.
+
+
+
### `rotation`
월드 뷰 회전을 제어하는 컨트롤러입니다. 각도는 degrees 기준입니다.
@@ -800,6 +903,7 @@ undoRedoManager.redo();
* `patchmap:initialized`: `patchmap.init()`이 성공적으로 완료되었을 때 발생합니다.
* `patchmap:draw`: `patchmap.draw()`를 통해 새로운 데이터가 렌더링되었을 때 발생합니다.
* `patchmap:updated`: `patchmap.update()`를 통해 요소가 업데이트되었을 때 발생합니다.
+ * `patchmap:canvas-bounds-changed`: `patchmap.setCanvasBounds()`로 유한 캔버스 bounds가 변경되었을 때 발생합니다.
* `patchmap:destroyed`: `patchmap.destroy()`가 호출되어 인스턴스가 파괴될 때 발생합니다.
#### `UndoRedoManager`
diff --git a/src/canvas-bounds/clamp.js b/src/canvas-bounds/clamp.js
new file mode 100644
index 00000000..4939c89d
--- /dev/null
+++ b/src/canvas-bounds/clamp.js
@@ -0,0 +1,139 @@
+import { Point } from 'pixi.js';
+import { getBoundsFromPoints } from '../utils/transform';
+import { getFrameCorrection } from './restrict';
+
+const CLAMP_EPSILON = 0.0001;
+
+export const clampViewportToCanvasBounds = (
+ viewport,
+ bounds,
+ world,
+ { centerUnderflow = false, preserveUnderflow = false } = {},
+) => {
+ if (!viewport || !bounds) return;
+
+ if (world) {
+ clampViewportToTransformedCanvasBounds(viewport, bounds, world, {
+ centerUnderflow,
+ preserveUnderflow,
+ });
+ return;
+ }
+
+ clampViewportAxis({
+ viewport,
+ min: bounds.x,
+ max: bounds.right,
+ size: bounds.width,
+ screenSize: viewport.screenWidth,
+ worldScreenSize: viewport.worldScreenWidth,
+ scale: viewport.scale?.x,
+ minEdgeKey: 'left',
+ maxEdgeKey: 'right',
+ positionKey: 'x',
+ centerUnderflow,
+ preserveUnderflow,
+ });
+ clampViewportAxis({
+ viewport,
+ min: bounds.y,
+ max: bounds.bottom,
+ size: bounds.height,
+ screenSize: viewport.screenHeight,
+ worldScreenSize: viewport.worldScreenHeight,
+ scale: viewport.scale?.y,
+ minEdgeKey: 'top',
+ maxEdgeKey: 'bottom',
+ positionKey: 'y',
+ centerUnderflow,
+ preserveUnderflow,
+ });
+};
+
+const clampViewportAxis = ({
+ viewport,
+ min,
+ max,
+ size,
+ screenSize,
+ worldScreenSize,
+ scale,
+ minEdgeKey,
+ maxEdgeKey,
+ positionKey,
+ centerUnderflow,
+ preserveUnderflow,
+}) => {
+ const safeScale = Math.abs(scale || 1);
+ const visibleSize = Number.isFinite(worldScreenSize)
+ ? worldScreenSize
+ : screenSize / safeScale;
+
+ if (visibleSize >= size) {
+ if (centerUnderflow) {
+ const center = min + size / 2;
+ viewport[positionKey] = -center * safeScale + screenSize / 2;
+ } else if (preserveUnderflow) {
+ return;
+ } else if (viewport[minEdgeKey] > min) {
+ viewport[minEdgeKey] = min;
+ } else if (viewport[maxEdgeKey] < max) {
+ viewport[maxEdgeKey] = max;
+ }
+ } else if (viewport[minEdgeKey] < min) {
+ viewport[minEdgeKey] = min;
+ } else if (viewport[maxEdgeKey] > max) {
+ viewport[maxEdgeKey] = max;
+ }
+};
+
+const clampViewportToTransformedCanvasBounds = (
+ viewport,
+ bounds,
+ world,
+ { centerUnderflow = false, preserveUnderflow = false } = {},
+) => {
+ for (let iteration = 0; iteration < 4; iteration += 1) {
+ const frame = getVisibleCanvasFrame(viewport, world);
+ const correction = getFrameCorrection(frame, bounds, {
+ centerUnderflow,
+ preserveUnderflow,
+ });
+ if (
+ Math.abs(correction.x) <= CLAMP_EPSILON &&
+ Math.abs(correction.y) <= CLAMP_EPSILON
+ ) {
+ return;
+ }
+
+ const center = screenPointToCanvasPoint({
+ world,
+ point: new Point(viewport.screenWidth / 2, viewport.screenHeight / 2),
+ });
+ const correctedCenter = new Point(
+ center.x + correction.x,
+ center.y + correction.y,
+ );
+ const correctedViewportCenter =
+ world.localTransform?.apply?.(correctedCenter) ??
+ viewport.toLocal(correctedCenter, world);
+ viewport.moveCenter(correctedViewportCenter.x, correctedViewportCenter.y);
+ }
+};
+
+const getVisibleCanvasFrame = (viewport, world) => {
+ const screenWidth = viewport.screenWidth ?? 0;
+ const screenHeight = viewport.screenHeight ?? 0;
+ return getBoundsFromPoints(
+ [
+ new Point(0, 0),
+ new Point(screenWidth, 0),
+ new Point(screenWidth, screenHeight),
+ new Point(0, screenHeight),
+ ].map((point) => screenPointToCanvasPoint({ world, point })),
+ );
+};
+
+const screenPointToCanvasPoint = ({ world, point }) => {
+ return world.toLocal(point);
+};
diff --git a/src/canvas-bounds/controller.js b/src/canvas-bounds/controller.js
new file mode 100644
index 00000000..8f3df345
--- /dev/null
+++ b/src/canvas-bounds/controller.js
@@ -0,0 +1,138 @@
+import { Rectangle } from 'pixi.js';
+import { clampViewportToCanvasBounds } from './clamp';
+
+export default class CanvasBoundsController {
+ constructor({ viewport, world, bounds } = {}) {
+ this.viewport = viewport;
+ this.world = world;
+ this.bounds = bounds;
+ this._applyViewportClamp = (event) => {
+ this.applyViewportClamp({
+ preserveUnderflow: event?.type === 'wheel',
+ });
+ };
+ this._requestViewportClamp = () => {
+ this.requestViewportClamp();
+ };
+ this._isApplyingClamp = false;
+ this._baseClampZoomMinScale = null;
+ this._clampFrame = null;
+
+ this.attach();
+ }
+
+ attach() {
+ if (!this.viewport || !this.world || !this.bounds) return;
+
+ this.configureViewport();
+ this.viewport.on?.('world_transformed', this._applyViewportClamp);
+ this.viewport.on?.('moved', this._applyViewportClamp);
+ this.viewport.on?.('zoomed', this._requestViewportClamp);
+ }
+
+ configureViewport() {
+ if (!this.viewport || !this.bounds) return;
+
+ this.viewport.resize?.(
+ this.viewport.screenWidth,
+ this.viewport.screenHeight,
+ this.bounds.width,
+ this.bounds.height,
+ );
+ this.configureMinimumScale();
+ this.viewport.forceHitArea = new Rectangle(
+ this.bounds.x,
+ this.bounds.y,
+ this.bounds.width,
+ this.bounds.height,
+ );
+ this.viewport.plugins?.remove?.('clamp');
+ this.applyViewportClamp({
+ centerUnderflow: true,
+ });
+ }
+
+ configureMinimumScale() {
+ const minScale = getCanvasFitMinScale(this.viewport, this.bounds);
+ if (!Number.isFinite(minScale) || minScale <= 0) return;
+
+ const clampZoom = this.viewport.plugins?.get?.('clamp-zoom');
+ const options = clampZoom?.options ?? {};
+ if (this._baseClampZoomMinScale === null) {
+ this._baseClampZoomMinScale = options.minScale ?? 0;
+ }
+ const nextMinScale = Math.max(this._baseClampZoomMinScale, minScale);
+
+ if (options.minScale !== nextMinScale) {
+ this.viewport.plugin?.add?.({
+ clampZoom: {
+ ...options,
+ minScale: nextMinScale,
+ },
+ });
+ }
+ if (Math.abs(this.viewport.scale?.x ?? 1) < nextMinScale) {
+ this.viewport.setZoom?.(nextMinScale, true);
+ }
+ }
+
+ resize() {
+ this.configureViewport();
+ }
+
+ setBounds(bounds) {
+ this.bounds = bounds;
+ this.configureViewport();
+ }
+
+ applyViewportClamp(options) {
+ if (this._isApplyingClamp) return;
+ this.cancelPendingClamp();
+ this._isApplyingClamp = true;
+ try {
+ clampViewportToCanvasBounds(
+ this.viewport,
+ this.bounds,
+ this.world,
+ options,
+ );
+ } finally {
+ this._isApplyingClamp = false;
+ }
+ }
+
+ requestViewportClamp() {
+ if (this._clampFrame !== null) return;
+ const requestFrame =
+ globalThis.requestAnimationFrame ??
+ ((callback) => globalThis.setTimeout(callback, 16));
+ this._clampFrame = requestFrame(() => {
+ this._clampFrame = null;
+ this.applyViewportClamp();
+ });
+ }
+
+ cancelPendingClamp() {
+ if (this._clampFrame === null) return;
+ const cancelFrame =
+ globalThis.cancelAnimationFrame ?? globalThis.clearTimeout;
+ cancelFrame?.(this._clampFrame);
+ this._clampFrame = null;
+ }
+
+ destroy() {
+ this.viewport?.off?.('world_transformed', this._applyViewportClamp);
+ this.viewport?.off?.('moved', this._applyViewportClamp);
+ this.viewport?.off?.('zoomed', this._requestViewportClamp);
+ this.cancelPendingClamp();
+ this.viewport = null;
+ this.world = null;
+ this.bounds = null;
+ }
+}
+
+const getCanvasFitMinScale = (viewport, bounds) =>
+ Math.min(
+ viewport.screenWidth / bounds.width,
+ viewport.screenHeight / bounds.height,
+ );
diff --git a/src/canvas-bounds/options.js b/src/canvas-bounds/options.js
new file mode 100644
index 00000000..dfb78e7b
--- /dev/null
+++ b/src/canvas-bounds/options.js
@@ -0,0 +1,150 @@
+/**
+ * @typedef {object} CanvasBounds
+ * @property {number} x
+ * @property {number} y
+ * @property {number} width
+ * @property {number} height
+ * @property {number} right
+ * @property {number} bottom
+ */
+
+const BOUNDS_KEYS = ['x', 'y', 'width', 'height'];
+const DEFAULT_AUTO_BOUNDS = Object.freeze({
+ x: 0,
+ y: 0,
+ width: 5000,
+ height: 3000,
+});
+const MIN_AUTO_BOUNDS = Object.freeze({
+ width: 5000,
+ height: 3000,
+});
+
+export const normalizeCanvasBounds = (bounds, options = {}) => {
+ if (bounds == null) return null;
+ if (typeof bounds !== 'object' || Array.isArray(bounds)) {
+ throw new TypeError('canvas.bounds must be an object.');
+ }
+
+ const contentBounds = normalizeContentBounds(options.contentBounds);
+ const width = normalizeCanvasBoundsSize({
+ bounds,
+ key: 'width',
+ contentSize: contentBounds?.width,
+ fallback: DEFAULT_AUTO_BOUNDS.width,
+ });
+ const height = normalizeCanvasBoundsSize({
+ bounds,
+ key: 'height',
+ contentSize: contentBounds?.height,
+ fallback: DEFAULT_AUTO_BOUNDS.height,
+ });
+ const normalized = { width, height };
+
+ normalized.x = normalizeCanvasBoundsPosition({
+ bounds,
+ key: 'x',
+ contentPosition: contentBounds?.x,
+ contentSize: contentBounds?.width,
+ size: width,
+ fallback: DEFAULT_AUTO_BOUNDS.x,
+ });
+ normalized.y = normalizeCanvasBoundsPosition({
+ bounds,
+ key: 'y',
+ contentPosition: contentBounds?.y,
+ contentSize: contentBounds?.height,
+ size: height,
+ fallback: DEFAULT_AUTO_BOUNDS.y,
+ });
+
+ const right = normalized.x + normalized.width;
+ const bottom = normalized.y + normalized.height;
+ if (!Number.isFinite(right)) {
+ throw new TypeError('canvas.bounds.right must be a finite number.');
+ }
+ if (!Number.isFinite(bottom)) {
+ throw new TypeError('canvas.bounds.bottom must be a finite number.');
+ }
+
+ return Object.freeze({
+ x: normalized.x,
+ y: normalized.y,
+ width: normalized.width,
+ height: normalized.height,
+ right,
+ bottom,
+ });
+};
+
+export const hasAutoCanvasBounds = (bounds) =>
+ bounds != null &&
+ typeof bounds === 'object' &&
+ !Array.isArray(bounds) &&
+ BOUNDS_KEYS.some((key) => bounds[key] === undefined);
+
+const normalizeCanvasBoundsSize = ({ bounds, key, contentSize, fallback }) => {
+ const value = bounds[key];
+ if (value === undefined) {
+ const autoSize = contentSize
+ ? Math.max(contentSize, MIN_AUTO_BOUNDS[key])
+ : fallback;
+ return normalizePositiveSize(autoSize, key);
+ }
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ throw new TypeError(`canvas.bounds.${key} must be a finite number.`);
+ }
+ return normalizePositiveSize(value, key);
+};
+
+const normalizePositiveSize = (value, key) => {
+ if (value <= 0) {
+ throw new TypeError(`canvas.bounds.${key} must be greater than 0.`);
+ }
+ return value;
+};
+
+const normalizeCanvasBoundsPosition = ({
+ bounds,
+ key,
+ contentPosition,
+ contentSize,
+ size,
+ fallback,
+}) => {
+ const value = bounds[key];
+ if (value === undefined) {
+ return contentSize
+ ? contentPosition + contentSize / 2 - size / 2
+ : fallback;
+ }
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ throw new TypeError(`canvas.bounds.${key} must be a finite number.`);
+ }
+ return value;
+};
+
+const normalizeContentBounds = (bounds) => {
+ if (!bounds || typeof bounds !== 'object') return null;
+
+ const normalized = {};
+ for (const key of BOUNDS_KEYS) {
+ const value =
+ bounds[key] ??
+ (key === 'x' ? bounds.minX : undefined) ??
+ (key === 'y' ? bounds.minY : undefined) ??
+ (key === 'width' && Number.isFinite(bounds.maxX - bounds.minX)
+ ? bounds.maxX - bounds.minX
+ : undefined) ??
+ (key === 'height' && Number.isFinite(bounds.maxY - bounds.minY)
+ ? bounds.maxY - bounds.minY
+ : undefined);
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ return null;
+ }
+ normalized[key] = value;
+ }
+
+ if (normalized.width <= 0 || normalized.height <= 0) return null;
+ return normalized;
+};
diff --git a/src/canvas-bounds/options.test.js b/src/canvas-bounds/options.test.js
new file mode 100644
index 00000000..dcccb4f3
--- /dev/null
+++ b/src/canvas-bounds/options.test.js
@@ -0,0 +1,211 @@
+import { describe, expect, it } from 'vitest';
+import { clampViewportToCanvasBounds } from './clamp';
+import { normalizeCanvasBounds } from './options';
+
+describe('canvas bounds options', () => {
+ it('normalizes finite canvas bounds with derived edges', () => {
+ expect(
+ normalizeCanvasBounds({ x: -100, y: 50, width: 5000, height: 3000 }),
+ ).toEqual({
+ x: -100,
+ y: 50,
+ width: 5000,
+ height: 3000,
+ right: 4900,
+ bottom: 3050,
+ });
+ });
+
+ it('resolves missing canvas bounds fields from content bounds', () => {
+ expect(
+ normalizeCanvasBounds(
+ {},
+ { contentBounds: { x: -200, y: 100, width: 400, height: 300 } },
+ ),
+ ).toEqual({
+ x: -2500,
+ y: -1250,
+ width: 5000,
+ height: 3000,
+ right: 2500,
+ bottom: 1750,
+ });
+ });
+
+ it('uses explicit canvas bounds fields while resolving the missing fields', () => {
+ expect(
+ normalizeCanvasBounds(
+ { width: 2000 },
+ { contentBounds: { x: -200, y: 100, width: 400, height: 300 } },
+ ),
+ ).toEqual({
+ x: -1000,
+ y: -1250,
+ width: 2000,
+ height: 3000,
+ right: 1000,
+ bottom: 1750,
+ });
+ });
+
+ it('returns null when canvas bounds are omitted', () => {
+ expect(normalizeCanvasBounds()).toBeNull();
+ expect(normalizeCanvasBounds(null)).toBeNull();
+ });
+
+ it('rejects non-finite explicit bounds values', () => {
+ expect(() =>
+ normalizeCanvasBounds({
+ x: 0,
+ y: 0,
+ width: Number.POSITIVE_INFINITY,
+ height: 1,
+ }),
+ ).toThrow('canvas.bounds.width must be a finite number.');
+
+ expect(() => normalizeCanvasBounds({ x: '0' })).toThrow(
+ 'canvas.bounds.x must be a finite number.',
+ );
+ });
+
+ it('rejects non-positive sizes', () => {
+ expect(() =>
+ normalizeCanvasBounds({ x: 0, y: 0, width: 0, height: 1 }),
+ ).toThrow('canvas.bounds.width must be greater than 0.');
+ expect(() =>
+ normalizeCanvasBounds({ x: 0, y: 0, width: 1, height: -1 }),
+ ).toThrow('canvas.bounds.height must be greater than 0.');
+ });
+
+ it('rejects bounds whose derived edges overflow finite numbers', () => {
+ expect(() =>
+ normalizeCanvasBounds({
+ x: Number.MAX_VALUE,
+ y: 0,
+ width: Number.MAX_VALUE,
+ height: 1,
+ }),
+ ).toThrow('canvas.bounds.right must be a finite number.');
+
+ expect(() =>
+ normalizeCanvasBounds({
+ x: 0,
+ y: Number.MAX_VALUE,
+ width: 1,
+ height: Number.MAX_VALUE,
+ }),
+ ).toThrow('canvas.bounds.bottom must be a finite number.');
+ });
+
+ it('clamps viewport axes against non-zero canvas bounds', () => {
+ const viewport = {
+ screenWidth: 800,
+ screenHeight: 600,
+ worldScreenWidth: 400,
+ worldScreenHeight: 300,
+ scale: { x: 1, y: 1 },
+ left: -100,
+ right: 300,
+ top: 10,
+ bottom: 310,
+ x: 100,
+ y: -10,
+ };
+ const bounds = normalizeCanvasBounds({
+ x: -20,
+ y: 30,
+ width: 1000,
+ height: 700,
+ });
+
+ clampViewportToCanvasBounds(viewport, bounds);
+
+ expect(viewport.left).toBe(-20);
+ expect(viewport.top).toBe(30);
+ });
+
+ it('keeps underflowing viewport axes within non-zero canvas bounds', () => {
+ const viewport = {
+ screenWidth: 800,
+ screenHeight: 600,
+ worldScreenWidth: 800,
+ worldScreenHeight: 600,
+ scale: { x: 1, y: 1 },
+ left: 150,
+ right: 950,
+ top: 100,
+ bottom: 700,
+ x: 0,
+ y: 0,
+ };
+ const bounds = normalizeCanvasBounds({
+ x: 100,
+ y: 50,
+ width: 120,
+ height: 80,
+ });
+
+ clampViewportToCanvasBounds(viewport, bounds);
+
+ expect(viewport.left).toBe(100);
+ expect(viewport.top).toBe(50);
+ });
+
+ it('centers underflowing viewport axes when requested', () => {
+ const viewport = {
+ screenWidth: 800,
+ screenHeight: 600,
+ worldScreenWidth: 800,
+ worldScreenHeight: 600,
+ scale: { x: 1, y: 1 },
+ left: 0,
+ right: 800,
+ top: 0,
+ bottom: 600,
+ x: 0,
+ y: 0,
+ };
+ const bounds = normalizeCanvasBounds({
+ x: 100,
+ y: 50,
+ width: 120,
+ height: 80,
+ });
+
+ clampViewportToCanvasBounds(viewport, bounds, null, {
+ centerUnderflow: true,
+ });
+
+ expect(viewport.x).toBe(240);
+ expect(viewport.y).toBe(210);
+ });
+
+ it('preserves underflowing viewport axes when requested for pointer-anchored zoom', () => {
+ const viewport = {
+ screenWidth: 800,
+ screenHeight: 600,
+ worldScreenWidth: 800,
+ worldScreenHeight: 600,
+ scale: { x: 1, y: 1 },
+ left: 150,
+ right: 950,
+ top: 100,
+ bottom: 700,
+ x: 0,
+ y: 0,
+ };
+ const bounds = normalizeCanvasBounds({
+ x: 100,
+ y: 50,
+ width: 120,
+ height: 80,
+ });
+
+ clampViewportToCanvasBounds(viewport, bounds, null, {
+ preserveUnderflow: true,
+ });
+
+ expect(viewport.left).toBe(150);
+ expect(viewport.top).toBe(100);
+ });
+});
diff --git a/src/canvas-bounds/restrict.js b/src/canvas-bounds/restrict.js
new file mode 100644
index 00000000..89c7585e
--- /dev/null
+++ b/src/canvas-bounds/restrict.js
@@ -0,0 +1,119 @@
+import { Point } from 'pixi.js';
+import { getBoundsFromPoints } from '../utils/transform';
+
+const EPSILON = 0.0001;
+
+export const areViewportFrameCornersInsideCanvasBounds = ({
+ corners,
+ viewport,
+ world,
+ canvasBounds,
+}) => {
+ if (!canvasBounds || !Array.isArray(corners) || corners.length === 0) {
+ return true;
+ }
+ if (!viewport || !world) return true;
+
+ const canvasCorners = corners.map((corner) =>
+ toCanvasPoint(corner, viewport, world),
+ );
+ return canvasCorners.every((point) =>
+ isPointInsideCanvasBounds(point, canvasBounds),
+ );
+};
+
+export const isViewportFrameInsideCanvasBounds = ({
+ corners,
+ viewport,
+ element,
+}) => {
+ const canvasBounds = element?.store?.canvasBounds;
+ if (!canvasBounds) return true;
+
+ return areViewportFrameCornersInsideCanvasBounds({
+ corners,
+ viewport,
+ world: element?.store?.world,
+ canvasBounds,
+ });
+};
+
+export const getFrameCorrection = (
+ frame,
+ canvasBounds,
+ { centerUnderflow = false, preserveUnderflow = false } = {},
+) => {
+ if (!frame || !canvasBounds) return { x: 0, y: 0 };
+ return {
+ x: getAxisCorrection({
+ min: frame.x,
+ max: frame.x + frame.width,
+ size: frame.width,
+ boundMin: canvasBounds.x,
+ boundMax: canvasBounds.right,
+ boundSize: canvasBounds.width,
+ centerUnderflow,
+ preserveUnderflow,
+ }),
+ y: getAxisCorrection({
+ min: frame.y,
+ max: frame.y + frame.height,
+ size: frame.height,
+ boundMin: canvasBounds.y,
+ boundMax: canvasBounds.bottom,
+ boundSize: canvasBounds.height,
+ centerUnderflow,
+ preserveUnderflow,
+ }),
+ };
+};
+
+const isPointInsideCanvasBounds = (point, canvasBounds) =>
+ point.x >= canvasBounds.x - EPSILON &&
+ point.x <= canvasBounds.right + EPSILON &&
+ point.y >= canvasBounds.y - EPSILON &&
+ point.y <= canvasBounds.bottom + EPSILON;
+
+const toCanvasPoint = (point, viewport, world) => {
+ const viewportPoint = new Point(point.x, point.y);
+ const globalPoint =
+ typeof viewport.toGlobal === 'function'
+ ? viewport.toGlobal(viewportPoint)
+ : viewportPoint;
+ return world.toLocal(globalPoint);
+};
+
+export const getCanvasFrameFromViewportCorners = ({
+ corners,
+ viewport,
+ world,
+}) => {
+ const canvasCorners = corners.map((corner) =>
+ toCanvasPoint(corner, viewport, world),
+ );
+ return getBoundsFromPoints(canvasCorners);
+};
+
+const getAxisCorrection = ({
+ min,
+ max,
+ size,
+ boundMin,
+ boundMax,
+ boundSize,
+ centerUnderflow,
+ preserveUnderflow,
+}) => {
+ if (size > boundSize) {
+ if (centerUnderflow) {
+ return boundMin + boundSize / 2 - (min + size / 2);
+ }
+ if (preserveUnderflow) return 0;
+ if (min > boundMin) return boundMin - min;
+ if (max < boundMax) return boundMax - max;
+ return 0;
+ }
+ if (min < boundMin) return boundMin - min;
+ if (max > boundMax) return boundMax - max;
+ return 0;
+};
diff --git a/src/canvas-bounds/restrict.test.js b/src/canvas-bounds/restrict.test.js
new file mode 100644
index 00000000..d296e59f
--- /dev/null
+++ b/src/canvas-bounds/restrict.test.js
@@ -0,0 +1,69 @@
+import { describe, expect, it } from 'vitest';
+import { getFrameCorrection } from './restrict';
+
+const canvasBounds = Object.freeze({
+ x: 0,
+ y: 10,
+ width: 500,
+ height: 300,
+ right: 500,
+ bottom: 310,
+});
+
+describe('canvas bounds restriction', () => {
+ it('returns no correction for frames already inside canvas bounds', () => {
+ expect(
+ getFrameCorrection(
+ { x: 20, y: 30, width: 100, height: 80 },
+ canvasBounds,
+ ),
+ ).toEqual({ x: 0, y: 0 });
+ });
+
+ it('moves overflowing frames back inside canvas bounds', () => {
+ expect(
+ getFrameCorrection(
+ { x: 480, y: -20, width: 50, height: 40 },
+ canvasBounds,
+ ),
+ ).toEqual({ x: -30, y: 30 });
+ });
+
+ it('keeps oversized frames containing canvas bounds without forcing center alignment', () => {
+ expect(
+ getFrameCorrection(
+ { x: 100, y: 100, width: 700, height: 500 },
+ canvasBounds,
+ ),
+ ).toEqual({ x: -100, y: -90 });
+ });
+
+ it('does not move oversized frames that already contain canvas bounds', () => {
+ expect(
+ getFrameCorrection(
+ { x: -50, y: -20, width: 700, height: 500 },
+ canvasBounds,
+ ),
+ ).toEqual({ x: 0, y: 0 });
+ });
+
+ it('preserves oversized frames when requested for pointer-anchored zoom', () => {
+ expect(
+ getFrameCorrection(
+ { x: 100, y: 100, width: 700, height: 500 },
+ canvasBounds,
+ { preserveUnderflow: true },
+ ),
+ ).toEqual({ x: 0, y: 0 });
+ });
+
+ it('centers oversized frames when requested', () => {
+ expect(
+ getFrameCorrection(
+ { x: 100, y: 100, width: 700, height: 500 },
+ canvasBounds,
+ { centerUnderflow: true },
+ ),
+ ).toEqual({ x: -200, y: -190 });
+ });
+});
diff --git a/src/display/mixins/Sourceable.js b/src/display/mixins/Sourceable.js
index 05852d0f..ebeed66a 100644
--- a/src/display/mixins/Sourceable.js
+++ b/src/display/mixins/Sourceable.js
@@ -67,6 +67,7 @@ export const Sourceable = (superClass) => {
if (typeof this._onTextureApplied === 'function') {
this._onTextureApplied(texture);
}
+ this.store?.viewport?.emit?.('object_transformed', this);
}
};
MixedClass.registerHandler(
diff --git a/src/minimap/Minimap.js b/src/minimap/Minimap.js
new file mode 100644
index 00000000..6a976f6f
--- /dev/null
+++ b/src/minimap/Minimap.js
@@ -0,0 +1,601 @@
+import { Point } from 'pixi.js';
+import {
+ createMinimapObjectSnapshot,
+ createMinimapViewport,
+ minimapPointToCanvasPoint,
+} from './model';
+
+const DEFAULT_OPTIONS = Object.freeze({
+ style: {
+ objectFill: '#94a3b8',
+ viewportFill: 'rgba(12, 115, 191, 0.08)',
+ viewportStroke: '#0c73bf',
+ viewportStrokeWidth: 2,
+ },
+});
+const DEFAULT_RECT_FILL = '#cbd5e1';
+const PATCHMAP_EVENTS = Object.freeze([
+ ['patchmap:initialized', '_onPatchmapInitialized'],
+ ['patchmap:draw', '_requestObjectRender'],
+ ['patchmap:updated', '_requestObjectRender'],
+ ['patchmap:canvas-bounds-changed', '_requestObjectRender'],
+ ['patchmap:rotated', '_requestObjectRender'],
+ ['patchmap:flipped', '_requestObjectRender'],
+]);
+const VIEWPORT_EVENTS = Object.freeze([
+ ['moved', '_requestRender'],
+ ['zoomed', '_requestRender'],
+ ['world_transformed', '_requestRender'],
+ ['object_transformed', '_requestObjectRender'],
+]);
+const POINTER_EVENTS = Object.freeze([
+ ['pointerdown', '_onPointerDown'],
+ ['pointermove', '_onPointerMove'],
+ ['pointerup', '_onPointerUp'],
+ ['pointercancel', '_onPointerUp'],
+ ['pointerleave', '_onPointerUp'],
+]);
+
+/**
+ * @typedef {object} MinimapStyle
+ * @property {string | number} [objectFill]
+ * @property {string} [viewportFill]
+ * @property {string | number} [viewportStroke]
+ * @property {number} [viewportStrokeWidth]
+ */
+
+/**
+ * @typedef {object} MinimapOptions
+ * @property {MinimapStyle} [style]
+ */
+
+export default class Minimap {
+ /**
+ * @param {object} params
+ * @param {import('../patchmap').Patchmap} params.patchmap
+ * @param {HTMLElement} params.container
+ * @param {MinimapOptions} [params.options]
+ * @param {(target: Minimap) => void} [params.onDestroy]
+ */
+ constructor({ patchmap, container, options = {}, onDestroy } = {}) {
+ if (!patchmap?.canvas?.bounds) {
+ throw new TypeError('patchmap.createMinimap() requires canvas.bounds.');
+ }
+ if (!container?.appendChild) {
+ throw new TypeError('patchmap.createMinimap() requires a DOM container.');
+ }
+
+ this.patchmap = patchmap;
+ this.container = container;
+ this.options = mergeOptions(options);
+ this.onDestroy = typeof onDestroy === 'function' ? onDestroy : null;
+ this.canvas = document.createElement('canvas');
+ this.context = this.canvas.getContext('2d');
+ this._objectLayer = document.createElement('canvas');
+ this._objectLayerContext = this._objectLayer.getContext('2d');
+ this._snapshot = null;
+ this._objectSnapshot = null;
+ this._objectLayerKey = null;
+ this._objectsDirty = true;
+ this._attachedViewport = null;
+ this._attachedTicker = null;
+ this._resizeObserver = null;
+ this._size = resolveContainerSize(container);
+ this._frame = null;
+ this._viewportTransformKey = null;
+ this._destroyed = false;
+ this._isPointerActive = false;
+
+ this._requestRender = () => this.requestRender();
+ this._requestObjectRender = () => this.invalidateObjects();
+ this._onPatchmapInitialized = () => this.handlePatchmapInitialized();
+ this._watchViewportTransform = () => this.watchViewportTransform();
+ this._onPointerDown = (event) => this.handlePointerDown(event);
+ this._onPointerMove = (event) => this.handlePointerMove(event);
+ this._onPointerUp = () => this.handlePointerUp();
+
+ this.mount();
+ this.attach();
+ this.render();
+ }
+
+ get destroyed() {
+ return this._destroyed;
+ }
+
+ mount() {
+ this.canvas.width = this._size.width;
+ this.canvas.height = this._size.height;
+ this.canvas.style.width = '100%';
+ this.canvas.style.height = '100%';
+ this.canvas.style.display = 'block';
+ this.canvas.style.cursor = 'pointer';
+ this.canvas.style.touchAction = 'none';
+ this.container.appendChild(this.canvas);
+ this.observeContainerResize();
+ }
+
+ attach() {
+ for (const [event, handlerKey] of PATCHMAP_EVENTS) {
+ this.patchmap.on(event, this[handlerKey]);
+ }
+ this.attachViewport();
+ for (const [event, handlerKey] of POINTER_EVENTS) {
+ this.canvas.addEventListener(event, this[handlerKey]);
+ }
+ }
+
+ detach() {
+ for (const [event, handlerKey] of PATCHMAP_EVENTS) {
+ this.patchmap?.off?.(event, this[handlerKey]);
+ }
+ this.detachViewport();
+ for (const [event, handlerKey] of POINTER_EVENTS) {
+ this.canvas?.removeEventListener(event, this[handlerKey]);
+ }
+ this._resizeObserver?.disconnect?.();
+ this._resizeObserver = null;
+ }
+
+ observeContainerResize() {
+ if (typeof ResizeObserver === 'undefined') return;
+
+ this._resizeObserver = new ResizeObserver(() => {
+ if (this._destroyed) return;
+ const nextSize = resolveContainerSize(this.container);
+ if (
+ nextSize.width === this._size.width &&
+ nextSize.height === this._size.height
+ ) {
+ return;
+ }
+
+ this._size = nextSize;
+ this._objectsDirty = true;
+ this.requestRender();
+ });
+ this._resizeObserver.observe(this.container);
+ }
+
+ attachViewport() {
+ const viewport = this.patchmap?.viewport ?? null;
+ const ticker = this.patchmap?.app?.ticker ?? null;
+ if (
+ viewport === this._attachedViewport &&
+ ticker === this._attachedTicker
+ ) {
+ return;
+ }
+
+ this.detachViewport();
+ this._attachedViewport = viewport;
+ this._attachedTicker = ticker;
+
+ for (const [event, handlerKey] of VIEWPORT_EVENTS) {
+ viewport?.on?.(event, this[handlerKey]);
+ }
+ ticker?.add?.(this._watchViewportTransform);
+ }
+
+ detachViewport() {
+ for (const [event, handlerKey] of VIEWPORT_EVENTS) {
+ this._attachedViewport?.off?.(event, this[handlerKey]);
+ }
+ this._attachedTicker?.remove?.(this._watchViewportTransform);
+ this._attachedViewport = null;
+ this._attachedTicker = null;
+ }
+
+ handlePatchmapInitialized() {
+ if (this._destroyed) return;
+ this.attachViewport();
+ this.invalidateObjects();
+ }
+
+ watchViewportTransform() {
+ if (this._destroyed) return;
+
+ const transformKey = createViewportTransformKey(this.patchmap);
+ if (transformKey === this._viewportTransformKey) return;
+
+ this._viewportTransformKey = transformKey;
+ this.requestRender();
+ }
+
+ invalidateObjects() {
+ if (this._destroyed) return;
+ this._objectsDirty = true;
+ this.requestRender();
+ }
+
+ requestRender() {
+ if (this._destroyed || this._frame !== null) return;
+ const requestFrame =
+ globalThis.requestAnimationFrame ??
+ ((callback) => globalThis.setTimeout(callback, 16));
+ this._frame = requestFrame(() => {
+ this._frame = null;
+ this.render();
+ });
+ }
+
+ render() {
+ if (!this.context || !this._objectLayerContext) return;
+ this.cancelPendingRender();
+
+ const ratio = globalThis.devicePixelRatio || 1;
+ this._size = resolveContainerSize(this.container);
+ const { width, height } = this._size;
+ const pixelWidth = Math.max(Math.ceil(width * ratio), 1);
+ const pixelHeight = Math.max(Math.ceil(height * ratio), 1);
+ if (
+ this.canvas.width !== pixelWidth ||
+ this.canvas.height !== pixelHeight
+ ) {
+ this.canvas.width = pixelWidth;
+ this.canvas.height = pixelHeight;
+ this._objectsDirty = true;
+ }
+
+ const objectSnapshot = this.getObjectSnapshot({
+ ratio,
+ width,
+ height,
+ pixelWidth,
+ pixelHeight,
+ });
+ if (!objectSnapshot) {
+ this.clearLayers({ pixelWidth, pixelHeight });
+ this._snapshot = null;
+ this._viewportTransformKey = null;
+ return;
+ }
+
+ this._snapshot = {
+ ...objectSnapshot,
+ viewport: createMinimapViewport({
+ patchmap: this.patchmap,
+ canvasBounds: objectSnapshot.canvasBounds,
+ scale: objectSnapshot.scale,
+ origin: objectSnapshot.origin,
+ }),
+ };
+
+ const ctx = this.context;
+ ctx.save();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.clearRect(0, 0, pixelWidth, pixelHeight);
+ ctx.drawImage(this._objectLayer, 0, 0);
+ ctx.restore();
+
+ ctx.save();
+ ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
+ this.drawViewportLayer(ctx, this._snapshot);
+ ctx.restore();
+ this._viewportTransformKey = createViewportTransformKey(this.patchmap);
+ }
+
+ clearLayers({ pixelWidth, pixelHeight }) {
+ const clear = (ctx) => {
+ ctx?.save();
+ ctx?.setTransform(1, 0, 0, 1, 0, 0);
+ ctx?.clearRect(0, 0, pixelWidth, pixelHeight);
+ ctx?.restore();
+ };
+ clear(this.context);
+ clear(this._objectLayerContext);
+ }
+
+ cancelPendingRender() {
+ if (this._frame === null) return;
+ const cancelFrame =
+ globalThis.cancelAnimationFrame ?? globalThis.clearTimeout;
+ cancelFrame?.(this._frame);
+ this._frame = null;
+ }
+
+ getObjectSnapshot({ ratio, width, height, pixelWidth, pixelHeight }) {
+ const layerKey = this.getObjectLayerKey({ ratio, pixelWidth, pixelHeight });
+ if (
+ !this._objectsDirty &&
+ this._objectSnapshot &&
+ this._objectLayerKey === layerKey
+ ) {
+ return this._objectSnapshot;
+ }
+
+ const snapshot = createMinimapObjectSnapshot({
+ patchmap: this.patchmap,
+ width,
+ height,
+ inset: getMinimapContentInset(this.options.style),
+ });
+ if (!snapshot) {
+ this._objectSnapshot = null;
+ return null;
+ }
+
+ this._objectSnapshot = snapshot;
+ this._objectLayerKey = layerKey;
+ this._objectsDirty = false;
+ this.drawObjectLayer({
+ snapshot,
+ ratio,
+ pixelWidth,
+ pixelHeight,
+ });
+ return snapshot;
+ }
+
+ getObjectLayerKey({ ratio, pixelWidth, pixelHeight }) {
+ const style = this.options.style;
+ const bounds = this.patchmap?.canvas?.bounds;
+ return JSON.stringify({
+ ratio,
+ pixelWidth,
+ pixelHeight,
+ canvasBounds: bounds
+ ? {
+ x: bounds.x,
+ y: bounds.y,
+ width: bounds.width,
+ height: bounds.height,
+ }
+ : null,
+ objectFill: style.objectFill,
+ });
+ }
+
+ drawObjectLayer({ snapshot, ratio, pixelWidth, pixelHeight }) {
+ const ctx = this._objectLayerContext;
+ if (!ctx) return;
+ const { width, height } = this._size;
+
+ if (
+ this._objectLayer.width !== pixelWidth ||
+ this._objectLayer.height !== pixelHeight
+ ) {
+ this._objectLayer.width = pixelWidth;
+ this._objectLayer.height = pixelHeight;
+ }
+
+ ctx.save();
+ ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
+ ctx.clearRect(0, 0, width, height);
+ this.drawStaticSnapshot(ctx, snapshot);
+ ctx.restore();
+ }
+
+ drawStaticSnapshot(ctx, snapshot) {
+ const canvas = snapshot.canvas;
+
+ ctx.save();
+ this.drawCanvasClip(ctx, canvas);
+ ctx.clip();
+ for (const object of snapshot.objects) {
+ this.drawSilhouette(ctx, object);
+ }
+ ctx.restore();
+ }
+
+ drawViewportLayer(ctx, snapshot) {
+ const canvas = snapshot.canvas;
+ ctx.save();
+ this.drawCanvasClip(ctx, canvas);
+ ctx.clip();
+ this.drawViewport(ctx, snapshot.viewport, this.options.style);
+ ctx.restore();
+ }
+
+ drawCanvasClip(ctx, canvas) {
+ ctx.beginPath();
+ ctx.rect(canvas.x, canvas.y, canvas.width, canvas.height);
+ }
+
+ drawSilhouette(ctx, object) {
+ const paths = object.paths?.length ? object.paths : [object.points];
+ if (!paths.length) return;
+
+ ctx.fillStyle =
+ object.type === 'rect' &&
+ this.options.style.objectFill === DEFAULT_OPTIONS.style.objectFill
+ ? DEFAULT_RECT_FILL
+ : this.options.style.objectFill;
+ ctx.beginPath();
+ for (const path of paths) {
+ this.drawPath(ctx, path);
+ }
+ ctx.fill('evenodd');
+ }
+
+ drawPath(ctx, points) {
+ if (!points?.length) return;
+
+ ctx.moveTo(points[0].x, points[0].y);
+ for (let index = 1; index < points.length; index += 1) {
+ ctx.lineTo(points[index].x, points[index].y);
+ }
+ ctx.closePath();
+ }
+
+ drawViewport(ctx, points, style) {
+ if (!points.length) return;
+
+ ctx.beginPath();
+ this.drawPath(ctx, points);
+ ctx.fillStyle = style.viewportFill;
+ ctx.strokeStyle = style.viewportStroke;
+ ctx.lineWidth = style.viewportStrokeWidth;
+ ctx.fill();
+ ctx.stroke();
+ }
+
+ handlePointerDown(event) {
+ if (this._destroyed) return;
+ this._isPointerActive = true;
+ this.canvas.setPointerCapture?.(event.pointerId);
+ this.moveViewportFromEvent(event);
+ }
+
+ handlePointerMove(event) {
+ if (!this._isPointerActive || this._destroyed) return;
+ this.moveViewportFromEvent(event);
+ }
+
+ handlePointerUp() {
+ this._isPointerActive = false;
+ }
+
+ moveViewportFromEvent(event) {
+ const snapshot = this._snapshot;
+ if (!snapshot) return;
+
+ const rect = this.canvas.getBoundingClientRect();
+ const point = {
+ x: event.clientX - rect.left,
+ y: event.clientY - rect.top,
+ };
+ const canvasPoint = clampCanvasPoint(
+ minimapPointToCanvasPoint({
+ point,
+ canvasBounds: snapshot.canvasBounds,
+ scale: snapshot.scale,
+ origin: snapshot.origin,
+ }),
+ snapshot.canvasBounds,
+ );
+ const globalPoint = this.patchmap.world.toGlobal(
+ new Point(canvasPoint.x, canvasPoint.y),
+ );
+ const viewportPoint = this.patchmap.viewport.toLocal(globalPoint);
+
+ this.patchmap.viewport.moveCenter(viewportPoint.x, viewportPoint.y);
+ this.patchmap.viewport.emit?.('moved', {
+ viewport: this.patchmap.viewport,
+ type: 'minimap',
+ });
+ this.requestRender();
+ }
+
+ destroy() {
+ if (this._destroyed) return;
+ this._destroyed = true;
+ if (this._frame !== null) {
+ this.cancelPendingRender();
+ }
+ this.detach();
+ this.canvas?.remove();
+ this.onDestroy?.(this);
+ this.patchmap = null;
+ this.container = null;
+ this.canvas = null;
+ this.context = null;
+ this._objectLayer = null;
+ this._objectLayerContext = null;
+ this._snapshot = null;
+ this._objectSnapshot = null;
+ }
+}
+
+const mergeOptions = (options) => {
+ const merged = {
+ ...DEFAULT_OPTIONS,
+ ...options,
+ style: {
+ ...DEFAULT_OPTIONS.style,
+ ...(options.style ?? {}),
+ },
+ };
+ const style = {
+ objectFill: merged.style.objectFill,
+ viewportFill: merged.style.viewportFill,
+ viewportStroke: merged.style.viewportStroke,
+ viewportStrokeWidth: normalizeNonNegativeNumber(
+ merged.style.viewportStrokeWidth,
+ 'minimap.style.viewportStrokeWidth',
+ ),
+ };
+ return {
+ style,
+ };
+};
+
+const normalizeNonNegativeNumber = (value, name) => {
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
+ throw new TypeError(`${name} must be a non-negative finite number.`);
+ }
+ return value;
+};
+
+const FALLBACK_SIZE = Object.freeze({ width: 180, height: 120 });
+
+const resolveContainerSize = (container) => {
+ const rect = container?.getBoundingClientRect?.();
+ const width =
+ normalizeSizeValue(container?.clientWidth) ??
+ normalizeSizeValue(rect?.width);
+ const height =
+ normalizeSizeValue(container?.clientHeight) ??
+ normalizeSizeValue(rect?.height);
+
+ return {
+ width: width ?? FALLBACK_SIZE.width,
+ height: height ?? FALLBACK_SIZE.height,
+ };
+};
+
+const normalizeSizeValue = (value) =>
+ Number.isFinite(value) && value > 0 ? value : undefined;
+
+const clampCanvasPoint = (point, canvasBounds) => ({
+ x: Math.min(Math.max(point.x, canvasBounds.x), canvasBounds.right),
+ y: Math.min(Math.max(point.y, canvasBounds.y), canvasBounds.bottom),
+});
+
+const getMinimapContentInset = (style) =>
+ Math.max(0, style.viewportStrokeWidth / 2);
+
+const createViewportTransformKey = (patchmap) => {
+ const viewport = patchmap?.viewport;
+ const world = patchmap?.world;
+ const screen = patchmap?.app?.renderer?.screen;
+ const viewportMatrix = viewport?.worldTransform;
+ const worldMatrix = world?.worldTransform;
+
+ return [
+ viewportMatrix?.a ?? 1,
+ viewportMatrix?.b ?? 0,
+ viewportMatrix?.c ?? 0,
+ viewportMatrix?.d ?? 1,
+ viewportMatrix?.tx ?? 0,
+ viewportMatrix?.ty ?? 0,
+ viewport?.x ?? 0,
+ viewport?.y ?? 0,
+ viewport?.scale?.x ?? 1,
+ viewport?.scale?.y ?? 1,
+ viewport?.pivot?.x ?? 0,
+ viewport?.pivot?.y ?? 0,
+ viewport?.skew?.x ?? 0,
+ viewport?.skew?.y ?? 0,
+ viewport?.rotation ?? 0,
+ viewport?.screenWidth ?? screen?.width ?? 0,
+ viewport?.screenHeight ?? screen?.height ?? 0,
+ worldMatrix?.a ?? 1,
+ worldMatrix?.b ?? 0,
+ worldMatrix?.c ?? 0,
+ worldMatrix?.d ?? 1,
+ worldMatrix?.tx ?? 0,
+ worldMatrix?.ty ?? 0,
+ world?.x ?? 0,
+ world?.y ?? 0,
+ world?.scale?.x ?? 1,
+ world?.scale?.y ?? 1,
+ world?.pivot?.x ?? 0,
+ world?.pivot?.y ?? 0,
+ world?.skew?.x ?? 0,
+ world?.skew?.y ?? 0,
+ world?.rotation ?? 0,
+ screen?.width ?? 0,
+ screen?.height ?? 0,
+ ].join('|');
+};
diff --git a/src/minimap/model.js b/src/minimap/model.js
new file mode 100644
index 00000000..804603e7
--- /dev/null
+++ b/src/minimap/model.js
@@ -0,0 +1,409 @@
+import { Point } from 'pixi.js';
+import { getObjectFrameWorldCorners } from '../utils/transform';
+
+const DEFAULT_ELIGIBLE_TYPES = new Set(['item', 'grid', 'rect']);
+
+export const createMinimapObjectSnapshot = ({
+ patchmap,
+ width,
+ height,
+ inset = 0,
+}) => {
+ const canvasBounds = patchmap?.canvas?.bounds;
+ if (!canvasBounds) {
+ return null;
+ }
+
+ const scale = getMinimapScale({ canvasBounds, width, height, inset });
+ const origin = {
+ x: inset,
+ y: inset,
+ };
+
+ return {
+ canvas: projectRect(canvasBounds, canvasBounds, scale, origin),
+ objects: collectObjectSilhouettes({
+ patchmap,
+ canvasBounds,
+ scale,
+ origin,
+ }),
+ scale,
+ origin,
+ canvasBounds,
+ };
+};
+
+export const createMinimapViewport = ({
+ patchmap,
+ canvasBounds,
+ scale,
+ origin,
+}) => getViewportPolygon({ patchmap, canvasBounds, scale, origin });
+
+export const minimapPointToCanvasPoint = ({
+ point,
+ canvasBounds,
+ scale,
+ origin,
+}) => ({
+ x: canvasBounds.x + (point.x - origin.x) / scale.x,
+ y: canvasBounds.y + (point.y - origin.y) / scale.y,
+});
+
+const getMinimapScale = ({ canvasBounds, width, height, inset }) => {
+ const availableWidth = Math.max(width - inset * 2, 1);
+ const availableHeight = Math.max(height - inset * 2, 1);
+ return {
+ x: availableWidth / canvasBounds.width,
+ y: availableHeight / canvasBounds.height,
+ };
+};
+
+const collectObjectSilhouettes = ({
+ patchmap,
+ canvasBounds,
+ scale,
+ origin,
+}) => {
+ const world = patchmap?.world;
+ if (!world) return [];
+
+ const objects = [];
+ for (const element of collectManagedElements(world)) {
+ if (!isMinimapEligibleElement(element)) continue;
+
+ const silhouette = getElementCanvasSilhouette(element, world);
+ if (
+ !silhouette ||
+ !silhouetteIntersectsCanvasBounds(silhouette, canvasBounds)
+ ) {
+ continue;
+ }
+ objects.push(projectSilhouette(silhouette, canvasBounds, scale, origin));
+ }
+ return objects;
+};
+
+const createSilhouette = (element, paths) => ({
+ type: element?.type,
+ points: paths[0] ?? [],
+ paths,
+});
+
+const projectSilhouette = (silhouette, canvasBounds, scale, origin) => {
+ const paths = silhouette.paths.map((path) =>
+ path.map((point) => projectPoint(point, canvasBounds, scale, origin)),
+ );
+ return createSilhouette(silhouette, paths);
+};
+
+const getElementCanvasSilhouette = (element, world) => {
+ if (element?.type === 'grid') {
+ const paths = getGridCellCanvasPaths(element, world);
+ return paths.length ? createSilhouette(element, paths) : null;
+ }
+ return createSilhouette(element, [
+ getObjectFrameWorldCorners(element).map((point) => world.toLocal(point)),
+ ]);
+};
+
+const getGridCellCanvasPaths = (grid, world) => {
+ const cells = grid?.props?.cells;
+ if (!Array.isArray(cells) || cells.length === 0) return [];
+
+ const size = normalizeSize(grid.props?.item?.size);
+ if (!size) return [];
+
+ const gap = normalizeGap(grid.props?.gap);
+ const { rows, cols, activeCells, activeSet } = analyzeGridCells(cells);
+ if (!rows || !cols || activeCells.length === 0) return [];
+
+ const active = (row, col) => activeSet.has(cellKey(row, col));
+ const toCanvasPoint = createGridLocalToCanvasPoint(grid, world);
+
+ if (activeCells.length === rows * cols) {
+ return [
+ [
+ toGridCanvasPoint({ x: 0, y: 0 }),
+ toGridCanvasPoint({ x: cols, y: 0 }),
+ toGridCanvasPoint({ x: cols, y: rows }),
+ toGridCanvasPoint({ x: 0, y: rows }),
+ ],
+ ];
+ }
+
+ const loops = traceActiveCellBoundaryLoops({ activeCells, active });
+ return loops.map((loop) =>
+ simplifyCollinearPoints(loop.map((point) => toGridCanvasPoint(point))),
+ );
+
+ function toGridCanvasPoint(point) {
+ return toCanvasPoint(
+ getGridAxisEdge(point.x, cols, size.width, gap.x),
+ getGridAxisEdge(point.y, rows, size.height, gap.y),
+ );
+ }
+};
+
+const analyzeGridCells = (cells) => {
+ const rows = cells.length;
+ let cols = 0;
+ const activeCells = [];
+ const activeSet = new Set();
+
+ for (let row = 0; row < rows; row += 1) {
+ const cellRow = Array.isArray(cells[row]) ? cells[row] : [];
+ cols = Math.max(cols, cellRow.length);
+ for (let col = 0; col < cellRow.length; col += 1) {
+ if (!cellRow[col]) continue;
+ activeCells.push({ row, col });
+ activeSet.add(cellKey(row, col));
+ }
+ }
+
+ return { rows, cols, activeCells, activeSet };
+};
+
+const cellKey = (row, col) => `${row},${col}`;
+
+const getGridAxisEdge = (index, count, size, gap) => {
+ const step = size + gap;
+ if (index === count) return Math.max(count * size + (count - 1) * gap, 0);
+ return index * step;
+};
+
+const traceActiveCellBoundaryLoops = ({ activeCells, active }) => {
+ const edges = [];
+ const edgesByStart = new Map();
+ const pushEdge = (startX, startY, endX, endY) => {
+ const edge = createBoundaryEdge(startX, startY, endX, endY);
+ edges.push(edge);
+ const key = pointKey(edge.start);
+ const list = edgesByStart.get(key) ?? [];
+ list.push(edge);
+ edgesByStart.set(key, list);
+ };
+
+ for (const { row, col } of activeCells) {
+ if (!active(row - 1, col)) {
+ pushEdge(col, row, col + 1, row);
+ }
+ if (!active(row, col + 1)) {
+ pushEdge(col + 1, row, col + 1, row + 1);
+ }
+ if (!active(row + 1, col)) {
+ pushEdge(col + 1, row + 1, col, row + 1);
+ }
+ if (!active(row, col - 1)) {
+ pushEdge(col, row + 1, col, row);
+ }
+ }
+
+ const loops = [];
+ for (const edge of edges) {
+ if (edge.used) continue;
+
+ const loop = [edge.start];
+ let current = edge;
+ current.used = true;
+ while (!samePoint(current.end, loop[0])) {
+ loop.push(current.end);
+ current = takeNextBoundaryEdge(edgesByStart, current);
+ if (!current) break;
+ current.used = true;
+ }
+
+ if (current && loop.length >= 3) {
+ loops.push(loop);
+ }
+ }
+ return loops;
+};
+
+const createBoundaryEdge = (startX, startY, endX, endY) => ({
+ start: { x: startX, y: startY },
+ end: { x: endX, y: endY },
+ direction: getEdgeDirection(startX, startY, endX, endY),
+ used: false,
+});
+
+const getEdgeDirection = (startX, startY, endX, endY) => {
+ if (endX > startX) return 0;
+ if (endY > startY) return 1;
+ if (endX < startX) return 2;
+ return 3;
+};
+
+const takeNextBoundaryEdge = (edgesByStart, previousEdge) => {
+ const candidates = edgesByStart.get(pointKey(previousEdge.end));
+ if (!candidates?.length) return null;
+
+ const directionPriority = [];
+ directionPriority[(previousEdge.direction + 1) % 4] = 0;
+ directionPriority[previousEdge.direction] = 1;
+ directionPriority[(previousEdge.direction + 3) % 4] = 2;
+ directionPriority[(previousEdge.direction + 2) % 4] = 3;
+
+ let nextEdge = null;
+ let nextPriority = Infinity;
+ for (const edge of candidates) {
+ if (edge.used) continue;
+ const priority = directionPriority[edge.direction] ?? Infinity;
+ if (priority < nextPriority) {
+ nextEdge = edge;
+ nextPriority = priority;
+ }
+ }
+ return nextEdge;
+};
+
+const pointKey = (point) => `${point.x},${point.y}`;
+
+const samePoint = (a, b) => a.x === b.x && a.y === b.y;
+
+const simplifyCollinearPoints = (points) => {
+ if (points.length <= 3) return points;
+
+ return points.filter((point, index) => {
+ const previous = points[(index - 1 + points.length) % points.length];
+ const next = points[(index + 1) % points.length];
+ return (
+ (previous.x - point.x) * (next.y - point.y) !==
+ (previous.y - point.y) * (next.x - point.x)
+ );
+ });
+};
+
+const collectManagedElements = (root) => {
+ const result = [];
+ const visit = (node) => {
+ if (!node) return;
+ if (node !== root && node?.type && node?.constructor?.isElement) {
+ result.push(node);
+ if (node.type === 'grid') return;
+ }
+ for (const child of getRenderOrderedChildren(node)) {
+ visit(child);
+ }
+ };
+ visit(root);
+ return result;
+};
+
+const getRenderOrderedChildren = (node) => {
+ const children = node.children ?? [];
+ if (children.length <= 1) return children;
+ return [...children]
+ .map((child, index) => ({ child, index }))
+ .sort((a, b) => {
+ const zDiff = (a.child.zIndex ?? 0) - (b.child.zIndex ?? 0);
+ return zDiff || a.index - b.index;
+ })
+ .map(({ child }) => child);
+};
+
+const isMinimapEligibleElement = (element) =>
+ DEFAULT_ELIGIBLE_TYPES.has(element?.type) &&
+ element.visible !== false &&
+ element.renderable !== false &&
+ element.props?.show !== false;
+
+const createGridLocalToCanvasPoint = (grid, world) => {
+ const origin = world.toLocal(grid.toGlobal(new Point(0, 0)));
+ const xAxis = world.toLocal(grid.toGlobal(new Point(1, 0)));
+ const yAxis = world.toLocal(grid.toGlobal(new Point(0, 1)));
+ const basisX = {
+ x: xAxis.x - origin.x,
+ y: xAxis.y - origin.y,
+ };
+ const basisY = {
+ x: yAxis.x - origin.x,
+ y: yAxis.y - origin.y,
+ };
+
+ return (x, y) => ({
+ x: origin.x + basisX.x * x + basisY.x * y,
+ y: origin.y + basisX.y * x + basisY.y * y,
+ });
+};
+
+const normalizeSize = (size) => {
+ if (typeof size === 'number') return { width: size, height: size };
+ if (Number.isFinite(size?.width) && Number.isFinite(size?.height)) {
+ return size;
+ }
+ return null;
+};
+
+const normalizeGap = (gap) => {
+ if (typeof gap === 'number') return { x: gap, y: gap };
+ return {
+ x: Number.isFinite(gap?.x) ? gap.x : 0,
+ y: Number.isFinite(gap?.y) ? gap.y : 0,
+ };
+};
+
+const getViewportPolygon = ({ patchmap, canvasBounds, scale, origin }) => {
+ const viewport = patchmap?.viewport;
+ const world = patchmap?.world;
+ const screen = patchmap?.app?.renderer?.screen;
+ const screenWidth = viewport?.screenWidth ?? screen?.width;
+ const screenHeight = viewport?.screenHeight ?? screen?.height;
+ if (!viewport || !world || !screenWidth || !screenHeight) return [];
+
+ return [
+ new Point(0, 0),
+ new Point(screenWidth, 0),
+ new Point(screenWidth, screenHeight),
+ new Point(0, screenHeight),
+ ].map((point) =>
+ projectPoint(
+ screenPointToCanvasPoint({ world, point }),
+ canvasBounds,
+ scale,
+ origin,
+ ),
+ );
+};
+
+const screenPointToCanvasPoint = ({ world, point }) => {
+ return world.toLocal(point);
+};
+
+const projectRect = (rect, canvasBounds, scale, origin) => ({
+ x: origin.x + (rect.x - canvasBounds.x) * scale.x,
+ y: origin.y + (rect.y - canvasBounds.y) * scale.y,
+ width: rect.width * scale.x,
+ height: rect.height * scale.y,
+});
+
+const projectPoint = (point, canvasBounds, scale, origin) => ({
+ x: origin.x + (point.x - canvasBounds.x) * scale.x,
+ y: origin.y + (point.y - canvasBounds.y) * scale.y,
+});
+
+const silhouetteIntersectsCanvasBounds = (silhouette, canvasBounds) =>
+ silhouette.paths.some((path) =>
+ pathIntersectsCanvasBounds(path, canvasBounds),
+ );
+
+const pathIntersectsCanvasBounds = (path, canvasBounds) => {
+ if (!Array.isArray(path) || path.length === 0) return false;
+ let minX = Infinity;
+ let maxX = -Infinity;
+ let minY = Infinity;
+ let maxY = -Infinity;
+ for (const point of path) {
+ minX = Math.min(minX, point.x);
+ maxX = Math.max(maxX, point.x);
+ minY = Math.min(minY, point.y);
+ maxY = Math.max(maxY, point.y);
+ }
+ return (
+ maxX >= canvasBounds.x &&
+ minX <= canvasBounds.right &&
+ maxY >= canvasBounds.y &&
+ minY <= canvasBounds.bottom
+ );
+};
diff --git a/src/minimap/model.test.js b/src/minimap/model.test.js
new file mode 100644
index 00000000..cc3b4ee7
--- /dev/null
+++ b/src/minimap/model.test.js
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+import {
+ createMinimapObjectSnapshot,
+ minimapPointToCanvasPoint,
+} from './model';
+
+describe('minimap model', () => {
+ it('projects finite canvas bounds to fill the minimap area', () => {
+ const snapshot = createMinimapObjectSnapshot({
+ patchmap: {
+ canvas: {
+ bounds: {
+ x: 0,
+ y: 0,
+ width: 1000,
+ height: 500,
+ right: 1000,
+ bottom: 500,
+ },
+ },
+ },
+ width: 240,
+ height: 144,
+ inset: 1,
+ });
+
+ expect(snapshot.canvas).toEqual({
+ x: 1,
+ y: 1,
+ width: 238,
+ height: 142,
+ });
+ expect(snapshot.scale).toEqual({
+ x: 0.238,
+ y: 0.284,
+ });
+ });
+
+ it('maps minimap points back to finite canvas coordinates', () => {
+ expect(
+ minimapPointToCanvasPoint({
+ point: { x: 90, y: 60 },
+ canvasBounds: {
+ x: 0,
+ y: 0,
+ width: 1000,
+ height: 500,
+ right: 1000,
+ bottom: 500,
+ },
+ scale: { x: 0.164, y: 0.164 },
+ origin: { x: 8, y: 19 },
+ }),
+ ).toEqual({
+ x: 500,
+ y: 250,
+ });
+ });
+});
diff --git a/src/patchmap.js b/src/patchmap.js
index 39195051..b1e2f1d2 100644
--- a/src/patchmap.js
+++ b/src/patchmap.js
@@ -1,6 +1,11 @@
import gsap from 'gsap';
import { Application, UPDATE_PRIORITY } from 'pixi.js';
import { isValidationError } from 'zod-validation-error';
+import CanvasBoundsController from './canvas-bounds/controller';
+import {
+ hasAutoCanvasBounds,
+ normalizeCanvasBounds,
+} from './canvas-bounds/options';
import { UndoRedoManager } from './command/UndoRedoManager';
import './display/components/registry';
import { draw } from './display/draw';
@@ -18,14 +23,41 @@ import {
initResizeObserver,
initViewport,
} from './init';
+import Minimap from './minimap/Minimap';
import Transformer from './transformer/Transformer';
import { convertLegacyData } from './utils/convert';
import { event } from './utils/event/canvas';
import { WildcardEventEmitter } from './utils/event/WildcardEventEmitter';
import { selector } from './utils/selector/selector';
import { themeStore } from './utils/theme';
+import { getBoundsFromPoints, getObjectWorldCorners } from './utils/transform';
import { validateMapData } from './utils/validator';
+const AUTO_BOUNDS_PADDING = 500;
+
+/**
+ * @typedef {object} CanvasBoundsInput
+ * @property {number} [x]
+ * @property {number} [y]
+ * @property {number} [width]
+ * @property {number} [height]
+ */
+
+/**
+ * @typedef {object} CanvasInitOptions
+ * @property {CanvasBoundsInput} [bounds]
+ */
+
+/**
+ * @typedef {object} PatchmapInitOptions
+ * @property {object} [app]
+ * @property {object} [viewport]
+ * @property {object} [theme]
+ * @property {Array