From ba0ccaad9217e92a54fc9b53e252c456562d7b43 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 29 Apr 2026 17:09:38 +0900 Subject: [PATCH 01/18] fix(patch-map): preserve normalized text placement updates --- src/display/components/Text.js | 3 +- src/display/default-props.js | 13 ++++ src/display/mixins/Childrenable.js | 6 +- src/display/mixins/Componentsable.js | 6 +- src/display/mixins/utils.js | 7 +- src/display/mixins/utils.test.js | 30 ++++++++- src/tests/render/components/Text.test.js | 81 +++++++++++++++++------- src/tests/render/patchmap.test.js | 30 +++++++++ 8 files changed, 141 insertions(+), 35 deletions(-) diff --git a/src/display/components/Text.js b/src/display/components/Text.js index b2e40da3..a6eb983e 100644 --- a/src/display/components/Text.js +++ b/src/display/components/Text.js @@ -12,6 +12,7 @@ import { mixins } from '../mixins/utils'; import { WorldTransformable } from '../mixins/WorldTransformable'; const HANDLER_KEYS = ['text', 'style', 'split', 'attrs']; +const PLACEMENT_HANDLER_KEYS = [...HANDLER_KEYS, 'placement', 'margin']; const ComposedText = mixins( BitmapText, @@ -48,4 +49,4 @@ Text.registerHandler( Text.prototype._applyWorldTransform, UPDATE_STAGES.WORLD_TRANSFORM, ); -Text.registerHandler(HANDLER_KEYS, Text.prototype._applyPlacement); +Text.registerHandler(PLACEMENT_HANDLER_KEYS, Text.prototype._applyPlacement); diff --git a/src/display/default-props.js b/src/display/default-props.js index c29931cd..03f0c979 100644 --- a/src/display/default-props.js +++ b/src/display/default-props.js @@ -90,6 +90,11 @@ const withStrokeStyleDefaults = (style) => ({ ...(isPlainObject(style) ? style : {}), }); +const withBoxSpacingDefaults = (value) => ({ + ...normalizeBoxSpacing(0), + ...normalizeBoxSpacing(value), +}); + const withItemLikeDefaults = (value, options = {}) => { const { materializeComponents = true } = options; let next = value; @@ -98,6 +103,8 @@ const withItemLikeDefaults = (value, options = {}) => { } if (next.padding === undefined) { next = { ...next, padding: normalizeBoxSpacing(0) }; + } else { + next = { ...next, padding: withBoxSpacingDefaults(next.padding) }; } if (next.contentOrientation === undefined) { next = { ...next, contentOrientation: 'upright' }; @@ -192,6 +199,8 @@ export const applyComponentDefaults = (value) => { } if (next.margin === undefined) { next = { ...next, margin: normalizeBoxSpacing(0) }; + } else { + next = { ...next, margin: withBoxSpacingDefaults(next.margin) }; } if (next.animation === undefined) { next = { ...next, animation: true }; @@ -206,6 +215,8 @@ export const applyComponentDefaults = (value) => { } if (next.margin === undefined) { next = { ...next, margin: normalizeBoxSpacing(0) }; + } else { + next = { ...next, margin: withBoxSpacingDefaults(next.margin) }; } break; case 'text': @@ -214,6 +225,8 @@ export const applyComponentDefaults = (value) => { } if (next.margin === undefined) { next = { ...next, margin: normalizeBoxSpacing(0) }; + } else { + next = { ...next, margin: withBoxSpacingDefaults(next.margin) }; } if (next.text === undefined) { next = { ...next, text: '' }; diff --git a/src/display/mixins/Childrenable.js b/src/display/mixins/Childrenable.js index 7c7eb101..a38a7ef1 100644 --- a/src/display/mixins/Childrenable.js +++ b/src/display/mixins/Childrenable.js @@ -13,7 +13,7 @@ export const Childrenable = (superClass) => { _applyChildren(relevantChanges, options = {}) { const childOptions = options.validateSchema === false - ? { ...options, validateSchema: false, normalize: false } + ? { ...options, validateSchema: false } : options; let childrenChanges = options.refresh ? relevantChanges?.children @@ -79,9 +79,7 @@ export const Childrenable = (superClass) => { }; const canUseInitialFastPath = (options) => - options.mergeStrategy === 'replace' && - options.validateSchema === false && - options.normalize === false; + options.mergeStrategy === 'replace' && options.validateSchema === false; const applyInitialChild = ( element, diff --git a/src/display/mixins/Componentsable.js b/src/display/mixins/Componentsable.js index 82f34bdc..3eaab7c8 100644 --- a/src/display/mixins/Componentsable.js +++ b/src/display/mixins/Componentsable.js @@ -14,7 +14,7 @@ export const Componentsable = (superClass) => { _applyComponents(relevantChanges, options = {}) { const childOptions = options.validateSchema === false - ? { ...options, validateSchema: false, normalize: false } + ? { ...options, validateSchema: false } : options; let componentsChanges = options.refresh ? relevantChanges?.components @@ -101,9 +101,7 @@ export const Componentsable = (superClass) => { }; const canUseInitialFastPath = (options) => - options.mergeStrategy === 'replace' && - options.validateSchema === false && - options.normalize === false; + options.mergeStrategy === 'replace' && options.validateSchema === false; const applyInitialComponent = ( component, diff --git a/src/display/mixins/utils.js b/src/display/mixins/utils.js index 991f83c3..145b0e1e 100644 --- a/src/display/mixins/utils.js +++ b/src/display/mixins/utils.js @@ -2,6 +2,7 @@ import gsap from 'gsap'; import { isValidationError } from 'zod-validation-error'; import { findIndexByPriority } from '../../utils/findIndexByPriority'; import { validate } from '../../utils/validator'; +import { normalizeChanges } from '../normalize'; import { ZERO_MARGIN } from './constants'; export const tweensOf = (object) => gsap.getTweensOf(object); @@ -197,7 +198,11 @@ export const validateAndPrepareChanges = ( schema, options = {}, ) => { - const preparedChanges = [...changes]; + const shouldNormalize = + options.validateSchema === false && options.normalize !== false; + const preparedChanges = shouldNormalize + ? changes.map((change) => normalizeChanges(change, change?.type)) + : [...changes]; const used = new Set(); const newElementDefs = []; const newElementIndices = []; diff --git a/src/display/mixins/utils.test.js b/src/display/mixins/utils.test.js index 13d5ea55..bdbd8aaf 100644 --- a/src/display/mixins/utils.test.js +++ b/src/display/mixins/utils.test.js @@ -232,8 +232,34 @@ describe('validateAndPrepareChanges', () => { ]); }); + it('normalizes changes without schema validation by default', () => { + const changes = [{ type: 'text', margin: { x: 4, top: -17 } }]; + const schema = z.array( + z.object({ + type: z.string(), + margin: z.object({ + top: z.number(), + right: z.number(), + bottom: z.number(), + left: z.number(), + }), + }), + ); + + expect( + validateAndPrepareChanges([], changes, schema, { + validateSchema: false, + }), + ).toEqual([ + { + type: 'text', + margin: { top: -17, right: 4, left: 4 }, + }, + ]); + }); + it('applies lightweight defaults without schema validation when validateSchema is false', () => { - const changes = [{ type: 'text' }]; + const changes = [{ type: 'text', margin: { top: -17 } }]; const schema = z.array( z.object({ type: z.string(), @@ -253,7 +279,7 @@ describe('validateAndPrepareChanges', () => { show: true, tint: 0xffffff, placement: 'center', - margin: { top: 0, right: 0, bottom: 0, left: 0 }, + margin: { top: -17, right: 0, bottom: 0, left: 0 }, text: '', style: { fontFamily: 'FiraCode', diff --git a/src/tests/render/components/Text.test.js b/src/tests/render/components/Text.test.js index d3a64a28..0bdd45ef 100644 --- a/src/tests/render/components/Text.test.js +++ b/src/tests/render/components/Text.test.js @@ -48,6 +48,40 @@ describe('Text Component Tests', () => { expect(text.text).toBe('Updated Text'); }); + it('should preserve placement margin when text changes', () => { + const patchmap = getPatchmap(); + patchmap.draw([ + { + type: 'item', + id: 'item-with-header-label', + size: { width: 60, height: 70 }, + padding: { left: 4, right: 4, bottom: 4, top: 20 }, + components: [ + { + type: 'text', + id: 'header-label', + text: 'INV 1', + placement: 'top', + margin: { top: -17 }, + style: { fontSize: 10 }, + }, + ], + }, + ]); + gsap.exportRoot().totalProgress(1); + + const label = patchmap.selector('$..[?(@.id=="header-label")]')[0]; + const initialY = label.y; + + patchmap.update({ + path: '$..[?(@.id=="header-label")]', + changes: { text: 'INV 2' }, + }); + + expect(label.y).toBe(initialY); + expect(label.y).toBeLessThan(20); + }); + it('should update style properties', () => { const patchmap = getPatchmap(); patchmap.draw([itemWithText]); @@ -109,30 +143,31 @@ describe('Text Component Tests', () => { }, ]; - it.each(layoutTestCases)( - 'should correctly position to $description', - ({ itemSize, textPlacement, validate }) => { - const patchmap = getPatchmap(); - const testItem = { - type: 'item', - id: 'test-item-layout', - size: itemSize, - components: [ - { - type: 'text', - id: 'text-layout', - text: 'Layout', // 텍스트가 있어야 너비가 생김 - placement: textPlacement, - }, - ], - }; - patchmap.draw([testItem]); - gsap.exportRoot().totalProgress(1); + it.each(layoutTestCases)('should correctly position to $description', ({ + itemSize, + textPlacement, + validate, + }) => { + const patchmap = getPatchmap(); + const testItem = { + type: 'item', + id: 'test-item-layout', + size: itemSize, + components: [ + { + type: 'text', + id: 'text-layout', + text: 'Layout', // 텍스트가 있어야 너비가 생김 + placement: textPlacement, + }, + ], + }; + patchmap.draw([testItem]); + gsap.exportRoot().totalProgress(1); - const text = patchmap.selector('$..[?(@.id=="text-layout")]')[0]; - validate(text, itemSize); - }, - ); + const text = patchmap.selector('$..[?(@.id=="text-layout")]')[0]; + validate(text, itemSize); + }); }); it('should correctly split text based on the "split" property', () => { diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js index dec3059e..ef4b9102 100644 --- a/src/tests/render/patchmap.test.js +++ b/src/tests/render/patchmap.test.js @@ -326,6 +326,36 @@ describe('patchmap test', () => { }); }); + it('normalizes child shorthand updates when schema validation is skipped', () => { + const patchmap = getPatchmap(); + patchmap.draw([{ type: 'group', id: 'normalized-children', children: [] }]); + + patchmap.update({ + path: '$..[?(@.id=="normalized-children")]', + validateSchema: false, + changes: { + children: [ + { + type: 'item', + id: 'child-with-partial-padding', + size: 80, + padding: { top: 12 }, + }, + ], + }, + }); + + const child = patchmap.selector( + '$..[?(@.id=="child-with-partial-padding")]', + )[0]; + expect(child.props.padding).toEqual({ + top: 12, + right: 0, + bottom: 0, + left: 0, + }); + }); + it('sorts top-level world children by zIndex after draw and update', async () => { const patchmap = getPatchmap(); patchmap.draw([ From 9484226d87a7369e5a1dd862edfaa952b62a86d9 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 11 May 2026 19:08:27 +0900 Subject: [PATCH 02/18] feat: add finite canvas bounds and minimap --- README.md | 27 +++ README_KR.md | 27 +++ src/canvas-bounds/CanvasBoundsLayer.js | 55 +++++ src/canvas-bounds/clamp.js | 147 ++++++++++++ src/canvas-bounds/controller.js | 95 ++++++++ src/canvas-bounds/options.js | 49 ++++ src/canvas-bounds/options.test.js | 117 +++++++++ src/canvas-bounds/restrict.js | 103 ++++++++ src/canvas-bounds/restrict.test.js | 40 ++++ src/minimap/Minimap.js | 314 +++++++++++++++++++++++++ src/minimap/model.js | 152 ++++++++++++ src/minimap/model.test.js | 25 ++ src/patchmap.js | 107 ++++++++- src/tests/render/canvas-bounds.test.js | 257 ++++++++++++++++++++ src/tests/render/minimap.test.js | 213 +++++++++++++++++ 15 files changed, 1724 insertions(+), 4 deletions(-) create mode 100644 src/canvas-bounds/CanvasBoundsLayer.js create mode 100644 src/canvas-bounds/clamp.js create mode 100644 src/canvas-bounds/controller.js create mode 100644 src/canvas-bounds/options.js create mode 100644 src/canvas-bounds/options.test.js create mode 100644 src/canvas-bounds/restrict.js create mode 100644 src/canvas-bounds/restrict.test.js create mode 100644 src/minimap/Minimap.js create mode 100644 src/minimap/model.js create mode 100644 src/minimap/model.test.js create mode 100644 src/tests/render/canvas-bounds.test.js create mode 100644 src/tests/render/minimap.test.js diff --git a/README.md b/README.md index a73c2e5a..23cfbafb 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,9 @@ await patchmap.init(el, { viewport: { plugins: { decelerate: { disabled: true } } }, + canvas: { + bounds: { x: 0, y: 0, width: 5000, height: 3000 } + }, theme: { primary: { default: '#c2410c' } } @@ -160,6 +163,30 @@ 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'), { + width: 180, + height: 120, + }); + + minimap.destroy(); + ``` + - `theme` - Theme options Default: ```js diff --git a/README_KR.md b/README_KR.md index 539b6197..e4ef83fe 100644 --- a/README_KR.md +++ b/README_KR.md @@ -128,6 +128,9 @@ await patchmap.init(el, { viewport: { plugins: { decelerate: { disabled: true } } }, + canvas: { + bounds: { x: 0, y: 0, width: 5000, height: 3000 } + }, theme: { primary: { default: '#c2410c' } } @@ -168,6 +171,30 @@ 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'), { + width: 180, + height: 120, + }); + + minimap.destroy(); + ``` + - `theme` Default: ```js diff --git a/src/canvas-bounds/CanvasBoundsLayer.js b/src/canvas-bounds/CanvasBoundsLayer.js new file mode 100644 index 00000000..4d2189ed --- /dev/null +++ b/src/canvas-bounds/CanvasBoundsLayer.js @@ -0,0 +1,55 @@ +import { Graphics } from 'pixi.js'; + +const DEFAULT_STYLE = Object.freeze({ + background: 0xffffff, + backgroundAlpha: 0.72, + border: 0xd4d4d8, + borderAlpha: 1, + borderWidth: 1, +}); + +export default class CanvasBoundsLayer extends Graphics { + constructor({ bounds, style = {} } = {}) { + super(); + this.label = 'patch-map:canvas-bounds-layer'; + this.eventMode = 'none'; + this.interactiveChildren = false; + this._canvasBounds = bounds; + this._style = { ...DEFAULT_STYLE, ...style }; + this.draw(); + } + + get canvasBounds() { + return this._canvasBounds; + } + + set canvasBounds(value) { + this._canvasBounds = value; + this.draw(); + } + + draw() { + this.clear(); + if (!this._canvasBounds) return; + + const { x, y, width, height } = this._canvasBounds; + this.rect(x, y, width, height) + .fill({ + color: this._style.background, + alpha: this._style.backgroundAlpha, + }) + .stroke({ + color: this._style.border, + alpha: this._style.borderAlpha, + width: this._style.borderWidth, + }); + } + + syncWorldTransform(world) { + if (!world) return; + this.position.copyFrom(world.position); + this.pivot.copyFrom(world.pivot); + this.scale.copyFrom(world.scale); + this.angle = world.angle; + } +} diff --git a/src/canvas-bounds/clamp.js b/src/canvas-bounds/clamp.js new file mode 100644 index 00000000..688a6c57 --- /dev/null +++ b/src/canvas-bounds/clamp.js @@ -0,0 +1,147 @@ +import { Point } from 'pixi.js'; +import { getBoundsFromPoints } from '../utils/transform'; + +const CLAMP_EPSILON = 0.0001; + +export const clampViewportToCanvasBounds = (viewport, bounds, world) => { + if (!viewport || !bounds) return; + + if (world) { + clampViewportToTransformedCanvasBounds(viewport, bounds, world); + return; + } + + clampViewportAxis({ + viewport, + min: bounds.x, + max: bounds.right, + size: bounds.width, + screenSize: viewport.screenWidth, + worldScreenSize: viewport.worldScreenWidth, + scale: viewport.scale?.x, + positionKey: 'x', + minEdgeKey: 'left', + maxEdgeKey: 'right', + }); + clampViewportAxis({ + viewport, + min: bounds.y, + max: bounds.bottom, + size: bounds.height, + screenSize: viewport.screenHeight, + worldScreenSize: viewport.worldScreenHeight, + scale: viewport.scale?.y, + positionKey: 'y', + minEdgeKey: 'top', + maxEdgeKey: 'bottom', + }); +}; + +const clampViewportAxis = ({ + viewport, + min, + max, + size, + screenSize, + worldScreenSize, + scale, + positionKey, + minEdgeKey, + maxEdgeKey, +}) => { + const safeScale = Math.abs(scale || 1); + const visibleSize = Number.isFinite(worldScreenSize) + ? worldScreenSize + : screenSize / safeScale; + + if (visibleSize >= size) { + const center = min + size / 2; + viewport[positionKey] = -center * safeScale + screenSize / 2; + return; + } + + if (viewport[minEdgeKey] < min) { + viewport[minEdgeKey] = min; + } else if (viewport[maxEdgeKey] > max) { + viewport[maxEdgeKey] = max; + } +}; + +const clampViewportToTransformedCanvasBounds = (viewport, bounds, world) => { + for (let iteration = 0; iteration < 4; iteration += 1) { + const frame = getVisibleCanvasFrame(viewport, world); + const correction = getFrameCorrection(frame, bounds); + if ( + Math.abs(correction.x) <= CLAMP_EPSILON && + Math.abs(correction.y) <= CLAMP_EPSILON + ) { + return; + } + + const center = screenPointToCanvasPoint({ + viewport, + 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); +}; + +const getFrameCorrection = (frame, bounds) => ({ + x: getAxisCorrection({ + min: frame.x, + max: frame.x + frame.width, + size: frame.width, + boundMin: bounds.x, + boundMax: bounds.right, + boundSize: bounds.width, + }), + y: getAxisCorrection({ + min: frame.y, + max: frame.y + frame.height, + size: frame.height, + boundMin: bounds.y, + boundMax: bounds.bottom, + boundSize: bounds.height, + }), +}); + +const getAxisCorrection = ({ + min, + max, + size, + boundMin, + boundMax, + boundSize, +}) => { + if (size >= boundSize) { + return boundMin + boundSize / 2 - (min + size / 2); + } + if (min < boundMin) return boundMin - min; + if (max > boundMax) return boundMax - max; + return 0; +}; diff --git a/src/canvas-bounds/controller.js b/src/canvas-bounds/controller.js new file mode 100644 index 00000000..8a3ed57b --- /dev/null +++ b/src/canvas-bounds/controller.js @@ -0,0 +1,95 @@ +import { Rectangle } from 'pixi.js'; +import CanvasBoundsLayer from './CanvasBoundsLayer'; +import { clampViewportToCanvasBounds } from './clamp'; + +export default class CanvasBoundsController { + constructor({ viewport, world, bounds, style } = {}) { + this.viewport = viewport; + this.world = world; + this.bounds = bounds; + this.layer = new CanvasBoundsLayer({ bounds, style }); + this._onWorldTransformed = (targetWorld) => { + this.syncLayer(targetWorld ?? this.world); + this.applyViewportClamp(); + }; + this._onViewportChanged = () => { + this.applyViewportClamp(); + }; + this._isApplyingClamp = false; + + this.attach(); + } + + attach() { + if (!this.viewport || !this.world || !this.bounds) return; + + this.insertLayerBehindWorld(); + this.syncLayer(); + this.configureViewport(); + this.viewport.on?.('world_transformed', this._onWorldTransformed); + this.viewport.on?.('moved', this._onViewportChanged); + this.viewport.on?.('zoomed', this._onViewportChanged); + } + + insertLayerBehindWorld() { + if (this.layer.parent === this.viewport) return; + + const worldIndex = this.viewport.getChildIndex?.(this.world) ?? -1; + if (worldIndex >= 0) { + this.viewport.addChildAt(this.layer, worldIndex); + } else { + this.viewport.addChild(this.layer); + } + } + + configureViewport() { + if (!this.viewport || !this.bounds) return; + + this.viewport.resize?.( + this.viewport.screenWidth, + this.viewport.screenHeight, + this.bounds.width, + this.bounds.height, + ); + this.viewport.forceHitArea = new Rectangle( + this.bounds.x, + this.bounds.y, + this.bounds.width, + this.bounds.height, + ); + this.viewport.plugins?.remove?.('clamp'); + this.applyViewportClamp(); + } + + resize() { + this.configureViewport(); + } + + syncLayer(world = this.world) { + this.layer.syncWorldTransform(world); + } + + applyViewportClamp() { + if (this._isApplyingClamp) return; + this._isApplyingClamp = true; + try { + clampViewportToCanvasBounds(this.viewport, this.bounds, this.world); + } finally { + this._isApplyingClamp = false; + } + } + + destroy() { + this.viewport?.off?.('world_transformed', this._onWorldTransformed); + this.viewport?.off?.('moved', this._onViewportChanged); + this.viewport?.off?.('zoomed', this._onViewportChanged); + if (this.layer?.parent) { + this.layer.parent.removeChild(this.layer); + } + this.layer?.destroy?.(); + this.layer = null; + this.viewport = null; + this.world = null; + this.bounds = null; + } +} diff --git a/src/canvas-bounds/options.js b/src/canvas-bounds/options.js new file mode 100644 index 00000000..eadd491d --- /dev/null +++ b/src/canvas-bounds/options.js @@ -0,0 +1,49 @@ +/** + * @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']; + +export const normalizeCanvasBounds = (bounds) => { + if (bounds == null) return null; + if (typeof bounds !== 'object' || Array.isArray(bounds)) { + throw new TypeError('canvas.bounds must be an object.'); + } + + const normalized = {}; + for (const key of BOUNDS_KEYS) { + const value = bounds[key]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new TypeError(`canvas.bounds.${key} must be a finite number.`); + } + normalized[key] = value; + } + + if (normalized.width <= 0) { + throw new TypeError('canvas.bounds.width must be greater than 0.'); + } + if (normalized.height <= 0) { + throw new TypeError('canvas.bounds.height must be greater than 0.'); + } + + 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({ + ...normalized, + right, + bottom, + }); +}; diff --git a/src/canvas-bounds/options.test.js b/src/canvas-bounds/options.test.js new file mode 100644 index 00000000..ea48150d --- /dev/null +++ b/src/canvas-bounds/options.test.js @@ -0,0 +1,117 @@ +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('returns null when canvas bounds are omitted', () => { + expect(normalizeCanvasBounds()).toBeNull(); + expect(normalizeCanvasBounds(null)).toBeNull(); + }); + + it('rejects non-finite 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', y: 0, width: 1, height: 1 }), + ).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('centers underflowing viewport axes on non-zero canvas bounds', () => { + const viewport = { + screenWidth: 800, + screenHeight: 600, + worldScreenWidth: 800, + worldScreenHeight: 600, + scale: { x: 1, y: 1 }, + x: 0, + y: 0, + }; + const bounds = normalizeCanvasBounds({ + x: 100, + y: 50, + width: 120, + height: 80, + }); + + clampViewportToCanvasBounds(viewport, bounds); + + expect(viewport.x).toBe(240); + expect(viewport.y).toBe(210); + }); +}); diff --git a/src/canvas-bounds/restrict.js b/src/canvas-bounds/restrict.js new file mode 100644 index 00000000..facd3d19 --- /dev/null +++ b/src/canvas-bounds/restrict.js @@ -0,0 +1,103 @@ +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) => { + 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, + }), + y: getAxisCorrection({ + min: frame.y, + max: frame.y + frame.height, + size: frame.height, + boundMin: canvasBounds.y, + boundMax: canvasBounds.bottom, + boundSize: canvasBounds.height, + }), + }; +}; + +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, +}) => { + if (size > boundSize) { + return boundMin + boundSize / 2 - (min + size / 2); + } + 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..60da6a30 --- /dev/null +++ b/src/canvas-bounds/restrict.test.js @@ -0,0 +1,40 @@ +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('centers oversized frames that cannot fully fit inside canvas bounds', () => { + expect( + getFrameCorrection( + { x: 100, y: 100, width: 700, height: 500 }, + canvasBounds, + ), + ).toEqual({ x: -200, y: -190 }); + }); +}); diff --git a/src/minimap/Minimap.js b/src/minimap/Minimap.js new file mode 100644 index 00000000..612aa235 --- /dev/null +++ b/src/minimap/Minimap.js @@ -0,0 +1,314 @@ +import { Point } from 'pixi.js'; +import { createMinimapSnapshot, minimapPointToCanvasPoint } from './model'; + +const DEFAULT_OPTIONS = Object.freeze({ + width: 180, + height: 120, + padding: 8, + opacity: 0.92, + style: { + background: '#ffffff', + border: '#d4d4d8', + object: '#94a3b8', + viewport: '#0c73bf', + viewportFill: 'rgba(12, 115, 191, 0.08)', + viewportStrokeWidth: 2, + }, +}); + +/** + * @typedef {object} MinimapStyle + * @property {string | number} [background] + * @property {string | number} [border] + * @property {string | number} [object] + * @property {string | number} [viewport] + * @property {string} [viewportFill] + * @property {number} [viewportStrokeWidth] + */ + +/** + * @typedef {object} MinimapOptions + * @property {number} [width] + * @property {number} [height] + * @property {number} [padding] + * @property {number} [opacity] + * @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._snapshot = null; + this._frame = null; + this._destroyed = false; + this._isPointerActive = false; + + this._requestRender = () => this.requestRender(); + 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.options.width; + this.canvas.height = this.options.height; + this.canvas.style.width = `${this.options.width}px`; + this.canvas.style.height = `${this.options.height}px`; + this.canvas.style.opacity = String(this.options.opacity); + this.canvas.style.display = 'block'; + this.canvas.style.cursor = 'pointer'; + this.canvas.style.touchAction = 'none'; + this.container.appendChild(this.canvas); + } + + attach() { + this.patchmap.on('patchmap:draw', this._requestRender); + this.patchmap.on('patchmap:updated', this._requestRender); + this.patchmap.on('patchmap:rotated', this._requestRender); + this.patchmap.on('patchmap:flipped', this._requestRender); + this.patchmap.viewport?.on?.('moved', this._requestRender); + this.patchmap.viewport?.on?.('zoomed', this._requestRender); + this.patchmap.viewport?.on?.('world_transformed', this._requestRender); + this.patchmap.viewport?.on?.('object_transformed', this._requestRender); + this.canvas.addEventListener('pointerdown', this._onPointerDown); + this.canvas.addEventListener('pointermove', this._onPointerMove); + this.canvas.addEventListener('pointerup', this._onPointerUp); + this.canvas.addEventListener('pointercancel', this._onPointerUp); + this.canvas.addEventListener('pointerleave', this._onPointerUp); + } + + detach() { + this.patchmap?.off?.('patchmap:draw', this._requestRender); + this.patchmap?.off?.('patchmap:updated', this._requestRender); + this.patchmap?.off?.('patchmap:rotated', this._requestRender); + this.patchmap?.off?.('patchmap:flipped', this._requestRender); + this.patchmap?.viewport?.off?.('moved', this._requestRender); + this.patchmap?.viewport?.off?.('zoomed', this._requestRender); + this.patchmap?.viewport?.off?.('world_transformed', this._requestRender); + this.patchmap?.viewport?.off?.('object_transformed', this._requestRender); + this.canvas?.removeEventListener('pointerdown', this._onPointerDown); + this.canvas?.removeEventListener('pointermove', this._onPointerMove); + this.canvas?.removeEventListener('pointerup', this._onPointerUp); + this.canvas?.removeEventListener('pointercancel', this._onPointerUp); + this.canvas?.removeEventListener('pointerleave', this._onPointerUp); + } + + 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) return; + + const ratio = globalThis.devicePixelRatio || 1; + const width = this.options.width; + const height = this.options.height; + 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._snapshot = createMinimapSnapshot({ + patchmap: this.patchmap, + width, + height, + padding: this.options.padding, + }); + + const ctx = this.context; + ctx.save(); + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + ctx.clearRect(0, 0, width, height); + if (this._snapshot) { + this.drawSnapshot(ctx, this._snapshot); + } + ctx.restore(); + } + + drawSnapshot(ctx, snapshot) { + const style = this.options.style; + const canvas = snapshot.canvas; + + ctx.fillStyle = style.background; + ctx.strokeStyle = style.border; + ctx.lineWidth = 1; + ctx.fillRect(canvas.x, canvas.y, canvas.width, canvas.height); + ctx.strokeRect(canvas.x, canvas.y, canvas.width, canvas.height); + + ctx.save(); + ctx.beginPath(); + ctx.rect(canvas.x, canvas.y, canvas.width, canvas.height); + ctx.clip(); + ctx.fillStyle = style.object; + for (const object of snapshot.objects) { + ctx.fillRect(object.x, object.y, object.width, object.height); + } + this.drawViewport(ctx, snapshot.viewport, style); + ctx.restore(); + } + + drawViewport(ctx, points, style) { + if (!points.length) return; + + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (const point of points.slice(1)) { + ctx.lineTo(point.x, point.y); + } + ctx.closePath(); + ctx.fillStyle = style.viewportFill; + ctx.strokeStyle = style.viewport; + 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) { + const cancelFrame = + globalThis.cancelAnimationFrame ?? globalThis.clearTimeout; + cancelFrame?.(this._frame); + this._frame = null; + } + this.detach(); + this.canvas?.remove(); + this.onDestroy?.(this); + this.patchmap = null; + this.container = null; + this.canvas = null; + this.context = null; + this._snapshot = null; + } +} + +const mergeOptions = (options) => { + const merged = { + ...DEFAULT_OPTIONS, + ...options, + style: { + ...DEFAULT_OPTIONS.style, + ...(options.style ?? {}), + }, + }; + return { + ...merged, + width: normalizePositiveNumber(merged.width, 'minimap.width'), + height: normalizePositiveNumber(merged.height, 'minimap.height'), + padding: normalizeNonNegativeNumber(merged.padding, 'minimap.padding'), + opacity: normalizeOpacity(merged.opacity), + }; +}; + +const normalizePositiveNumber = (value, name) => { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + throw new TypeError(`${name} must be a positive finite number.`); + } + return value; +}; + +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 normalizeOpacity = (value) => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new TypeError('minimap.opacity must be a finite number.'); + } + return Math.min(Math.max(value, 0), 1); +}; + +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), +}); diff --git a/src/minimap/model.js b/src/minimap/model.js new file mode 100644 index 00000000..b33da4a8 --- /dev/null +++ b/src/minimap/model.js @@ -0,0 +1,152 @@ +import { Point } from 'pixi.js'; +import { + getBoundsFromPoints, + getObjectFrameWorldCorners, +} from '../utils/transform'; + +const DEFAULT_ELIGIBLE_TYPES = new Set(['item', 'grid']); + +export const createMinimapSnapshot = ({ patchmap, width, height, padding }) => { + const canvasBounds = patchmap?.canvas?.bounds; + if (!canvasBounds) { + return null; + } + + const scale = getMinimapScale({ canvasBounds, width, height, padding }); + const origin = { + x: padding + (width - padding * 2 - canvasBounds.width * scale) / 2, + y: padding + (height - padding * 2 - canvasBounds.height * scale) / 2, + }; + + return { + canvas: projectRect(canvasBounds, canvasBounds, scale, origin), + viewport: getViewportPolygon({ patchmap, canvasBounds, scale, origin }), + objects: collectObjectSilhouettes({ + patchmap, + canvasBounds, + scale, + origin, + }), + scale, + origin, + canvasBounds, + }; +}; + +export const minimapPointToCanvasPoint = ({ + point, + canvasBounds, + scale, + origin, +}) => ({ + x: canvasBounds.x + (point.x - origin.x) / scale, + y: canvasBounds.y + (point.y - origin.y) / scale, +}); + +const getMinimapScale = ({ canvasBounds, width, height, padding }) => { + const availableWidth = Math.max(width - padding * 2, 1); + const availableHeight = Math.max(height - padding * 2, 1); + return Math.min( + availableWidth / canvasBounds.width, + availableHeight / canvasBounds.height, + ); +}; + +const collectObjectSilhouettes = ({ + patchmap, + canvasBounds, + scale, + origin, +}) => { + const world = patchmap?.world; + if (!world) return []; + + return collectManagedElements(world) + .filter(isMinimapEligibleElement) + .map((element) => getElementCanvasFrame(element, world)) + .map((frame) => intersectRects(frame, canvasBounds)) + .filter(Boolean) + .map((frame) => projectRect(frame, canvasBounds, scale, origin)); +}; + +const collectManagedElements = (root) => { + const result = []; + const visit = (node) => { + if (!node) return; + if (node !== root && node?.type && node?.constructor?.isElement) { + result.push(node); + } + for (const child of node.children ?? []) { + visit(child); + } + }; + visit(root); + return result; +}; + +const isMinimapEligibleElement = (element) => + DEFAULT_ELIGIBLE_TYPES.has(element?.type) && + element.visible !== false && + element.renderable !== false && + element.props?.show !== false; + +const getElementCanvasFrame = (element, world) => { + const canvasCorners = getObjectFrameWorldCorners(element).map((point) => + world.toLocal(point), + ); + return getBoundsFromPoints(canvasCorners); +}; + +const getViewportPolygon = ({ patchmap, canvasBounds, scale, origin }) => { + const app = patchmap?.app; + const viewport = patchmap?.viewport; + const world = patchmap?.world; + if (!app || !viewport || !world) return []; + + return [ + new Point(0, 0), + new Point(app.screen.width, 0), + new Point(app.screen.width, app.screen.height), + new Point(0, app.screen.height), + ].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, + y: origin.y + (rect.y - canvasBounds.y) * scale, + width: rect.width * scale, + height: rect.height * scale, +}); + +const projectPoint = (point, canvasBounds, scale, origin) => ({ + x: origin.x + (point.x - canvasBounds.x) * scale, + y: origin.y + (point.y - canvasBounds.y) * scale, +}); + +const intersectRects = (rect, canvasBounds) => { + if (!rect) return null; + + const x = Math.max(rect.x, canvasBounds.x); + const y = Math.max(rect.y, canvasBounds.y); + const right = Math.min(rect.x + rect.width, canvasBounds.right); + const bottom = Math.min(rect.y + rect.height, canvasBounds.bottom); + if (right <= x || bottom <= y) return null; + + return { + x, + y, + width: right - x, + height: bottom - y, + }; +}; diff --git a/src/minimap/model.test.js b/src/minimap/model.test.js new file mode 100644 index 00000000..87a670f3 --- /dev/null +++ b/src/minimap/model.test.js @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { minimapPointToCanvasPoint } from './model'; + +describe('minimap model', () => { + 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: 0.164, + origin: { x: 8, y: 19 }, + }), + ).toEqual({ + x: 500, + y: 250, + }); + }); +}); diff --git a/src/patchmap.js b/src/patchmap.js index 39195051..c8803c2c 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -1,6 +1,8 @@ import gsap from 'gsap'; import { Application, UPDATE_PRIORITY } from 'pixi.js'; import { isValidationError } from 'zod-validation-error'; +import CanvasBoundsController from './canvas-bounds/controller'; +import { normalizeCanvasBounds } from './canvas-bounds/options'; import { UndoRedoManager } from './command/UndoRedoManager'; import './display/components/registry'; import { draw } from './display/draw'; @@ -18,6 +20,7 @@ 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'; @@ -26,6 +29,29 @@ import { selector } from './utils/selector/selector'; import { themeStore } from './utils/theme'; import { validateMapData } from './utils/validator'; +/** + * @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; @@ -38,6 +64,11 @@ class Patchmap extends WildcardEventEmitter { _stateManager = null; _world = null; _viewTransform = this._createViewTransform(); + /** @type {import('./canvas-bounds/options').CanvasBounds | null} */ + _canvasBounds = null; + /** @type {import('./canvas-bounds/controller').default | null} */ + _canvasBoundsController = null; + _minimaps = new Set(); _drawToken = 0; get app() { @@ -60,6 +91,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,6 +157,10 @@ class Patchmap extends WildcardEventEmitter { }; } + /** + * @param {HTMLElement} element + * @param {PatchmapInitOptions} [opts] + */ async init(element, opts = {}) { if (this.isInit) return; @@ -125,11 +169,14 @@ class Patchmap extends WildcardEventEmitter { viewport: viewportOptions = {}, theme: themeOptions = {}, assets: assetsOptions = [], + canvas: canvasOptions = {}, transformer, } = opts; + const canvasBounds = normalizeCanvasBounds(canvasOptions?.bounds); this.undoRedoManager._setHotkeys(); this._theme.set(themeOptions); + this._canvasBounds = canvasBounds; this._app = new Application(); await initApp(this.app, { resizeTo: element, ...appOptions }); @@ -138,6 +185,13 @@ class Patchmap extends WildcardEventEmitter { 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 +201,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); @@ -161,10 +219,15 @@ class Patchmap extends WildcardEventEmitter { destroy() { if (!this.isInit) return; + for (const minimap of this._minimaps) { + minimap.destroy(); + } + this._minimaps.clear(); this.undoRedoManager.destroy(); this.animationContext.revert(); this.stateManager.resetState(); this.stateManager.destroy(); + this._canvasBoundsController?.destroy(); event.removeAllEvent(this.viewport); this.viewport.destroy({ children: true, context: true, style: true }); const parentElement = this.app.canvas.parentElement; @@ -183,6 +246,9 @@ class Patchmap extends WildcardEventEmitter { this._stateManager = null; this._world = null; this._viewTransform = this._createViewTransform(); + this._canvasBounds = null; + this._canvasBoundsController = null; + this._minimaps = new Set(); this._drawToken = 0; this.emit('patchmap:destroyed', { target: this }); this.removeAllListeners(); @@ -255,7 +321,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 +333,10 @@ 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; } get rotation() { @@ -279,6 +351,22 @@ 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; + } + _createViewTransform() { return new ViewTransform({ onRotate: (angle) => @@ -289,7 +377,7 @@ class Patchmap extends WildcardEventEmitter { } _createStoreContext() { - return { + const store = { app: this.app, viewport: this._viewport, world: this._world, @@ -298,6 +386,17 @@ class Patchmap extends WildcardEventEmitter { theme: this.theme, animationContext: this.animationContext, }; + if (this._canvasBounds) { + store.canvasBounds = this._canvasBounds; + } + return store; + } + + _emitViewportMoved(type) { + this.viewport?.emit?.('moved', { + viewport: this.viewport, + type, + }); } } diff --git a/src/tests/render/canvas-bounds.test.js b/src/tests/render/canvas-bounds.test.js new file mode 100644 index 00000000..bb7a105d --- /dev/null +++ b/src/tests/render/canvas-bounds.test.js @@ -0,0 +1,257 @@ +import { afterEach, describe, expect, it } 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; +}; + +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 a viewport-backed finite canvas layer behind world', 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(); + + const layer = patchmap._canvasBoundsController.layer; + expect(layer.parent).toBe(patchmap.viewport); + expect(layer.canvasBounds).toEqual(patchmap.canvas.bounds); + expect(() => layer.getBounds()).not.toThrow(); + expect(patchmap.viewport.getChildIndex(layer)).toBeLessThan( + patchmap.viewport.getChildIndex(patchmap.world), + ); + expect(patchmap.viewport.forceHitArea).toMatchObject({ + x: -20, + y: 30, + width: 120, + height: 80, + }); + }); + + it('keeps the finite canvas layer aligned with world transforms', async () => { + element = createHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 500, height: 300 }, + }, + }); + + patchmap.rotation.set(25); + patchmap.flip.set({ x: true }); + + const layer = patchmap._canvasBoundsController.layer; + expect(layer.position.x).toBe(patchmap.world.position.x); + expect(layer.position.y).toBe(patchmap.world.position.y); + expect(layer.pivot.x).toBe(patchmap.world.pivot.x); + expect(layer.pivot.y).toBe(patchmap.world.pivot.y); + expect(layer.scale.x).toBe(patchmap.world.scale.x); + expect(layer.scale.y).toBe(patchmap.world.scale.y); + expect(layer.angle).toBe(patchmap.world.angle); + }); + + 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('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('centers non-zero finite canvas bounds when the canvas underflows the viewport', async () => { + element = createHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 100, y: 50, width: 120, height: 80 }, + }, + }); + + patchmap._canvasBoundsController.applyViewportClamp(); + + const center = patchmap.viewport.toWorld( + patchmap.viewport.screenWidth / 2, + patchmap.viewport.screenHeight / 2, + ); + expect(center.x).toBeCloseTo(160, 1); + expect(center.y).toBeCloseTo(90, 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..31f39eb5 --- /dev/null +++ b/src/tests/render/minimap.test.js @@ -0,0 +1,213 @@ +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 = () => { + const element = document.createElement('div'); + document.body.appendChild(element); + return element; +}; + +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('rejects invalid minimap dimensions', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + + expect(() => patchmap.createMinimap(minimapHost, { width: 0 })).toThrow( + 'minimap.width must be a positive finite number.', + ); + }); + + 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); + }); +}); From 2bd17c2b25ea1a21d61d1d0484f978ec49acbbc8 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 11 May 2026 19:08:43 +0900 Subject: [PATCH 03/18] fix: restrict transformer gestures to canvas bounds --- src/transformer/ResizeGestureController.js | 3 +- src/transformer/RotateGestureController.js | 3 +- src/transformer/resize-apply.js | 129 +++++++++++++++-- src/transformer/resize-apply.test.js | 153 +++++++++++++++++++++ src/transformer/rotate-apply.js | 27 +++- src/transformer/rotate-apply.test.js | 40 ++++++ 6 files changed, 341 insertions(+), 14 deletions(-) create mode 100644 src/transformer/resize-apply.test.js 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(); + }); }); From 8f44c266eaa38948160bc2348875c51e1cd66cac Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 11 May 2026 19:08:54 +0900 Subject: [PATCH 04/18] chore: ignore gstack session files --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/ From 841c90fb65cc724944a40376d3d2308a2065e858 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 12 May 2026 11:51:58 +0900 Subject: [PATCH 05/18] feat: render minimap contour silhouettes --- src/minimap/Minimap.js | 31 +++- src/minimap/model.js | 303 ++++++++++++++++++++++++++++--- src/tests/render/minimap.test.js | 155 ++++++++++++++++ 3 files changed, 460 insertions(+), 29 deletions(-) diff --git a/src/minimap/Minimap.js b/src/minimap/Minimap.js index 612aa235..80eba5b8 100644 --- a/src/minimap/Minimap.js +++ b/src/minimap/Minimap.js @@ -180,12 +180,41 @@ export default class Minimap { ctx.clip(); ctx.fillStyle = style.object; for (const object of snapshot.objects) { - ctx.fillRect(object.x, object.y, object.width, object.height); + this.drawSilhouette(ctx, object); } this.drawViewport(ctx, snapshot.viewport, style); ctx.restore(); } + drawPolygon(ctx, points) { + if (!points?.length) return; + + ctx.beginPath(); + this.drawPath(ctx, points); + ctx.fill(); + } + + drawSilhouette(ctx, object) { + const paths = object.paths?.length ? object.paths : [object.points]; + if (!paths.length) return; + + 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; diff --git a/src/minimap/model.js b/src/minimap/model.js index b33da4a8..76b2e6d8 100644 --- a/src/minimap/model.js +++ b/src/minimap/model.js @@ -1,12 +1,34 @@ import { Point } from 'pixi.js'; -import { - getBoundsFromPoints, - getObjectFrameWorldCorners, -} from '../utils/transform'; +import { getObjectFrameWorldCorners } from '../utils/transform'; const DEFAULT_ELIGIBLE_TYPES = new Set(['item', 'grid']); export const createMinimapSnapshot = ({ patchmap, width, height, padding }) => { + const objectSnapshot = createMinimapObjectSnapshot({ + patchmap, + width, + height, + padding, + }); + if (!objectSnapshot) return null; + + return { + ...objectSnapshot, + viewport: createMinimapViewport({ + patchmap, + canvasBounds: objectSnapshot.canvasBounds, + scale: objectSnapshot.scale, + origin: objectSnapshot.origin, + }), + }; +}; + +export const createMinimapObjectSnapshot = ({ + patchmap, + width, + height, + padding, +}) => { const canvasBounds = patchmap?.canvas?.bounds; if (!canvasBounds) { return null; @@ -20,7 +42,6 @@ export const createMinimapSnapshot = ({ patchmap, width, height, padding }) => { return { canvas: projectRect(canvasBounds, canvasBounds, scale, origin), - viewport: getViewportPolygon({ patchmap, canvasBounds, scale, origin }), objects: collectObjectSilhouettes({ patchmap, canvasBounds, @@ -33,6 +54,13 @@ export const createMinimapSnapshot = ({ patchmap, width, height, padding }) => { }; }; +export const createMinimapViewport = ({ + patchmap, + canvasBounds, + scale, + origin, +}) => getViewportPolygon({ patchmap, canvasBounds, scale, origin }); + export const minimapPointToCanvasPoint = ({ point, canvasBounds, @@ -63,10 +91,192 @@ const collectObjectSilhouettes = ({ return collectManagedElements(world) .filter(isMinimapEligibleElement) - .map((element) => getElementCanvasFrame(element, world)) - .map((frame) => intersectRects(frame, canvasBounds)) - .filter(Boolean) - .map((frame) => projectRect(frame, canvasBounds, scale, origin)); + .flatMap((element) => + getElementCanvasSilhouettes(element, world) + .filter((silhouette) => + silhouetteIntersectsCanvasBounds(silhouette, canvasBounds), + ) + .map((silhouette) => { + const paths = silhouette.paths.map((path) => + path.map((point) => + projectPoint(point, canvasBounds, scale, origin), + ), + ); + return { + points: paths[0] ?? [], + paths, + }; + }), + ); +}; + +const createSilhouette = (paths) => ({ + points: paths[0] ?? [], + paths, +}); + +const getElementCanvasSilhouettes = (element, world) => { + if (element?.type === 'grid') { + const paths = getGridCellCanvasPaths(element, world); + return paths.length ? [createSilhouette(paths)] : []; + } + return [ + createSilhouette([ + 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 rows = cells.length; + const cols = Math.max(0, ...cells.map((row) => row?.length ?? 0)); + if (!rows || !cols) return []; + + const gap = normalizeGap(grid.props?.gap); + const xEdges = createGridAxisEdges(cols, size.width, gap.x); + const yEdges = createGridAxisEdges(rows, size.height, gap.y); + const active = (row, col) => Boolean(cells[row]?.[col]); + const toCanvasPoint = createGridLocalToCanvasPoint(grid, world); + + if (isFullyActiveGrid({ rows, cols, active })) { + return [ + [ + toCanvasPoint(xEdges[0], yEdges[0]), + toCanvasPoint(xEdges[cols], yEdges[0]), + toCanvasPoint(xEdges[cols], yEdges[rows]), + toCanvasPoint(xEdges[0], yEdges[rows]), + ], + ]; + } + + const loops = traceActiveCellBoundaryLoops({ rows, cols, active }); + return loops.map((loop) => + simplifyCollinearPoints( + loop.map((point) => toCanvasPoint(xEdges[point.x], yEdges[point.y])), + ), + ); +}; + +const isFullyActiveGrid = ({ rows, cols, active }) => { + for (let row = 0; row < rows; row += 1) { + for (let col = 0; col < cols; col += 1) { + if (!active(row, col)) return false; + } + } + return true; +}; + +const createGridAxisEdges = (count, size, gap) => { + const step = size + gap; + return Array.from({ length: count + 1 }, (_, index) => { + if (index === count) return Math.max(count * size + (count - 1) * gap, 0); + return index * step; + }); +}; + +const traceActiveCellBoundaryLoops = ({ rows, cols, active }) => { + const edges = []; + for (let row = 0; row < rows; row += 1) { + for (let col = 0; col < cols; col += 1) { + if (!active(row, col)) continue; + if (!active(row - 1, col)) { + edges.push(createBoundaryEdge(col, row, col + 1, row)); + } + if (!active(row, col + 1)) { + edges.push(createBoundaryEdge(col + 1, row, col + 1, row + 1)); + } + if (!active(row + 1, col)) { + edges.push(createBoundaryEdge(col + 1, row + 1, col, row + 1)); + } + if (!active(row, col - 1)) { + edges.push(createBoundaryEdge(col, row + 1, col, row)); + } + } + } + + const edgesByStart = new Map(); + for (const edge of edges) { + const key = pointKey(edge.start); + const list = edgesByStart.get(key) ?? []; + list.push(edge); + edgesByStart.set(key, list); + } + + 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)) + ?.filter((edge) => !edge.used); + if (!candidates?.length) return null; + + const preferredDirections = [ + (previousEdge.direction + 1) % 4, + previousEdge.direction, + (previousEdge.direction + 3) % 4, + (previousEdge.direction + 2) % 4, + ]; + return candidates.sort( + (a, b) => + preferredDirections.indexOf(a.direction) - + preferredDirections.indexOf(b.direction), + )[0]; +}; + +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) => { @@ -75,6 +285,7 @@ const collectManagedElements = (root) => { if (!node) return; if (node !== root && node?.type && node?.constructor?.isElement) { result.push(node); + if (node.type === 'grid') return; } for (const child of node.children ?? []) { visit(child); @@ -90,11 +301,39 @@ const isMinimapEligibleElement = (element) => element.renderable !== false && element.props?.show !== false; -const getElementCanvasFrame = (element, world) => { - const canvasCorners = getObjectFrameWorldCorners(element).map((point) => - world.toLocal(point), - ); - return getBoundsFromPoints(canvasCorners); +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 }) => { @@ -134,19 +373,27 @@ const projectPoint = (point, canvasBounds, scale, origin) => ({ y: origin.y + (point.y - canvasBounds.y) * scale, }); -const intersectRects = (rect, canvasBounds) => { - if (!rect) return null; - - const x = Math.max(rect.x, canvasBounds.x); - const y = Math.max(rect.y, canvasBounds.y); - const right = Math.min(rect.x + rect.width, canvasBounds.right); - const bottom = Math.min(rect.y + rect.height, canvasBounds.bottom); - if (right <= x || bottom <= y) return null; +const silhouetteIntersectsCanvasBounds = (silhouette, canvasBounds) => + silhouette.paths.some((path) => + pathIntersectsCanvasBounds(path, canvasBounds), + ); - return { - x, - y, - width: right - x, - height: bottom - y, - }; +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/tests/render/minimap.test.js b/src/tests/render/minimap.test.js index 31f39eb5..24aa67c9 100644 --- a/src/tests/render/minimap.test.js +++ b/src/tests/render/minimap.test.js @@ -210,4 +210,159 @@ describe('minimap', () => { expect(minimap._snapshot.objects).toHaveLength(1); }); + + 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); + }); }); From 9154e18001b7af68f0363382be347799909d19f2 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 12 May 2026 11:52:08 +0900 Subject: [PATCH 06/18] perf: cache minimap object layer --- src/minimap/Minimap.js | 168 ++++++++++++++++++++++++++----- src/tests/render/minimap.test.js | 68 +++++++++++++ 2 files changed, 213 insertions(+), 23 deletions(-) diff --git a/src/minimap/Minimap.js b/src/minimap/Minimap.js index 80eba5b8..355892d4 100644 --- a/src/minimap/Minimap.js +++ b/src/minimap/Minimap.js @@ -1,5 +1,9 @@ import { Point } from 'pixi.js'; -import { createMinimapSnapshot, minimapPointToCanvasPoint } from './model'; +import { + createMinimapObjectSnapshot, + createMinimapViewport, + minimapPointToCanvasPoint, +} from './model'; const DEFAULT_OPTIONS = Object.freeze({ width: 180, @@ -57,12 +61,18 @@ export default class Minimap { 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._frame = null; this._destroyed = false; this._isPointerActive = false; this._requestRender = () => this.requestRender(); + this._requestObjectRender = () => this.invalidateObjects(); this._onPointerDown = (event) => this.handlePointerDown(event); this._onPointerMove = (event) => this.handlePointerMove(event); this._onPointerUp = () => this.handlePointerUp(); @@ -89,14 +99,17 @@ export default class Minimap { } attach() { - this.patchmap.on('patchmap:draw', this._requestRender); - this.patchmap.on('patchmap:updated', this._requestRender); - this.patchmap.on('patchmap:rotated', this._requestRender); - this.patchmap.on('patchmap:flipped', this._requestRender); + this.patchmap.on('patchmap:draw', this._requestObjectRender); + this.patchmap.on('patchmap:updated', this._requestObjectRender); + this.patchmap.on('patchmap:rotated', this._requestObjectRender); + this.patchmap.on('patchmap:flipped', this._requestObjectRender); this.patchmap.viewport?.on?.('moved', this._requestRender); this.patchmap.viewport?.on?.('zoomed', this._requestRender); this.patchmap.viewport?.on?.('world_transformed', this._requestRender); - this.patchmap.viewport?.on?.('object_transformed', this._requestRender); + this.patchmap.viewport?.on?.( + 'object_transformed', + this._requestObjectRender, + ); this.canvas.addEventListener('pointerdown', this._onPointerDown); this.canvas.addEventListener('pointermove', this._onPointerMove); this.canvas.addEventListener('pointerup', this._onPointerUp); @@ -105,14 +118,17 @@ export default class Minimap { } detach() { - this.patchmap?.off?.('patchmap:draw', this._requestRender); - this.patchmap?.off?.('patchmap:updated', this._requestRender); - this.patchmap?.off?.('patchmap:rotated', this._requestRender); - this.patchmap?.off?.('patchmap:flipped', this._requestRender); + this.patchmap?.off?.('patchmap:draw', this._requestObjectRender); + this.patchmap?.off?.('patchmap:updated', this._requestObjectRender); + this.patchmap?.off?.('patchmap:rotated', this._requestObjectRender); + this.patchmap?.off?.('patchmap:flipped', this._requestObjectRender); this.patchmap?.viewport?.off?.('moved', this._requestRender); this.patchmap?.viewport?.off?.('zoomed', this._requestRender); this.patchmap?.viewport?.off?.('world_transformed', this._requestRender); - this.patchmap?.viewport?.off?.('object_transformed', this._requestRender); + this.patchmap?.viewport?.off?.( + 'object_transformed', + this._requestObjectRender, + ); this.canvas?.removeEventListener('pointerdown', this._onPointerDown); this.canvas?.removeEventListener('pointermove', this._onPointerMove); this.canvas?.removeEventListener('pointerup', this._onPointerUp); @@ -120,6 +136,12 @@ export default class Minimap { this.canvas?.removeEventListener('pointerleave', this._onPointerUp); } + invalidateObjects() { + if (this._destroyed) return; + this._objectsDirty = true; + this.requestRender(); + } + requestRender() { if (this._destroyed || this._frame !== null) return; const requestFrame = @@ -132,7 +154,8 @@ export default class Minimap { } render() { - if (!this.context) return; + if (!this.context || !this._objectLayerContext) return; + this.cancelPendingRender(); const ratio = globalThis.devicePixelRatio || 1; const width = this.options.width; @@ -145,26 +168,116 @@ export default class Minimap { ) { this.canvas.width = pixelWidth; this.canvas.height = pixelHeight; + this._objectsDirty = true; + } + + const objectSnapshot = this.getObjectSnapshot({ + ratio, + width, + height, + pixelWidth, + pixelHeight, + }); + if (!objectSnapshot) 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(); + } + + 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; } - this._snapshot = createMinimapSnapshot({ + const snapshot = createMinimapObjectSnapshot({ patchmap: this.patchmap, width, height, padding: this.options.padding, }); + if (!snapshot) { + this._objectSnapshot = null; + return null; + } + + this._objectSnapshot = snapshot; + this._objectLayerKey = layerKey; + this._objectsDirty = false; + this.drawObjectLayer({ + snapshot, + ratio, + width, + height, + pixelWidth, + pixelHeight, + }); + return snapshot; + } + + getObjectLayerKey({ ratio, pixelWidth, pixelHeight }) { + const style = this.options.style; + return JSON.stringify({ + ratio, + pixelWidth, + pixelHeight, + background: style.background, + border: style.border, + object: style.object, + }); + } + + drawObjectLayer({ snapshot, ratio, width, height, pixelWidth, pixelHeight }) { + const ctx = this._objectLayerContext; + if (!ctx) return; + + if ( + this._objectLayer.width !== pixelWidth || + this._objectLayer.height !== pixelHeight + ) { + this._objectLayer.width = pixelWidth; + this._objectLayer.height = pixelHeight; + } - const ctx = this.context; ctx.save(); ctx.setTransform(ratio, 0, 0, ratio, 0, 0); ctx.clearRect(0, 0, width, height); - if (this._snapshot) { - this.drawSnapshot(ctx, this._snapshot); - } + this.drawStaticSnapshot(ctx, snapshot); ctx.restore(); } - drawSnapshot(ctx, snapshot) { + drawStaticSnapshot(ctx, snapshot) { const style = this.options.style; const canvas = snapshot.canvas; @@ -182,7 +295,16 @@ export default class Minimap { for (const object of snapshot.objects) { this.drawSilhouette(ctx, object); } - this.drawViewport(ctx, snapshot.viewport, style); + ctx.restore(); + } + + drawViewportLayer(ctx, snapshot) { + const canvas = snapshot.canvas; + ctx.save(); + ctx.beginPath(); + ctx.rect(canvas.x, canvas.y, canvas.width, canvas.height); + ctx.clip(); + this.drawViewport(ctx, snapshot.viewport, this.options.style); ctx.restore(); } @@ -282,10 +404,7 @@ export default class Minimap { if (this._destroyed) return; this._destroyed = true; if (this._frame !== null) { - const cancelFrame = - globalThis.cancelAnimationFrame ?? globalThis.clearTimeout; - cancelFrame?.(this._frame); - this._frame = null; + this.cancelPendingRender(); } this.detach(); this.canvas?.remove(); @@ -294,7 +413,10 @@ export default class Minimap { this.container = null; this.canvas = null; this.context = null; + this._objectLayer = null; + this._objectLayerContext = null; this._snapshot = null; + this._objectSnapshot = null; } } diff --git a/src/tests/render/minimap.test.js b/src/tests/render/minimap.test.js index 24aa67c9..2c010f09 100644 --- a/src/tests/render/minimap.test.js +++ b/src/tests/render/minimap.test.js @@ -365,4 +365,72 @@ describe('minimap', () => { 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 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); + }); }); From b583a136996eb06bbc0a37c40bb02208b75081ad Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 12 May 2026 16:33:19 +0900 Subject: [PATCH 07/18] refactor: simplify finite canvas bounds clamping --- src/canvas-bounds/CanvasBoundsLayer.js | 55 ------------------- src/canvas-bounds/clamp.js | 37 +------------ src/canvas-bounds/controller.js | 76 ++++++++++++++------------ src/tests/render/canvas-bounds.test.js | 48 +++++++++------- 4 files changed, 70 insertions(+), 146 deletions(-) delete mode 100644 src/canvas-bounds/CanvasBoundsLayer.js diff --git a/src/canvas-bounds/CanvasBoundsLayer.js b/src/canvas-bounds/CanvasBoundsLayer.js deleted file mode 100644 index 4d2189ed..00000000 --- a/src/canvas-bounds/CanvasBoundsLayer.js +++ /dev/null @@ -1,55 +0,0 @@ -import { Graphics } from 'pixi.js'; - -const DEFAULT_STYLE = Object.freeze({ - background: 0xffffff, - backgroundAlpha: 0.72, - border: 0xd4d4d8, - borderAlpha: 1, - borderWidth: 1, -}); - -export default class CanvasBoundsLayer extends Graphics { - constructor({ bounds, style = {} } = {}) { - super(); - this.label = 'patch-map:canvas-bounds-layer'; - this.eventMode = 'none'; - this.interactiveChildren = false; - this._canvasBounds = bounds; - this._style = { ...DEFAULT_STYLE, ...style }; - this.draw(); - } - - get canvasBounds() { - return this._canvasBounds; - } - - set canvasBounds(value) { - this._canvasBounds = value; - this.draw(); - } - - draw() { - this.clear(); - if (!this._canvasBounds) return; - - const { x, y, width, height } = this._canvasBounds; - this.rect(x, y, width, height) - .fill({ - color: this._style.background, - alpha: this._style.backgroundAlpha, - }) - .stroke({ - color: this._style.border, - alpha: this._style.borderAlpha, - width: this._style.borderWidth, - }); - } - - syncWorldTransform(world) { - if (!world) return; - this.position.copyFrom(world.position); - this.pivot.copyFrom(world.pivot); - this.scale.copyFrom(world.scale); - this.angle = world.angle; - } -} diff --git a/src/canvas-bounds/clamp.js b/src/canvas-bounds/clamp.js index 688a6c57..eae50ba3 100644 --- a/src/canvas-bounds/clamp.js +++ b/src/canvas-bounds/clamp.js @@ -1,5 +1,6 @@ import { Point } from 'pixi.js'; import { getBoundsFromPoints } from '../utils/transform'; +import { getFrameCorrection } from './restrict'; const CLAMP_EPSILON = 0.0001; @@ -79,7 +80,6 @@ const clampViewportToTransformedCanvasBounds = (viewport, bounds, world) => { } const center = screenPointToCanvasPoint({ - viewport, world, point: new Point(viewport.screenWidth / 2, viewport.screenHeight / 2), }); @@ -110,38 +110,3 @@ const getVisibleCanvasFrame = (viewport, world) => { const screenPointToCanvasPoint = ({ world, point }) => { return world.toLocal(point); }; - -const getFrameCorrection = (frame, bounds) => ({ - x: getAxisCorrection({ - min: frame.x, - max: frame.x + frame.width, - size: frame.width, - boundMin: bounds.x, - boundMax: bounds.right, - boundSize: bounds.width, - }), - y: getAxisCorrection({ - min: frame.y, - max: frame.y + frame.height, - size: frame.height, - boundMin: bounds.y, - boundMax: bounds.bottom, - boundSize: bounds.height, - }), -}); - -const getAxisCorrection = ({ - min, - max, - size, - boundMin, - boundMax, - boundSize, -}) => { - if (size >= boundSize) { - return boundMin + boundSize / 2 - (min + size / 2); - } - if (min < boundMin) return boundMin - min; - if (max > boundMax) return boundMax - max; - return 0; -}; diff --git a/src/canvas-bounds/controller.js b/src/canvas-bounds/controller.js index 8a3ed57b..d2eb1ead 100644 --- a/src/canvas-bounds/controller.js +++ b/src/canvas-bounds/controller.js @@ -1,21 +1,16 @@ import { Rectangle } from 'pixi.js'; -import CanvasBoundsLayer from './CanvasBoundsLayer'; import { clampViewportToCanvasBounds } from './clamp'; export default class CanvasBoundsController { - constructor({ viewport, world, bounds, style } = {}) { + constructor({ viewport, world, bounds } = {}) { this.viewport = viewport; this.world = world; this.bounds = bounds; - this.layer = new CanvasBoundsLayer({ bounds, style }); - this._onWorldTransformed = (targetWorld) => { - this.syncLayer(targetWorld ?? this.world); - this.applyViewportClamp(); - }; - this._onViewportChanged = () => { + this._applyViewportClamp = () => { this.applyViewportClamp(); }; this._isApplyingClamp = false; + this._baseClampZoomMinScale = null; this.attach(); } @@ -23,23 +18,10 @@ export default class CanvasBoundsController { attach() { if (!this.viewport || !this.world || !this.bounds) return; - this.insertLayerBehindWorld(); - this.syncLayer(); this.configureViewport(); - this.viewport.on?.('world_transformed', this._onWorldTransformed); - this.viewport.on?.('moved', this._onViewportChanged); - this.viewport.on?.('zoomed', this._onViewportChanged); - } - - insertLayerBehindWorld() { - if (this.layer.parent === this.viewport) return; - - const worldIndex = this.viewport.getChildIndex?.(this.world) ?? -1; - if (worldIndex >= 0) { - this.viewport.addChildAt(this.layer, worldIndex); - } else { - this.viewport.addChild(this.layer); - } + this.viewport.on?.('world_transformed', this._applyViewportClamp); + this.viewport.on?.('moved', this._applyViewportClamp); + this.viewport.on?.('zoomed', this._applyViewportClamp); } configureViewport() { @@ -51,6 +33,7 @@ export default class CanvasBoundsController { this.bounds.width, this.bounds.height, ); + this.configureMinimumScale(); this.viewport.forceHitArea = new Rectangle( this.bounds.x, this.bounds.y, @@ -61,12 +44,32 @@ export default class CanvasBoundsController { this.applyViewportClamp(); } - resize() { - this.configureViewport(); + 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); + } } - syncLayer(world = this.world) { - this.layer.syncWorldTransform(world); + resize() { + this.configureViewport(); } applyViewportClamp() { @@ -80,16 +83,17 @@ export default class CanvasBoundsController { } destroy() { - this.viewport?.off?.('world_transformed', this._onWorldTransformed); - this.viewport?.off?.('moved', this._onViewportChanged); - this.viewport?.off?.('zoomed', this._onViewportChanged); - if (this.layer?.parent) { - this.layer.parent.removeChild(this.layer); - } - this.layer?.destroy?.(); - this.layer = null; + this.viewport?.off?.('world_transformed', this._applyViewportClamp); + this.viewport?.off?.('moved', this._applyViewportClamp); + this.viewport?.off?.('zoomed', this._applyViewportClamp); 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/tests/render/canvas-bounds.test.js b/src/tests/render/canvas-bounds.test.js index bb7a105d..0db6ac93 100644 --- a/src/tests/render/canvas-bounds.test.js +++ b/src/tests/render/canvas-bounds.test.js @@ -31,7 +31,7 @@ describe('canvas bounds', () => { expect(patchmap._canvasBoundsController).toBeNull(); }); - it('installs a viewport-backed finite canvas layer behind world', async () => { + it('installs finite canvas bounds without adding a render layer', async () => { element = createHost(); patchmap = new Patchmap(); @@ -50,14 +50,6 @@ describe('canvas bounds', () => { bottom: 110, }); expect(patchmap._canvasBoundsController).toBeTruthy(); - - const layer = patchmap._canvasBoundsController.layer; - expect(layer.parent).toBe(patchmap.viewport); - expect(layer.canvasBounds).toEqual(patchmap.canvas.bounds); - expect(() => layer.getBounds()).not.toThrow(); - expect(patchmap.viewport.getChildIndex(layer)).toBeLessThan( - patchmap.viewport.getChildIndex(patchmap.world), - ); expect(patchmap.viewport.forceHitArea).toMatchObject({ x: -20, y: 30, @@ -66,27 +58,45 @@ describe('canvas bounds', () => { }); }); - it('keeps the finite canvas layer aligned with world transforms', async () => { + 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: 500, height: 300 }, + 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 layer = patchmap._canvasBoundsController.layer; - expect(layer.position.x).toBe(patchmap.world.position.x); - expect(layer.position.y).toBe(patchmap.world.position.y); - expect(layer.pivot.x).toBe(patchmap.world.pivot.x); - expect(layer.pivot.y).toBe(patchmap.world.pivot.y); - expect(layer.scale.x).toBe(patchmap.world.scale.x); - expect(layer.scale.y).toBe(patchmap.world.scale.y); - expect(layer.angle).toBe(patchmap.world.angle); + const clampZoom = patchmap.viewport.plugins.get('clamp-zoom'); + expect(clampZoom.options.minScale).toBeCloseTo(0.4); }); it('clamps viewport movement to finite canvas bounds', async () => { From e806585776fd4589755db618b98de5549d400a44 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 12 May 2026 16:35:27 +0900 Subject: [PATCH 08/18] feat: add configurable finite canvas minimap --- src/minimap/Minimap.js | 207 ++++++++++++++++++---------- src/minimap/model.js | 168 +++++++++++------------ src/tests/render/minimap.test.js | 225 +++++++++++++++++++++++++++++++ 3 files changed, 449 insertions(+), 151 deletions(-) diff --git a/src/minimap/Minimap.js b/src/minimap/Minimap.js index 355892d4..7cbc199f 100644 --- a/src/minimap/Minimap.js +++ b/src/minimap/Minimap.js @@ -8,25 +8,60 @@ import { const DEFAULT_OPTIONS = Object.freeze({ width: 180, height: 120, - padding: 8, opacity: 0.92, + position: 'bottom-right', + positionOffset: 16, style: { - background: '#ffffff', - border: '#d4d4d8', - object: '#94a3b8', - viewport: '#0c73bf', + canvasFill: '#ffffff', + canvasStroke: '#d4d4d8', + objectFill: '#94a3b8', viewportFill: 'rgba(12, 115, 191, 0.08)', + viewportStroke: '#0c73bf', viewportStrokeWidth: 2, }, }); +const DEFAULT_RECT_FILL = '#cbd5e1'; +const MINIMAP_CANVAS_STROKE_WIDTH = 1; +const MINIMAP_POSITIONS = new Set([ + 'top-left', + 'top-right', + 'bottom-left', + 'bottom-right', +]); +const POSITION_STYLE_KEYS = Object.freeze([ + 'position', + 'top', + 'right', + 'bottom', + 'left', +]); +const PATCHMAP_EVENTS = Object.freeze([ + ['patchmap:draw', '_requestObjectRender'], + ['patchmap:updated', '_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} [background] - * @property {string | number} [border] - * @property {string | number} [object] - * @property {string | number} [viewport] + * @property {string | number} [canvasFill] + * @property {string | number} [canvasStroke] + * @property {string | number} [objectFill] * @property {string} [viewportFill] + * @property {string | number} [viewportStroke] * @property {number} [viewportStrokeWidth] */ @@ -34,8 +69,9 @@ const DEFAULT_OPTIONS = Object.freeze({ * @typedef {object} MinimapOptions * @property {number} [width] * @property {number} [height] - * @property {number} [padding] * @property {number} [opacity] + * @property {'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'} [position] + * @property {number} [positionOffset] * @property {MinimapStyle} [style] */ @@ -67,6 +103,7 @@ export default class Minimap { this._objectSnapshot = null; this._objectLayerKey = null; this._objectsDirty = true; + this._containerStyleSnapshot = null; this._frame = null; this._destroyed = false; this._isPointerActive = false; @@ -87,6 +124,7 @@ export default class Minimap { } mount() { + this.applyContainerPosition(); this.canvas.width = this.options.width; this.canvas.height = this.options.height; this.canvas.style.width = `${this.options.width}px`; @@ -98,42 +136,43 @@ export default class Minimap { this.container.appendChild(this.canvas); } + applyContainerPosition() { + if (!this.container?.style) return; + this._containerStyleSnapshot ??= snapshotContainerStyle(this.container); + + const [vertical, horizontal] = this.options.position.split('-'); + const offset = `${this.options.positionOffset}px`; + Object.assign(this.container.style, { + position: 'fixed', + top: vertical === 'top' ? offset : '', + right: horizontal === 'right' ? offset : '', + bottom: vertical === 'bottom' ? offset : '', + left: horizontal === 'left' ? offset : '', + }); + } + attach() { - this.patchmap.on('patchmap:draw', this._requestObjectRender); - this.patchmap.on('patchmap:updated', this._requestObjectRender); - this.patchmap.on('patchmap:rotated', this._requestObjectRender); - this.patchmap.on('patchmap:flipped', this._requestObjectRender); - this.patchmap.viewport?.on?.('moved', this._requestRender); - this.patchmap.viewport?.on?.('zoomed', this._requestRender); - this.patchmap.viewport?.on?.('world_transformed', this._requestRender); - this.patchmap.viewport?.on?.( - 'object_transformed', - this._requestObjectRender, - ); - this.canvas.addEventListener('pointerdown', this._onPointerDown); - this.canvas.addEventListener('pointermove', this._onPointerMove); - this.canvas.addEventListener('pointerup', this._onPointerUp); - this.canvas.addEventListener('pointercancel', this._onPointerUp); - this.canvas.addEventListener('pointerleave', this._onPointerUp); + for (const [event, handlerKey] of PATCHMAP_EVENTS) { + this.patchmap.on(event, this[handlerKey]); + } + for (const [event, handlerKey] of VIEWPORT_EVENTS) { + this.patchmap.viewport?.on?.(event, this[handlerKey]); + } + for (const [event, handlerKey] of POINTER_EVENTS) { + this.canvas.addEventListener(event, this[handlerKey]); + } } detach() { - this.patchmap?.off?.('patchmap:draw', this._requestObjectRender); - this.patchmap?.off?.('patchmap:updated', this._requestObjectRender); - this.patchmap?.off?.('patchmap:rotated', this._requestObjectRender); - this.patchmap?.off?.('patchmap:flipped', this._requestObjectRender); - this.patchmap?.viewport?.off?.('moved', this._requestRender); - this.patchmap?.viewport?.off?.('zoomed', this._requestRender); - this.patchmap?.viewport?.off?.('world_transformed', this._requestRender); - this.patchmap?.viewport?.off?.( - 'object_transformed', - this._requestObjectRender, - ); - this.canvas?.removeEventListener('pointerdown', this._onPointerDown); - this.canvas?.removeEventListener('pointermove', this._onPointerMove); - this.canvas?.removeEventListener('pointerup', this._onPointerUp); - this.canvas?.removeEventListener('pointercancel', this._onPointerUp); - this.canvas?.removeEventListener('pointerleave', this._onPointerUp); + for (const [event, handlerKey] of PATCHMAP_EVENTS) { + this.patchmap?.off?.(event, this[handlerKey]); + } + for (const [event, handlerKey] of VIEWPORT_EVENTS) { + this.patchmap?.viewport?.off?.(event, this[handlerKey]); + } + for (const [event, handlerKey] of POINTER_EVENTS) { + this.canvas?.removeEventListener(event, this[handlerKey]); + } } invalidateObjects() { @@ -225,7 +264,7 @@ export default class Minimap { patchmap: this.patchmap, width, height, - padding: this.options.padding, + inset: getMinimapContentInset(this.options.style), }); if (!snapshot) { this._objectSnapshot = null; @@ -238,8 +277,6 @@ export default class Minimap { this.drawObjectLayer({ snapshot, ratio, - width, - height, pixelWidth, pixelHeight, }); @@ -252,15 +289,16 @@ export default class Minimap { ratio, pixelWidth, pixelHeight, - background: style.background, - border: style.border, - object: style.object, + canvasFill: style.canvasFill, + canvasStroke: style.canvasStroke, + objectFill: style.objectFill, }); } - drawObjectLayer({ snapshot, ratio, width, height, pixelWidth, pixelHeight }) { + drawObjectLayer({ snapshot, ratio, pixelWidth, pixelHeight }) { const ctx = this._objectLayerContext; if (!ctx) return; + const { width, height } = this.options; if ( this._objectLayer.width !== pixelWidth || @@ -281,8 +319,8 @@ export default class Minimap { const style = this.options.style; const canvas = snapshot.canvas; - ctx.fillStyle = style.background; - ctx.strokeStyle = style.border; + ctx.fillStyle = style.canvasFill; + ctx.strokeStyle = style.canvasStroke; ctx.lineWidth = 1; ctx.fillRect(canvas.x, canvas.y, canvas.width, canvas.height); ctx.strokeRect(canvas.x, canvas.y, canvas.width, canvas.height); @@ -291,7 +329,6 @@ export default class Minimap { ctx.beginPath(); ctx.rect(canvas.x, canvas.y, canvas.width, canvas.height); ctx.clip(); - ctx.fillStyle = style.object; for (const object of snapshot.objects) { this.drawSilhouette(ctx, object); } @@ -308,18 +345,15 @@ export default class Minimap { ctx.restore(); } - drawPolygon(ctx, points) { - if (!points?.length) return; - - ctx.beginPath(); - this.drawPath(ctx, points); - ctx.fill(); - } - 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); @@ -341,13 +375,9 @@ export default class Minimap { if (!points.length) return; ctx.beginPath(); - ctx.moveTo(points[0].x, points[0].y); - for (const point of points.slice(1)) { - ctx.lineTo(point.x, point.y); - } - ctx.closePath(); + this.drawPath(ctx, points); ctx.fillStyle = style.viewportFill; - ctx.strokeStyle = style.viewport; + ctx.strokeStyle = style.viewportStroke; ctx.lineWidth = style.viewportStrokeWidth; ctx.fill(); ctx.stroke(); @@ -408,6 +438,7 @@ export default class Minimap { } this.detach(); this.canvas?.remove(); + restoreContainerStyle(this.container, this._containerStyleSnapshot); this.onDestroy?.(this); this.patchmap = null; this.container = null; @@ -417,6 +448,7 @@ export default class Minimap { this._objectLayerContext = null; this._snapshot = null; this._objectSnapshot = null; + this._containerStyleSnapshot = null; } } @@ -429,15 +461,39 @@ const mergeOptions = (options) => { ...(options.style ?? {}), }, }; + const style = { + canvasFill: merged.style.canvasFill, + canvasStroke: merged.style.canvasStroke, + objectFill: merged.style.objectFill, + viewportFill: merged.style.viewportFill, + viewportStroke: merged.style.viewportStroke, + viewportStrokeWidth: normalizeNonNegativeNumber( + merged.style.viewportStrokeWidth, + 'minimap.style.viewportStrokeWidth', + ), + }; return { - ...merged, width: normalizePositiveNumber(merged.width, 'minimap.width'), height: normalizePositiveNumber(merged.height, 'minimap.height'), - padding: normalizeNonNegativeNumber(merged.padding, 'minimap.padding'), opacity: normalizeOpacity(merged.opacity), + position: normalizePosition(merged.position), + positionOffset: normalizeNonNegativeNumber( + merged.positionOffset, + 'minimap.positionOffset', + ), + style, }; }; +const normalizePosition = (value) => { + if (!MINIMAP_POSITIONS.has(value)) { + throw new TypeError( + 'minimap.position must be one of top-left, top-right, bottom-left, bottom-right.', + ); + } + return value; +}; + const normalizePositiveNumber = (value, name) => { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { throw new TypeError(`${name} must be a positive finite number.`); @@ -463,3 +519,18 @@ 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(MINIMAP_CANVAS_STROKE_WIDTH, style.viewportStrokeWidth / 2); + +const snapshotContainerStyle = (container) => + Object.fromEntries( + POSITION_STYLE_KEYS.map((key) => [key, container.style[key]]), + ); + +const restoreContainerStyle = (container, snapshot) => { + if (!container?.style || !snapshot) return; + for (const key of POSITION_STYLE_KEYS) { + container.style[key] = snapshot[key] ?? ''; + } +}; diff --git a/src/minimap/model.js b/src/minimap/model.js index 76b2e6d8..feede045 100644 --- a/src/minimap/model.js +++ b/src/minimap/model.js @@ -1,43 +1,23 @@ import { Point } from 'pixi.js'; import { getObjectFrameWorldCorners } from '../utils/transform'; -const DEFAULT_ELIGIBLE_TYPES = new Set(['item', 'grid']); - -export const createMinimapSnapshot = ({ patchmap, width, height, padding }) => { - const objectSnapshot = createMinimapObjectSnapshot({ - patchmap, - width, - height, - padding, - }); - if (!objectSnapshot) return null; - - return { - ...objectSnapshot, - viewport: createMinimapViewport({ - patchmap, - canvasBounds: objectSnapshot.canvasBounds, - scale: objectSnapshot.scale, - origin: objectSnapshot.origin, - }), - }; -}; +const DEFAULT_ELIGIBLE_TYPES = new Set(['item', 'grid', 'rect']); export const createMinimapObjectSnapshot = ({ patchmap, width, height, - padding, + inset = 0, }) => { const canvasBounds = patchmap?.canvas?.bounds; if (!canvasBounds) { return null; } - const scale = getMinimapScale({ canvasBounds, width, height, padding }); + const scale = getMinimapScale({ canvasBounds, width, height, inset }); const origin = { - x: padding + (width - padding * 2 - canvasBounds.width * scale) / 2, - y: padding + (height - padding * 2 - canvasBounds.height * scale) / 2, + x: inset + (width - inset * 2 - canvasBounds.width * scale) / 2, + y: inset + (height - inset * 2 - canvasBounds.height * scale) / 2, }; return { @@ -71,9 +51,9 @@ export const minimapPointToCanvasPoint = ({ y: canvasBounds.y + (point.y - origin.y) / scale, }); -const getMinimapScale = ({ canvasBounds, width, height, padding }) => { - const availableWidth = Math.max(width - padding * 2, 1); - const availableHeight = Math.max(height - padding * 2, 1); +const getMinimapScale = ({ canvasBounds, width, height, inset }) => { + const availableWidth = Math.max(width - inset * 2, 1); + const availableHeight = Math.max(height - inset * 2, 1); return Math.min( availableWidth / canvasBounds.width, availableHeight / canvasBounds.height, @@ -89,42 +69,43 @@ const collectObjectSilhouettes = ({ const world = patchmap?.world; if (!world) return []; - return collectManagedElements(world) - .filter(isMinimapEligibleElement) - .flatMap((element) => - getElementCanvasSilhouettes(element, world) - .filter((silhouette) => - silhouetteIntersectsCanvasBounds(silhouette, canvasBounds), - ) - .map((silhouette) => { - const paths = silhouette.paths.map((path) => - path.map((point) => - projectPoint(point, canvasBounds, scale, origin), - ), - ); - return { - points: paths[0] ?? [], - paths, - }; - }), - ); + 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 = (paths) => ({ +const createSilhouette = (element, paths) => ({ + type: element?.type, points: paths[0] ?? [], paths, }); -const getElementCanvasSilhouettes = (element, world) => { +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(paths)] : []; + return paths.length ? createSilhouette(element, paths) : null; } - return [ - createSilhouette([ - getObjectFrameWorldCorners(element).map((point) => world.toLocal(point)), - ]), - ]; + return createSilhouette(element, [ + getObjectFrameWorldCorners(element).map((point) => world.toLocal(point)), + ]); }; const getGridCellCanvasPaths = (grid, world) => { @@ -135,7 +116,10 @@ const getGridCellCanvasPaths = (grid, world) => { if (!size) return []; const rows = cells.length; - const cols = Math.max(0, ...cells.map((row) => row?.length ?? 0)); + let cols = 0; + for (const row of cells) { + cols = Math.max(cols, row?.length ?? 0); + } if (!rows || !cols) return []; const gap = normalizeGap(grid.props?.gap); @@ -182,32 +166,34 @@ const createGridAxisEdges = (count, size, gap) => { const traceActiveCellBoundaryLoops = ({ rows, cols, 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 (let row = 0; row < rows; row += 1) { for (let col = 0; col < cols; col += 1) { if (!active(row, col)) continue; if (!active(row - 1, col)) { - edges.push(createBoundaryEdge(col, row, col + 1, row)); + pushEdge(col, row, col + 1, row); } if (!active(row, col + 1)) { - edges.push(createBoundaryEdge(col + 1, row, col + 1, row + 1)); + pushEdge(col + 1, row, col + 1, row + 1); } if (!active(row + 1, col)) { - edges.push(createBoundaryEdge(col + 1, row + 1, col, row + 1)); + pushEdge(col + 1, row + 1, col, row + 1); } if (!active(row, col - 1)) { - edges.push(createBoundaryEdge(col, row + 1, col, row)); + pushEdge(col, row + 1, col, row); } } } - const edgesByStart = new Map(); - for (const edge of edges) { - const key = pointKey(edge.start); - const list = edgesByStart.get(key) ?? []; - list.push(edge); - edgesByStart.set(key, list); - } - const loops = []; for (const edge of edges) { if (edge.used) continue; @@ -244,22 +230,26 @@ const getEdgeDirection = (startX, startY, endX, endY) => { }; const takeNextBoundaryEdge = (edgesByStart, previousEdge) => { - const candidates = edgesByStart - .get(pointKey(previousEdge.end)) - ?.filter((edge) => !edge.used); + const candidates = edgesByStart.get(pointKey(previousEdge.end)); if (!candidates?.length) return null; - const preferredDirections = [ - (previousEdge.direction + 1) % 4, - previousEdge.direction, - (previousEdge.direction + 3) % 4, - (previousEdge.direction + 2) % 4, - ]; - return candidates.sort( - (a, b) => - preferredDirections.indexOf(a.direction) - - preferredDirections.indexOf(b.direction), - )[0]; + 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}`; @@ -287,7 +277,7 @@ const collectManagedElements = (root) => { result.push(node); if (node.type === 'grid') return; } - for (const child of node.children ?? []) { + for (const child of getRenderOrderedChildren(node)) { visit(child); } }; @@ -295,6 +285,18 @@ const collectManagedElements = (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 && diff --git a/src/tests/render/minimap.test.js b/src/tests/render/minimap.test.js index 2c010f09..47219464 100644 --- a/src/tests/render/minimap.test.js +++ b/src/tests/render/minimap.test.js @@ -84,6 +84,131 @@ describe('minimap', () => { ); }); + 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 style fill and stroke 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: { + canvasFill: '#111111', + canvasStroke: '#222222', + objectFill: '#333333', + viewportFill: 'rgba(1, 2, 3, 0.2)', + viewportStroke: '#444444', + viewportStrokeWidth: 3, + }, + }); + + expect(minimap.options.style).toEqual({ + canvasFill: '#111111', + canvasStroke: '#222222', + objectFill: '#333333', + viewportFill: 'rgba(1, 2, 3, 0.2)', + viewportStroke: '#444444', + viewportStrokeWidth: 3, + }); + }); + + it('anchors the minimap to the bottom-right corner by default', 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(minimapHost.style.position).toBe('fixed'); + expect(minimapHost.style.right).toBe('16px'); + expect(minimapHost.style.bottom).toBe('16px'); + expect(minimapHost.style.top).toBe(''); + expect(minimapHost.style.left).toBe(''); + + minimap.destroy(); + + expect(minimapHost.style.position).toBe(''); + expect(minimapHost.style.right).toBe(''); + expect(minimapHost.style.bottom).toBe(''); + }); + + it.each([ + ['top-left', { top: '12px', left: '12px', right: '', bottom: '' }], + ['top-right', { top: '12px', right: '12px', left: '', bottom: '' }], + ['bottom-left', { bottom: '12px', left: '12px', top: '', right: '' }], + ['bottom-right', { bottom: '12px', right: '12px', top: '', left: '' }], + ])('anchors the minimap to %s', async (position, expected) => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + + patchmap.createMinimap(minimapHost, { + position, + positionOffset: 12, + }); + + expect(minimapHost.style.position).toBe('fixed'); + for (const [key, value] of Object.entries(expected)) { + expect(minimapHost.style[key]).toBe(value); + } + }); + + it('rejects invalid minimap positions', async () => { + element = createHost(); + minimapHost = createMinimapHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + + expect(() => + patchmap.createMinimap(minimapHost, { position: 'center' }), + ).toThrow( + 'minimap.position must be one of top-left, top-right, bottom-left, bottom-right.', + ); + }); + it('moves the viewport from minimap pointer navigation', async () => { element = createHost(); minimapHost = createMinimapHost(); @@ -211,6 +336,106 @@ describe('minimap', () => { 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(); From c15d60b1f1cfa9507d866ad712dfcdb46666bf8a Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 12 May 2026 16:37:51 +0900 Subject: [PATCH 09/18] docs: document finite canvas minimap options --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 23cfbafb..208a8bbc 100644 --- a/README.md +++ b/README.md @@ -182,11 +182,23 @@ Customize the rendering behavior using the following options: const minimap = patchmap.createMinimap(document.querySelector('#minimap'), { width: 180, height: 120, + position: 'bottom-right', + style: { + canvasFill: '#ffffff', + canvasStroke: '#d4d4d8', + objectFill: '#94a3b8', + viewportFill: 'rgba(12, 115, 191, 0.08)', + viewportStroke: '#0c73bf', + viewportStrokeWidth: 2, + }, }); minimap.destroy(); ``` + Minimap `position` accepts `top-left`, `top-right`, `bottom-left`, or + `bottom-right`. The default is `bottom-right`. + - `theme` - Theme options Default: ```js From a071511aab4ff73e423f186fd7ced0dccd5dbf81 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 12 May 2026 17:15:15 +0900 Subject: [PATCH 10/18] feat: default minimap to init host --- README.md | 6 +++++- src/patchmap.js | 30 ++++++++++++++++++++++++++---- src/tests/render/minimap.test.js | 24 ++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 208a8bbc..2e5e8e0b 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ Customize the rendering behavior using the following options: Finite canvas mode also enables minimap creation: ```js - const minimap = patchmap.createMinimap(document.querySelector('#minimap'), { + const minimap = patchmap.createMinimap({ width: 180, height: 120, position: 'bottom-right', @@ -196,6 +196,10 @@ Customize the rendering behavior using the following options: minimap.destroy(); ``` + By default, `createMinimap(options)` creates its own minimap container inside + the element passed to `init(el)`. You can still provide a custom container with + `createMinimap(container, options)`. + Minimap `position` accepts `top-left`, `top-right`, `bottom-left`, or `bottom-right`. The default is `bottom-right`. diff --git a/src/patchmap.js b/src/patchmap.js index c8803c2c..4277d38f 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -63,6 +63,7 @@ class Patchmap extends WildcardEventEmitter { _transformer = null; _stateManager = null; _world = null; + _element = null; _viewTransform = this._createViewTransform(); /** @type {import('./canvas-bounds/options').CanvasBounds | null} */ _canvasBounds = null; @@ -174,6 +175,7 @@ class Patchmap extends WildcardEventEmitter { } = opts; const canvasBounds = normalizeCanvasBounds(canvasOptions?.bounds); + this._element = element; this.undoRedoManager._setHotkeys(); this._theme.set(themeOptions); this._canvasBounds = canvasBounds; @@ -245,6 +247,7 @@ class Patchmap extends WildcardEventEmitter { this._transformer = null; this._stateManager = null; this._world = null; + this._element = null; this._viewTransform = this._createViewTransform(); this._canvasBounds = null; this._canvasBoundsController = null; @@ -352,16 +355,24 @@ class Patchmap extends WildcardEventEmitter { } /** - * @param {HTMLElement} container + * @param {HTMLElement | import('./minimap/Minimap').MinimapOptions} [containerOrOptions] * @param {import('./minimap/Minimap').MinimapOptions} [options] * @returns {Minimap} */ - createMinimap(container, options = {}) { + createMinimap(containerOrOptions = {}, options = {}) { + const hasContainer = isHTMLElement(containerOrOptions); + const container = hasContainer + ? containerOrOptions + : createDefaultMinimapContainer(this._element); + const minimapOptions = hasContainer ? options : containerOrOptions; const minimap = new Minimap({ patchmap: this, container, - options, - onDestroy: (target) => this._minimaps.delete(target), + options: minimapOptions, + onDestroy: (target) => { + this._minimaps.delete(target); + if (!hasContainer) container.remove(); + }, }); this._minimaps.add(minimap); return minimap; @@ -410,3 +421,14 @@ function scheduleUserVisibleTask(task) { } export { Patchmap }; + +const isHTMLElement = (value) => + typeof HTMLElement !== 'undefined' && value instanceof HTMLElement; + +const createDefaultMinimapContainer = (root) => { + if (!root?.appendChild) return null; + const container = document.createElement('div'); + container.dataset.patchmapMinimap = 'true'; + root.appendChild(container); + return container; +}; diff --git a/src/tests/render/minimap.test.js b/src/tests/render/minimap.test.js index 47219464..4131d876 100644 --- a/src/tests/render/minimap.test.js +++ b/src/tests/render/minimap.test.js @@ -68,6 +68,30 @@ describe('minimap', () => { expect(minimapHost.querySelector('canvas')).toBeNull(); }); + it('creates a default minimap container inside the init host', async () => { + element = createHost(); + patchmap = new Patchmap(); + + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + + const minimap = patchmap.createMinimap({ + width: 180, + height: 120, + }); + const generatedHost = element.querySelector('[data-patchmap-minimap]'); + + expect(generatedHost).toBe(minimap.container); + expect(generatedHost.querySelector('canvas')).toBe(minimap.canvas); + + minimap.destroy(); + + expect(element.querySelector('[data-patchmap-minimap]')).toBeNull(); + }); + it('rejects invalid minimap dimensions', async () => { element = createHost(); minimapHost = createMinimapHost(); From c55492f081343e82b196b3bc815c42c68efbbec0 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 12 May 2026 17:24:39 +0900 Subject: [PATCH 11/18] fix: anchor auto minimap inside init host --- src/minimap/Minimap.js | 8 ++++- src/patchmap.js | 53 +++++++++++++++++++++++++++++--- src/tests/render/minimap.test.js | 8 +++++ 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/minimap/Minimap.js b/src/minimap/Minimap.js index 7cbc199f..f90b1ccf 100644 --- a/src/minimap/Minimap.js +++ b/src/minimap/Minimap.js @@ -34,6 +34,7 @@ const POSITION_STYLE_KEYS = Object.freeze([ 'right', 'bottom', 'left', + 'zIndex', ]); const PATCHMAP_EVENTS = Object.freeze([ ['patchmap:draw', '_requestObjectRender'], @@ -142,12 +143,17 @@ export default class Minimap { const [vertical, horizontal] = this.options.position.split('-'); const offset = `${this.options.positionOffset}px`; + const containerPosition = + this.container.dataset.patchmapMinimapAuto === 'true' + ? 'absolute' + : 'fixed'; Object.assign(this.container.style, { - position: 'fixed', + position: containerPosition, top: vertical === 'top' ? offset : '', right: horizontal === 'right' ? offset : '', bottom: vertical === 'bottom' ? offset : '', left: horizontal === 'left' ? offset : '', + zIndex: '1', }); } diff --git a/src/patchmap.js b/src/patchmap.js index 4277d38f..b59e7ce4 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -361,9 +361,12 @@ class Patchmap extends WildcardEventEmitter { */ createMinimap(containerOrOptions = {}, options = {}) { const hasContainer = isHTMLElement(containerOrOptions); + const defaultContainer = hasContainer + ? null + : createDefaultMinimapContainer(this._element); const container = hasContainer ? containerOrOptions - : createDefaultMinimapContainer(this._element); + : defaultContainer.container; const minimapOptions = hasContainer ? options : containerOrOptions; const minimap = new Minimap({ patchmap: this, @@ -371,7 +374,10 @@ class Patchmap extends WildcardEventEmitter { options: minimapOptions, onDestroy: (target) => { this._minimaps.delete(target); - if (!hasContainer) container.remove(); + if (!hasContainer) { + container.remove(); + defaultContainer.restore(); + } }, }); this._minimaps.add(minimap); @@ -422,13 +428,52 @@ function scheduleUserVisibleTask(task) { export { Patchmap }; +const DEFAULT_MINIMAP_ROOTS = new WeakMap(); + const isHTMLElement = (value) => typeof HTMLElement !== 'undefined' && value instanceof HTMLElement; const createDefaultMinimapContainer = (root) => { - if (!root?.appendChild) return null; + if (!root?.appendChild) { + return { container: null, restore: () => {} }; + } + const restoreRootPosition = retainPositionedRoot(root); const container = document.createElement('div'); container.dataset.patchmapMinimap = 'true'; + container.dataset.patchmapMinimapAuto = 'true'; root.appendChild(container); - return container; + return { + container, + restore: restoreRootPosition, + }; +}; + +const retainPositionedRoot = (root) => { + const existing = DEFAULT_MINIMAP_ROOTS.get(root); + if (existing) { + existing.count += 1; + return () => releasePositionedRoot(root); + } + + const shouldSetPosition = getComputedStyle(root).position === 'static'; + DEFAULT_MINIMAP_ROOTS.set(root, { + count: 1, + previousPosition: root.style.position, + shouldSetPosition, + }); + if (shouldSetPosition) { + root.style.position = 'relative'; + } + return () => releasePositionedRoot(root); +}; + +const releasePositionedRoot = (root) => { + const entry = DEFAULT_MINIMAP_ROOTS.get(root); + if (!entry) return; + entry.count -= 1; + if (entry.count > 0) return; + if (entry.shouldSetPosition) { + root.style.position = entry.previousPosition; + } + DEFAULT_MINIMAP_ROOTS.delete(root); }; diff --git a/src/tests/render/minimap.test.js b/src/tests/render/minimap.test.js index 4131d876..613a7d8d 100644 --- a/src/tests/render/minimap.test.js +++ b/src/tests/render/minimap.test.js @@ -86,10 +86,16 @@ describe('minimap', () => { expect(generatedHost).toBe(minimap.container); expect(generatedHost.querySelector('canvas')).toBe(minimap.canvas); + expect(element.style.position).toBe('relative'); + expect(generatedHost.style.position).toBe('absolute'); + expect(generatedHost.style.right).toBe('16px'); + expect(generatedHost.style.bottom).toBe('16px'); + expect(generatedHost.style.zIndex).toBe('1'); minimap.destroy(); expect(element.querySelector('[data-patchmap-minimap]')).toBeNull(); + expect(element.style.position).toBe(''); }); it('rejects invalid minimap dimensions', async () => { @@ -178,6 +184,7 @@ describe('minimap', () => { expect(minimapHost.style.position).toBe('fixed'); expect(minimapHost.style.right).toBe('16px'); expect(minimapHost.style.bottom).toBe('16px'); + expect(minimapHost.style.zIndex).toBe('1'); expect(minimapHost.style.top).toBe(''); expect(minimapHost.style.left).toBe(''); @@ -186,6 +193,7 @@ describe('minimap', () => { expect(minimapHost.style.position).toBe(''); expect(minimapHost.style.right).toBe(''); expect(minimapHost.style.bottom).toBe(''); + expect(minimapHost.style.zIndex).toBe(''); }); it.each([ From 21b85a72e82971b6255cc7730211c5b690afd756 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 13 May 2026 14:47:30 +0900 Subject: [PATCH 12/18] fix: stabilize finite canvas bounds lifecycle --- src/canvas-bounds/clamp.js | 53 +++++-- src/canvas-bounds/controller.js | 53 ++++++- src/canvas-bounds/options.test.js | 64 +++++++- src/canvas-bounds/restrict.js | 20 ++- src/canvas-bounds/restrict.test.js | 31 +++- src/patchmap.js | 91 ++++++++++- src/tests/render/canvas-bounds.test.js | 200 +++++++++++++++++++++++-- src/tests/render/patchmap.test.js | 49 +++++- 8 files changed, 519 insertions(+), 42 deletions(-) diff --git a/src/canvas-bounds/clamp.js b/src/canvas-bounds/clamp.js index eae50ba3..4939c89d 100644 --- a/src/canvas-bounds/clamp.js +++ b/src/canvas-bounds/clamp.js @@ -4,11 +4,19 @@ import { getFrameCorrection } from './restrict'; const CLAMP_EPSILON = 0.0001; -export const clampViewportToCanvasBounds = (viewport, bounds, world) => { +export const clampViewportToCanvasBounds = ( + viewport, + bounds, + world, + { centerUnderflow = false, preserveUnderflow = false } = {}, +) => { if (!viewport || !bounds) return; if (world) { - clampViewportToTransformedCanvasBounds(viewport, bounds, world); + clampViewportToTransformedCanvasBounds(viewport, bounds, world, { + centerUnderflow, + preserveUnderflow, + }); return; } @@ -20,9 +28,11 @@ export const clampViewportToCanvasBounds = (viewport, bounds, world) => { screenSize: viewport.screenWidth, worldScreenSize: viewport.worldScreenWidth, scale: viewport.scale?.x, - positionKey: 'x', minEdgeKey: 'left', maxEdgeKey: 'right', + positionKey: 'x', + centerUnderflow, + preserveUnderflow, }); clampViewportAxis({ viewport, @@ -32,9 +42,11 @@ export const clampViewportToCanvasBounds = (viewport, bounds, world) => { screenSize: viewport.screenHeight, worldScreenSize: viewport.worldScreenHeight, scale: viewport.scale?.y, - positionKey: 'y', minEdgeKey: 'top', maxEdgeKey: 'bottom', + positionKey: 'y', + centerUnderflow, + preserveUnderflow, }); }; @@ -46,9 +58,11 @@ const clampViewportAxis = ({ screenSize, worldScreenSize, scale, - positionKey, minEdgeKey, maxEdgeKey, + positionKey, + centerUnderflow, + preserveUnderflow, }) => { const safeScale = Math.abs(scale || 1); const visibleSize = Number.isFinite(worldScreenSize) @@ -56,22 +70,35 @@ const clampViewportAxis = ({ : screenSize / safeScale; if (visibleSize >= size) { - const center = min + size / 2; - viewport[positionKey] = -center * safeScale + screenSize / 2; - return; - } - - if (viewport[minEdgeKey] < min) { + 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) => { +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); + const correction = getFrameCorrection(frame, bounds, { + centerUnderflow, + preserveUnderflow, + }); if ( Math.abs(correction.x) <= CLAMP_EPSILON && Math.abs(correction.y) <= CLAMP_EPSILON diff --git a/src/canvas-bounds/controller.js b/src/canvas-bounds/controller.js index d2eb1ead..8f3df345 100644 --- a/src/canvas-bounds/controller.js +++ b/src/canvas-bounds/controller.js @@ -6,11 +6,17 @@ export default class CanvasBoundsController { this.viewport = viewport; this.world = world; this.bounds = bounds; - this._applyViewportClamp = () => { - this.applyViewportClamp(); + this._applyViewportClamp = (event) => { + this.applyViewportClamp({ + preserveUnderflow: event?.type === 'wheel', + }); + }; + this._requestViewportClamp = () => { + this.requestViewportClamp(); }; this._isApplyingClamp = false; this._baseClampZoomMinScale = null; + this._clampFrame = null; this.attach(); } @@ -21,7 +27,7 @@ export default class CanvasBoundsController { this.configureViewport(); this.viewport.on?.('world_transformed', this._applyViewportClamp); this.viewport.on?.('moved', this._applyViewportClamp); - this.viewport.on?.('zoomed', this._applyViewportClamp); + this.viewport.on?.('zoomed', this._requestViewportClamp); } configureViewport() { @@ -41,7 +47,9 @@ export default class CanvasBoundsController { this.bounds.height, ); this.viewport.plugins?.remove?.('clamp'); - this.applyViewportClamp(); + this.applyViewportClamp({ + centerUnderflow: true, + }); } configureMinimumScale() { @@ -72,20 +80,51 @@ export default class CanvasBoundsController { this.configureViewport(); } - applyViewportClamp() { + 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); + 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._applyViewportClamp); + this.viewport?.off?.('zoomed', this._requestViewportClamp); + this.cancelPendingClamp(); this.viewport = null; this.world = null; this.bounds = null; diff --git a/src/canvas-bounds/options.test.js b/src/canvas-bounds/options.test.js index ea48150d..51a5c944 100644 --- a/src/canvas-bounds/options.test.js +++ b/src/canvas-bounds/options.test.js @@ -92,13 +92,17 @@ describe('canvas bounds options', () => { expect(viewport.top).toBe(30); }); - it('centers underflowing viewport axes on non-zero canvas bounds', () => { + 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, }; @@ -111,7 +115,65 @@ describe('canvas bounds options', () => { 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 index facd3d19..89c7585e 100644 --- a/src/canvas-bounds/restrict.js +++ b/src/canvas-bounds/restrict.js @@ -38,7 +38,11 @@ export const isViewportFrameInsideCanvasBounds = ({ }); }; -export const getFrameCorrection = (frame, canvasBounds) => { +export const getFrameCorrection = ( + frame, + canvasBounds, + { centerUnderflow = false, preserveUnderflow = false } = {}, +) => { if (!frame || !canvasBounds) return { x: 0, y: 0 }; return { x: getAxisCorrection({ @@ -48,6 +52,8 @@ export const getFrameCorrection = (frame, canvasBounds) => { boundMin: canvasBounds.x, boundMax: canvasBounds.right, boundSize: canvasBounds.width, + centerUnderflow, + preserveUnderflow, }), y: getAxisCorrection({ min: frame.y, @@ -56,6 +62,8 @@ export const getFrameCorrection = (frame, canvasBounds) => { boundMin: canvasBounds.y, boundMax: canvasBounds.bottom, boundSize: canvasBounds.height, + centerUnderflow, + preserveUnderflow, }), }; }; @@ -93,9 +101,17 @@ const getAxisCorrection = ({ boundMin, boundMax, boundSize, + centerUnderflow, + preserveUnderflow, }) => { if (size > boundSize) { - return boundMin + boundSize / 2 - (min + size / 2); + 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; diff --git a/src/canvas-bounds/restrict.test.js b/src/canvas-bounds/restrict.test.js index 60da6a30..d296e59f 100644 --- a/src/canvas-bounds/restrict.test.js +++ b/src/canvas-bounds/restrict.test.js @@ -29,12 +29,41 @@ describe('canvas bounds restriction', () => { ).toEqual({ x: -30, y: 30 }); }); - it('centers oversized frames that cannot fully fit inside canvas bounds', () => { + 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/patchmap.js b/src/patchmap.js index b59e7ce4..5c618e87 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -65,6 +65,8 @@ class Patchmap extends WildcardEventEmitter { _world = null; _element = null; _viewTransform = this._createViewTransform(); + _initPromise = null; + _destroyAfterInit = false; /** @type {import('./canvas-bounds/options').CanvasBounds | null} */ _canvasBounds = null; /** @type {import('./canvas-bounds/controller').default | null} */ @@ -163,17 +165,30 @@ class Patchmap extends WildcardEventEmitter { * @param {PatchmapInitOptions} [opts] */ async init(element, opts = {}) { - if (this.isInit) return; + if (this.isInit) return this._initPromise; + if (this._initPromise) return this._initPromise; + this._destroyAfterInit = false; + this._initPromise = this._init(element, opts).catch((error) => { + this._initPromise = null; + throw error; + }); + return this._initPromise; + } + + async _init(element, opts = {}) { const { app: appOptions = {}, viewport: viewportOptions = {}, theme: themeOptions = {}, assets: assetsOptions = [], - canvas: canvasOptions = {}, + canvas: canvasOptions, transformer, } = opts; - const canvasBounds = normalizeCanvasBounds(canvasOptions?.bounds); + const canvasBounds = + canvasOptions && Object.hasOwn(canvasOptions, 'bounds') + ? normalizeCanvasBounds(canvasOptions.bounds) + : this._canvasBounds; this._element = element; this.undoRedoManager._setHotkeys(); @@ -215,11 +230,26 @@ class Patchmap extends WildcardEventEmitter { this.transformer = transformer; } this.isInit = true; + this._initPromise = null; + if (this._destroyAfterInit) { + this._destroyAfterInit = false; + this.destroy(); + return; + } this.emit('patchmap:initialized', { target: this }); } destroy() { - if (!this.isInit) return; + if (!this.isInit) { + if (this._initPromise) { + this._destroyAfterInit = true; + for (const minimap of this._minimaps) { + minimap.destroy(); + } + this._minimaps.clear(); + } + return; + } for (const minimap of this._minimaps) { minimap.destroy(); @@ -227,14 +257,14 @@ class Patchmap extends WildcardEventEmitter { this._minimaps.clear(); this.undoRedoManager.destroy(); this.animationContext.revert(); - this.stateManager.resetState(); - this.stateManager.destroy(); + this.stateManager?.resetState(); + this.stateManager?.destroy(); this._canvasBoundsController?.destroy(); 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; @@ -249,6 +279,8 @@ class Patchmap extends WildcardEventEmitter { this._world = null; this._element = null; this._viewTransform = this._createViewTransform(); + this._initPromise = null; + this._destroyAfterInit = false; this._canvasBounds = null; this._canvasBoundsController = null; this._minimaps = new Set(); @@ -342,6 +374,40 @@ class Patchmap extends WildcardEventEmitter { return result; } + /** + * @param {CanvasBoundsInput | null | undefined} bounds + * @returns {import('./canvas-bounds/options').CanvasBounds | null} + */ + setCanvasBounds(bounds) { + const canvasBounds = normalizeCanvasBounds(bounds); + 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; + } + get rotation() { return this._viewTransform.rotation; } @@ -409,6 +475,17 @@ class Patchmap extends WildcardEventEmitter { 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, diff --git a/src/tests/render/canvas-bounds.test.js b/src/tests/render/canvas-bounds.test.js index 0db6ac93..bf54082a 100644 --- a/src/tests/render/canvas-bounds.test.js +++ b/src/tests/render/canvas-bounds.test.js @@ -1,14 +1,49 @@ import { afterEach, describe, expect, it } from 'vitest'; import { Patchmap } from '../../patchmap'; -const createHost = () => { +const createHost = ({ width = 800, height = 600 } = {}) => { const element = document.createElement('div'); - element.style.width = '800px'; - element.style.height = '600px'; + 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; @@ -58,6 +93,25 @@ describe('canvas bounds', () => { }); }); + 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(); @@ -122,6 +176,53 @@ describe('canvas bounds', () => { 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(); @@ -143,7 +244,84 @@ describe('canvas bounds', () => { expect(frame.y + frame.height).toBeLessThanOrEqual(2400.01); }); - it('centers non-zero finite canvas bounds when the canvas underflows the viewport', async () => { + 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(); @@ -153,14 +331,16 @@ describe('canvas bounds', () => { }, }); + patchmap._canvasBoundsController.applyViewportClamp(); + patchmap.viewport.top = 50; patchmap._canvasBoundsController.applyViewportClamp(); - const center = patchmap.viewport.toWorld( - patchmap.viewport.screenWidth / 2, - patchmap.viewport.screenHeight / 2, - ); - expect(center.x).toBeCloseTo(160, 1); - expect(center.y).toBeCloseTo(90, 1); + 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 () => { 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) => { From dbd48b439a6a8fcbe89665781831ed78b633bd19 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 13 May 2026 14:47:41 +0900 Subject: [PATCH 13/18] fix: keep minimap synced with viewport changes --- src/minimap/Minimap.js | 133 ++++++++++++++++++++++++-- src/tests/render/minimap.test.js | 158 +++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 7 deletions(-) diff --git a/src/minimap/Minimap.js b/src/minimap/Minimap.js index f90b1ccf..25714859 100644 --- a/src/minimap/Minimap.js +++ b/src/minimap/Minimap.js @@ -37,8 +37,10 @@ const POSITION_STYLE_KEYS = Object.freeze([ 'zIndex', ]); 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'], ]); @@ -105,12 +107,17 @@ export default class Minimap { this._objectLayerKey = null; this._objectsDirty = true; this._containerStyleSnapshot = null; + this._attachedViewport = null; + this._attachedTicker = null; 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(); @@ -161,9 +168,7 @@ export default class Minimap { for (const [event, handlerKey] of PATCHMAP_EVENTS) { this.patchmap.on(event, this[handlerKey]); } - for (const [event, handlerKey] of VIEWPORT_EVENTS) { - this.patchmap.viewport?.on?.(event, this[handlerKey]); - } + this.attachViewport(); for (const [event, handlerKey] of POINTER_EVENTS) { this.canvas.addEventListener(event, this[handlerKey]); } @@ -173,14 +178,57 @@ export default class Minimap { for (const [event, handlerKey] of PATCHMAP_EVENTS) { this.patchmap?.off?.(event, this[handlerKey]); } - for (const [event, handlerKey] of VIEWPORT_EVENTS) { - this.patchmap?.viewport?.off?.(event, this[handlerKey]); - } + this.detachViewport(); for (const [event, handlerKey] of POINTER_EVENTS) { this.canvas?.removeEventListener(event, this[handlerKey]); } } + 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; @@ -223,7 +271,12 @@ export default class Minimap { pixelWidth, pixelHeight, }); - if (!objectSnapshot) return; + if (!objectSnapshot) { + this.clearLayers({ pixelWidth, pixelHeight }); + this._snapshot = null; + this._viewportTransformKey = null; + return; + } this._snapshot = { ...objectSnapshot, @@ -246,6 +299,18 @@ export default class Minimap { 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() { @@ -291,10 +356,19 @@ export default class Minimap { 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, canvasFill: style.canvasFill, canvasStroke: style.canvasStroke, objectFill: style.objectFill, @@ -540,3 +614,48 @@ const restoreContainerStyle = (container, snapshot) => { container.style[key] = snapshot[key] ?? ''; } }; + +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/tests/render/minimap.test.js b/src/tests/render/minimap.test.js index 613a7d8d..fe929695 100644 --- a/src/tests/render/minimap.test.js +++ b/src/tests/render/minimap.test.js @@ -15,6 +15,12 @@ const createMinimapHost = () => { 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; @@ -657,6 +663,158 @@ describe('minimap', () => { 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(); From 9690770607d9a3e086306678150e27043800c2d9 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 13 May 2026 14:47:48 +0900 Subject: [PATCH 14/18] perf: optimize minimap grid contours --- src/minimap/model.js | 106 +++++++++++++++++++++++-------------------- 1 file changed, 57 insertions(+), 49 deletions(-) diff --git a/src/minimap/model.js b/src/minimap/model.js index feede045..826996e7 100644 --- a/src/minimap/model.js +++ b/src/minimap/model.js @@ -115,56 +115,65 @@ const getGridCellCanvasPaths = (grid, world) => { const size = normalizeSize(grid.props?.item?.size); if (!size) return []; - const rows = cells.length; - let cols = 0; - for (const row of cells) { - cols = Math.max(cols, row?.length ?? 0); - } - if (!rows || !cols) return []; - const gap = normalizeGap(grid.props?.gap); - const xEdges = createGridAxisEdges(cols, size.width, gap.x); - const yEdges = createGridAxisEdges(rows, size.height, gap.y); - const active = (row, col) => Boolean(cells[row]?.[col]); + 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 (isFullyActiveGrid({ rows, cols, active })) { + if (activeCells.length === rows * cols) { return [ [ - toCanvasPoint(xEdges[0], yEdges[0]), - toCanvasPoint(xEdges[cols], yEdges[0]), - toCanvasPoint(xEdges[cols], yEdges[rows]), - toCanvasPoint(xEdges[0], yEdges[rows]), + toGridCanvasPoint({ x: 0, y: 0 }), + toGridCanvasPoint({ x: cols, y: 0 }), + toGridCanvasPoint({ x: cols, y: rows }), + toGridCanvasPoint({ x: 0, y: rows }), ], ]; } - const loops = traceActiveCellBoundaryLoops({ rows, cols, active }); + const loops = traceActiveCellBoundaryLoops({ activeCells, active }); return loops.map((loop) => - simplifyCollinearPoints( - loop.map((point) => toCanvasPoint(xEdges[point.x], yEdges[point.y])), - ), + 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 isFullyActiveGrid = ({ rows, cols, active }) => { +const analyzeGridCells = (cells) => { + const rows = cells.length; + let cols = 0; + const activeCells = []; + const activeSet = new Set(); + for (let row = 0; row < rows; row += 1) { - for (let col = 0; col < cols; col += 1) { - if (!active(row, col)) return false; + 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 true; + + return { rows, cols, activeCells, activeSet }; }; -const createGridAxisEdges = (count, size, gap) => { +const cellKey = (row, col) => `${row},${col}`; + +const getGridAxisEdge = (index, count, size, gap) => { const step = size + gap; - return Array.from({ length: count + 1 }, (_, index) => { - if (index === count) return Math.max(count * size + (count - 1) * gap, 0); - return index * step; - }); + if (index === count) return Math.max(count * size + (count - 1) * gap, 0); + return index * step; }; -const traceActiveCellBoundaryLoops = ({ rows, cols, active }) => { +const traceActiveCellBoundaryLoops = ({ activeCells, active }) => { const edges = []; const edgesByStart = new Map(); const pushEdge = (startX, startY, endX, endY) => { @@ -176,21 +185,18 @@ const traceActiveCellBoundaryLoops = ({ rows, cols, active }) => { edgesByStart.set(key, list); }; - for (let row = 0; row < rows; row += 1) { - for (let col = 0; col < cols; col += 1) { - if (!active(row, col)) continue; - 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); - } + 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); } } @@ -339,16 +345,18 @@ const normalizeGap = (gap) => { }; const getViewportPolygon = ({ patchmap, canvasBounds, scale, origin }) => { - const app = patchmap?.app; const viewport = patchmap?.viewport; const world = patchmap?.world; - if (!app || !viewport || !world) return []; + 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(app.screen.width, 0), - new Point(app.screen.width, app.screen.height), - new Point(0, app.screen.height), + new Point(screenWidth, 0), + new Point(screenWidth, screenHeight), + new Point(0, screenHeight), ].map((point) => projectPoint( screenPointToCanvasPoint({ world, point }), From 65c138128593b78219674d1bfc9ebbb116fbe286 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 13 May 2026 14:54:56 +0900 Subject: [PATCH 15/18] docs: document finite canvas minimap API --- README.md | 68 ++++++++++++++++++++++++++++++++++++++++++ README_KR.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/README.md b/README.md index 2e5e8e0b..ba8e7cb5 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(containerOrOptions, options)](#createminimapcontaineroroptions-options) - [rotation](#rotation) - [flip](#flip) - [selector(path)](#selectorpath) @@ -203,6 +205,11 @@ Customize the rendering behavior using the following options: Minimap `position` accepts `top-left`, `top-right`, `bottom-left`, or `bottom-right`. The default is `bottom-right`. + 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 @@ -514,6 +521,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 +patchmap.setCanvasBounds({ x: -500, y: -300, width: 5000, height: 3000 }); +patchmap.setCanvasBounds(null); +``` + +When bounds are changed, PATCH MAP emits `patchmap:canvas-bounds-changed`. + +
+ +### `createMinimap(containerOrOptions, options)` +Creates a minimap for the current finite canvas. `canvas.bounds` must be set +before creating a minimap. + +```js +const minimap = patchmap.createMinimap({ + width: 240, + height: 144, + position: 'bottom-right', + positionOffset: 16, + opacity: 0.92, + style: { + canvasFill: '#ffffff', + canvasStroke: '#d4d4d8', + objectFill: '#94a3b8', + viewportFill: 'rgba(12, 115, 191, 0.08)', + viewportStroke: '#0c73bf', + viewportStrokeWidth: 2, + }, +}); +``` + +Calling `createMinimap(options)` creates a minimap container inside the element +passed to `init(el)`. If that root element is statically positioned, PATCH MAP +temporarily sets it to `position: relative` while the auto-created minimap is +mounted. Use `createMinimap(container, options)` when you want to own the +container yourself. + +Supported options: + +- `width` / `height`: minimap canvas size in CSS pixels. Defaults to `180 x 120`. +- `opacity`: minimap canvas opacity. Default is `0.92`. +- `position`: `top-left`, `top-right`, `bottom-left`, or `bottom-right`. + Default is `bottom-right`. +- `positionOffset`: distance from the selected corner. Default is `16`. +- `style.canvasFill`, `style.canvasStroke`: finite canvas fill and stroke. +- `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. @@ -834,6 +901,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 e4ef83fe..d3814bcd 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(containerOrOptions, options)](#createminimapcontaineroroptions-options) - [rotation](#rotation) - [flip](#flip) - [selector(path)](#selectorpath) @@ -190,11 +192,32 @@ await patchmap.init(el, { const minimap = patchmap.createMinimap(document.querySelector('#minimap'), { width: 180, height: 120, + position: 'bottom-right', + style: { + canvasFill: '#ffffff', + canvasStroke: '#d4d4d8', + objectFill: '#94a3b8', + viewportFill: 'rgba(12, 115, 191, 0.08)', + viewportStroke: '#0c73bf', + viewportStrokeWidth: 2, + }, }); minimap.destroy(); ``` + 기본적으로 `createMinimap(options)`는 `init(el)`에 전달한 element 내부에 + 미니맵 컨테이너를 직접 생성합니다. 직접 관리하는 컨테이너를 쓰려면 + `createMinimap(container, options)`를 사용하세요. + + 미니맵 `position`은 `top-left`, `top-right`, `bottom-left`, `bottom-right` + 중 하나입니다. 기본값은 `bottom-right`입니다. + + 미니맵은 `item`, `grid`, `rect` element를 표시합니다. 가독성과 성능을 + 위해 `image`, `text`, `relations`, `group`은 표시하지 않습니다. 렌더 순서는 + 캔버스 z-order를 따르므로 객체가 겹칠 때도 메인 캔버스의 쌓임 순서와 + 일치합니다. + - `theme` Default: ```js @@ -504,6 +527,66 @@ patchmap.fit(['item-1', 'item-2'], {
+### `setCanvasBounds(bounds)` +런타임에 유한 캔버스 영역을 갱신합니다. `null`을 전달하면 다시 무한 캔버스 +동작으로 돌아가고 활성 bounds가 제거됩니다. + +```js +patchmap.setCanvasBounds({ x: -500, y: -300, width: 5000, height: 3000 }); +patchmap.setCanvasBounds(null); +``` + +bounds가 변경되면 PATCH MAP은 `patchmap:canvas-bounds-changed` 이벤트를 +발생시킵니다. + +
+ +### `createMinimap(containerOrOptions, options)` +현재 유한 캔버스를 기준으로 미니맵을 생성합니다. 미니맵을 만들기 전에 +`canvas.bounds`가 설정되어 있어야 합니다. + +```js +const minimap = patchmap.createMinimap({ + width: 240, + height: 144, + position: 'bottom-right', + positionOffset: 16, + opacity: 0.92, + style: { + canvasFill: '#ffffff', + canvasStroke: '#d4d4d8', + objectFill: '#94a3b8', + viewportFill: 'rgba(12, 115, 191, 0.08)', + viewportStroke: '#0c73bf', + viewportStrokeWidth: 2, + }, +}); +``` + +`createMinimap(options)`를 호출하면 `init(el)`에 전달한 element 내부에 +미니맵 컨테이너가 생성됩니다. 해당 root element가 static position이면 +미니맵이 mount되어 있는 동안 PATCH MAP이 임시로 `position: relative`를 +적용합니다. 컨테이너를 직접 관리하려면 `createMinimap(container, options)`를 +사용하세요. + +지원 옵션: + +- `width` / `height`: CSS pixel 기준 미니맵 canvas 크기입니다. 기본값은 `180 x 120`입니다. +- `opacity`: 미니맵 canvas 투명도입니다. 기본값은 `0.92`입니다. +- `position`: `top-left`, `top-right`, `bottom-left`, `bottom-right` 중 하나입니다. + 기본값은 `bottom-right`입니다. +- `positionOffset`: 선택한 모서리로부터의 거리입니다. 기본값은 `16`입니다. +- `style.canvasFill`, `style.canvasStroke`: 유한 캔버스 영역의 fill과 stroke입니다. +- `style.objectFill`: `item`, `grid`, 대부분의 `rect` silhouette에 쓰는 fill입니다. + standalone `rect`는 이 옵션을 명시적으로 덮어쓰지 않으면 더 밝은 기본 fill을 사용합니다. +- `style.viewportFill`, `style.viewportStroke`, `style.viewportStrokeWidth`: + viewport 표시 영역의 스타일입니다. + +반환된 minimap 객체는 `destroy()`를 제공하며, 미니맵을 소유한 UI가 제거될 때 +함께 destroy해야 합니다. + +
+ ### `rotation` 월드 뷰 회전을 제어하는 컨트롤러입니다. 각도는 degrees 기준입니다. @@ -827,6 +910,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` From f2eaf7a19ad98b073be1b061307b504a8b0b39bf Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 13 May 2026 15:18:18 +0900 Subject: [PATCH 16/18] refactor: clarify patchmap init lifecycle --- src/patchmap.js | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/src/patchmap.js b/src/patchmap.js index 5c618e87..2f7b082b 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -66,7 +66,7 @@ class Patchmap extends WildcardEventEmitter { _element = null; _viewTransform = this._createViewTransform(); _initPromise = null; - _destroyAfterInit = false; + _destroyRequestedDuringInit = false; /** @type {import('./canvas-bounds/options').CanvasBounds | null} */ _canvasBounds = null; /** @type {import('./canvas-bounds/controller').default | null} */ @@ -165,18 +165,17 @@ class Patchmap extends WildcardEventEmitter { * @param {PatchmapInitOptions} [opts] */ async init(element, opts = {}) { - if (this.isInit) return this._initPromise; + if (this.isInit) return; if (this._initPromise) return this._initPromise; - this._destroyAfterInit = false; - this._initPromise = this._init(element, opts).catch((error) => { + this._destroyRequestedDuringInit = false; + this._initPromise = this._initialize(element, opts).finally(() => { this._initPromise = null; - throw error; }); return this._initPromise; } - async _init(element, opts = {}) { + async _initialize(element, opts = {}) { const { app: appOptions = {}, viewport: viewportOptions = {}, @@ -230,9 +229,7 @@ class Patchmap extends WildcardEventEmitter { this.transformer = transformer; } this.isInit = true; - this._initPromise = null; - if (this._destroyAfterInit) { - this._destroyAfterInit = false; + if (this._destroyRequestedDuringInit) { this.destroy(); return; } @@ -242,19 +239,13 @@ class Patchmap extends WildcardEventEmitter { destroy() { if (!this.isInit) { if (this._initPromise) { - this._destroyAfterInit = true; - for (const minimap of this._minimaps) { - minimap.destroy(); - } - this._minimaps.clear(); + this._destroyRequestedDuringInit = true; + this._destroyMinimaps(); } return; } - for (const minimap of this._minimaps) { - minimap.destroy(); - } - this._minimaps.clear(); + this._destroyMinimaps(); this.undoRedoManager.destroy(); this.animationContext.revert(); this.stateManager?.resetState(); @@ -280,7 +271,7 @@ class Patchmap extends WildcardEventEmitter { this._element = null; this._viewTransform = this._createViewTransform(); this._initPromise = null; - this._destroyAfterInit = false; + this._destroyRequestedDuringInit = false; this._canvasBounds = null; this._canvasBoundsController = null; this._minimaps = new Set(); @@ -450,6 +441,13 @@ class Patchmap extends WildcardEventEmitter { return minimap; } + _destroyMinimaps() { + for (const minimap of this._minimaps) { + minimap.destroy(); + } + this._minimaps.clear(); + } + _createViewTransform() { return new ViewTransform({ onRotate: (angle) => From a0c69bc04db2f4c60022a52516f2ccb99398264b Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 13 May 2026 19:16:25 +0900 Subject: [PATCH 17/18] feat: add finite canvas minimap behavior --- src/canvas-bounds/options.js | 133 +++++++++-- src/canvas-bounds/options.test.js | 40 +++- src/display/mixins/Sourceable.js | 1 + src/minimap/Minimap.js | 176 +++++---------- src/minimap/model.js | 28 +-- src/minimap/model.test.js | 38 +++- src/patchmap.js | 292 ++++++++++++++++++++----- src/tests/render/canvas-bounds.test.js | 99 +++++++++ src/tests/render/minimap.test.js | 137 +++++------- 9 files changed, 647 insertions(+), 297 deletions(-) diff --git a/src/canvas-bounds/options.js b/src/canvas-bounds/options.js index eadd491d..dfb78e7b 100644 --- a/src/canvas-bounds/options.js +++ b/src/canvas-bounds/options.js @@ -9,28 +9,54 @@ */ 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) => { +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 normalized = {}; - for (const key of BOUNDS_KEYS) { - const value = bounds[key]; - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new TypeError(`canvas.bounds.${key} must be a finite number.`); - } - normalized[key] = value; - } + 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 }; - if (normalized.width <= 0) { - throw new TypeError('canvas.bounds.width must be greater than 0.'); - } - if (normalized.height <= 0) { - throw new TypeError('canvas.bounds.height must be greater than 0.'); - } + 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; @@ -42,8 +68,83 @@ export const normalizeCanvasBounds = (bounds) => { } return Object.freeze({ - ...normalized, + 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 index 51a5c944..dcccb4f3 100644 --- a/src/canvas-bounds/options.test.js +++ b/src/canvas-bounds/options.test.js @@ -16,12 +16,44 @@ describe('canvas bounds options', () => { }); }); + 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 bounds values', () => { + it('rejects non-finite explicit bounds values', () => { expect(() => normalizeCanvasBounds({ x: 0, @@ -31,9 +63,9 @@ describe('canvas bounds options', () => { }), ).toThrow('canvas.bounds.width must be a finite number.'); - expect(() => - normalizeCanvasBounds({ x: '0', y: 0, width: 1, height: 1 }), - ).toThrow('canvas.bounds.x must be a finite number.'); + expect(() => normalizeCanvasBounds({ x: '0' })).toThrow( + 'canvas.bounds.x must be a finite number.', + ); }); it('rejects non-positive sizes', () => { 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 index 25714859..6a976f6f 100644 --- a/src/minimap/Minimap.js +++ b/src/minimap/Minimap.js @@ -6,14 +6,7 @@ import { } from './model'; const DEFAULT_OPTIONS = Object.freeze({ - width: 180, - height: 120, - opacity: 0.92, - position: 'bottom-right', - positionOffset: 16, style: { - canvasFill: '#ffffff', - canvasStroke: '#d4d4d8', objectFill: '#94a3b8', viewportFill: 'rgba(12, 115, 191, 0.08)', viewportStroke: '#0c73bf', @@ -21,21 +14,6 @@ const DEFAULT_OPTIONS = Object.freeze({ }, }); const DEFAULT_RECT_FILL = '#cbd5e1'; -const MINIMAP_CANVAS_STROKE_WIDTH = 1; -const MINIMAP_POSITIONS = new Set([ - 'top-left', - 'top-right', - 'bottom-left', - 'bottom-right', -]); -const POSITION_STYLE_KEYS = Object.freeze([ - 'position', - 'top', - 'right', - 'bottom', - 'left', - 'zIndex', -]); const PATCHMAP_EVENTS = Object.freeze([ ['patchmap:initialized', '_onPatchmapInitialized'], ['patchmap:draw', '_requestObjectRender'], @@ -60,8 +38,6 @@ const POINTER_EVENTS = Object.freeze([ /** * @typedef {object} MinimapStyle - * @property {string | number} [canvasFill] - * @property {string | number} [canvasStroke] * @property {string | number} [objectFill] * @property {string} [viewportFill] * @property {string | number} [viewportStroke] @@ -70,11 +46,6 @@ const POINTER_EVENTS = Object.freeze([ /** * @typedef {object} MinimapOptions - * @property {number} [width] - * @property {number} [height] - * @property {number} [opacity] - * @property {'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'} [position] - * @property {number} [positionOffset] * @property {MinimapStyle} [style] */ @@ -106,9 +77,10 @@ export default class Minimap { this._objectSnapshot = null; this._objectLayerKey = null; this._objectsDirty = true; - this._containerStyleSnapshot = null; this._attachedViewport = null; this._attachedTicker = null; + this._resizeObserver = null; + this._size = resolveContainerSize(container); this._frame = null; this._viewportTransformKey = null; this._destroyed = false; @@ -132,36 +104,15 @@ export default class Minimap { } mount() { - this.applyContainerPosition(); - this.canvas.width = this.options.width; - this.canvas.height = this.options.height; - this.canvas.style.width = `${this.options.width}px`; - this.canvas.style.height = `${this.options.height}px`; - this.canvas.style.opacity = String(this.options.opacity); + 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); - } - - applyContainerPosition() { - if (!this.container?.style) return; - this._containerStyleSnapshot ??= snapshotContainerStyle(this.container); - - const [vertical, horizontal] = this.options.position.split('-'); - const offset = `${this.options.positionOffset}px`; - const containerPosition = - this.container.dataset.patchmapMinimapAuto === 'true' - ? 'absolute' - : 'fixed'; - Object.assign(this.container.style, { - position: containerPosition, - top: vertical === 'top' ? offset : '', - right: horizontal === 'right' ? offset : '', - bottom: vertical === 'bottom' ? offset : '', - left: horizontal === 'left' ? offset : '', - zIndex: '1', - }); + this.observeContainerResize(); } attach() { @@ -182,6 +133,28 @@ export default class Minimap { 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() { @@ -251,8 +224,8 @@ export default class Minimap { this.cancelPendingRender(); const ratio = globalThis.devicePixelRatio || 1; - const width = this.options.width; - const height = this.options.height; + 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 ( @@ -369,8 +342,6 @@ export default class Minimap { height: bounds.height, } : null, - canvasFill: style.canvasFill, - canvasStroke: style.canvasStroke, objectFill: style.objectFill, }); } @@ -378,7 +349,7 @@ export default class Minimap { drawObjectLayer({ snapshot, ratio, pixelWidth, pixelHeight }) { const ctx = this._objectLayerContext; if (!ctx) return; - const { width, height } = this.options; + const { width, height } = this._size; if ( this._objectLayer.width !== pixelWidth || @@ -396,18 +367,10 @@ export default class Minimap { } drawStaticSnapshot(ctx, snapshot) { - const style = this.options.style; const canvas = snapshot.canvas; - ctx.fillStyle = style.canvasFill; - ctx.strokeStyle = style.canvasStroke; - ctx.lineWidth = 1; - ctx.fillRect(canvas.x, canvas.y, canvas.width, canvas.height); - ctx.strokeRect(canvas.x, canvas.y, canvas.width, canvas.height); - ctx.save(); - ctx.beginPath(); - ctx.rect(canvas.x, canvas.y, canvas.width, canvas.height); + this.drawCanvasClip(ctx, canvas); ctx.clip(); for (const object of snapshot.objects) { this.drawSilhouette(ctx, object); @@ -418,13 +381,17 @@ export default class Minimap { drawViewportLayer(ctx, snapshot) { const canvas = snapshot.canvas; ctx.save(); - ctx.beginPath(); - ctx.rect(canvas.x, canvas.y, canvas.width, canvas.height); + 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; @@ -518,7 +485,6 @@ export default class Minimap { } this.detach(); this.canvas?.remove(); - restoreContainerStyle(this.container, this._containerStyleSnapshot); this.onDestroy?.(this); this.patchmap = null; this.container = null; @@ -528,7 +494,6 @@ export default class Minimap { this._objectLayerContext = null; this._snapshot = null; this._objectSnapshot = null; - this._containerStyleSnapshot = null; } } @@ -542,8 +507,6 @@ const mergeOptions = (options) => { }, }; const style = { - canvasFill: merged.style.canvasFill, - canvasStroke: merged.style.canvasStroke, objectFill: merged.style.objectFill, viewportFill: merged.style.viewportFill, viewportStroke: merged.style.viewportStroke, @@ -553,34 +516,10 @@ const mergeOptions = (options) => { ), }; return { - width: normalizePositiveNumber(merged.width, 'minimap.width'), - height: normalizePositiveNumber(merged.height, 'minimap.height'), - opacity: normalizeOpacity(merged.opacity), - position: normalizePosition(merged.position), - positionOffset: normalizeNonNegativeNumber( - merged.positionOffset, - 'minimap.positionOffset', - ), style, }; }; -const normalizePosition = (value) => { - if (!MINIMAP_POSITIONS.has(value)) { - throw new TypeError( - 'minimap.position must be one of top-left, top-right, bottom-left, bottom-right.', - ); - } - return value; -}; - -const normalizePositiveNumber = (value, name) => { - if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { - throw new TypeError(`${name} must be a positive finite number.`); - } - return value; -}; - const normalizeNonNegativeNumber = (value, name) => { if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { throw new TypeError(`${name} must be a non-negative finite number.`); @@ -588,32 +527,33 @@ const normalizeNonNegativeNumber = (value, name) => { return value; }; -const normalizeOpacity = (value) => { - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new TypeError('minimap.opacity must be a finite number.'); - } - return Math.min(Math.max(value, 0), 1); +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(MINIMAP_CANVAS_STROKE_WIDTH, style.viewportStrokeWidth / 2); - -const snapshotContainerStyle = (container) => - Object.fromEntries( - POSITION_STYLE_KEYS.map((key) => [key, container.style[key]]), - ); - -const restoreContainerStyle = (container, snapshot) => { - if (!container?.style || !snapshot) return; - for (const key of POSITION_STYLE_KEYS) { - container.style[key] = snapshot[key] ?? ''; - } -}; + Math.max(0, style.viewportStrokeWidth / 2); const createViewportTransformKey = (patchmap) => { const viewport = patchmap?.viewport; diff --git a/src/minimap/model.js b/src/minimap/model.js index 826996e7..804603e7 100644 --- a/src/minimap/model.js +++ b/src/minimap/model.js @@ -16,8 +16,8 @@ export const createMinimapObjectSnapshot = ({ const scale = getMinimapScale({ canvasBounds, width, height, inset }); const origin = { - x: inset + (width - inset * 2 - canvasBounds.width * scale) / 2, - y: inset + (height - inset * 2 - canvasBounds.height * scale) / 2, + x: inset, + y: inset, }; return { @@ -47,17 +47,17 @@ export const minimapPointToCanvasPoint = ({ scale, origin, }) => ({ - x: canvasBounds.x + (point.x - origin.x) / scale, - y: canvasBounds.y + (point.y - origin.y) / scale, + 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 Math.min( - availableWidth / canvasBounds.width, - availableHeight / canvasBounds.height, - ); + return { + x: availableWidth / canvasBounds.width, + y: availableHeight / canvasBounds.height, + }; }; const collectObjectSilhouettes = ({ @@ -372,15 +372,15 @@ const screenPointToCanvasPoint = ({ world, point }) => { }; const projectRect = (rect, canvasBounds, scale, origin) => ({ - x: origin.x + (rect.x - canvasBounds.x) * scale, - y: origin.y + (rect.y - canvasBounds.y) * scale, - width: rect.width * scale, - height: rect.height * scale, + 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, - y: origin.y + (point.y - canvasBounds.y) * scale, + x: origin.x + (point.x - canvasBounds.x) * scale.x, + y: origin.y + (point.y - canvasBounds.y) * scale.y, }); const silhouetteIntersectsCanvasBounds = (silhouette, canvasBounds) => diff --git a/src/minimap/model.test.js b/src/minimap/model.test.js index 87a670f3..cc3b4ee7 100644 --- a/src/minimap/model.test.js +++ b/src/minimap/model.test.js @@ -1,7 +1,41 @@ import { describe, expect, it } from 'vitest'; -import { minimapPointToCanvasPoint } from './model'; +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({ @@ -14,7 +48,7 @@ describe('minimap model', () => { right: 1000, bottom: 500, }, - scale: 0.164, + scale: { x: 0.164, y: 0.164 }, origin: { x: 8, y: 19 }, }), ).toEqual({ diff --git a/src/patchmap.js b/src/patchmap.js index 2f7b082b..b1e2f1d2 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -2,7 +2,10 @@ import gsap from 'gsap'; import { Application, UPDATE_PRIORITY } from 'pixi.js'; import { isValidationError } from 'zod-validation-error'; import CanvasBoundsController from './canvas-bounds/controller'; -import { normalizeCanvasBounds } from './canvas-bounds/options'; +import { + hasAutoCanvasBounds, + normalizeCanvasBounds, +} from './canvas-bounds/options'; import { UndoRedoManager } from './command/UndoRedoManager'; import './display/components/registry'; import { draw } from './display/draw'; @@ -27,14 +30,17 @@ 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 + * @property {number} [x] + * @property {number} [y] + * @property {number} [width] + * @property {number} [height] */ /** @@ -67,12 +73,16 @@ class Patchmap extends WildcardEventEmitter { _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; @@ -184,20 +194,26 @@ class Patchmap extends WildcardEventEmitter { canvas: canvasOptions, transformer, } = opts; - const canvasBounds = + const canvasBoundsInput = canvasOptions && Object.hasOwn(canvasOptions, 'bounds') - ? normalizeCanvasBounds(canvasOptions.bounds) - : this._canvasBounds; + ? 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); @@ -251,6 +267,11 @@ class Patchmap extends WildcardEventEmitter { 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; @@ -272,10 +293,12 @@ class Patchmap extends WildcardEventEmitter { 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(); } @@ -297,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 @@ -370,7 +394,12 @@ class Patchmap extends WildcardEventEmitter { * @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(); @@ -399,6 +428,75 @@ class Patchmap extends WildcardEventEmitter { 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() { return this._viewTransform.rotation; } @@ -412,29 +510,17 @@ class Patchmap extends WildcardEventEmitter { } /** - * @param {HTMLElement | import('./minimap/Minimap').MinimapOptions} [containerOrOptions] + * @param {HTMLElement} container * @param {import('./minimap/Minimap').MinimapOptions} [options] * @returns {Minimap} */ - createMinimap(containerOrOptions = {}, options = {}) { - const hasContainer = isHTMLElement(containerOrOptions); - const defaultContainer = hasContainer - ? null - : createDefaultMinimapContainer(this._element); - const container = hasContainer - ? containerOrOptions - : defaultContainer.container; - const minimapOptions = hasContainer ? options : containerOrOptions; + createMinimap(container, options = {}) { const minimap = new Minimap({ patchmap: this, container, - options: minimapOptions, + options, onDestroy: (target) => { this._minimaps.delete(target); - if (!hasContainer) { - container.remove(); - defaultContainer.restore(); - } }, }); this._minimaps.add(minimap); @@ -501,54 +587,140 @@ function scheduleUserVisibleTask(task) { setTimeout(task, 0); } -export { Patchmap }; +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 DEFAULT_MINIMAP_ROOTS = new WeakMap(); +const getElementBounds = (element, origin, predicate) => { + if (!element || element.show === false) return null; -const isHTMLElement = (value) => - typeof HTMLElement !== 'undefined' && value instanceof HTMLElement; + 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; -const createDefaultMinimapContainer = (root) => { - if (!root?.appendChild) { - return { container: null, restore: () => {} }; - } - const restoreRootPosition = retainPositionedRoot(root); - const container = document.createElement('div'); - container.dataset.patchmapMinimap = 'true'; - container.dataset.patchmapMinimapAuto = 'true'; - root.appendChild(container); return { - container, - restore: restoreRootPosition, + x: origin.x, + y: origin.y, + width: size.width, + height: size.height, }; }; -const retainPositionedRoot = (root) => { - const existing = DEFAULT_MINIMAP_ROOTS.get(root); - if (existing) { - existing.count += 1; - return () => releasePositionedRoot(root); +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), + }; } - const shouldSetPosition = getComputedStyle(root).position === 'static'; - DEFAULT_MINIMAP_ROOTS.set(root, { - count: 1, - previousPosition: root.style.position, - shouldSetPosition, - }); - if (shouldSetPosition) { - root.style.position = 'relative'; + if (['item', 'image', 'rect', 'text'].includes(element.type)) { + return normalizeSize(element.size); } - return () => releasePositionedRoot(root); + + return null; }; -const releasePositionedRoot = (root) => { - const entry = DEFAULT_MINIMAP_ROOTS.get(root); - if (!entry) return; - entry.count -= 1; - if (entry.count > 0) return; - if (entry.shouldSetPosition) { - root.style.position = entry.previousPosition; +const normalizeSize = (size) => { + if (typeof size === 'number') { + return { width: size, height: size }; } - DEFAULT_MINIMAP_ROOTS.delete(root); + 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 index bf54082a..0ed13e70 100644 --- a/src/tests/render/canvas-bounds.test.js +++ b/src/tests/render/canvas-bounds.test.js @@ -93,6 +93,105 @@ describe('canvas bounds', () => { }); }); + 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(); diff --git a/src/tests/render/minimap.test.js b/src/tests/render/minimap.test.js index fe929695..aaaaeaa4 100644 --- a/src/tests/render/minimap.test.js +++ b/src/tests/render/minimap.test.js @@ -9,8 +9,10 @@ const createHost = () => { return element; }; -const createMinimapHost = () => { +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; }; @@ -74,7 +76,7 @@ describe('minimap', () => { expect(minimapHost.querySelector('canvas')).toBeNull(); }); - it('creates a default minimap container inside the init host', async () => { + it('requires an explicit minimap container', async () => { element = createHost(); patchmap = new Patchmap(); @@ -84,40 +86,51 @@ describe('minimap', () => { }, }); - const minimap = patchmap.createMinimap({ - width: 180, - height: 120, - }); - const generatedHost = element.querySelector('[data-patchmap-minimap]'); + expect(() => patchmap.createMinimap()).toThrow( + 'patchmap.createMinimap() requires a DOM container.', + ); + }); - expect(generatedHost).toBe(minimap.container); - expect(generatedHost.querySelector('canvas')).toBe(minimap.canvas); - expect(element.style.position).toBe('relative'); - expect(generatedHost.style.position).toBe('absolute'); - expect(generatedHost.style.right).toBe('16px'); - expect(generatedHost.style.bottom).toBe('16px'); - expect(generatedHost.style.zIndex).toBe('1'); + it('fills the given minimap host area', async () => { + element = createHost(); + minimapHost = createMinimapHost({ width: 240, height: 144 }); + patchmap = new Patchmap(); - minimap.destroy(); + await patchmap.init(element, { + canvas: { + bounds: { x: 0, y: 0, width: 1000, height: 500 }, + }, + }); + + const minimap = patchmap.createMinimap(minimapHost); - expect(element.querySelector('[data-patchmap-minimap]')).toBeNull(); - expect(element.style.position).toBe(''); + 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('rejects invalid minimap dimensions', async () => { + it('keeps the viewport indicator flush with minimap edges at canvas bounds', async () => { element = createHost(); - minimapHost = createMinimapHost(); + minimapHost = createMinimapHost({ width: 240, height: 144 }); patchmap = new Patchmap(); await patchmap.init(element, { canvas: { - bounds: { x: 0, y: 0, width: 1000, height: 500 }, + bounds: { x: 0, y: 0, width: 5000, height: 12000 }, }, }); - expect(() => patchmap.createMinimap(minimapHost, { width: 0 })).toThrow( - 'minimap.width must be a positive finite number.', + 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 () => { @@ -142,7 +155,7 @@ describe('minimap', () => { expect(minimap._snapshot.canvas.x).toBeCloseTo(1, 4); }); - it('uses explicit minimap style fill and stroke option names', async () => { + it('uses explicit minimap render color option names', async () => { element = createHost(); minimapHost = createMinimapHost(); patchmap = new Patchmap(); @@ -155,8 +168,6 @@ describe('minimap', () => { const minimap = patchmap.createMinimap(minimapHost, { style: { - canvasFill: '#111111', - canvasStroke: '#222222', objectFill: '#333333', viewportFill: 'rgba(1, 2, 3, 0.2)', viewportStroke: '#444444', @@ -165,8 +176,6 @@ describe('minimap', () => { }); expect(minimap.options.style).toEqual({ - canvasFill: '#111111', - canvasStroke: '#222222', objectFill: '#333333', viewportFill: 'rgba(1, 2, 3, 0.2)', viewportStroke: '#444444', @@ -174,9 +183,15 @@ describe('minimap', () => { }); }); - it('anchors the minimap to the bottom-right corner by default', async () => { + 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, { @@ -187,64 +202,20 @@ describe('minimap', () => { const minimap = patchmap.createMinimap(minimapHost); - expect(minimapHost.style.position).toBe('fixed'); - expect(minimapHost.style.right).toBe('16px'); - expect(minimapHost.style.bottom).toBe('16px'); - expect(minimapHost.style.zIndex).toBe('1'); - expect(minimapHost.style.top).toBe(''); - expect(minimapHost.style.left).toBe(''); + 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(''); - expect(minimapHost.style.right).toBe(''); - expect(minimapHost.style.bottom).toBe(''); - expect(minimapHost.style.zIndex).toBe(''); - }); - - it.each([ - ['top-left', { top: '12px', left: '12px', right: '', bottom: '' }], - ['top-right', { top: '12px', right: '12px', left: '', bottom: '' }], - ['bottom-left', { bottom: '12px', left: '12px', top: '', right: '' }], - ['bottom-right', { bottom: '12px', right: '12px', top: '', left: '' }], - ])('anchors the minimap to %s', async (position, expected) => { - element = createHost(); - minimapHost = createMinimapHost(); - patchmap = new Patchmap(); - - await patchmap.init(element, { - canvas: { - bounds: { x: 0, y: 0, width: 1000, height: 500 }, - }, - }); - - patchmap.createMinimap(minimapHost, { - position, - positionOffset: 12, - }); - - expect(minimapHost.style.position).toBe('fixed'); - for (const [key, value] of Object.entries(expected)) { - expect(minimapHost.style[key]).toBe(value); - } - }); - - it('rejects invalid minimap positions', async () => { - element = createHost(); - minimapHost = createMinimapHost(); - patchmap = new Patchmap(); - - await patchmap.init(element, { - canvas: { - bounds: { x: 0, y: 0, width: 1000, height: 500 }, - }, - }); - - expect(() => - patchmap.createMinimap(minimapHost, { position: 'center' }), - ).toThrow( - 'minimap.position must be one of top-left, top-right, bottom-left, bottom-right.', - ); + expect(minimapHost.style.position).toBe('absolute'); + expect(minimapHost.style.right).toBe('24px'); }); it('moves the viewport from minimap pointer navigation', async () => { From 7c668a7554a52854b874c1028bbd45e2371bc679 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 13 May 2026 19:16:37 +0900 Subject: [PATCH 18/18] docs: document finite canvas minimap usage --- README.md | 64 ++++++++++++++++++++++++---------------------------- README_KR.md | 61 ++++++++++++++++++++++--------------------------- 2 files changed, 56 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index ba8e7cb5..34d358fd 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Therefore, to use this, an understanding of the following two libraries is essen - [focus(ids, opts)](#focusids-opts) - [fit(ids, options)](#fitids-options) - [setCanvasBounds(bounds)](#setcanvasboundsbounds) - - [createMinimap(containerOrOptions, options)](#createminimapcontaineroroptions-options) + - [createMinimap(container, options)](#createminimapcontainer-options) - [rotation](#rotation) - [flip](#flip) - [selector(path)](#selectorpath) @@ -124,7 +124,7 @@ await patchmap.init(el, { plugins: { decelerate: { disabled: true } } }, canvas: { - bounds: { x: 0, y: 0, width: 5000, height: 3000 } + bounds: {} }, theme: { primary: { default: '#c2410c' } @@ -181,13 +181,8 @@ Customize the rendering behavior using the following options: Finite canvas mode also enables minimap creation: ```js - const minimap = patchmap.createMinimap({ - width: 180, - height: 120, - position: 'bottom-right', + const minimap = patchmap.createMinimap(document.querySelector('#minimap'), { style: { - canvasFill: '#ffffff', - canvasStroke: '#d4d4d8', objectFill: '#94a3b8', viewportFill: 'rgba(12, 115, 191, 0.08)', viewportStroke: '#0c73bf', @@ -198,12 +193,11 @@ Customize the rendering behavior using the following options: minimap.destroy(); ``` - By default, `createMinimap(options)` creates its own minimap container inside - the element passed to `init(el)`. You can still provide a custom container with - `createMinimap(container, options)`. - - Minimap `position` accepts `top-left`, `top-right`, `bottom-left`, or - `bottom-right`. The default is `bottom-right`. + 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 @@ -526,28 +520,35 @@ 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(containerOrOptions, options)` +### `createMinimap(container, options)` Creates a minimap for the current finite canvas. `canvas.bounds` must be set -before creating a minimap. +before creating a minimap. The first argument must be the container element that +will receive the minimap canvas. ```js -const minimap = patchmap.createMinimap({ - width: 240, - height: 144, - position: 'bottom-right', - positionOffset: 16, - opacity: 0.92, +const minimap = patchmap.createMinimap(document.querySelector('#minimap'), { style: { - canvasFill: '#ffffff', - canvasStroke: '#d4d4d8', objectFill: '#94a3b8', viewportFill: 'rgba(12, 115, 191, 0.08)', viewportStroke: '#0c73bf', @@ -556,20 +557,13 @@ const minimap = patchmap.createMinimap({ }); ``` -Calling `createMinimap(options)` creates a minimap container inside the element -passed to `init(el)`. If that root element is statically positioned, PATCH MAP -temporarily sets it to `position: relative` while the auto-created minimap is -mounted. Use `createMinimap(container, options)` when you want to own the -container yourself. +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: -- `width` / `height`: minimap canvas size in CSS pixels. Defaults to `180 x 120`. -- `opacity`: minimap canvas opacity. Default is `0.92`. -- `position`: `top-left`, `top-right`, `bottom-left`, or `bottom-right`. - Default is `bottom-right`. -- `positionOffset`: distance from the selected corner. Default is `16`. -- `style.canvasFill`, `style.canvasStroke`: finite canvas fill and stroke. - `style.objectFill`: fill used for `item`, `grid`, and most `rect` silhouettes. Standalone `rect` uses a lighter default fill unless this option is explicitly overridden. diff --git a/README_KR.md b/README_KR.md index d3814bcd..2966d716 100644 --- a/README_KR.md +++ b/README_KR.md @@ -29,7 +29,7 @@ PATCH MAP은 PATCH 서비스의 요구 사항을 충족시키기 위해 `pixi.js - [focus(ids, opts)](#focusids-opts) - [fit(ids, options)](#fitids-options) - [setCanvasBounds(bounds)](#setcanvasboundsbounds) - - [createMinimap(containerOrOptions, options)](#createminimapcontaineroroptions-options) + - [createMinimap(container, options)](#createminimapcontainer-options) - [rotation](#rotation) - [flip](#flip) - [selector(path)](#selectorpath) @@ -131,7 +131,7 @@ await patchmap.init(el, { plugins: { decelerate: { disabled: true } } }, canvas: { - bounds: { x: 0, y: 0, width: 5000, height: 3000 } + bounds: {} }, theme: { primary: { default: '#c2410c' } @@ -190,12 +190,7 @@ await patchmap.init(el, { ```js const minimap = patchmap.createMinimap(document.querySelector('#minimap'), { - width: 180, - height: 120, - position: 'bottom-right', style: { - canvasFill: '#ffffff', - canvasStroke: '#d4d4d8', objectFill: '#94a3b8', viewportFill: 'rgba(12, 115, 191, 0.08)', viewportStroke: '#0c73bf', @@ -206,12 +201,10 @@ await patchmap.init(el, { minimap.destroy(); ``` - 기본적으로 `createMinimap(options)`는 `init(el)`에 전달한 element 내부에 - 미니맵 컨테이너를 직접 생성합니다. 직접 관리하는 컨테이너를 쓰려면 - `createMinimap(container, options)`를 사용하세요. - - 미니맵 `position`은 `top-left`, `top-right`, `bottom-left`, `bottom-right` - 중 하나입니다. 기본값은 `bottom-right`입니다. + PATCH MAP은 미니맵 host의 위치나 장식을 제어하지 않습니다. 크기가 정해진 + container element를 전달하고, 해당 element의 위치, 배경, border, radius, + shadow는 애플리케이션에서 직접 스타일링하세요. 주입되는 canvas는 container를 + 꽉 채우고 배경은 transparent라 host element의 배경이 그대로 보입니다. 미니맵은 `item`, `grid`, `rect` element를 표시합니다. 가독성과 성능을 위해 `image`, `text`, `relations`, `group`은 표시하지 않습니다. 렌더 순서는 @@ -532,29 +525,36 @@ patchmap.fit(['item-1', 'item-2'], { 동작으로 돌아가고 활성 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(containerOrOptions, options)` +### `createMinimap(container, options)` 현재 유한 캔버스를 기준으로 미니맵을 생성합니다. 미니맵을 만들기 전에 -`canvas.bounds`가 설정되어 있어야 합니다. +`canvas.bounds`가 설정되어 있어야 합니다. 첫 번째 인자는 미니맵 canvas가 +주입될 container element여야 합니다. ```js -const minimap = patchmap.createMinimap({ - width: 240, - height: 144, - position: 'bottom-right', - positionOffset: 16, - opacity: 0.92, +const minimap = patchmap.createMinimap(document.querySelector('#minimap'), { style: { - canvasFill: '#ffffff', - canvasStroke: '#d4d4d8', objectFill: '#94a3b8', viewportFill: 'rgba(12, 115, 191, 0.08)', viewportStroke: '#0c73bf', @@ -563,20 +563,13 @@ const minimap = patchmap.createMinimap({ }); ``` -`createMinimap(options)`를 호출하면 `init(el)`에 전달한 element 내부에 -미니맵 컨테이너가 생성됩니다. 해당 root element가 static position이면 -미니맵이 mount되어 있는 동안 PATCH MAP이 임시로 `position: relative`를 -적용합니다. 컨테이너를 직접 관리하려면 `createMinimap(container, options)`를 -사용하세요. +PATCH MAP은 미니맵 canvas 주입과 렌더링만 담당합니다. 크기, 위치, 배경, +border, border radius, shadow 같은 시각적 chrome은 container element에서 +제어하세요. 미니맵 canvas는 container를 꽉 채우고 배경은 transparent라 +container 배경이 그대로 보입니다. 지원 옵션: -- `width` / `height`: CSS pixel 기준 미니맵 canvas 크기입니다. 기본값은 `180 x 120`입니다. -- `opacity`: 미니맵 canvas 투명도입니다. 기본값은 `0.92`입니다. -- `position`: `top-left`, `top-right`, `bottom-left`, `bottom-right` 중 하나입니다. - 기본값은 `bottom-right`입니다. -- `positionOffset`: 선택한 모서리로부터의 거리입니다. 기본값은 `16`입니다. -- `style.canvasFill`, `style.canvasStroke`: 유한 캔버스 영역의 fill과 stroke입니다. - `style.objectFill`: `item`, `grid`, 대부분의 `rect` silhouette에 쓰는 fill입니다. standalone `rect`는 이 옵션을 명시적으로 덮어쓰지 않으면 더 밝은 기본 fill을 사용합니다. - `style.viewportFill`, `style.viewportStroke`, `style.viewportStrokeWidth`: