diff --git a/README.md b/README.md index e7de313..c0c87b3 100644 --- a/README.md +++ b/README.md @@ -392,7 +392,34 @@ patchmap.fit('group-id-1') patchmap.fit('grid-1') // Fit on objects with ids 'item-1' and 'item-2' -patchmap.fit(['item-1', 'item-2']) +patchmap.fit(['item-1', 'item-2']); +``` + +
+ +### `rotation` + +Rotation controller for world view. Use degrees. + +```js +patchmap.rotation.value = 90; +patchmap.rotation.rotateBy(90); +patchmap.rotation.reset(); +``` + +
+ +### `flip` + +Flip controller for world view. + +```js +patchmap.flip.x = true; +patchmap.flip.y = false; +patchmap.flip.set({ x: true, y: true }); +patchmap.flip.toggleX(); +patchmap.flip.toggleY(); +patchmap.flip.reset(); ```
diff --git a/README_KR.md b/README_KR.md index 07dd7df..981827d 100644 --- a/README_KR.md +++ b/README_KR.md @@ -398,7 +398,34 @@ patchmap.fit('group-id-1') patchmap.fit('grid-1') // id가 'item-1'과 'item-2'인 객체들을 기준으로 fit -patchmap.fit(['item-1', 'item-2']) +patchmap.fit(['item-1', 'item-2']); +``` + +
+ +### `rotation` + +월드 뷰 회전을 제어하는 컨트롤러입니다. 각도는 degrees 기준입니다. + +```js +patchmap.rotation.value = 90; +patchmap.rotation.rotateBy(90); +patchmap.rotation.reset(); +``` + +
+ +### `flip` + +월드 뷰 플립을 제어하는 컨트롤러입니다. + +```js +patchmap.flip.x = true; +patchmap.flip.y = false; +patchmap.flip.set({ x: true, y: true }); +patchmap.flip.toggleX(); +patchmap.flip.toggleY(); +patchmap.flip.reset(); ```
diff --git a/src/display/World.js b/src/display/World.js new file mode 100644 index 0000000..8cb7432 --- /dev/null +++ b/src/display/World.js @@ -0,0 +1,17 @@ +import { Container } from 'pixi.js'; +import { canvasSchema } from './data-schema/element-schema'; +import { Base } from './mixins/Base'; +import { Childrenable } from './mixins/Childrenable'; +import { mixins } from './mixins/utils'; + +const ComposedWorld = mixins(Container, Base, Childrenable); + +export default class World extends ComposedWorld { + constructor(options) { + super({ type: 'canvas', ...options }); + } + + apply(changes, options) { + super.apply(changes, canvasSchema, options); + } +} diff --git a/src/display/components/Bar.js b/src/display/components/Bar.js index ead3df8..75db063 100644 --- a/src/display/components/Bar.js +++ b/src/display/components/Bar.js @@ -8,6 +8,7 @@ import { Showable } from '../mixins/Showable'; import { Sourceable } from '../mixins/Sourceable'; import { Tintable } from '../mixins/Tintable'; import { mixins } from '../mixins/utils'; +import { WorldTransformable } from '../mixins/WorldTransformable'; const EXTRA_KEYS = { PLACEMENT: ['source', 'size'], @@ -21,20 +22,61 @@ const ComposedBar = mixins( Tintable, Animationable, AnimationSizeable, + WorldTransformable, Placementable, ); export class Bar extends ComposedBar { + static useViewLayout = false; + static useViewPlacement = true; + static worldRotationOptions = { mode: 'readable' }; + static worldTransformKeys = ['source', 'size']; + constructor(store) { super({ type: 'bar', store, texture: Texture.WHITE }); + this.useViewLayout = false; + this.useViewPlacement = true; this.constructor.registerHandler( EXTRA_KEYS.PLACEMENT, this._applyPlacement, ); + + this._boundOnObjectTransformed = this._onObjectTransformed.bind(this); + this.store?.viewport?.on( + 'object_transformed', + this._boundOnObjectTransformed, + ); + this._applyWorldTransform(); } apply(changes, options) { super.apply(changes, barSchema, options); } + + _onObjectTransformed(changedObject) { + if (changedObject !== this.store?.world) return; + this._applyWorldTransform(); + this._applyPlacement({ + placement: this.props.placement, + margin: this.props.margin, + }); + this._applyAnimationSize({ + animation: this.props.animation, + animationDuration: this.props.animationDuration, + source: this.props.source, + size: this.props.size, + margin: this.props.margin, + }); + } + + destroy(options) { + if (this.store?.viewport && this._boundOnObjectTransformed) { + this.store.viewport.off( + 'object_transformed', + this._boundOnObjectTransformed, + ); + } + super.destroy(options); + } } diff --git a/src/display/components/Icon.js b/src/display/components/Icon.js index 397e341..fbda01f 100644 --- a/src/display/components/Icon.js +++ b/src/display/components/Icon.js @@ -7,6 +7,7 @@ import { Showable } from '../mixins/Showable'; import { Sourceable } from '../mixins/Sourceable'; import { Tintable } from '../mixins/Tintable'; import { mixins } from '../mixins/utils'; +import { WorldTransformable } from '../mixins/WorldTransformable'; const EXTRA_KEYS = { PLACEMENT: ['source', 'size'], @@ -19,20 +20,54 @@ const ComposedIcon = mixins( Sourceable, Tintable, ComponentSizeable, + WorldTransformable, Placementable, ); export class Icon extends ComposedIcon { + static useViewLayout = false; + static useViewPlacement = true; + static worldRotationOptions = { mode: 'readable' }; + static worldTransformKeys = ['source', 'size']; + constructor(store) { super({ type: 'icon', store, texture: Texture.WHITE }); + this.useViewLayout = false; + this.useViewPlacement = true; this.constructor.registerHandler( EXTRA_KEYS.PLACEMENT, this._applyPlacement, ); + + this._boundOnObjectTransformed = this._onObjectTransformed.bind(this); + this.store?.viewport?.on( + 'object_transformed', + this._boundOnObjectTransformed, + ); + this._applyWorldTransform(); } apply(changes, options) { super.apply(changes, iconSchema, options); } + + _onObjectTransformed(changedObject) { + if (changedObject !== this.store?.world) return; + this._applyWorldTransform(); + this._applyPlacement({ + placement: this.props.placement, + margin: this.props.margin, + }); + } + + destroy(options) { + if (this.store?.viewport && this._boundOnObjectTransformed) { + this.store.viewport.off( + 'object_transformed', + this._boundOnObjectTransformed, + ); + } + super.destroy(options); + } } diff --git a/src/display/components/Text.js b/src/display/components/Text.js index 9eb3be5..994a828 100644 --- a/src/display/components/Text.js +++ b/src/display/components/Text.js @@ -4,10 +4,10 @@ import { Base } from '../mixins/Base'; import { Placementable } from '../mixins/Placementable'; import { Showable } from '../mixins/Showable'; import { Textable } from '../mixins/Textable'; -import { TextLayoutable } from '../mixins/TextLayoutable'; import { Textstyleable } from '../mixins/Textstyleable'; import { Tintable } from '../mixins/Tintable'; import { mixins } from '../mixins/utils'; +import { WorldTransformable } from '../mixins/WorldTransformable'; const EXTRA_KEYS = { PLACEMENT: ['text', 'style', 'split'], @@ -19,22 +19,55 @@ const ComposedText = mixins( Showable, Textable, Textstyleable, - TextLayoutable, Tintable, + WorldTransformable, Placementable, ); export class Text extends ComposedText { + static useViewLayout = false; + static useViewPlacement = true; + static worldRotationOptions = { mode: 'readable' }; + static worldTransformKeys = ['text', 'style', 'split']; + constructor(store) { super({ type: 'text', store, text: '' }); + this.useViewLayout = false; + this.useViewPlacement = true; this.constructor.registerHandler( EXTRA_KEYS.PLACEMENT, this._applyPlacement, ); + + this._boundOnObjectTransformed = this._onObjectTransformed.bind(this); + this.store?.viewport?.on( + 'object_transformed', + this._boundOnObjectTransformed, + ); + this._applyWorldTransform(); } apply(changes, options) { super.apply(changes, textSchema, options); } + + _onObjectTransformed(changedObject) { + if (changedObject !== this.store?.world) return; + this._applyWorldTransform(); + this._applyPlacement({ + placement: this.props.placement, + margin: this.props.margin, + }); + } + + destroy(options) { + if (this.store?.viewport && this._boundOnObjectTransformed) { + this.store.viewport.off( + 'object_transformed', + this._boundOnObjectTransformed, + ); + } + super.destroy(options); + } } diff --git a/src/display/draw.js b/src/display/draw.js index 236547e..72dc3b4 100644 --- a/src/display/draw.js +++ b/src/display/draw.js @@ -1,12 +1,9 @@ import Element from './elements/Element'; export const draw = (store, data) => { - const { viewport } = store; - destroyChildren(viewport); - viewport.apply( - { type: 'canvas', children: data }, - { mergeStrategy: 'replace' }, - ); + const root = store.world ?? store.viewport; + destroyChildren(root); + root.apply({ type: 'canvas', children: data }, { mergeStrategy: 'replace' }); }; const destroyChildren = (parent) => { diff --git a/src/display/mixins/Base.js b/src/display/mixins/Base.js index d43eabc..54bce26 100644 --- a/src/display/mixins/Base.js +++ b/src/display/mixins/Base.js @@ -38,7 +38,7 @@ export const Base = (superClass) => { if (!this.localTransform || !this.visible) return; if (!this.localTransform.equals(this._lastLocalTransform)) { - this.store.viewport?.emit('object_transformed', this); + this.store?.viewport?.emit('object_transformed', this); this._lastLocalTransform.copyFrom(this.localTransform); } } @@ -81,13 +81,9 @@ export const Base = (superClass) => { if (isValidationError(nextProps)) throw nextProps; const actualChanges = diffReplace(this.props, nextProps) ?? {}; - if ( - options?.historyId && - Object.keys(actualChanges).length > 0 && - this.store.undoRedoManager - ) { + if (options?.historyId && Object.keys(actualChanges).length > 0) { const command = new UpdateCommand(this, changes, options); - this.store?.undoRedoManager.execute(command, options); + this.store.undoRedoManager.execute(command, options); return; } diff --git a/src/display/mixins/Childrenable.js b/src/display/mixins/Childrenable.js index a98e873..5964f88 100644 --- a/src/display/mixins/Childrenable.js +++ b/src/display/mixins/Childrenable.js @@ -13,25 +13,37 @@ export const Childrenable = (superClass) => { let { children: childrenChanges } = relevantChanges; const elements = [...this.children]; + const overlay = this.type === 'canvas' ? this.store?.overlay : null; + const overlayElements = overlay + ? [...overlay.children].filter((child) => child.type === 'relations') + : []; + + const attachChild = (child, useOverlay) => + useOverlay ? overlay.addChild(child) : this.addChild(child); + const detachChild = (child, useOverlay) => + useOverlay ? overlay.removeChild(child) : this.removeChild(child); + childrenChanges = validateAndPrepareChanges( - elements, + [...elements, ...overlayElements], childrenChanges, mapDataSchema, ); for (const childChange of childrenChanges) { - const idx = findIndexByPriority(elements, childChange); + const isOverlay = overlay && childChange.type === 'relations'; + const searchElements = isOverlay ? overlayElements : elements; + const idx = findIndexByPriority(searchElements, childChange); let element = null; if (idx !== -1) { - element = elements[idx]; - elements.splice(idx, 1); + element = searchElements[idx]; + searchElements.splice(idx, 1); if (options.mergeStrategy === 'replace') { - this.addChild(element); + attachChild(element, isOverlay); } } else { element = newElement(childChange.type, this.store); - this.addChild(element); + attachChild(element, isOverlay); } element.apply(childChange, options); } @@ -39,7 +51,12 @@ export const Childrenable = (superClass) => { if (options.mergeStrategy === 'replace') { elements.forEach((element) => { if (!element.type) return; // Don't remove children that are not managed by patchmap (e.g. raw PIXI objects) - this.removeChild(element); + detachChild(element, false); + element.destroy({ children: true }); + }); + overlayElements.forEach((element) => { + if (!element.type || !overlay) return; + detachChild(element, true); element.destroy({ children: true }); }); } diff --git a/src/display/mixins/Placementable.js b/src/display/mixins/Placementable.js index a5f6e86..2c2890d 100644 --- a/src/display/mixins/Placementable.js +++ b/src/display/mixins/Placementable.js @@ -1,5 +1,5 @@ import { UPDATE_STAGES } from './constants'; -import { getLayoutContext } from './utils'; +import { getLayoutContext, mapViewDirection } from './utils'; const KEYS = ['placement', 'margin']; @@ -21,9 +21,70 @@ export const Placementable = (superClass) => { ? { h: first, v: second } : DIRECTION_MAP[first]; - const x = getHorizontalPosition(this, directions.h, margin); - const y = getVerticalPosition(this, directions.v, margin); - this.position.set(x, y); + let layoutDirections = directions; + const layoutMargin = { ...margin }; + + const useViewPlacement = + (this.constructor.useViewPlacement === true || + this.useViewPlacement === true) && + this.store?.view; + if (useViewPlacement) { + const h = + directions.h && directions.h !== 'center' + ? mapViewDirection(this.store.view, directions.h) + : 'center'; + const v = + directions.v && directions.v !== 'center' + ? mapViewDirection(this.store.view, directions.v) + : 'center'; + + // 방향이 원래와 달라졌다면 마진도 스왑 + if (h !== directions.h) { + layoutMargin.left = margin.right; + layoutMargin.right = margin.left; + } + if (v !== directions.v) { + layoutMargin.top = margin.bottom; + layoutMargin.bottom = margin.top; + } + layoutDirections = { h, v }; + } + + const x = getHorizontalPosition(this, layoutDirections.h, layoutMargin); + const y = getVerticalPosition(this, layoutDirections.v, layoutMargin); + + const bounds = this.getLocalBounds(); + const pivot = this.pivot || { x: 0, y: 0 }; + const scale = this.scale || { x: 1, y: 1 }; + const angle = (this.angle || 0) * (Math.PI / 180); + + // 로컬 경계의 네 모서리를 구함 + const corners = [ + { x: bounds.x, y: bounds.y }, + { x: bounds.x + bounds.width, y: bounds.y }, + { x: bounds.x, y: bounds.y + bounds.height }, + { x: bounds.x + bounds.width, y: bounds.y + bounds.height }, + ]; + + const cos = Math.cos(angle); + const sin = Math.sin(angle); + + let visualLeftMin = Number.POSITIVE_INFINITY; + let visualTopMin = Number.POSITIVE_INFINITY; + + // 각 모서리를 피봇 기준으로 변환(Scale + Rotation)하여 부모 좌표계에서의 시각적 최소 지점을 찾음 + for (const corner of corners) { + const lx = (corner.x - pivot.x) * scale.x; + const ly = (corner.y - pivot.y) * scale.y; + + const rotatedX = lx * cos - ly * sin; + const rotatedY = lx * sin + ly * cos; + + if (rotatedX < visualLeftMin) visualLeftMin = rotatedX; + if (rotatedY < visualTopMin) visualTopMin = rotatedY; + } + + this.position.set(x - visualLeftMin, y - visualTopMin); } }; MixedClass.registerHandler( @@ -37,14 +98,16 @@ export const Placementable = (superClass) => { const getHorizontalPosition = (component, align, margin) => { const { parentWidth, contentWidth, parentPadding } = getLayoutContext(component); + const bounds = component.getLocalBounds(); + const componentWidth = bounds.width * Math.abs(component.scale.x); let result = null; if (align === 'left') { result = parentPadding.left + margin.left; } else if (align === 'right') { - result = parentWidth - component.width - margin.right - parentPadding.right; + result = parentWidth - componentWidth - margin.right - parentPadding.right; } else if (align === 'center') { - const marginWidth = component.width + margin.left + margin.right; + const marginWidth = componentWidth + margin.left + margin.right; const blockStartPosition = (contentWidth - marginWidth) / 2; result = parentPadding.left + blockStartPosition + margin.left; } @@ -54,15 +117,17 @@ const getHorizontalPosition = (component, align, margin) => { const getVerticalPosition = (component, align, margin) => { const { parentHeight, contentHeight, parentPadding } = getLayoutContext(component); + const bounds = component.getLocalBounds(); + const componentHeight = bounds.height * Math.abs(component.scale.y); let result = null; if (align === 'top') { result = parentPadding.top + margin.top; } else if (align === 'bottom') { result = - parentHeight - component.height - margin.bottom - parentPadding.bottom; + parentHeight - componentHeight - margin.bottom - parentPadding.bottom; } else if (align === 'center') { - const marginHeight = component.height + margin.top + margin.bottom; + const marginHeight = componentHeight + margin.top + margin.bottom; const blockStartPosition = (contentHeight - marginHeight) / 2; result = parentPadding.top + blockStartPosition + margin.top; } diff --git a/src/display/mixins/Placementable.test.js b/src/display/mixins/Placementable.test.js new file mode 100644 index 0000000..d8f45e8 --- /dev/null +++ b/src/display/mixins/Placementable.test.js @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Placementable } from './Placementable'; +import * as utils from './utils'; + +// Mock getLayoutContext to control parent size +vi.mock('./utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getLayoutContext: vi.fn(), + mapViewDirection: vi.fn((_view, dir) => dir), // default no-flip + }; +}); + +describe('Placementable', () => { + const MockBase = class { + static registerHandler = vi.fn(); + constructor() { + this.position = { + x: 0, + y: 0, + set: vi.fn((x, y) => { + this.position.x = x; + this.position.y = y; + }), + }; + this.pivot = { x: 0, y: 0 }; + this.scale = { x: 1, y: 1 }; + this.angle = 0; + this.props = { + placement: 'left-top', + margin: { left: 0, top: 0, right: 0, bottom: 0 }, + }; + } + getLocalBounds() { + return { x: 0, y: 0, width: 100, height: 50 }; + } + }; + + const TestComponent = Placementable(MockBase); + + it('should position at 0,0 when placement is left-top and no rotation/pivot', () => { + vi.mocked(utils.getLayoutContext).mockReturnValue({ + parentWidth: 500, + parentHeight: 500, + contentWidth: 500, + contentHeight: 500, + parentPadding: { left: 0, top: 0, right: 0, bottom: 0 }, + }); + + const comp = new TestComponent(); + comp._applyPlacement(comp.props); + + expect(comp.position.x).toBe(0); + expect(comp.position.y).toBe(0); + }); + + it('should compensate for pivot', () => { + vi.mocked(utils.getLayoutContext).mockReturnValue({ + parentWidth: 500, + parentHeight: 500, + contentWidth: 500, + contentHeight: 500, + parentPadding: { left: 0, top: 0, right: 0, bottom: 0 }, + }); + + const comp = new TestComponent(); + comp.pivot = { x: 50, y: 25 }; // Pivot at center + comp._applyPlacement(comp.props); + + // If target is 0,0 and min visual is -50, -25 (relative to pivot) + // position should be 0 - (-50) = 50, 0 - (-25) = 25 + expect(comp.position.x).toBe(50); + expect(comp.position.y).toBe(25); + }); + + it('should compensate for rotation (e.g. 180 deg)', () => { + const comp = new TestComponent(); + comp.pivot = { x: 50, y: 25 }; + comp.angle = 180; + + comp._applyPlacement(comp.props); + + // At 180deg, the corner (0,0) moves to (100, 50) relative to pivot 50,25? + // Wait. Corners rel to pivot: (-50, -25), (50, -25), (-50, 25), (50, 25) + // Rotated 180: (50, 25), (-50, 25), (50, -25), (-50, -25) + // Min X is -50, Min Y is -25. + // Wait, visual min should still be -50, -25 because it's a symmetric rectangle around center. + expect(comp.position.x).toBeCloseTo(50); + expect(comp.position.y).toBeCloseTo(25); + }); + + it('should handle complex 90deg rotation with off-center pivot', () => { + const comp = new TestComponent(); + comp.pivot = { x: 0, y: 0 }; // top-left pivot + comp.angle = 90; + + comp._applyPlacement(comp.props); + + // Bounds (0,0,100,50). Pivot 0,0. Angle 90. + // Rel to pivot: (0,0), (100,0), (0,50), (100,50) + // Rotated 90 (x'= -y, y'= x): (0,0), (0,100), (-50,0), (-50,100) + // Visual Min is (-50, 0). + // Target position is (0,0). + // result = (0 - (-50), 0 - 0) = (50, 0) + expect(comp.position.x).toBeCloseTo(50); + expect(comp.position.y).toBeCloseTo(0); + }); + + it('should handle scale and rotation together', () => { + const comp = new TestComponent(); + comp.scale = { x: -1, y: 1 }; // flipped + comp.angle = 0; + comp.pivot = { x: 50, y: 25 }; + + comp._applyPlacement(comp.props); + + // Bounds (0,0,100,50). Pivot 50,25. + // Rel to pivot: (-50,-25), (50,-25), (-50,25), (50,25) + // Apply Scale -1,1: (50,-25), (-50,-25), (50,25), (-50,25) + // Visual Min is still -50, -25. + // Position = 0 - (-50) = 50. + expect(comp.position.x).toBeCloseTo(50); + }); + + it('should swap margins when direction is flipped by view', () => { + // Force mapViewDirection to return 'right' for 'left' + vi.mocked(utils.mapViewDirection).mockReturnValue('right'); + vi.mocked(utils.getLayoutContext).mockReturnValue({ + parentWidth: 500, + parentHeight: 500, + contentWidth: 500, + contentHeight: 500, + parentPadding: { left: 0, top: 0, right: 0, bottom: 0 }, + }); + + const comp = new TestComponent(); + comp.props.placement = 'left-center'; + comp.props.margin = { left: 20, right: 100, top: 0, bottom: 0 }; + comp.useViewPlacement = true; + comp.store = { view: { angle: 180 } }; + + comp._applyPlacement(comp.props); + + // Direction became 'right' + // Margins should swap: left becomes 100, right becomes 20 + // getHorizontalPosition for 'right': + // result = parentWidth - componentWidth - margin.right - parentPadding.right + // result = 500 - 100 - 20 - 0 = 380 + // Since visual min is 0 (default pivot 0,0), position.x should be 380 + expect(comp.position.x).toBe(380); + }); +}); diff --git a/src/display/mixins/WorldTransformable.js b/src/display/mixins/WorldTransformable.js new file mode 100644 index 0000000..4e9cb35 --- /dev/null +++ b/src/display/mixins/WorldTransformable.js @@ -0,0 +1,25 @@ +import { applyWorldFlip } from '../utils/world-flip'; +import { applyWorldRotation } from '../utils/world-rotation'; +import { UPDATE_STAGES } from './constants'; + +export const WorldTransformable = (superClass) => { + const MixedClass = class extends superClass { + _applyWorldTransform() { + if (!this.store?.view) return; + + const options = this.constructor.worldRotationOptions || {}; + applyWorldRotation(this, this.store.view, options); + applyWorldFlip(this, this.store.view); + } + }; + + if (superClass.worldTransformKeys) { + MixedClass.registerHandler( + superClass.worldTransformKeys, + MixedClass.prototype._applyWorldTransform, + UPDATE_STAGES.WORLD_TRANSFORM, + ); + } + + return MixedClass; +}; diff --git a/src/display/mixins/constants.js b/src/display/mixins/constants.js index 5010569..257b3fc 100644 --- a/src/display/mixins/constants.js +++ b/src/display/mixins/constants.js @@ -4,9 +4,15 @@ export const UPDATE_STAGES = Object.freeze({ VISUAL: 20, ANIMATION: 25, SIZE: 30, + WORLD_TRANSFORM: 35, LAYOUT: 40, }); +export const ROTATION_THRESHOLD = { + MIN: 90, + MAX: 270, +}; + export const FONT_WEIGHT = { STRING: { 100: 'thin', diff --git a/src/display/mixins/utils.js b/src/display/mixins/utils.js index 6c2c5c7..50e248e 100644 --- a/src/display/mixins/utils.js +++ b/src/display/mixins/utils.js @@ -2,7 +2,7 @@ import gsap from 'gsap'; import { isValidationError } from 'zod-validation-error'; import { findIndexByPriority } from '../../utils/findIndexByPriority'; import { validate } from '../../utils/validator'; -import { ZERO_MARGIN } from './constants'; +import { ROTATION_THRESHOLD, ZERO_MARGIN } from './constants'; export const tweensOf = (object) => gsap.getTweensOf(object); @@ -131,14 +131,15 @@ export const getLayoutContext = (component) => { const parentWidth = parent.props.size.width; const parentHeight = parent.props.size.height; + const effectivePadding = parentPadding; const contentWidth = Math.max( 0, - parentWidth - parentPadding.left - parentPadding.right, + parentWidth - effectivePadding.left - effectivePadding.right, ); const contentHeight = Math.max( 0, - parentHeight - parentPadding.top - parentPadding.bottom, + parentHeight - effectivePadding.top - effectivePadding.bottom, ); return { @@ -146,10 +147,43 @@ export const getLayoutContext = (component) => { parentHeight, contentWidth, contentHeight, - parentPadding, + parentPadding: effectivePadding, }; }; +export const mapViewDirection = (view, direction, options = {}) => { + if (!view) return direction; + const viewAngle = (((view.angle ?? 0) % 360) + 360) % 360; + + let hFlipped = false; + let vFlipped = false; + + // 90도~270도 구간 (Readable 모드) 보정 + if ( + viewAngle >= ROTATION_THRESHOLD.MIN && + viewAngle < ROTATION_THRESHOLD.MAX + ) { + hFlipped = !hFlipped; + vFlipped = !vFlipped; + } + + // 뷰 자체의 플립 상태 반영 + if (view.flipX) hFlipped = !hFlipped; + if (view.flipY) vFlipped = !vFlipped; + + if (direction === 'left' || direction === 'right') { + if (hFlipped) return direction === 'left' ? 'right' : 'left'; + return direction; + } + + if (direction === 'top' || direction === 'bottom') { + if (vFlipped) return direction === 'top' ? 'bottom' : 'top'; + return direction; + } + + return direction; +}; + export const splitText = (text, split) => { if (!split || split === 0) { return text; diff --git a/src/display/mixins/utils.test.js b/src/display/mixins/utils.test.js index 173d336..3b457a7 100644 --- a/src/display/mixins/utils.test.js +++ b/src/display/mixins/utils.test.js @@ -1,5 +1,66 @@ import { describe, expect, it } from 'vitest'; -import { calcSize, parseCalcExpression } from './utils'; +import { ROTATION_THRESHOLD } from './constants'; +import { calcSize, mapViewDirection, parseCalcExpression } from './utils'; + +describe('mapViewDirection', () => { + it('should return original direction if view is not provided', () => { + expect(mapViewDirection(null, 'left')).toBe('left'); + }); + + it('should not flip when angle is 0 and no flip is active', () => { + const view = { angle: 0, flipX: false, flipY: false }; + expect(mapViewDirection(view, 'left')).toBe('left'); + expect(mapViewDirection(view, 'right')).toBe('right'); + expect(mapViewDirection(view, 'top')).toBe('top'); + expect(mapViewDirection(view, 'bottom')).toBe('bottom'); + }); + + it('should flip both axes when angle is within threshold (e.g., 180)', () => { + const view = { angle: 180, flipX: false, flipY: false }; + expect(mapViewDirection(view, 'left')).toBe('right'); + expect(mapViewDirection(view, 'right')).toBe('left'); + expect(mapViewDirection(view, 'top')).toBe('bottom'); + expect(mapViewDirection(view, 'bottom')).toBe('top'); + }); + + it('should respect flipX', () => { + const view = { angle: 0, flipX: true, flipY: false }; + expect(mapViewDirection(view, 'left')).toBe('right'); + expect(mapViewDirection(view, 'right')).toBe('left'); + expect(mapViewDirection(view, 'top')).toBe('top'); + }); + + it('should respect flipY', () => { + const view = { angle: 0, flipX: false, flipY: true }; + expect(mapViewDirection(view, 'left')).toBe('left'); + expect(mapViewDirection(view, 'top')).toBe('bottom'); + expect(mapViewDirection(view, 'bottom')).toBe('top'); + }); + + it('should cancel out flipX and angle flip (180 deg)', () => { + const view = { angle: 180, flipX: true, flipY: false }; + // angle 180 makes hFlipped=true, flipX=true makes hFlipped=false (toggle) + expect(mapViewDirection(view, 'left')).toBe('left'); + // angle 180 makes vFlipped=true, flipY=false keeps vFlipped=true + expect(mapViewDirection(view, 'top')).toBe('bottom'); + }); + + it('should handle boundary angles correctly', () => { + const minView = { + angle: ROTATION_THRESHOLD.MIN, + flipX: false, + flipY: false, + }; + expect(mapViewDirection(minView, 'left')).toBe('right'); + + const justBeforeMin = { + angle: ROTATION_THRESHOLD.MIN - 1, + flipX: false, + flipY: false, + }; + expect(mapViewDirection(justBeforeMin, 'left')).toBe('left'); + }); +}); describe('parseCalcExpression', () => { const parentDimension = 200; @@ -53,12 +114,12 @@ describe('parseCalcExpression', () => { }, ]; - it.each(testCases)( - 'should correctly parse $name', - ({ expression, expected }) => { - expect(parseCalcExpression(expression, parentDimension)).toBe(expected); - }, - ); + it.each(testCases)('should correctly parse $name', ({ + expression, + expected, + }) => { + expect(parseCalcExpression(expression, parentDimension)).toBe(expected); + }); }); describe('calcSize', () => { @@ -182,15 +243,17 @@ describe('calcSize', () => { }, ]; - it.each(testCases)( - 'should calculate size correctly $name', - ({ props, respectsPadding, parent = mockParent, expected }) => { - const mockComponent = { - constructor: { respectsPadding }, - parent, - }; - const result = calcSize(mockComponent, props); - expect(result).toEqual(expected); - }, - ); + it.each(testCases)('should calculate size correctly $name', ({ + props, + respectsPadding, + parent = mockParent, + expected, + }) => { + const mockComponent = { + constructor: { respectsPadding }, + parent, + }; + const result = calcSize(mockComponent, props); + expect(result).toEqual(expected); + }); }); diff --git a/src/display/utils/world-flip.js b/src/display/utils/world-flip.js new file mode 100644 index 0000000..deee865 --- /dev/null +++ b/src/display/utils/world-flip.js @@ -0,0 +1,21 @@ +export const applyWorldFlip = (displayObject, view) => { + if (!displayObject || !view) return; + + const prevState = displayObject._flipState ?? { x: false, y: false }; + const nextState = { x: !!view.flipX, y: !!view.flipY }; + if (prevState.x === nextState.x && prevState.y === nextState.y) { + return; + } + const absScaleX = Math.abs(displayObject.scale?.x ?? 1); + const absScaleY = Math.abs(displayObject.scale?.y ?? 1); + + displayObject.scale.set( + absScaleX * (nextState.x ? -1 : 1), + absScaleY * (nextState.y ? -1 : 1), + ); + + displayObject._flipState = { + x: nextState.x, + y: nextState.y, + }; +}; diff --git a/src/display/utils/world-rotation.js b/src/display/utils/world-rotation.js new file mode 100644 index 0000000..47dfa67 --- /dev/null +++ b/src/display/utils/world-rotation.js @@ -0,0 +1,76 @@ +import { ROTATION_THRESHOLD } from '../mixins/constants'; + +const ROTATION_STATE = Symbol('rotationState'); + +export const applyWorldRotation = (displayObject, view, options = {}) => { + if (!displayObject || !view) return; + + const viewAngle = Number(view.angle ?? 0); + if (Number.isNaN(viewAngle)) return; + + const prevState = displayObject[ROTATION_STATE] ?? { + compensation: 0, + basePivotX: displayObject.pivot?.x ?? 0, + basePivotY: displayObject.pivot?.y ?? 0, + }; + const baseAngle = (displayObject.angle ?? 0) - (prevState.compensation ?? 0); + + let compensation = 0; + if (options.mode === 'readable') { + const normalized = ((viewAngle % 360) + 360) % 360; + if ( + normalized >= ROTATION_THRESHOLD.MIN && + normalized < ROTATION_THRESHOLD.MAX + ) { + compensation = 180; + } + } else { + const isFlipped = view.flipX !== view.flipY; + compensation = isFlipped ? viewAngle : -viewAngle; + } + + const hadComp = (prevState.compensation ?? 0) !== 0; + const needsComp = compensation !== 0; + if (!hadComp && !needsComp) { + displayObject[ROTATION_STATE] = { + compensation: 0, + viewAngle, + basePivotX: prevState.basePivotX ?? displayObject.pivot?.x ?? 0, + basePivotY: prevState.basePivotY ?? displayObject.pivot?.y ?? 0, + }; + return; + } + + const basePivotX = hadComp + ? (prevState.basePivotX ?? 0) + : (displayObject.pivot?.x ?? 0); + const basePivotY = hadComp + ? (prevState.basePivotY ?? 0) + : (displayObject.pivot?.y ?? 0); + + const bounds = displayObject.getLocalBounds?.() ?? { + x: 0, + y: 0, + width: displayObject.width ?? 0, + height: displayObject.height ?? 0, + }; + const pivotX = bounds.x + bounds.width / 2; + const pivotY = bounds.y + bounds.height / 2; + + if (needsComp) { + displayObject.pivot.set(pivotX, pivotY); + } else { + displayObject.pivot.set(basePivotX, basePivotY); + } + displayObject.angle = baseAngle + compensation; + + displayObject[ROTATION_STATE] = { + baseAngle, + compensation, + viewAngle, + pivotX, + pivotY, + basePivotX, + basePivotY, + }; +}; diff --git a/src/display/utils/world-transform.test.js b/src/display/utils/world-transform.test.js new file mode 100644 index 0000000..e5cc0f7 --- /dev/null +++ b/src/display/utils/world-transform.test.js @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ROTATION_THRESHOLD } from '../mixins/constants'; +import { applyWorldFlip } from './world-flip'; +import { applyWorldRotation } from './world-rotation'; + +describe('applyWorldRotation', () => { + const createMockDisplayObject = (angle = 0, pivotX = 0, pivotY = 0) => ({ + angle, + pivot: { + x: pivotX, + y: pivotY, + set: vi.fn(function (x, y) { + this.x = x; + this.y = y; + }), + }, + getLocalBounds: vi.fn(() => ({ x: 0, y: 0, width: 100, height: 50 })), + }); + + it('should not change angle when compensation is 0 (readable mode)', () => { + const obj = createMockDisplayObject(10); + const view = { angle: 0 }; + applyWorldRotation(obj, view, { mode: 'readable' }); + expect(obj.angle).toBe(10); + expect(obj.pivot.set).not.toHaveBeenCalled(); + }); + + it('should compensate 180 degrees in readable mode when view is upside down', () => { + const obj = createMockDisplayObject(0); // Initial angle 0 + const view = { angle: 180 }; + applyWorldRotation(obj, view, { mode: 'readable' }); + + expect(obj.angle).toBe(180); // 0 + 180 + // Pivot should be set to center of bounds (100/2, 50/2) + expect(obj.pivot.set).toHaveBeenCalledWith(50, 25); + }); + + it('should use ROTATION_THRESHOLD for readable mode boundary', () => { + const obj = createMockDisplayObject(0); + const viewAfterThreshold = { angle: ROTATION_THRESHOLD.MIN }; + applyWorldRotation(obj, viewAfterThreshold, { mode: 'readable' }); + expect(obj.angle).toBe(180); + + const objUnder = createMockDisplayObject(0); + const viewUnderThreshold = { angle: ROTATION_THRESHOLD.MIN - 1 }; + applyWorldRotation(objUnder, viewUnderThreshold, { mode: 'readable' }); + expect(objUnder.angle).toBe(0); + }); + + it('should restore pivot when moving out of threshold', () => { + const obj = createMockDisplayObject(0, 10, 10); + + // 1. Move into threshold + applyWorldRotation(obj, { angle: 180 }, { mode: 'readable' }); + expect(obj.pivot.x).toBe(50); + + // 2. Move out of threshold + applyWorldRotation(obj, { angle: 0 }, { mode: 'readable' }); + expect(obj.pivot.x).toBe(10); // Should restore original base pivot + }); +}); + +describe('applyWorldFlip', () => { + const createMockDisplayObject = (scaleX = 1, scaleY = 1) => ({ + scale: { + x: scaleX, + y: scaleY, + set: vi.fn(function (x, y) { + this.x = x; + this.y = y; + }), + }, + }); + + it('should flip scale.x when view.flipX is true', () => { + const obj = createMockDisplayObject(1, 1); + const view = { flipX: true, flipY: false }; + applyWorldFlip(obj, view); + expect(obj.scale.set).toHaveBeenCalledWith(-1, 1); + }); + + it('should not flip if view state has not changed', () => { + const obj = createMockDisplayObject(1, 1); + const view = { flipX: true, flipY: false }; + + applyWorldFlip(obj, view); // First call + expect(obj.scale.set).toHaveBeenCalledTimes(1); + + applyWorldFlip(obj, view); // Second call with same state + expect(obj.scale.set).toHaveBeenCalledTimes(1); + }); + + it('should maintain original scale magnitude', () => { + const obj = createMockDisplayObject(2, 0.5); + const view = { flipX: true, flipY: true }; + applyWorldFlip(obj, view); + expect(obj.scale.set).toHaveBeenCalledWith(-2, -0.5); + }); +}); diff --git a/src/display/view-transform/FlipController.js b/src/display/view-transform/FlipController.js new file mode 100644 index 0000000..804ece4 --- /dev/null +++ b/src/display/view-transform/FlipController.js @@ -0,0 +1,41 @@ +export class FlipController { + constructor(owner) { + this._owner = owner; + } + + get x() { + return this._owner.viewState.flipX; + } + + set x(value) { + if (typeof value === 'boolean') { + this._owner.setFlip({ x: value }); + } + } + + get y() { + return this._owner.viewState.flipY; + } + + set y(value) { + if (typeof value === 'boolean') { + this._owner.setFlip({ y: value }); + } + } + + set({ x, y } = {}) { + return this._owner.setFlip({ x, y }); + } + + toggleX() { + return this._owner.setFlip({ x: !this._owner.viewState.flipX }); + } + + toggleY() { + return this._owner.setFlip({ y: !this._owner.viewState.flipY }); + } + + reset() { + return this._owner.setFlip({ x: false, y: false }); + } +} diff --git a/src/display/view-transform/RotationController.js b/src/display/view-transform/RotationController.js new file mode 100644 index 0000000..bd9a6a3 --- /dev/null +++ b/src/display/view-transform/RotationController.js @@ -0,0 +1,25 @@ +export class RotationController { + constructor(owner) { + this._owner = owner; + } + + get value() { + return this._owner.rotationValue; + } + + set value(value) { + this._owner.setRotation(value); + } + + set(value) { + return this._owner.setRotation(value); + } + + rotateBy(delta) { + return this._owner.setRotation(this._owner.rotationValue + Number(delta)); + } + + reset() { + return this._owner.setRotation(0); + } +} diff --git a/src/display/view-transform/ViewTransform.js b/src/display/view-transform/ViewTransform.js new file mode 100644 index 0000000..0b417d6 --- /dev/null +++ b/src/display/view-transform/ViewTransform.js @@ -0,0 +1,92 @@ +import { + getViewportWorldCenter, + getWorldLocalCenter, +} from '../../utils/viewport-rotation'; +import { FlipController } from './FlipController'; +import { RotationController } from './RotationController'; + +const DEFAULT_VIEW_STATE = { flipX: false, flipY: false, angle: 0 }; + +export default class ViewTransform { + constructor({ + viewport = null, + world = null, + viewState, + onRotate, + onFlip, + } = {}) { + this._viewport = viewport; + this._world = world; + this._viewState = viewState ?? { ...DEFAULT_VIEW_STATE }; + this._flipController = new FlipController(this); + this._rotationController = new RotationController(this); + this._onRotate = typeof onRotate === 'function' ? onRotate : null; + this._onFlip = typeof onFlip === 'function' ? onFlip : null; + } + + get viewState() { + return this._viewState; + } + + attach({ viewport, world } = {}) { + if (viewport) this._viewport = viewport; + if (world) this._world = world; + this.applyWorldTransform(); + } + + detach() { + this._viewport = null; + this._world = null; + } + + get rotation() { + return this._rotationController; + } + + get flip() { + return this._flipController; + } + + get rotationValue() { + return this._world?.angle ?? this._viewState.angle ?? 0; + } + + setRotation(angle) { + const nextAngle = Number(angle); + if (Number.isNaN(nextAngle)) return null; + this._viewState.angle = nextAngle; + if (!this._viewport || !this._world) return nextAngle; + this.applyWorldTransform({ angle: nextAngle }); + this._onRotate?.(nextAngle); + return nextAngle; + } + + setFlip({ x, y } = {}) { + if (typeof x === 'boolean') { + this._viewState.flipX = x; + } + if (typeof y === 'boolean') { + this._viewState.flipY = y; + } + if (!this._viewport || !this._world) { + return { x: this._viewState.flipX, y: this._viewState.flipY }; + } + this.applyWorldTransform(); + this._onFlip?.({ x: this._viewState.flipX, y: this._viewState.flipY }); + return { x: this._viewState.flipX, y: this._viewState.flipY }; + } + + applyWorldTransform({ angle = this._viewState.angle ?? 0 } = {}) { + if (!this._viewport || !this._world) return; + this._viewState.angle = angle; + const center = getViewportWorldCenter(this._viewport); + const localCenter = getWorldLocalCenter(this._viewport, this._world); + this._world.pivot.set(localCenter.x, localCenter.y); + this._world.position.set(center.x, center.y); + this._world.angle = angle; + this._world.scale.set( + this._viewState.flipX ? -1 : 1, + this._viewState.flipY ? -1 : 1, + ); + } +} diff --git a/src/events/focus-fit.js b/src/events/focus-fit.js index 77133be..bdb91fa 100644 --- a/src/events/focus-fit.js +++ b/src/events/focus-fit.js @@ -2,10 +2,13 @@ import { isValidationError } from 'zod-validation-error'; import { calcGroupOrientedBounds } from '../utils/bounds'; import { selector } from '../utils/selector/selector'; import { validate } from '../utils/validator'; +import { moveViewportCenter } from '../utils/viewport-rotation'; import { focusFitIdsSchema } from './schema'; -export const focus = (viewport, ids) => centerViewport(viewport, ids, false); -export const fit = (viewport, ids) => centerViewport(viewport, ids, true); +export const focus = (viewport, ids, viewAngle) => + centerViewport(viewport, ids, false, viewAngle); +export const fit = (viewport, ids, viewAngle) => + centerViewport(viewport, ids, true, viewAngle); /** * Centers and optionally fits the viewport to given object IDs. @@ -14,20 +17,25 @@ export const fit = (viewport, ids) => centerViewport(viewport, ids, true); * @param {boolean} shouldFit - Whether to fit the viewport to the objects' bounds. * @returns {void|null} Returns null if no objects found. */ -const centerViewport = (viewport, ids, shouldFit = false) => { +const centerViewport = (viewport, ids, shouldFit = false, viewAngle) => { checkValidate(ids); const objects = getObjectsById(viewport, ids); if (!objects.length) return null; const bounds = calcGroupOrientedBounds(objects); const center = viewport.toLocal(bounds.center); if (bounds) { - viewport.moveCenter(center.x, center.y); + moveViewportCenter(viewport, center, viewAngle); if (shouldFit) { - viewport.fit( - true, - bounds.innerBounds.width / viewport.scale.x, - bounds.innerBounds.height / viewport.scale.y, + const width = bounds.innerBounds.width / viewport.scale.x; + const height = bounds.innerBounds.height / viewport.scale.y; + const scale = Math.min( + viewport.screenWidth / width, + viewport.screenHeight / height, ); + viewport.scale.set(scale); + const clampZoom = viewport.plugins?.get?.('clamp-zoom', true); + clampZoom?.clamp?.(); + moveViewportCenter(viewport, center, viewAngle); } } }; @@ -46,7 +54,9 @@ const getObjectsById = (viewport, ids) => { viewport, '$..children[?(@.type != null && @.parent.type !== "item" && @.parent.type !== "relations")]', ).reduce((acc, curr) => { - acc[curr.id] = curr; + if (curr.id) { + acc[curr.id] = curr; + } return acc; }, {}); return idsArr.flatMap((i) => objs[i]).filter((obj) => obj); diff --git a/src/patchmap.js b/src/patchmap.js index e8fdef2..7462bec 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -1,10 +1,16 @@ import gsap from 'gsap'; -import { Application, UPDATE_PRIORITY } from 'pixi.js'; +import { Application, Container, UPDATE_PRIORITY } from 'pixi.js'; import { isValidationError } from 'zod-validation-error'; import { UndoRedoManager } from './command/UndoRedoManager'; +import './display/components/registry'; import { draw } from './display/draw'; +import './display/elements/registry'; import { update } from './display/update'; +import ViewTransform from './display/view-transform/ViewTransform'; +import World from './display/World'; import { fit as fitViewport, focus } from './events/focus-fit'; +import StateManager from './events/StateManager'; +import SelectionState from './events/states/SelectionState'; import { initApp, initAsset, @@ -12,17 +18,13 @@ import { initResizeObserver, initViewport, } from './init'; +import Transformer from './transformer/Transformer'; import { convertLegacyData } from './utils/convert'; import { event } from './utils/event/canvas'; +import { WildcardEventEmitter } from './utils/event/WildcardEventEmitter'; import { selector } from './utils/selector/selector'; import { themeStore } from './utils/theme'; import { validateMapData } from './utils/validator'; -import './display/elements/registry'; -import './display/components/registry'; -import StateManager from './events/StateManager'; -import SelectionState from './events/states/SelectionState'; -import Transformer from './transformer/Transformer'; -import { WildcardEventEmitter } from './utils/event/WildcardEventEmitter'; class Patchmap extends WildcardEventEmitter { _app = null; @@ -34,6 +36,9 @@ class Patchmap extends WildcardEventEmitter { _animationContext = gsap.context(() => {}); _transformer = null; _stateManager = null; + _world = null; + _overlay = null; + _viewTransform = this._createViewTransform(); get app() { return this._app; @@ -47,6 +52,14 @@ class Patchmap extends WildcardEventEmitter { this._viewport = value; } + get world() { + return this._world; + } + + get overlay() { + return this._overlay; + } + get theme() { return this._theme.get(); } @@ -133,7 +146,16 @@ class Patchmap extends WildcardEventEmitter { theme: this.theme, animationContext: this.animationContext, }; + store.view = this._viewTransform.viewState; this.viewport = initViewport(this.app, viewportOptions, store); + this._overlay = new Container(); + Object.assign(this._overlay, { type: 'overlay' }); + store.overlay = this._overlay; + this._world = new World({ store }); + store.world = this._world; + this.viewport.addChild(this._world); + this.viewport.addChild(this._overlay); + this._viewTransform.attach({ viewport: this.viewport, world: this._world }); await initAsset(assetsOptions); initCanvas(element, this.app); @@ -171,6 +193,9 @@ class Patchmap extends WildcardEventEmitter { this._animationContext = gsap.context(() => {}); this._transformer = null; this._stateManager = null; + this._world = null; + this._overlay = null; + this._viewTransform = this._createViewTransform(); this.emit('patchmap:destroyed', { target: this }); this.removeAllListeners(); } @@ -184,6 +209,8 @@ class Patchmap extends WildcardEventEmitter { const store = { viewport: this.viewport, + world: this.world, + overlay: this.overlay, undoRedoManager: this.undoRedoManager, theme: this.theme, animationContext: this.animationContext, @@ -227,16 +254,33 @@ class Patchmap extends WildcardEventEmitter { } focus(ids) { - focus(this.viewport, ids); + focus(this.viewport, ids, this._viewTransform?.viewState?.angle); } fit(ids) { - fitViewport(this.viewport, ids); + fitViewport(this.viewport, ids, this._viewTransform?.viewState?.angle); + } + + get rotation() { + return this._viewTransform.rotation; + } + + get flip() { + return this._viewTransform.flip; } selector(path, opts) { return selector(this.viewport, path, opts); } + + _createViewTransform() { + return new ViewTransform({ + onRotate: (angle) => + this.emit('patchmap:rotated', { angle, target: this }), + onFlip: (flip) => + this.emit('patchmap:flipped', { ...flip, target: this }), + }); + } } export { Patchmap }; diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js index 308b60e..eb1307c 100644 --- a/src/tests/render/patchmap.test.js +++ b/src/tests/render/patchmap.test.js @@ -75,7 +75,15 @@ describe('patchmap test', () => { it('draw', () => { const patchmap = getPatchmap(); patchmap.draw(sampleData); - expect(patchmap.viewport.children.length).toBe(2); + expect(patchmap.world).toBeDefined(); + expect(patchmap.overlay).toBeDefined(); + expect(patchmap.viewport.children).toContain(patchmap.world); + expect(patchmap.viewport.children).toContain(patchmap.overlay); + expect(patchmap.world.children.length).toBe(1); + + const relations = patchmap.selector('$..[?(@.id=="relations-1")]')[0]; + expect(relations).toBeDefined(); + expect(relations.parent).toBe(patchmap.overlay); const group = patchmap.selector('$..[?(@.id=="group-1")]')[0]; expect(group).toBeDefined(); @@ -329,31 +337,31 @@ describe('patchmap test', () => { position: { x: 220, y: 280 }, expectedId: 'relations-1', }, - ])( - 'should select the correct element when $case', - async ({ position, expectedId }) => { - const viewport = patchmap.viewport; - transform(viewport); - await vi.advanceTimersByTimeAsync(100); - - viewport.emit('click', { - global: viewport.toGlobal(position), - stopPropagation: () => {}, - }); - - expect(onClick).toHaveBeenCalledTimes(1); - const receivedElement = onClick.mock.calls[0][0]; - - if (expectedId === null) { - expect(receivedElement).toBeNull(); - } else { - expect(receivedElement).toBeDefined(); - expect(receivedElement.id).toBe(expectedId); - } - - expect(onDrag).not.toHaveBeenCalled(); - }, - ); + ])('should select the correct element when $case', async ({ + position, + expectedId, + }) => { + const viewport = patchmap.viewport; + transform(viewport); + await vi.advanceTimersByTimeAsync(100); + + viewport.emit('click', { + global: viewport.toGlobal(position), + stopPropagation: () => {}, + }); + + expect(onClick).toHaveBeenCalledTimes(1); + const receivedElement = onClick.mock.calls[0][0]; + + if (expectedId === null) { + expect(receivedElement).toBeNull(); + } else { + expect(receivedElement).toBeDefined(); + expect(receivedElement.id).toBe(expectedId); + } + + expect(onDrag).not.toHaveBeenCalled(); + }); }); }); @@ -394,39 +402,38 @@ describe('patchmap test', () => { clickPosition: { x: 210, y: 310 }, expectedId: 'group-2', }, - ])( - 'should return the correct object when selectUnit is "$selectUnit"', - async ({ selectUnit, clickPosition, expectedId }) => { - patchmap.draw([ - { type: 'group', id: 'group-2', children: sampleData }, - ]); - await vi.advanceTimersByTimeAsync(100); + ])('should return the correct object when selectUnit is "$selectUnit"', async ({ + selectUnit, + clickPosition, + expectedId, + }) => { + patchmap.draw([{ type: 'group', id: 'group-2', children: sampleData }]); + await vi.advanceTimersByTimeAsync(100); - const onClick = vi.fn(); + const onClick = vi.fn(); - patchmap.stateManager.setState('selection', { - enabled: true, - selectUnit: selectUnit, - onClick: onClick, - }); + patchmap.stateManager.setState('selection', { + enabled: true, + selectUnit: selectUnit, + onClick: onClick, + }); - const viewport = patchmap.viewport; - viewport.emit('click', { - global: viewport.toGlobal(clickPosition), - stopPropagation: () => {}, - }); + const viewport = patchmap.viewport; + viewport.emit('click', { + global: viewport.toGlobal(clickPosition), + stopPropagation: () => {}, + }); - expect(onClick).toHaveBeenCalledTimes(1); - const selectedObject = onClick.mock.calls[0][0]; + expect(onClick).toHaveBeenCalledTimes(1); + const selectedObject = onClick.mock.calls[0][0]; - if (expectedId) { - expect(selectedObject).toBeDefined(); - expect(selectedObject.id).toBe(expectedId); - } else { - expect(selectedObject).toBeNull(); - } - }, - ); + if (expectedId) { + expect(selectedObject).toBeDefined(); + expect(selectedObject.id).toBe(expectedId); + } else { + expect(selectedObject).toBeNull(); + } + }); }); }); }); diff --git a/src/tests/render/view-transform.test.js b/src/tests/render/view-transform.test.js new file mode 100644 index 0000000..c3fdfe4 --- /dev/null +++ b/src/tests/render/view-transform.test.js @@ -0,0 +1,328 @@ +import gsap from 'gsap'; +import { Point } from 'pixi.js'; +import { describe, expect, it } from 'vitest'; +import { setupPatchmapTests } from './patchmap.setup'; + +describe('View Transform Tests', () => { + const { getPatchmap } = setupPatchmapTests(); + + const createItemWithComponents = (components) => ({ + type: 'item', + id: 'item-transform', + size: { width: 200, height: 100 }, + components, + }); + + const getById = (patchmap, id) => + patchmap.selector(`$..[?(@.id=="${id}")]`)[0]; + + const tempWorldPoint = new Point(); + const tempParentPoint = new Point(); + + const getBoundsInParent = (component) => { + const bounds = component.getBounds(); + if (!component.parent) { + return { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }; + } + const corners = [ + { x: bounds.x, y: bounds.y }, + { x: bounds.x + bounds.width, y: bounds.y }, + { x: bounds.x, y: bounds.y + bounds.height }, + { x: bounds.x + bounds.width, y: bounds.y + bounds.height }, + ]; + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const corner of corners) { + tempWorldPoint.set(corner.x, corner.y); + component.parent.toLocal(tempWorldPoint, undefined, tempParentPoint); + minX = Math.min(minX, tempParentPoint.x); + minY = Math.min(minY, tempParentPoint.y); + maxX = Math.max(maxX, tempParentPoint.x); + maxY = Math.max(maxY, tempParentPoint.y); + } + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; + }; + + it('should apply rotation and flip via controllers', () => { + const patchmap = getPatchmap(); + patchmap.draw([ + createItemWithComponents([ + { + type: 'text', + id: 'text-basic', + text: 'Hello', + style: { fontSize: 20, fill: 'black' }, + }, + ]), + ]); + gsap.exportRoot().totalProgress(1); + + patchmap.rotation.set(90); + expect(patchmap.world.angle).toBe(90); + expect(patchmap.rotation.value).toBe(90); + + patchmap.rotation.rotateBy(90); + expect(patchmap.world.angle).toBe(180); + + patchmap.rotation.reset(); + expect(patchmap.world.angle).toBe(0); + + patchmap.flip.toggleX(); + expect(patchmap.world.scale.x).toBe(-1); + expect(patchmap.flip.x).toBe(true); + + patchmap.flip.toggleY(); + expect(patchmap.world.scale.y).toBe(-1); + expect(patchmap.flip.y).toBe(true); + + patchmap.flip.reset(); + expect(patchmap.world.scale.x).toBe(1); + expect(patchmap.world.scale.y).toBe(1); + }); + + it('should sync text and icon orientation on rotation and flip', () => { + const patchmap = getPatchmap(); + patchmap.draw([ + createItemWithComponents([ + { + type: 'text', + id: 'text-sync', + text: 'Sync', + style: { fontSize: 20, fill: 'black' }, + }, + { + type: 'icon', + id: 'icon-sync', + source: 'device', + size: 20, + }, + ]), + ]); + gsap.exportRoot().totalProgress(1); + + const text = getById(patchmap, 'text-sync'); + const icon = getById(patchmap, 'icon-sync'); + + patchmap.rotation.set(90); + patchmap.flip.set({ x: true, y: true }); + patchmap.viewport.emit('object_transformed', patchmap.world); + + expect(text.angle).toBe(-90); + expect(icon.angle).toBe(-90); + expect(text.scale.x).toBeLessThan(0); + expect(text.scale.y).toBeLessThan(0); + expect(icon.scale.x).toBeLessThan(0); + expect(icon.scale.y).toBeLessThan(0); + + patchmap.rotation.reset(); + patchmap.flip.reset(); + patchmap.viewport.emit('object_transformed', patchmap.world); + + expect(text.angle).toBe(0); + expect(icon.angle).toBe(0); + expect(text.scale.x).toBeGreaterThan(0); + expect(text.scale.y).toBeGreaterThan(0); + expect(icon.scale.x).toBeGreaterThan(0); + expect(icon.scale.y).toBeGreaterThan(0); + }); + + const placements = [ + 'left-top', + 'center', + 'right-bottom', + 'right-top', + 'left-bottom', + 'top', + 'bottom', + 'left', + 'right', + ]; + + it.each( + placements, + )('should keep text placement stable after rotation/flip (%s)', (placement) => { + const patchmap = getPatchmap(); + patchmap.draw([ + createItemWithComponents([ + { + type: 'text', + id: 'text-placement', + text: 'Place', + placement, + style: { fontSize: 20, fill: 'black' }, + }, + ]), + ]); + gsap.exportRoot().totalProgress(1); + + const text = getById(patchmap, 'text-placement'); + const initialBounds = getBoundsInParent(text); + + patchmap.rotation.set(90); + patchmap.flip.set({ x: true, y: true }); + patchmap.viewport.emit('object_transformed', patchmap.world); + + const rotatedBounds = getBoundsInParent(text); + expect(rotatedBounds.x).toBeCloseTo(initialBounds.x); + expect(rotatedBounds.y).toBeCloseTo(initialBounds.y); + }); + + it.each( + placements, + )('should keep icon placement stable after rotation/flip (%s)', (placement) => { + const patchmap = getPatchmap(); + patchmap.draw([ + createItemWithComponents([ + { + type: 'icon', + id: 'icon-placement', + source: 'device', + size: 20, + placement, + }, + ]), + ]); + gsap.exportRoot().totalProgress(1); + + const icon = getById(patchmap, 'icon-placement'); + const initialBounds = getBoundsInParent(icon); + + patchmap.rotation.set(90); + patchmap.flip.set({ x: true, y: true }); + patchmap.viewport.emit('object_transformed', patchmap.world); + + const rotatedBounds = getBoundsInParent(icon); + expect(rotatedBounds.x).toBeCloseTo(initialBounds.x); + expect(rotatedBounds.y).toBeCloseTo(initialBounds.y); + }); + + it('should keep icon bounds stable on flipX/flipY', () => { + const patchmap = getPatchmap(); + patchmap.draw([ + createItemWithComponents([ + { + type: 'icon', + id: 'icon-bounds', + source: 'device', + size: 30, + placement: 'left-top', + }, + ]), + ]); + gsap.exportRoot().totalProgress(1); + + const icon = getById(patchmap, 'icon-bounds'); + const before = getBoundsInParent(icon); + + patchmap.flip.set({ x: true }); + patchmap.viewport.emit('object_transformed', patchmap.world); + const afterFlipX = getBoundsInParent(icon); + expect(afterFlipX.x).toBeCloseTo(before.x); + expect(afterFlipX.y).toBeCloseTo(before.y); + + patchmap.flip.set({ x: true, y: true }); + patchmap.viewport.emit('object_transformed', patchmap.world); + const afterFlipY = getBoundsInParent(icon); + expect(afterFlipY.x).toBeCloseTo(before.x); + expect(afterFlipY.y).toBeCloseTo(before.y); + }); + + it('should keep text bounds stable on flipX/flipY', () => { + const patchmap = getPatchmap(); + patchmap.draw([ + createItemWithComponents([ + { + type: 'text', + id: 'text-bounds', + text: 'Flip', + style: { fontSize: 20, fill: 'black' }, + placement: 'left-top', + }, + ]), + ]); + gsap.exportRoot().totalProgress(1); + + const text = getById(patchmap, 'text-bounds'); + const before = getBoundsInParent(text); + + patchmap.flip.set({ x: true }); + patchmap.viewport.emit('object_transformed', patchmap.world); + const afterFlipX = getBoundsInParent(text); + expect(afterFlipX.x).toBeCloseTo(before.x); + expect(afterFlipX.y).toBeCloseTo(before.y); + + patchmap.flip.set({ x: true, y: true }); + patchmap.viewport.emit('object_transformed', patchmap.world); + const afterFlipY = getBoundsInParent(text); + expect(afterFlipY.x).toBeCloseTo(before.x); + expect(afterFlipY.y).toBeCloseTo(before.y); + }); + + it('should swap bar percent size on 90-degree rotation', () => { + const patchmap = getPatchmap(); + patchmap.draw([ + createItemWithComponents([ + { + type: 'bar', + id: 'bar-rotate-size', + source: { fill: 'blue', borderWidth: 0, borderColor: 'transparent' }, + size: { width: '60%', height: '20%' }, + placement: 'center', + animation: false, + }, + ]), + ]); + gsap.exportRoot().totalProgress(1); + + const bar = getById(patchmap, 'bar-rotate-size'); + + patchmap.rotation.set(90); + patchmap.flip.set({ x: true, y: true }); + patchmap.viewport.emit('object_transformed', patchmap.world); + + expect(bar.width).toBeCloseTo(60); // 60% of item height (100) + expect(bar.height).toBeCloseTo(40); // 20% of item width (200) + }); + + it('should keep bar at screen bottom under rotation/flip', () => { + const patchmap = getPatchmap(); + patchmap.draw([ + createItemWithComponents([ + { + type: 'bar', + id: 'bar-screen-bottom', + source: { fill: 'blue', borderWidth: 0, borderColor: 'transparent' }, + size: { width: '40%', height: '20%' }, + placement: 'bottom', + animation: false, + }, + ]), + ]); + gsap.exportRoot().totalProgress(1); + + const bar = getById(patchmap, 'bar-screen-bottom'); + const item = getById(patchmap, 'item-transform'); + + patchmap.rotation.set(90); + patchmap.flip.set({ y: true }); + patchmap.viewport.emit('object_transformed', patchmap.world); + + const barBounds = bar.getBounds(); + const itemBounds = item.getBounds(); + expect(barBounds.y + barBounds.height).toBeCloseTo( + itemBounds.y + itemBounds.height, + ); + }); +}); diff --git a/src/utils/viewport-rotation.js b/src/utils/viewport-rotation.js new file mode 100644 index 0000000..abe3341 --- /dev/null +++ b/src/utils/viewport-rotation.js @@ -0,0 +1,35 @@ +export const moveViewportCenter = (viewport, center, viewAngle) => { + if (!viewport || !center) return; + const angle = + typeof viewAngle === 'number' + ? (viewAngle * Math.PI) / 180 + : (viewport.rotation ?? 0); + if (!angle) { + viewport.moveCenter(center.x, center.y); + return; + } + + const scaleX = viewport.scale?.x ?? 1; + const scaleY = viewport.scale?.y ?? 1; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const screenCenterX = viewport.screenWidth / 2; + const screenCenterY = viewport.screenHeight / 2; + const rotatedX = cos * scaleX * center.x - sin * scaleY * center.y; + const rotatedY = sin * scaleX * center.x + cos * scaleY * center.y; + + viewport.position.set(screenCenterX - rotatedX, screenCenterY - rotatedY); + viewport.plugins?.reset?.(); + viewport.dirty = true; +}; + +export const getViewportWorldCenter = (viewport) => { + if (!viewport) return { x: 0, y: 0 }; + return viewport.toWorld(viewport.screenWidth / 2, viewport.screenHeight / 2); +}; + +export const getWorldLocalCenter = (viewport, world) => { + if (!viewport || !world) return { x: 0, y: 0 }; + const worldPoint = getViewportWorldCenter(viewport); + return world.toLocal(worldPoint, world.parent); +}; diff --git a/src/utils/viewport-rotation.test.js b/src/utils/viewport-rotation.test.js new file mode 100644 index 0000000..b09937d --- /dev/null +++ b/src/utils/viewport-rotation.test.js @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { moveViewportCenter } from './viewport-rotation'; + +describe('moveViewportCenter', () => { + it('uses moveCenter when angle is zero', () => { + const viewport = { + rotation: 0, + scale: { x: 1, y: 1 }, + screenWidth: 100, + screenHeight: 100, + position: { set: vi.fn() }, + moveCenter: vi.fn(), + plugins: { reset: vi.fn() }, + dirty: false, + }; + + moveViewportCenter(viewport, { x: 10, y: 20 }); + + expect(viewport.moveCenter).toHaveBeenCalledWith(10, 20); + expect(viewport.position.set).not.toHaveBeenCalled(); + }); + + it('uses viewAngle for rotated centering', () => { + const viewport = { + rotation: 0, + scale: { x: 1, y: 1 }, + screenWidth: 100, + screenHeight: 100, + position: { set: vi.fn() }, + moveCenter: vi.fn(), + plugins: { reset: vi.fn() }, + dirty: false, + }; + + moveViewportCenter(viewport, { x: 10, y: 0 }, 90); + + expect(viewport.moveCenter).not.toHaveBeenCalled(); + expect(viewport.position.set).toHaveBeenCalledWith(50, 40); + expect(viewport.plugins.reset).toHaveBeenCalled(); + expect(viewport.dirty).toBe(true); + }); +});