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