diff --git a/README.md b/README.md
index 84ee0957..4c249582 100644
--- a/README.md
+++ b/README.md
@@ -53,8 +53,8 @@ npm install @conalog/patch-map
#### CDN
```html
-
-
+
+
```
### Usage
@@ -67,27 +67,30 @@ const data = [
type: 'group',
id: 'group-id-1',
label: 'group-label-1',
- items: [{
+ children: [{
type: 'grid',
id: 'grid-1',
label: 'grid-label-1',
cells: [ [1, 0, 1], [1, 1, 1] ],
- position: { x: 0, y: 0 },
- itemSize: { width: 40, height: 80 },
- components: [
- {
- type: 'background',
- texture: {
- type: 'rect',
- fill: 'white',
- borderWidth: 2,
- borderColor: 'primary.dark',
- radius: 4,
- }
- },
- { type: 'icon', asset: 'loading', size: 16 }
- ]
- }]
+ gap: 4,
+ item: {
+ size: { width: 40, height: 80 },
+ components: [
+ {
+ type: 'background',
+ source: {
+ type: 'rect',
+ fill: 'white',
+ borderWidth: 2,
+ borderColor: 'primary.dark',
+ radius: 4,
+ }
+ },
+ { type: 'icon', source: 'loading', tint: 'black', size: 16 },
+ ]
+ },
+ }],
+ attrs: { x: 100, y: 100, },
}
];
@@ -185,27 +188,30 @@ const data = [
type: 'group',
id: 'group-id-1',
label: 'group-label-1',
- items: [{
+ children: [{
type: 'grid',
id: 'grid-1',
label: 'grid-label-1',
cells: [ [1, 0, 1], [1, 1, 1] ],
- position: { x: 0, y: 0 },
- itemSize: { width: 40, height: 80 },
- components: [
- {
- type: 'background',
- texture: {
- type: 'rect',
- fill: 'white',
- borderWidth: 2,
- borderColor: 'primary.dark',
- radius: 4,
- }
- },
- { type: 'icon', asset: 'loading', size: 16 }
- ]
- }]
+ gap: 4,
+ item: {
+ size: { width: 40, height: 80 },
+ components: [
+ {
+ type: 'background',
+ source: {
+ type: 'rect',
+ fill: 'white',
+ borderWidth: 2,
+ borderColor: 'primary.dark',
+ radius: 4,
+ }
+ },
+ { type: 'icon', source: 'loading', tint: 'black', size: 16 },
+ ]
+ },
+ }],
+ attrs: { x: 100, y: 100, },
}
];
patchmap.draw(data);
@@ -220,24 +226,29 @@ For **detailed type definitions**, refer to the [data.d.ts](src/display/data-sch
### `update(options)`
-Updates the state of specific objects on the canvas. Use this to change properties like color or text visibility for already rendered objects.
+Updates the properties of objects rendered on the canvas. By default, only the changed properties are applied, but you can precisely control the update behavior using the `refresh` or `arrayMerge` options.
#### **`Options`**
-- `path`(optional, string) - Selector for the object to which the event will be applied, following [jsonpath](https://github.com/JSONPath-Plus/JSONPath) syntax.
-- `elements`(optional, object \| array) - Direct references to one or more objects to update. Accepts a single object or an array. (Objects returned from [selector](#selectorpath), etc.).
-- `changes`(required, object) - New properties to apply (e.g., color, text visibility).
-- `saveToHistory`(optional, boolean \| string) - Determines whether to record changes made by this `update` method in the `undoRedoManager`. If a string that matches the historyId of a previously saved record is provided, the two records will be merged into a single undo/redo step.
-- `relativeTransform`(optional, boolean) - Determines whether to use relative values for `position`, `rotation`, and `angle`. If `true`, the provided values will be added to the object's values.
+- `path` (optional, string) - Selector for the object to which the event will be applied, following [jsonpath](https://github.com/JSONPath-Plus/JSONPath) syntax.
+- `elements` (optional, object \| array) - Direct references to one or more objects to update. Accepts a single object or an array. (Objects returned from [selector](#selectorpath), etc.).
+- `changes` (optional, object) - New properties to apply (e.g., color, text visibility). If the `refresh` option is set to `true`, this can be omitted.
+- `history` (optional, boolean \| string) - Determines whether to record changes made by this `update` method in the `undoRedoManager`. If a string that matches the historyId of a previously saved record is provided, the two records will be merged into a single undo/redo step.
+- `relativeTransform` (optional, boolean) - Determines whether to use relative values for `position`, `rotation`, and `angle`. If `true`, the provided values will be added to the object's values.
+- `arrayMerge` (optional, string) - Determines how to merge array properties. The default is `'merge'`.
+ - `'merge'` (default): Merges the target and source arrays.
+ - `'replace'`: Completely replaces the target array with the source array. Useful for forcing a specific state.
+- `refresh` (optional, boolean) - If set to `true`, all property handlers are forcibly re-executed and the object is "refreshed" even if the values in `changes` are the same as before. This is useful when child objects need to be recalculated due to changes in the parent. Default is `false`.
+
```js
// Apply changes to objects with the label "grid-label-1"
patchmap.update({
path: `$..children[?(@.label=="grid-label-1")]`,
changes: {
- components: [
- { type: 'icon', asset: 'wifi' }
- ]
- }
+ item: {
+ components: [{ type: 'icon', source: 'wifi' }],
+ },
+ },
});
// Apply changes to objects of type "group"
@@ -252,10 +263,16 @@ patchmap.update({
patchmap.update({
path: `$..children[?(@.type=="group")].children[?(@.type=="grid")]`,
changes: {
- components: [
- { type: 'icon', tint: 'black' }
- ]
- }
+ item: {
+ components: [{ type: 'icon', tint: 'red' }],
+ },
+ },
+});
+
+// Force a full property update (refresh) for all objects of type "relations" using refresh: true
+patchmap.update({
+ path: `$..children[?(@.type==="relations")]`,
+ refresh: true
});
```
diff --git a/README_KR.md b/README_KR.md
index a7c6a2f2..d91ffbd8 100644
--- a/README_KR.md
+++ b/README_KR.md
@@ -53,8 +53,8 @@ npm install @conalog/patch-map
#### CDN
```html
-
-
+
+
```
### 기본 예제
@@ -67,27 +67,30 @@ const data = [
type: 'group',
id: 'group-id-1',
label: 'group-label-1',
- items: [{
+ children: [{
type: 'grid',
id: 'grid-1',
label: 'grid-label-1',
cells: [ [1, 0, 1], [1, 1, 1] ],
- position: { x: 0, y: 0 },
- itemSize: { width: 40, height: 80 },
- components: [
- {
- type: 'background',
- texture: {
- type: 'rect',
- fill: 'white',
- borderWidth: 2,
- borderColor: 'primary.dark',
- radius: 4,
- }
- },
- { type: 'icon', asset: 'loading', size: 16 }
- ]
- }]
+ gap: 4,
+ item: {
+ size: { width: 40, height: 80 },
+ components: [
+ {
+ type: 'background',
+ source: {
+ type: 'rect',
+ fill: 'white',
+ borderWidth: 2,
+ borderColor: 'primary.dark',
+ radius: 4,
+ }
+ },
+ { type: 'icon', source: 'loading', tint: 'black', size: 16 },
+ ]
+ },
+ }],
+ attrs: { x: 100, y: 100, },
}
];
@@ -185,27 +188,30 @@ const data = [
type: 'group',
id: 'group-id-1',
label: 'group-label-1',
- items: [{
+ children: [{
type: 'grid',
id: 'grid-1',
label: 'grid-label-1',
cells: [ [1, 0, 1], [1, 1, 1] ],
- position: { x: 0, y: 0 },
- itemSize: { width: 40, height: 80 },
- components: [
- {
- type: 'background',
- texture: {
- type: 'rect',
- fill: 'white',
- borderWidth: 2,
- borderColor: 'primary.dark',
- radius: 4,
- }
- },
- { type: 'icon', texture: 'loading', size: 16 }
- ]
- }]
+ gap: 4,
+ item: {
+ size: { width: 40, height: 80 },
+ components: [
+ {
+ type: 'background',
+ source: {
+ type: 'rect',
+ fill: 'white',
+ borderWidth: 2,
+ borderColor: 'primary.dark',
+ radius: 4,
+ }
+ },
+ { type: 'icon', source: 'loading', tint: 'black', size: 16 },
+ ]
+ },
+ }],
+ attrs: { x: 100, y: 100, },
}
];
patchmap.draw(data);
@@ -219,24 +225,28 @@ draw method가 요구하는 **데이터 구조**입니다.
### `update(options)`
-캔버스에 이미 렌더링된 객체의 상태를 업데이트합니다. 색상이나 텍스트 가시성 같은 속성을 변경하는 데 사용하세요.
+캔버스에 렌더링된 객체의 속성을 업데이트합니다. 기본적으로 변경된 속성만 반영하지만, refresh 또는 arrayMerge 옵션을 통해 업데이트 동작을 정밀하게 제어할 수 있습니다.
#### **`Options`**
-- `path`(optional, string) - [jsonpath](https://github.com/JSONPath-Plus/JSONPath) 문법에 따른 selector로, 이벤트가 적용될 객체를 선택합니다.
-- `elements`(optional, object \| array) - 업데이트할 하나 이상의 객체에 대한 직접 참조입니다. 단일 객체 또는 배열을 허용합니다. ([selector](#selectorpath)에서 반환된 객체 등).
-- `changes`(required, object) - 적용할 새로운 속성 (예: 색상, 텍스트 가시성).
-- `saveToHistory`(optional, boolean \| string) - 해당 `update` 메소드에 의한 변경 사항을 `undoRedoManager`에 기록할 것인지 결정합니다. 이전에 저장된 기록의 historyId와 일치하는 문자열이 제공되면, 두 기록이 하나의 실행 취소/재실행 단계로 병합됩니다.
-- `relativeTransform`(optional, boolean) - `position`, `rotation`, `angle` 값에 대해서 상대값을 이용할 지 결정합니다. 만약, `true` 라면 전달된 값을 객체의 값에 더합니다.
+- `path` (optional, string) - [jsonpath](https://github.com/JSONPath-Plus/JSONPath) 문법에 따른 selector로, 이벤트가 적용될 객체를 선택합니다.
+- `elements` (optional, object \| array) - 업데이트할 하나 이상의 객체에 대한 직접 참조입니다. 단일 객체 또는 배열을 허용합니다. ([selector](#selectorpath)에서 반환된 객체 등).
+- `changes` (optional, object) - 적용할 새로운 속성 (예: 색상, 텍스트 가시성). `refresh` 옵션을 `true`로 설정할 경우 생략할 수 있습니다.
+- `history` (optional, boolean \| string) - 해당 `update` 메소드에 의한 변경 사항을 `undoRedoManager`에 기록할 것인지 결정합니다. 이전에 저장된 기록의 historyId와 일치하는 문자열이 제공되면, 두 기록이 하나의 실행 취소/재실행 단계로 병합됩니다.
+- `relativeTransform` (optional, boolean) - `position`, `rotation`, `angle` 값에 대해서 상대값을 이용할 지 결정합니다. 만약, `true` 라면 전달된 값을 객체의 값에 더합니다.
+- `arrayMerge` (optional, string) - 배열 속성을 병합하는 방식을 결정합니다. 기본값은 `'merge'` 입니다.
+ - `'merge'` (기본값): 대상 배열과 소스 배열을 병합합니다.
+ - `'replace'`: 대상 배열을 소스 배열로 완전히 교체하여, 특정 상태로 강제할 때 유용합니다.
+- `refresh` (optional, boolean) - `true`로 설정하면, `changes`의 속성 값이 이전과 동일하더라도 모든 속성 핸들러를 강제로 다시 실행하여 객체를 "새로고침"합니다. 부모의 상태 변화에 따라 자식 객체를 다시 계산해야 할 때 유용합니다. 기본값은 `false` 입니다.
```js
// label이 "grid-label-1"인 객체들에 대해 변경 사항 적용
patchmap.update({
path: `$..children[?(@.label=="grid-label-1")]`,
changes: {
- components: [
- { type: 'icon', asset: 'wifi' }
- ]
- }
+ item: {
+ components: [{ type: 'icon', source: 'wifi' }],
+ },
+ },
});
// type이 "group"인 객체들에 대해 변경 사항 적용
@@ -251,10 +261,16 @@ patchmap.update({
patchmap.update({
path: `$..children[?(@.type=="group")].children[?(@.type=="grid")]`,
changes: {
- components: [
- { type: 'icon', tint: 'black' }
- ]
- }
+ item: {
+ components: [{ type: 'icon', tint: 'red' }],
+ },
+ },
+});
+
+// type이 "relations"인 모든 객체를 찾아서(refresh: true로) 강제로 전체 속성 업데이트(새로고침) 수행
+patchmap.update({
+ path: `$..children[?(@.type==="relations")]`,
+ refresh: true
});
```
diff --git a/biome.json b/biome.json
index 32a59b21..b3a44351 100644
--- a/biome.json
+++ b/biome.json
@@ -32,7 +32,8 @@
"noConstructorReturn": "off"
},
"complexity": {
- "noForEach": "off"
+ "noForEach": "off",
+ "noThisInStatic": "off"
}
}
},
diff --git a/package-lock.json b/package-lock.json
index 5b146325..ba08948e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,12 +9,13 @@
"version": "0.2.0",
"license": "MIT",
"dependencies": {
+ "@pixi-essentials/bounds": "^3.0.0",
"gsap": "^3.12.7",
"is-plain-object": "^5.0.0",
"jsonpath-plus": "^10.3.0",
"pixi-viewport": "^6.0.3",
"uuid": "^11.1.0",
- "zod": "^3.24.3",
+ "zod": "^3.25.67",
"zod-validation-error": "^3.4.0"
},
"devDependencies": {
@@ -24,12 +25,14 @@
"@rollup/plugin-commonjs": "^28.0.2",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-url": "^8.0.2",
+ "@vitest/browser": "^3.2.4",
"husky": "^9.1.7",
+ "playwright": "^1.53.2",
"rollup": "^4.34.8",
"rollup-plugin-copy": "^3.5.0",
"standard-version": "^9.5.0",
"typescript": "^5.7.3",
- "vitest": "^3.0.6"
+ "vitest": "^3.2.4"
},
"engines": {
"node": ">=20"
@@ -38,6 +41,31 @@
"pixi.js": "^8.8.0"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "peer": true
+ },
"node_modules/@babel/code-frame": {
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
@@ -63,6 +91,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.27.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
+ "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@biomejs/biome": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz",
@@ -365,10 +403,203 @@
"node": ">=v18"
}
},
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz",
+ "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz",
+ "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@csstools/color-helpers": "^5.0.2",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz",
+ "integrity": "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.6.tgz",
+ "integrity": "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz",
+ "integrity": "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.6.tgz",
+ "integrity": "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.25.3",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz",
- "integrity": "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==",
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz",
+ "integrity": "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==",
"cpu": [
"arm64"
],
@@ -382,6 +613,363 @@
"node": ">=18"
}
},
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz",
+ "integrity": "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz",
+ "integrity": "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz",
+ "integrity": "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz",
+ "integrity": "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz",
+ "integrity": "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz",
+ "integrity": "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz",
+ "integrity": "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz",
+ "integrity": "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz",
+ "integrity": "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz",
+ "integrity": "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz",
+ "integrity": "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz",
+ "integrity": "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz",
+ "integrity": "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz",
+ "integrity": "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz",
+ "integrity": "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz",
+ "integrity": "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz",
+ "integrity": "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz",
+ "integrity": "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz",
+ "integrity": "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz",
+ "integrity": "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz",
+ "integrity": "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@hutson/parse-repository-url": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@hutson/parse-repository-url/-/parse-repository-url-3.0.2.tgz",
@@ -461,6 +1049,15 @@
"node": ">= 8"
}
},
+ "node_modules/@pixi-essentials/bounds": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@pixi-essentials/bounds/-/bounds-3.0.0.tgz",
+ "integrity": "sha512-AHWL8J1pc7tKB9qFW/x909/NZkLLCBKzeDWiwHVOLyCAiSVYHU+dACAohKNfDHLf1rgTgM5R7JHRQR6SA1gG/g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@pixi/math": "^7.0.0"
+ }
+ },
"node_modules/@pixi/colord": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
@@ -468,6 +1065,20 @@
"license": "MIT",
"peer": true
},
+ "node_modules/@pixi/math": {
+ "version": "7.4.3",
+ "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz",
+ "integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rollup/plugin-commonjs": {
"version": "28.0.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.3.tgz",
@@ -550,35 +1161,126 @@
"dev": true,
"license": "MIT",
"dependencies": {
- "@types/estree": "^1.0.0",
- "estree-walker": "^2.0.2",
- "picomatch": "^4.0.2"
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.40.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.1.tgz",
+ "integrity": "sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
+ "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "chalk": "^4.1.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
},
"engines": {
- "node": ">=14.0.0"
+ "node": ">=8"
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
},
"peerDependencies": {
- "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
- },
- "peerDependenciesMeta": {
- "rollup": {
- "optional": true
- }
+ "@testing-library/dom": ">=7.21.4"
}
},
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.40.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.1.tgz",
- "integrity": "sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz",
+ "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
+ "dependencies": {
+ "@types/deep-eql": "*"
+ }
},
"node_modules/@types/conventional-commits-parser": {
"version": "5.0.1",
@@ -597,6 +1299,13 @@
"license": "MIT",
"peer": true
},
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/earcut": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz",
@@ -670,15 +1379,52 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@vitest/browser": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.4.tgz",
+ "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/user-event": "^14.6.1",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "magic-string": "^0.30.17",
+ "sirv": "^3.0.1",
+ "tinyrainbow": "^2.0.0",
+ "ws": "^8.18.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "playwright": "*",
+ "vitest": "3.2.4",
+ "webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "playwright": {
+ "optional": true
+ },
+ "safaridriver": {
+ "optional": true
+ },
+ "webdriverio": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.2.tgz",
- "integrity": "sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "3.1.2",
- "@vitest/utils": "3.1.2",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
@@ -687,13 +1433,13 @@
}
},
"node_modules/@vitest/mocker": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.2.tgz",
- "integrity": "sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "3.1.2",
+ "@vitest/spy": "3.2.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
@@ -702,7 +1448,7 @@
},
"peerDependencies": {
"msw": "^2.4.9",
- "vite": "^5.0.0 || ^6.0.0"
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
@@ -724,9 +1470,9 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz",
- "integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -737,27 +1483,28 @@
}
},
"node_modules/@vitest/runner": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.2.tgz",
- "integrity": "sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "3.1.2",
- "pathe": "^2.0.3"
+ "@vitest/utils": "3.2.4",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.2.tgz",
- "integrity": "sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "3.1.2",
+ "@vitest/pretty-format": "3.2.4",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
@@ -766,27 +1513,27 @@
}
},
"node_modules/@vitest/spy": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.2.tgz",
- "integrity": "sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "tinyspy": "^3.0.2"
+ "tinyspy": "^4.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.2.tgz",
- "integrity": "sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "3.1.2",
- "loupe": "^3.1.3",
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
"funding": {
@@ -817,6 +1564,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
@@ -867,6 +1626,16 @@
"dev": true,
"license": "Python-2.0"
},
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
"node_modules/array-ify": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz",
@@ -911,6 +1680,43 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -935,6 +1741,33 @@
"node": ">=8"
}
},
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -990,10 +1823,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/canvas": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.1.2.tgz",
+ "integrity": "sha512-Z/tzFAcBzoCvJlOSlCnoekh1Gu8YMn0J51+UAuXJAbW1Z6I9l2mZgdD7738MepoeeIcUdDtbMnOg6cC7GJxy/g==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "node-addon-api": "^7.0.0",
+ "prebuild-install": "^7.1.3"
+ },
+ "engines": {
+ "node": "^18.12.0 || >= 20.9.0"
+ }
+ },
"node_modules/chai": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz",
- "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.1.tgz",
+ "integrity": "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1004,7 +1854,7 @@
"pathval": "^2.0.0"
},
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/chalk": {
@@ -1030,6 +1880,15 @@
"node": ">= 16"
}
},
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "peer": true
+ },
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -2194,6 +3053,22 @@
"typescript": ">=5"
}
},
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/dargs": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz",
@@ -2207,6 +3082,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/dateformat": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz",
@@ -2218,9 +3109,9 @@
}
},
"node_modules/debug": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
- "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+ "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2272,6 +3163,33 @@
"node": ">=0.10.0"
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -2282,6 +3200,18 @@
"node": ">=6"
}
},
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -2292,6 +3222,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-indent": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz",
@@ -2302,6 +3242,18 @@
"node": ">=8"
}
},
+ "node_modules/detect-libc": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
+ "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -2325,6 +3277,13 @@
"node": ">=8"
}
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/dot-prop": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
@@ -2432,6 +3391,33 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -2460,9 +3446,9 @@
"license": "MIT"
},
"node_modules/esbuild": {
- "version": "0.25.3",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz",
- "integrity": "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==",
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz",
+ "integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -2473,31 +3459,32 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.25.3",
- "@esbuild/android-arm": "0.25.3",
- "@esbuild/android-arm64": "0.25.3",
- "@esbuild/android-x64": "0.25.3",
- "@esbuild/darwin-arm64": "0.25.3",
- "@esbuild/darwin-x64": "0.25.3",
- "@esbuild/freebsd-arm64": "0.25.3",
- "@esbuild/freebsd-x64": "0.25.3",
- "@esbuild/linux-arm": "0.25.3",
- "@esbuild/linux-arm64": "0.25.3",
- "@esbuild/linux-ia32": "0.25.3",
- "@esbuild/linux-loong64": "0.25.3",
- "@esbuild/linux-mips64el": "0.25.3",
- "@esbuild/linux-ppc64": "0.25.3",
- "@esbuild/linux-riscv64": "0.25.3",
- "@esbuild/linux-s390x": "0.25.3",
- "@esbuild/linux-x64": "0.25.3",
- "@esbuild/netbsd-arm64": "0.25.3",
- "@esbuild/netbsd-x64": "0.25.3",
- "@esbuild/openbsd-arm64": "0.25.3",
- "@esbuild/openbsd-x64": "0.25.3",
- "@esbuild/sunos-x64": "0.25.3",
- "@esbuild/win32-arm64": "0.25.3",
- "@esbuild/win32-ia32": "0.25.3",
- "@esbuild/win32-x64": "0.25.3"
+ "@esbuild/aix-ppc64": "0.25.6",
+ "@esbuild/android-arm": "0.25.6",
+ "@esbuild/android-arm64": "0.25.6",
+ "@esbuild/android-x64": "0.25.6",
+ "@esbuild/darwin-arm64": "0.25.6",
+ "@esbuild/darwin-x64": "0.25.6",
+ "@esbuild/freebsd-arm64": "0.25.6",
+ "@esbuild/freebsd-x64": "0.25.6",
+ "@esbuild/linux-arm": "0.25.6",
+ "@esbuild/linux-arm64": "0.25.6",
+ "@esbuild/linux-ia32": "0.25.6",
+ "@esbuild/linux-loong64": "0.25.6",
+ "@esbuild/linux-mips64el": "0.25.6",
+ "@esbuild/linux-ppc64": "0.25.6",
+ "@esbuild/linux-riscv64": "0.25.6",
+ "@esbuild/linux-s390x": "0.25.6",
+ "@esbuild/linux-x64": "0.25.6",
+ "@esbuild/netbsd-arm64": "0.25.6",
+ "@esbuild/netbsd-x64": "0.25.6",
+ "@esbuild/openbsd-arm64": "0.25.6",
+ "@esbuild/openbsd-x64": "0.25.6",
+ "@esbuild/openharmony-arm64": "0.25.6",
+ "@esbuild/sunos-x64": "0.25.6",
+ "@esbuild/win32-arm64": "0.25.6",
+ "@esbuild/win32-ia32": "0.25.6",
+ "@esbuild/win32-x64": "0.25.6"
}
},
"node_modules/escalade": {
@@ -2534,6 +3521,18 @@
"license": "MIT",
"peer": true
},
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "dev": true,
+ "license": "(MIT OR WTFPL)",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/expect-type": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz",
@@ -2657,6 +3656,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
@@ -3090,6 +4098,15 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -3242,6 +4259,53 @@
"node": ">=10"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
@@ -3258,6 +4322,44 @@
"url": "https://github.com/sponsors/typicode"
}
},
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "peer": true
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3447,6 +4549,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/is-reference": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
@@ -3521,6 +4632,48 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsdom": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "cssstyle": "^4.2.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.5.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.16",
+ "parse5": "^7.2.1",
+ "rrweb-cssom": "^0.8.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.1.1",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.1.1",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/jsep": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
@@ -3764,9 +4917,9 @@
"license": "MIT"
},
"node_modules/loupe": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
- "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==",
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.4.tgz",
+ "integrity": "sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==",
"dev": true,
"license": "MIT"
},
@@ -3783,6 +4936,16 @@
"node": ">=10"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
@@ -3895,6 +5058,21 @@
"node": ">=10.0.0"
}
},
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -3943,6 +5121,15 @@
"node": ">= 6"
}
},
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/modify-values": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz",
@@ -3953,6 +5140,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3979,6 +5176,15 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
@@ -3986,6 +5192,30 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/node-abi": {
+ "version": "3.75.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
+ "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/normalize-package-data": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz",
@@ -4002,6 +5232,15 @@
"node": ">=10"
}
},
+ "node_modules/nwsapi": {
+ "version": "2.2.20",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz",
+ "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -4093,6 +5332,21 @@
"license": "MIT",
"peer": true
},
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-exists": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
@@ -4138,9 +5392,9 @@
"license": "MIT"
},
"node_modules/pathval": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
- "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4205,10 +5459,57 @@
"parse-svg-path": "^0.1.2"
}
},
+ "node_modules/playwright": {
+ "version": "1.53.2",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.2.tgz",
+ "integrity": "sha512-6K/qQxVFuVQhRQhFsVZ9fGeatxirtrpPgxzBYWyZLEXJzqYwuL4fuNmfOfD5et1tJE4GScKyPNeLhZeRwuTU3A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.53.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.53.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.2.tgz",
+ "integrity": "sha512-ox/OytMy+2w1jcYEYlOo1Hhp8hZkLCximMTUTMBXjGUA1KoFfiSZ+DU+3a739jsPY0yoKH2TFy9S2fsJas8yAw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/postcss": {
- "version": "8.5.3",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
- "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
@@ -4226,7 +5527,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.8",
+ "nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -4234,6 +5535,63 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -4241,6 +5599,31 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pump": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
+ "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/q": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
@@ -4284,6 +5667,40 @@
"node": ">=8"
}
},
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "dev": true,
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/rc/node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "peer": true
+ },
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/read-pkg": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz",
@@ -4597,6 +6014,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -4642,6 +6068,30 @@
],
"license": "MIT"
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
@@ -4662,6 +6112,72 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/sirv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz",
+ "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -5052,6 +6568,38 @@
"node": ">=8"
}
},
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz",
+ "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/strip-literal/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -5078,6 +6626,49 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
+ "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/text-extensions": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz",
@@ -5123,9 +6714,9 @@
"license": "MIT"
},
"node_modules/tinyglobby": {
- "version": "0.2.13",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
- "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
+ "version": "0.2.14",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
+ "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5140,9 +6731,9 @@
}
},
"node_modules/tinypool": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz",
- "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5160,15 +6751,39 @@
}
},
"node_modules/tinyspy": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
- "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz",
+ "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -5182,6 +6797,46 @@
"node": ">=8.0"
}
},
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/trim-newlines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz",
@@ -5192,6 +6847,21 @@
"node": ">=8"
}
},
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/type-fest": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz",
@@ -5377,17 +7047,17 @@
}
},
"node_modules/vite-node": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.2.tgz",
- "integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
- "debug": "^4.4.0",
- "es-module-lexer": "^1.6.0",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
"pathe": "^2.0.3",
- "vite": "^5.0.0 || ^6.0.0"
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"bin": {
"vite-node": "vite-node.mjs"
@@ -5400,32 +7070,34 @@
}
},
"node_modules/vitest": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.2.tgz",
- "integrity": "sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/expect": "3.1.2",
- "@vitest/mocker": "3.1.2",
- "@vitest/pretty-format": "^3.1.2",
- "@vitest/runner": "3.1.2",
- "@vitest/snapshot": "3.1.2",
- "@vitest/spy": "3.1.2",
- "@vitest/utils": "3.1.2",
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.4",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/pretty-format": "^3.2.4",
+ "@vitest/runner": "3.2.4",
+ "@vitest/snapshot": "3.2.4",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
"chai": "^5.2.0",
- "debug": "^4.4.0",
+ "debug": "^4.4.1",
"expect-type": "^1.2.1",
"magic-string": "^0.30.17",
"pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
"std-env": "^3.9.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
- "tinyglobby": "^0.2.13",
- "tinypool": "^1.0.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
"tinyrainbow": "^2.0.0",
- "vite": "^5.0.0 || ^6.0.0",
- "vite-node": "3.1.2",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
"why-is-node-running": "^2.3.0"
},
"bin": {
@@ -5441,8 +7113,8 @@
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
- "@vitest/browser": "3.1.2",
- "@vitest/ui": "3.1.2",
+ "@vitest/browser": "3.2.4",
+ "@vitest/ui": "3.2.4",
"happy-dom": "*",
"jsdom": "*"
},
@@ -5470,6 +7142,76 @@
}
}
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -5519,6 +7261,49 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/ws": {
+ "version": "8.18.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
+ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -5589,9 +7374,9 @@
}
},
"node_modules/zod": {
- "version": "3.24.3",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz",
- "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==",
+ "version": "3.25.67",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz",
+ "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
diff --git a/package.json b/package.json
index 74eb1bee..fac34c03 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,9 @@
"format": "biome format",
"lint": "biome check --staged",
"lint:fix": "biome check --staged --write",
- "test:unit": "vitest",
+ "test:unit": "vitest --project unit",
+ "test:browser": "vitest --browser=chromium",
+ "test:headless": "vitest --browser.headless",
"prepare": "husky",
"release": "standard-version"
},
@@ -42,12 +44,13 @@
"dist"
],
"dependencies": {
+ "@pixi-essentials/bounds": "^3.0.0",
"gsap": "^3.12.7",
"is-plain-object": "^5.0.0",
"jsonpath-plus": "^10.3.0",
"pixi-viewport": "^6.0.3",
"uuid": "^11.1.0",
- "zod": "^3.24.3",
+ "zod": "^3.25.67",
"zod-validation-error": "^3.4.0"
},
"peerDependencies": {
@@ -60,11 +63,13 @@
"@rollup/plugin-commonjs": "^28.0.2",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-url": "^8.0.2",
+ "@vitest/browser": "^3.2.4",
"husky": "^9.1.7",
+ "playwright": "^1.53.2",
"rollup": "^4.34.8",
"rollup-plugin-copy": "^3.5.0",
"standard-version": "^9.5.0",
"typescript": "^5.7.3",
- "vitest": "^3.0.6"
+ "vitest": "^3.2.4"
}
}
diff --git a/src/assets/textures/utils.js b/src/assets/textures/utils.js
index 7f66cb8d..05092576 100644
--- a/src/assets/textures/utils.js
+++ b/src/assets/textures/utils.js
@@ -1,4 +1,4 @@
-import { TextureStyle } from '../../display/data-schema/component-schema';
+import { TextureStyle } from '../../display/data-schema/primitive-schema';
import { deepMerge } from '../../utils/deepmerge/deepmerge';
const RESOLUTION = 5;
diff --git a/src/assets/utils.js b/src/assets/utils.js
deleted file mode 100644
index 6d63d316..00000000
--- a/src/assets/utils.js
+++ /dev/null
@@ -1,14 +0,0 @@
-export const transformManifest = (data) => {
- return {
- bundles: Object.entries(data).map(([name, assets]) => ({
- name,
- assets: Object.entries(assets)
- .filter(([_, details]) => !details.disabled)
- .map(([alias, details]) => ({
- alias,
- src: details.src,
- data: { resolution: 3 },
- })),
- })),
- };
-};
diff --git a/src/command/commands/index.js b/src/command/commands/index.js
index ebb81e05..feed7644 100644
--- a/src/command/commands/index.js
+++ b/src/command/commands/index.js
@@ -1,4 +1 @@
export { BundleCommand } from './bundle';
-export { PositionCommand } from './position';
-export { ShowCommand } from './show';
-export { TintCommand } from './tint';
diff --git a/src/command/commands/position.js b/src/command/commands/position.js
deleted file mode 100644
index bc0d4539..00000000
--- a/src/command/commands/position.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * @fileoverview PositionCommand class implementation for changing object positions with undo/redo functionality.
- */
-import { changePosition } from '../../display/change';
-import { parsePick } from '../utils';
-import { Command } from './base';
-
-const optionKeys = ['position'];
-
-/**
- * PositionCommand class.
- * A command for changing the position of an object with undo/redo functionality.
- */
-export class PositionCommand extends Command {
- /**
- * Creates an instance of PositionCommand.
- * @param {Object} object - The Pixi.js display object whose position will be changed.
- * @param {Object} config - The new configuration for the object's position.
- */
- constructor(object, config) {
- super('position_object');
- this.object = object;
- this._config = parsePick(config, optionKeys);
- this._prevConfig = parsePick(this.object.config, optionKeys);
- }
-
- get config() {
- return this._config;
- }
-
- get prevConfig() {
- return this._prevConfig;
- }
-
- /**
- * Executes the command to change the object's position.
- */
- execute() {
- changePosition(this.object, this.config);
- }
-
- /**
- * Undoes the command, reverting the object's position to its previous state.
- */
- undo() {
- changePosition(this.object, this.prevConfig);
- }
-}
diff --git a/src/command/commands/show.js b/src/command/commands/show.js
deleted file mode 100644
index e31c240f..00000000
--- a/src/command/commands/show.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * @fileoverview ShowCommand class implementation for toggling the show state of an object.
- */
-import { changeShow } from '../../display/change';
-import { parsePick } from '../utils';
-import { Command } from './base';
-
-const optionKeys = ['show'];
-
-/**
- * ShowCommand class.
- * A command for toggling the visibility state of an object.
- */
-export class ShowCommand extends Command {
- /**
- * Creates an instance of ShowCommand.
- * @param {Object} object - The Pixi.js display object whose renderable will be changed.
- * @param {Object} config - The new configuration containing the show state.
- */
- constructor(object, config) {
- super('show_object');
- this.object = object;
- this._config = parsePick(config, optionKeys);
- this._prevConfig = parsePick(object.config, optionKeys);
- }
-
- get config() {
- return this._config;
- }
-
- get prevConfig() {
- return this._prevConfig;
- }
-
- /**
- * Executes the command to change the object's show state.
- */
- execute() {
- changeShow(this.object, this.config);
- }
-
- /**
- * Undoes the command, reverting the object's show state to its previous state.
- */
- undo() {
- changeShow(this.object, this.prevConfig);
- }
-}
diff --git a/src/command/commands/tint.js b/src/command/commands/tint.js
deleted file mode 100644
index 14beb7dd..00000000
--- a/src/command/commands/tint.js
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * @fileoverview TintCommand class implementation for changing the tint of an object with undo/redo functionality.
- */
-
-import { changeTint } from '../../display/change';
-import { parsePick } from '../utils';
-import { Command } from './base';
-
-const optionKeys = ['tint'];
-
-/**
- * TintCommand class.
- * A command for changing the tint of an object with undo/redo functionality.
- */
-export class TintCommand extends Command {
- /**
- * Creates an instance of TintCommand.
- * @param {Object} object - The Pixi.js display object whose tint will be changed.
- * @param {Object} config - The new configuration for the object's tint.
- * @param {object} options - Options for command execution.
- */
- constructor(object, config, options) {
- super('tint_object');
- this.object = object;
- this._config = parsePick(config, optionKeys);
- this._prevConfig = parsePick(object.config, optionKeys);
- this._options = options;
- }
-
- get config() {
- return this._config;
- }
-
- get prevConfig() {
- return this._prevConfig;
- }
-
- get options() {
- return this._options;
- }
-
- /**
- * Executes the command to change the object's tint.
- */
- execute() {
- changeTint(this.object, this.config, this.options);
- }
-
- /**
- * Undoes the command, reverting the object's tint to its previous state.
- */
- undo() {
- changeTint(this.object, this.prevConfig, this.options);
- }
-}
diff --git a/src/display/change/animation.js b/src/display/change/animation.js
deleted file mode 100644
index 14427c5f..00000000
--- a/src/display/change/animation.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import { isConfigMatch, tweensOf, updateConfig } from './utils';
-
-export const changeAnimation = (object, { animation }) => {
- if (isConfigMatch(object, 'animation', animation)) {
- return;
- }
- if (!animation) {
- tweensOf(object).forEach((tween) => tween.progress(1).kill());
- }
- updateConfig(object, { animation });
-};
diff --git a/src/display/change/asset.js b/src/display/change/asset.js
deleted file mode 100644
index 3472a384..00000000
--- a/src/display/change/asset.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import { getTexture } from '../../assets/textures/texture';
-import { getViewport } from '../../utils/get';
-import { isConfigMatch, updateConfig } from './utils';
-
-export const changeAsset = (object, { asset: assetConfig }, { theme }) => {
- if (isConfigMatch(object, 'asset', assetConfig)) {
- return;
- }
-
- const renderer = getViewport(object).app.renderer;
- const asset = getTexture(renderer, theme, assetConfig);
- if (!asset) {
- console.warn(`Asset not found for config: ${JSON.stringify(assetConfig)}`);
- }
- object.texture = asset ?? null;
- updateConfig(object, { asset: assetConfig });
-};
diff --git a/src/display/change/index.js b/src/display/change/index.js
deleted file mode 100644
index e4883cce..00000000
--- a/src/display/change/index.js
+++ /dev/null
@@ -1,15 +0,0 @@
-export { changeProperty } from './property';
-export { changeShow } from './show';
-export { changePosition } from './position';
-export { changeLinks } from './links';
-export { changeStrokeStyle } from './stroke-style';
-export { changeTint } from './tint';
-export { changeTexture } from './texture';
-export { changeAsset } from './asset';
-export { changeTextureTransform } from './texture-transform';
-export { changePercentSize } from './percent-size';
-export { changeSize } from './size';
-export { changePlacement } from './placement';
-export { changeText } from './text';
-export { changeTextStyle } from './text-style';
-export { changeAnimation } from './animation';
diff --git a/src/display/change/links.js b/src/display/change/links.js
deleted file mode 100644
index fb1bbddd..00000000
--- a/src/display/change/links.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import { getScaleBounds } from '../../utils/canvas';
-import { deepMerge } from '../../utils/deepmerge/deepmerge';
-import { selector } from '../../utils/selector/selector';
-import { updateConfig } from './utils';
-
-export const changeLinks = (object, { links }) => {
- const path = selector(object, '$.children[?(@.type==="path")]')[0];
- if (!path) return;
-
- path.clear();
- path.links = [];
- const objs = collectLinkedObjects(object.viewport, links);
- for (const link of links) {
- const { sourcePoint, targetPoint } = getLinkPoints(
- link,
- objs,
- object.viewport,
- );
- if (!sourcePoint || !targetPoint) continue;
-
- if (shouldMovePoint(path, sourcePoint)) {
- path.moveTo(...sourcePoint);
- }
- path.lineTo(...targetPoint);
- path.links.push({ sourcePoint, targetPoint });
- }
- path.stroke();
- updateConfig(object, { links }, true);
- deepMerge(object, { metadata: { linkedIds: Object.keys(objs) } });
-
- function collectLinkedObjects(viewport, links) {
- const uniqueIds = new Set(
- links.flatMap((link) => [link.source, link.target]),
- );
- const items = selector(viewport, '$..children').filter((item) =>
- uniqueIds.has(item.id),
- );
- return Object.fromEntries(items.map((item) => [item.id, item]));
- }
-
- function getLinkPoints(link, objs, viewport) {
- const sourceObject = objs[link.source];
- const targetObject = objs[link.target];
- const sourcePoint = sourceObject
- ? getPoint(getScaleBounds(viewport, sourceObject))
- : null;
- const targetPoint = targetObject
- ? getPoint(getScaleBounds(viewport, targetObject))
- : null;
- return { sourcePoint, targetPoint };
- }
-
- function shouldMovePoint(path, [sx, sy]) {
- const lastLink = path.links[path.links.length - 1];
- if (!lastLink) return true;
- return lastLink.targetPoint[0] !== sx || lastLink.targetPoint[1] !== sy;
- }
-
- function getPoint(bounds) {
- return [bounds.x + bounds.width / 2, bounds.y + bounds.height / 2];
- }
-};
diff --git a/src/display/change/percent-size.js b/src/display/change/percent-size.js
deleted file mode 100644
index 3a6e401e..00000000
--- a/src/display/change/percent-size.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import gsap from 'gsap';
-import { parseMargin } from '../utils';
-import { changePlacement } from './placement';
-import { isConfigMatch, killTweensOf, updateConfig } from './utils';
-
-export const changePercentSize = (
- object,
- {
- percentWidth = object.config.percentWidth,
- percentHeight = object.config.percentHeight,
- margin = object.config.margin,
- animationDuration = object.config.animationDuration,
- },
- { animationContext },
-) => {
- if (
- isConfigMatch(object, 'percentWidth', percentWidth) &&
- isConfigMatch(object, 'percentHeight', percentHeight) &&
- isConfigMatch(object, 'margin', margin)
- ) {
- return;
- }
-
- const marginObj = parseMargin(margin);
- if (Number.isFinite(percentWidth)) {
- changeWidth(object, percentWidth, marginObj);
- }
- if (Number.isFinite(percentHeight)) {
- changeHeight(object, percentHeight, marginObj);
- }
- updateConfig(object, {
- percentWidth,
- percentHeight,
- margin,
- animationDuration,
- });
-
- function changeWidth(component, percentWidth, marginObj) {
- const maxWidth =
- component.parent.size.width - (marginObj.left + marginObj.right);
- component.width = maxWidth * percentWidth;
- }
-
- function changeHeight(component, percentHeight) {
- const maxHeight =
- component.parent.size.height - (marginObj.top + marginObj.bottom);
-
- if (object.config.animation) {
- animationContext.add(() => {
- killTweensOf(component);
- gsap.to(component, {
- pixi: { height: maxHeight * percentHeight },
- duration: animationDuration / 1000,
- ease: 'power2.inOut',
- onUpdate: () => changePlacement(component, {}),
- });
- });
- } else {
- component.height = maxHeight * percentHeight;
- }
- }
-};
diff --git a/src/display/change/pipeline/base.js b/src/display/change/pipeline/base.js
deleted file mode 100644
index 06212bda..00000000
--- a/src/display/change/pipeline/base.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import * as change from '..';
-import { Commands } from '../../../command';
-import { createCommandHandler } from './utils';
-
-export const basePipeline = {
- show: {
- keys: ['show'],
- handler: createCommandHandler(Commands.ShowCommand, change.changeShow),
- },
-};
diff --git a/src/display/change/pipeline/component.js b/src/display/change/pipeline/component.js
deleted file mode 100644
index 6df2ee61..00000000
--- a/src/display/change/pipeline/component.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import * as change from '..';
-import { Commands } from '../../../command';
-import { basePipeline } from './base';
-import { createCommandHandler } from './utils';
-
-export const componentPipeline = {
- ...basePipeline,
- tint: {
- keys: ['color', 'tint'],
- handler: createCommandHandler(Commands.TintCommand, change.changeTint),
- },
- texture: {
- keys: ['texture'],
- handler: (component, config, options) => {
- change.changeTexture(component, config, options);
- },
- },
- asset: {
- keys: ['asset'],
- handler: (component, config, options) => {
- change.changeAsset(component, config, options);
- },
- },
- textureTransform: {
- keys: ['texture'],
- handler: (component) => {
- change.changeTextureTransform(component);
- },
- },
- animation: {
- keys: ['animation'],
- handler: (component, config) => {
- change.changeAnimation(component, config);
- },
- },
- percentSize: {
- keys: ['percentWidth', 'percentHeight', 'margin'],
- handler: (component, config, options) => {
- change.changePercentSize(component, config, options);
- change.changePlacement(component, {});
- },
- },
- size: {
- keys: ['size'],
- handler: (component, config) => {
- change.changeSize(component, config);
- change.changePlacement(component, {});
- },
- },
- placement: {
- keys: ['placement', 'margin'],
- handler: change.changePlacement,
- },
- text: {
- keys: ['text', 'split'],
- handler: (component, config, options) => {
- change.changeText(component, config, options);
- change.changePlacement(component, config); // Ensure placement is updated after text change
- },
- },
- textStyle: {
- keys: ['style', 'margin'],
- handler: (component, config, options) => {
- change.changeTextStyle(component, config, options);
- change.changePlacement(component, config); // Ensure placement is updated after style change
- },
- },
-};
diff --git a/src/display/change/pipeline/element.js b/src/display/change/pipeline/element.js
deleted file mode 100644
index 41a0954d..00000000
--- a/src/display/change/pipeline/element.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import * as change from '..';
-import { Commands } from '../../../command';
-import { updateComponents } from '../../update/update-components';
-import { basePipeline } from './base';
-import { createCommandHandler } from './utils';
-
-export const elementPipeline = {
- ...basePipeline,
- position: {
- keys: ['position'],
- handler: createCommandHandler(
- Commands.PositionCommand,
- change.changePosition,
- ),
- },
- gridComponents: {
- keys: ['components'],
- handler: (element, config, options) => {
- for (const cell of element.children) {
- updateComponents(cell, config, options);
- }
- },
- },
- components: {
- keys: ['components'],
- handler: updateComponents,
- },
- links: {
- keys: ['links'],
- handler: change.changeLinks,
- },
- strokeStyle: {
- keys: ['strokeStyle'],
- handler: change.changeStrokeStyle,
- },
-};
diff --git a/src/display/change/pipeline/utils.js b/src/display/change/pipeline/utils.js
deleted file mode 100644
index 6a860a03..00000000
--- a/src/display/change/pipeline/utils.js
+++ /dev/null
@@ -1,12 +0,0 @@
-export const createCommandHandler = (Command, changeFn) => {
- return (object, config, options) => {
- const { undoRedoManager } = options;
- if (options?.historyId) {
- undoRedoManager.execute(new Command(object, config, options), {
- historyId: options.historyId,
- });
- } else {
- changeFn(object, config, options);
- }
- };
-};
diff --git a/src/display/change/placement.js b/src/display/change/placement.js
deleted file mode 100644
index aec17e41..00000000
--- a/src/display/change/placement.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import { parseMargin } from '../utils';
-import { updateConfig } from './utils';
-
-export const changePlacement = (
- object,
- { placement = object.config.placement, margin = object.config.margin },
-) => {
- if (!placement || !margin) return;
-
- const directionMap = {
- left: { h: 'left', v: 'center' },
- right: { h: 'right', v: 'center' },
- top: { h: 'center', v: 'top' },
- bottom: { h: 'center', v: 'bottom' },
- center: { h: 'center', v: 'center' },
- };
- const marginObj = parseMargin(margin);
-
- const [first, second] = placement.split('-');
- const directions = second ? { h: first, v: second } : directionMap[first];
-
- object.visible = false;
- const x = getHorizontalPosition(object, directions.h, marginObj);
- const y = getVerticalPosition(object, directions.v, marginObj);
- object.position.set(x, y);
- object.visible = true;
- updateConfig(object, { placement, margin });
-
- function getHorizontalPosition(component, alignment, margin) {
- const parentWidth = component.parent.size.width;
- const positions = {
- left: margin.left,
- right: parentWidth - component.width - margin.right,
- center: (parentWidth - component.width) / 2,
- };
- return positions[alignment] ?? positions.center;
- }
-
- function getVerticalPosition(component, alignment, margin) {
- const parentHeight = component.parent.size.height;
- const positions = {
- top: margin.top,
- bottom: parentHeight - component.height - margin.bottom,
- center: (parentHeight - component.height) / 2,
- };
- return positions[alignment] ?? positions.center;
- }
-};
diff --git a/src/display/change/position.js b/src/display/change/position.js
deleted file mode 100644
index d56a819a..00000000
--- a/src/display/change/position.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import { updateConfig } from './utils';
-
-export const changePosition = (object, { position }) => {
- object.position.set(position.x, position.y);
- updateConfig(object, { position });
-};
diff --git a/src/display/change/property.js b/src/display/change/property.js
deleted file mode 100644
index 933cbbf5..00000000
--- a/src/display/change/property.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import { deepMerge } from '../../utils/deepmerge/deepmerge';
-
-export const changeProperty = (object, key, value) => {
- deepMerge(object, { [key]: value });
-};
diff --git a/src/display/change/show.js b/src/display/change/show.js
deleted file mode 100644
index 735f171f..00000000
--- a/src/display/change/show.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import { updateConfig } from './utils';
-
-export const changeShow = (object, { show }) => {
- object.renderable = show;
- updateConfig(object, { show });
-};
diff --git a/src/display/change/size.js b/src/display/change/size.js
deleted file mode 100644
index da7cf193..00000000
--- a/src/display/change/size.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import { updateConfig } from './utils';
-
-export const changeSize = (object, { size = object.config.size }) => {
- object.setSize(size);
- updateConfig(object, { size });
-};
diff --git a/src/display/change/stroke-style.js b/src/display/change/stroke-style.js
deleted file mode 100644
index e3e72627..00000000
--- a/src/display/change/stroke-style.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import { getColor } from '../../utils/get';
-import { selector } from '../../utils/selector/selector';
-import { updateConfig } from './utils';
-
-export const changeStrokeStyle = (
- object,
- { strokeStyle, links },
- { theme },
-) => {
- const path = selector(object, '$.children[?(@.type==="path")]')[0];
- if (!path) return;
-
- if ('color' in strokeStyle) {
- strokeStyle.color = getColor(theme, strokeStyle.color);
- }
-
- path.setStrokeStyle({ ...path.strokeStyle, ...strokeStyle });
- if (!links && path.links.length > 0) {
- reRenderPath(path);
- }
- updateConfig(object, { strokeStyle });
-
- function reRenderPath(path) {
- path.clear();
- const { links } = path;
- for (let i = 0; i < path.links.length; i++) {
- const { sourcePoint, targetPoint } = links[i];
- if (i === 0 || !pointsMatch(links[i - 1].targetPoint, sourcePoint)) {
- path.moveTo(...sourcePoint);
- }
- path.lineTo(...targetPoint);
- }
- path.stroke();
- }
-
- function pointsMatch([x1, y1], [x2, y2]) {
- return x1 === x2 && y1 === y2;
- }
-};
diff --git a/src/display/change/text-style.js b/src/display/change/text-style.js
deleted file mode 100644
index f262d63a..00000000
--- a/src/display/change/text-style.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import { getColor } from '../../utils/get';
-import { FONT_WEIGHT } from '../components/config';
-import { parseMargin } from '../utils';
-import { isConfigMatch, updateConfig } from './utils';
-
-export const changeTextStyle = (
- object,
- { style = object.config.style, margin = object.config.margin },
- { theme },
-) => {
- if (
- isConfigMatch(object, 'style', style) &&
- isConfigMatch(object, 'margin', margin)
- ) {
- return;
- }
-
- for (const key in style) {
- if (key === 'fontFamily' || key === 'fontWeight') {
- object.style.fontFamily = `${style.fontFamily ?? object.style.fontFamily.split(' ')[0]} ${FONT_WEIGHT[style.fontWeight ?? object.style.fontWeight]}`;
- } else if (key === 'fill') {
- object.style[key] = getColor(theme, style.fill);
- } else if (key === 'fontSize' && style[key] === 'auto') {
- const marginObj = parseMargin(margin);
- setAutoFontSize(object, marginObj);
- } else {
- object.style[key] = style[key];
- }
- }
- updateConfig(object, { style, margin });
-
- function setAutoFontSize(component, margin) {
- component.visible = false;
- const { width, height } = component.parent.getSize();
- const parentSize = {
- width: width - margin.left - margin.right,
- height: height - margin.top - margin.bottom,
- };
- component.visible = true;
-
- let minSize = 1;
- let maxSize = 100;
-
- while (minSize <= maxSize) {
- const fontSize = Math.floor((minSize + maxSize) / 2);
- component.style.fontSize = fontSize;
-
- const metrics = component.getLocalBounds();
- if (
- metrics.width <= parentSize.width &&
- metrics.height <= parentSize.height
- ) {
- minSize = fontSize + 1;
- } else {
- maxSize = fontSize - 1;
- }
- }
- }
-};
diff --git a/src/display/change/text.js b/src/display/change/text.js
deleted file mode 100644
index 1126d28b..00000000
--- a/src/display/change/text.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { changeTextStyle } from './text-style';
-import { isConfigMatch, updateConfig } from './utils';
-
-export const changeText = (
- object,
- { text = object.config.text, split = object.config.split },
- { theme },
-) => {
- if (
- isConfigMatch(object, 'text', text) &&
- isConfigMatch(object, 'split', split)
- ) {
- return;
- }
-
- object.text = splitText(text, split);
-
- if (object.config?.style?.fontSize === 'auto') {
- changeTextStyle(object, { style: { fontSize: 'auto' } }, { theme });
- }
- updateConfig(object, { text, split });
-
- function splitText(text, chunkSize) {
- if (chunkSize === 0 || chunkSize == null) {
- return text;
- }
- let result = '';
- for (let i = 0; i < text.length; i += chunkSize) {
- result += `${text.slice(i, i + chunkSize)}\n`;
- }
- return result.trim();
- }
-};
diff --git a/src/display/change/texture-transform.js b/src/display/change/texture-transform.js
deleted file mode 100644
index 40354414..00000000
--- a/src/display/change/texture-transform.js
+++ /dev/null
@@ -1,10 +0,0 @@
-export const changeTextureTransform = (object) => {
- const borderWidth = object.texture.metadata.borderWidth;
- if (!borderWidth) return;
- const parentSize = object.parent.size;
- object.setSize(
- parentSize.width + borderWidth,
- parentSize.height + borderWidth,
- );
- object.position.set(-borderWidth / 2);
-};
diff --git a/src/display/change/texture.js b/src/display/change/texture.js
deleted file mode 100644
index 485a57c4..00000000
--- a/src/display/change/texture.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { getTexture } from '../../assets/textures/texture';
-import { deepMerge } from '../../utils/deepmerge/deepmerge';
-import { getViewport } from '../../utils/get';
-import { isConfigMatch, updateConfig } from './utils';
-
-export const changeTexture = (
- object,
- { texture: textureConfig },
- { theme },
-) => {
- if (isConfigMatch(object, 'texture', textureConfig)) {
- return;
- }
-
- const renderer = getViewport(object).app.renderer;
- const texture = getTexture(
- renderer,
- theme,
- deepMerge(object.texture?.metadata?.config, textureConfig),
- );
- object.texture = texture ?? null;
- Object.assign(object, { ...texture.metadata.slice });
- updateConfig(object, { texture: textureConfig });
-};
diff --git a/src/display/change/tint.js b/src/display/change/tint.js
deleted file mode 100644
index 1cba08b4..00000000
--- a/src/display/change/tint.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { getColor } from '../../utils/get';
-import { updateConfig } from './utils';
-
-export const changeTint = (object, { color, tint }, { theme }) => {
- const hexColor = getColor(theme, tint ?? color);
- object.tint = hexColor;
- updateConfig(object, { tint });
-};
diff --git a/src/display/change/utils.js b/src/display/change/utils.js
deleted file mode 100644
index 08b5d5e4..00000000
--- a/src/display/change/utils.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import gsap from 'gsap';
-import { deepMerge } from '../../utils/deepmerge/deepmerge';
-import { isSame } from '../../utils/diff/isSame';
-
-export const isConfigMatch = (object, key, value) => {
- return value == null || isSame(object.config[key], value);
-};
-
-export const updateConfig = (object, config, overwrite = false) => {
- if (overwrite) {
- object.config = { ...object.config, ...config };
- } else {
- object.config = deepMerge(object.config, config);
- }
-};
-
-export const tweensOf = (object) => gsap.getTweensOf(object);
-
-export const killTweensOf = (object) => gsap.killTweensOf(object);
diff --git a/src/display/components/Background.js b/src/display/components/Background.js
new file mode 100644
index 00000000..63264ba6
--- /dev/null
+++ b/src/display/components/Background.js
@@ -0,0 +1,27 @@
+import { NineSliceSprite, Texture } from 'pixi.js';
+import { backgroundSchema } from '../data-schema/component-schema';
+import { Base } from '../mixins/Base';
+import { ComponentSizeable } from '../mixins/Componentsizeable';
+import { Showable } from '../mixins/Showable';
+import { Sourceable } from '../mixins/Sourceable';
+import { Tintable } from '../mixins/Tintable';
+import { mixins } from '../mixins/utils';
+
+const ComposedBackground = mixins(
+ NineSliceSprite,
+ Base,
+ Showable,
+ Sourceable,
+ Tintable,
+ ComponentSizeable,
+);
+
+export class Background extends ComposedBackground {
+ constructor(context) {
+ super({ type: 'background', context, texture: Texture.WHITE });
+ }
+
+ update(changes, options) {
+ super.update(changes, backgroundSchema, options);
+ }
+}
diff --git a/src/display/components/Bar.js b/src/display/components/Bar.js
new file mode 100644
index 00000000..c2c05450
--- /dev/null
+++ b/src/display/components/Bar.js
@@ -0,0 +1,40 @@
+import { NineSliceSprite, Texture } from 'pixi.js';
+import { barSchema } from '../data-schema/component-schema';
+import { Animationable } from '../mixins/Animationable';
+import { AnimationSizeable } from '../mixins/Animationsizeable';
+import { Base } from '../mixins/Base';
+import { Placementable } from '../mixins/Placementable';
+import { Showable } from '../mixins/Showable';
+import { Sourceable } from '../mixins/Sourceable';
+import { Tintable } from '../mixins/Tintable';
+import { mixins } from '../mixins/utils';
+
+const EXTRA_KEYS = {
+ PLACEMENT: ['source', 'size'],
+};
+
+const ComposedBar = mixins(
+ NineSliceSprite,
+ Base,
+ Showable,
+ Sourceable,
+ Tintable,
+ Animationable,
+ AnimationSizeable,
+ Placementable,
+);
+
+export class Bar extends ComposedBar {
+ constructor(context) {
+ super({ type: 'bar', context, texture: Texture.WHITE });
+
+ this.constructor.registerHandler(
+ EXTRA_KEYS.PLACEMENT,
+ this._applyPlacement,
+ );
+ }
+
+ update(changes, options) {
+ super.update(changes, barSchema, options);
+ }
+}
diff --git a/src/display/components/Icon.js b/src/display/components/Icon.js
new file mode 100644
index 00000000..23abdc02
--- /dev/null
+++ b/src/display/components/Icon.js
@@ -0,0 +1,38 @@
+import { Sprite, Texture } from 'pixi.js';
+import { iconSchema } from '../data-schema/component-schema';
+import { Base } from '../mixins/Base';
+import { ComponentSizeable } from '../mixins/Componentsizeable';
+import { Placementable } from '../mixins/Placementable';
+import { Showable } from '../mixins/Showable';
+import { Sourceable } from '../mixins/Sourceable';
+import { Tintable } from '../mixins/Tintable';
+import { mixins } from '../mixins/utils';
+
+const EXTRA_KEYS = {
+ PLACEMENT: ['source', 'size'],
+};
+
+const ComposedIcon = mixins(
+ Sprite,
+ Base,
+ Showable,
+ Sourceable,
+ Tintable,
+ ComponentSizeable,
+ Placementable,
+);
+
+export class Icon extends ComposedIcon {
+ constructor(context) {
+ super({ type: 'icon', context, texture: Texture.WHITE });
+
+ this.constructor.registerHandler(
+ EXTRA_KEYS.PLACEMENT,
+ this._applyPlacement,
+ );
+ }
+
+ update(changes, options) {
+ super.update(changes, iconSchema, options);
+ }
+}
diff --git a/src/display/components/Text.js b/src/display/components/Text.js
new file mode 100644
index 00000000..3d559eb2
--- /dev/null
+++ b/src/display/components/Text.js
@@ -0,0 +1,41 @@
+import { BitmapText } from 'pixi.js';
+import { textSchema } from '../data-schema/component-schema';
+import { Base } from '../mixins/Base';
+import { Placementable } from '../mixins/Placementable';
+import { Showable } from '../mixins/Showable';
+import { Textable } from '../mixins/Textable';
+import { Textstyleable } from '../mixins/Textstyleable';
+import { mixins } from '../mixins/utils';
+
+const EXTRA_KEYS = {
+ PLACEMENT: ['text', 'split'],
+};
+
+const ComposedText = mixins(
+ BitmapText,
+ Base,
+ Showable,
+ Textable,
+ Textstyleable,
+ Placementable,
+);
+
+export class Text extends ComposedText {
+ constructor(context) {
+ super({
+ type: 'text',
+ context,
+ text: '',
+ style: { fontFamily: 'FiraCode regular', fill: 'black' },
+ });
+
+ this.constructor.registerHandler(
+ EXTRA_KEYS.PLACEMENT,
+ this._applyPlacement,
+ );
+ }
+
+ update(changes, options) {
+ super.update(changes, textSchema, options);
+ }
+}
diff --git a/src/display/components/background.js b/src/display/components/background.js
deleted file mode 100644
index c7703d21..00000000
--- a/src/display/components/background.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { NineSliceSprite, Texture } from 'pixi.js';
-import { componentPipeline } from '../change/pipeline/component';
-import { updateObject } from '../update/update-object';
-
-export const backgroundComponent = () => {
- const component = new NineSliceSprite({ texture: Texture.WHITE });
- component.type = 'background';
- component.id = null;
- component.config = {};
- return component;
-};
-
-const pipelineKeys = ['show', 'texture', 'textureTransform', 'tint'];
-export const updateBackgroundComponent = (component, config, options) => {
- updateObject(component, config, componentPipeline, pipelineKeys, options);
-};
diff --git a/src/display/components/bar.js b/src/display/components/bar.js
deleted file mode 100644
index a3c0aff0..00000000
--- a/src/display/components/bar.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { NineSliceSprite, Texture } from 'pixi.js';
-import { componentPipeline } from '../change/pipeline/component';
-import { updateObject } from '../update/update-object';
-
-export const barComponent = () => {
- const component = new NineSliceSprite({ texture: Texture.WHITE });
- component.type = 'bar';
- component.id = null;
- component.config = {};
- return component;
-};
-
-const pipelineKeys = [
- 'animation',
- 'show',
- 'texture',
- 'tint',
- 'percentSize',
- 'placement',
-];
-export const updateBarComponent = (component, config, options) => {
- updateObject(component, config, componentPipeline, pipelineKeys, options);
-};
diff --git a/src/display/components/creator.js b/src/display/components/creator.js
new file mode 100644
index 00000000..f0f67b0c
--- /dev/null
+++ b/src/display/components/creator.js
@@ -0,0 +1,21 @@
+/**
+ * @fileoverview Component creation factory.
+ *
+ * To solve a circular dependency issue, this module does not import specific component classes directly.
+ * Instead, it uses a registration pattern where classes are registered via `registerComponent` and instantiated via `newComponent`.
+ *
+ * The registration of component classes is handled explicitly in `registry.js` at the application's entry point.
+ */
+
+const creator = {};
+
+export const registerComponent = (type, componentClass) => {
+ creator[type] = componentClass;
+};
+
+export const newComponent = (type, context) => {
+ if (!creator[type]) {
+ throw new Error(`Component type "${type}" has not been registered.`);
+ }
+ return new creator[type](context);
+};
diff --git a/src/display/components/icon.js b/src/display/components/icon.js
deleted file mode 100644
index 5e37a53f..00000000
--- a/src/display/components/icon.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { Sprite, Texture } from 'pixi.js';
-import { componentPipeline } from '../change/pipeline/component';
-import { updateObject } from '../update/update-object';
-
-export const iconComponent = () => {
- const component = new Sprite(Texture.WHITE);
- component.type = 'icon';
- component.id = null;
- component.config = {};
- return component;
-};
-
-const pipelineKeys = ['show', 'asset', 'size', 'tint', 'placement'];
-export const updateIconComponent = (component, config, options) => {
- updateObject(component, config, componentPipeline, pipelineKeys, options);
-};
diff --git a/src/display/components/registry.js b/src/display/components/registry.js
new file mode 100644
index 00000000..8b9473dd
--- /dev/null
+++ b/src/display/components/registry.js
@@ -0,0 +1,10 @@
+import { Background } from './Background';
+import { Bar } from './Bar';
+import { Icon } from './Icon';
+import { Text } from './Text';
+import { registerComponent } from './creator';
+
+registerComponent('background', Background);
+registerComponent('bar', Bar);
+registerComponent('icon', Icon);
+registerComponent('text', Text);
diff --git a/src/display/components/text.js b/src/display/components/text.js
deleted file mode 100644
index e2fffd60..00000000
--- a/src/display/components/text.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { BitmapText } from 'pixi.js';
-import { componentPipeline } from '../change/pipeline/component';
-import { updateObject } from '../update/update-object';
-
-export const textComponent = () => {
- const component = new BitmapText({ text: '' });
- component.type = 'text';
- component.id = null;
- component.config = {};
- return component;
-};
-
-const pipelineKeys = ['show', 'text', 'textStyle', 'placement'];
-export const updateTextComponent = (component, config, options) => {
- updateObject(component, config, componentPipeline, pipelineKeys, options);
-};
diff --git a/src/display/data-schema/color-schema.js b/src/display/data-schema/color-schema.js
new file mode 100644
index 00000000..07b6ac05
--- /dev/null
+++ b/src/display/data-schema/color-schema.js
@@ -0,0 +1,40 @@
+import { Color as PixiColor } from 'pixi.js';
+import { z } from 'zod';
+
+export const HslColor = z
+ .object({
+ h: z.number(),
+ s: z.number(),
+ l: z.number(),
+ })
+ .strict();
+
+export const HslaColor = HslColor.extend({
+ a: z.number(),
+});
+
+export const HsvColor = z
+ .object({
+ h: z.number(),
+ s: z.number(),
+ v: z.number(),
+ })
+ .strict();
+
+export const HsvaColor = HsvColor.extend({
+ a: z.number(),
+});
+
+export const RgbColor = z
+ .object({
+ r: z.number(),
+ g: z.number(),
+ b: z.number(),
+ })
+ .strict();
+
+export const RgbaColor = RgbColor.extend({
+ a: z.number(),
+});
+
+export const Color = z.instanceof(PixiColor);
diff --git a/src/display/data-schema/color.d.ts b/src/display/data-schema/color.d.ts
new file mode 100644
index 00000000..f48ea0d5
--- /dev/null
+++ b/src/display/data-schema/color.d.ts
@@ -0,0 +1,65 @@
+/**
+ * color.d.ts
+ *
+ * This file contains TypeScript definitions for various color formats
+ * based on the Zod schemas. It is intended for developers to understand
+ * the valid data structures for color-related properties.
+ */
+
+import type { Color as PixiColor } from 'pixi.js';
+
+/**
+ * An object representing a color in the RGB (Red, Green, Blue) model.
+ * Each channel is a number, typically from 0 to 255.
+ */
+export interface RgbColor {
+ r: number;
+ g: number;
+ b: number;
+}
+
+/**
+ * An object representing a color in the RGBA (Red, Green, Blue, Alpha) model.
+ * The alpha channel represents transparency.
+ */
+export interface RgbaColor extends RgbColor {
+ a: number;
+}
+
+/**
+ * An object representing a color in the HSL (Hue, Saturation, Lightness) model.
+ */
+export interface HslColor {
+ h: number;
+ s: number;
+ l: number;
+}
+
+/**
+ * An object representing a color in the HSLA (Hue, Saturation, Lightness, Alpha) model.
+ */
+export interface HslaColor extends HslColor {
+ a: number;
+}
+
+/**
+ * An object representing a color in the HSV (Hue, Saturation, Value) model.
+ */
+export interface HsvColor {
+ h: number;
+ s: number;
+ v: number;
+}
+
+/**
+ * An object representing a color in the HSVA (Hue, Saturation, Value, Alpha) model.
+ */
+export interface HsvaColor extends HsvColor {
+ a: number;
+}
+
+/**
+ * Represents an instance of the PixiJS Color class.
+ * @see {@link https://pixijs.download/release/docs/color.Color.html}
+ */
+export type Color = PixiColor;
diff --git a/src/display/data-schema/component-schema.js b/src/display/data-schema/component-schema.js
index 89f7a502..9964a575 100644
--- a/src/display/data-schema/component-schema.js
+++ b/src/display/data-schema/component-schema.js
@@ -1,86 +1,82 @@
import { z } from 'zod';
+import {
+ Base,
+ Margin,
+ Placement,
+ PxOrPercentSize,
+ TextStyle,
+ TextureStyle,
+ Tint,
+} from './primitive-schema';
-export const Placement = z.enum([
- 'left',
- 'left-top',
- 'left-bottom',
- 'top',
- 'right',
- 'right-top',
- 'right-bottom',
- 'bottom',
- 'center',
-]);
-
-export const Margin = z.string().regex(/^(\d+(\.\d+)?(\s+\d+(\.\d+)?){0,3})$/);
-
-const TextureType = z.enum(['rect']);
-export const TextureStyle = z
- .object({
- type: TextureType,
- fill: z.nullable(z.string()),
- borderWidth: z.nullable(z.number()),
- borderColor: z.nullable(z.string()),
- radius: z.nullable(z.number()),
- })
- .partial();
-
-const defaultConfig = z
- .object({
- show: z.boolean().default(true),
- })
- .passthrough();
-
-const background = defaultConfig.extend({
+/**
+ * An Item's background, sourced from a style object or an asset URL.
+ * Visually represented by a `NineSliceSprite`.
+ * @see {@link https://pixijs.download/release/docs/scene.NineSliceSprite.html}
+ */
+export const backgroundSchema = Base.extend({
type: z.literal('background'),
- texture: TextureStyle,
-});
+ source: z.union([TextureStyle, z.string()]),
+ size: z
+ .any()
+ .optional()
+ .transform(() => ({
+ width: { value: 100, unit: '%' },
+ height: { value: 100, unit: '%' },
+ })),
+ tint: Tint.optional(),
+}).strict();
-const bar = defaultConfig.extend({
+/**
+ * A component for progress bars or bar graphs.
+ * Visually represented by a `NineSliceSprite`.
+ * @see {@link https://pixijs.download/release/docs/scene.NineSliceSprite.html}
+ */
+export const barSchema = Base.extend({
type: z.literal('bar'),
- texture: TextureStyle,
+ source: TextureStyle,
+ size: PxOrPercentSize,
placement: Placement.default('bottom'),
- margin: Margin.default('0'),
- percentWidth: z.number().min(0).max(1).default(1),
- percentHeight: z.number().min(0).max(1).default(1),
+ margin: Margin.default(0),
+ tint: Tint.optional(),
animation: z.boolean().default(true),
animationDuration: z.number().default(200),
-});
+}).strict();
-const icon = defaultConfig.extend({
+/**
+ * A component for displaying an icon image.
+ * Visually represented by a `Sprite`.
+ * @see {@link https://pixijs.download/release/docs/scene.Sprite.html}
+ */
+export const iconSchema = Base.extend({
type: z.literal('icon'),
- asset: z.string(),
+ source: z.string(),
+ size: PxOrPercentSize,
placement: Placement.default('center'),
- margin: Margin.default('0'),
- size: z.number().nonnegative(),
-});
+ margin: Margin.default(0),
+ tint: Tint.optional(),
+}).strict();
-const text = defaultConfig.extend({
+/**
+ * A text label component.
+ * Visually represented by a `BitmapText`.
+ * @see {@link https://pixijs.download/release/docs/scene.BitmapText.html}
+ */
+export const textSchema = Base.extend({
type: z.literal('text'),
placement: Placement.default('center'),
- margin: Margin.default('0'),
+ margin: Margin.default(0),
+ tint: Tint.optional(),
text: z.string().default(''),
- style: z
- .preprocess(
- (val) => ({
- fontFamily: 'FiraCode',
- fontWeight: 400,
- fill: 'black',
- ...val,
- }),
- z.record(z.unknown()),
- )
- .default({}),
+ style: TextStyle.optional(),
split: z.number().int().default(0),
-});
+}).strict();
export const componentSchema = z.discriminatedUnion('type', [
- background,
- bar,
- icon,
- text,
+ backgroundSchema,
+ barSchema,
+ iconSchema,
+ textSchema,
]);
-export const componentArraySchema = z
- .discriminatedUnion('type', [background, bar, icon, text])
- .array();
+export const componentArraySchema = componentSchema.array();
diff --git a/src/display/data-schema/component-schema.test.js b/src/display/data-schema/component-schema.test.js
index 86210afa..e2743447 100644
--- a/src/display/data-schema/component-schema.test.js
+++ b/src/display/data-schema/component-schema.test.js
@@ -1,257 +1,264 @@
-import { describe, expect, it } from 'vitest';
-import { componentArraySchema, componentSchema } from './component-schema';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { uid } from '../../utils/uuid';
+import {
+ backgroundSchema,
+ barSchema,
+ componentArraySchema,
+ componentSchema,
+ iconSchema,
+ textSchema,
+} from './component-schema.js';
-describe('componentArraySchema', () => {
- // --------------------------------------------------------------------------
- // 1) 전체 레이아웃 배열 구조 테스트
- // --------------------------------------------------------------------------
- it('should validate a valid component array with multiple items', () => {
- const validComponent = [
- {
- type: 'background',
- texture: { type: 'rect' },
- },
- {
- type: 'bar',
- texture: { type: 'rect' },
- percentWidth: 0.5,
- percentHeight: 1,
- show: false,
- },
- {
- type: 'icon',
- asset: 'icon.png',
- size: 32,
- zIndex: 10,
- },
- {
- type: 'text',
- placement: 'top',
- text: 'Hello World',
- },
- ];
+// Mocking a unique ID generator for predictable test outcomes.
+vi.mock('../../utils/uuid', () => ({
+ uid: vi.fn(),
+}));
- const result = componentArraySchema.safeParse(validComponent);
- expect(result.success).toBe(true);
- expect(result.success && result.data).toBeDefined();
- });
+beforeEach(() => {
+ vi.mocked(uid).mockClear();
+ vi.mocked(uid).mockReturnValue('mock-id-0');
+});
- it('should fail if an invalid type is present in the component', () => {
- const invalidComponent = {
- // 여기에 존재하지 않는 type
- type: 'wrongType',
- texture: { type: 'rect' },
- };
+describe('Component Schemas', () => {
+ describe('Background Schema', () => {
+ it('should parse with a string source', () => {
+ const data = { type: 'background', source: 'image.png' };
+ const parsed = backgroundSchema.parse(data);
+ expect(parsed.source).toBe('image.png');
+ expect(parsed.id).toBe('mock-id-0'); // check default from Base
+ });
- const result = componentSchema.safeParse(invalidComponent);
- expect(result.success).toBe(false);
- });
+ it('should parse with a TextureStyle object source', () => {
+ const data = {
+ type: 'background',
+ source: { type: 'rect', fill: 'red' },
+ };
+ const parsed = backgroundSchema.parse(data);
+ expect(parsed.source).toEqual({ type: 'rect', fill: 'red' });
+ });
- // --------------------------------------------------------------------------
- // 2) background 타입 테스트
- // --------------------------------------------------------------------------
- describe('background type', () => {
- it('should pass with valid background data', () => {
- const data = [
- {
- type: 'background',
- texture: { type: 'rect' },
- },
- ];
- const result = componentArraySchema.safeParse(data);
- expect(result.success).toBe(true);
+ it('should fail with an invalid source type', () => {
+ const data = { type: 'background', source: 123 };
+ expect(() => backgroundSchema.parse(data)).toThrow();
});
- it('should fail if texture is missing', () => {
- const data = [
- {
- type: 'background',
- },
- ];
- const result = componentArraySchema.safeParse(data);
- expect(result.success).toBe(false);
+ it('should fail if an unknown property is provided', () => {
+ const data = {
+ type: 'background',
+ source: 'image.png',
+ unknown: 'property',
+ };
+ expect(() => backgroundSchema.parse(data)).toThrow();
});
});
- // --------------------------------------------------------------------------
- // 3) bar 타입 테스트
- // --------------------------------------------------------------------------
- describe('bar type', () => {
- it('should pass with valid bar data (checking defaults too)', () => {
- const data = [
- {
- type: 'bar',
- texture: { type: 'rect' },
- },
- ];
- const result = componentArraySchema.safeParse(data);
- expect(result.success).toBe(true);
+ describe('Bar Schema', () => {
+ const baseBar = {
+ type: 'bar',
+ source: { type: 'rect', fill: 'blue' },
+ };
- // percentWidth, percentHeight는 기본값이 1로 설정됨
- if (result.success) {
- expect(result.data[0].percentWidth).toBe(1);
- expect(result.data[0].percentHeight).toBe(1);
- // show, zIndex 등 defaultConfig 값도 확인
- expect(result.data[0].show).toBe(true);
- }
+ it('should parse a minimal valid bar and transform size to an object', () => {
+ const data = { ...baseBar, size: 100 }; // Input size is a single number
+ const parsed = barSchema.parse(data);
+ expect(parsed.placement).toBe('bottom');
+ expect(parsed.margin).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
+ expect(parsed.animation).toBe(true);
+ expect(parsed.animationDuration).toBe(200);
+ // The single number `100` is transformed into a full width/height object.
+ expect(parsed.size).toEqual({
+ width: { value: 100, unit: 'px' },
+ height: { value: 100, unit: 'px' },
+ });
});
- it('should fail if percentWidth is larger than 1', () => {
- const data = [
- {
- type: 'bar',
- texture: { type: 'rect' },
- percentWidth: 1.1,
- },
- ];
- const result = componentArraySchema.safeParse(data);
- expect(result.success).toBe(false);
+ it('should correctly parse an object size', () => {
+ const data = {
+ ...baseBar,
+ size: { width: '50%', height: 20 },
+ };
+ const parsed = barSchema.parse(data);
+ // The size object is parsed correctly.
+ expect(parsed.size).toEqual({
+ width: { value: 50, unit: '%' },
+ height: { value: 20, unit: 'px' },
+ });
});
- it('should fail if placement is not in the enum list', () => {
- const data = [
- {
- type: 'bar',
- texture: { type: 'rect' },
- placement: 'unknown-placement',
+ it.each([
+ {
+ case: 'single number',
+ input: 150,
+ expected: {
+ width: { value: 150, unit: 'px' },
+ height: { value: 150, unit: 'px' },
},
- ];
- const result = componentArraySchema.safeParse(data);
- expect(result.success).toBe(false);
- });
- });
-
- // --------------------------------------------------------------------------
- // 4) icon 타입 테스트
- // --------------------------------------------------------------------------
- describe('icon type', () => {
- it('should pass with valid icon data (checking defaults)', () => {
- const data = [
- {
- type: 'icon',
- asset: 'icon.png',
- size: 64,
+ },
+ {
+ case: 'percentage string',
+ input: '75%',
+ expected: {
+ width: { value: 75, unit: '%' },
+ height: { value: 75, unit: '%' },
},
- ];
- const result = componentArraySchema.safeParse(data);
- expect(result.success).toBe(true);
+ },
+ ])(
+ 'should correctly parse and transform different valid size formats: $case',
+ ({ input, expected }) => {
+ const data = { ...baseBar, size: input };
+ const parsed = barSchema.parse(data);
+ expect(parsed.size).toEqual(expected);
+ },
+ );
- if (result.success) {
- expect(result.data[0].show).toBe(true);
- }
+ it('should fail if required `size` or `source` is missing', () => {
+ const dataWithoutSource = { type: 'bar', size: 100 };
+ const dataWithoutSize = { type: 'bar', source: { type: 'rect' } };
+ expect(() => barSchema.parse(dataWithoutSource)).toThrow();
+ expect(() => barSchema.parse(dataWithoutSize)).toThrow();
});
- it('should fail if size is negative', () => {
- const data = [
- {
- type: 'icon',
- asset: 'icon.png',
- size: -1,
- },
- ];
- const result = componentArraySchema.safeParse(data);
- expect(result.success).toBe(false);
+ it('should fail if size is a partial object', () => {
+ const dataWithPartialWidth = { ...baseBar, size: { width: '25%' } }; // Missing height
+ const dataWithPartialHeight = { ...baseBar, size: { height: 20 } }; // Missing width
+ expect(() => barSchema.parse(dataWithPartialWidth)).toThrow();
+ expect(() => barSchema.parse(dataWithPartialHeight)).toThrow();
});
- it('should fail if texture is missing in icon', () => {
- const data = [
- {
- type: 'icon',
- size: 32,
- },
- ];
- const result = componentArraySchema.safeParse(data);
- expect(result.success).toBe(false);
+ it('should fail if an unknown property is provided', () => {
+ const data = { ...baseBar, size: 100, another: 'property' };
+ expect(() => barSchema.parse(data)).toThrow();
});
});
- // --------------------------------------------------------------------------
- // 5) text 타입 테스트
- // --------------------------------------------------------------------------
- describe('text type', () => {
- it('should pass with minimal text data (checking defaults)', () => {
- const data = [
- {
- type: 'text',
- },
- ];
- const result = componentArraySchema.safeParse(data);
- expect(result.success).toBe(true);
+ describe('Icon Schema', () => {
+ const baseIcon = { type: 'icon', source: 'icon.svg' };
- if (result.success) {
- // text 디폴트값 확인
- expect(result.data[0].text).toBe('');
- // placement 디폴트값 확인
- expect(result.data[0].placement).toBe('center');
- }
+ it('should parse a minimal valid icon and transform size to an object', () => {
+ const data = { ...baseIcon, size: 50 };
+ const parsed = iconSchema.parse(data);
+ expect(parsed.placement).toBe('center');
+ expect(parsed.margin).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
+ // The single number `50` is transformed into a full width/height object.
+ expect(parsed.size).toEqual({
+ width: { value: 50, unit: 'px' },
+ height: { value: 50, unit: 'px' },
+ });
});
- it('should pass with a custom style object', () => {
- const data = [
- {
- type: 'text',
- text: 'Hello!',
- style: {
- fontWeight: 'bold',
- customProp: 123,
- },
+ it.each([
+ {
+ case: 'percentage string',
+ input: '75%',
+ expected: {
+ width: { value: 75, unit: '%' },
+ height: { value: 75, unit: '%' },
},
- ];
- const result = componentArraySchema.safeParse(data);
- expect(result.success).toBe(true);
+ },
+ {
+ case: 'object with width and height',
+ input: { width: 100, height: '100%' },
+ expected: {
+ width: { value: 100, unit: 'px' },
+ height: { value: 100, unit: '%' },
+ },
+ },
+ ])(
+ 'should parse and transform correctly with different size properties: $case',
+ ({ input, expected }) => {
+ const data = { ...baseIcon, size: input };
+ const parsed = iconSchema.parse(data);
+ expect(parsed.size).toEqual(expected);
+ },
+ );
+
+ it('should fail if required `source` or `size` is missing', () => {
+ const dataWithoutSource = { type: 'icon', size: 50 };
+ const dataWithoutSize = { type: 'icon', source: 'icon.svg' };
+ expect(() => iconSchema.parse(dataWithoutSource)).toThrow();
+ expect(() => iconSchema.parse(dataWithoutSize)).toThrow();
});
- it('should fail if placement is invalid in text', () => {
- const data = [
- {
- type: 'text',
- placement: 'invalid-placement',
- },
- ];
- const result = componentArraySchema.safeParse(data);
- expect(result.success).toBe(false);
+ it('should fail if size is a partial object', () => {
+ const dataWithPartialWidth = { ...baseIcon, size: { width: '25%' } }; // Missing height
+ const dataWithPartialHeight = { ...baseIcon, size: { height: 20 } }; // Missing width
+ expect(() => iconSchema.parse(dataWithPartialWidth)).toThrow();
+ expect(() => iconSchema.parse(dataWithPartialHeight)).toThrow();
+ });
+
+ it('should fail if an unknown property is provided', () => {
+ const data = { ...baseIcon, size: 50, extra: 'property' };
+ expect(() => iconSchema.parse(data)).toThrow();
});
});
- // --------------------------------------------------------------------------
- // 6) 기타 에러 케이스 / 엣지 케이스
- // --------------------------------------------------------------------------
- describe('edge cases', () => {
- it('should fail if array is empty (though empty array is actually valid syntax-wise, might be logic error)', () => {
- // 스키마상 빈 배열도 통과하지만,
- // "레이아웃은 최소 1개 이상의 요소가 있어야 한다"라는
- // 요구사항이 있는 경우를 가정해볼 수 있음.
- // 만약 최소 1개 이상이 필요하면 .min(1)을 사용하세요.
- const data = [];
- // 현재 스키마는 빈 배열도 가능하므로 success가 true가 됩니다.
- // 정말로 실패를 원한다면 아래 주석 처럼 변경해주세요:
- // export const componentArraySchema = z
- // .discriminatedUnion('type', [background, bar, icon, text])
- // .array().min(1);
- const result = componentArraySchema.safeParse(data);
- // 여기서는 빈 배열도 "성공" 케이스임
- expect(result.success).toBe(true);
+ describe('Text Schema', () => {
+ it('should parse valid text and apply all defaults', () => {
+ const parsed = textSchema.parse({ type: 'text' });
+ expect(parsed.text).toBe('');
+ expect(parsed.split).toBe(0);
+ expect(parsed.placement).toBe('center');
});
- it('should fail if the input is not an array at all', () => {
+ it('should correctly merge provided styles with defaults', () => {
const data = {
- type: 'background',
- texture: { type: 'rect' },
+ type: 'text',
+ style: { fill: 'red', fontSize: 24 },
};
- const result = componentArraySchema.safeParse(data);
+ const parsed = textSchema.parse(data);
+ expect(parsed.style.fill).toBe('red'); // Overridden
+ expect(parsed.style.fontSize).toBe(24); // Added
+ });
+ });
+
+ describe('componentSchema (Discriminated Union)', () => {
+ it.each([
+ { case: 'a valid background', data: { type: 'background', source: 'a' } },
+ {
+ case: 'a valid bar',
+ data: { type: 'bar', source: { type: 'rect' }, size: 100 },
+ },
+ {
+ case: 'a valid icon',
+ data: { type: 'icon', source: 'a', size: '50%' },
+ },
+ { case: 'a valid text', data: { type: 'text' } },
+ ])('should correctly parse $case', ({ data }) => {
+ expect(() => componentSchema.parse(data)).not.toThrow();
+ });
+
+ it('should fail for an object with an unknown type', () => {
+ const data = { type: 'chart', value: 100 };
+ const result = componentSchema.safeParse(data);
expect(result.success).toBe(false);
+ expect(result.error.issues[0].code).toBe('invalid_union_discriminator');
});
+ });
- it('should fail if non-object is included in the array', () => {
+ describe('componentArraySchema', () => {
+ it('should parse a valid array of mixed components', () => {
const data = [
+ { type: 'background', source: 'bg.png' },
+ { type: 'text', text: 'Hello World' },
+ { type: 'icon', source: 'icon.svg', size: '10%' },
{
- type: 'background',
- texture: { type: 'rect' },
+ type: 'bar',
+ source: { fill: 'green' },
+ size: { width: 100, height: '20%' },
},
- 1234, // 비객체
+ ];
+ expect(() => componentArraySchema.parse(data)).not.toThrow();
+ });
+
+ it('should fail if any single element in the array is invalid', () => {
+ const data = [
+ { type: 'text', text: 'Valid' },
+ { type: 'bar', source: { type: 'rect' } }, // Invalid: missing 'size'
];
const result = componentArraySchema.safeParse(data);
expect(result.success).toBe(false);
+ // The error path should correctly point to the invalid element's missing property.
+ expect(result.error.issues[0].path).toEqual([1, 'size']);
});
});
});
diff --git a/src/display/data-schema/data-schema.js b/src/display/data-schema/data-schema.js
deleted file mode 100644
index 03abc882..00000000
--- a/src/display/data-schema/data-schema.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import { z } from 'zod';
-import { deepPartial } from '../../utils/zod-deep-strict-partial';
-import { componentArraySchema } from './component-schema';
-
-const position = z
- .object({
- x: z.number().default(0),
- y: z.number().default(0),
- })
- .strict();
-
-const size = z
- .object({
- width: z.number().nonnegative(),
- height: z.number().nonnegative(),
- })
- .strict();
-
-export const relation = z
- .object({
- source: z.string(),
- target: z.string(),
- })
- .strict();
-
-const defaultInfo = z
- .object({
- show: z.boolean().default(true),
- id: z.string(),
- metadata: z.record(z.unknown()).default({}),
- })
- .passthrough();
-
-const gridObject = defaultInfo.extend({
- type: z.literal('grid'),
- cells: z.array(z.array(z.union([z.literal(0), z.literal(1)]))),
- components: componentArraySchema,
- position: position.default({}),
- itemSize: size,
-});
-export const deepGridObject = deepPartial(gridObject);
-
-const singleObject = defaultInfo.extend({
- type: z.literal('item'),
- components: componentArraySchema,
- position: position.default({}),
- size: size,
-});
-export const deepSingleObject = deepPartial(singleObject);
-
-const relationGroupObject = defaultInfo.extend({
- type: z.literal('relations'),
- links: z.array(relation),
- strokeStyle: z.preprocess(
- (val) => ({ color: 'black', ...val }),
- z.record(z.unknown()),
- ),
-});
-export const deepRelationGroupObject = deepPartial(relationGroupObject);
-
-const groupObject = defaultInfo.extend({
- type: z.literal('group'),
- items: z.array(z.lazy(() => itemTypes)),
- position: position.default({}),
-});
-export const deepGroupObject = deepPartial(groupObject);
-
-const itemTypes = z.discriminatedUnion('type', [
- groupObject,
- gridObject,
- singleObject,
- relationGroupObject,
-]);
-
-export const mapDataSchema = z.array(itemTypes).superRefine((items, ctx) => {
- const errors = collectIds(items);
- errors.forEach((error) =>
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: error }),
- );
-});
-
-function collectIds(items, idSet = new Set(), path = []) {
- const errors = [];
- items.forEach((item, index) => {
- const currentPath = [...path, index.toString()];
- if (idSet.has(item.id)) {
- errors.push(`Duplicate id: ${item.id} at ${currentPath.join('.')}`);
- } else {
- idSet.add(item.id);
- }
-
- if (item.type === 'group' && Array.isArray(item.items)) {
- errors.push(
- ...collectIds(item.items, idSet, currentPath.concat('items')),
- );
- }
- });
- return errors;
-}
diff --git a/src/display/data-schema/data-schema.test.js b/src/display/data-schema/data-schema.test.js
deleted file mode 100644
index 92cdbf5c..00000000
--- a/src/display/data-schema/data-schema.test.js
+++ /dev/null
@@ -1,359 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { mapDataSchema } from './data-schema';
-
-describe('mapDataSchema (modified tests for required fields)', () => {
- // --------------------------------------------------------------------------
- // 1) 정상 케이스 테스트
- // --------------------------------------------------------------------------
- it('should validate a minimal valid single item object (type=item) with size', () => {
- // 이제 size가 required이므로 반드시 포함해야 함
- const data = [
- {
- id: 'unique-item-1',
- type: 'item',
- components: [], // componentSchema는 배열
- size: { width: 100, height: 50 },
- },
- ];
- const result = mapDataSchema.safeParse(data);
- expect(result.success).toBe(true);
- if (result.success) {
- // 기본값으로 세팅된 transform 속성 확인
- // position은 default(0,0), angle은 default(0)
- expect(result.data[0].position.x).toBe(0);
- expect(result.data[0].position.y).toBe(0);
-
- // size가 정상적으로 들어왔는지
- expect(result.data[0].size.width).toBe(100);
- expect(result.data[0].size.height).toBe(50);
- }
- });
-
- it('should validate a valid grid object (type=grid) with cells and size', () => {
- const data = [
- {
- id: 'grid-1',
- type: 'grid',
- cells: [
- [0, 1, 0],
- [1, 0, 1],
- ],
- components: [
- {
- type: 'background',
- texture: { type: 'rect' },
- width: 100,
- height: 100,
- },
- ],
- itemSize: {
- width: 200,
- height: 200,
- },
- position: {
- x: 50,
- y: 50,
- },
- },
- ];
- const result = mapDataSchema.safeParse(data);
- expect(result.success).toBe(true);
- });
-
- it('should validate a valid relations object (type=relations)', () => {
- // relation이 required이므로, links 내부 요소도 제대로 있어야 함
- const data = [
- {
- id: 'relations-1',
- type: 'relations',
- links: [
- { source: 'itemA', target: 'itemB' },
- { source: 'itemC', target: 'itemD' },
- ],
- },
- ];
- const result = mapDataSchema.safeParse(data);
- expect(result.success).toBe(true);
- });
-
- it('should pass if links in relations is empty or has missing fields', () => {
- const data = [
- {
- id: 'relations-bad-links',
- type: 'relations',
- links: [],
- },
- ];
- const result = mapDataSchema.safeParse(data);
- expect(result.success).toBe(true);
- });
-
- // --------------------------------------------------------------------------
- // 2) 에러 케이스 테스트
- // --------------------------------------------------------------------------
- describe('Error cases (required fields and stricter checks)', () => {
- it('should fail if size is missing (now required) in item', () => {
- const data = [
- {
- id: 'item-1',
- type: 'item',
- components: [],
- // size 필드가 없음
- },
- ];
- const result = mapDataSchema.safeParse(data);
- expect(result.success).toBe(false);
- if (!result.success) {
- // size is required
- const sizeError = result.error.issues.find((issue) =>
- issue.path.includes('size'),
- );
- expect(sizeError).toBeDefined();
- }
- });
-
- it('should fail if links contain invalid fields', () => {
- const data = [
- {
- id: 'relations-bad-links',
- type: 'relations',
- links: [{ sourec: 'typo' }],
- },
- ];
- const result = mapDataSchema.safeParse(data);
- expect(result.success).toBe(false);
- });
-
- it('should fail if relation object is missing required fields', () => {
- const data = [
- {
- id: 'relations-bad-link',
- type: 'relations',
- links: [
- // source, target 모두 반드시 있어야 함
- { source: 'itemA' }, // target 누락
- ],
- },
- ];
- const result = mapDataSchema.safeParse(data);
- expect(result.success).toBe(false);
-
- if (!result.success) {
- // relation의 target 누락 관련 에러를 체크
- const targetError = result.error.issues.find((issue) =>
- issue.path.includes('links'),
- );
- expect(targetError).toBeDefined();
- }
- });
-
- it('should fail if id is duplicated', () => {
- const data = [
- {
- id: 'dupId',
- type: 'item',
- components: [],
- size: { width: 100, height: 100 },
- },
- {
- id: 'dupId',
- type: 'grid',
- cells: [[0]],
- components: [],
- itemSize: { width: 50, height: 50 },
- },
- ];
- const result = mapDataSchema.safeParse(data);
- expect(result.success).toBe(false);
- if (!result.success) {
- expect(result.error.issues[0].message).toMatch(/Duplicate id/);
- }
- });
-
- it('should fail if cells in grid are not 0 or 1', () => {
- const data = [
- {
- id: 'grid-bad-cells',
- type: 'grid',
- cells: [
- [2, 0], // 2는 허용되지 않음
- ],
- components: [],
- size: { width: 100, height: 100 },
- },
- ];
- const result = mapDataSchema.safeParse(data);
- expect(result.success).toBe(false);
- });
-
- it('should fail if size has negative width or height', () => {
- const data = [
- {
- id: 'item-2',
- type: 'item',
- components: [],
- size: { width: -100, height: 100 }, // width가 음수
- },
- ];
- const result = mapDataSchema.safeParse(data);
- expect(result.success).toBe(false);
- });
-
- it('should fail if position is not an object', () => {
- const data = [
- {
- id: 'item-4',
- type: 'item',
- components: [],
- position: 'invalid-position', // 객체가 아님
- size: { width: 10, height: 10 },
- },
- ];
- const result = mapDataSchema.safeParse(data);
- expect(result.success).toBe(false);
- });
- });
-
- // --------------------------------------------------------------------------
- // 3) type=group 케이스
- // --------------------------------------------------------------------------
- describe('Group object (type=group)', () => {
- it('should pass when group has valid nested items', () => {
- // group 내부에 grid와 item을 예시로 넣어봄
- const data = [
- {
- id: 'group-1',
- type: 'group',
- label: 'Sample Group',
- metadata: {
- customKey: 'customValue',
- },
- items: [
- {
- id: 'nested-grid-1',
- type: 'grid',
- cells: [
- [0, 1],
- [1, 0],
- ],
- components: [],
- // transform 필드
- position: { x: 10, y: 20 },
- itemSize: { width: 100, height: 50 },
- angle: 45,
- },
- {
- id: 'nested-item-1',
- type: 'item',
- components: [],
- // transform 필드
- position: { x: 5, y: 5 },
- size: { width: 50, height: 50 },
- },
- ],
- },
- ];
-
- const result = mapDataSchema.safeParse(data);
- expect(result.success).toBe(true);
-
- if (result.success) {
- // 그룹 객체에 대한 필드 확인
- const group = result.data[0];
- expect(group.id).toBe('group-1');
- expect(group.type).toBe('group');
- expect(group.label).toBe('Sample Group');
-
- // items 배열 확인
- expect(Array.isArray(group.items)).toBe(true);
- expect(group.items).toHaveLength(2);
-
- // 첫 번째는 grid
- expect(group.items[0].type).toBe('grid');
- // 두 번째는 item
- expect(group.items[1].type).toBe('item');
- }
- });
-
- it('should fail if group items have duplicate id', () => {
- // group 내부에 중복 id를 가진 아이템
- const data = [
- {
- id: 'duplicate-item',
- type: 'group',
- items: [
- {
- id: '123',
- type: 'item',
- components: [],
- position: { x: 0, y: 0 },
- size: { width: 10, height: 10 },
- },
- {
- id: 'duplicate-item',
- type: 'grid',
- cells: [[0]],
- components: [],
- position: { x: 10, y: 10 },
- itemSize: { width: 20, height: 20 },
- },
- ],
- },
- ];
-
- const result = mapDataSchema.safeParse(data);
- expect(result.success).toBe(false);
-
- if (!result.success) {
- // 중복 id 관련 에러 메시지를 확인
- const duplicateError = result.error.issues.find((issue) =>
- issue.message.includes('Duplicate id: duplicate-item'),
- );
- expect(duplicateError).toBeDefined();
- }
- });
-
- it('should fail if items is missing or not an array', () => {
- // items 필드 자체가 없는 경우
- const data = [
- {
- id: 'group-without-items',
- type: 'group',
- // items 누락
- },
- ];
-
- const result = mapDataSchema.safeParse(data);
- expect(result.success).toBe(false);
- if (!result.success) {
- const missingItemsError = result.error.issues.find((issue) =>
- issue.path.includes('items'),
- );
- expect(missingItemsError).toBeDefined();
- }
- });
- });
-
- // --------------------------------------------------------------------------
- // 4) 기타 / 엣지 케이스
- // --------------------------------------------------------------------------
- describe('Edge cases', () => {
- it('should pass an empty array (if no items are required)', () => {
- // 스키마상 array().min(1)이 아니므로 빈 배열도 "성공"으로 본다.
- const data = [];
- const result = mapDataSchema.safeParse(data);
- expect(result.success).toBe(true);
- });
-
- it('should fail if the root data is not an array', () => {
- const data = {
- id: 'wrong-root',
- type: 'item',
- components: [],
- size: { width: 10, height: 10 },
- };
- const result = mapDataSchema.safeParse(data);
- expect(result.success).toBe(false);
- });
- });
-});
diff --git a/src/display/data-schema/data.d.ts b/src/display/data-schema/data.d.ts
index d8cf7498..7a2ca728 100644
--- a/src/display/data-schema/data.d.ts
+++ b/src/display/data-schema/data.d.ts
@@ -1,146 +1,373 @@
/**
- * An interface for extended properties that can be commonly applied to all objects.
+ * data.d.ts
+ *
+ * This file contains TypeScript type definitions generated from the Zod schemas
+ * to help library users easily understand the required data structure.
+ * For readability, each interface explicitly includes all its properties
+ * rather than using 'extends'.
*/
-export interface BaseObject {
- [key: string]: unknown;
-}
+
+import type {
+ Color,
+ HslColor,
+ HslaColor,
+ HsvColor,
+ HsvaColor,
+ RgbColor,
+ RgbaColor,
+} from './color';
+
+//================================================================================
+// 1. Top-Level Data Structure
+//================================================================================
/**
- * Top-level data structure
+ * The root structure of the entire map data, which is an array of Element objects.
+ *
+ * @example
+ * const mapData: MapData = [
+ * { type: 'grid', id: 'g1', cells: [[1, 1]], item: { components: [], size: 100 } },
+ * { type: 'item', id: 'i1', components: [], size: 100 },
+ * { type: 'relations', id: 'r1', links: [{ source: 'g1', target: 'i1' }] },
+ * ];
*/
-export type Data = Array;
+export type MapData = Element[];
/**
- * Group Type
+ * A union type of all possible top-level elements that constitute the map data.
+ * The specific type of element is determined by the 'type' property.
*/
-export interface Group extends BaseObject {
- type: 'group';
- id: string;
- show?: boolean; // default: true
- metadata?: Record; // default: {}
+export type Element = Group | Grid | Item | Relations;
+
+//================================================================================
+// 2. Element Types (from element-schema.js)
+//================================================================================
- items: Array;
+/**
+ * Groups multiple elements to apply common properties.
+ * You can specify coordinates (x, y) for the entire group via 'attrs'.
+ * @see {@link https://pixijs.download/release/docs/scene.Container.html}
+ *
+ * @example
+ * const groupExample: Group = {
+ * type: 'group',
+ * id: 'group-api-servers',
+ * children: [
+ * { type: 'item', id: 'server-1', components: [] },
+ * { type: 'item', id: 'server-2', components: [], attrs: { x: 100, y: 200 } }
+ * ],
+ * attrs: { x: 100, y: 50 },
+ * };
+ */
+export interface Group {
+ type: 'group';
+ id?: string; // Default: uid
+ label?: string;
+ show?: boolean; // Default: true
+ children: Element[];
+ attrs?: Record;
}
/**
- * Grid Type
+ * Lays out items in a grid format.
+ * The visibility of an item is determined by 1 or 0 in the 'cells' array.
+ * @see {@link https://pixijs.download/release/docs/scene.Container.html}
+ *
+ * @example
+ * const gridExample: Grid = {
+ * type: 'grid',
+ * id: 'server-rack',
+ * gap: { x: 10, y: 10 },
+ * cells: [
+ * [1, 1, 0],
+ * [1, 0, 1]
+ * ],
+ * item: {
+ * components: [
+ * {
+ * type: 'background',
+ * source: { type: 'rect', fill: '#eee', radius: 4 },
+ * }
+ * ],
+ * size: 60,
+ * },
+ * };
*/
-export interface Grid extends BaseObject {
+export interface Grid {
type: 'grid';
- id: string;
- show?: boolean; // default: true
- metadata?: Record; // default: {}
-
- cells: Array>;
- components: components[];
-
- position?: {
- x?: number; // default: 0
- y?: number; // default: 0
- };
- itemSize: {
- width: number;
- height: number;
+ id?: string; // Default: uid
+ label?: string;
+ show?: boolean; // Default: true
+ cells: (0 | 1)[][];
+ gap?: Gap;
+ item: {
+ components?: Component[];
+ size: Size;
};
+ attrs?: Record;
}
/**
- * Item Type
+ * The most basic single element that constitutes the map.
+ * It contains various components (Background, Text, Icon, etc.) to form its visual representation.
+ * @see {@link https://pixijs.download/release/docs/scene.Container.html}
+ *
+ * @example
+ * const itemExample: Item = {
+ * type: 'item',
+ * id: 'main-server',
+ * size: { width: 120, height: 100 },
+ * components: [
+ * {
+ * type: 'background',
+ * source: { type: 'rect', fill: '#fff', borderColor: '#ddd', borderWidth: 1 }
+ * },
+ * {
+ * type: 'text',
+ * text: 'Main Server',
+ * placement: 'top',
+ * margin: 8
+ * },
+ * {
+ * type: 'bar',
+ * source: { type: 'rect', fill: 'black' },
+ * size: { width: '80%', height: 8 },
+ * tint: 'primary.default',
+ * placement: 'bottom'
+ * },
+ * {
+ * type: 'icon',
+ * source: 'ok.svg',
+ * size: 16,
+ * placement: 'right-bottom'
+ * }
+ * ],
+ * attrs: { x: 300, y: 150 },
+ * }
*/
-export interface Item extends BaseObject {
+export interface Item {
type: 'item';
- id: string;
- show?: boolean; // default: true
- metadata?: Record; // default: {}
-
- components: components[];
-
- position?: {
- x?: number; // default: 0
- y?: number; // default: 0
- };
- size: {
- width: number;
- height: number;
- };
+ id?: string; // Default: uid
+ label?: string;
+ show?: boolean; // Default: true
+ components?: Component[];
+ size: Size;
+ attrs?: Record;
}
/**
- * Relations Type
+ * Represents relationships between elements by connecting them with lines.
+ * Specify the IDs of the elements to connect in the 'links' array.
+ * @see {@link https://pixijs.download/release/docs/scene.Container.html}
+ *
+ * @example
+ * const relationsExample: Relations = {
+ * type: 'relations',
+ * id: 'server-connections',
+ * links: [
+ * { source: 'main-server', target: 'sub-server-1' },
+ * { source: 'main-server', target: 'sub-server-2' }
+ * ],
+ * style: { color: '#083967', width: 2, cap: 'round', join: 'round' }
+ * };
*/
-export interface Relations extends BaseObject {
+export interface Relations {
type: 'relations';
- id: string;
- show?: boolean; // default: true
- metadata?: Record; // default: {}
-
- links: Array<{ source: string; target: string }>;
- strokeStyle?: Record;
+ id?: string; // Default: uid
+ label?: string;
+ show?: boolean; // Default: true
+ links: { source: string; target: string }[];
+ style?: RelationsStyle;
+ attrs?: Record;
}
+//================================================================================
+// 3. Component Types (from component-schema.js)
+//================================================================================
+
/**
- * components Type (background, bar, icon, text)
+ * A union type for all visual components that can be included inside an Item.
*/
-export type components =
- | BackgroundComponent
- | BarComponent
- | IconComponent
- | TextComponent;
+export type Component = Background | Bar | Icon | Text;
/**
- * Background components
+ * An Item's background, sourced from a style object or an asset URL.
+ * @see {@link https://pixijs.download/release/docs/scene.NineSliceSprite.html}
+ *
+ * @example
+ * // As a style object
+ * const backgroundStyleExample: Background = {
+ * type: 'background',
+ * id: 'bg-rect',
+ * source: { type: 'rect', fill: '#1A1A1A', radius: 8 }
+ * };
+ *
+ * @example
+ * // As an image URL
+ * const backgroundUrlExample: Background = {
+ * type: 'background',
+ * id: 'bg-image',
+ * source: 'background-image.png'
+ * };
*/
-export interface BackgroundComponent extends BaseObject {
+export interface Background {
type: 'background';
- show?: boolean; // default: true
- texture: TextureStyle;
+ id?: string; // Default: uid
+ label?: string;
+ show?: boolean; // Default: true
+ source: TextureStyle | string;
+ tint?: Tint;
+ attrs?: Record;
}
/**
- * Bar components
+ * A component for progress bars or bar graphs.
+ * @see {@link https://pixijs.download/release/docs/scene.NineSliceSprite.html}
+ *
+ * @example
+ * const barExample: Bar = {
+ * type: 'bar',
+ * id: 'cpu-usage-bar',
+ * source: { type: 'rect', fill: 'green' },
+ * size: { width: '80%', height: 10 }, // 80% of the parent Item's width, 10px height
+ * placement: 'bottom',
+ * animation: true,
+ * animationDuration: 200,
+ * };
*/
-export interface BarComponent extends BaseObject {
+export interface Bar {
type: 'bar';
- show?: boolean; // default: true
- texture: TextureStyle;
-
- placement?: Placement; // default: 'bottom'
- margin?: string; // default: '0', ('4 2', '2 1 3 4')
- percentWidth?: number; // default: 1 (0~1)
- percentHeight?: number; // default: 1 (0~1)
- animation?: boolean; // default: true
- animationDuration?: number; // default: 200
+ id?: string; // Default: uid
+ label?: string;
+ show?: boolean; // Default: true
+ source: TextureStyle;
+ size: PxOrPercentSize;
+ placement?: Placement; // Default: 'bottom'
+ margin?: Margin; // Default: 0
+ tint?: Tint;
+ animation?: boolean; // Default: true
+ animationDuration?: number; // Default: 200
+ attrs?: Record;
}
/**
- * Icon components
+ * A component for displaying an icon image.
+ * @see {@link https://pixijs.download/release/docs/scene.Sprite.html}
+ *
+ * @example
+ * const iconExample: Icon = {
+ * type: 'icon',
+ * id: 'warning-icon',
+ * source: 'warning-icon.svg',
+ * size: 24, // 24px x 24px
+ * placement: 'left-top',
+ * };
*/
-export interface IconComponent extends BaseObject {
+export interface Icon {
type: 'icon';
- show?: boolean; // default: true
- asset: string; // object, inverter, combiner, edge, device, loading, warning, wifi, etc.
-
- placement?: Placement; // default: 'center'
- margin?: string; // default: '0', ('4 2', '2 1 3 4')
- size: number; // 0 or higher
+ id?: string; // Default: uid
+ label?: string;
+ show?: boolean; // Default: true
+ source: string;
+ size: PxOrPercentSize;
+ placement?: Placement; // Default: 'center'
+ margin?: Margin; // Default: 0
+ tint?: Tint;
+ attrs?: Record;
}
/**
- * Text components
+ * A text label component.
+ * @see {@link https://pixijs.download/release/docs/scene.BitmapText.html}
+ *
+ * @example
+ * const textExample: Text = {
+ * type: 'text',
+ * id: 'cpu-label',
+ * text: 'CPU Usage',
+ * placement: 'center',
+ * style: { fill: '#333', fontSize: 14, fontWeight: 'bold' },
+ * split: 0,
+ * };
*/
-export interface TextComponent extends BaseObject {
+export interface Text {
type: 'text';
- show?: boolean; // default: true
-
- placement?: Placement; // default: 'center'
- margin?: string; // default: '0', ('4 2', '2 1 3 4')
- text?: string; // default: ''
- style?: Record;
- split?: number; // default: 0
+ id?: string; // Default: uid
+ label?: string;
+ show?: boolean; // Default: true
+ text?: string; // Default: ''
+ placement?: Placement; // Default: 'center'
+ margin?: Margin; // Default: 0
+ tint?: Tint;
+ style?: TextStyle;
+ split?: number; // Default: 0
+ attrs?: Record;
}
+//================================================================================
+// 4. Primitive & Utility Types (from primitive-schema.js)
+//================================================================================
+
+/**
+ * A value that can be specified in pixels (number), as a percentage (string),
+ * or as an object with value and unit.
+ *
+ * @example
+ * // For a 100px value:
+ * const pxValue: PxOrPercent = 100;
+ *
+ * @example
+ * // For a 75% value:
+ * const percentValue: PxOrPercent = '75%';
+ *
+ * @example
+ * // As an object:
+ * const objectValue: PxOrPercent = { value: 50, unit: '%' };
+ */
+export type PxOrPercent = number | string | { value: number; unit: 'px' | '%' };
+
/**
- * String used for placement
+ * Defines a size with width and height, where each can be specified in pixels or percentage.
+ *
+ * @example
+ * // For a 100px by 100px size:
+ * const squareSize: PxOrPercentSize = 100;
+ *
+ * @example
+ * // For a 50% width and 75% height:
+ * const responsiveSize: PxOrPercentSize = { width: '50%', height: '75%' };
+ *
+ * @example
+ * // For a 25% width and 25% height:
+ * const uniformResponsiveSize: PxOrPercentSize = '25%';
+ */
+export type PxOrPercentSize =
+ | PxOrPercent
+ | {
+ width: PxOrPercent;
+ height: PxOrPercent;
+ };
+
+/**
+ * Defines a size with width and height in numbers (pixels).
+ *
+ * @example
+ * // For a 100px by 100px size:
+ * const sizeExample: Size = 100;
+ *
+ * @example
+ * // For a 120px width and 80px height:
+ * const rectSizeExample: Size = { width: 120, height: 80 };
+ */
+export type Size =
+ | number
+ | {
+ width: number;
+ height: number;
+ };
+
+/**
+ * Specifies the position of a component within its parent Item.
*/
export type Placement =
| 'left'
@@ -151,13 +378,133 @@ export type Placement =
| 'right-top'
| 'right-bottom'
| 'bottom'
- | 'center';
+ | 'center'
+ | 'none';
+
+/**
+ * Defines the gap between cells in a Grid.
+ *
+ * @example
+ * // To set a 10px gap for both x and y:
+ * const uniformGap: Gap = 10;
+ *
+ * @example
+ * // To set a 5px horizontal and 15px vertical gap:
+ * const customGap: Gap = { x: 5, y: 15 };
+ */
+export type Gap =
+ | number
+ | {
+ x?: number; // Default: 0
+ y?: number; // Default: 0
+ };
+
+/**
+ * Defines the margin around a component.
+ *
+ * @example
+ * // To apply a 10px margin on all four sides:
+ * const uniformMargin: Margin = 10;
+ *
+ * @example
+ * // To apply 10px top/bottom and 5px left/right margins:
+ * const axisMargin: Margin = { y: 10, x: 5 };
+ *
+ * @example
+ * // To apply individual margins for each side:
+ * const detailedMargin: Margin = { top: 1, right: 2, bottom: 3, left: 4 };
+ */
+export type Margin =
+ | number
+ | { x?: number; y?: number }
+ | { top?: number; right?: number; bottom?: number; left?: number };
-export type TextureType = 'rect';
+/**
+ * Defines the style for a rectangular texture, used for backgrounds, bars, etc.
+ * All properties are optional.
+ *
+ * @example
+ * const textureStyleExample: TextureStyle = {
+ * type: 'rect',
+ * fill: '#ff0000',
+ * borderWidth: 2,
+ * borderColor: '#000000',
+ * radius: 5
+ * };
+ */
export interface TextureStyle {
- type?: TextureType;
- fill?: string | null;
- borderWidth?: number | null;
- borderColor?: string | null;
- radius?: number | null;
+ type: 'rect';
+ fill?: string;
+ borderWidth?: number;
+ borderColor?: string;
+ radius?: number;
}
+
+/**
+ * Defines the line style for a Relations element.
+ * You can pass an object similar to PIXI.Graphics' lineStyle options.
+ *
+ * @see {@link https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html}
+ *
+ * @example
+ * const relationsStyleExample: RelationsStyle = {
+ * color: 'red',
+ * width: 2,
+ * cap: 'square'
+ * };
+ */
+export type RelationsStyle = Record;
+
+/**
+ * Defines the text style for a Text component.
+ * You can pass an object similar to PIXI.TextStyle options.
+ *
+ * @see {@link https://pixijs.download/release/docs/text.TextStyleOptions.html}
+ *
+ * @example
+ * const textStyleExample: TextStyle = {
+ * fontFamily: 'Arial',
+ * fontSize: 24,
+ * fill: 'white',
+ * stroke: { color: 'black', width: 2 }
+ * };
+ */
+export type TextStyle = Record;
+
+/**
+ * Defines a tint color to be applied to a component.
+ * Accepts any valid PixiJS ColorSource format, such as theme keys,
+ * hex codes, numbers, or color objects.
+ *
+ * @example
+ * // As a theme key (string)
+ * const tintThemeKey: Tint = 'primary.default';
+ *
+ * @example
+ * // As a hex string
+ * const tintHexString: Tint = '#ff0000';
+ *
+ * @example
+ * // As a hex number
+ * const tintHexNumber: Tint = 0xff0000;
+ *
+ * @example
+ * // As an RGB object
+ * const tintRgbObject: Tint = { r: 255, g: 0, b: 0 };
+ *
+ * @see {@link https://pixijs.download/release/docs/color.ColorSource.html}
+ */
+export type Tint =
+ | string
+ | number
+ | number[]
+ | Float32Array
+ | Uint8Array
+ | Uint8ClampedArray
+ | HslColor
+ | HslaColor
+ | HsvColor
+ | HsvaColor
+ | RgbColor
+ | RgbaColor
+ | Color;
diff --git a/src/display/data-schema/element-schema.js b/src/display/data-schema/element-schema.js
new file mode 100644
index 00000000..ce726cc6
--- /dev/null
+++ b/src/display/data-schema/element-schema.js
@@ -0,0 +1,85 @@
+import { z } from 'zod';
+import { componentArraySchema } from './component-schema';
+import { Base, Gap, RelationsStyle, Size } from './primitive-schema';
+
+/**
+ * Groups multiple elements to apply common properties..
+ * Visually represented by a `Container`.
+ * @see {@link https://pixijs.download/release/docs/scene.Container.html}
+ */
+export const groupSchema = Base.extend({
+ type: z.literal('group'),
+ children: z.array(z.lazy(() => elementTypes)),
+}).strict();
+
+/**
+ * Lays out items in a grid format.
+ * The visibility of an item is determined by 1 or 0 in the 'cells' array.
+ * Visually represented by a `Container`.
+ * @see {@link https://pixijs.download/release/docs/scene.Container.html}
+ */
+export const gridSchema = Base.extend({
+ type: z.literal('grid'),
+ cells: z.array(z.array(z.union([z.literal(0), z.literal(1)]))),
+ gap: Gap,
+ item: z.object({ components: componentArraySchema, size: Size }),
+}).strict();
+
+/**
+ * The most basic single element that constitutes the map.
+ * It contains various components (Background, Text, Icon, etc.) to form its visual representation.
+ * Visually represented by a `Container`.
+ * @see {@link https://pixijs.download/release/docs/scene.Container.html}
+ */
+export const itemSchema = Base.extend({
+ type: z.literal('item'),
+ components: componentArraySchema,
+ size: Size,
+}).strict();
+
+/**
+ * Represents relationships between elements by connecting them with lines.
+ * Specify the IDs of the elements to connect in the 'links' array.
+ * Visually represented by a `Container`.
+ * @see {@link https://pixijs.download/release/docs/scene.Container.html}
+ */
+export const relationsSchema = Base.extend({
+ type: z.literal('relations'),
+ links: z.array(z.object({ source: z.string(), target: z.string() })),
+ style: RelationsStyle.optional(),
+}).strict();
+
+export const elementTypes = z.discriminatedUnion('type', [
+ groupSchema,
+ gridSchema,
+ itemSchema,
+ relationsSchema,
+]);
+
+export const mapDataSchema = z
+ .array(elementTypes)
+ .superRefine((elements, ctx) => {
+ const errors = collectIds(elements);
+ errors.forEach((error) =>
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: error }),
+ );
+ });
+
+function collectIds(elements, idSet = new Set(), path = []) {
+ const errors = [];
+ elements.forEach((element, index) => {
+ const currentPath = [...path, index.toString()];
+ if (idSet.has(element.id)) {
+ errors.push(`Duplicate id: ${element.id} at ${currentPath.join('.')}`);
+ } else {
+ idSet.add(element.id);
+ }
+
+ if (element.type === 'group' && Array.isArray(element.children)) {
+ errors.push(
+ ...collectIds(element.children, idSet, currentPath.concat('children')),
+ );
+ }
+ });
+ return errors;
+}
diff --git a/src/display/data-schema/element-schema.test.js b/src/display/data-schema/element-schema.test.js
new file mode 100644
index 00000000..caf19adc
--- /dev/null
+++ b/src/display/data-schema/element-schema.test.js
@@ -0,0 +1,302 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { z } from 'zod';
+import { uid } from '../../utils/uuid';
+import {
+ gridSchema,
+ groupSchema,
+ itemSchema,
+ mapDataSchema,
+ relationsSchema,
+} from './element-schema.js';
+
+// Mock component-schema as its details are not relevant for these element tests.
+vi.mock('./component-schema', () => ({
+ componentArraySchema: z.array(z.any()).default([]),
+}));
+
+// Mock the uid generator for predictable test outcomes.
+vi.mock('../../utils/uuid', () => ({
+ uid: vi.fn(),
+}));
+
+beforeEach(() => {
+ vi.mocked(uid).mockClear();
+});
+
+describe('Element Schemas', () => {
+ describe('Group Schema', () => {
+ it('should parse a valid group with nested elements', () => {
+ const groupData = {
+ type: 'group',
+ id: 'group-1',
+ children: [
+ { type: 'item', id: 'item-1', size: { width: 100, height: 100 } },
+ ],
+ };
+ const parsed = groupSchema.parse(groupData);
+ expect(parsed.children).toHaveLength(1);
+ expect(parsed.children[0].type).toBe('item');
+ });
+
+ it('should parse a group with empty children', () => {
+ const groupData = { type: 'group', id: 'group-1', children: [] };
+ expect(() => groupSchema.parse(groupData)).not.toThrow();
+ });
+
+ it('should fail if children is missing', () => {
+ const groupData = { type: 'group', id: 'group-1' };
+ expect(() => groupSchema.parse(groupData)).toThrow();
+ });
+
+ it('should fail if children contains an invalid element', () => {
+ const invalidGroupData = {
+ type: 'group',
+ id: 'group-1',
+ children: [{ type: 'invalid-type' }],
+ };
+ expect(() => groupSchema.parse(invalidGroupData)).toThrow();
+ });
+
+ it('should fail if an unknown property is provided', () => {
+ const groupData = {
+ type: 'group',
+ id: 'group-1',
+ children: [],
+ extra: 'property',
+ };
+ expect(() => groupSchema.parse(groupData)).toThrow();
+ });
+ });
+
+ describe('Grid Schema', () => {
+ const baseGrid = {
+ type: 'grid',
+ id: 'grid-1',
+ cells: [[1]],
+ item: { size: { width: 50, height: 50 } },
+ };
+
+ it('should parse a valid grid and preprocess gap', () => {
+ const parsed = gridSchema.parse(baseGrid);
+ expect(parsed.gap).toEqual({ x: 0, y: 0 });
+ expect(parsed.item).toEqual({
+ size: { width: 50, height: 50 },
+ components: [],
+ });
+ });
+
+ it('should fail if cells contains invalid values', () => {
+ const gridData = { ...baseGrid, cells: [[1, 2]] };
+ expect(() => gridSchema.parse(gridData)).toThrow();
+ });
+
+ it('should fail if item is missing required size', () => {
+ const gridData = { ...baseGrid, item: { components: [] } };
+ expect(() => gridSchema.parse(gridData)).toThrow();
+ });
+
+ it('should parse a valid grid with default size', () => {
+ const gridData = { ...baseGrid, item: { size: 80 } };
+ expect(() => gridSchema.parse(gridData)).not.toThrow();
+ });
+
+ it('should fail if required properties are missing', () => {
+ expect(() => gridSchema.parse({ type: 'grid', id: 'g1' })).toThrow(); // missing cells, item
+ });
+
+ it('should fail if an unknown property is provided', () => {
+ const gridData = { ...baseGrid, unknown: 'property' };
+ expect(() => gridSchema.parse(gridData)).toThrow();
+ });
+ });
+
+ describe('Item Schema', () => {
+ it('should parse a valid item with required properties', () => {
+ const itemData = {
+ type: 'item',
+ id: 'item-1',
+ size: { width: 100, height: 200 },
+ };
+ const parsed = itemSchema.parse(itemData);
+ expect(parsed.size.width).toBe(100);
+ expect(parsed.size.height).toBe(200);
+ expect(parsed.components).toEqual([]); // default value
+ });
+
+ it('should fail if required size properties are missing', () => {
+ const itemData = { type: 'item', id: 'item-1' };
+ expect(() => itemSchema.parse(itemData)).toThrow();
+ });
+
+ it('should fail if an unknown property is provided', () => {
+ const itemData = {
+ type: 'item',
+ id: 'item-1',
+ size: { width: 100, height: 100 },
+ x: 50, // This is an unknown property
+ };
+ expect(() => itemSchema.parse(itemData)).toThrow();
+ });
+ });
+
+ describe('Relations Schema', () => {
+ it('should parse valid relations and apply default style', () => {
+ const relationsData = {
+ type: 'relations',
+ id: 'rel-1',
+ links: [{ source: 'a', target: 'b' }],
+ };
+ const parsed = relationsSchema.parse(relationsData);
+ expect(parsed.links).toHaveLength(1);
+ });
+
+ it('should accept an overridden style', () => {
+ const relationsData = {
+ type: 'relations',
+ id: 'rel-1',
+ links: [],
+ style: { color: 'blue', lineWidth: 2 },
+ };
+ const parsed = relationsSchema.parse(relationsData);
+ expect(parsed.style).toEqual({ color: 'blue', lineWidth: 2 });
+ });
+
+ it('should fail for an invalid links property', () => {
+ const relationsData = {
+ type: 'relations',
+ id: 'rel-1',
+ links: [{ source: 'a' }], // missing target
+ };
+ expect(() => relationsSchema.parse(relationsData)).toThrow();
+ });
+
+ it('should fail if an unknown property is provided', () => {
+ const relationsData = {
+ type: 'relations',
+ id: 'rel-1',
+ links: [],
+ extra: 'data',
+ };
+ expect(() => relationsSchema.parse(relationsData)).toThrow();
+ });
+
+ it('should fail if links is missing', () => {
+ const relationsData = {
+ type: 'relations',
+ id: 'rel-1',
+ };
+ expect(() => relationsSchema.parse(relationsData)).toThrow();
+ });
+ });
+
+ describe('mapDataSchema (Full Integration)', () => {
+ it('should parse a valid array of mixed elements with unique IDs', () => {
+ const data = [
+ { type: 'item', id: 'item-1', size: { width: 10, height: 10 } },
+ {
+ type: 'group',
+ id: 'group-1',
+ children: [
+ { type: 'item', id: 'item-2', size: { width: 10, height: 10 } },
+ ],
+ },
+ ];
+ expect(() => mapDataSchema.parse(data)).not.toThrow();
+ });
+
+ it('should apply default IDs and pass validation if they are unique', () => {
+ vi.mocked(uid)
+ .mockReturnValueOnce('mock-id-0')
+ .mockReturnValueOnce('mock-id-1');
+ const data = [
+ { type: 'item', size: { width: 10, height: 10 } },
+ { type: 'item', size: { width: 10, height: 10 } },
+ ];
+ const parsed = mapDataSchema.parse(data);
+ expect(parsed[0].id).toBe('mock-id-0');
+ expect(parsed[1].id).toBe('mock-id-1');
+ });
+
+ it('should fail if an element has an unknown type', () => {
+ const data = [{ type: 'rectangle', id: 'rect-1' }];
+ const result = mapDataSchema.safeParse(data);
+ expect(result.success).toBe(false);
+ expect(result.error.issues[0].code).toBe('invalid_union_discriminator');
+ });
+
+ // --- ID uniqueness validation using superRefine ---
+ describe('ID uniqueness validation', () => {
+ const getFirstError = (data) => {
+ const result = mapDataSchema.safeParse(data);
+ return result.success ? null : result.error.issues[0].message;
+ };
+
+ it('should fail for duplicate IDs at the root level', () => {
+ const data = [
+ { type: 'item', id: 'dup-id', size: { width: 10, height: 10 } },
+ { type: 'item', id: 'dup-id', size: { width: 10, height: 10 } },
+ ];
+ expect(getFirstError(data)).toBe('Duplicate id: dup-id at 1');
+ });
+
+ it('should fail for duplicate ID between root and a nested group', () => {
+ const data = [
+ {
+ type: 'item',
+ id: 'cross-level-dup',
+ size: { width: 10, height: 10 },
+ },
+ {
+ type: 'group',
+ id: 'group-1',
+ children: [
+ {
+ type: 'item',
+ id: 'cross-level-dup',
+ size: { width: 10, height: 10 },
+ },
+ ],
+ },
+ ];
+ expect(getFirstError(data)).toBe(
+ 'Duplicate id: cross-level-dup at 1.children.0',
+ );
+ });
+
+ it('should fail for duplicate ID in deeply nested groups', () => {
+ const data = [
+ {
+ type: 'group',
+ id: 'g1',
+ children: [
+ {
+ type: 'group',
+ id: 'g2',
+ children: [
+ {
+ type: 'item',
+ id: 'deep-dup',
+ size: { width: 1, height: 1 },
+ },
+ ],
+ },
+ { type: 'item', id: 'deep-dup', size: { width: 1, height: 1 } },
+ ],
+ },
+ ];
+ expect(getFirstError(data)).toBe(
+ 'Duplicate id: deep-dup at 0.children.1',
+ );
+ });
+
+ it('should fail when a default ID clashes with a provided ID', () => {
+ vi.mocked(uid).mockReturnValueOnce('mock-id-0');
+ const data = [
+ { type: 'item', id: 'mock-id-0', size: { width: 10, height: 10 } },
+ { type: 'item', size: { width: 10, height: 10 } }, // This will get default id 'mock-id-0'
+ ];
+ expect(getFirstError(data)).toBe('Duplicate id: mock-id-0 at 1');
+ });
+ });
+ });
+});
diff --git a/src/display/data-schema/primitive-schema.js b/src/display/data-schema/primitive-schema.js
new file mode 100644
index 00000000..81cfb39c
--- /dev/null
+++ b/src/display/data-schema/primitive-schema.js
@@ -0,0 +1,211 @@
+import { z } from 'zod';
+import { uid } from '../../utils/uuid';
+import {
+ Color,
+ HslColor,
+ HslaColor,
+ HsvColor,
+ HsvaColor,
+ RgbColor,
+ RgbaColor,
+} from './color-schema';
+
+export const Base = z
+ .object({
+ show: z.boolean().default(true),
+ id: z.string().default(() => uid()),
+ label: z.string().optional(),
+ attrs: z.record(z.string(), z.unknown()).optional(),
+ })
+ .strict();
+
+export const Size = z.union([
+ z
+ .number()
+ .nonnegative()
+ .transform((val) => ({ width: val, height: val })),
+ z.object({
+ width: z.number().nonnegative(),
+ height: z.number().nonnegative(),
+ }),
+]);
+
+export const pxOrPercentSchema = z
+ .union([
+ z.number().nonnegative(),
+ z.string().regex(/^\d+(\.\d+)?%$/),
+ z.string(),
+ z
+ .object({ value: z.number().nonnegative(), unit: z.enum(['px', '%']) })
+ .strict(),
+ ])
+ .transform((val) => {
+ if (typeof val === 'number') {
+ return { value: val, unit: 'px' };
+ }
+ if (typeof val === 'string' && val.endsWith('%')) {
+ return { value: Number.parseFloat(val.slice(0, -1)), unit: '%' };
+ }
+ return val;
+ })
+ .refine(
+ (val) => {
+ if (typeof val !== 'string') return true;
+ if (!val.startsWith('calc(') || !val.endsWith(')')) return false;
+
+ // Extract the expression inside "calc(...)".
+ const expression = val.substring(5, val.length - 1).trim();
+ if (!expression) return false;
+
+ // Use a regular expression to tokenize the expression.
+ // This will capture numbers (positive or negative) with "px" or "%" units, and "+" or "-" operators.
+ // e.g., "10% + -20px" -> ["10%", "+", "-20px"]
+ const tokens = expression.match(/-?\d+(\.\d+)?(px|%)|[+-]/g);
+ if (!tokens) return false;
+
+ // This flag tracks whether we expect a term (like "10px") or an operator (like "+").
+ // An expression must start with a term.
+ let expectTerm = true;
+ for (const token of tokens) {
+ const isOperator = token === '+' || token === '-';
+ const isTerm = !isOperator;
+
+ // If we expect a term but find an operator, it's invalid.
+ if (expectTerm && !isTerm) return false;
+ // If we expect an operator but find a term, it's invalid.
+ if (!expectTerm && !isOperator) return false;
+
+ // Flip the expectation for the next token.
+ expectTerm = !expectTerm;
+ }
+ // If the loop finishes and we are still expecting a term, it means the expression
+ // ended with an operator, which is invalid (e.g., "calc(5px +)").
+ if (expectTerm) return false;
+
+ // --- CSS Spec Rule: Operators must be surrounded by spaces. ---
+ // We check this rule against the original expression string, as the token array loses whitespace info.
+ let tempExpr = expression;
+ for (let i = 0; i < tokens.length; i++) {
+ const token = tokens[i];
+ const nextToken = tokens[i + 1];
+
+ // Check only for operators that are not the last token.
+ if ((token === '+' || token === '-') && nextToken) {
+ const operatorIndex = tempExpr.indexOf(token);
+ const nextTokenIndex = tempExpr.indexOf(nextToken, operatorIndex);
+
+ // Get the substring between the current operator and the next token.
+ const between = tempExpr.substring(
+ operatorIndex + token.length,
+ nextTokenIndex,
+ );
+
+ // If this substring contains anything other than whitespace, it's invalid.
+ if (between.trim() !== '') return false;
+ // If the substring is empty, it means there was no space, which is invalid.
+ if (between.length === 0) return false;
+ }
+
+ // Remove the processed part of the string to avoid finding the same token again.
+ tempExpr = tempExpr.substring(tempExpr.indexOf(token) + token.length);
+ }
+
+ // If all checks pass, the calc() string is valid.
+ return true;
+ },
+ {
+ message:
+ "Invalid calc format. Operators must be surrounded by spaces. Example: 'calc(100% - 20px)'",
+ },
+ );
+
+export const PxOrPercentSize = z.union([
+ pxOrPercentSchema.transform((val) => ({ width: val, height: val })),
+ z.object({
+ width: pxOrPercentSchema,
+ height: pxOrPercentSchema,
+ }),
+]);
+
+export const Placement = z.enum([
+ 'left',
+ 'left-top',
+ 'left-bottom',
+ 'top',
+ 'right',
+ 'right-top',
+ 'right-bottom',
+ 'bottom',
+ 'center',
+ 'none',
+]);
+
+export const Gap = z.preprocess(
+ (val) => (typeof val === 'number' ? { x: val, y: val } : val),
+ z
+ .object({
+ x: z.number().nonnegative().default(0),
+ y: z.number().nonnegative().default(0),
+ })
+ .default({}),
+);
+
+export const Margin = z.preprocess(
+ (val) => {
+ if (typeof val === 'number') {
+ return { top: val, right: val, bottom: val, left: val };
+ }
+ if (val && typeof val === 'object' && ('x' in val || 'y' in val)) {
+ const { x = 0, y = 0 } = val;
+ return { top: y, right: x, bottom: y, left: x };
+ }
+ return val;
+ },
+ z
+ .object({
+ top: z.number().default(0),
+ right: z.number().default(0),
+ bottom: z.number().default(0),
+ left: z.number().default(0),
+ })
+ .default({}),
+);
+
+export const TextureStyle = z
+ .object({
+ type: z.enum(['rect']),
+ fill: z.string(),
+ borderWidth: z.number(),
+ borderColor: z.string(),
+ radius: z.number(),
+ })
+ .partial();
+
+/**
+ * @see {@link https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html}
+ */
+export const RelationsStyle = z.record(z.string(), z.unknown());
+
+/**
+ * @see {@link https://pixijs.download/release/docs/text.TextStyleOptions.html}
+ */
+export const TextStyle = z.record(z.string(), z.unknown());
+
+/**
+ * @see {@link https://pixijs.download/release/docs/color.ColorSource.html}
+ */
+export const Tint = z.union([
+ z.string(),
+ z.number(),
+ z.array(z.number()),
+ z.instanceof(Float32Array),
+ z.instanceof(Uint8Array),
+ z.instanceof(Uint8ClampedArray),
+ HslColor,
+ HslaColor,
+ HsvColor,
+ HsvaColor,
+ RgbColor,
+ RgbaColor,
+ Color,
+]);
diff --git a/src/display/data-schema/primitive-schema.test.js b/src/display/data-schema/primitive-schema.test.js
new file mode 100644
index 00000000..3ec81e25
--- /dev/null
+++ b/src/display/data-schema/primitive-schema.test.js
@@ -0,0 +1,412 @@
+import { describe, expect, it, vi } from 'vitest';
+import { uid } from '../../utils/uuid';
+import { deepPartial } from '../../utils/zod-deep-strict-partial';
+import {
+ Base,
+ Gap,
+ Margin,
+ Placement,
+ PxOrPercentSize,
+ RelationsStyle,
+ Size,
+ TextStyle,
+ TextureStyle,
+ Tint,
+ pxOrPercentSchema,
+} from './primitive-schema';
+
+vi.mock('../../utils/uuid');
+vi.mocked(uid).mockReturnValue('mock-uid-123');
+
+describe('Primitive Schema Tests', () => {
+ describe('Tint Schema', () => {
+ const validColorSourceCases = [
+ // CSS Color Names (pass as string)
+ { case: 'CSS color name', value: 'red' },
+ // Hex Values (string passes as string, number passes as any)
+ { case: 'number hex', value: 0xff0000 },
+ { case: '6-digit hex string', value: '#ff0000' },
+ { case: '3-digit hex string', value: '#f00' },
+ { case: '8-digit hex string with alpha', value: '#ff0000ff' },
+ { case: '4-digit hex string with alpha', value: '#f00f' },
+ // RGB/RGBA Objects (pass as any)
+ { case: 'RGB object', value: { r: 255, g: 0, b: 0 } },
+ { case: 'RGBA object', value: { r: 255, g: 0, b: 0, a: 0.5 } },
+ // RGB/RGBA Strings (pass as string)
+ { case: 'rgb string', value: 'rgb(255, 0, 0)' },
+ { case: 'rgba string', value: 'rgba(255, 0, 0, 0.5)' },
+ // Arrays (pass as any)
+ { case: 'normalized RGB array', value: [1, 0, 0] },
+ { case: 'normalized RGBA array', value: [1, 0, 0, 0.5] },
+ // Typed Arrays (pass as any)
+ { case: 'Float32Array', value: new Float32Array([1, 0, 0, 0.5]) },
+ { case: 'Uint8Array', value: new Uint8Array([255, 0, 0]) },
+ {
+ case: 'Uint8ClampedArray',
+ value: new Uint8ClampedArray([255, 0, 0, 128]),
+ },
+ // HSL/HSLA (object passes as any, string passes as string)
+ { case: 'HSL object', value: { h: 0, s: 100, l: 50 } },
+ { case: 'HSLA object', value: { h: 0, s: 100, l: 50, a: 0.5 } },
+ { case: 'hsl string', value: 'hsl(0, 100%, 50%)' },
+ // HSV/HSVA (pass as any)
+ { case: 'HSV object', value: { h: 0, s: 100, v: 100 } },
+ { case: 'HSVA object', value: { h: 0, s: 100, v: 100, a: 0.5 } },
+ ];
+
+ it.each(validColorSourceCases)(
+ 'should correctly parse various color source types: $case',
+ ({ value }) => {
+ expect(() => Tint.parse(value)).not.toThrow();
+ const parsed = Tint.parse(value);
+ expect(parsed).toEqual(value);
+ },
+ );
+ });
+
+ describe('Base Schema', () => {
+ it('should parse a valid object with all properties', () => {
+ const data = {
+ show: false,
+ id: 'custom-id',
+ label: 'My Base',
+ attrs: { extra: 'value' },
+ };
+ const result = Base.parse(data);
+ expect(result).toEqual(data);
+ });
+
+ it('should apply default values for missing optional properties', () => {
+ const data = {};
+ const result = Base.parse(data);
+ expect(result).toEqual({ show: true, id: 'mock-uid-123' });
+ expect(uid).toHaveBeenCalled();
+ });
+
+ it('should throw an error for unknown properties due to .strict()', () => {
+ const data = { show: true, unknownProperty: 'test' };
+ expect(() => Base.parse(data)).toThrow();
+ });
+ });
+
+ describe('Size Schema', () => {
+ it('should transform a single number into a width/height object', () => {
+ const input = 100;
+ const expected = { width: 100, height: 100 };
+ expect(Size.parse(input)).toEqual(expected);
+ });
+
+ it('should correctly parse a valid width/height object', () => {
+ const input = { width: 100, height: 200 };
+ expect(Size.parse(input)).toEqual(input);
+ });
+
+ it.each([
+ { case: 'negative number', input: -100 },
+ { case: 'invalid object property', input: { width: '100', height: 100 } },
+ { case: 'partial object', input: { width: 100 } },
+ { case: 'null input', input: null },
+ ])('should throw an error for invalid input: $case', ({ input }) => {
+ expect(() => Size.parse(input)).toThrow();
+ });
+ });
+
+ describe('pxOrPercentSchema', () => {
+ it.each([
+ {
+ case: 'pixel number',
+ input: 100,
+ expected: { value: 100, unit: 'px' },
+ },
+ {
+ case: 'percentage string',
+ input: '80%',
+ expected: { value: 80, unit: '%' },
+ },
+ {
+ case: 'pre-formatted object',
+ input: { value: 50, unit: 'px' },
+ expected: { value: 50, unit: 'px' },
+ },
+ ])('should correctly parse and transform $case', ({ input, expected }) => {
+ expect(pxOrPercentSchema.parse(input)).toEqual(expected);
+ });
+
+ it.each([
+ { case: 'negative number', input: -100 },
+ { case: 'malformed percentage string', input: '100' },
+ {
+ case: 'invalid pre-formatted object unit',
+ input: { value: 50, unit: 'em' },
+ },
+ ])('should throw an error for invalid input for $case', ({ input }) => {
+ expect(() => pxOrPercentSchema.parse(input)).toThrow();
+ });
+ });
+
+ describe('PxOrPercentSize Schema', () => {
+ it('should transform a single number into a full px width/height object', () => {
+ const input = 100;
+ const expected = {
+ width: { value: 100, unit: 'px' },
+ height: { value: 100, unit: 'px' },
+ };
+ expect(PxOrPercentSize.parse(input)).toEqual(expected);
+ });
+
+ it('should transform a single percentage string into a full % width/height object', () => {
+ const input = '75%';
+ const expected = {
+ width: { value: 75, unit: '%' },
+ height: { value: 75, unit: '%' },
+ };
+ expect(PxOrPercentSize.parse(input)).toEqual(expected);
+ });
+
+ it('should correctly parse a full width/height object', () => {
+ const input = { width: 150, height: '75%' };
+ const expected = {
+ width: { value: 150, unit: 'px' },
+ height: { value: 75, unit: '%' },
+ };
+ expect(PxOrPercentSize.parse(input)).toEqual(expected);
+ });
+
+ it('should allow partial PxOrPercentSize objects with deepPartial', () => {
+ const input = {
+ width: { value: 150, unit: 'px' },
+ height: { value: 75, unit: '%' },
+ };
+ const expected = {
+ width: { value: 150, unit: 'px' },
+ height: { value: 75, unit: '%' },
+ };
+ expect(deepPartial(PxOrPercentSize).parse(input)).toEqual(expected);
+ });
+
+ it.each([
+ { case: 'partial object', input: { width: 100 } },
+ { case: 'invalid value in object', input: { width: -50, height: 100 } },
+ { case: 'null input', input: null },
+ ])('should throw an error for invalid input: $case', ({ input }) => {
+ expect(() => PxOrPercentSize.parse(input)).toThrow();
+ });
+ });
+
+ describe('Placement Schema', () => {
+ it.each([
+ 'left',
+ 'left-top',
+ 'left-bottom',
+ 'top',
+ 'right',
+ 'right-top',
+ 'right-bottom',
+ 'bottom',
+ 'center',
+ 'none',
+ ])('should accept valid placement value: %s', (placement) => {
+ expect(() => Placement.parse(placement)).not.toThrow();
+ });
+
+ it.each(['top-left', 'invalid-placement', null])(
+ 'should reject invalid placement value: %s',
+ (placement) => {
+ expect(() => Placement.parse(placement)).toThrow();
+ },
+ );
+ });
+
+ describe('Gap Schema', () => {
+ it.each([
+ { case: 'a single number', input: 20, expected: { x: 20, y: 20 } },
+ {
+ case: 'object with x and y',
+ input: { x: 10, y: 30 },
+ expected: { x: 10, y: 30 },
+ },
+ {
+ case: 'object with only x',
+ input: { x: 15 },
+ expected: { x: 15, y: 0 },
+ },
+ {
+ case: 'object with only y',
+ input: { y: 25 },
+ expected: { x: 0, y: 25 },
+ },
+ { case: 'empty object', input: {}, expected: { x: 0, y: 0 } },
+ { case: 'undefined', input: undefined, expected: { x: 0, y: 0 } },
+ ])('should correctly preprocess and parse $case', ({ input, expected }) => {
+ expect(Gap.parse(input)).toEqual(expected);
+ });
+
+ it.each([
+ { case: 'negative number', input: -10 },
+ { case: 'object with negative x', input: { x: -10, y: 10 } },
+ { case: 'null input', input: null },
+ { case: 'string input', input: '20' },
+ { case: 'object with non-numeric value', input: { x: '10', y: 10 } },
+ ])('should throw an error for invalid input for $case', ({ input }) => {
+ expect(() => Gap.parse(input)).toThrow();
+ });
+ });
+
+ describe('Margin Schema', () => {
+ it.each([
+ {
+ case: 'single number',
+ input: 15,
+ expected: { top: 15, right: 15, bottom: 15, left: 15 },
+ },
+ {
+ case: 'object with x and y',
+ input: { x: 10, y: 20 },
+ expected: { top: 20, right: 10, bottom: 20, left: 10 },
+ },
+ {
+ case: 'full object',
+ input: { top: 5, right: 10, bottom: 15, left: 20 },
+ expected: { top: 5, right: 10, bottom: 15, left: 20 },
+ },
+ {
+ case: 'object with only x',
+ input: { x: 30 },
+ expected: { top: 0, right: 30, bottom: 0, left: 30 },
+ },
+ {
+ case: 'object with only y',
+ input: { y: 40 },
+ expected: { top: 40, right: 0, bottom: 40, left: 0 },
+ },
+ {
+ case: 'empty object',
+ input: {},
+ expected: { top: 0, right: 0, bottom: 0, left: 0 },
+ },
+ {
+ case: 'undefined',
+ input: undefined,
+ expected: { top: 0, right: 0, bottom: 0, left: 0 },
+ },
+ {
+ case: 'partial object with undefined',
+ input: { top: 10, right: undefined },
+ expected: { top: 10, right: 0, bottom: 0, left: 0 },
+ },
+ {
+ case: 'negative number',
+ input: -10,
+ expected: { top: -10, right: -10, bottom: -10, left: -10 },
+ },
+ {
+ case: 'object with negative value',
+ input: { top: -5, right: 10, bottom: -15, left: 20 },
+ expected: { top: -5, right: 10, bottom: -15, left: 20 },
+ },
+ ])('should correctly preprocess and parse $case', ({ input, expected }) => {
+ expect(Margin.parse(input)).toEqual(expected);
+ });
+
+ it.each([
+ { case: 'null input', input: null },
+ { case: 'object with non-numeric value', input: { top: '10' } },
+ ])('should throw an error for invalid input for $case', ({ input }) => {
+ expect(() => Margin.parse(input)).toThrow();
+ });
+ });
+
+ describe('TextureStyle Schema', () => {
+ it('should parse a full valid object', () => {
+ const data = {
+ type: 'rect',
+ fill: 'red',
+ borderWidth: 2,
+ borderColor: 'black',
+ radius: 5,
+ };
+ expect(TextureStyle.parse(data)).toEqual(data);
+ });
+
+ it('should parse an empty object', () => {
+ expect(TextureStyle.parse({})).toEqual({});
+ });
+
+ it.each([
+ { case: 'invalid enum for type', input: { type: 'circle' } },
+ { case: 'invalid type for fill', input: { fill: 123 } },
+ { case: 'invalid type for borderWidth', input: { borderWidth: '2px' } },
+ ])('should fail on invalid data types ($case)', ({ input }) => {
+ expect(() => TextureStyle.parse(input)).toThrow();
+ });
+ });
+
+ describe('RelationsStyle Schema', () => {
+ it('should add default color if not provided', () => {
+ const data = { lineWidth: 2 };
+ expect(RelationsStyle.parse(data)).toEqual({ lineWidth: 2 });
+ });
+ });
+
+ describe('TextStyle Schema', () => {
+ it('should apply default styles for a partial object', () => {
+ const data = { fontSize: 16 };
+ expect(TextStyle.parse(data)).toEqual({ fontSize: 16 });
+ });
+
+ it('should not override provided styles', () => {
+ const data = { fontFamily: 'Arial', fill: 'red', fontWeight: 'bold' };
+ expect(TextStyle.parse(data)).toEqual({
+ fontFamily: 'Arial',
+ fontWeight: 'bold',
+ fill: 'red',
+ });
+ });
+ });
+
+ describe('pxOrPercentSchema with calc() support', () => {
+ describe('Valid calc() Expressions', () => {
+ const validCalcCases = [
+ { case: 'simple subtraction', input: 'calc(100% - 20px)' },
+ { case: 'different order', input: 'calc(10px - 100%)' },
+ { case: 'simple addition', input: 'calc(20px + 40%)' },
+ { case: 'multiple px values', input: 'calc(5px + 1px - 4px)' },
+ {
+ case: 'multiple mixed values',
+ input: 'calc(10% + 20% - 14px + 40%)',
+ },
+ { case: 'multiple spaces', input: 'calc( 100% - 20px )' },
+ { case: '', input: 'calc( 100% + -20px )' },
+ ];
+
+ it.each(validCalcCases)(
+ 'should parse valid calc expression: $case',
+ ({ input }) => {
+ expect(pxOrPercentSchema.parse(input)).toBe(input);
+ },
+ );
+ });
+
+ describe('Invalid calc() Expressions', () => {
+ const invalidCalcCases = [
+ { case: 'invalid unit (rem)', input: 'calc(100% - 20rem)' },
+ { case: 'missing closing parenthesis', input: 'calc(100% - 20px' },
+ { case: 'missing opening parenthesis', input: '100% - 20px)' },
+ { case: 'empty calc', input: 'calc()' },
+ { case: 'ending with operator', input: 'calc(100% -)' },
+ { case: 'starting with operator', input: 'calc(- 100%)' },
+ { case: 'double operators', input: 'calc(100% -- 20px)' },
+ { case: 'invalid operator', input: 'calc(100% * 20px)' },
+ { case: 'no units', input: 'calc(100 - 20)' },
+ { case: 'no spaces', input: 'calc(100%-20px)' },
+ ];
+
+ it.each(invalidCalcCases)(
+ 'should throw an error for invalid calc expression: $case',
+ ({ input }) => {
+ expect(() => pxOrPercentSchema.parse(input)).toThrow();
+ },
+ );
+ });
+ });
+});
diff --git a/src/display/draw.js b/src/display/draw.js
index 599a58c5..be4bb408 100644
--- a/src/display/draw.js
+++ b/src/display/draw.js
@@ -1,15 +1,4 @@
-import { createGrid } from './elements/grid';
-import { createGroup } from './elements/group';
-import { createItem } from './elements/item';
-import { createRelations } from './elements/relations';
-import { update } from './update/update';
-
-const elementcreators = {
- group: createGroup,
- grid: createGrid,
- item: createItem,
- relations: createRelations,
-};
+import { newElement } from './elements/creator';
export const draw = (context, data) => {
const { viewport } = context;
@@ -17,18 +6,10 @@ export const draw = (context, data) => {
render(viewport, data);
function render(parent, data) {
- for (const config of data) {
- const creator = elementcreators[config.type];
- if (creator) {
- const element = creator(config);
- element.viewport = viewport;
- update(context, { elements: element, changes: config });
- parent.addChild(element);
-
- if (config.type === 'group') {
- render(element, config.items);
- }
- }
+ for (const changes of data) {
+ const element = newElement(changes.type, context);
+ element.update(changes);
+ parent.addChild(element);
}
}
};
diff --git a/src/display/elements/Element.js b/src/display/elements/Element.js
new file mode 100644
index 00000000..b027f4df
--- /dev/null
+++ b/src/display/elements/Element.js
@@ -0,0 +1,12 @@
+import { Container } from 'pixi.js';
+import { Base } from '../mixins/Base';
+import { Showable } from '../mixins/Showable';
+import { mixins } from '../mixins/utils';
+
+const ComposedElement = mixins(Container, Base, Showable);
+
+export default class Element extends ComposedElement {
+ constructor(options) {
+ super(Object.assign(options, { eventMode: 'static' }));
+ }
+}
diff --git a/src/display/elements/Grid.js b/src/display/elements/Grid.js
new file mode 100644
index 00000000..4394a030
--- /dev/null
+++ b/src/display/elements/Grid.js
@@ -0,0 +1,17 @@
+import { gridSchema } from '../data-schema/element-schema';
+import { Cellsable } from '../mixins/Cellsable';
+import { Itemable } from '../mixins/Itemable';
+import { mixins } from '../mixins/utils';
+import Element from './Element';
+
+const ComposedGrid = mixins(Element, Cellsable, Itemable);
+
+export class Grid extends ComposedGrid {
+ constructor(context) {
+ super({ type: 'grid', context });
+ }
+
+ update(changes, options) {
+ super.update(changes, gridSchema, options);
+ }
+}
diff --git a/src/display/elements/Group.js b/src/display/elements/Group.js
new file mode 100644
index 00000000..411ff0aa
--- /dev/null
+++ b/src/display/elements/Group.js
@@ -0,0 +1,16 @@
+import { groupSchema } from '../data-schema/element-schema';
+import { Childrenable } from '../mixins/Childrenable';
+import { mixins } from '../mixins/utils';
+import Element from './Element';
+
+const ComposedGroup = mixins(Element, Childrenable);
+
+export class Group extends ComposedGroup {
+ constructor(context) {
+ super({ type: 'group', context, isRenderGroup: true });
+ }
+
+ update(changes, options) {
+ super.update(changes, groupSchema, options);
+ }
+}
diff --git a/src/display/elements/Item.js b/src/display/elements/Item.js
new file mode 100644
index 00000000..b3153cf3
--- /dev/null
+++ b/src/display/elements/Item.js
@@ -0,0 +1,17 @@
+import { itemSchema } from '../data-schema/element-schema';
+import { Componentsable } from '../mixins/Componentsable';
+import { ItemSizeable } from '../mixins/Itemsizeable';
+import { mixins } from '../mixins/utils';
+import Element from './Element';
+
+const ComposedItem = mixins(Element, Componentsable, ItemSizeable);
+
+export class Item extends ComposedItem {
+ constructor(context) {
+ super({ type: 'item', context });
+ }
+
+ update(changes, options) {
+ super.update(changes, itemSchema, options);
+ }
+}
diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js
new file mode 100644
index 00000000..abd2c601
--- /dev/null
+++ b/src/display/elements/Relations.js
@@ -0,0 +1,93 @@
+import { Graphics } from 'pixi.js';
+import { calcOrientedBounds } from '../../utils/bounds';
+import { selector } from '../../utils/selector/selector';
+import { relationsSchema } from '../data-schema/element-schema';
+import { Relationstyleable } from '../mixins/Relationstyleable';
+import { Linksable } from '../mixins/linksable';
+import { mixins } from '../mixins/utils';
+import Element from './Element';
+
+const ComposedRelations = mixins(Element, Linksable, Relationstyleable);
+
+export class Relations extends ComposedRelations {
+ _renderDirty = true;
+ _renderOnNextTick = false;
+
+ constructor(context) {
+ super({ type: 'relations', context });
+ this.initPath();
+
+ this._updateTransform = this._updateTransform.bind(this);
+ this.context.viewport.app.ticker.add(this._updateTransform);
+ }
+
+ update(changes, options) {
+ super.update(changes, relationsSchema, options);
+ }
+
+ initPath() {
+ const path = new Graphics();
+ path.setStrokeStyle({ color: 'black' });
+ Object.assign(path, { type: 'path', links: [] });
+ this.addChild(path);
+ }
+
+ _updateTransform() {
+ if (this._renderOnNextTick) {
+ this.renderLink();
+ this._renderOnNextTick = false;
+ }
+
+ if (this._renderDirty) {
+ this._renderOnNextTick = true;
+ this._renderDirty = false;
+ }
+ }
+
+ destroy(options) {
+ this.context.viewport.app.ticker.remove(this._updateTransform);
+ super.destroy(options);
+ }
+
+ renderLink() {
+ const { links } = this.props;
+ const path = selector(this, '$.children[?(@.type==="path")]')[0];
+ if (!path) return;
+ path.clear();
+ let lastPoint = null;
+
+ for (const link of links) {
+ const sourceObject = this.linkedObjects[link.source];
+ const targetObject = this.linkedObjects[link.target];
+
+ if (
+ !sourceObject ||
+ !targetObject ||
+ sourceObject?.destroyed ||
+ targetObject?.destroyed
+ ) {
+ continue;
+ }
+
+ const sourceBounds = this.toLocal(
+ calcOrientedBounds(sourceObject).center,
+ );
+ const targetBounds = this.toLocal(
+ calcOrientedBounds(targetObject).center,
+ );
+
+ const sourcePoint = [sourceBounds.x, sourceBounds.y];
+ const targetPoint = [targetBounds.x, targetBounds.y];
+ if (
+ !lastPoint ||
+ lastPoint[0] !== sourcePoint[0] ||
+ lastPoint[1] !== sourcePoint[1]
+ ) {
+ path.moveTo(...sourcePoint);
+ }
+ path.lineTo(...targetPoint);
+ lastPoint = targetPoint;
+ }
+ path.stroke();
+ }
+}
diff --git a/src/display/elements/creator.js b/src/display/elements/creator.js
new file mode 100644
index 00000000..54e256df
--- /dev/null
+++ b/src/display/elements/creator.js
@@ -0,0 +1,21 @@
+/**
+ * @fileoverview Element creation factory.
+ *
+ * To solve a circular dependency issue, this module does not import specific element classes directly.
+ * Instead, it uses a registration pattern where classes are registered via `registerElement` and instantiated via `newElement`.
+ *
+ * The registration of element classes is handled explicitly in `registry.js` at the application's entry point.
+ */
+
+const creator = {};
+
+export const registerElement = (type, elementClass) => {
+ creator[type] = elementClass;
+};
+
+export const newElement = (type, context) => {
+ if (!creator[type]) {
+ throw new Error(`Element type "${type}" has not been registered.`);
+ }
+ return new creator[type](context);
+};
diff --git a/src/display/elements/grid.js b/src/display/elements/grid.js
deleted file mode 100644
index b9a0bcab..00000000
--- a/src/display/elements/grid.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import { isValidationError } from 'zod-validation-error';
-import { validate } from '../../utils/validator';
-import { elementPipeline } from '../change/pipeline/element';
-import { deepGridObject } from '../data-schema/data-schema';
-import { updateObject } from '../update/update-object';
-import { createContainer } from '../utils';
-import { createItem } from './item';
-
-const GRID_OBJECT_CONFIG = {
- margin: 4,
-};
-
-export const createGrid = (config) => {
- const element = createContainer(config);
- element.position.set(config.position.x, config.position.y);
- element.config = {
- ...element.config,
- position: config.position,
- cells: config.cells,
- itemSize: config.itemSize,
- };
- addItemElements(element, config.cells, config.itemSize);
- return element;
-};
-
-const pipelineKeys = ['show', 'position', 'gridComponents'];
-export const updateGrid = (element, changes, options) => {
- const validated = validate(changes, deepGridObject);
- if (isValidationError(validated)) throw validated;
- updateObject(element, changes, elementPipeline, pipelineKeys, options);
-};
-
-const addItemElements = (container, cells, cellSize) => {
- for (let rowIndex = 0; rowIndex < cells.length; rowIndex++) {
- const row = cells[rowIndex];
- for (let colIndex = 0; colIndex < row.length; colIndex++) {
- const col = row[colIndex];
- if (!col || col === 0) continue;
-
- const item = createItem({
- type: 'item',
- id: `${container.id}.${rowIndex}.${colIndex}`,
- position: {
- x: colIndex * (cellSize.width + GRID_OBJECT_CONFIG.margin),
- y: rowIndex * (cellSize.height + GRID_OBJECT_CONFIG.margin),
- },
- size: {
- width: cellSize.width,
- height: cellSize.height,
- },
- metadata: {
- index: colIndex + row.length * rowIndex,
- },
- });
- container.addChild(item);
- }
- }
-};
diff --git a/src/display/elements/group.js b/src/display/elements/group.js
deleted file mode 100644
index bcb772db..00000000
--- a/src/display/elements/group.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { isValidationError } from 'zod-validation-error';
-import { validate } from '../../utils/validator';
-import { elementPipeline } from '../change/pipeline/element';
-import { deepGroupObject } from '../data-schema/data-schema';
-import { updateObject } from '../update/update-object';
-import { createContainer } from '../utils';
-
-export const createGroup = (config) => {
- const container = createContainer({ ...config, isRenderGroup: true });
- return container;
-};
-
-const pipelineKeys = ['show', 'position'];
-export const updateGroup = (element, changes, options) => {
- const validated = validate(changes, deepGroupObject);
- if (isValidationError(validated)) throw validated;
- updateObject(element, changes, elementPipeline, pipelineKeys, options);
-};
diff --git a/src/display/elements/item.js b/src/display/elements/item.js
deleted file mode 100644
index 75d8bbd1..00000000
--- a/src/display/elements/item.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { isValidationError } from 'zod-validation-error';
-import { validate } from '../../utils/validator';
-import { elementPipeline } from '../change/pipeline/element';
-import { deepSingleObject } from '../data-schema/data-schema';
-import { updateObject } from '../update/update-object';
-import { createContainer } from '../utils';
-
-export const createItem = (config) => {
- const element = createContainer(config);
- element.position.set(config.position.x, config.position.y);
- element.size = config.size;
- element.config = {
- ...element.config,
- position: config.position,
- size: config.size,
- };
- return element;
-};
-
-const pipelineKeys = ['show', 'position', 'components'];
-export const updateItem = (element, changes, options) => {
- const validated = validate(changes, deepSingleObject);
- if (isValidationError(validated)) throw validated;
- updateObject(element, changes, elementPipeline, pipelineKeys, options);
-};
diff --git a/src/display/elements/registry.js b/src/display/elements/registry.js
new file mode 100644
index 00000000..74ce021a
--- /dev/null
+++ b/src/display/elements/registry.js
@@ -0,0 +1,10 @@
+import { Grid } from './Grid';
+import { Group } from './Group';
+import { Item } from './Item';
+import { Relations } from './Relations';
+import { registerElement } from './creator';
+
+registerElement('group', Group);
+registerElement('grid', Grid);
+registerElement('item', Item);
+registerElement('relations', Relations);
diff --git a/src/display/elements/relations.js b/src/display/elements/relations.js
deleted file mode 100644
index 5023af84..00000000
--- a/src/display/elements/relations.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import { Graphics } from 'pixi.js';
-import { isValidationError } from 'zod-validation-error';
-import { validate } from '../../utils/validator';
-import { elementPipeline } from '../change/pipeline/element';
-import { deepRelationGroupObject } from '../data-schema/data-schema';
-import { updateObject } from '../update/update-object';
-import { createContainer } from '../utils';
-
-export const createRelations = (config) => {
- const element = createContainer(config);
- const path = createPath();
- element.addChild(path);
- return element;
-};
-
-const pipelineKeys = ['show', 'strokeStyle', 'links'];
-export const updateRelations = (element, changes, options) => {
- const validated = validate(changes, deepRelationGroupObject);
- if (isValidationError(validated)) throw validated;
- updateObject(element, changes, elementPipeline, pipelineKeys, options);
-};
-
-const createPath = () => {
- const path = new Graphics();
- Object.assign(path, { type: 'path', links: [] });
- return path;
-};
diff --git a/src/display/mixins/Animationable.js b/src/display/mixins/Animationable.js
new file mode 100644
index 00000000..01d095ac
--- /dev/null
+++ b/src/display/mixins/Animationable.js
@@ -0,0 +1,21 @@
+import { UPDATE_STAGES } from './constants';
+import { tweensOf } from './utils';
+
+const KEYS = ['animation'];
+
+export const Animationable = (superClass) => {
+ const MixedClass = class extends superClass {
+ _applyAnimation(relevantChanges) {
+ const { animation } = relevantChanges;
+ if (!animation) {
+ tweensOf(this).forEach((tween) => tween.progress(1).kill());
+ }
+ }
+ };
+ MixedClass.registerHandler(
+ KEYS,
+ MixedClass.prototype._applyAnimation,
+ UPDATE_STAGES.ANIMATION,
+ );
+ return MixedClass;
+};
diff --git a/src/display/mixins/Animationsizeable.js b/src/display/mixins/Animationsizeable.js
new file mode 100644
index 00000000..482553f1
--- /dev/null
+++ b/src/display/mixins/Animationsizeable.js
@@ -0,0 +1,43 @@
+import gsap from 'gsap';
+import { calcSize, killTweensOf } from '../mixins/utils';
+import { UPDATE_STAGES } from './constants';
+
+const KEYS = ['animation', 'animationDuration', 'source', 'size', 'margin'];
+
+export const AnimationSizeable = (superClass) => {
+ const MixedClass = class extends superClass {
+ _applyAnimationSize(relevantChanges) {
+ const { animation, animationDuration, source, size, margin } =
+ relevantChanges;
+ const newSize = calcSize(this, { source, size, margin });
+
+ if (animation) {
+ this.context.animationContext.add(() => {
+ killTweensOf(this);
+ gsap.to(this, {
+ pixi: {
+ width: newSize.width,
+ height: newSize.height,
+ },
+ duration: animationDuration / 1000,
+ ease: 'power2.inOut',
+ onUpdate: () => {
+ this._applyPlacement({
+ placement: this.props.placement,
+ margin: this.props.margin,
+ });
+ },
+ });
+ });
+ } else {
+ this.setSize(newSize.width, newSize.height);
+ }
+ }
+ };
+ MixedClass.registerHandler(
+ KEYS,
+ MixedClass.prototype._applyAnimationSize,
+ UPDATE_STAGES.SIZE,
+ );
+ return MixedClass;
+};
diff --git a/src/display/mixins/Base.js b/src/display/mixins/Base.js
new file mode 100644
index 00000000..8a53af26
--- /dev/null
+++ b/src/display/mixins/Base.js
@@ -0,0 +1,121 @@
+import { isValidationError } from 'zod-validation-error';
+import { deepMerge } from '../../utils/deepmerge/deepmerge';
+import { diffJson } from '../../utils/diff/diff-json';
+import { validate } from '../../utils/validator';
+import { deepPartial } from '../../utils/zod-deep-strict-partial';
+import { Type } from './Type';
+
+export const Base = (superClass) => {
+ return class extends Type(superClass) {
+ static _handlerMap = new Map();
+ static _handlerRegistry = new Map();
+ #context;
+
+ constructor(options = {}) {
+ const { context = null, ...rest } = options;
+ super(rest);
+ this.#context = context;
+ this.props = {};
+ }
+
+ get context() {
+ return this.#context;
+ }
+
+ static registerHandler(keys, handler, stage) {
+ if (!Object.prototype.hasOwnProperty.call(this, '_handlerRegistry')) {
+ this._handlerRegistry = new Map(this._handlerRegistry);
+ this._handlerMap = new Map(this._handlerMap);
+ }
+
+ const registration = this._handlerRegistry.get(handler) ?? {
+ keys: new Set(),
+ stage: stage ?? 99,
+ };
+ keys.forEach((key) => registration.keys.add(key));
+ this._handlerRegistry.set(handler, registration);
+ registration.keys.forEach((key) => {
+ if (!this._handlerMap.has(key)) this._handlerMap.set(key, new Set());
+ this._handlerMap.get(key).add(handler);
+ });
+ }
+
+ update(changes, schema, options = {}) {
+ const { arrayMerge = 'merge', refresh = false } = options;
+ const effectiveChanges = refresh && !changes ? {} : changes;
+ const validatedChanges = validate(effectiveChanges, deepPartial(schema));
+ if (isValidationError(validatedChanges)) throw validatedChanges;
+
+ const prevProps = JSON.parse(JSON.stringify(this.props));
+ this.props = deepMerge(prevProps, validatedChanges, { arrayMerge });
+
+ const keysToProcess = refresh
+ ? Object.keys(this.props)
+ : Object.keys(diffJson(prevProps, this.props) ?? {});
+
+ const { id, label, attrs } = validatedChanges;
+ if (id || label || attrs) {
+ this._applyRaw({ id, label, ...attrs }, arrayMerge);
+ }
+
+ const tasks = new Map();
+ for (const key of keysToProcess) {
+ const handlers = this.constructor._handlerMap.get(key);
+ if (handlers) {
+ handlers.forEach((handler) => {
+ if (!tasks.has(handler)) {
+ const { stage } = this.constructor._handlerRegistry.get(handler);
+ tasks.set(handler, { stage });
+ }
+ });
+ }
+ }
+
+ const sortedTasks = [...tasks.entries()].sort(
+ (a, b) => a[1].stage - b[1].stage,
+ );
+ sortedTasks.forEach(([handler, _]) => {
+ const keysForHandler =
+ this.constructor._handlerRegistry.get(handler).keys;
+ const fullPayload = {};
+ keysForHandler.forEach((key) => {
+ if (Object.prototype.hasOwnProperty.call(this.props, key)) {
+ fullPayload[key] = this.props[key];
+ }
+ });
+ handler.call(this, fullPayload, { arrayMerge, refresh });
+ });
+
+ if (this.parent?._onChildUpdate) {
+ this.parent._onChildUpdate(
+ this.id,
+ diffJson(prevProps, this.props),
+ arrayMerge,
+ );
+ }
+ }
+
+ _applyRaw(attrs, arrayMerge) {
+ for (const [key, value] of Object.entries(attrs)) {
+ if (value === undefined) continue;
+
+ if (key === 'x' || key === 'y') {
+ const x = key === 'x' ? value : (attrs?.x ?? this.x);
+ const y = key === 'y' ? value : (attrs?.y ?? this.y);
+ this.position.set(x, y);
+ } else if (key === 'width' || key === 'height') {
+ const width = key === 'width' ? value : (attrs?.width ?? this.width);
+ const height =
+ key === 'height' ? value : (attrs?.height ?? this.height);
+ this.setSize(width, height);
+ } else {
+ this._updateProperty(key, value, arrayMerge);
+ }
+ }
+ }
+
+ _updateProperty(key, value, arrayMerge) {
+ deepMerge(this, { [key]: value }, { arrayMerge });
+ }
+ };
+};
diff --git a/src/display/mixins/Cellsable.js b/src/display/mixins/Cellsable.js
new file mode 100644
index 00000000..6a7f98e6
--- /dev/null
+++ b/src/display/mixins/Cellsable.js
@@ -0,0 +1,51 @@
+import { newElement } from '../elements/creator';
+import { UPDATE_STAGES } from './constants';
+
+const KEYS = ['cells'];
+
+export const Cellsable = (superClass) => {
+ const MixedClass = class extends superClass {
+ _applyCells(relevantChanges) {
+ const { cells } = relevantChanges;
+
+ const { gap, item: itemProps } = this.props;
+ const currentItemIds = new Set(this.children.map((child) => child.id));
+ const requiredItemIds = new Set();
+
+ cells.forEach((row, rowIndex) => {
+ row.forEach((col, colIndex) => {
+ const id = `${this.id}.${rowIndex}.${colIndex}`;
+ if (col === 1) {
+ requiredItemIds.add(id);
+ if (!currentItemIds.has(id)) {
+ const item = newElement('item', this.context);
+ item.update({
+ id,
+ ...itemProps,
+ attrs: {
+ x: colIndex * (itemProps.size.width + gap.x),
+ y: rowIndex * (itemProps.size.height + gap.y),
+ },
+ });
+ this.addChild(item);
+ }
+ }
+ });
+ });
+
+ const currentItems = [...this.children];
+ currentItems.forEach((item) => {
+ if (!requiredItemIds.has(item.id)) {
+ this.removeChild(item);
+ item.destroy({ children: true });
+ }
+ });
+ }
+ };
+ MixedClass.registerHandler(
+ KEYS,
+ MixedClass.prototype._applyCells,
+ UPDATE_STAGES.CHILD_RENDER,
+ );
+ return MixedClass;
+};
diff --git a/src/display/mixins/Childrenable.js b/src/display/mixins/Childrenable.js
new file mode 100644
index 00000000..adaa5242
--- /dev/null
+++ b/src/display/mixins/Childrenable.js
@@ -0,0 +1,63 @@
+import { isValidationError } from 'zod-validation-error';
+import { deepMerge } from '../../utils/deepmerge/deepmerge';
+import { findIndexByPriority } from '../../utils/findIndexByPriority';
+import { validate } from '../../utils/validator';
+import { elementTypes } from '../data-schema/element-schema';
+import { newElement } from '../elements/creator';
+import { UPDATE_STAGES } from './constants';
+
+const KEYS = ['children'];
+
+export const Childrenable = (superClass) => {
+ const MixedClass = class extends superClass {
+ _applyChildren(relevantChanges, options) {
+ const { children } = relevantChanges;
+ let elements = [...this.children];
+
+ if (options.arrayMerge === 'replace') {
+ elements.forEach((element) => {
+ this.removeChild(element);
+ element.destroy({ children: true });
+ });
+ elements = [];
+ }
+
+ for (let childChange of children) {
+ const idx = findIndexByPriority(elements, childChange);
+ let element = null;
+
+ if (idx !== -1) {
+ element = elements[idx];
+ elements.splice(idx, 1);
+ } else {
+ childChange = validate(childChange, elementTypes);
+ if (isValidationError(childChange)) throw childChange;
+
+ element = newElement(childChange.type, this.context);
+ this.addChild(element);
+ }
+ element.update(childChange, options);
+ }
+ }
+
+ _onChildUpdate(childId, changes, arrayMerge) {
+ if (!this.props.children) return;
+
+ const childIndex = this.props.children.findIndex((c) => c.id === childId);
+ if (childIndex !== -1) {
+ const updatedChildProps = deepMerge(
+ this.props.children[childIndex],
+ changes,
+ { arrayMerge },
+ );
+ this.props.children[childIndex] = updatedChildProps;
+ }
+ }
+ };
+ MixedClass.registerHandler(
+ KEYS,
+ MixedClass.prototype._applyChildren,
+ UPDATE_STAGES.CHILD_RENDER,
+ );
+ return MixedClass;
+};
diff --git a/src/display/mixins/Componentsable.js b/src/display/mixins/Componentsable.js
new file mode 100644
index 00000000..8e4c5042
--- /dev/null
+++ b/src/display/mixins/Componentsable.js
@@ -0,0 +1,65 @@
+import { isValidationError } from 'zod-validation-error';
+import { deepMerge } from '../../utils/deepmerge/deepmerge';
+import { findIndexByPriority } from '../../utils/findIndexByPriority';
+import { validate } from '../../utils/validator';
+import { newComponent } from '../components/creator';
+import { componentSchema } from '../data-schema/component-schema';
+import { UPDATE_STAGES } from './constants';
+
+const KEYS = ['components'];
+
+export const Componentsable = (superClass) => {
+ const MixedClass = class extends superClass {
+ _applyComponents(relevantChanges, options) {
+ const { components: componentsChanges } = relevantChanges;
+ let components = [...this.children];
+
+ if (options.arrayMerge === 'replace') {
+ components.forEach((component) => {
+ this.removeChild(component);
+ component.destroy({ children: true });
+ });
+ components = [];
+ }
+
+ for (let componentChange of componentsChanges) {
+ const idx = findIndexByPriority(components, componentChange);
+ let component = null;
+
+ if (idx !== -1) {
+ component = components[idx];
+ components.splice(idx, 1);
+ } else {
+ componentChange = validate(componentChange, componentSchema);
+ if (isValidationError(componentChange)) throw componentChange;
+
+ component = newComponent(componentChange.type, this.context);
+ this.addChild(component);
+ }
+ component.update(componentChange, options);
+ }
+ }
+
+ _onChildUpdate(childId, changes, arrayMerge) {
+ if (!this.props.components) return;
+
+ const childIndex = this.props.components.findIndex(
+ (c) => c.id === childId,
+ );
+ if (childIndex !== -1) {
+ const updatedChildProps = deepMerge(
+ this.props.components[childIndex],
+ changes,
+ { arrayMerge },
+ );
+ this.props.components[childIndex] = updatedChildProps;
+ }
+ }
+ };
+ MixedClass.registerHandler(
+ KEYS,
+ MixedClass.prototype._applyComponents,
+ UPDATE_STAGES.CHILD_RENDER,
+ );
+ return MixedClass;
+};
diff --git a/src/display/mixins/Componentsizeable.js b/src/display/mixins/Componentsizeable.js
new file mode 100644
index 00000000..09f62375
--- /dev/null
+++ b/src/display/mixins/Componentsizeable.js
@@ -0,0 +1,21 @@
+import { UPDATE_STAGES } from './constants';
+import { calcSize } from './utils';
+
+const KEYS = ['source', 'size', 'margin'];
+
+export const ComponentSizeable = (superClass) => {
+ const MixedClass = class extends superClass {
+ _applyComponentSize(relevantChanges) {
+ const { source, size, margin } = relevantChanges;
+ const newSize = calcSize(this, { source, size, margin });
+ this.setSize(newSize.width, newSize.height);
+ this.position.set(-newSize.borderWidth / 2);
+ }
+ };
+ MixedClass.registerHandler(
+ KEYS,
+ MixedClass.prototype._applyComponentSize,
+ UPDATE_STAGES.SIZE,
+ );
+ return MixedClass;
+};
diff --git a/src/display/mixins/Itemable.js b/src/display/mixins/Itemable.js
new file mode 100644
index 00000000..448b928f
--- /dev/null
+++ b/src/display/mixins/Itemable.js
@@ -0,0 +1,37 @@
+import { UPDATE_STAGES } from './constants';
+
+const KEYS = ['item', 'gap'];
+
+export const Itemable = (superClass) => {
+ const MixedClass = class extends superClass {
+ _applyItem(relevantChanges, options) {
+ const { item: itemProps, gap } = relevantChanges;
+
+ const gridIdPrefix = `${this.id}.`;
+ for (const child of this.children) {
+ if (!child.id.startsWith(gridIdPrefix)) continue;
+ const coordsPart = child.id.substring(gridIdPrefix.length);
+ const [rowIndex, colIndex] = coordsPart.split('.').map(Number);
+
+ if (!Number.isNaN(rowIndex) && !Number.isNaN(colIndex)) {
+ child.update(
+ {
+ ...itemProps,
+ attrs: {
+ x: colIndex * (itemProps.size.width + gap.x),
+ y: rowIndex * (itemProps.size.height + gap.y),
+ },
+ },
+ options,
+ );
+ }
+ }
+ }
+ };
+ MixedClass.registerHandler(
+ KEYS,
+ MixedClass.prototype._applyItem,
+ UPDATE_STAGES.VISUAL,
+ );
+ return MixedClass;
+};
diff --git a/src/display/mixins/Itemsizeable.js b/src/display/mixins/Itemsizeable.js
new file mode 100644
index 00000000..2c3e369b
--- /dev/null
+++ b/src/display/mixins/Itemsizeable.js
@@ -0,0 +1,19 @@
+import { UPDATE_STAGES } from './constants';
+
+const KEYS = ['size'];
+
+export const ItemSizeable = (superClass) => {
+ const MixedClass = class extends superClass {
+ _applyItemSize() {
+ for (const child of this.children) {
+ child.update(null, { refresh: true });
+ }
+ }
+ };
+ MixedClass.registerHandler(
+ KEYS,
+ MixedClass.prototype._applyItemSize,
+ UPDATE_STAGES.SIZE,
+ );
+ return MixedClass;
+};
diff --git a/src/display/mixins/Placementable.js b/src/display/mixins/Placementable.js
new file mode 100644
index 00000000..d14f4952
--- /dev/null
+++ b/src/display/mixins/Placementable.js
@@ -0,0 +1,64 @@
+import { UPDATE_STAGES } from './constants';
+
+const KEYS = ['placement', 'margin'];
+
+const DIRECTION_MAP = {
+ left: { h: 'left', v: 'center' },
+ right: { h: 'right', v: 'center' },
+ top: { h: 'center', v: 'top' },
+ bottom: { h: 'center', v: 'bottom' },
+ center: { h: 'center', v: 'center' },
+};
+
+export const Placementable = (superClass) => {
+ const MixedClass = class extends superClass {
+ _applyPlacement(relevantChanges) {
+ const { placement, margin } = relevantChanges;
+
+ const [first, second] = placement.split('-');
+ const directions = second
+ ? { 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);
+ }
+ };
+ MixedClass.registerHandler(
+ KEYS,
+ MixedClass.prototype._applyPlacement,
+ UPDATE_STAGES.LAYOUT,
+ );
+ return MixedClass;
+};
+
+const getHorizontalPosition = (component, align, margin) => {
+ const parentWidth = component.parent.props.size.width;
+ let result = null;
+ if (align === 'left') {
+ result = margin.left;
+ } else if (align === 'right') {
+ result = parentWidth - component.width - margin.right;
+ } else if (align === 'center') {
+ const marginWidth = component.width + margin.left + margin.right;
+ const blockStartPosition = (parentWidth - marginWidth) / 2;
+ result = blockStartPosition + margin.left;
+ }
+ return result;
+};
+
+const getVerticalPosition = (component, align, margin) => {
+ const parentHeight = component.parent.props.size.height;
+ let result = null;
+ if (align === 'top') {
+ result = margin.top;
+ } else if (align === 'bottom') {
+ result = parentHeight - component.height - margin.bottom;
+ } else if (align === 'center') {
+ const marginHeight = component.height + margin.top + margin.bottom;
+ const blockStartPosition = (parentHeight - marginHeight) / 2;
+ result = blockStartPosition + margin.top;
+ }
+ return result;
+};
diff --git a/src/display/mixins/Relationstyleable.js b/src/display/mixins/Relationstyleable.js
new file mode 100644
index 00000000..fdd84e8d
--- /dev/null
+++ b/src/display/mixins/Relationstyleable.js
@@ -0,0 +1,27 @@
+import { getColor } from '../../utils/get';
+import { selector } from '../../utils/selector/selector';
+import { UPDATE_STAGES } from './constants';
+
+const KEYS = ['style'];
+
+export const Relationstyleable = (superClass) => {
+ const MixedClass = class extends superClass {
+ _applyRelationstyle(relevantChanges) {
+ const { style } = relevantChanges;
+ const path = selector(this, '$.children[?(@.type==="path")]')[0];
+ if (!path) return;
+
+ if ('color' in style) {
+ style.color = getColor(this.context.theme, style.color);
+ }
+ path.setStrokeStyle({ ...path.strokeStyle, ...style });
+ this._renderDirty = true;
+ }
+ };
+ MixedClass.registerHandler(
+ KEYS,
+ MixedClass.prototype._applyRelationstyle,
+ UPDATE_STAGES.VISUAL,
+ );
+ return MixedClass;
+};
diff --git a/src/display/mixins/Showable.js b/src/display/mixins/Showable.js
new file mode 100644
index 00000000..96624d50
--- /dev/null
+++ b/src/display/mixins/Showable.js
@@ -0,0 +1,18 @@
+import { UPDATE_STAGES } from './constants';
+
+const KEYS = ['show'];
+
+export const Showable = (superClass) => {
+ const MixedClass = class extends superClass {
+ _applyShow(relevantChanges) {
+ const { show } = relevantChanges;
+ this.renderable = show;
+ }
+ };
+ MixedClass.registerHandler(
+ KEYS,
+ MixedClass.prototype._applyShow,
+ UPDATE_STAGES.RENDER,
+ );
+ return MixedClass;
+};
diff --git a/src/display/mixins/Sourceable.js b/src/display/mixins/Sourceable.js
new file mode 100644
index 00000000..ea38e8be
--- /dev/null
+++ b/src/display/mixins/Sourceable.js
@@ -0,0 +1,21 @@
+import { getTexture } from '../../assets/textures/texture';
+import { UPDATE_STAGES } from './constants';
+
+const KEYS = ['source'];
+
+export const Sourceable = (superClass) => {
+ const MixedClass = class extends superClass {
+ _applySource(relevantChanges) {
+ const { source } = relevantChanges;
+ const { viewport, theme } = this.context;
+ const texture = getTexture(viewport.app.renderer, theme, source);
+ this.texture = texture;
+ }
+ };
+ MixedClass.registerHandler(
+ KEYS,
+ MixedClass.prototype._applySource,
+ UPDATE_STAGES.RENDER,
+ );
+ return MixedClass;
+};
diff --git a/src/display/mixins/Textable.js b/src/display/mixins/Textable.js
new file mode 100644
index 00000000..6bb5c111
--- /dev/null
+++ b/src/display/mixins/Textable.js
@@ -0,0 +1,29 @@
+import { UPDATE_STAGES } from './constants';
+
+const KEYS = ['text', 'split'];
+
+export const Textable = (superClass) => {
+ const MixedClass = class extends superClass {
+ _applyText(relevantChanges) {
+ const { text, split } = relevantChanges;
+ this.text = splitText(text, split);
+ }
+ };
+ MixedClass.registerHandler(
+ KEYS,
+ MixedClass.prototype._applyText,
+ UPDATE_STAGES.RENDER,
+ );
+ return MixedClass;
+};
+
+const splitText = (text, split) => {
+ if (split === 0) {
+ return text;
+ }
+ let result = '';
+ for (let i = 0; i < text.length; i += split) {
+ result += `${text.slice(i, i + split)}\n`;
+ }
+ return result.trim();
+};
diff --git a/src/display/mixins/Textstyleable.js b/src/display/mixins/Textstyleable.js
new file mode 100644
index 00000000..d47bac0f
--- /dev/null
+++ b/src/display/mixins/Textstyleable.js
@@ -0,0 +1,59 @@
+import { getColor } from '../../utils/get';
+import { FONT_WEIGHT, UPDATE_STAGES } from './constants';
+
+const KEYS = ['text', 'split', 'style', 'margin'];
+
+export const Textstyleable = (superClass) => {
+ const MixedClass = class extends superClass {
+ _applyTextstyle(relevantChanges) {
+ const { style, margin } = relevantChanges;
+ const { theme } = this.context.theme;
+
+ for (const key in style) {
+ if (key === 'fontFamily' || key === 'fontWeight') {
+ this.style.fontFamily = `${style.fontFamily ?? this.style.fontFamily.split(' ')[0]} ${FONT_WEIGHT[style.fontWeight ?? this.style.fontWeight]}`;
+ } else if (key === 'fill') {
+ this.style[key] = getColor(theme, style.fill);
+ } else if (key === 'fontSize' && style[key] === 'auto') {
+ setAutoFontSize(this, margin);
+ } else {
+ this.style[key] = style[key];
+ }
+ }
+ }
+ };
+ MixedClass.registerHandler(
+ KEYS,
+ MixedClass.prototype._applyTextstyle,
+ UPDATE_STAGES.VISUAL,
+ );
+ return MixedClass;
+};
+
+const setAutoFontSize = (object, margin) => {
+ object.visible = false;
+ const { width, height } = object.parent.props.size;
+ const parentSize = {
+ width: width - margin.left - margin.right,
+ height: height - margin.top - margin.bottom,
+ };
+ object.visible = true;
+
+ let minSize = 1;
+ let maxSize = 100;
+
+ while (minSize <= maxSize) {
+ const fontSize = Math.floor((minSize + maxSize) / 2);
+ object.style.fontSize = fontSize;
+
+ const metrics = object.getLocalBounds();
+ if (
+ metrics.width <= parentSize.width &&
+ metrics.height <= parentSize.height
+ ) {
+ minSize = fontSize + 1;
+ } else {
+ maxSize = fontSize - 1;
+ }
+ }
+};
diff --git a/src/display/mixins/Tintable.js b/src/display/mixins/Tintable.js
new file mode 100644
index 00000000..f7e7cbe1
--- /dev/null
+++ b/src/display/mixins/Tintable.js
@@ -0,0 +1,19 @@
+import { getColor } from '../../utils/get';
+import { UPDATE_STAGES } from './constants';
+
+const KEYS = ['tint'];
+
+export const Tintable = (superClass) => {
+ const MixedClass = class extends superClass {
+ _applyTint(relevantChanges) {
+ const { tint } = relevantChanges;
+ this.tint = getColor(this.context.theme, tint);
+ }
+ };
+ MixedClass.registerHandler(
+ KEYS,
+ MixedClass.prototype._applyTint,
+ UPDATE_STAGES.VISUAL,
+ );
+ return MixedClass;
+};
diff --git a/src/display/mixins/Type.js b/src/display/mixins/Type.js
new file mode 100644
index 00000000..9ca124ce
--- /dev/null
+++ b/src/display/mixins/Type.js
@@ -0,0 +1,15 @@
+export const Type = (superClass) => {
+ return class extends superClass {
+ #type;
+
+ constructor(options = {}) {
+ const { type = null, ...rest } = options;
+ super(rest);
+ this.#type = type;
+ }
+
+ get type() {
+ return this.#type;
+ }
+ };
+};
diff --git a/src/display/components/config.js b/src/display/mixins/constants.js
similarity index 72%
rename from src/display/components/config.js
rename to src/display/mixins/constants.js
index 0f04d03c..6d387e50 100644
--- a/src/display/components/config.js
+++ b/src/display/mixins/constants.js
@@ -1,3 +1,12 @@
+export const UPDATE_STAGES = Object.freeze({
+ RENDER: 0,
+ CHILD_RENDER: 10,
+ VISUAL: 20,
+ ANIMATION: 25,
+ SIZE: 30,
+ LAYOUT: 40,
+});
+
export const FONT_WEIGHT = {
100: 'thin',
200: 'extralight',
diff --git a/src/display/mixins/linksable.js b/src/display/mixins/linksable.js
new file mode 100644
index 00000000..d898f1d3
--- /dev/null
+++ b/src/display/mixins/linksable.js
@@ -0,0 +1,28 @@
+import { selector } from '../../utils/selector/selector';
+import { UPDATE_STAGES } from './constants';
+
+const KEYS = ['links'];
+
+export const Linksable = (superClass) => {
+ const MixedClass = class extends superClass {
+ _applyLinks(relevantChanges) {
+ const { links } = relevantChanges;
+ this.linkedObjects = uniqueLinked(this.context.viewport, links);
+ this._renderDirty = true;
+ }
+ };
+ MixedClass.registerHandler(
+ KEYS,
+ MixedClass.prototype._applyLinks,
+ UPDATE_STAGES.RENDER,
+ );
+ return MixedClass;
+};
+
+const uniqueLinked = (viewport, links) => {
+ const uniqueIds = new Set(links.flatMap((link) => Object.values(link)));
+ const objects = selector(viewport, '$..children').filter((obj) =>
+ uniqueIds.has(obj.id),
+ );
+ return Object.fromEntries(objects.map((obj) => [obj.id, obj]));
+};
diff --git a/src/display/mixins/utils.js b/src/display/mixins/utils.js
new file mode 100644
index 00000000..733e1597
--- /dev/null
+++ b/src/display/mixins/utils.js
@@ -0,0 +1,62 @@
+import gsap from 'gsap';
+
+export const tweensOf = (object) => gsap.getTweensOf(object);
+
+export const killTweensOf = (object) => gsap.killTweensOf(object);
+
+const parseCalcExpression = (expression, parentDimension) => {
+ const innerExpression = expression.substring(5, expression.length - 1);
+ const sanitizedExpression = innerExpression.replace(/\s-\s/g, ' + -');
+ const terms = sanitizedExpression.split(/\s\+\s/);
+
+ let totalValue = 0;
+ for (const term of terms) {
+ const trimmedTerm = term.trim();
+ if (trimmedTerm.endsWith('%')) {
+ const percentage = Number.parseFloat(trimmedTerm);
+ totalValue += parentDimension * (percentage / 100);
+ } else {
+ const pixels = Number.parseFloat(trimmedTerm);
+ totalValue += pixels;
+ }
+ }
+ return totalValue;
+};
+
+export const calcSize = (component, { source, size }) => {
+ const { width: parentWidth, height: parentHeight } =
+ component.parent.props.size;
+ const borderWidth =
+ typeof source === 'object' ? (source?.borderWidth ?? 0) : 0;
+
+ let finalWidth = null;
+ let finalHeight = null;
+
+ if (typeof size.width === 'string' && size.width.startsWith('calc')) {
+ finalWidth = parseCalcExpression(size.width, parentWidth);
+ } else {
+ finalWidth =
+ size.width.unit === '%'
+ ? parentWidth * (size.width.value / 100)
+ : size.width.value;
+ }
+
+ if (typeof size.height === 'string' && size.height.startsWith('calc')) {
+ finalHeight = parseCalcExpression(size.height, parentHeight);
+ } else {
+ finalHeight =
+ size.height.unit === '%'
+ ? parentHeight * (size.height.value / 100)
+ : size.height.value;
+ }
+
+ return {
+ width: finalWidth + borderWidth,
+ height: finalHeight + borderWidth,
+ borderWidth: borderWidth,
+ };
+};
+
+export const mixins = (baseClass, ...mixins) => {
+ return mixins.reduce((target, mixin) => mixin(target), baseClass);
+};
diff --git a/src/display/update.js b/src/display/update.js
new file mode 100644
index 00000000..fec59f9c
--- /dev/null
+++ b/src/display/update.js
@@ -0,0 +1,67 @@
+import { z } from 'zod';
+import { isValidationError } from 'zod-validation-error';
+import { convertArray } from '../utils/convert';
+import { selector } from '../utils/selector/selector';
+import { uid } from '../utils/uuid';
+import { validate } from '../utils/validator';
+
+const updateSchema = z.object({
+ path: z.nullable(z.string()).default(null),
+ changes: z.record(z.unknown()).nullable().default(null),
+ history: z.union([z.boolean(), z.string()]).default(false),
+ relativeTransform: z.boolean().default(false),
+ arrayMerge: z.enum(['merge', 'replace']).default('merge'),
+ refresh: z.boolean().default(false),
+});
+
+export const update = (viewport, opts) => {
+ const config = validate(opts, updateSchema.passthrough());
+ if (isValidationError(config)) throw config;
+
+ const historyId = createHistoryId(config.history);
+ const elements = 'elements' in config ? convertArray(config.elements) : [];
+ if (viewport && config.path) {
+ elements.push(...selector(viewport, config.path));
+ }
+
+ for (const element of elements) {
+ if (!element) {
+ continue;
+ }
+ const changes = JSON.parse(JSON.stringify(config.changes));
+ if (config.relativeTransform && changes.attrs) {
+ changes.attrs = applyRelativeTransform(element, changes.attrs);
+ }
+ element.update(changes, {
+ historyId,
+ arrayMerge: config.arrayMerge,
+ refresh: config.refresh,
+ });
+ }
+};
+
+const applyRelativeTransform = (element, changes) => {
+ const { x, y, rotation, angle } = changes;
+
+ if (x) {
+ changes.x = element.x + x;
+ }
+ if (y) {
+ changes.y = element.y + y;
+ }
+ if (rotation) {
+ changes.rotation = element.rotation + rotation;
+ }
+ if (angle) {
+ changes.angle = element.angle + angle;
+ }
+ return changes;
+};
+
+const createHistoryId = (history) => {
+ let historyId = null;
+ if (history) {
+ historyId = typeof history === 'string' ? history : uid();
+ }
+ return historyId;
+};
diff --git a/src/display/update/update-components.js b/src/display/update/update-components.js
deleted file mode 100644
index 7c7f0fce..00000000
--- a/src/display/update/update-components.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import { isValidationError } from 'zod-validation-error';
-import { findIndexByPriority } from '../../utils/findIndexByPriority';
-import { validate } from '../../utils/validator';
-import {
- backgroundComponent,
- updateBackgroundComponent,
-} from '../components/background';
-import { barComponent, updateBarComponent } from '../components/bar';
-import { iconComponent, updateIconComponent } from '../components/icon';
-import { textComponent, updateTextComponent } from '../components/text';
-import { componentSchema } from '../data-schema/component-schema';
-
-const componentFn = {
- background: {
- create: backgroundComponent,
- update: updateBackgroundComponent,
- },
- icon: {
- create: iconComponent,
- update: updateIconComponent,
- },
- bar: {
- create: barComponent,
- update: updateBarComponent,
- },
- text: {
- create: textComponent,
- update: updateTextComponent,
- },
-};
-
-export const updateComponents = (
- item,
- { components: componentConfig },
- options,
-) => {
- if (!componentConfig) return;
-
- const itemComponents = [...item.children];
- for (let config of componentConfig) {
- const idx = findIndexByPriority(itemComponents, config);
- let component = null;
-
- if (idx !== -1) {
- component = itemComponents[idx];
- itemComponents.splice(idx, 1);
- } else {
- config = validate(config, componentSchema);
- if (isValidationError(config)) throw config;
-
- component = createComponent(config);
- if (!component) continue;
- item.addChild(component);
- }
-
- componentFn[component.type].update(component, config, options);
- }
-};
-
-const createComponent = (config) => {
- const component = componentFn[config.type].create({ ...config });
- component.config = { ...component.config };
- return component;
-};
diff --git a/src/display/update/update-object.js b/src/display/update/update-object.js
deleted file mode 100644
index 40f3470a..00000000
--- a/src/display/update/update-object.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import { changeProperty } from '../change';
-
-const DEFAULT_EXCEPTION_KEYS = new Set(['position']);
-
-export const updateObject = (
- object,
- changes,
- pipeline,
- pipelineKeys,
- options,
-) => {
- if (!object) return;
-
- const pipelines = pipelineKeys.map((key) => pipeline[key]).filter(Boolean);
- for (const { keys, handler } of pipelines) {
- const hasMatch = keys.some((key) => key in changes);
- if (hasMatch) {
- handler(object, changes, options);
- }
- }
-
- const matchedKeys = new Set(pipelines.flatMap((item) => item.keys));
- for (const [key, value] of Object.entries(changes)) {
- if (!matchedKeys.has(key) && !DEFAULT_EXCEPTION_KEYS.has(key)) {
- changeProperty(object, key, value);
- }
- }
-};
diff --git a/src/display/update/update.js b/src/display/update/update.js
deleted file mode 100644
index c8e93f85..00000000
--- a/src/display/update/update.js
+++ /dev/null
@@ -1,76 +0,0 @@
-import { z } from 'zod';
-import { isValidationError } from 'zod-validation-error';
-import { convertArray } from '../../utils/convert';
-import { selector } from '../../utils/selector/selector';
-import { uid } from '../../utils/uuid';
-import { validate } from '../../utils/validator';
-import { updateGrid } from '../elements/grid';
-import { updateGroup } from '../elements/group';
-import { updateItem } from '../elements/item';
-import { updateRelations } from '../elements/relations';
-
-const updateSchema = z.object({
- path: z.nullable(z.string()).default(null),
- changes: z.record(z.unknown()),
- saveToHistory: z.union([z.boolean(), z.string()]).default(false),
- relativeTransform: z.boolean().default(false),
-});
-
-const elementUpdaters = {
- group: updateGroup,
- grid: updateGrid,
- item: updateItem,
- relations: updateRelations,
-};
-
-export const update = (context, opts) => {
- const config = validate(opts, updateSchema.passthrough());
- if (isValidationError(config)) throw config;
-
- const { viewport = null, ...otherContext } = context;
- const historyId = createHistoryId(config.saveToHistory);
- const elements = 'elements' in config ? convertArray(config.elements) : [];
- if (viewport && config.path) {
- elements.push(...selector(viewport, config.path));
- }
-
- for (const element of elements) {
- if (!element) continue;
- const elConfig = { ...config };
- if (elConfig.relativeTransform) {
- elConfig.changes = applyRelativeTransform(element, elConfig.changes);
- }
-
- const updater = elementUpdaters[element.type];
- if (updater) {
- updater(element, elConfig.changes, { historyId, ...otherContext });
- }
- }
-};
-
-const applyRelativeTransform = (element, changes) => {
- const newChanges = { ...changes };
- const { position, rotation, angle } = newChanges;
-
- if (position) {
- newChanges.position = {
- x: element.x + position.x,
- y: element.y + position.y,
- };
- }
- if (rotation) {
- newChanges.rotation = element.rotation + rotation;
- }
- if (angle) {
- newChanges.angle = element.angle + angle;
- }
- return newChanges;
-};
-
-const createHistoryId = (saveToHistory) => {
- let historyId = null;
- if (saveToHistory) {
- historyId = typeof saveToHistory === 'string' ? saveToHistory : uid();
- }
- return historyId;
-};
diff --git a/src/display/utils.js b/src/display/utils.js
deleted file mode 100644
index e628a369..00000000
--- a/src/display/utils.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Container } from 'pixi.js';
-import { isValidationError } from 'zod-validation-error';
-import { validate } from '../utils/validator';
-import { Margin } from './data-schema/component-schema';
-
-export const parseMargin = (margin) => {
- if (isValidationError(validate(margin, Margin))) {
- throw new Error(
- 'Invalid margin format. Expected format: "top [right] [bottom] [left]" with numeric values.',
- );
- }
-
- const values = margin.trim().split(/\s+/).map(Number);
- const [top, right = top, bottom = top, left = right] = values;
- return { top, right, bottom, left };
-};
-
-export const createContainer = ({ type, id, label, isRenderGroup = false }) => {
- const container = new Container({ isRenderGroup });
- container.eventMode = 'static';
- Object.assign(container, { type, id, label });
- container.config = { type, id, label };
- return container;
-};
diff --git a/src/display/utils.test.js b/src/display/utils.test.js
deleted file mode 100644
index 2226791c..00000000
--- a/src/display/utils.test.js
+++ /dev/null
@@ -1,87 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { parseMargin } from './utils';
-
-describe('parseMargin', () => {
- it('should handle a single value for all sides', () => {
- const input = '10';
- const output = parseMargin(input);
- expect(output).toEqual({ top: 10, right: 10, bottom: 10, left: 10 });
- });
-
- it('should handle two values (vertical | horizontal)', () => {
- const input = '10 20';
- const output = parseMargin(input);
- expect(output).toEqual({ top: 10, right: 20, bottom: 10, left: 20 });
- });
-
- it('should handle three values (top | horizontal | bottom)', () => {
- const input = '10 20 30';
- const output = parseMargin(input);
- expect(output).toEqual({ top: 10, right: 20, bottom: 30, left: 20 });
- });
-
- it('should handle four values (top | right | bottom | left)', () => {
- const input = '10 20 30 40';
- const output = parseMargin(input);
- expect(output).toEqual({ top: 10, right: 20, bottom: 30, left: 40 });
- });
-
- it('should handle multiple spaces between numbers', () => {
- const input = '10 20 30 40';
- const output = parseMargin(input);
- expect(output).toEqual({ top: 10, right: 20, bottom: 30, left: 40 });
- });
-
- it('should handle single value as a float', () => {
- const input = '10.5';
- const output = parseMargin(input);
- expect(output).toEqual({
- top: 10.5,
- right: 10.5,
- bottom: 10.5,
- left: 10.5,
- });
- });
-
- it('should handle multiple values with floats', () => {
- const input = '10.5 20.75';
- const output = parseMargin(input);
- expect(output).toEqual({
- top: 10.5,
- right: 20.75,
- bottom: 10.5,
- left: 20.75,
- });
- });
-
- it('should handle mixed integers and floats', () => {
- const input = '10.5 20 30.75 40';
- const output = parseMargin(input);
- expect(output).toEqual({ top: 10.5, right: 20, bottom: 30.75, left: 40 });
- });
-
- it('should throw an error for invalid input (non-numeric)', () => {
- const input = '10px 20';
- expect(() => parseMargin(input)).toThrow();
- });
-
- it('should throw an error for more than 4 values', () => {
- const input = '10 20 30 40 50';
- expect(() => parseMargin(input)).toThrow();
- });
-
- it('should throw an error for invalid float format', () => {
- const input = '10. 20';
- expect(() => parseMargin(input)).toThrow();
- });
-
- it('should throw an error for leading spaces', () => {
- const input = ' 10 20 30 40';
- expect(() => parseMargin(input)).toThrow();
- });
-
- it('should throw an error for trailing spaces', () => {
- const input = '10 20 30 40 ';
- expect(() => parseMargin(input)).toThrow();
- });
-});
diff --git a/src/events/drag-select.js b/src/events/drag-select.js
index 912d068a..2a021d51 100644
--- a/src/events/drag-select.js
+++ b/src/events/drag-select.js
@@ -1,11 +1,10 @@
import { isValidationError } from 'zod-validation-error';
-import { getPointerPosition } from '../utils/canvas';
import { deepMerge } from '../utils/deepmerge/deepmerge';
import { event } from '../utils/event/canvas';
import { validate } from '../utils/validator';
import { findIntersectObjects } from './find';
import { dragSelectEventSchema } from './schema';
-import { checkEvents, isMoved } from './utils';
+import { checkEvents, getPointerPosition, isMoved } from './utils';
const DRAG_SELECT_EVENT_ID = 'drag-select-down drag-select-move drag-select-up';
const DEBOUNCE_FN_INTERVAL = 25; // ms
diff --git a/src/events/focus-fit.js b/src/events/focus-fit.js
index 01fecfd5..77133be0 100644
--- a/src/events/focus-fit.js
+++ b/src/events/focus-fit.js
@@ -1,5 +1,5 @@
import { isValidationError } from 'zod-validation-error';
-import { getScaleBounds } from '../utils/canvas';
+import { calcGroupOrientedBounds } from '../utils/bounds';
import { selector } from '../utils/selector/selector';
import { validate } from '../utils/validator';
import { focusFitIdsSchema } from './schema';
@@ -18,14 +18,16 @@ const centerViewport = (viewport, ids, shouldFit = false) => {
checkValidate(ids);
const objects = getObjectsById(viewport, ids);
if (!objects.length) return null;
- const bounds = calculateBounds(viewport, objects);
+ const bounds = calcGroupOrientedBounds(objects);
+ const center = viewport.toLocal(bounds.center);
if (bounds) {
- viewport.moveCenter(
- bounds.x + bounds.width / 2,
- bounds.y + bounds.height / 2,
- );
+ viewport.moveCenter(center.x, center.y);
if (shouldFit) {
- viewport.fit(true, bounds.width, bounds.height);
+ viewport.fit(
+ true,
+ bounds.innerBounds.width / viewport.scale.x,
+ bounds.innerBounds.height / viewport.scale.y,
+ );
}
}
};
@@ -49,12 +51,3 @@ const getObjectsById = (viewport, ids) => {
}, {});
return idsArr.flatMap((i) => objs[i]).filter((obj) => obj);
};
-
-const calculateBounds = (viewport, objects) => {
- const boundsArray = objects.map((obj) => getScaleBounds(viewport, obj));
- const minX = Math.min(...boundsArray.map((b) => b.x));
- const minY = Math.min(...boundsArray.map((b) => b.y));
- const maxX = Math.max(...boundsArray.map((b) => b.x + b.width));
- const maxY = Math.max(...boundsArray.map((b) => b.y + b.height));
- return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
-};
diff --git a/src/events/single-select.js b/src/events/single-select.js
index 8871dca4..548a31a9 100644
--- a/src/events/single-select.js
+++ b/src/events/single-select.js
@@ -1,11 +1,10 @@
import { isValidationError } from 'zod-validation-error';
-import { getPointerPosition } from '../utils/canvas';
import { deepMerge } from '../utils/deepmerge/deepmerge';
import { event } from '../utils/event/canvas';
import { validate } from '../utils/validator';
import { findIntersectObject } from './find';
import { selectEventSchema } from './schema';
-import { checkEvents, isMoved } from './utils';
+import { checkEvents, getPointerPosition, isMoved } from './utils';
const SELECT_EVENT_ID = 'select-down select-up select-over';
diff --git a/src/events/utils.js b/src/events/utils.js
index 6f337e2e..fe820a1a 100644
--- a/src/events/utils.js
+++ b/src/events/utils.js
@@ -41,3 +41,9 @@ const getHighestParentByType = (obj, typeName) => {
}
return highest;
};
+
+export const getPointerPosition = (viewport) => {
+ const renderer = viewport?.app?.renderer;
+ const global = renderer?.events.pointer.global;
+ return viewport ? viewport.toWorld(global.x, global.y) : global;
+};
diff --git a/src/init.js b/src/init.js
index b5e82b9f..c1c9538f 100644
--- a/src/init.js
+++ b/src/init.js
@@ -4,6 +4,7 @@ import { Viewport } from 'pixi-viewport';
import * as PIXI from 'pixi.js';
import { firaCode } from './assets/fonts';
import { icons } from './assets/icons';
+import { Type } from './display/mixins/Type';
import { deepMerge } from './utils/deepmerge/deepmerge';
import { plugin } from './utils/event/viewport';
import { uid } from './utils/uuid';
@@ -66,9 +67,8 @@ export const initViewport = (app, opts = {}) => {
},
opts,
);
- const viewport = new Viewport(options);
+ const viewport = new (Type(Viewport))({ ...options, type: 'canvas' });
viewport.app = app;
- viewport.type = 'canvas';
viewport.events = {};
viewport.plugin = {
add: (plugins) => plugin.add(viewport, plugins),
@@ -123,7 +123,7 @@ export const initResizeObserver = (el, app, viewport) => {
export const initCanvas = (el, app) => {
const div = document.createElement('div');
- div.classList.add('w-full', 'h-full', 'overflow-hidden');
+ div.style = 'width:100%;height:100%;overflow:hidden;';
div.appendChild(app.canvas);
el.appendChild(div);
};
diff --git a/src/patch-map.ts b/src/patch-map.ts
index 74c3c9e6..a483a7aa 100644
--- a/src/patch-map.ts
+++ b/src/patch-map.ts
@@ -1,4 +1,3 @@
export { Patchmap } from './patchmap';
export { UndoRedoManager } from './command/undo-redo-manager';
export { Command } from './command/commands/base';
-export * as change from './display/change';
diff --git a/src/patchmap.js b/src/patchmap.js
index abfa0e04..1e6f6d87 100644
--- a/src/patchmap.js
+++ b/src/patchmap.js
@@ -3,7 +3,7 @@ import { Application, Graphics } from 'pixi.js';
import { isValidationError } from 'zod-validation-error';
import { UndoRedoManager } from './command/undo-redo-manager';
import { draw } from './display/draw';
-import { update } from './display/update/update';
+import { update } from './display/update';
import { dragSelect } from './events/drag-select';
import { fit, focus } from './events/focus-fit';
import { select } from './events/single-select';
@@ -19,6 +19,8 @@ import { event } from './utils/event/canvas';
import { selector } from './utils/selector/selector';
import { themeStore } from './utils/theme';
import { validateMapData } from './utils/validator';
+import './display/elements/registry';
+import './display/components/registry';
class Patchmap {
constructor() {
@@ -101,6 +103,8 @@ class Patchmap {
}
destroy() {
+ if (!this.isInit) return;
+
this.undoRedoManager.destroy();
this.animationContext.revert();
event.removeAllEvent(this.viewport);
@@ -122,37 +126,30 @@ class Patchmap {
}
draw(data) {
- const zData = preprocessData(JSON.parse(JSON.stringify(data)));
- if (!zData) return;
+ const processedData = processData(JSON.parse(JSON.stringify(data)));
+ if (!processedData) return;
- const validatedData = validateMapData(zData);
+ const validatedData = validateMapData(processedData);
if (isValidationError(validatedData)) throw validatedData;
- this.app.stop();
- this.undoRedoManager.clear();
- this.animationContext.revert();
- event.removeAllEvent(this.viewport);
- this.initSelectState();
const context = {
viewport: this.viewport,
undoRedoManager: this.undoRedoManager,
theme: this.theme,
animationContext: this.animationContext,
};
+
+ this.app.stop();
+ this.undoRedoManager.clear();
+ this.animationContext.revert();
+ event.removeAllEvent(this.viewport);
+ this.initSelectState();
draw(context, validatedData);
this.app.start();
return validatedData;
- function preprocessData(data) {
- if (isLegacyData(data)) {
- return convertLegacyData(data);
- }
-
- if (!Array.isArray(data)) {
- console.error('Invalid data format. Expected an array.');
- return null;
- }
- return data;
+ function processData(data) {
+ return isLegacyData(data) ? convertLegacyData(data) : data;
}
function isLegacyData(data) {
@@ -163,13 +160,7 @@ class Patchmap {
}
update(opts) {
- const context = {
- viewport: this.viewport,
- undoRedoManager: this.undoRedoManager,
- theme: this.theme,
- animationContext: this.animationContext,
- };
- update(context, opts);
+ update(this.viewport, opts);
}
focus(ids) {
diff --git a/src/tests/render/components/Background.test.js b/src/tests/render/components/Background.test.js
new file mode 100644
index 00000000..6278a173
--- /dev/null
+++ b/src/tests/render/components/Background.test.js
@@ -0,0 +1,162 @@
+import { Sprite } from 'pixi.js';
+import { describe, expect, it, vi } from 'vitest';
+import { Base } from '../../../display/mixins/Base';
+import { Sourceable } from '../../../display/mixins/Sourceable';
+import { mixins } from '../../../display/mixins/utils';
+import { setupPatchmapTests } from '../patchmap.setup';
+
+describe('Background Component In Item', () => {
+ const { getPatchmap } = setupPatchmapTests();
+
+ const baseItemData = {
+ type: 'item',
+ id: 'item-with-background',
+ size: { width: 100, height: 100 },
+ components: [
+ {
+ type: 'background',
+ id: 'background-1',
+ source: {
+ type: 'rect',
+ fill: 'white',
+ borderColor: 'black',
+ borderWidth: 2,
+ radius: 4,
+ },
+ tint: 'gray.default',
+ },
+ ],
+ attrs: { x: 50, y: 50 },
+ };
+
+ it('should render the background component with initial properties', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const background = patchmap.selector('$..[?(@.id=="background-1")]')[0];
+ expect(background).toBeDefined();
+ expect(background.props.source.fill).toBe('white');
+ expect(background.props.tint).toBe('gray.default');
+ expect(background.tint).toBe(0xd9d9d9);
+ });
+
+ it('should update a single property: tint', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ patchmap.update({
+ path: '$..[?(@.id=="background-1")]',
+ changes: {
+ tint: 'primary.accent', // #EF4444
+ },
+ });
+
+ const background = patchmap.selector('$..[?(@.id=="background-1")]')[0];
+ expect(background.props.tint).toBe('primary.accent');
+ expect(background.tint).toBe(0xef4444);
+ expect(background.props.source.fill).toBe('white');
+ });
+
+ it('should update a single property: source', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const newSource = { type: 'rect', fill: 'black', radius: 10 };
+ patchmap.update({
+ path: '$..[?(@.id=="background-1")]',
+ changes: {
+ source: newSource,
+ },
+ });
+
+ const background = patchmap.selector('$..[?(@.id=="background-1")]')[0];
+ expect(background.props.source).toEqual({
+ ...baseItemData.components[0].source,
+ ...newSource,
+ });
+ expect(background.tint).toBe(0xd9d9d9);
+ });
+
+ it('should update multiple properties simultaneously', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const newSource = { type: 'rect', fill: 'blue' };
+ patchmap.update({
+ path: '$..[?(@.id=="background-1")]',
+ changes: {
+ tint: 'primary.dark', // #083967
+ source: newSource,
+ },
+ });
+
+ const background = patchmap.selector('$..[?(@.id=="background-1")]')[0];
+ expect(background.props.tint).toBe('primary.dark');
+ expect(background.tint).toBe(0x083967);
+ expect(background.props.source).toEqual({
+ ...baseItemData.components[0].source,
+ ...newSource,
+ });
+ });
+
+ it('should replace the entire component array when arrayMerge is "replace"', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+
+ const newBackground = {
+ type: 'background',
+ id: 'background-new',
+ source: { type: 'rect', fill: 'green' },
+ };
+ patchmap.update({
+ path: '$..[?(@.id=="item-with-background")]',
+ changes: {
+ components: [newBackground],
+ },
+ arrayMerge: 'replace',
+ });
+
+ const item = patchmap.selector('$..[?(@.id=="item-with-background")]')[0];
+ expect(item.children.length).toBe(1);
+
+ const background = item.children[0];
+ expect(background.id).toBe('background-new');
+ expect(background.props.source.fill).toBe('green');
+
+ const oldText = patchmap.selector('$..[?(@.id=="text-1")]')[0];
+ expect(oldText).toBeUndefined();
+ });
+
+ it('should re-render the background when refresh is true, even with same data', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([baseItemData]);
+ const background = patchmap.selector('$..[?(@.id=="background-1")]')[0];
+ const handlerSet = background.constructor._handlerMap.get('source');
+ const handlerRegistry = background.constructor._handlerRegistry;
+
+ const spy = vi.spyOn(
+ mixins(Sprite, Base, Sourceable).prototype,
+ '_applySource',
+ );
+ handlerSet.forEach((handler) => {
+ if (handler.name === '_applySource') {
+ const registry = handlerRegistry.get(handler);
+ handlerRegistry.delete(handler);
+ handlerRegistry.set(spy, registry);
+ handlerSet.delete(handler);
+ }
+ });
+ handlerSet.add(spy);
+
+ patchmap.update({
+ path: '$..[?(@.id=="background-1")]',
+ changes: {
+ source: baseItemData.components[0].source,
+ },
+ refresh: true,
+ });
+
+ expect(spy).toHaveBeenCalled();
+ spy.mockRestore();
+ });
+});
diff --git a/src/tests/render/components/Icon.test.js b/src/tests/render/components/Icon.test.js
new file mode 100644
index 00000000..2035689e
--- /dev/null
+++ b/src/tests/render/components/Icon.test.js
@@ -0,0 +1,378 @@
+import { describe, expect, it, vi } from 'vitest';
+import { setupPatchmapTests } from '../patchmap.setup';
+
+describe('Icon Component Tests', () => {
+ const { getPatchmap } = setupPatchmapTests();
+
+ const itemWithIcon = {
+ type: 'item',
+ id: 'item-with-icon',
+ size: { width: 100, height: 100 },
+ components: [
+ {
+ type: 'background',
+ source: { type: 'rect', borderWidth: 1, borderColor: 'red' },
+ },
+ {
+ type: 'icon',
+ id: 'icon-1',
+ source: 'device',
+ size: 50,
+ tint: 'primary.default',
+ },
+ ],
+ };
+
+ it('should toggle the visibility of the icon component using the "show" property', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([itemWithIcon]);
+
+ let icon = patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ expect(icon).toBeDefined();
+ expect(icon.props.show).toBe(true);
+ expect(icon.renderable).toBe(true);
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { show: false },
+ });
+ icon = patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ expect(icon.props.show).toBe(false);
+ expect(icon.renderable).toBe(false);
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { show: true },
+ });
+ icon = patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ expect(icon.props.show).toBe(true);
+ expect(icon.renderable).toBe(true);
+ });
+
+ it('should change the icon source when updated', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([itemWithIcon]);
+
+ const icon = patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ const initialTexture = icon.texture;
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { source: 'wifi' },
+ });
+
+ const updatedIcon = patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ const newTexture = updatedIcon.texture;
+ expect(updatedIcon.props.source).toBe('wifi');
+ expect(newTexture).toBeDefined();
+ expect(newTexture).not.toBe(initialTexture);
+ });
+
+ it('should handle an unregistered string source by logging a warning', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([itemWithIcon]);
+
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const unregisteredSource = 'unregistered-icon-asset';
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { source: unregisteredSource },
+ });
+
+ const icon = patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'PixiJS Warning: ',
+ '[Assets] Asset id unregistered-icon-asset was not found in the Cache',
+ );
+ expect(icon.texture).toBeDefined();
+ expect(icon.props.source).toBe(unregisteredSource);
+ consoleSpy.mockRestore();
+ });
+
+ describe('size', () => {
+ it('should correctly resize the icon when a single number is provided for size', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([itemWithIcon]);
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { size: 75 },
+ });
+
+ const icon = patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ expect(icon.props.size).toEqual({
+ width: { value: 75, unit: 'px' },
+ height: { value: 75, unit: 'px' },
+ });
+ expect(icon.width).toBe(75);
+ expect(icon.height).toBe(75);
+ });
+
+ it('should correctly resize the icon when a percentage string is provided for size', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([itemWithIcon]);
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { size: '50%' },
+ });
+
+ const icon = patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ const parent = patchmap.selector('$..[?(@.id=="item-with-icon")]')[0];
+
+ expect(icon.props.size).toEqual({
+ width: { value: 50, unit: '%' },
+ height: { value: 50, unit: '%' },
+ });
+ expect(icon.width).toBe(parent.props.size.width * 0.5);
+ expect(icon.height).toBe(parent.props.size.height * 0.5);
+ });
+
+ it('should correctly resize the icon when a size object with mixed units is provided', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([itemWithIcon]);
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { size: { width: 60, height: '30%' } },
+ });
+
+ const icon = patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ const parent = patchmap.selector('$..[?(@.id=="item-with-icon")]')[0];
+
+ expect(icon.props.size).toEqual({
+ width: { value: 60, unit: 'px' },
+ height: { value: 30, unit: '%' },
+ });
+ expect(icon.width).toBe(60);
+ expect(icon.height).toBe(parent.props.size.height * 0.3);
+ });
+
+ it('should throw an error if a partial size object is provided', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([itemWithIcon]);
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { size: { width: 80 } },
+ });
+
+ const icon = patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ expect(icon.props.size).toEqual({
+ width: { value: 80, unit: 'px' },
+ height: { value: 50, unit: 'px' },
+ });
+ expect(icon.width).toBe(80);
+ expect(icon.height).toBe(50);
+ });
+ });
+
+ describe('placement', () => {
+ it.each([
+ { placement: 'center', expected: { x: 25, y: 25 } },
+ { placement: 'top', expected: { x: 25, y: 0 } },
+ { placement: 'bottom', expected: { x: 25, y: 50 } },
+ { placement: 'left', expected: { x: 0, y: 25 } },
+ { placement: 'right', expected: { x: 50, y: 25 } },
+ { placement: 'left-top', expected: { x: 0, y: 0 } },
+ { placement: 'right-top', expected: { x: 50, y: 0 } },
+ { placement: 'left-bottom', expected: { x: 0, y: 50 } },
+ { placement: 'right-bottom', expected: { x: 50, y: 50 } },
+ ])(
+ 'should correctly position the icon for placement: $placement',
+ ({ placement, expected }) => {
+ const patchmap = getPatchmap();
+ patchmap.draw([itemWithIcon]);
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { placement: placement },
+ });
+
+ const icon = patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ expect(icon.x).toBe(expected.x);
+ expect(icon.y).toBe(expected.y);
+ },
+ );
+ });
+
+ describe('margin', () => {
+ it.each([
+ {
+ case: 'a single number',
+ margin: 10,
+ placement: 'left-top',
+ expected: { x: 10, y: 10 },
+ },
+ {
+ case: 'an object with x and y',
+ margin: { x: 5, y: 15 },
+ placement: 'right-bottom',
+ expected: { x: 45, y: 35 },
+ },
+ {
+ case: 'a full object',
+ margin: { top: 5, right: 10, bottom: 15, left: 20 },
+ placement: 'left-top',
+ expected: { x: 20, y: 5 },
+ },
+ {
+ case: 'a partial object',
+ margin: { top: 20, left: 8 },
+ placement: 'left-top',
+ expected: { x: 8, y: 20 },
+ },
+ {
+ case: 'a negative number',
+ margin: -5,
+ placement: 'left-top',
+ expected: { x: -5, y: -5 },
+ },
+ {
+ case: 'an object with negative values',
+ margin: { top: -10, right: 5 },
+ placement: 'right-top',
+ expected: { x: 45, y: -10 },
+ },
+ ])(
+ 'should correctly apply margin with placement: $case',
+ ({ margin, placement, expected }) => {
+ const patchmap = getPatchmap();
+ patchmap.draw([itemWithIcon]);
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { placement, margin },
+ });
+
+ const icon = patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ expect(icon.x).toBe(expected.x);
+ expect(icon.y).toBe(expected.y);
+ },
+ );
+ });
+
+ describe('size, placement, margin', () => {
+ it.each([
+ {
+ case: 'center placement with no margin and px size',
+ changes: { size: 60, placement: 'center', margin: 0 },
+ expected: { x: 20, y: 20, width: 60, height: 60 },
+ },
+ {
+ case: 'top-right placement with numeric margin and percentage size',
+ changes: { size: '40%', placement: 'right-top', margin: 10 },
+ expected: { x: 50, y: 10, width: 40, height: 40 },
+ },
+ {
+ case: 'bottom-left placement with x/y margin and mixed size units',
+ changes: {
+ size: { width: 50, height: '20%' },
+ placement: 'left-bottom',
+ margin: { x: 5, y: 15 },
+ },
+ expected: { x: 5, y: 65, width: 50, height: 20 },
+ },
+ {
+ case: 'center placement with full margin object',
+ changes: {
+ size: 30,
+ placement: 'center',
+ margin: { top: 5, right: 10, bottom: 15, left: 20 },
+ },
+ expected: { x: 40, y: 30, width: 30, height: 30 },
+ },
+ {
+ case: 'right placement with negative left/right margin',
+ changes: { size: 50, placement: 'right', margin: { x: -10, y: 0 } },
+ expected: { x: 60, y: 25, width: 50, height: 50 },
+ },
+ {
+ case: 'bottom placement with negative top/bottom margin',
+ changes: { size: '30%', placement: 'bottom', margin: { y: -5 } },
+ expected: { x: 35, y: 75, width: 30, height: 30 },
+ },
+ ])(
+ 'should correctly calculate position and size with combined properties for: $case',
+ ({ changes, expected }) => {
+ const patchmap = getPatchmap();
+ patchmap.draw([itemWithIcon]);
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: changes,
+ });
+
+ const icon = patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ expect(icon.width).toBeCloseTo(expected.width);
+ expect(icon.height).toBeCloseTo(expected.height);
+ expect(icon.x).toBeCloseTo(expected.x);
+ expect(icon.y).toBeCloseTo(expected.y);
+ },
+ );
+ });
+
+ describe('tint', () => {
+ const itemWithIcon = {
+ type: 'item',
+ id: 'item-1',
+ size: 100,
+ components: [
+ {
+ type: 'icon',
+ id: 'icon-1',
+ source: 'object',
+ size: 50,
+ tint: 'white',
+ },
+ ],
+ };
+
+ it('should apply tint from a theme color string', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([itemWithIcon]);
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { tint: 'primary.default' },
+ });
+
+ const icon = patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ expect(icon.tint).toBe(0x0c73bf);
+ });
+
+ it('should apply tint from a direct hex color string', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([itemWithIcon]);
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { tint: '#FF0000' },
+ });
+
+ const icon = patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ expect(icon.tint).toBe(0xff0000);
+ });
+
+ it('should apply tint from a direct hex color number', () => {
+ const patchmap = getPatchmap();
+ patchmap.draw([itemWithIcon]);
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { tint: 0x00ff00 },
+ });
+
+ let icon = patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ expect(icon.tint).toBe(0x00ff00);
+
+ patchmap.update({
+ path: '$..[?(@.id=="icon-1")]',
+ changes: { tint: 0x0000ff },
+ });
+
+ icon = patchmap.selector('$..[?(@.id=="icon-1")]')[0];
+ expect(icon.tint).toBe(0x0000ff);
+ });
+ });
+});
diff --git a/src/tests/render/patchmap.setup.js b/src/tests/render/patchmap.setup.js
new file mode 100644
index 00000000..78ba475c
--- /dev/null
+++ b/src/tests/render/patchmap.setup.js
@@ -0,0 +1,30 @@
+import { afterEach, beforeEach } from 'vitest';
+import { Patchmap } from '../../patchmap';
+
+export const setupPatchmapTests = () => {
+ let patchmap;
+ let element;
+
+ beforeEach(async () => {
+ document.body.innerHTML = '';
+ element = document.createElement('div');
+ element.style.height = '100svh';
+ document.body.appendChild(element);
+
+ patchmap = new Patchmap();
+ await patchmap.init(element);
+ });
+
+ afterEach(() => {
+ if (patchmap) {
+ patchmap.destroy();
+ }
+ if (element?.parentElement) {
+ document.body.removeChild(element);
+ }
+ });
+
+ return {
+ getPatchmap: () => patchmap,
+ };
+};
diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js
new file mode 100644
index 00000000..7b1c4a01
--- /dev/null
+++ b/src/tests/render/patchmap.test.js
@@ -0,0 +1,80 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { Patchmap } from '../../patchmap';
+
+const sampleData = [
+ {
+ type: 'group',
+ id: 'group-1',
+ label: 'group-label-1',
+ children: [
+ {
+ type: 'grid',
+ id: 'grid-1',
+ label: 'grid-label-1',
+ cells: [[1, 0, 1]],
+ gap: 4,
+ item: {
+ size: { width: 40, height: 80 },
+ components: [
+ {
+ type: 'background',
+ source: { type: 'rect', fill: 'white' },
+ tint: 'red',
+ },
+ ],
+ },
+ },
+ {
+ type: 'item',
+ id: 'item-1',
+ label: 'item-label-1',
+ size: 50,
+ components: [],
+ },
+ ],
+ attrs: { x: 100, y: 100 },
+ },
+];
+
+describe('patchmap test', () => {
+ let patchmap;
+ let element;
+
+ beforeEach(async () => {
+ element = document.createElement('div');
+ element.style.height = '100svh';
+ document.body.appendChild(element);
+
+ patchmap = new Patchmap();
+ await patchmap.init(element);
+ });
+
+ afterEach(() => {
+ // patchmap.destroy();
+ // document.body.removeChild(element);
+ });
+
+ it('draw', () => {
+ patchmap.draw(sampleData);
+ expect(patchmap.viewport.children.length).toBe(1);
+
+ const group = patchmap.selector('$..[?(@.id=="group-1")]')[0];
+ expect(group).toBeDefined();
+ expect(group.id).toBe('group-1');
+ expect(group.type).toBe('group');
+ expect(group.x).toBe(100);
+ expect(group.y).toBe(100);
+
+ const grid = patchmap.selector('$..[?(@.id=="grid-1")]')[0];
+ expect(grid).toBeDefined();
+ expect(grid.id).toBe('grid-1');
+ expect(grid.type).toBe('grid');
+
+ const item = patchmap.selector('$..[?(@.id=="item-1")]')[0];
+ expect(item).toBeDefined();
+ expect(item.id).toBe('item-1');
+
+ const gridItems = grid.children;
+ expect(gridItems.length).toBe(2);
+ });
+});
diff --git a/src/utils/bounds.js b/src/utils/bounds.js
new file mode 100644
index 00000000..d71091b4
--- /dev/null
+++ b/src/utils/bounds.js
@@ -0,0 +1,53 @@
+import { OrientedBounds } from '@pixi-essentials/bounds';
+import { Matrix, Transform } from 'pixi.js';
+import {
+ decomposeTransform,
+ getBoundsFromPoints,
+ getCentroid,
+ getObjectWorldCorners,
+} from './transform';
+
+const tempBounds = new OrientedBounds();
+const tempTransform = new Transform();
+const tempMatrix = new Matrix();
+
+export const calcOrientedBounds = (object, bounds = tempBounds) => {
+ decomposeTransform(tempTransform, object.worldTransform);
+ const worldRotation = tempTransform.rotation;
+ const worldCorners = getObjectWorldCorners(object);
+ const centroid = getCentroid(worldCorners);
+
+ const unrotateMatrix = tempMatrix;
+ unrotateMatrix
+ .identity()
+ .translate(-centroid.x, -centroid.y)
+ .rotate(-worldRotation)
+ .translate(centroid.x, centroid.y);
+ unrotateMatrix.apply(worldCorners[0], worldCorners[0]);
+ unrotateMatrix.apply(worldCorners[1], worldCorners[1]);
+ unrotateMatrix.apply(worldCorners[2], worldCorners[2]);
+ unrotateMatrix.apply(worldCorners[3], worldCorners[3]);
+
+ const innerBounds = getBoundsFromPoints(worldCorners);
+ const resultBounds = bounds || new OrientedBounds();
+ resultBounds.rotation = worldRotation;
+ resultBounds.innerBounds.copyFrom(innerBounds);
+ resultBounds.update();
+ return resultBounds;
+};
+
+export const calcGroupOrientedBounds = (group, bounds = tempBounds) => {
+ if (!group || group.length === 0) {
+ return;
+ }
+
+ const allWorldCorners = group.flatMap((element) => {
+ return getObjectWorldCorners(element);
+ });
+ const groupInnerBounds = getBoundsFromPoints(allWorldCorners);
+ const resultBounds = bounds || new OrientedBounds();
+ resultBounds.rotation = 0;
+ resultBounds.innerBounds.copyFrom(groupInnerBounds);
+ resultBounds.update();
+ return resultBounds;
+};
diff --git a/src/utils/canvas.js b/src/utils/canvas.js
deleted file mode 100644
index bb1a921c..00000000
--- a/src/utils/canvas.js
+++ /dev/null
@@ -1,15 +0,0 @@
-export const getScaleBounds = (viewport, object) => {
- const bounds = object.getBounds();
- return {
- x: (bounds.x - viewport.position.x) / viewport.scale.x,
- y: (bounds.y - viewport.position.y) / viewport.scale.y,
- width: bounds.width / viewport.scale.x,
- height: bounds.height / viewport.scale.y,
- };
-};
-
-export const getPointerPosition = (viewport) => {
- const renderer = viewport?.app?.renderer;
- const global = renderer?.events.pointer.global;
- return viewport ? viewport.toWorld(global.x, global.y) : global;
-};
diff --git a/src/utils/convert.js b/src/utils/convert.js
index 50a4366a..dfad52dd 100644
--- a/src/utils/convert.js
+++ b/src/utils/convert.js
@@ -15,57 +15,58 @@ export const convertLegacyData = (data) => {
type: 'group',
id: uid(),
label: key === 'grids' ? 'panelGroups' : key,
- items: [],
+ children: [],
};
if (key === 'grids') {
for (const value of values) {
const { transform, ...props } = value.properties;
- objs[key].items.push({
+ objs[key].children.push({
type: 'grid',
id: value.id,
label: value.name,
cells: value.children.map((row) =>
row.map((child) => (child === '0' ? 0 : 1)),
),
- position: { x: transform.x, y: transform.y },
- angle: transform.rotation,
- itemSize: {
- width: props.spec.width * 40,
- height: props.spec.height * 40,
- },
- components: [
- {
- type: 'background',
- id: 'default',
- texture: {
- type: 'rect',
- fill: 'white',
- borderWidth: 2,
- borderColor: 'primary.dark',
- radius: 6,
- },
+ gap: 4,
+ item: {
+ size: {
+ width: props.spec.width * 40,
+ height: props.spec.height * 40,
},
- {
- type: 'bar',
- id: 'default',
- texture: {
- type: 'rect',
- fill: 'white',
- radius: 3,
+ components: [
+ {
+ type: 'background',
+ source: {
+ type: 'rect',
+ fill: 'white',
+ borderWidth: 2,
+ borderColor: 'primary.dark',
+ radius: 6,
+ },
},
- tint: 'primary.default',
- show: false,
- margin: '3',
- },
- ],
- metadata: props,
+ {
+ type: 'bar',
+ show: false,
+ size: 'calc(100% - 6px)',
+ source: { type: 'rect', radius: 3, fill: 'white' },
+ tint: 'primary.default',
+ margin: { bottom: 3 },
+ },
+ ],
+ },
+ attrs: {
+ x: transform.x,
+ y: transform.y,
+ angle: transform.rotation,
+ metadata: props,
+ },
});
}
} else if (key === 'strings') {
objs[key].show = false;
for (const value of values) {
- objs[key].items.push({
+ objs[key].children.push({
type: 'relations',
id: value.id,
label: value.name,
@@ -83,47 +84,51 @@ export const convertLegacyData = (data) => {
},
]
: [],
- strokeStyle: {
+ style: {
width: 4,
color: value.properties.color.dark,
cap: 'round',
join: 'round',
},
- metadata: value.properties,
+ attrs: {
+ metadata: value.properties,
+ },
});
}
} else {
- objs[key].zIndex = 10;
+ objs[key].attrs = {};
+ objs[key].attrs.zIndex = 10;
for (const value of values) {
const { transform, ...props } = value.properties;
- objs[key].items.push({
+ objs[key].children.push({
type: 'item',
id: value.id,
label: value.name,
- position: { x: transform.x, y: transform.y },
- size: { width: 24, height: 24 },
+ size: 40,
components: [
{
type: 'background',
- id: 'default',
- texture: {
+ source: {
type: 'rect',
fill: 'white',
borderWidth: 2,
borderColor: 'primary.default',
- radius: 4,
+ radius: 6,
},
},
{
type: 'icon',
- id: 'default',
- asset: key === 'combines' ? 'combiner' : key.slice(0, -1),
- size: 16,
+ source: key === 'combines' ? 'combiner' : key.slice(0, -1),
+ size: 24,
tint: 'primary.default',
placement: 'center',
},
],
- metadata: props,
+ attrs: {
+ x: transform.x,
+ y: transform.y,
+ metadata: props,
+ },
});
}
}
diff --git a/src/utils/deepmerge/deepmerge.js b/src/utils/deepmerge/deepmerge.js
index 5019c40f..908304b6 100644
--- a/src/utils/deepmerge/deepmerge.js
+++ b/src/utils/deepmerge/deepmerge.js
@@ -56,7 +56,12 @@ const _deepMerge = (target, source, options, visited) => {
};
const mergeArray = (target, source, options, visited) => {
- const { mergeBy } = options;
+ const { mergeBy, arrayMerge = null } = options;
+
+ if (arrayMerge === 'replace') {
+ return source;
+ }
+
const merged = [...target];
const used = new Set();
diff --git a/src/utils/deepmerge/deepmerge.test.js b/src/utils/deepmerge/deepmerge.test.js
index 8056b13a..0ce099ba 100644
--- a/src/utils/deepmerge/deepmerge.test.js
+++ b/src/utils/deepmerge/deepmerge.test.js
@@ -341,3 +341,31 @@ describe('deepMerge – additional edge‑case coverage', () => {
expect(result).toEqual([a, b]);
});
});
+
+describe('deepMerge – arrayMerge option', () => {
+ test.each([
+ {
+ name: 'should replace array when arrayMerge is "replace"',
+ left: { arr: [1, 2, 3] },
+ right: { arr: [4, 5] },
+ options: { arrayMerge: 'replace' },
+ expected: { arr: [4, 5] },
+ },
+ {
+ name: 'should merge arrays by default (no option)',
+ left: { arr: [1, 2, 3] },
+ right: { arr: [4, 5] },
+ options: {},
+ expected: { arr: [4, 5, 3] },
+ },
+ {
+ name: 'should merge nested arrays when arrayMerge is "replace" at top level',
+ left: { nested: { arr: ['a', 'b'] } },
+ right: { nested: { arr: ['c'] } },
+ options: { arrayMerge: 'replace' },
+ expected: { nested: { arr: ['c'] } },
+ },
+ ])('$name', ({ left, right, options, expected }) => {
+ expect(deepMerge(left, right, options)).toEqual(expected);
+ });
+});
diff --git a/src/utils/diff/diff-json.test.js b/src/utils/diff/diff-json.test.js
index b837ce54..e692e479 100644
--- a/src/utils/diff/diff-json.test.js
+++ b/src/utils/diff/diff-json.test.js
@@ -9,6 +9,12 @@ describe('diffJson function tests', () => {
obj2: { a: 1, b: 2 },
expected: {},
},
+ {
+ name: 'obj2 is null',
+ obj1: { a: 1, b: 2 },
+ obj2: null,
+ expected: { a: 1, b: 2 },
+ },
{
name: 'Key only in obj2',
obj1: { a: 1 },
diff --git a/src/utils/get.js b/src/utils/get.js
index 0e2513ab..29c403d9 100644
--- a/src/utils/get.js
+++ b/src/utils/get.js
@@ -1,19 +1,17 @@
-export const getNestedValue = (object, path = null) => {
- if (!path) return null;
+export const getNestedValue = (object, path) => {
+ if (typeof path !== 'string' || !path) {
+ return null;
+ }
+
return path
.split('.')
.reduce((acc, key) => (acc && acc[key] != null ? acc[key] : null), object);
};
export const getColor = (theme, color) => {
- return (
- (typeof color === 'string' && color.startsWith('#')
- ? color
- : getNestedValue(theme, color)) ?? '#000'
- );
-};
-
-export const getViewport = (object) => {
- if (!object) return null;
- return object.viewport ?? getViewport(object.parent);
+ if (typeof color !== 'string') {
+ return color;
+ }
+ const themeColor = getNestedValue(theme, color);
+ return themeColor ?? color;
};
diff --git a/src/utils/intersects/intersect-point.js b/src/utils/intersects/intersect-point.js
index 329c7d22..cc9ecda9 100644
--- a/src/utils/intersects/intersect-point.js
+++ b/src/utils/intersects/intersect-point.js
@@ -1,12 +1,11 @@
import { Polygon } from 'pixi.js';
-import { getViewport } from '../get';
import { getPoints } from './get-points';
export const intersectPoint = (obj, point) => {
- const viewport = getViewport(obj);
+ const viewport = obj?.context?.viewport;
if (!viewport) return false;
- if (obj.context && 'containsPoint' in obj) {
+ if ('containsPoint' in obj) {
return obj.containsPoint(point);
}
diff --git a/src/utils/intersects/intersect.js b/src/utils/intersects/intersect.js
index 594963b0..7c16e8f6 100644
--- a/src/utils/intersects/intersect.js
+++ b/src/utils/intersects/intersect.js
@@ -1,9 +1,8 @@
-import { getViewport } from '../get';
import { getPoints } from './get-points';
import { sat } from './sat';
export const intersect = (obj1, obj2) => {
- const viewport = getViewport(obj1) ?? getViewport(obj2);
+ const viewport = obj1?.context?.viewport ?? obj2?.context?.viewport;
if (!viewport) return false;
const points1 = getPoints(viewport, obj1);
diff --git a/src/utils/transform.js b/src/utils/transform.js
new file mode 100644
index 00000000..804dd5ad
--- /dev/null
+++ b/src/utils/transform.js
@@ -0,0 +1,109 @@
+import { Point, Rectangle } from 'pixi.js';
+
+// A temporary array of points to be reused across calculations, avoiding frequent object allocation.
+const tempCorners = [new Point(), new Point(), new Point(), new Point()];
+
+/**
+ * Calculates the four corners of a DisplayObject in world space.
+ *
+ * @param {PIXI.DisplayObject} displayObject - The DisplayObject to measure.
+ * @returns {Array} An array of 4 new Point instances for the world-space corners.
+ */
+export const getObjectWorldCorners = (displayObject) => {
+ const corners = tempCorners;
+ const localBounds = displayObject.getLocalBounds();
+ const worldTransform = displayObject.worldTransform;
+
+ // Set the four corners based on the object's original (local) bounds.
+ corners[0].set(localBounds.x, localBounds.y);
+ corners[1].set(localBounds.x + localBounds.width, localBounds.y);
+ corners[2].set(
+ localBounds.x + localBounds.width,
+ localBounds.y + localBounds.height,
+ );
+ corners[3].set(localBounds.x, localBounds.y + localBounds.height);
+
+ // Apply the final world transformation to each corner to get its on-screen position.
+ worldTransform.apply(corners[0], corners[0]);
+ worldTransform.apply(corners[1], corners[1]);
+ worldTransform.apply(corners[2], corners[2]);
+ worldTransform.apply(corners[3], corners[3]);
+
+ // Return clones to prevent mutation of the globally reused `tempCorners` array.
+ return corners.map((point) => point.clone());
+};
+
+/**
+ * Calculates the geometric center (centroid) of an array of points.
+ *
+ * @param {Array} points - An array of points to calculate the centroid from.
+ * @returns {{x: number, y: number}} A new Point object representing the centroid.
+ */
+export const getCentroid = (points) => {
+ const cx = (points[0].x + points[1].x + points[2].x + points[3].x) / 4;
+ const cy = (points[0].y + points[1].y + points[2].y + points[3].y) / 4;
+ return { x: cx, y: cy };
+};
+
+/**
+ * Calculates the smallest axis-aligned rectangle that encloses a set of points.
+ *
+ * @param {Array} points - The array of points to enclose.
+ * @returns {PIXI.Rectangle} A new Rectangle representing the bounding box.
+ */
+export const getBoundsFromPoints = (points) => {
+ let minX = Number.POSITIVE_INFINITY;
+ let minY = Number.POSITIVE_INFINITY;
+ let maxX = Number.NEGATIVE_INFINITY;
+ let maxY = Number.NEGATIVE_INFINITY;
+
+ for (let i = 0; i < points.length; i++) {
+ const point = points[i];
+ minX = Math.min(minX, point.x);
+ minY = Math.min(minY, point.y);
+ maxX = Math.max(maxX, point.x);
+ maxY = Math.max(maxY, point.y);
+ }
+
+ // Handle the edge case of an empty input array.
+ if (minX === Number.POSITIVE_INFINITY) {
+ return new Rectangle(0, 0, 0, 0);
+ }
+ return new Rectangle(minX, minY, maxX - minX, maxY - minY);
+};
+
+/**
+ * Decomposes a PIXI.Matrix into its constituent properties (scale, skew, rotation, position)
+ * and applies them to a PIXI.Transform object.
+ *
+ * @param {PIXI.Transform} transform - The Transform object to store the decomposed results into.
+ * @param {PIXI.Matrix} matrix - The Matrix object to decompose.
+ * @returns {PIXI.Transform} The resulting Transform object with the applied properties.
+ */
+export const decomposeTransform = (transform, matrix) => {
+ const a = matrix.a;
+ const b = matrix.b;
+ const c = matrix.c;
+ const d = matrix.d;
+
+ transform.position.set(matrix.tx, matrix.ty);
+
+ const skewX = -Math.atan2(-c, d);
+ const skewY = Math.atan2(b, a);
+
+ const delta = Math.abs(skewX + skewY);
+
+ // This check differentiates between a pure rotation and a transformation with skew.
+ // The epsilon (0.00001) is used to handle floating-point inaccuracies.
+ if (delta < 0.00001 || Math.abs(Math.PI - delta) < 0.00001) {
+ transform.rotation = skewY;
+ transform.skew.set(0, 0);
+ } else {
+ transform.rotation = 0;
+ transform.skew.set(skewX, skewY);
+ }
+ transform.scale.x = Math.sqrt(a * a + b * b);
+ transform.scale.y = Math.sqrt(c * c + d * d);
+
+ return transform;
+};
diff --git a/src/utils/validator.js b/src/utils/validator.js
index 6f45e71a..efa7ac9b 100644
--- a/src/utils/validator.js
+++ b/src/utils/validator.js
@@ -1,5 +1,5 @@
import { fromError } from 'zod-validation-error';
-import { mapDataSchema } from '../display/data-schema/data-schema';
+import { mapDataSchema } from '../display/data-schema/element-schema';
export const validate = (data, schema) => {
try {
diff --git a/vitest.config.js b/vitest.config.js
new file mode 100644
index 00000000..1c491bc3
--- /dev/null
+++ b/vitest.config.js
@@ -0,0 +1,27 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ projects: [
+ {
+ test: {
+ include: ['./src/**/*.{test,spec}.js'],
+ exclude: ['**/tests/**'],
+ name: 'unit',
+ environment: 'node',
+ },
+ },
+ {
+ test: {
+ include: ['**/tests/**/*.{test,spec}.js'],
+ name: 'browser',
+ browser: {
+ provider: 'playwright',
+ enabled: true,
+ instances: [{ browser: 'chromium' }],
+ },
+ },
+ },
+ ],
+ },
+});