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);
+ });
+});