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} [assets] + * @property {CanvasInitOptions} [canvas] + * @property {Transformer} [transformer] + */ + class Patchmap extends WildcardEventEmitter { _app = null; _viewport = null; @@ -37,8 +69,20 @@ class Patchmap extends WildcardEventEmitter { _transformer = null; _stateManager = null; _world = null; + _element = null; _viewTransform = this._createViewTransform(); + _initPromise = null; + _destroyRequestedDuringInit = false; + /** @type {CanvasBoundsInput | null} */ + _canvasBoundsInput = null; + /** @type {import('./canvas-bounds/options').CanvasBounds | null} */ + _canvasBounds = null; + /** @type {import('./canvas-bounds/controller').default | null} */ + _canvasBoundsController = null; + _minimaps = new Set(); _drawToken = 0; + _autoCanvasBoundsFrame = null; + _requestAutoCanvasBoundsRefresh = () => this.requestAutoCanvasBoundsRefresh(); get app() { return this._app; @@ -60,6 +104,15 @@ class Patchmap extends WildcardEventEmitter { return this._isInit; } + /** + * @returns {{ bounds: import('./canvas-bounds/options').CanvasBounds | null }} + */ + get canvas() { + return { + bounds: this._canvasBounds, + }; + } + set isInit(value) { this._isInit = value; } @@ -117,27 +170,60 @@ class Patchmap extends WildcardEventEmitter { }; } + /** + * @param {HTMLElement} element + * @param {PatchmapInitOptions} [opts] + */ async init(element, opts = {}) { if (this.isInit) return; + if (this._initPromise) return this._initPromise; + this._destroyRequestedDuringInit = false; + this._initPromise = this._initialize(element, opts).finally(() => { + this._initPromise = null; + }); + return this._initPromise; + } + + async _initialize(element, opts = {}) { const { app: appOptions = {}, viewport: viewportOptions = {}, theme: themeOptions = {}, assets: assetsOptions = [], + canvas: canvasOptions, transformer, } = opts; + const canvasBoundsInput = + canvasOptions && Object.hasOwn(canvasOptions, 'bounds') + ? canvasOptions.bounds + : this._canvasBoundsInput; + const canvasBounds = normalizeCanvasBounds(canvasBoundsInput); + this._element = element; this.undoRedoManager._setHotkeys(); this._theme.set(themeOptions); + this._canvasBoundsInput = canvasBoundsInput ?? null; + this._canvasBounds = canvasBounds; this._app = new Application(); await initApp(this.app, { resizeTo: element, ...appOptions }); const store = this._createStoreContext(); this._viewport = initViewport(this.app, viewportOptions, store); + this.viewport.on?.( + 'object_transformed', + this._requestAutoCanvasBoundsRefresh, + ); this._world = new World({ store }); store.world = this._world; this.viewport.addChild(this._world); + this._canvasBoundsController = canvasBounds + ? new CanvasBoundsController({ + viewport: this.viewport, + world: this._world, + bounds: canvasBounds, + }) + : null; this._viewTransform.attach({ viewport: this.viewport, world: this._world }); await initAsset(assetsOptions); @@ -147,7 +233,11 @@ class Patchmap extends WildcardEventEmitter { element, this.app, this.viewport, - () => this._viewTransform.applyWorldTransform(), + () => { + this._canvasBoundsController?.resize(); + this._viewTransform.applyWorldTransform(); + this._canvasBoundsController?.applyViewportClamp(); + }, ); this._stateManager = new StateManager(this); this._stateManager.register('selection', SelectionState, true); @@ -155,21 +245,38 @@ class Patchmap extends WildcardEventEmitter { this.transformer = transformer; } this.isInit = true; + if (this._destroyRequestedDuringInit) { + this.destroy(); + return; + } this.emit('patchmap:initialized', { target: this }); } destroy() { - if (!this.isInit) return; + if (!this.isInit) { + if (this._initPromise) { + this._destroyRequestedDuringInit = true; + this._destroyMinimaps(); + } + return; + } + this._destroyMinimaps(); this.undoRedoManager.destroy(); this.animationContext.revert(); - this.stateManager.resetState(); - this.stateManager.destroy(); + this.stateManager?.resetState(); + this.stateManager?.destroy(); + this._canvasBoundsController?.destroy(); + this.viewport?.off?.( + 'object_transformed', + this._requestAutoCanvasBoundsRefresh, + ); + this.cancelPendingAutoCanvasBoundsRefresh(); event.removeAllEvent(this.viewport); this.viewport.destroy({ children: true, context: true, style: true }); const parentElement = this.app.canvas.parentElement; this.app.destroy(true); - parentElement.remove(); + parentElement?.remove(); if (this._resizeObserver) this._resizeObserver.disconnect(); this._app = null; @@ -182,8 +289,16 @@ class Patchmap extends WildcardEventEmitter { this._transformer = null; this._stateManager = null; this._world = null; + this._element = null; this._viewTransform = this._createViewTransform(); + this._initPromise = null; + this._destroyRequestedDuringInit = false; + this._canvasBoundsInput = null; + this._canvasBounds = null; + this._canvasBoundsController = null; + this._minimaps = new Set(); this._drawToken = 0; + this._autoCanvasBoundsFrame = null; this.emit('patchmap:destroyed', { target: this }); this.removeAllListeners(); } @@ -205,6 +320,7 @@ class Patchmap extends WildcardEventEmitter { this.animationContext.revert(); event.removeAllEvent(this.viewport); draw(store, validatedData); + this._refreshAutoCanvasBounds(validatedData); // Force a refresh of all relation elements after the initial draw. This ensures // that all link targets exist in the scene graph before the relations @@ -255,7 +371,10 @@ class Patchmap extends WildcardEventEmitter { * @returns {void|null} */ focus(ids, opts) { - return focus(this.viewport, this.world, ids, opts); + const result = focus(this.viewport, this.world, ids, opts); + this._canvasBoundsController?.applyViewportClamp(); + this._emitViewportMoved('focus'); + return result; } /** @@ -264,7 +383,118 @@ class Patchmap extends WildcardEventEmitter { * @returns {void|null} */ fit(ids, opts) { - return fitViewport(this.viewport, this.world, ids, opts); + const result = fitViewport(this.viewport, this.world, ids, opts); + this._canvasBoundsController?.applyViewportClamp(); + this._emitViewportMoved('fit'); + return result; + } + + /** + * @param {CanvasBoundsInput | null | undefined} bounds + * @returns {import('./canvas-bounds/options').CanvasBounds | null} + */ + setCanvasBounds(bounds) { + this._canvasBoundsInput = bounds ?? null; + const canvasBounds = normalizeCanvasBounds(bounds); + return this._applyCanvasBounds(canvasBounds); + } + + _applyCanvasBounds(canvasBounds) { + this._canvasBounds = canvasBounds; + this._syncCanvasBoundsToStore(); + + if (!this.isInit) { + return canvasBounds; + } + + if (!canvasBounds) { + this._canvasBoundsController?.destroy(); + this._canvasBoundsController = null; + this.viewport.forceHitArea = null; + } else if (this._canvasBoundsController) { + this._canvasBoundsController.setBounds(canvasBounds); + } else { + this._canvasBoundsController = new CanvasBoundsController({ + viewport: this.viewport, + world: this.world, + bounds: canvasBounds, + }); + } + + this.emit('patchmap:canvas-bounds-changed', { + bounds: canvasBounds, + target: this, + }); + return canvasBounds; + } + + _refreshAutoCanvasBounds(data) { + if (!hasAutoCanvasBounds(this._canvasBoundsInput)) return; + + const canvasBounds = normalizeCanvasBounds(this._canvasBoundsInput, { + contentBounds: this._getCanvasContentBounds(data), + }); + if (areCanvasBoundsEqual(canvasBounds, this._canvasBounds)) return; + + this._applyCanvasBounds(canvasBounds); + } + + requestAutoCanvasBoundsRefresh() { + if (!hasAutoCanvasBounds(this._canvasBoundsInput)) return; + if (this._autoCanvasBoundsFrame !== null) return; + + const requestFrame = + globalThis.requestAnimationFrame ?? + ((callback) => globalThis.setTimeout(callback, 16)); + this._autoCanvasBoundsFrame = requestFrame(() => { + this._autoCanvasBoundsFrame = null; + if (!this.isInit) return; + this._refreshAutoCanvasBounds(); + }); + } + + cancelPendingAutoCanvasBoundsRefresh() { + if (this._autoCanvasBoundsFrame === null) return; + const cancelFrame = + globalThis.cancelAnimationFrame ?? globalThis.clearTimeout; + cancelFrame?.(this._autoCanvasBoundsFrame); + this._autoCanvasBoundsFrame = null; + } + + _getCanvasContentBounds(data) { + const imageBounds = unionBounds( + getElementsBounds(data, undefined, isImageElement), + this._getRenderedCanvasContentBounds(isImageElement), + ); + const nonImageBounds = unionBounds( + getElementsBounds(data, undefined, isNonImageElement), + this._getRenderedCanvasContentBounds(isNonImageElement), + ); + return padBounds( + unionBounds(imageBounds, nonImageBounds), + AUTO_BOUNDS_PADDING, + ); + } + + _getRenderedCanvasContentBounds(predicate = () => true) { + const world = this.world; + if (!world) return null; + + const elements = collectRenderedElements(world, predicate); + if (elements.length === 0) return null; + + const canvasPoints = elements.flatMap((element) => + getObjectWorldCorners(element).map((point) => world.toLocal(point)), + ); + if (canvasPoints.length === 0) return null; + + const bounds = getBoundsFromPoints(canvasPoints); + return { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }; } get rotation() { @@ -279,6 +509,31 @@ class Patchmap extends WildcardEventEmitter { return selector(this.world, path, opts); } + /** + * @param {HTMLElement} container + * @param {import('./minimap/Minimap').MinimapOptions} [options] + * @returns {Minimap} + */ + createMinimap(container, options = {}) { + const minimap = new Minimap({ + patchmap: this, + container, + options, + onDestroy: (target) => { + this._minimaps.delete(target); + }, + }); + this._minimaps.add(minimap); + return minimap; + } + + _destroyMinimaps() { + for (const minimap of this._minimaps) { + minimap.destroy(); + } + this._minimaps.clear(); + } + _createViewTransform() { return new ViewTransform({ onRotate: (angle) => @@ -289,7 +544,7 @@ class Patchmap extends WildcardEventEmitter { } _createStoreContext() { - return { + const store = { app: this.app, viewport: this._viewport, world: this._world, @@ -298,6 +553,28 @@ class Patchmap extends WildcardEventEmitter { theme: this.theme, animationContext: this.animationContext, }; + if (this._canvasBounds) { + store.canvasBounds = this._canvasBounds; + } + return store; + } + + _syncCanvasBoundsToStore() { + const store = this.world?.store; + if (!store) return; + + if (this._canvasBounds) { + store.canvasBounds = this._canvasBounds; + } else { + delete store.canvasBounds; + } + } + + _emitViewportMoved(type) { + this.viewport?.emit?.('moved', { + viewport: this.viewport, + type, + }); } } @@ -310,4 +587,140 @@ function scheduleUserVisibleTask(task) { setTimeout(task, 0); } +const areCanvasBoundsEqual = (a, b) => + a === b || + (a != null && + b != null && + a.x === b.x && + a.y === b.y && + a.width === b.width && + a.height === b.height); + +const getElementsBounds = ( + elements, + origin = { x: 0, y: 0 }, + predicate = () => true, +) => { + if (!Array.isArray(elements) || elements.length === 0) return null; + + return elements + .map((element) => getElementBounds(element, origin, predicate)) + .filter(Boolean) + .reduce(unionBounds, null); +}; + +const getElementBounds = (element, origin, predicate) => { + if (!element || element.show === false) return null; + + const x = + origin.x + (Number.isFinite(element.attrs?.x) ? element.attrs.x : 0); + const y = + origin.y + (Number.isFinite(element.attrs?.y) ? element.attrs.y : 0); + const ownBounds = predicate(element) + ? getElementOwnBounds(element, { x, y }) + : null; + const childBounds = getElementsBounds(element.children, { x, y }, predicate); + + return unionBounds(ownBounds, childBounds); +}; + +const getElementOwnBounds = (element, origin) => { + const size = getElementSize(element); + if (!size) return null; + + return { + x: origin.x, + y: origin.y, + width: size.width, + height: size.height, + }; +}; + +const getElementSize = (element) => { + if (element.type === 'grid') { + const rows = element.cells?.length ?? 0; + const cols = Math.max(0, ...(element.cells ?? []).map((row) => row.length)); + const itemSize = normalizeSize(element.item?.size); + if (!itemSize || rows === 0 || cols === 0) return null; + + const gap = element.gap ?? {}; + return { + width: cols * itemSize.width + Math.max(cols - 1, 0) * (gap.x ?? 0), + height: rows * itemSize.height + Math.max(rows - 1, 0) * (gap.y ?? 0), + }; + } + + if (['item', 'image', 'rect', 'text'].includes(element.type)) { + return normalizeSize(element.size); + } + + return null; +}; + +const normalizeSize = (size) => { + if (typeof size === 'number') { + return { width: size, height: size }; + } + if (Number.isFinite(size?.width) && Number.isFinite(size?.height)) { + return { width: size.width, height: size.height }; + } + return null; +}; + +const unionBounds = (a, b) => { + if (!a) return b; + if (!b) return a; + + const minX = Math.min(a.x, b.x); + const minY = Math.min(a.y, b.y); + const maxX = Math.max(a.x + a.width, b.x + b.width); + const maxY = Math.max(a.y + a.height, b.y + b.height); + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; +}; + +const padBounds = (bounds, padding) => { + if (!bounds || padding <= 0) return bounds; + return { + x: bounds.x - padding, + y: bounds.y - padding, + width: bounds.width + padding * 2, + height: bounds.height + padding * 2, + }; +}; + +const isImageElement = (element) => element?.type === 'image'; + +const isNonImageElement = (element) => element?.type !== 'image'; + +const collectRenderedElements = (root, predicate) => { + const result = []; + const visit = (node) => { + if (!node) return; + + if (node !== root && node?.type && node?.constructor?.isElement) { + if ( + predicate(node) && + node.visible !== false && + node.renderable !== false && + node.props?.show !== false + ) { + result.push(node); + } + if (node.type === 'grid') return; + } + + for (const child of node.children ?? []) { + visit(child); + } + }; + visit(root); + return result; +}; + export { Patchmap }; diff --git a/src/tests/render/canvas-bounds.test.js b/src/tests/render/canvas-bounds.test.js new file mode 100644 index 00000000..0ed13e70 --- /dev/null +++ b/src/tests/render/canvas-bounds.test.js @@ -0,0 +1,546 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { Patchmap } from '../../patchmap'; + +const createHost = ({ width = 800, height = 600 } = {}) => { + const element = document.createElement('div'); + element.style.width = `${width}px`; + element.style.height = `${height}px`; + document.body.appendChild(element); + return element; +}; + +const waitForFrame = () => + new Promise((resolve) => requestAnimationFrame(resolve)); + +const getRendererPoint = (patchmap, clientPoint) => { + const point = { x: 0, y: 0 }; + patchmap.app.renderer.events.mapPositionToPoint( + point, + clientPoint.x, + clientPoint.y, + ); + return point; +}; + +const getViewportClientPoint = (patchmap, xRatio, yRatio) => { + const rect = patchmap.app.canvas.getBoundingClientRect(); + return { + x: rect.left + rect.width * xRatio, + y: rect.top + rect.height * yRatio, + }; +}; + +const dispatchViewportWheel = (patchmap, clientPoint, deltaY) => { + patchmap.app.canvas.dispatchEvent( + new WheelEvent('wheel', { + clientX: clientPoint.x, + clientY: clientPoint.y, + deltaY, + bubbles: true, + cancelable: true, + }), + ); +}; + +const getDistance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); + +describe('canvas bounds', () => { + let patchmap; + let element; + + afterEach(() => { + patchmap?.destroy(); + patchmap = null; + element?.remove(); + element = null; + document.body.innerHTML = ''; + }); + + it('keeps infinite canvas behavior when bounds are omitted', async () => { + element = createHost(); + patchmap = new Patchmap(); + + await patchmap.init(element); + + expect(patchmap.canvas.bounds).toBeNull(); + expect(patchmap._canvasBoundsController).toBeNull(); + }); + + it('installs finite canvas bounds without adding a render layer', async () => { + element = createHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: -20, y: 30, width: 120, height: 80 }, + }, + }); + + expect(patchmap.canvas.bounds).toEqual({ + x: -20, + y: 30, + width: 120, + height: 80, + right: 100, + bottom: 110, + }); + expect(patchmap._canvasBoundsController).toBeTruthy(); + expect(patchmap.viewport.forceHitArea).toMatchObject({ + x: -20, + y: 30, + width: 120, + height: 80, + }); + }); + + it('resolves missing finite canvas bounds from rendered content', async () => { + element = createHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: {}, + }, + }); + + expect(patchmap.canvas.bounds).toEqual({ + x: 0, + y: 0, + width: 5000, + height: 3000, + right: 5000, + bottom: 3000, + }); + + patchmap.draw([ + { + type: 'rect', + id: 'content', + attrs: { x: -200, y: 100 }, + size: { width: 400, height: 300 }, + }, + ]); + + expect(patchmap.canvas.bounds).toEqual({ + x: -2500, + y: -1250, + width: 5000, + height: 3000, + right: 2500, + bottom: 1750, + }); + expect(patchmap.viewport.forceHitArea).toMatchObject({ + x: -2500, + y: -1250, + width: 5000, + height: 3000, + }); + }); + + it('refreshes automatic finite canvas bounds when an image texture changes rendered bounds', async () => { + element = createHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: {}, + }, + }); + + patchmap.draw([ + { + type: 'rect', + id: 'panel', + attrs: { x: 100, y: 100 }, + size: { width: 100, height: 100 }, + }, + ]); + + const image = patchmap.selector('$..[?(@.id=="map-image")]')?.[0]; + expect(image).toBeUndefined(); + + patchmap.draw([ + { + type: 'image', + id: 'map-image', + source: '', + attrs: { x: -9000, y: -1000 }, + }, + { + type: 'rect', + id: 'panel', + attrs: { x: 100, y: 100 }, + size: { width: 100, height: 100 }, + }, + ]); + + const renderedImage = patchmap.selector('$..[?(@.id=="map-image")]')[0]; + renderedImage.sprite.width = 12000; + renderedImage.sprite.height = 4000; + patchmap.viewport.setZoom(2, true); + patchmap.viewport.moveCenter(1500, -700); + patchmap.viewport.emit('object_transformed', renderedImage); + await waitForFrame(); + + expect(patchmap.canvas.bounds).toEqual({ + x: -9500, + y: -1500, + width: 13000, + height: 5000, + right: 3500, + bottom: 3500, + }); + }); + + it('keeps pre-init finite canvas bounds when init omits canvas options', async () => { + element = createHost(); + patchmap = new Patchmap(); + + patchmap.setCanvasBounds({ x: -500, y: -300, width: 5000, height: 3000 }); + await patchmap.init(element); + + expect(patchmap.canvas.bounds).toEqual({ + x: -500, + y: -300, + width: 5000, + height: 3000, + right: 4500, + bottom: 2700, + }); + expect(patchmap._canvasBoundsController).toBeTruthy(); + expect(patchmap.world.store.canvasBounds).toBe(patchmap.canvas.bounds); + }); + + it('keeps viewport clamping aligned with world transforms', async () => { + element = createHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 2400, height: 2400 }, + }, + }); + + patchmap.rotation.set(25); + patchmap.flip.set({ x: true }); + patchmap.viewport.moveCenter(100000, 100000); + patchmap.viewport.emit('world_transformed'); + + const frame = getVisibleCanvasFrame(patchmap); + expect(frame.x).toBeGreaterThanOrEqual(-0.01); + expect(frame.y).toBeGreaterThanOrEqual(-0.01); + expect(frame.x + frame.width).toBeLessThanOrEqual(2400.01); + expect(frame.y + frame.height).toBeLessThanOrEqual(2400.01); + }); + + it('raises finite canvas minScale to the scale where the whole canvas fits', async () => { + element = createHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 2000, height: 1000 }, + }, + viewport: { + plugins: { + clampZoom: { minScale: 0.1 }, + }, + }, + }); + + const clampZoom = patchmap.viewport.plugins.get('clamp-zoom'); + expect(clampZoom.options.minScale).toBeCloseTo(0.4); + }); + + it('clamps viewport movement to finite canvas bounds', async () => { + element = createHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: -20, y: 30, width: 2000, height: 1600 }, + }, + }); + + patchmap.viewport.moveCenter(100000, 100000); + patchmap.viewport.emit('moved'); + + expect(patchmap.viewport.right).toBeLessThanOrEqual(1980); + expect(patchmap.viewport.bottom).toBeLessThanOrEqual(1630); + + patchmap.viewport.moveCenter(-100000, -100000); + patchmap.viewport.emit('moved'); + + expect(patchmap.viewport.left).toBeGreaterThanOrEqual(-20); + expect(patchmap.viewport.top).toBeGreaterThanOrEqual(30); + }); + + it('updates finite canvas bounds at runtime', async () => { + element = createHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 5000, height: 3000 }, + }, + }); + + const events = []; + patchmap.on('patchmap:canvas-bounds-changed', (event) => { + events.push(event.bounds); + }); + + patchmap.setCanvasBounds({ + x: -4000, + y: -3000, + width: 8000, + height: 8000, + }); + + expect(patchmap.canvas.bounds).toEqual({ + x: -4000, + y: -3000, + width: 8000, + height: 8000, + right: 4000, + bottom: 5000, + }); + expect(patchmap.viewport.forceHitArea).toMatchObject({ + x: -4000, + y: -3000, + width: 8000, + height: 8000, + }); + expect(patchmap.world.store.canvasBounds).toBe(patchmap.canvas.bounds); + expect(events).toEqual([patchmap.canvas.bounds]); + + patchmap.viewport.moveCenter(-100000, -100000); + patchmap.viewport.emit('moved'); + + const frame = getVisibleCanvasFrame(patchmap); + expect(frame.x).toBeGreaterThanOrEqual(-4000.01); + expect(frame.y).toBeGreaterThanOrEqual(-3000.01); + }); + + it('clamps viewport movement against rotated canvas bounds', async () => { + element = createHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 2400, height: 2400 }, + }, + }); + + patchmap.rotation.set(90); + patchmap.viewport.moveCenter(100000, 100000); + patchmap.viewport.emit('moved'); + + const frame = getVisibleCanvasFrame(patchmap); + expect(frame.x).toBeGreaterThanOrEqual(-0.01); + expect(frame.y).toBeGreaterThanOrEqual(-0.01); + expect(frame.x + frame.width).toBeLessThanOrEqual(2400.01); + expect(frame.y + frame.height).toBeLessThanOrEqual(2400.01); + }); + + it('keeps wheel zoom anchored to the pointer while finite canvas bounds are clamped', async () => { + element = createHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 5000, height: 3000 }, + }, + viewport: { + plugins: { + clampZoom: { minScale: 0.1 }, + }, + }, + }); + + const minScale = + patchmap.viewport.plugins.get('clamp-zoom').options.minScale; + patchmap.viewport.setZoom(minScale, true); + patchmap._canvasBoundsController.applyViewportClamp({ + centerUnderflow: true, + }); + const clampOptions = []; + const applyViewportClamp = + patchmap._canvasBoundsController.applyViewportClamp.bind( + patchmap._canvasBoundsController, + ); + patchmap._canvasBoundsController.applyViewportClamp = (options) => { + clampOptions.push(options ?? {}); + return applyViewportClamp(options); + }; + + const clientPoint = getViewportClientPoint(patchmap, 0.5, 0.5); + const pointer = getRendererPoint(patchmap, clientPoint); + const before = patchmap.viewport.toWorld(pointer.x, pointer.y); + + dispatchViewportWheel(patchmap, clientPoint, -120); + await waitForFrame(); + + const after = patchmap.viewport.toWorld(pointer.x, pointer.y); + + expect(patchmap.viewport.scale.x).toBeGreaterThan(minScale); + expect(getDistance(after, before)).toBeLessThan(0.5); + }); + + it('keeps wheel zoom anchored when one finite canvas axis underflows the viewport', async () => { + element = createHost({ width: 600, height: 900 }); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 5000, height: 3000 }, + }, + viewport: { + plugins: { + clampZoom: { minScale: 0.1 }, + }, + }, + }); + + const minScale = + patchmap.viewport.plugins.get('clamp-zoom').options.minScale; + patchmap.viewport.setZoom(minScale, true); + patchmap._canvasBoundsController.applyViewportClamp(); + + const clientPoint = getViewportClientPoint(patchmap, 0.65, 0.48); + const pointer = getRendererPoint(patchmap, clientPoint); + const before = patchmap.viewport.toWorld(pointer.x, pointer.y); + + dispatchViewportWheel(patchmap, clientPoint, -120); + await waitForFrame(); + + const after = patchmap.viewport.toWorld(pointer.x, pointer.y); + + expect(patchmap.viewport.scale.x).toBeGreaterThan(minScale); + expect(getDistance(after, before)).toBeLessThan(0.5); + }); + + it('keeps underflowing finite canvas bounds visible without forcing center alignment', async () => { + element = createHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 100, y: 50, width: 120, height: 80 }, + }, + }); + + patchmap._canvasBoundsController.applyViewportClamp(); + patchmap.viewport.top = 50; + patchmap._canvasBoundsController.applyViewportClamp(); + + const frame = getVisibleCanvasFrame(patchmap); + expect(frame.x).toBeLessThanOrEqual(100.01); + expect(frame.y).toBeLessThanOrEqual(50.01); + expect(frame.x + frame.width).toBeGreaterThanOrEqual(219.99); + expect(frame.y + frame.height).toBeGreaterThanOrEqual(129.99); + expect(frame.y).toBeCloseTo(50, 1); + }); + + it('keeps focus and fit inside finite canvas bounds', async () => { + element = createHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: -20, y: 30, width: 2000, height: 1600 }, + }, + }); + patchmap.draw([ + { + type: 'item', + id: 'outside', + size: 50, + components: [ + { + type: 'background', + source: { type: 'rect', fill: 'white' }, + }, + ], + attrs: { x: 100000, y: 100000 }, + }, + ]); + + patchmap.focus('outside'); + + expect(patchmap.viewport.right).toBeLessThanOrEqual(1980); + expect(patchmap.viewport.bottom).toBeLessThanOrEqual(1630); + + patchmap.viewport.moveCenter(-100000, -100000); + patchmap.fit('outside', { padding: 0 }); + + expect(patchmap.viewport.right).toBeLessThanOrEqual(1980); + expect(patchmap.viewport.bottom).toBeLessThanOrEqual(1630); + }); + + it('keeps programmatic updates permissive in finite canvas mode', async () => { + element = createHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 500, height: 300 }, + }, + }); + patchmap.draw([ + { + type: 'item', + id: 'bounded', + size: 50, + components: [ + { + type: 'background', + source: { type: 'rect', fill: 'white' }, + }, + ], + attrs: { x: 100, y: 100 }, + }, + ]); + const item = patchmap.selector('$..[?(@.id=="bounded")]')[0]; + + patchmap.update({ + elements: [item], + changes: { attrs: { x: 1000, y: -1000 } }, + history: true, + }); + + expect(item.x).toBeCloseTo(1000, 1); + expect(item.y).toBeCloseTo(-1000, 1); + expect(item.props.attrs.x).toBeCloseTo(1000, 1); + expect(item.props.attrs.y).toBeCloseTo(-1000, 1); + + patchmap.undoRedoManager.undo(); + + expect(item.x).toBeCloseTo(100, 1); + expect(item.y).toBeCloseTo(100, 1); + + patchmap.undoRedoManager.redo(); + + expect(item.x).toBeCloseTo(1000, 1); + expect(item.y).toBeCloseTo(-1000, 1); + }); +}); + +const getVisibleCanvasFrame = (patchmap) => { + const points = [ + { x: 0, y: 0 }, + { x: patchmap.app.screen.width, y: 0 }, + { x: patchmap.app.screen.width, y: patchmap.app.screen.height }, + { x: 0, y: patchmap.app.screen.height }, + ].map((point) => patchmap.world.toLocal(point)); + const xs = points.map((point) => point.x); + const ys = points.map((point) => point.y); + const x = Math.min(...xs); + const y = Math.min(...ys); + return { + x, + y, + width: Math.max(...xs) - x, + height: Math.max(...ys) - y, + }; +}; diff --git a/src/tests/render/minimap.test.js b/src/tests/render/minimap.test.js new file mode 100644 index 00000000..aaaaeaa4 --- /dev/null +++ b/src/tests/render/minimap.test.js @@ -0,0 +1,822 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Patchmap } from '../../patchmap'; + +const createHost = () => { + const element = document.createElement('div'); + element.style.width = '800px'; + element.style.height = '600px'; + document.body.appendChild(element); + return element; +}; + +const createMinimapHost = ({ width = 180, height = 120 } = {}) => { + const element = document.createElement('div'); + element.style.width = `${width}px`; + element.style.height = `${height}px`; + document.body.appendChild(element); + return element; +}; + +const nextAnimationFrame = () => + new Promise((resolve) => requestAnimationFrame(resolve)); + +const serializePoints = (points) => + points.map((point) => ({ x: point.x, y: point.y })); + +describe('minimap', () => { + let patchmap; + let element; + let minimapHost; + + afterEach(() => { + patchmap?.destroy(); + patchmap = null; + element?.remove(); + element = null; + minimapHost?.remove(); + minimapHost = null; + document.body.innerHTML = ''; + }); + + it('requires finite canvas bounds', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element); + + expect(() => patchmap.createMinimap(minimapHost)).toThrow( + 'patchmap.createMinimap() requires canvas.bounds.', + ); + }); + + it('renders a minimap canvas and cleans it up', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + + const minimap = patchmap.createMinimap(minimapHost, { + width: 180, + height: 120, + }); + + expect(minimapHost.querySelector('canvas')).toBe(minimap.canvas); + expect(minimap.canvas.width).toBeGreaterThanOrEqual(180); + expect(minimap.canvas.height).toBeGreaterThanOrEqual(120); + + minimap.destroy(); + + expect(minimap.destroyed).toBe(true); + expect(minimapHost.querySelector('canvas')).toBeNull(); + }); + + it('requires an explicit minimap container', async () => { + element = createHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + + expect(() => patchmap.createMinimap()).toThrow( + 'patchmap.createMinimap() requires a DOM container.', + ); + }); + + it('fills the given minimap host area', async () => { + element = createHost(); + minimapHost = createMinimapHost({ width: 240, height: 144 }); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + + const minimap = patchmap.createMinimap(minimapHost); + + expect(minimap.canvas.style.width).toBe('100%'); + expect(minimap.canvas.style.height).toBe('100%'); + expect(minimap.canvas.width).toBeGreaterThanOrEqual(240); + expect(minimap.canvas.height).toBeGreaterThanOrEqual(144); + }); + + it('keeps the viewport indicator flush with minimap edges at canvas bounds', async () => { + element = createHost(); + minimapHost = createMinimapHost({ width: 240, height: 144 }); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 5000, height: 12000 }, + }, + }); + + const minimap = patchmap.createMinimap(minimapHost); + patchmap.viewport.setZoom(1, true); + patchmap.viewport.moveCenter(-100000, 6000); + patchmap._canvasBoundsController.applyViewportClamp(); + minimap.render(); + + const left = Math.min( + ...minimap._snapshot.viewport.map((point) => point.x), + ); + expect(left).toBeCloseTo(minimap._snapshot.canvas.x, 4); + }); + + it('does not expose minimap padding as a public option', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + + const minimap = patchmap.createMinimap(minimapHost, { + width: 180, + height: 120, + padding: 40, + }); + minimap.render(); + + expect(minimap.options.padding).toBeUndefined(); + expect(minimap._snapshot.canvas.x).toBeCloseTo(1, 4); + }); + + it('uses explicit minimap render color option names', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + + const minimap = patchmap.createMinimap(minimapHost, { + style: { + objectFill: '#333333', + viewportFill: 'rgba(1, 2, 3, 0.2)', + viewportStroke: '#444444', + viewportStrokeWidth: 3, + }, + }); + + expect(minimap.options.style).toEqual({ + objectFill: '#333333', + viewportFill: 'rgba(1, 2, 3, 0.2)', + viewportStroke: '#444444', + viewportStrokeWidth: 3, + }); + }); + + it('leaves minimap host positioning and chrome to the caller', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + minimapHost.style.position = 'absolute'; + minimapHost.style.right = '24px'; + minimapHost.style.bottom = '24px'; + minimapHost.style.borderRadius = '8px'; + minimapHost.style.boxShadow = '0 8px 16px rgba(0, 0, 0, 0.2)'; + minimapHost.style.background = 'rgb(24, 24, 27)'; + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + + const minimap = patchmap.createMinimap(minimapHost); + + expect(minimapHost.style.position).toBe('absolute'); + expect(minimapHost.style.right).toBe('24px'); + expect(minimapHost.style.bottom).toBe('24px'); + expect(minimapHost.style.borderRadius).toBe('8px'); + expect(minimapHost.style.boxShadow).toContain('rgba'); + expect(minimapHost.style.background).toBe('rgb(24, 24, 27)'); + expect(minimap.canvas.style.background).toBe(''); + expect(minimap.canvas.style.borderRadius).toBe(''); + expect(minimap.canvas.style.boxShadow).toBe(''); + + minimap.destroy(); + + expect(minimapHost.style.position).toBe('absolute'); + expect(minimapHost.style.right).toBe('24px'); + }); + + it('moves the viewport from minimap pointer navigation', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + + const minimap = patchmap.createMinimap(minimapHost, { + width: 180, + height: 120, + }); + const emitSpy = vi.spyOn(patchmap.viewport, 'emit'); + minimap.canvas.getBoundingClientRect = () => ({ + left: 0, + top: 0, + width: 180, + height: 120, + right: 180, + bottom: 120, + }); + + minimap.canvas.dispatchEvent( + new PointerEvent('pointerdown', { + pointerId: 1, + clientX: 90, + clientY: 60, + bubbles: true, + }), + ); + + const center = patchmap.viewport.toWorld( + patchmap.viewport.screenWidth / 2, + patchmap.viewport.screenHeight / 2, + ); + expect(center.x).toBeCloseTo(500, 1); + expect(center.y).toBeCloseTo(250, 1); + expect(emitSpy).toHaveBeenCalledWith( + 'moved', + expect.objectContaining({ + viewport: patchmap.viewport, + type: 'minimap', + }), + ); + }); + + it('refreshes from focus and fit viewport changes', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 2000, height: 1200 }, + }, + }); + patchmap.draw([ + { + type: 'item', + id: 'target', + size: 50, + components: [ + { + type: 'background', + source: { type: 'rect', fill: 'white' }, + }, + ], + attrs: { x: 1000, y: 600 }, + }, + ]); + + const minimap = patchmap.createMinimap(minimapHost); + const requestRenderSpy = vi.spyOn(minimap, 'requestRender'); + + patchmap.focus('target'); + patchmap.fit('target'); + + expect(requestRenderSpy).toHaveBeenCalledTimes(2); + }); + + it('omits hidden elements from the minimap snapshot', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + patchmap.draw([ + { + type: 'item', + id: 'visible', + size: 50, + components: [ + { + type: 'background', + source: { type: 'rect', fill: 'white' }, + }, + ], + attrs: { x: 100, y: 100 }, + }, + { + type: 'item', + id: 'hidden', + show: false, + size: 50, + components: [ + { + type: 'background', + source: { type: 'rect', fill: 'white' }, + }, + ], + attrs: { x: 300, y: 100 }, + }, + ]); + + const minimap = patchmap.createMinimap(minimapHost); + minimap.render(); + + expect(minimap._snapshot.objects).toHaveLength(1); + }); + + it('includes standalone rect elements but omits image and text', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + patchmap.draw([ + { + type: 'rect', + id: 'rect-object', + size: { width: 80, height: 40 }, + fill: 'white', + attrs: { x: 100, y: 100, angle: 15 }, + }, + { + type: 'image', + id: 'image-object', + source: '', + size: { width: 90, height: 50 }, + attrs: { x: 250, y: 100, angle: 20 }, + }, + { + type: 'text', + id: 'text-object', + text: 'Label', + size: { width: 120, height: 40 }, + attrs: { x: 420, y: 100, angle: 25 }, + }, + { + type: 'relations', + id: 'relations-object', + links: [], + style: { width: 2 }, + }, + ]); + + const minimap = patchmap.createMinimap(minimapHost); + minimap.render(); + + expect(minimap._snapshot.objects).toHaveLength(1); + expect(minimap._snapshot.objects.map((object) => object.type)).toEqual([ + 'rect', + ]); + expect( + minimap._snapshot.objects.every((object) => object.points.length === 4), + ).toBe(true); + }); + + it('orders minimap objects by render z-index', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + patchmap.draw([ + { + type: 'rect', + id: 'front-rect', + size: { width: 100, height: 100 }, + fill: 'white', + attrs: { x: 300, y: 100, zIndex: 20 }, + }, + { + type: 'rect', + id: 'back-rect', + size: { width: 100, height: 100 }, + fill: 'white', + attrs: { x: 100, y: 100, zIndex: 0 }, + }, + { + type: 'item', + id: 'middle-item', + size: 100, + components: [ + { + type: 'background', + source: { type: 'rect', fill: 'white' }, + }, + ], + attrs: { x: 200, y: 100, zIndex: 10 }, + }, + ]); + + const minimap = patchmap.createMinimap(minimapHost); + minimap.render(); + + const objectXs = minimap._snapshot.objects.map( + (object) => object.points[0].x, + ); + expect(objectXs).toEqual([...objectXs].sort((a, b) => a - b)); + }); + + it('projects rotated objects as minimap polygons', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + patchmap.draw([ + { + type: 'item', + id: 'rotated', + size: { width: 80, height: 40 }, + components: [ + { + type: 'background', + source: { type: 'rect', fill: 'white' }, + }, + ], + attrs: { x: 200, y: 120, angle: 30 }, + }, + ]); + + const minimap = patchmap.createMinimap(minimapHost); + minimap.render(); + + const points = minimap._snapshot.objects[0].points; + expect(points).toHaveLength(4); + expect(points[1].y).not.toBeCloseTo(points[0].y, 4); + }); + + it('renders a full grid as one minimap silhouette', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + patchmap.draw([ + { + type: 'grid', + id: 'full-grid', + cells: [ + [1, 1], + [1, 1], + ], + gap: 10, + item: { + size: { width: 50, height: 30 }, + components: [ + { + type: 'background', + source: { type: 'rect', fill: 'white' }, + }, + ], + }, + attrs: { x: 100, y: 100, angle: 20 }, + }, + ]); + + const minimap = patchmap.createMinimap(minimapHost); + minimap.render(); + + expect(minimap._snapshot.objects).toHaveLength(1); + expect(minimap._snapshot.objects[0].paths).toHaveLength(1); + expect(minimap._snapshot.objects[0].points).toHaveLength(4); + }); + + it('reflects inactive grid cells as simplified minimap contours', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + patchmap.draw([ + { + type: 'grid', + id: 'sparse-grid', + cells: [ + [1, 0], + [1, 1], + ], + gap: 10, + item: { + size: { width: 50, height: 30 }, + components: [ + { + type: 'background', + source: { type: 'rect', fill: 'white' }, + }, + ], + }, + attrs: { x: 100, y: 100, angle: 20 }, + }, + ]); + + const grid = patchmap.selector('$..[?(@.id=="sparse-grid")]')[0]; + const minimap = patchmap.createMinimap(minimapHost); + minimap.render(); + + expect(grid.children).toHaveLength(3); + expect(minimap._snapshot.objects).toHaveLength(1); + expect(minimap._snapshot.objects[0].paths).toHaveLength(1); + expect(minimap._snapshot.objects[0].points.length).toBeGreaterThan(4); + }); + + it('reflects inactive interior grid cells as minimap holes', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + patchmap.draw([ + { + type: 'grid', + id: 'hole-grid', + cells: [ + [1, 1, 1], + [1, 0, 1], + [1, 1, 1], + ], + gap: 10, + item: { + size: { width: 50, height: 30 }, + components: [ + { + type: 'background', + source: { type: 'rect', fill: 'white' }, + }, + ], + }, + attrs: { x: 100, y: 100 }, + }, + ]); + + const minimap = patchmap.createMinimap(minimapHost); + minimap.render(); + + expect(minimap._snapshot.objects).toHaveLength(1); + expect(minimap._snapshot.objects[0].paths).toHaveLength(2); + }); + + it('reuses minimap object silhouettes for viewport-only renders', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + patchmap.draw([ + { + type: 'item', + id: 'cached', + size: 50, + components: [ + { + type: 'background', + source: { type: 'rect', fill: 'white' }, + }, + ], + attrs: { x: 100, y: 100 }, + }, + ]); + + const minimap = patchmap.createMinimap(minimapHost); + const firstObjects = minimap._snapshot.objects; + + patchmap.viewport.emit('moved', { viewport: patchmap.viewport }); + minimap.render(); + + expect(minimap._snapshot.objects).toBe(firstObjects); + }); + + it('refreshes the viewport indicator after silent viewport transform changes', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 5000, height: 3000 }, + }, + }); + + const minimap = patchmap.createMinimap(minimapHost); + const firstViewport = serializePoints(minimap._snapshot.viewport); + + patchmap.viewport.position.set( + patchmap.viewport.x - 120, + patchmap.viewport.y - 80, + ); + patchmap.viewport.dirty = true; + patchmap.app.ticker.update(); + await nextAnimationFrame(); + + expect(serializePoints(minimap._snapshot.viewport)).not.toEqual( + firstViewport, + ); + + const movedViewport = serializePoints(minimap._snapshot.viewport); + + patchmap.viewport.setZoom(2, true); + patchmap.app.ticker.update(); + await nextAnimationFrame(); + + expect(serializePoints(minimap._snapshot.viewport)).not.toEqual( + movedViewport, + ); + }); + + it('refreshes the viewport indicator after silent world matrix changes', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 5000, height: 3000 }, + }, + }); + + const minimap = patchmap.createMinimap(minimapHost); + const firstViewport = serializePoints(minimap._snapshot.viewport); + + patchmap.world.skew.set(0.12, 0); + patchmap.app.ticker.update(); + await nextAnimationFrame(); + + expect(serializePoints(minimap._snapshot.viewport)).not.toEqual( + firstViewport, + ); + }); + + it('does not read the Pixi application screen getter while watching viewport transforms', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 5000, height: 3000 }, + }, + }); + patchmap._resizeObserver?.disconnect(); + Object.defineProperty(patchmap.app, 'screen', { + configurable: true, + get() { + throw new TypeError('screen getter should not be read'); + }, + }); + + const minimap = patchmap.createMinimap(minimapHost); + + expect(() => minimap.render()).not.toThrow(); + }); + + it('attaches viewport updates when created while patchmap initialization is still pending', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + const initPromise = patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 5000, height: 3000 }, + }, + }); + const minimap = patchmap.createMinimap(minimapHost); + + expect(minimap._attachedViewport).toBeNull(); + + await initPromise; + + expect(minimap._attachedViewport).toBe(patchmap.viewport); + const firstViewport = serializePoints(minimap._snapshot.viewport); + + patchmap.viewport.moveCenter({ x: 1600, y: 900 }); + patchmap.app.ticker.update(); + await nextAnimationFrame(); + + expect(serializePoints(minimap._snapshot.viewport)).not.toEqual( + firstViewport, + ); + }); + + it('destroys minimaps created while initialization is still pending', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + const initPromise = patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 5000, height: 3000 }, + }, + }); + const minimap = patchmap.createMinimap(minimapHost); + + patchmap.destroy(); + await initPromise; + + expect(minimap.destroyed).toBe(true); + expect(patchmap.isInit).toBe(false); + expect(minimapHost.querySelector('canvas')).toBeNull(); + }); + + it('clears minimap snapshots when finite canvas bounds are removed', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + + const minimap = patchmap.createMinimap(minimapHost); + expect(minimap._snapshot).toBeTruthy(); + + patchmap.setCanvasBounds(null); + minimap.render(); + + expect(minimap._snapshot).toBeNull(); + expect(minimap._objectSnapshot).toBeNull(); + }); + + it('refreshes minimap object silhouettes after object updates', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + patchmap.draw([ + { + type: 'item', + id: 'updated', + size: 50, + components: [ + { + type: 'background', + source: { type: 'rect', fill: 'white' }, + }, + ], + attrs: { x: 100, y: 100 }, + }, + ]); + + const minimap = patchmap.createMinimap(minimapHost); + const firstObjects = minimap._snapshot.objects; + + patchmap.emit('patchmap:updated', { patchmap }); + minimap.render(); + + expect(minimap._snapshot.objects).not.toBe(firstObjects); + }); +}); diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js index ef4b9102..3ed2f330 100644 --- a/src/tests/render/patchmap.test.js +++ b/src/tests/render/patchmap.test.js @@ -1,4 +1,4 @@ -import { Container } from 'pixi.js'; +import { Container, Point } from 'pixi.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Transformer } from '../../patch-map'; import { getCentroid, getObjectFrameWorldCorners } from '../../utils/transform'; @@ -92,6 +92,38 @@ const relationEndpointIds = [ const waitForScene = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); +const getRendererPoint = (patchmap, clientPoint) => { + const point = new Point(); + patchmap.app.renderer.events.mapPositionToPoint( + point, + clientPoint.x, + clientPoint.y, + ); + return point; +}; + +const dispatchViewportWheel = (patchmap, clientPoint, deltaY) => { + patchmap.app.canvas.dispatchEvent( + new WheelEvent('wheel', { + clientX: clientPoint.x, + clientY: clientPoint.y, + deltaY, + bubbles: true, + cancelable: true, + }), + ); +}; + +const getViewportClientPoint = (patchmap, xRatio, yRatio) => { + const rect = patchmap.app.canvas.getBoundingClientRect(); + return { + x: rect.left + rect.width * xRatio, + y: rect.top + rect.height * yRatio, + }; +}; + +const getDistance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); + const ROOT_TEXT_WORLD_SCENARIOS = [ ['-90 / none', -90, { x: false, y: false }, -90, -90], ['-75 / none', -75, { x: false, y: false }, -75, -75], @@ -876,6 +908,21 @@ describe('patchmap test', () => { expect(world.position.y).toBeCloseTo(center.y, 3); }); + it('zooms around the mouse pointer for wheel zoom', async () => { + const patchmap = getPatchmap(); + const clientPoint = getViewportClientPoint(patchmap, 0.7, 0.4); + const pointer = getRendererPoint(patchmap, clientPoint); + const before = patchmap.viewport.toWorld(pointer.x, pointer.y); + + dispatchViewportWheel(patchmap, clientPoint, -120); + await waitForScene(); + + const after = patchmap.viewport.toWorld(pointer.x, pointer.y); + + expect(patchmap.viewport.scale.x).toBeGreaterThan(1); + expect(getDistance(after, before)).toBeLessThan(0.5); + }); + it.each( ROOT_TEXT_WORLD_SCENARIOS, )('keeps standalone text visual upright across %s', async (_label, angle, flip, expectedOuter, expectedVisual) => { diff --git a/src/transformer/ResizeGestureController.js b/src/transformer/ResizeGestureController.js index 5790f036..2751cf21 100644 --- a/src/transformer/ResizeGestureController.js +++ b/src/transformer/ResizeGestureController.js @@ -201,11 +201,12 @@ export default class ResizeGestureController { keepRatio, }); - applyResizeUpdates({ + const appliedCount = applyResizeUpdates({ updates, viewport, historyId: this._activeResize.historyId, }); + if (!appliedCount) return; this._emitUpdateElements(); this._requestRender(); diff --git a/src/transformer/RotateGestureController.js b/src/transformer/RotateGestureController.js index fef12f99..6d290e34 100644 --- a/src/transformer/RotateGestureController.js +++ b/src/transformer/RotateGestureController.js @@ -113,11 +113,12 @@ export default class RotateGestureController { deltaAngle, }); - applyRotateUpdates({ + const appliedCount = applyRotateUpdates({ updates, viewport, historyId: this._activeRotate.historyId, }); + if (!appliedCount) return; this._emitUpdateElements(); this._requestRender(); diff --git a/src/transformer/resize-apply.js b/src/transformer/resize-apply.js index 531f4006..e767de51 100644 --- a/src/transformer/resize-apply.js +++ b/src/transformer/resize-apply.js @@ -1,4 +1,6 @@ import { Point } from 'pixi.js'; +import { isViewportFrameInsideCanvasBounds } from '../canvas-bounds/restrict'; +import { getObjectFrameLocalCorners } from '../utils/transform'; import { isResizableElement } from './resize-context'; import { computeResize, @@ -13,6 +15,7 @@ import { * @property {number} y * @property {number} width * @property {number} height + * @property {{ x: number, y: number }[]} corners */ /** @@ -42,6 +45,7 @@ export const createResizeElementStates = ({ elements, viewport }) => y: viewportPosition.y, width: size.width, height: size.height, + corners: getObjectFrameLocalCorners(element, viewport), }; }); @@ -82,10 +86,20 @@ export const computeResizeUpdates = ({ activeResize, delta, keepRatio }) => { keepRatio, }); - return activeResize.elementStates.map((state) => ({ - element: state.element, - updatedState: resizeElementState(state, resizeInfo), - })); + return activeResize.elementStates.map((state) => { + const updatedState = resizeElementState(state, resizeInfo); + return { + element: state.element, + updatedState: { + ...updatedState, + corners: getResizedStateCorners({ + state, + updatedState, + resizeInfo, + }), + }, + }; + }); }; const computeOrientedResizeUpdates = ({ activeResize, delta, keepRatio }) => { @@ -109,14 +123,24 @@ const computeOrientedResizeUpdates = ({ activeResize, delta, keepRatio }) => { resizeInfo.bounds.y, ); + const updatedState = { + x: origin.x, + y: origin.y, + width: snapSizeToUnit(resizeInfo.bounds.width, state.width), + height: snapSizeToUnit(resizeInfo.bounds.height, state.height), + }; + return [ { element: state.element, updatedState: { - x: origin.x, - y: origin.y, - width: snapSizeToUnit(resizeInfo.bounds.width, state.width), - height: snapSizeToUnit(resizeInfo.bounds.height, state.height), + ...updatedState, + corners: getOrientedStateCorners({ + geometry, + origin, + width: updatedState.width, + height: updatedState.height, + }), }, }, ]; @@ -172,6 +196,8 @@ const normalizeVector = (vector) => { * @param {string | null | undefined} params.historyId */ export const applyResizeUpdates = ({ updates, viewport, historyId }) => { + if (!areResizeUpdatesInsideCanvasBounds({ updates, viewport })) return 0; + updates.forEach(({ element, updatedState }) => { applyElementResize({ element, @@ -180,6 +206,7 @@ export const applyResizeUpdates = ({ updates, viewport, historyId }) => { historyId, }); }); + return updates.length; }; /** @@ -195,6 +222,92 @@ const getElementSize = (element) => { return { width: element.width, height: element.height }; }; +const areResizeUpdatesInsideCanvasBounds = ({ updates, viewport }) => + updates.every(({ element, updatedState }) => + isViewportFrameInsideCanvasBounds({ + corners: updatedState.corners, + viewport, + element, + }), + ); + +const getAxisAlignedStateCorners = ({ x, y, width, height }) => [ + { x, y }, + { x: x + width, y }, + { x: x + width, y: y + height }, + { x, y: y + height }, +]; + +const getResizedStateCorners = ({ state, updatedState, resizeInfo }) => { + if (!Array.isArray(state.corners) || state.corners.length < 4) { + return getAxisAlignedStateCorners(updatedState); + } + + const [topLeft, topRight, , bottomLeft] = state.corners; + const widthScale = getSafeScale(updatedState.width, state.width); + const heightScale = getSafeScale(updatedState.height, state.height); + const topLeftOffset = { + x: (topLeft.x - state.x) * resizeInfo.scaleX, + y: (topLeft.y - state.y) * resizeInfo.scaleY, + }; + const nextTopLeft = { + x: updatedState.x + topLeftOffset.x, + y: updatedState.y + topLeftOffset.y, + }; + const widthVector = { + x: (topRight.x - topLeft.x) * widthScale, + y: (topRight.y - topLeft.y) * widthScale, + }; + const heightVector = { + x: (bottomLeft.x - topLeft.x) * heightScale, + y: (bottomLeft.y - topLeft.y) * heightScale, + }; + const nextTopRight = { + x: nextTopLeft.x + widthVector.x, + y: nextTopLeft.y + widthVector.y, + }; + const nextBottomLeft = { + x: nextTopLeft.x + heightVector.x, + y: nextTopLeft.y + heightVector.y, + }; + + return [ + nextTopLeft, + nextTopRight, + { + x: nextTopRight.x + heightVector.x, + y: nextTopRight.y + heightVector.y, + }, + nextBottomLeft, + ]; +}; + +const getSafeScale = (nextSize, previousSize) => { + if (!Number.isFinite(nextSize) || !Number.isFinite(previousSize)) return 1; + if (!previousSize) return 1; + return nextSize / previousSize; +}; + +const getOrientedStateCorners = ({ geometry, origin, width, height }) => { + const topRight = { + x: origin.x + geometry.xAxis.x * width, + y: origin.y + geometry.xAxis.y * width, + }; + const bottomLeft = { + x: origin.x + geometry.yAxis.x * height, + y: origin.y + geometry.yAxis.y * height, + }; + return [ + origin, + topRight, + { + x: topRight.x + geometry.yAxis.x * height, + y: topRight.y + geometry.yAxis.y * height, + }, + bottomLeft, + ]; +}; + /** * Applies a single resize result to one element. * diff --git a/src/transformer/resize-apply.test.js b/src/transformer/resize-apply.test.js new file mode 100644 index 00000000..fa3d402d --- /dev/null +++ b/src/transformer/resize-apply.test.js @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from 'vitest'; +import { applyResizeUpdates, computeResizeUpdates } from './resize-apply'; + +const canvasBounds = Object.freeze({ + x: 0, + y: 0, + width: 100, + height: 100, + right: 100, + bottom: 100, +}); + +const createViewport = () => ({ + toGlobal: (point) => ({ x: point.x, y: point.y }), +}); + +const createWorld = () => ({ + toLocal: (point) => ({ x: point.x, y: point.y }), +}); + +const createElement = ({ canvasBounds: bounds = null } = {}) => ({ + constructor: { isResizable: true }, + props: {}, + parent: null, + store: { + canvasBounds: bounds, + world: createWorld(), + }, + apply: vi.fn(), +}); + +describe('resize-apply canvas bounds', () => { + it('applies resize updates whose proposed frame stays inside canvas bounds', () => { + const element = createElement({ canvasBounds }); + + const count = applyResizeUpdates({ + viewport: createViewport(), + updates: [ + { + element, + updatedState: { + x: 10, + y: 20, + width: 30, + height: 40, + corners: [ + { x: 10, y: 20 }, + { x: 40, y: 20 }, + { x: 40, y: 60 }, + { x: 10, y: 60 }, + ], + }, + }, + ], + }); + + expect(count).toBe(1); + expect(element.apply).toHaveBeenCalledWith( + { + attrs: { x: 10, y: 20 }, + size: { width: 30, height: 40 }, + }, + undefined, + ); + }); + + it('rejects resize updates whose proposed frame leaves canvas bounds', () => { + const element = createElement({ canvasBounds }); + + const count = applyResizeUpdates({ + viewport: createViewport(), + updates: [ + { + element, + updatedState: { + x: 10, + y: 20, + width: 120, + height: 40, + corners: [ + { x: 10, y: 20 }, + { x: 130, y: 20 }, + { x: 130, y: 60 }, + { x: 10, y: 60 }, + ], + }, + }, + ], + }); + + expect(count).toBe(0); + expect(element.apply).not.toHaveBeenCalled(); + }); + + it('keeps resize updates permissive when canvas bounds are omitted', () => { + const element = createElement(); + + const count = applyResizeUpdates({ + viewport: createViewport(), + updates: [ + { + element, + updatedState: { + x: 10, + y: 20, + width: 120, + height: 40, + corners: [ + { x: 10, y: 20 }, + { x: 130, y: 20 }, + { x: 130, y: 60 }, + { x: 10, y: 60 }, + ], + }, + }, + ], + }); + + expect(count).toBe(1); + expect(element.apply).toHaveBeenCalled(); + }); + + it('checks resized multi-selection states with the element frame corners', () => { + const element = createElement({ canvasBounds }); + const updates = computeResizeUpdates({ + activeResize: { + bounds: { x: 10, y: 10, width: 30, height: 30 }, + handle: 'right', + elementStates: [ + { + element, + x: 20, + y: 20, + width: 20, + height: 10, + corners: [ + { x: 20, y: 20 }, + { x: 34.1421356237, y: 34.1421356237 }, + { x: 27.0710678119, y: 41.2132034356 }, + { x: 12.9289321881, y: 27.0710678119 }, + ], + }, + ], + }, + delta: { x: 10, y: 0 }, + keepRatio: false, + }); + + expect(updates[0].updatedState.corners[1].y).toBeGreaterThan( + updates[0].updatedState.corners[0].y, + ); + }); +}); diff --git a/src/transformer/rotate-apply.js b/src/transformer/rotate-apply.js index 448de085..0dd02d69 100644 --- a/src/transformer/rotate-apply.js +++ b/src/transformer/rotate-apply.js @@ -1,4 +1,5 @@ import { Point } from 'pixi.js'; +import { isViewportFrameInsideCanvasBounds } from '../canvas-bounds/restrict'; import { getCentroid, getObjectFrameLocalCorners } from '../utils/transform'; import { isRotatableElement } from './rotate-context'; import { @@ -28,6 +29,7 @@ export const createRotateElementStates = ({ elements, viewport }) => return { element, origin, + corners, center, centerOffset: { x: center.x - origin.x, @@ -41,10 +43,15 @@ export const createRotateElementStates = ({ elements, viewport }) => export const computeRotateUpdates = ({ activeRotate, deltaAngle }) => activeRotate.elementStates.map((state) => ({ element: state.element, - updatedState: rotateElementState(state, { - center: activeRotate.frame.center, - deltaAngle, - }), + updatedState: { + ...rotateElementState(state, { + center: activeRotate.frame.center, + deltaAngle, + }), + corners: state.corners.map((corner) => + rotatePoint(corner, activeRotate.frame.center, deltaAngle), + ), + }, })); export const rotateElementState = (state, { center, deltaAngle }) => { @@ -66,6 +73,8 @@ export const rotateElementState = (state, { center, deltaAngle }) => { }; export const applyRotateUpdates = ({ updates, viewport, historyId }) => { + if (!areRotateUpdatesInsideCanvasBounds({ updates, viewport })) return 0; + updates.forEach(({ element, updatedState }) => { applyElementRotate({ element, @@ -74,8 +83,18 @@ export const applyRotateUpdates = ({ updates, viewport, historyId }) => { historyId, }); }); + return updates.length; }; +const areRotateUpdatesInsideCanvasBounds = ({ updates, viewport }) => + updates.every(({ element, updatedState }) => + isViewportFrameInsideCanvasBounds({ + corners: updatedState.corners, + viewport, + element, + }), + ); + const applyElementRotate = ({ element, updatedState, viewport, historyId }) => { if (!element || !isRotatableElement(element)) return; diff --git a/src/transformer/rotate-apply.test.js b/src/transformer/rotate-apply.test.js index d6761a65..56cfa9cd 100644 --- a/src/transformer/rotate-apply.test.js +++ b/src/transformer/rotate-apply.test.js @@ -43,6 +43,7 @@ const createElement = ({ const viewport = { toLocal: (point) => ({ x: point.x, y: point.y }), + toGlobal: (point) => ({ x: point.x, y: point.y }), }; describe('rotate-apply', () => { @@ -166,4 +167,43 @@ describe('rotate-apply', () => { undefined, ); }); + + it('rejects rotate updates whose proposed frame leaves canvas bounds', () => { + const canvasBounds = { + x: 0, + y: 0, + width: 20, + height: 20, + right: 20, + bottom: 20, + }; + const element = createElement({ + attrs: { x: 0, y: 0, angle: 0 }, + corners: [ + { x: 15, y: 0 }, + { x: 25, y: 0 }, + { x: 25, y: 10 }, + { x: 15, y: 10 }, + ], + }); + element.store = { + canvasBounds, + world: { toLocal: (point) => ({ x: point.x, y: point.y }) }, + }; + const updates = computeRotateUpdates({ + activeRotate: { + frame: { center: { x: 20, y: 5 } }, + elementStates: createRotateElementStates({ + elements: [element], + viewport, + }), + }, + deltaAngle: 0, + }); + + const count = applyRotateUpdates({ updates, viewport }); + + expect(count).toBe(0); + expect(element.apply).not.toHaveBeenCalled(); + }); });