From d54d99653e387b878a541ca62ebcc279baae15ff Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 24 Jun 2025 10:56:13 +0900 Subject: [PATCH 01/66] fix rename --- src/display/data-schema/{data-schema.js => element-schema.js} | 0 .../data-schema/{data-schema.test.js => element-schema.test.js} | 2 +- src/display/elements/grid.js | 2 +- src/display/elements/group.js | 2 +- src/display/elements/item.js | 2 +- src/display/elements/relations.js | 2 +- src/utils/validator.js | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) rename src/display/data-schema/{data-schema.js => element-schema.js} (100%) rename src/display/data-schema/{data-schema.test.js => element-schema.test.js} (99%) diff --git a/src/display/data-schema/data-schema.js b/src/display/data-schema/element-schema.js similarity index 100% rename from src/display/data-schema/data-schema.js rename to src/display/data-schema/element-schema.js diff --git a/src/display/data-schema/data-schema.test.js b/src/display/data-schema/element-schema.test.js similarity index 99% rename from src/display/data-schema/data-schema.test.js rename to src/display/data-schema/element-schema.test.js index 92cdbf5c..0119be85 100644 --- a/src/display/data-schema/data-schema.test.js +++ b/src/display/data-schema/element-schema.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { mapDataSchema } from './data-schema'; +import { mapDataSchema } from './element-schema'; describe('mapDataSchema (modified tests for required fields)', () => { // -------------------------------------------------------------------------- diff --git a/src/display/elements/grid.js b/src/display/elements/grid.js index b9a0bcab..dfd61e3e 100644 --- a/src/display/elements/grid.js +++ b/src/display/elements/grid.js @@ -1,7 +1,7 @@ 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 { deepGridObject } from '../data-schema/element-schema'; import { updateObject } from '../update/update-object'; import { createContainer } from '../utils'; import { createItem } from './item'; diff --git a/src/display/elements/group.js b/src/display/elements/group.js index bcb772db..e1e14788 100644 --- a/src/display/elements/group.js +++ b/src/display/elements/group.js @@ -1,7 +1,7 @@ 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 { deepGroupObject } from '../data-schema/element-schema'; import { updateObject } from '../update/update-object'; import { createContainer } from '../utils'; diff --git a/src/display/elements/item.js b/src/display/elements/item.js index 75d8bbd1..ee1dd8ae 100644 --- a/src/display/elements/item.js +++ b/src/display/elements/item.js @@ -1,7 +1,7 @@ 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 { deepSingleObject } from '../data-schema/element-schema'; import { updateObject } from '../update/update-object'; import { createContainer } from '../utils'; diff --git a/src/display/elements/relations.js b/src/display/elements/relations.js index 5023af84..205ca350 100644 --- a/src/display/elements/relations.js +++ b/src/display/elements/relations.js @@ -2,7 +2,7 @@ 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 { deepRelationGroupObject } from '../data-schema/element-schema'; import { updateObject } from '../update/update-object'; import { createContainer } from '../utils'; 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 { From 8ce7277fb695029f6ec9c0b38251bd89a4c49340 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 24 Jun 2025 12:24:35 +0900 Subject: [PATCH 02/66] fix element schema --- src/display/data-schema/element-schema.js | 118 ++-- .../data-schema/element-schema.test.js | 556 ++++++++---------- src/utils/convert.js | 57 +- 3 files changed, 347 insertions(+), 384 deletions(-) diff --git a/src/display/data-schema/element-schema.js b/src/display/data-schema/element-schema.js index 03abc882..eb117900 100644 --- a/src/display/data-schema/element-schema.js +++ b/src/display/data-schema/element-schema.js @@ -1,97 +1,99 @@ import { z } from 'zod'; -import { deepPartial } from '../../utils/zod-deep-strict-partial'; +import { uid } from '../../utils/uuid'; import { componentArraySchema } from './component-schema'; -const position = z - .object({ - x: z.number().default(0), - y: z.number().default(0), - }) - .strict(); - -const size = z - .object({ +const transformParts = { + position: z + .object({ + x: z.number().default(0), + y: z.number().default(0), + }) + .default({}), + 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 +const defaultSchema = z .object({ show: z.boolean().default(true), - id: z.string(), - metadata: z.record(z.unknown()).default({}), + id: z.string().default(() => uid()), }) .passthrough(); -const gridObject = defaultInfo.extend({ +export const gridSchema = defaultSchema.extend({ type: z.literal('grid'), + position: transformParts.position, cells: z.array(z.array(z.union([z.literal(0), z.literal(1)]))), - components: componentArraySchema, - position: position.default({}), - itemSize: size, + gap: z.preprocess( + (val) => { + return typeof val === 'number' ? { x: val, y: val } : val; + }, + z + .object({ + x: z.number().nonnegative().default(0), + y: z.number().nonnegative().default(0), + }) + .default({}), + ), + itemTemplate: z.object({ + size: transformParts.size, + components: componentArraySchema, + }), }); -export const deepGridObject = deepPartial(gridObject); -const singleObject = defaultInfo.extend({ +export const itemSchema = defaultSchema.extend({ type: z.literal('item'), + position: transformParts.position, + size: transformParts.size, components: componentArraySchema, - position: position.default({}), - size: size, }); -export const deepSingleObject = deepPartial(singleObject); -const relationGroupObject = defaultInfo.extend({ +export const relationsSchema = defaultSchema.extend({ type: z.literal('relations'), - links: z.array(relation), - strokeStyle: z.preprocess( + links: z.array(z.object({ source: z.string(), target: z.string() })), + style: z.preprocess( (val) => ({ color: 'black', ...val }), z.record(z.unknown()), - ), + ), // https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html }); -export const deepRelationGroupObject = deepPartial(relationGroupObject); -const groupObject = defaultInfo.extend({ +export const groupSchema = defaultSchema.extend({ type: z.literal('group'), - items: z.array(z.lazy(() => itemTypes)), - position: position.default({}), + children: z.array(z.lazy(() => elementTypes)), + position: transformParts.position, }); -export const deepGroupObject = deepPartial(groupObject); -const itemTypes = z.discriminatedUnion('type', [ - groupObject, - gridObject, - singleObject, - relationGroupObject, +const elementTypes = z.discriminatedUnion('type', [ + groupSchema, + gridSchema, + itemSchema, + relationsSchema, ]); -export const mapDataSchema = z.array(itemTypes).superRefine((items, ctx) => { - const errors = collectIds(items); - errors.forEach((error) => - ctx.addIssue({ code: z.ZodIssueCode.custom, message: error }), - ); -}); +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(items, idSet = new Set(), path = []) { +function collectIds(elements, idSet = new Set(), path = []) { const errors = []; - items.forEach((item, index) => { + elements.forEach((element, index) => { const currentPath = [...path, index.toString()]; - if (idSet.has(item.id)) { - errors.push(`Duplicate id: ${item.id} at ${currentPath.join('.')}`); + if (idSet.has(element.id)) { + errors.push(`Duplicate id: ${element.id} at ${currentPath.join('.')}`); } else { - idSet.add(item.id); + idSet.add(element.id); } - if (item.type === 'group' && Array.isArray(item.items)) { + if (element.type === 'group' && Array.isArray(element.children)) { errors.push( - ...collectIds(item.items, idSet, currentPath.concat('items')), + ...collectIds(element.children, idSet, currentPath.concat('children')), ); } }); diff --git a/src/display/data-schema/element-schema.test.js b/src/display/data-schema/element-schema.test.js index 0119be85..9d2dd34c 100644 --- a/src/display/data-schema/element-schema.test.js +++ b/src/display/data-schema/element-schema.test.js @@ -1,359 +1,321 @@ -import { describe, expect, it } from 'vitest'; -import { mapDataSchema } from './element-schema'; +import { describe, expect, it, test } from 'vitest'; +import { gridSchema, mapDataSchema } from './element-schema'; -describe('mapDataSchema (modified tests for required fields)', () => { - // -------------------------------------------------------------------------- - // 1) 정상 케이스 테스트 - // -------------------------------------------------------------------------- - it('should validate a minimal valid single item object (type=item) with size', () => { - // 이제 size가 required이므로 반드시 포함해야 함 +// --- Test Suite for valid data structures --- +describe('Success Cases', () => { + it('should validate a minimal `item` and apply default values', () => { const data = [ { - id: 'unique-item-1', type: 'item', - components: [], // componentSchema는 배열 + id: 'item-1', size: { width: 100, height: 50 }, + components: [], }, ]; 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); + expect(result.success, 'Validation should pass').toBe(true); - // size가 정상적으로 들어왔는지 - expect(result.data[0].size.width).toBe(100); - expect(result.data[0].size.height).toBe(50); + if (result.success) { + const parsed = result.data[0]; + expect(parsed.position).toEqual({ x: 0, y: 0 }); + expect(parsed.show).toBe(true); } }); - it('should validate a valid grid object (type=grid) with cells and size', () => { + it('should validate a `grid` with a complete itemTemplate', () => { 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, + cells: [[1]], + itemTemplate: { + size: { width: 50, height: 50 }, + components: [{ type: 'background', texture: { type: 'rect' } }], }, }, ]; const result = mapDataSchema.safeParse(data); - expect(result.success).toBe(true); + expect(result.success, 'Grid validation should pass').toBe(true); }); - it('should validate a valid relations object (type=relations)', () => { - // relation이 required이므로, links 내부 요소도 제대로 있어야 함 + it('should validate a `group` with nested children and optional size', () => { const data = [ { - id: 'relations-1', - type: 'relations', - links: [ - { source: 'itemA', target: 'itemB' }, - { source: 'itemC', target: 'itemD' }, + type: 'group', + id: 'group-1', + position: { x: 10, y: 10 }, + size: { width: 200, height: 200 }, + children: [ + { + type: 'item', + id: 'nested-item', + size: { width: 20, height: 20 }, + components: [], + }, ], }, ]; const result = mapDataSchema.safeParse(data); - expect(result.success).toBe(true); + expect(result.success, 'Group validation should pass').toBe(true); }); - it('should pass if links in relations is empty or has missing fields', () => { + it('should validate `relations` with custom styles and apply defaults', () => { const data = [ { - id: 'relations-bad-links', type: 'relations', - links: [], + links: [{ source: 'a', target: 'b' }], + style: { width: 5, color: '0xff0000' }, }, ]; 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(); - } - }); + expect(result.success, 'Relations validation should pass').toBe(true); - 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); - }); + if (result.success) { + const parsed = result.data[0]; + expect(parsed.style.width).toBe(5); + expect(parsed.style.color).toBe('0xff0000'); + } + }); - 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); + it('should allow passthrough of unknown properties', () => { + const data = [ + { + type: 'item', + id: 'item-1', + size: { width: 10, height: 10 }, + components: [], + // `passthrough` allows adding properties not defined in the schema + customData: { value: 123 }, + anotherProp: 'hello', + }, + ]; + const result = mapDataSchema.safeParse(data); + expect(result.success, 'Passthrough properties should be allowed').toBe( + true, + ); + if (result.success) { + expect(result.data[0].customData).toEqual({ value: 123 }); + expect(result.data[0].anotherProp).toEqual('hello'); + } + }); - if (!result.success) { - // relation의 target 누락 관련 에러를 체크 - const targetError = result.error.issues.find((issue) => - issue.path.includes('links'), - ); - expect(targetError).toBeDefined(); - } - }); + it('should generate a default ID if one is not provided', () => { + const data = [ + { type: 'item', size: { width: 1, height: 1 }, components: [] }, + ]; + const result = mapDataSchema.safeParse(data); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data[0].id).toBeDefined(); + expect(typeof result.data[0].id).toBe('string'); + } + }); +}); - it('should fail if id is duplicated', () => { - const data = [ +// --- Test Suite for invalid data structures --- +describe('Failure Cases', () => { + test.each([ + { + name: 'missing required `size` in `item`', + data: [{ type: 'item', id: 'item-1', components: [] }], + expectedPath: [0, 'size'], + }, + { + name: 'missing required `components` in `item`', + data: [{ type: 'item', id: 'item-1', size: { width: 1, height: 1 } }], + expectedPath: [0, 'components'], + }, + { + name: 'missing `itemTemplate` in `grid`', + data: [{ type: 'grid', id: 'grid-1', cells: [[1]] }], + expectedPath: [0, 'itemTemplate'], + }, + { + name: 'missing `children` in `group`', + data: [{ type: 'group', id: 'group-1' }], + expectedPath: [0, 'children'], + }, + { + name: 'negative `width` in `size`', + data: [ { - id: 'dupId', type: 'item', + id: 'item-1', components: [], - size: { width: 100, height: 100 }, - }, - { - id: 'dupId', - type: 'grid', - cells: [[0]], - components: [], - itemSize: { width: 50, height: 50 }, + size: { width: -10, height: 10 }, }, - ]; - 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 = [ + ], + expectedPath: [0, 'size', 'width'], + }, + { + name: 'invalid `cells` value in `grid`', + 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 }, + cells: [[2]], + itemTemplate: { size: { w: 1, h: 1 }, components: [] }, }, - ]; - const result = mapDataSchema.safeParse(data); - expect(result.success).toBe(false); - }); + ], + expectedPath: [0, 'cells', 0, 0], + }, + ])('should fail for $name', ({ name, data, expectedPath }) => { + const result = mapDataSchema.safeParse(data); + expect(result.success, `Should fail for: ${name}`).toBe(false); + if (!result.success) { + const errorPaths = result.error.issues.map((issue) => + issue.path.join('.'), + ); + expect(errorPaths).toContain(expectedPath.join('.')); + } }); +}); - // -------------------------------------------------------------------------- - // 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', +// --- Test Suite for edge cases and constraints --- +describe('Edge Cases and Constraints', () => { + it('should fail if an ID is duplicated, even in nested structures', () => { + const data = [ + { + type: 'item', + id: 'duplicate-id', + size: { width: 1, height: 1 }, + components: [], + }, + { + type: 'group', + id: 'group-1', + children: [ + { + type: 'item', + id: 'duplicate-id', + size: { width: 1, height: 1 }, + components: [], }, - 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); + ], + }, + ]; + const result = mapDataSchema.safeParse(data); + expect(result.success).toBe(false); + if (!result.success) { + const customError = result.error.issues.find((i) => i.code === 'custom'); + expect(customError).toBeDefined(); + expect(customError.message).toContain('Duplicate id: duplicate-id'); + } + }); - // 첫 번째는 grid - expect(group.items[0].type).toBe('grid'); - // 두 번째는 item - expect(group.items[1].type).toBe('item'); - } - }); + it('should pass validation for an empty array', () => { + const data = []; + const result = mapDataSchema.safeParse(data); + expect(result.success).toBe(true); + }); - 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 }, - }, - ], - }, - ]; + it('should fail if the root data is not an array', () => { + const data = { + type: 'item', + id: 'item-1', + size: { width: 1, height: 1 }, + components: [], + }; + const result = mapDataSchema.safeParse(data); + expect(result.success).toBe(false); + }); +}); - const result = mapDataSchema.safeParse(data); - expect(result.success).toBe(false); +describe('`gap` Validation', () => { + describe('Success Cases and Normalization', () => { + const gapSchema = gridSchema.pick({ type: true, gap: true }); - if (!result.success) { - // 중복 id 관련 에러 메시지를 확인 - const duplicateError = result.error.issues.find((issue) => - issue.message.includes('Duplicate id: duplicate-item'), + test.each([ + { + name: 'a single number (shorthand)', + input: { type: 'grid', gap: 10 }, + expected: { x: 10, y: 10 }, + }, + { + name: 'a full object with x and y', + input: { type: 'grid', gap: { x: 20, y: 15 } }, + expected: { x: 20, y: 15 }, + }, + { + name: 'a partial object with only x', + input: { type: 'grid', gap: { x: 5 } }, + expected: { x: 5, y: 0 }, // y should default to 0 + }, + { + name: 'a partial object with only y', + input: { type: 'grid', gap: { y: 8 } }, + expected: { x: 0, y: 8 }, // x should default to 0 + }, + { + name: 'undefined (should apply all defaults)', + input: { type: 'grid' }, + expected: { x: 0, y: 0 }, + }, + { + name: 'an empty object', + input: { type: 'grid', gap: {} }, + expected: { x: 0, y: 0 }, + }, + { + name: 'zero as a number', + input: { type: 'grid', gap: 0 }, + expected: { x: 0, y: 0 }, + }, + ])( + 'should correctly parse and default when `gap` is $name', + ({ name, input, expected }) => { + const result = gapSchema.safeParse(input); + expect(result.success, `Validation failed for case: ${name}`).toBe( + true, ); - expect(duplicateError).toBeDefined(); - } - }); + if (result.success) { + expect(result.data.gap).toEqual(expected); + } + }, + ); + }); - it('should fail if items is missing or not an array', () => { - // items 필드 자체가 없는 경우 - const data = [ - { - id: 'group-without-items', - type: 'group', - // items 누락 - }, - ]; + describe('Failure Cases', () => { + const gapSchema = gridSchema.pick({ type: true, gap: true }); - const result = mapDataSchema.safeParse(data); - expect(result.success).toBe(false); + test.each([ + { + name: 'a negative number', + input: { type: 'grid', gap: -10 }, + expectedPath: ['gap', 'x'], // The preprocessor turns it into {x: -10, y: -10} + }, + { + name: 'an object with a negative x value', + input: { type: 'grid', gap: { x: -5, y: 10 } }, + expectedPath: ['gap', 'x'], + }, + { + name: 'an object with a non-numeric value', + input: { type: 'grid', gap: { x: 10, y: 'invalid' } }, + expectedPath: ['gap', 'y'], + }, + { + name: 'a string value', + input: { type: 'grid', gap: '10' }, + expectedPath: ['gap'], // Fails the object check after preprocessing + }, + { + name: 'null', + input: { type: 'grid', gap: null }, + expectedPath: ['gap'], + }, + { + name: 'an array', + input: { type: 'grid', gap: [10, 10] }, + expectedPath: ['gap'], + }, + ])('should fail when `gap` is $name', ({ name, input, expectedPath }) => { + const result = gapSchema.safeParse(input); + expect(result.success, `Should fail for case: ${name}`).toBe(false); if (!result.success) { - const missingItemsError = result.error.issues.find((issue) => - issue.path.includes('items'), + const errorPaths = result.error.issues.map((issue) => + issue.path.join('.'), ); - expect(missingItemsError).toBeDefined(); + expect(errorPaths).toContain(expectedPath.join('.')); } }); }); - - // -------------------------------------------------------------------------- - // 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/utils/convert.js b/src/utils/convert.js index 50a4366a..de631d89 100644 --- a/src/utils/convert.js +++ b/src/utils/convert.js @@ -15,7 +15,7 @@ export const convertLegacyData = (data) => { type: 'group', id: uid(), label: key === 'grids' ? 'panelGroups' : key, - items: [], + children: [], }; if (key === 'grids') { @@ -30,35 +30,34 @@ export const convertLegacyData = (data) => { ), 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, + itemTemplate: { + 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', + id: 'default', + texture: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.dark', + radius: 6, + }, }, - tint: 'primary.default', - show: false, - margin: '3', - }, - ], + { + type: 'bar', + id: 'default', + texture: { type: 'rect', fill: 'white', radius: 3 }, + tint: 'primary.default', + show: false, + margin: '3', + }, + ], + }, metadata: props, }); } @@ -83,7 +82,7 @@ export const convertLegacyData = (data) => { }, ] : [], - strokeStyle: { + style: { width: 4, color: value.properties.color.dark, cap: 'round', From 4145942d1a0fec202e10faff57e8d05fcc3780ba Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 24 Jun 2025 14:59:48 +0900 Subject: [PATCH 03/66] fix --- src/display/data-schema/component-schema.js | 90 ++++++++++++------- src/display/data-schema/element-schema.js | 60 ++++++------- .../data-schema/element-schema.test.js | 6 +- 3 files changed, 90 insertions(+), 66 deletions(-) diff --git a/src/display/data-schema/component-schema.js b/src/display/data-schema/component-schema.js index 89f7a502..da05b586 100644 --- a/src/display/data-schema/component-schema.js +++ b/src/display/data-schema/component-schema.js @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { Base } from './element-schema'; export const Placement = z.enum([ 'left', @@ -12,12 +13,30 @@ export const Placement = z.enum([ 'center', ]); -export const Margin = z.string().regex(/^(\d+(\.\d+)?(\s+\d+(\.\d+)?){0,3})$/); +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({ top: 0, right: 0, bottom: 0, left: 0 }), +); -const TextureType = z.enum(['rect']); export const TextureStyle = z .object({ - type: TextureType, + type: z.enum(['rect']), fill: z.nullable(z.string()), borderWidth: z.nullable(z.number()), borderColor: z.nullable(z.string()), @@ -25,40 +44,50 @@ export const TextureStyle = z }) .partial(); -const defaultConfig = z - .object({ - show: z.boolean().default(true), - }) - .passthrough(); +const sizeValueSchema = z + .union([z.number().nonnegative(), z.string().regex(/^\d+(\.\d+)?%$/)]) + .transform((val) => { + return typeof val === 'number' + ? { value: val, unit: 'px' } + : { value: Number.parseFloat(val.slice(0, -1)), unit: '%' }; + }); + +const layout = { + x: z.number().default(0), + y: z.number().default(0), + margin: Margin.default(0), + placement: Placement.default('left-top'), +}; -const background = defaultConfig.extend({ +const size = { + width: sizeValueSchema, + height: sizeValueSchema, +}; + +const Background = Base.extend({ type: z.literal('background'), - texture: TextureStyle, + source: z.union([TextureStyle, z.string()]), }); -const bar = defaultConfig.extend({ +const Bar = Base.extend({ type: z.literal('bar'), - texture: TextureStyle, - 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), + source: TextureStyle, animation: z.boolean().default(true), animationDuration: z.number().default(200), + ...layout, + ...size, + placement: Placement.default('bottom'), }); -const icon = defaultConfig.extend({ +const Icon = Base.extend({ type: z.literal('icon'), - asset: z.string(), - placement: Placement.default('center'), - margin: Margin.default('0'), - size: z.number().nonnegative(), + source: z.string(), + ...layout, + ...size, }); -const text = defaultConfig.extend({ +const Text = Base.extend({ type: z.literal('text'), - placement: Placement.default('center'), - margin: Margin.default('0'), text: z.string().default(''), style: z .preprocess( @@ -72,15 +101,14 @@ const text = defaultConfig.extend({ ) .default({}), split: z.number().int().default(0), + ...layout, }); export const componentSchema = z.discriminatedUnion('type', [ - background, - bar, - icon, - text, + Background, + Bar, + Icon, + Text, ]); -export const componentArraySchema = z - .discriminatedUnion('type', [background, bar, icon, text]) - .array(); +export const componentArraySchema = componentSchema.array(); diff --git a/src/display/data-schema/element-schema.js b/src/display/data-schema/element-schema.js index eb117900..97016c7e 100644 --- a/src/display/data-schema/element-schema.js +++ b/src/display/data-schema/element-schema.js @@ -2,29 +2,25 @@ import { z } from 'zod'; import { uid } from '../../utils/uuid'; import { componentArraySchema } from './component-schema'; -const transformParts = { - position: z - .object({ - x: z.number().default(0), - y: z.number().default(0), - }) - .default({}), - size: z.object({ - width: z.number().nonnegative(), - height: z.number().nonnegative(), - }), -}; +export const Position = z.object({ + x: z.number().default(0), + y: z.number().default(0), +}); -const defaultSchema = z +const Size = z.object({ + width: z.number().nonnegative(), + height: z.number().nonnegative(), +}); + +export const Base = z .object({ show: z.boolean().default(true), id: z.string().default(() => uid()), }) .passthrough(); -export const gridSchema = defaultSchema.extend({ +export const Grid = Base.merge(Position).extend({ type: z.literal('grid'), - position: transformParts.position, cells: z.array(z.array(z.union([z.literal(0), z.literal(1)]))), gap: z.preprocess( (val) => { @@ -37,20 +33,21 @@ export const gridSchema = defaultSchema.extend({ }) .default({}), ), - itemTemplate: z.object({ - size: transformParts.size, - components: componentArraySchema, - }), + itemTemplate: z + .object({ + components: componentArraySchema, + }) + .merge(Size), }); -export const itemSchema = defaultSchema.extend({ - type: z.literal('item'), - position: transformParts.position, - size: transformParts.size, - components: componentArraySchema, -}); +export const Item = Base.merge(Position) + .merge(Size) + .extend({ + type: z.literal('item'), + components: componentArraySchema, + }); -export const relationsSchema = defaultSchema.extend({ +export const Relations = Base.extend({ type: z.literal('relations'), links: z.array(z.object({ source: z.string(), target: z.string() })), style: z.preprocess( @@ -59,17 +56,16 @@ export const relationsSchema = defaultSchema.extend({ ), // https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html }); -export const groupSchema = defaultSchema.extend({ +export const Group = Base.merge(Position).extend({ type: z.literal('group'), children: z.array(z.lazy(() => elementTypes)), - position: transformParts.position, }); const elementTypes = z.discriminatedUnion('type', [ - groupSchema, - gridSchema, - itemSchema, - relationsSchema, + Group, + Grid, + Item, + Relations, ]); export const mapDataSchema = z diff --git a/src/display/data-schema/element-schema.test.js b/src/display/data-schema/element-schema.test.js index 9d2dd34c..0d3a3674 100644 --- a/src/display/data-schema/element-schema.test.js +++ b/src/display/data-schema/element-schema.test.js @@ -1,5 +1,5 @@ import { describe, expect, it, test } from 'vitest'; -import { gridSchema, mapDataSchema } from './element-schema'; +import { Grid, mapDataSchema } from './element-schema'; // --- Test Suite for valid data structures --- describe('Success Cases', () => { @@ -221,7 +221,7 @@ describe('Edge Cases and Constraints', () => { describe('`gap` Validation', () => { describe('Success Cases and Normalization', () => { - const gapSchema = gridSchema.pick({ type: true, gap: true }); + const gapSchema = Grid.pick({ type: true, gap: true }); test.each([ { @@ -274,7 +274,7 @@ describe('`gap` Validation', () => { }); describe('Failure Cases', () => { - const gapSchema = gridSchema.pick({ type: true, gap: true }); + const gapSchema = Grid.pick({ type: true, gap: true }); test.each([ { From db4ebc041b26c8726e1892754247861a86a56c9d Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 24 Jun 2025 17:41:19 +0900 Subject: [PATCH 04/66] fix schema --- src/display/data-schema/component-schema.js | 121 +--- .../data-schema/component-schema.test.js | 383 ++++++------ src/display/data-schema/element-schema.js | 48 +- .../data-schema/element-schema.test.js | 562 +++++++++--------- src/display/data-schema/primitive-schema.js | 102 ++++ .../data-schema/primitive-schema.test.js | 386 ++++++++++++ src/display/utils.js | 15 - src/display/utils.test.js | 87 --- 8 files changed, 976 insertions(+), 728 deletions(-) create mode 100644 src/display/data-schema/primitive-schema.js create mode 100644 src/display/data-schema/primitive-schema.test.js delete mode 100644 src/display/utils.test.js diff --git a/src/display/data-schema/component-schema.js b/src/display/data-schema/component-schema.js index da05b586..8e47ccfa 100644 --- a/src/display/data-schema/component-schema.js +++ b/src/display/data-schema/component-schema.js @@ -1,107 +1,46 @@ import { z } from 'zod'; -import { Base } from './element-schema'; - -export const Placement = z.enum([ - 'left', - 'left-top', - 'left-bottom', - 'top', - 'right', - 'right-top', - 'right-bottom', - 'bottom', - 'center', -]); - -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({ top: 0, right: 0, bottom: 0, left: 0 }), -); - -export const TextureStyle = z - .object({ - type: z.enum(['rect']), - fill: z.nullable(z.string()), - borderWidth: z.nullable(z.number()), - borderColor: z.nullable(z.string()), - radius: z.nullable(z.number()), - }) - .partial(); - -const sizeValueSchema = z - .union([z.number().nonnegative(), z.string().regex(/^\d+(\.\d+)?%$/)]) - .transform((val) => { - return typeof val === 'number' - ? { value: val, unit: 'px' } - : { value: Number.parseFloat(val.slice(0, -1)), unit: '%' }; - }); - -const layout = { - x: z.number().default(0), - y: z.number().default(0), - margin: Margin.default(0), - placement: Placement.default('left-top'), -}; - -const size = { - width: sizeValueSchema, - height: sizeValueSchema, -}; - -const Background = Base.extend({ +import { + Base, + Margin, + Placement, + Position, + PxOrPercentSize, + TextStyle, + TextureStyle, + pxOrPercentSchema, +} from './primitive-schema'; + +export const Background = Base.extend({ type: z.literal('background'), source: z.union([TextureStyle, z.string()]), }); -const Bar = Base.extend({ - type: z.literal('bar'), - source: TextureStyle, - animation: z.boolean().default(true), - animationDuration: z.number().default(200), - ...layout, - ...size, - placement: Placement.default('bottom'), -}); +export const Bar = Base.merge(Position) + .merge(PxOrPercentSize) + .extend({ + type: z.literal('bar'), + source: TextureStyle, + placemnet: Placement.default('bottom'), + margin: Margin.default(0), + animation: z.boolean().default(true), + animationDuration: z.number().default(200), + }); -const Icon = Base.extend({ +export const Icon = Base.merge(Position).extend({ type: z.literal('icon'), source: z.string(), - ...layout, - ...size, + placemnet: Placement.default('center'), + margin: Margin.default(0), + size: pxOrPercentSchema, }); -const Text = Base.extend({ +export const Text = Base.merge(Position).extend({ type: z.literal('text'), + placemnet: Placement.default('center'), + margin: Margin.default(0), text: z.string().default(''), - style: z - .preprocess( - (val) => ({ - fontFamily: 'FiraCode', - fontWeight: 400, - fill: 'black', - ...val, - }), - z.record(z.unknown()), - ) - .default({}), + style: TextStyle, split: z.number().int().default(0), - ...layout, }); export const componentSchema = z.discriminatedUnion('type', [ diff --git a/src/display/data-schema/component-schema.test.js b/src/display/data-schema/component-schema.test.js index 86210afa..a88ff20e 100644 --- a/src/display/data-schema/component-schema.test.js +++ b/src/display/data-schema/component-schema.test.js @@ -1,257 +1,222 @@ -import { describe, expect, it } from 'vitest'; -import { componentArraySchema, componentSchema } from './component-schema'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// We import the actual schemas from the provided files for integration testing. +import { + Background, + Bar, + Icon, + Text, + componentArraySchema, + componentSchema, +} from './component-schema.js'; + +// --- Global Setup --- + +// Mocking a unique ID generator for predictable test outcomes. +// This is used by the `Base` schema in `primitive-schema.js`. +let idCounter = 0; +const uid = vi.fn(() => `mock-id-${idCounter++}`); +global.uid = uid; + +beforeEach(() => { + // Reset counter before each test to ensure test isolation. + idCounter = 0; + uid.mockClear(); +}); -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, - }, +// --- Test Suites --- + +describe('Component Schema Tests (Final Version)', () => { + // --- Test each component schema individually --- + + describe('Background Schema', () => { + it.each([ { - type: 'icon', - asset: 'icon.png', - size: 32, - zIndex: 10, + case: 'with a string source', + source: 'image.png', + expected: 'image.png', }, { - type: 'text', - placement: 'top', - text: 'Hello World', + case: 'with a TextureStyle object source', + source: { type: 'rect', fill: 'red' }, + expected: { type: 'rect', fill: 'red' }, }, - ]; + ])('should parse a valid background $case', ({ source, expected }) => { + const data = { type: 'background', source }; + const parsed = Background.parse(data); + expect(parsed.source).toEqual(expected); + }); - const result = componentArraySchema.safeParse(validComponent); - expect(result.success).toBe(true); - expect(result.success && result.data).toBeDefined(); + it('should fail with an invalid source type', () => { + const data = { type: 'background', source: 123 }; // Source must be string or TextureStyle + expect(() => Background.parse(data)).toThrow(); + }); }); - it('should fail if an invalid type is present in the component', () => { - const invalidComponent = { - // 여기에 존재하지 않는 type - type: 'wrongType', - texture: { type: 'rect' }, + describe('Bar Schema', () => { + // Base valid data for Bar tests + const baseBar = { + type: 'bar', + width: 100, + height: 20, + source: { fill: 'blue' }, }; - const result = componentSchema.safeParse(invalidComponent); - expect(result.success).toBe(false); - }); + it('should parse a valid bar and apply all defaults', () => { + const parsed = Bar.parse(baseBar); + expect(parsed.placemnet).toBe('bottom'); + // Margin preprocesses to a full object + expect(parsed.margin).toEqual({ top: 0, right: 0, bottom: 0, left: 0 }); + expect(parsed.animation).toBe(true); + expect(parsed.animationDuration).toBe(200); + // Check if PxOrPercentSize transformation worked + expect(parsed.width.unit).toBe('px'); + expect(parsed.height.value).toBe(20); + }); - // -------------------------------------------------------------------------- - // 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 correctly override default values', () => { + const data = { + ...baseBar, + placemnet: 'top', + margin: { x: 10, y: 20 }, // Use object syntax for margin + animation: false, + }; + const parsed = Bar.parse(data); + expect(parsed.placemnet).toBe('top'); + expect(parsed.margin).toEqual({ + top: 20, + right: 10, + bottom: 20, + left: 10, + }); + expect(parsed.animation).toBe(false); }); - 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 required properties from merged schemas (PxOrPercentSize) are missing', () => { + const { width, ...rest } = baseBar; // Missing width + expect(() => Bar.parse(rest)).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('Icon Schema', () => { + const baseIcon = { type: 'icon', source: 'icon.svg' }; - // 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.each([ + { case: 'a number size', size: 50, expected: { value: 50, unit: 'px' } }, + { + case: 'a percentage string size', + size: '75%', + expected: { value: 75, unit: '%' }, + }, + { case: 'a zero size', size: 0, expected: { value: 0, unit: 'px' } }, + ])('should parse a valid icon with $case', ({ size, expected }) => { + const data = { ...baseIcon, size }; + const parsed = Icon.parse(data); + expect(parsed.size).toEqual(expected); + // Check if defaults are applied + expect(parsed.placemnet).toBe('center'); }); - 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); + // Edge cases for the `size` property (which uses pxOrPercentSchema) + it.each([ + { case: 'a negative number', size: -10 }, + { case: 'a malformed percentage string', size: '50 percent' }, + { case: 'an invalid type like an object', size: { value: 50 } }, + { case: 'null or undefined', size: undefined }, + ])('should fail for an invalid size like $case', ({ size }) => { + const data = { ...baseIcon, size }; + // `size: undefined` will fail because the property is required + expect(() => Icon.parse(data)).toThrow(); }); - it('should fail if placement is not in the enum list', () => { - const data = [ - { - type: 'bar', - texture: { type: 'rect' }, - placement: 'unknown-placement', - }, - ]; - const result = componentArraySchema.safeParse(data); - expect(result.success).toBe(false); + it('should fail if required `source` is missing', () => { + const data = { type: 'icon', size: 50 }; + expect(() => Icon.parse(data)).toThrow(); }); }); - // -------------------------------------------------------------------------- - // 4) icon 타입 테스트 - // -------------------------------------------------------------------------- - describe('icon type', () => { - it('should pass with valid icon data (checking defaults)', () => { - const data = [ - { - type: 'icon', - asset: 'icon.png', - size: 64, - }, - ]; - const result = componentArraySchema.safeParse(data); - expect(result.success).toBe(true); - - if (result.success) { - expect(result.data[0].show).toBe(true); - } + describe('Text Schema', () => { + const baseText = { type: 'text' }; + + it('should parse valid text and apply all defaults', () => { + const parsed = Text.parse(baseText); + expect(parsed.text).toBe(''); + // Check for default style properties from TextStyle's preprocess + expect(parsed.style.fontFamily).toBe('FiraCode'); + expect(parsed.style.fill).toBe('black'); + expect(parsed.style.fontWeight).toBe(400); + expect(parsed.split).toBe(0); + expect(parsed.placemnet).toBe('center'); }); - 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 texture is missing in icon', () => { - const data = [ - { - type: 'icon', - size: 32, - }, - ]; - const result = componentArraySchema.safeParse(data); - expect(result.success).toBe(false); + it('should correctly merge provided styles with defaults', () => { + const data = { + ...baseText, + style: { fill: 'red', fontSize: 24, customProp: true }, + }; + const parsed = Text.parse(data); + expect(parsed.style.fill).toBe('red'); // Overridden + expect(parsed.style.fontSize).toBe(24); // Added + expect(parsed.style.fontFamily).toBe('FiraCode'); // Default maintained + expect(parsed.style.customProp).toBe(true); // Passthrough via z.record(z.unknown()) }); }); - // -------------------------------------------------------------------------- - // 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); - - if (result.success) { - // text 디폴트값 확인 - expect(result.data[0].text).toBe(''); - // placement 디폴트값 확인 - expect(result.data[0].placement).toBe('center'); - } + // --- Test the discriminated union and array schemas --- + describe('componentSchema (Discriminated Union)', () => { + it.each([ + { case: 'a valid background', data: { type: 'background', source: '' } }, + { + case: 'a valid bar', + data: { type: 'bar', width: 1, height: 1, source: {} }, + }, + { case: 'a valid icon', data: { type: 'icon', size: 1, source: '' } }, + { case: 'a valid text', data: { type: 'text' } }, + ])('should correctly parse $case', ({ data }) => { + expect(() => componentSchema.parse(data)).not.toThrow(); }); - it('should pass with a custom style object', () => { - const data = [ - { - type: 'text', - text: 'Hello!', - style: { - fontWeight: 'bold', - customProp: 123, - }, - }, - ]; - const result = componentArraySchema.safeParse(data); - expect(result.success).toBe(true); + 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); + // Check for the specific discriminated union error + expect(result.error.issues[0].code).toBe('invalid_union_discriminator'); }); - it('should fail if placement is invalid in text', () => { - const data = [ - { - type: 'text', - placement: 'invalid-placement', - }, - ]; - const result = componentArraySchema.safeParse(data); + it('should fail for a known type with missing required properties', () => { + // 'icon' requires 'source' and 'size' + const data = { type: 'icon', source: 'image.png' }; + const result = componentSchema.safeParse(data); expect(result.success).toBe(false); + // The error should point to the missing 'size' field + expect(result.error.issues[0].path).toEqual(['size']); }); }); - // -------------------------------------------------------------------------- - // 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('componentArraySchema', () => { + it('should parse a valid array of mixed, well-formed components', () => { + const data = [ + { type: 'background', source: 'bg.png' }, + { type: 'text', text: 'Hello World' }, + { type: 'icon', source: 'icon.svg', size: '10%' }, + ]; + expect(() => componentArraySchema.parse(data)).not.toThrow(); }); - it('should fail if the input is not an array at all', () => { - const data = { - type: 'background', - texture: { type: 'rect' }, - }; - const result = componentArraySchema.safeParse(data); - expect(result.success).toBe(false); + it('should correctly parse an empty array', () => { + expect(() => componentArraySchema.parse([])).not.toThrow(); }); - it('should fail if non-object is included in the array', () => { + it('should fail if any single element in the array is invalid', () => { const data = [ - { - type: 'background', - texture: { type: 'rect' }, - }, - 1234, // 비객체 + { type: 'text', text: 'Valid' }, + { type: 'icon', source: 'missing-size.svg' }, // This one is invalid ]; const result = componentArraySchema.safeParse(data); expect(result.success).toBe(false); + // The error path should correctly point to the invalid element in the array + expect(result.error.issues[0].path).toEqual([1, 'size']); }); }); }); diff --git a/src/display/data-schema/element-schema.js b/src/display/data-schema/element-schema.js index 97016c7e..744f04a8 100644 --- a/src/display/data-schema/element-schema.js +++ b/src/display/data-schema/element-schema.js @@ -1,43 +1,17 @@ import { z } from 'zod'; -import { uid } from '../../utils/uuid'; import { componentArraySchema } from './component-schema'; +import { Base, Gap, Position, RelationsStyle, Size } from './primitive-schema'; -export const Position = z.object({ - x: z.number().default(0), - y: z.number().default(0), -}); - -const Size = z.object({ - width: z.number().nonnegative(), - height: z.number().nonnegative(), +export const Group = Base.merge(Position).extend({ + type: z.literal('group'), + children: z.array(z.lazy(() => elementTypes)), }); -export const Base = z - .object({ - show: z.boolean().default(true), - id: z.string().default(() => uid()), - }) - .passthrough(); - export const Grid = Base.merge(Position).extend({ type: z.literal('grid'), cells: z.array(z.array(z.union([z.literal(0), z.literal(1)]))), - gap: z.preprocess( - (val) => { - return typeof val === 'number' ? { x: val, y: val } : val; - }, - z - .object({ - x: z.number().nonnegative().default(0), - y: z.number().nonnegative().default(0), - }) - .default({}), - ), - itemTemplate: z - .object({ - components: componentArraySchema, - }) - .merge(Size), + gap: Gap, + itemTemplate: z.object({ components: componentArraySchema }).merge(Size), }); export const Item = Base.merge(Position) @@ -50,15 +24,7 @@ export const Item = Base.merge(Position) export const Relations = Base.extend({ type: z.literal('relations'), links: z.array(z.object({ source: z.string(), target: z.string() })), - style: z.preprocess( - (val) => ({ color: 'black', ...val }), - z.record(z.unknown()), - ), // https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html -}); - -export const Group = Base.merge(Position).extend({ - type: z.literal('group'), - children: z.array(z.lazy(() => elementTypes)), + style: RelationsStyle, }); const elementTypes = z.discriminatedUnion('type', [ diff --git a/src/display/data-schema/element-schema.test.js b/src/display/data-schema/element-schema.test.js index 0d3a3674..e4350b2f 100644 --- a/src/display/data-schema/element-schema.test.js +++ b/src/display/data-schema/element-schema.test.js @@ -1,321 +1,313 @@ -import { describe, expect, it, test } from 'vitest'; -import { Grid, mapDataSchema } from './element-schema'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; -// --- Test Suite for valid data structures --- -describe('Success Cases', () => { - it('should validate a minimal `item` and apply default values', () => { - const data = [ - { - type: 'item', - id: 'item-1', - size: { width: 100, height: 50 }, - components: [], - }, - ]; - const result = mapDataSchema.safeParse(data); - expect(result.success, 'Validation should pass').toBe(true); +import { + Grid, + Group, + Item, + Relations, + mapDataSchema, +} from './element-schema.js'; - if (result.success) { - const parsed = result.data[0]; - expect(parsed.position).toEqual({ x: 0, y: 0 }); - expect(parsed.show).toBe(true); - } - }); +// We still mock component-schema as its details are not relevant for this test. +vi.mock('./component-schema', () => ({ + componentArraySchema: z.array(z.any()).default([]), +})); - it('should validate a `grid` with a complete itemTemplate', () => { - const data = [ - { - type: 'grid', - cells: [[1]], - itemTemplate: { - size: { width: 50, height: 50 }, - components: [{ type: 'background', texture: { type: 'rect' } }], - }, - }, - ]; - const result = mapDataSchema.safeParse(data); - expect(result.success, 'Grid validation should pass').toBe(true); - }); +// --- Global Setup --- - it('should validate a `group` with nested children and optional size', () => { - const data = [ - { +// Mocking a unique ID generator for predictable test outcomes. +let idCounter = 0; +const uid = vi.fn(() => `mock-id-${idCounter++}`); +global.uid = uid; + +beforeEach(() => { + // Reset counter before each test to ensure test isolation + idCounter = 0; + uid.mockClear(); +}); + +// --- Test Suites --- + +describe('Element Schema Tests (with real dependencies)', () => { + // A minimal valid item for use in other tests + const validItem = { + type: 'item', + id: 'item-1', + width: 100, + height: 100, + }; + + describe('Group Schema', () => { + it('should parse a valid group with no children', () => { + const groupData = { type: 'group', id: 'group-1', children: [] }; + expect(() => Group.parse(groupData)).not.toThrow(); + }); + + it('should parse a valid group with nested elements (lazy schema)', () => { + const groupData = { type: 'group', id: 'group-1', - position: { x: 10, y: 10 }, - size: { width: 200, height: 200 }, - children: [ - { - type: 'item', - id: 'nested-item', - size: { width: 20, height: 20 }, - components: [], - }, - ], - }, - ]; - const result = mapDataSchema.safeParse(data); - expect(result.success, 'Group validation should pass').toBe(true); + children: [validItem], + }; + const parsed = Group.parse(groupData); + expect(parsed.children).toHaveLength(1); + expect(parsed.children[0].type).toBe('item'); + }); + + it('should fail if children contains an invalid element', () => { + const invalidGroupData = { + type: 'group', + id: 'group-1', + children: [{ type: 'invalid-type' }], // an object that doesn't match any element type + }; + expect(() => Group.parse(invalidGroupData)).toThrow(); + }); }); - it('should validate `relations` with custom styles and apply defaults', () => { - const data = [ + describe('Grid Schema', () => { + // This test data is valid for the real Gap schema from base-schema + const baseGrid = { + type: 'grid', + id: 'grid-1', + itemTemplate: { width: 50, height: 50, components: [] }, + gap: 5, // Using a number, which the real Gap schema preprocesses + }; + + it.each([ { - type: 'relations', - links: [{ source: 'a', target: 'b' }], - style: { width: 5, color: '0xff0000' }, + case: 'a standard 2x2 grid', + cells: [ + [1, 0], + [0, 1], + ], }, - ]; - const result = mapDataSchema.safeParse(data); - expect(result.success, 'Relations validation should pass').toBe(true); - - if (result.success) { - const parsed = result.data[0]; - expect(parsed.style.width).toBe(5); - expect(parsed.style.color).toBe('0xff0000'); - } - }); + { case: 'an empty grid', cells: [] }, + { case: 'a grid with an empty row', cells: [[]] }, + { case: 'a ragged grid', cells: [[1], [0, 1]] }, + ])('should parse a valid grid for $case', ({ cells }) => { + const gridData = { ...baseGrid, cells }; + const parsed = Grid.parse(gridData); + // Check if the real Gap schema's preprocess worked + expect(parsed.gap).toEqual({ x: 5, y: 5 }); + }); - it('should allow passthrough of unknown properties', () => { - const data = [ + it.each([ + { case: 'cells containing a non-array', cells: [1, [0, 1]] }, { - type: 'item', - id: 'item-1', - size: { width: 10, height: 10 }, - components: [], - // `passthrough` allows adding properties not defined in the schema - customData: { value: 123 }, - anotherProp: 'hello', + case: 'cells containing invalid numbers', + cells: [ + [1, 2], + [0, 1], + ], + }, + { case: 'cells being not an array', cells: {} }, + ])( + 'should throw an error for invalid cells property for $case', + ({ cells }) => { + const gridData = { ...baseGrid, cells }; + expect(() => Grid.parse(gridData)).toThrow(); }, - ]; - const result = mapDataSchema.safeParse(data); - expect(result.success, 'Passthrough properties should be allowed').toBe( - true, ); - if (result.success) { - expect(result.data[0].customData).toEqual({ value: 123 }); - expect(result.data[0].anotherProp).toEqual('hello'); - } - }); - - it('should generate a default ID if one is not provided', () => { - const data = [ - { type: 'item', size: { width: 1, height: 1 }, components: [] }, - ]; - const result = mapDataSchema.safeParse(data); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data[0].id).toBeDefined(); - expect(typeof result.data[0].id).toBe('string'); - } - }); -}); -// --- Test Suite for invalid data structures --- -describe('Failure Cases', () => { - test.each([ - { - name: 'missing required `size` in `item`', - data: [{ type: 'item', id: 'item-1', components: [] }], - expectedPath: [0, 'size'], - }, - { - name: 'missing required `components` in `item`', - data: [{ type: 'item', id: 'item-1', size: { width: 1, height: 1 } }], - expectedPath: [0, 'components'], - }, - { - name: 'missing `itemTemplate` in `grid`', - data: [{ type: 'grid', id: 'grid-1', cells: [[1]] }], - expectedPath: [0, 'itemTemplate'], - }, - { - name: 'missing `children` in `group`', - data: [{ type: 'group', id: 'group-1' }], - expectedPath: [0, 'children'], - }, - { - name: 'negative `width` in `size`', - data: [ - { - type: 'item', - id: 'item-1', - components: [], - size: { width: -10, height: 10 }, - }, - ], - expectedPath: [0, 'size', 'width'], - }, - { - name: 'invalid `cells` value in `grid`', - data: [ - { - type: 'grid', - cells: [[2]], - itemTemplate: { size: { w: 1, h: 1 }, components: [] }, - }, - ], - expectedPath: [0, 'cells', 0, 0], - }, - ])('should fail for $name', ({ name, data, expectedPath }) => { - const result = mapDataSchema.safeParse(data); - expect(result.success, `Should fail for: ${name}`).toBe(false); - if (!result.success) { - const errorPaths = result.error.issues.map((issue) => - issue.path.join('.'), - ); - expect(errorPaths).toContain(expectedPath.join('.')); - } + it('should fail if itemTemplate is missing required size', () => { + const gridData = { ...baseGrid, itemTemplate: { components: [] } }; + expect(() => Grid.parse(gridData)).toThrow(); // width/height are required in Size schema + }); }); -}); -// --- Test Suite for edge cases and constraints --- -describe('Edge Cases and Constraints', () => { - it('should fail if an ID is duplicated, even in nested structures', () => { - const data = [ - { + describe('Item Schema', () => { + it('should parse a full valid item', () => { + const itemData = { type: 'item', - id: 'duplicate-id', - size: { width: 1, height: 1 }, - components: [], - }, - { - type: 'group', - id: 'group-1', - children: [ - { - type: 'item', - id: 'duplicate-id', - size: { width: 1, height: 1 }, - components: [], - }, - ], - }, - ]; - const result = mapDataSchema.safeParse(data); - expect(result.success).toBe(false); - if (!result.success) { - const customError = result.error.issues.find((i) => i.code === 'custom'); - expect(customError).toBeDefined(); - expect(customError.message).toContain('Duplicate id: duplicate-id'); - } - }); + id: 'item-1', + x: 10, + y: 20, + width: 100, + height: 200, + components: [{ type: 'text', content: 'hello' }], + }; + expect(() => Item.parse(itemData)).not.toThrow(); + }); - it('should pass validation for an empty array', () => { - const data = []; - const result = mapDataSchema.safeParse(data); - expect(result.success).toBe(true); + it('should fail if required properties from merged schemas are missing', () => { + // Item merges with Size, which requires width and height + const itemData = { type: 'item', id: 'item-1' }; + expect(() => Item.parse(itemData)).toThrow(); + }); }); - it('should fail if the root data is not an array', () => { - const data = { - type: 'item', - id: 'item-1', - size: { width: 1, height: 1 }, - components: [], - }; - const result = mapDataSchema.safeParse(data); - expect(result.success).toBe(false); - }); -}); + describe('Relations Schema', () => { + // The `style` property is required. Pass an empty object to trigger its preprocess. + const baseRelations = { type: 'relations', id: 'rel-1', style: {} }; -describe('`gap` Validation', () => { - describe('Success Cases and Normalization', () => { - const gapSchema = Grid.pick({ type: true, gap: true }); + it.each([ + { case: 'a valid links array', links: [{ source: 'a', target: 'b' }] }, + { case: 'an empty links array', links: [] }, + ])('should parse valid relations for $case', ({ links }) => { + const relationsData = { ...baseRelations, links }; + const parsed = Relations.parse(relationsData); + expect(parsed.links).toEqual(links); + // Check if the real RelationsStyle schema's preprocess worked + expect(parsed.style).toEqual({ color: 'black' }); + }); - test.each([ - { - name: 'a single number (shorthand)', - input: { type: 'grid', gap: 10 }, - expected: { x: 10, y: 10 }, - }, - { - name: 'a full object with x and y', - input: { type: 'grid', gap: { x: 20, y: 15 } }, - expected: { x: 20, y: 15 }, - }, + it.each([ + { case: 'links not being an array', links: {} }, { - name: 'a partial object with only x', - input: { type: 'grid', gap: { x: 5 } }, - expected: { x: 5, y: 0 }, // y should default to 0 + case: 'links array with invalid object (missing source)', + links: [{ target: 'b' }], }, { - name: 'a partial object with only y', - input: { type: 'grid', gap: { y: 8 } }, - expected: { x: 0, y: 8 }, // x should default to 0 - }, - { - name: 'undefined (should apply all defaults)', - input: { type: 'grid' }, - expected: { x: 0, y: 0 }, - }, - { - name: 'an empty object', - input: { type: 'grid', gap: {} }, - expected: { x: 0, y: 0 }, - }, - { - name: 'zero as a number', - input: { type: 'grid', gap: 0 }, - expected: { x: 0, y: 0 }, + case: 'links array with invalid object (source is not a string)', + links: [{ source: 123, target: 'b' }], }, ])( - 'should correctly parse and default when `gap` is $name', - ({ name, input, expected }) => { - const result = gapSchema.safeParse(input); - expect(result.success, `Validation failed for case: ${name}`).toBe( - true, - ); - if (result.success) { - expect(result.data.gap).toEqual(expected); - } + 'should throw an error for invalid links property for $case', + ({ links }) => { + const relationsData = { ...baseRelations, links }; + expect(() => Relations.parse(relationsData)).toThrow(); }, ); }); - describe('Failure Cases', () => { - const gapSchema = Grid.pick({ type: true, gap: true }); + describe('mapDataSchema (Integration and ID Uniqueness)', () => { + it('should parse a valid array of different elements with unique IDs', () => { + const data = [ + { type: 'item', id: 'item-10', width: 10, height: 10 }, + { type: 'group', id: 'group-20', children: [] }, + ]; + expect(() => mapDataSchema.parse(data)).not.toThrow(); + }); - test.each([ - { - name: 'a negative number', - input: { type: 'grid', gap: -10 }, - expectedPath: ['gap', 'x'], // The preprocessor turns it into {x: -10, y: -10} - }, - { - name: 'an object with a negative x value', - input: { type: 'grid', gap: { x: -5, y: 10 } }, - expectedPath: ['gap', 'x'], - }, - { - name: 'an object with a non-numeric value', - input: { type: 'grid', gap: { x: 10, y: 'invalid' } }, - expectedPath: ['gap', 'y'], - }, - { - name: 'a string value', - input: { type: 'grid', gap: '10' }, - expectedPath: ['gap'], // Fails the object check after preprocessing - }, - { - name: 'null', - input: { type: 'grid', gap: null }, - expectedPath: ['gap'], - }, - { - name: 'an array', - input: { type: 'grid', gap: [10, 10] }, - expectedPath: ['gap'], - }, - ])('should fail when `gap` is $name', ({ name, input, expectedPath }) => { - const result = gapSchema.safeParse(input); - expect(result.success, `Should fail for case: ${name}`).toBe(false); - if (!result.success) { - const errorPaths = result.error.issues.map((issue) => - issue.path.join('.'), - ); - expect(errorPaths).toContain(expectedPath.join('.')); - } + it('should parse correctly with default IDs applied', () => { + const data = [ + { type: 'item', width: 10, height: 10 }, // no id + { type: 'item', width: 10, height: 10 }, // no id + ]; + // uid() will be called, returning mock-id-0, mock-id-1, etc. + const parsed = mapDataSchema.parse(data); + expect(parsed[0].id).toBe('mock-id-0'); + expect(parsed[1].id).toBe('mock-id-1'); + expect(uid).toHaveBeenCalledTimes(2); + }); + + // --- Extreme Edge Cases for ID Uniqueness --- + describe('ID uniqueness validation (superRefine)', () => { + const getFirstError = (data) => { + const result = mapDataSchema.safeParse(data); + if (result.success) return null; + return result.error.issues[0].message; + }; + + it.each([ + { + case: 'duplicate IDs at the root level', + data: [ + { type: 'item', id: 'dup-id', width: 10, height: 10 }, + { type: 'item', id: 'dup-id', width: 10, height: 10 }, + ], + expectedError: 'Duplicate id: dup-id at 1', + }, + { + case: 'duplicate ID inside a nested group', + data: [ + { + type: 'group', + id: 'group-1', + children: [ + { type: 'item', id: 'nested-dup', width: 10, height: 10 }, + { type: 'item', id: 'nested-dup', width: 10, height: 10 }, + ], + }, + ], + expectedError: 'Duplicate id: nested-dup at 0.children.1', + }, + { + case: 'ID at root is duplicated in a nested group', + data: [ + { type: 'item', id: 'cross-level-dup', width: 10, height: 10 }, + { + type: 'group', + id: 'group-1', + children: [ + { type: 'item', id: 'cross-level-dup', width: 10, height: 10 }, + ], + }, + ], + expectedError: 'Duplicate id: cross-level-dup at 1.children.0', + }, + { + case: 'ID in a nested group is duplicated at the root', + data: [ + { + type: 'group', + id: 'group-1', + children: [ + { type: 'item', id: 'cross-level-dup', width: 10, height: 10 }, + ], + }, + { type: 'item', id: 'cross-level-dup', width: 10, height: 10 }, + ], + expectedError: 'Duplicate id: cross-level-dup at 1', + }, + { + case: 'duplicate IDs in deeply nested groups', + data: [ + { + type: 'group', + id: 'group-1', + children: [ + { + type: 'group', + id: 'group-2', + children: [ + { type: 'item', id: 'deep-dup', width: 10, height: 10 }, + ], + }, + { + type: 'group', + id: 'group-3', + children: [ + { type: 'item', id: 'deep-dup', width: 10, height: 10 }, + ], + }, + ], + }, + ], + expectedError: 'Duplicate id: deep-dup at 0.children.1.children.0', + }, + { + case: 'duplicate with default IDs', + data: [ + { type: 'item', id: 'mock-id-0', width: 10, height: 10 }, + { type: 'item', width: 10, height: 10 }, // This will get default id 'mock-id-0' + ], + expectedError: 'Duplicate id: mock-id-0 at 1', + }, + ])('should fail with error: $case', ({ data, expectedError }) => { + const message = getFirstError(data); + expect(message).toBe(expectedError); + }); + }); + + // --- Discriminated Union Edge Cases --- + describe('Discriminated Union validation', () => { + it('should fail if an element has an unknown type', () => { + const data = [{ type: 'rectangle', id: 'rect-1' }]; + expect(() => mapDataSchema.parse(data)).toThrow(); + }); + + it('should fail if an element has a correct type but incorrect properties', () => { + // 'grid' type requires a 'cells' property + const data = [ + { type: 'grid', id: 'grid-1', itemTemplate: { width: 1, height: 1 } }, + ]; + const result = mapDataSchema.safeParse(data); + expect(result.success).toBe(false); + expect(result.error.issues[0].message).toBe('Required'); // Zod's error for missing property + expect(result.error.issues[0].path).toEqual([0, 'cells']); + }); }); }); }); diff --git a/src/display/data-schema/primitive-schema.js b/src/display/data-schema/primitive-schema.js new file mode 100644 index 00000000..defe3dd0 --- /dev/null +++ b/src/display/data-schema/primitive-schema.js @@ -0,0 +1,102 @@ +import { z } from 'zod'; + +export const Base = z + .object({ + show: z.boolean().default(true), + id: z.string().default(() => uid()), + }) + .passthrough(); + +export const Position = z.object({ + x: z.number().default(0), + y: z.number().default(0), +}); + +export const Size = z.object({ + width: z.number().nonnegative(), + height: z.number().nonnegative(), +}); + +export const pxOrPercentSchema = z + .union([z.number().nonnegative(), z.string().regex(/^\d+(\.\d+)?%$/)]) + .transform((val) => { + return typeof val === 'number' + ? { value: val, unit: 'px' } + : { value: Number.parseFloat(val.slice(0, -1)), unit: '%' }; + }); + +export const PxOrPercentSize = 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) => { + return 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({ top: 0, right: 0, bottom: 0, left: 0 }), +); + +export const TextureStyle = z + .object({ + type: z.enum(['rect']), + fill: z.nullable(z.string()), + borderWidth: z.nullable(z.number()), + borderColor: z.nullable(z.string()), + radius: z.nullable(z.number()), + }) + .partial(); + +export const RelationsStyle = z.preprocess( + (val) => ({ color: 'black', ...val }), + z.record(z.unknown()), +); // https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html + +export const TextStyle = z.preprocess( + (val) => ({ + fontFamily: 'FiraCode', + fontWeight: 400, + fill: 'black', + ...val, + }), + z.record(z.unknown()), +); 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..c9fb81dd --- /dev/null +++ b/src/display/data-schema/primitive-schema.test.js @@ -0,0 +1,386 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + Base, + Gap, + Margin, + Placement, + Position, + PxOrPercentSize, + RelationsStyle, + Size, + TextStyle, + TextureStyle, +} from './primitive-schema'; // Assuming the schemas are in './base-schema.js' + +// --- Mocks --- + +// Mock for the global uid function used in the Base schema. +// Vitest's `vi.fn()` provides mocking capabilities. +const uid = vi.fn(() => 'mock-uid-123'); +global.uid = uid; + +// --- Test Suites --- + +describe('Base Schema Tests', () => { + // Test suite for the Base schema + describe('Base Schema', () => { + it('should parse a valid object with all properties and allow passthrough', () => { + const data = { show: false, id: 'custom-id', extra: 'passthrough-value' }; + const result = Base.parse(data); + expect(result).toEqual({ + show: false, + id: 'custom-id', + extra: 'passthrough-value', + }); + }); + + it('should apply default values for missing properties', () => { + const data = {}; + const result = Base.parse(data); + // The mock uid function should be called to generate a default id. + expect(result).toEqual({ show: true, id: 'mock-uid-123' }); + expect(uid).toHaveBeenCalled(); + }); + }); + + // Test suite for the Position schema + describe('Position Schema', () => { + it.each([ + { + case: 'standard positive integers', + input: { x: 100, y: 200 }, + expected: { x: 100, y: 200 }, + }, + { case: 'zero values', input: { x: 0, y: 0 }, expected: { x: 0, y: 0 } }, + { + case: 'negative values', + input: { x: -50, y: -150 }, + expected: { x: -50, y: -150 }, + }, + { + case: 'floating point numbers', + input: { x: 10.5, y: 20.5 }, + expected: { x: 10.5, y: 20.5 }, + }, + { + case: 'missing properties apply defaults', + input: {}, + expected: { x: 0, y: 0 }, + }, + { + case: 'one missing property', + input: { x: 55 }, + expected: { x: 55, y: 0 }, + }, + ])( + 'should correctly parse position object for $case', + ({ input, expected }) => { + // The imported Position is now a Zod schema, so we can use it directly. + expect(Position.parse(input)).toEqual(expected); + }, + ); + + it('should fail parsing with invalid types', () => { + expect(() => Position.parse({ x: '100', y: '200' })).toThrow(); + expect(() => Position.parse({ x: 100, y: null })).toThrow(); + }); + }); + + // Test suite for the Size schema + describe('Size Schema', () => { + it.each([ + { + case: 'standard positive integers', + input: { width: 100, height: 200 }, + expected: { width: 100, height: 200 }, + }, + { + case: 'zero values', + input: { width: 0, height: 0 }, + expected: { width: 0, height: 0 }, + }, + { + case: 'floating point numbers', + input: { width: 10.5, height: 20.5 }, + expected: { width: 10.5, height: 20.5 }, + }, + ])( + 'should correctly parse size object for $case', + ({ input, expected }) => { + // The imported Size is now a Zod schema. + expect(Size.parse(input)).toEqual(expected); + }, + ); + + it.each([ + { case: 'negative numbers', input: { width: -100, height: 100 } }, + { case: 'one negative number', input: { width: 100, height: -1 } }, + { case: 'invalid type (string)', input: { width: '100', height: 100 } }, + { case: 'missing property (no defaults)', input: { width: 100 } }, + ])( + 'should throw an error for invalid size object for $case', + ({ input }) => { + expect(() => Size.parse(input)).toThrow(); + }, + ); + }); + + // Test suite for the PxOrPercentSize schema + describe('PxOrPercentSize Schema', () => { + it.each([ + { + case: 'pixel values (numbers)', + input: { width: 100, height: 50 }, + expected: { + width: { value: 100, unit: 'px' }, + height: { value: 50, unit: 'px' }, + }, + }, + { + case: 'percentage values (strings)', + input: { width: '80%', height: '100%' }, + expected: { + width: { value: 80, unit: '%' }, + height: { value: 100, unit: '%' }, + }, + }, + { + case: 'mix of pixel and percentage', + input: { width: 150, height: '75%' }, + expected: { + width: { value: 150, unit: 'px' }, + height: { value: 75, unit: '%' }, + }, + }, + { + case: 'floating point values', + input: { width: 99.9, height: '33.3%' }, + expected: { + width: { value: 99.9, unit: 'px' }, + height: { value: 33.3, unit: '%' }, + }, + }, + { + case: 'zero values', + input: { width: 0, height: '0%' }, + expected: { + width: { value: 0, unit: 'px' }, + height: { value: 0, unit: '%' }, + }, + }, + ])( + 'should correctly parse and transform for $case', + ({ input, expected }) => { + // The imported PxOrPercentSize is now a Zod schema. + expect(PxOrPercentSize.parse(input)).toEqual(expected); + }, + ); + + it.each([ + { case: 'negative number', input: { width: -100, height: 50 } }, + { + case: 'malformed percentage string', + input: { width: '100', height: '50%' }, + }, + { + case: 'percentage with space', + input: { width: '50 %', height: '50%' }, + }, + { case: 'invalid unit', input: { width: '100em', height: '50%' } }, + { case: 'missing property', input: { width: 100 } }, + ])('should throw an error for invalid input for $case', ({ input }) => { + expect(() => PxOrPercentSize.parse(input)).toThrow(); + }); + }); + + // Test suite for the Placement schema + describe('Placement Schema', () => { + it.each([ + { placement: 'left' }, + { placement: 'left-top' }, + { placement: 'left-bottom' }, + { placement: 'top' }, + { placement: 'right' }, + { placement: 'right-top' }, + { placement: 'right-bottom' }, + { placement: 'bottom' }, + { placement: 'center' }, + { placement: 'none' }, + ])('should accept valid placement value: $placement', ({ placement }) => { + expect(() => Placement.parse(placement)).not.toThrow(); + }); + + it('should reject an invalid placement value', () => { + expect(() => Placement.parse('top-left')).toThrow(); // Invalid enum + }); + }); + + // Test suite for the Gap schema + describe('Gap Schema', () => { + it.each([ + { case: 'a single number', input: 20, expected: { x: 20, y: 20 } }, + { + case: 'an object with x and y', + input: { x: 10, y: 30 }, + expected: { x: 10, y: 30 }, + }, + { + case: 'an object with only x', + input: { x: 15 }, + expected: { x: 15, y: 0 }, + }, + { + case: 'an object with only y', + input: { y: 25 }, + expected: { y: 25, x: 0 }, + }, + { case: 'an 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('should throw an error for negative numbers', () => { + expect(() => Gap.parse(-10)).toThrow(); + expect(() => Gap.parse({ x: -10, y: 10 })).toThrow(); + }); + }); + + // Test suite for the Margin schema + describe('Margin Schema', () => { + it.each([ + { + case: 'a single number', + input: 15, + expected: { top: 15, right: 15, bottom: 15, left: 15 }, + }, + { + case: 'an object with x and y', + input: { x: 10, y: 20 }, + expected: { top: 20, right: 10, bottom: 20, left: 10 }, + }, + { + case: 'a full object', + input: { top: 5, right: 10, bottom: 15, left: 20 }, + expected: { top: 5, right: 10, bottom: 15, left: 20 }, + }, + { + case: 'an object with only x', + input: { x: 30 }, + expected: { top: 0, right: 30, bottom: 0, left: 30 }, + }, + { + case: 'an object with only y', + input: { y: 40 }, + expected: { top: 40, right: 0, bottom: 40, left: 0 }, + }, + { + case: 'an 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 }, + }, + ])('should correctly preprocess and parse $case', ({ input, expected }) => { + expect(Margin.parse(input)).toEqual(expected); + }); + }); + + // Test suite for the TextureStyle schema + 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 a partial object', () => { + const data = { fill: '#FFF' }; + expect(TextureStyle.parse(data)).toEqual({ fill: '#FFF' }); + }); + + it('should accept null values', () => { + const data = { + fill: null, + borderWidth: null, + borderColor: null, + radius: null, + }; + expect(TextureStyle.parse(data)).toEqual(data); + }); + + it('should fail on invalid enum for type', () => { + const data = { type: 'circle' }; + expect(() => TextureStyle.parse(data)).toThrow(); + }); + }); + + // Test suite for the RelationsStyle schema + describe('RelationsStyle Schema', () => { + it('should add default color if not provided', () => { + const data = { lineWidth: 2 }; + expect(RelationsStyle.parse(data)).toEqual({ + color: 'black', + lineWidth: 2, + }); + }); + + it('should not override provided color', () => { + const data = { color: 'blue', lineStyle: 'dashed' }; + expect(RelationsStyle.parse(data)).toEqual({ + color: 'blue', + lineStyle: 'dashed', + }); + }); + + it('should accept any other properties', () => { + const data = { customProp: true }; + expect(RelationsStyle.parse(data)).toEqual({ + color: 'black', + customProp: true, + }); + }); + }); + + // Test suite for the TextStyle schema + describe('TextStyle Schema', () => { + it('should apply default styles', () => { + const data = { fontSize: 16 }; + expect(TextStyle.parse(data)).toEqual({ + fontFamily: 'FiraCode', + fontWeight: 400, + fill: 'black', + fontSize: 16, + }); + }); + + it('should not override provided styles', () => { + const data = { fontFamily: 'Arial', fill: 'red' }; + expect(TextStyle.parse(data)).toEqual({ + fontFamily: 'Arial', + fontWeight: 400, + fill: 'red', + }); + }); + + it('should accept any other valid text properties', () => { + const data = { align: 'center', stroke: 'white', strokeThickness: 2 }; + expect(TextStyle.parse(data)).toEqual({ + fontFamily: 'FiraCode', + fontWeight: 400, + fill: 'black', + align: 'center', + stroke: 'white', + strokeThickness: 2, + }); + }); + }); +}); diff --git a/src/display/utils.js b/src/display/utils.js index e628a369..86ce2b65 100644 --- a/src/display/utils.js +++ b/src/display/utils.js @@ -1,19 +1,4 @@ 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 }); 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(); - }); -}); From 8704b6257cbbb11aa1fe5a95e110a84eda2b37e2 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 24 Jun 2025 18:04:17 +0900 Subject: [PATCH 05/66] fix data.d.ts --- src/display/data-schema/data.d.ts | 376 +++++++++++++++++++++++------- 1 file changed, 287 insertions(+), 89 deletions(-) diff --git a/src/display/data-schema/data.d.ts b/src/display/data-schema/data.d.ts index d8cf7498..4850394b 100644 --- a/src/display/data-schema/data.d.ts +++ b/src/display/data-schema/data.d.ts @@ -1,146 +1,287 @@ /** - * An interface for extended properties that can be commonly applied to all objects. + * data.d.ts + * + * This file contains TypeScript definitions generated from the Zod schemas. + * It is designed for developers to understand the data structure they need to provide. + * All properties are explicitly defined in each interface for readability, + * avoiding the need to trace `extends` clauses. */ -export interface BaseObject { - [key: string]: unknown; -} + +//================================================================================ +// 1. Top-Level Data Structure +//================================================================================ /** - * Top-level data structure + * The root of the map data, which is an array of elements. + * + * @example + * const mapData: MapData = [ + * { type: 'grid', id: 'grid1', ... }, + * { type: 'item', id: 'item1', ... }, + * { type: 'relations', id: 'rels1', ... } + * ]; */ -export type Data = Array; +export type MapData = Element[]; /** - * Group Type + * A discriminated union of all possible root-level elements. + * The `type` property is used to determine the specific element type. */ -export interface Group extends BaseObject { +export type Element = Group | Grid | Item | Relations; + +//================================================================================ +// 2. Element Types (from element-schema.js) +//================================================================================ + +/** + * Groups a collection of other elements, applying a position to all children. + * + * @example + * { + * type: 'group', + * id: 'group1', + * x: 100, + * y: 200, + * children: [ + * { type: 'item', id: 'childItem', width: 50, height: 50, components: [] } + * ] + * } + */ +export interface Group { type: 'group'; id: string; - show?: boolean; // default: true - metadata?: Record; // default: {} - - items: Array; + show?: boolean; // Default: true + x?: number; // Default: 0 + y?: number; // Default: 0 + children: Element[]; + [key: string]: unknown; // Allows other properties } /** - * Grid Type + * Creates a grid layout of items based on a template. + * + * @example + * { + * type: 'grid', + * id: 'grid1', + * cells: [[1, 1, 0], [1, 0, 1]], + * gap: 10, + * itemTemplate: { + * width: 64, + * height: 64, + * components: [ + * { type: 'background', id: 'bg-tpl', source: { fill: '#eee', radius: 4 } }, + * { type: 'icon', id: 'icon-tpl', source: 'default.svg', size: '50%' } + * ] + * } + * } */ -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: { + show?: boolean; // Default: true + x?: number; // Default: 0 + y?: number; // Default: 0 + cells: (0 | 1)[][]; + gap: Gap; + itemTemplate: { + components: Component[]; width: number; height: number; }; + [key: string]: unknown; // Allows other properties } /** - * Item Type + * A single, placeable item that contains visual components. + * + * @example + * { + * type: 'item', + * id: 'server1', + * x: 50, + * y: 50, + * width: 120, + * height: 100, + * components: [ + * { type: 'background', id: 'bg1', source: { fill: '#fff', borderColor: '#ddd', borderWidth: 1 } }, + * { type: 'text', id: 'label1', text: 'Main Server', placemnet: 'top', margin: 8 }, + * { type: 'bar', id: 'cpu-bar', source: { fill: 'lightblue' }, width: '80%', height: 8, y: 40 }, + * { type: 'icon', id: 'status-icon', source: 'ok.svg', size: 16, placemnet: 'bottom-right', margin: 4 } + * ] + * } */ -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; - }; + show?: boolean; // Default: true + x?: number; // Default: 0 + y?: number; // Default: 0 + width: number; + height: number; + components: Component[]; + [key: string]: unknown; // Allows other properties } /** - * Relations Type + * Defines visual links between elements. + * + * @example + * { + * type: 'relations', + * id: 'relations1', + * links: [ + * { source: 'server1', target: 'server2' }, + * { source: 'server1', target: 'switch1' } + * ], + * style: { color: 'rgba(0,0,255,0.5)', width: 2 } + * } */ -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; + show?: boolean; // Default: true + links: { source: string; target: string }[]; + style?: Record; // Corresponds to PIXI.ConvertedStrokeStyle + [key: string]: unknown; // Allows other properties } +//================================================================================ +// 3. Component Types (from component-schema.js) +//================================================================================ + /** - * components Type (background, bar, icon, text) + * A discriminated union of all possible visual components within an Item. */ -export type components = - | BackgroundComponent - | BarComponent - | IconComponent - | TextComponent; +export type Component = Background | Bar | Icon | Text; /** - * Background components + * A background for an item. The source can be a color/style object or an image URL. + * + * @example + * // Provide a style object + * { + * type: 'background', + * id: 'bg1', + * source: { fill: 'rgba(0,0,0,0.1)', radius: 8 } + * } + * + * @example + * // Provide an image URL + * { + * type: 'background', + * id: 'bg2', + * source: 'bg.png' + * } */ -export interface BackgroundComponent extends BaseObject { +export interface Background { type: 'background'; - show?: boolean; // default: true - texture: TextureStyle; + id: string; + show?: boolean; // Default: true + source: TextureStyle | string; + [key: string]: unknown; // Allows other properties } /** - * Bar components + * A progress bar or indicator. + * + * @example + * { + * type: 'bar', + * id: 'bar1', + * source: { fill: 'green' }, + * width: '80%', // 80% of the parent item's width + * height: 10, // 10 pixels high + * placemnet: 'bottom' + * } */ -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; + show?: boolean; // Default: true + x?: number; // Default: 0 + y?: number; // Default: 0 + width: PxOrPercent; + height: PxOrPercent; + source: TextureStyle; + placemnet?: Placement; // Default: 'bottom' + margin?: Margin; // Default: 0 + animation?: boolean; // Default: true + animationDuration?: number; // Default: 200 + [key: string]: unknown; // Allows other properties } /** - * Icon components + * An icon image. + * + * @example + * { + * type: 'icon', + * id: 'icon1', + * source: 'warning.svg', + * size: 24, // 24px + * placemnet: 'left-top', + * margin: { x: 4, y: 4 } + * } */ -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; + show?: boolean; // Default: true + x?: number; // Default: 0 + y?: number; // Default: 0 + source: string; + placemnet?: Placement; // Default: 'center' + margin?: Margin; // Default: 0 + size?: PxOrPercent; + [key: string]: unknown; // Allows other properties } /** - * Text components + * A text label. + * + * @example + * { + * type: 'text', + * id: 'text1', + * text: 'Hello World', + * placemnet: 'center', + * style: { fill: '#333', fontSize: 14, fontWeight: 'bold' } + * } */ -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; + show?: boolean; // Default: true + x?: number; // Default: 0 + y?: number; // Default: 0 + placemnet?: Placement; // Default: 'center' + margin?: Margin; // Default: 0 + text?: string; // Default: '' + style?: Record; // Corresponds to PIXI.TextStyle + split?: number; // Default: 0 + [key: string]: unknown; // Allows other properties } +//================================================================================ +// 4. Primitive & Utility Types (from primitive-schema.js) +//================================================================================ + +/** + * A value that can be specified in pixels (number) or as a percentage (string). + * + * @example + * // For a 100px width: + * width: 100 + * + * @example + * // For a 75% height: + * height: '75%' + */ +export type PxOrPercent = number | string; + /** - * String used for placement + * Defines the placement of a component within its parent item. */ export type Placement = | 'left' @@ -151,11 +292,68 @@ export type Placement = | 'right-top' | 'right-bottom' | 'bottom' - | 'center'; + | 'center' + | 'none'; -export type TextureType = 'rect'; +/** + * Defines the gap between grid cells. + * + * @example + * // To apply a 10px gap for both x and y: + * gap: 10 + * + * @example + * // To apply a 5px horizontal and 15px vertical gap: + * gap: { x: 5, y: 15 } + */ +export type Gap = + | number + | { + x?: number; // Default: 0 + y?: number; // Default: 0 + }; + +/** + * Defines margin around a component. + * + * @example + * // To apply a 10px margin to all four sides: + * margin: 10 + * + * @example + * // To apply 10px top/bottom and 5px left/right margins: + * margin: { y: 10, x: 5 } + * + * @example + * // To apply margins for each side individually: + * 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; + }; + +/** + * Defines the visual style of a rectangular texture. All properties are optional. + * + * @example + * const style: TextureStyle = { + * fill: '#ff0000', + * borderWidth: 2, + * borderColor: '#000000', + * radius: 5 + * } + */ export interface TextureStyle { - type?: TextureType; + type?: 'rect'; fill?: string | null; borderWidth?: number | null; borderColor?: string | null; From b5474895930af4ccd87ccb0352e7827afc2d1f47 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 24 Jun 2025 18:29:03 +0900 Subject: [PATCH 06/66] fix convert --- src/utils/convert.js | 47 +++++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/src/utils/convert.js b/src/utils/convert.js index de631d89..8df03854 100644 --- a/src/utils/convert.js +++ b/src/utils/convert.js @@ -21,26 +21,25 @@ export const convertLegacyData = (data) => { 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 }, + x: transform.x, + y: transform.y, angle: transform.rotation, gap: 4, itemTemplate: { - size: { - width: props.spec.width * 40, - height: props.spec.height * 40, - }, + width: props.spec.width * 40, + height: props.spec.height * 40, components: [ { type: 'background', - id: 'default', - texture: { + id: uid(), + source: { type: 'rect', fill: 'white', borderWidth: 2, @@ -50,11 +49,13 @@ export const convertLegacyData = (data) => { }, { type: 'bar', - id: 'default', - texture: { type: 'rect', fill: 'white', radius: 3 }, - tint: 'primary.default', + id: uid(), + width: '100%', + height: '100%', + source: { type: 'rect', fill: 'white', radius: 3 }, + color: 'primary.default', show: false, - margin: '3', + margin: 3, }, ], }, @@ -64,7 +65,7 @@ export const convertLegacyData = (data) => { } 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, @@ -95,17 +96,19 @@ export const convertLegacyData = (data) => { objs[key].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 }, + x: transform.x, + y: transform.y, + width: 40, + height: 40, components: [ { type: 'background', - id: 'default', - texture: { + id: uid(), + source: { type: 'rect', fill: 'white', borderWidth: 2, @@ -115,10 +118,10 @@ export const convertLegacyData = (data) => { }, { type: 'icon', - id: 'default', - asset: key === 'combines' ? 'combiner' : key.slice(0, -1), - size: 16, - tint: 'primary.default', + id: uid(), + source: key === 'combines' ? 'combiner' : key.slice(0, -1), + size: 20, + color: 'primary.default', placement: 'center', }, ], From c08c0cf5b6539ff5d6bc29dac6c64b00a7dfb572 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 24 Jun 2025 18:29:49 +0900 Subject: [PATCH 07/66] fix --- src/assets/textures/utils.js | 2 +- src/display/change/percent-size.js | 8 +++----- src/display/change/pipeline/element.js | 2 +- src/display/change/placement.js | 6 ++---- src/display/change/position.js | 4 ++-- src/display/change/text-style.js | 4 +--- src/display/elements/grid.js | 14 +++++++++----- src/display/elements/group.js | 5 +++-- src/display/elements/item.js | 13 +++++++------ src/display/elements/relations.js | 5 +++-- 10 files changed, 32 insertions(+), 31 deletions(-) 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/display/change/percent-size.js b/src/display/change/percent-size.js index 3a6e401e..3fa5ae07 100644 --- a/src/display/change/percent-size.js +++ b/src/display/change/percent-size.js @@ -1,5 +1,4 @@ import gsap from 'gsap'; -import { parseMargin } from '../utils'; import { changePlacement } from './placement'; import { isConfigMatch, killTweensOf, updateConfig } from './utils'; @@ -21,12 +20,11 @@ export const changePercentSize = ( return; } - const marginObj = parseMargin(margin); if (Number.isFinite(percentWidth)) { - changeWidth(object, percentWidth, marginObj); + changeWidth(object, percentWidth, margin); } if (Number.isFinite(percentHeight)) { - changeHeight(object, percentHeight, marginObj); + changeHeight(object, percentHeight, margin); } updateConfig(object, { percentWidth, @@ -43,7 +41,7 @@ export const changePercentSize = ( function changeHeight(component, percentHeight) { const maxHeight = - component.parent.size.height - (marginObj.top + marginObj.bottom); + component.parent.size.height - (margin.top + margin.bottom); if (object.config.animation) { animationContext.add(() => { diff --git a/src/display/change/pipeline/element.js b/src/display/change/pipeline/element.js index 41a0954d..c23c3fe0 100644 --- a/src/display/change/pipeline/element.js +++ b/src/display/change/pipeline/element.js @@ -7,7 +7,7 @@ import { createCommandHandler } from './utils'; export const elementPipeline = { ...basePipeline, position: { - keys: ['position'], + keys: ['x', 'y'], handler: createCommandHandler( Commands.PositionCommand, change.changePosition, diff --git a/src/display/change/placement.js b/src/display/change/placement.js index aec17e41..54c29bb4 100644 --- a/src/display/change/placement.js +++ b/src/display/change/placement.js @@ -1,4 +1,3 @@ -import { parseMargin } from '../utils'; import { updateConfig } from './utils'; export const changePlacement = ( @@ -14,14 +13,13 @@ export const changePlacement = ( 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); + const x = getHorizontalPosition(object, directions.h, margin); + const y = getVerticalPosition(object, directions.v, margin); object.position.set(x, y); object.visible = true; updateConfig(object, { placement, margin }); diff --git a/src/display/change/position.js b/src/display/change/position.js index d56a819a..aa1abe88 100644 --- a/src/display/change/position.js +++ b/src/display/change/position.js @@ -1,6 +1,6 @@ import { updateConfig } from './utils'; -export const changePosition = (object, { position }) => { - object.position.set(position.x, position.y); +export const changePosition = (object, { x, y }) => { + object.position.set(x, y); updateConfig(object, { position }); }; diff --git a/src/display/change/text-style.js b/src/display/change/text-style.js index f262d63a..c706f80e 100644 --- a/src/display/change/text-style.js +++ b/src/display/change/text-style.js @@ -1,6 +1,5 @@ import { getColor } from '../../utils/get'; import { FONT_WEIGHT } from '../components/config'; -import { parseMargin } from '../utils'; import { isConfigMatch, updateConfig } from './utils'; export const changeTextStyle = ( @@ -21,8 +20,7 @@ export const changeTextStyle = ( } 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); + setAutoFontSize(object, margin); } else { object.style[key] = style[key]; } diff --git a/src/display/elements/grid.js b/src/display/elements/grid.js index dfd61e3e..5b69f76b 100644 --- a/src/display/elements/grid.js +++ b/src/display/elements/grid.js @@ -1,7 +1,8 @@ import { isValidationError } from 'zod-validation-error'; import { validate } from '../../utils/validator'; +import { deepPartial } from '../../utils/zod-deep-strict-partial'; import { elementPipeline } from '../change/pipeline/element'; -import { deepGridObject } from '../data-schema/element-schema'; +import { Grid } from '../data-schema/element-schema'; import { updateObject } from '../update/update-object'; import { createContainer } from '../utils'; import { createItem } from './item'; @@ -12,12 +13,15 @@ const GRID_OBJECT_CONFIG = { export const createGrid = (config) => { const element = createContainer(config); - element.position.set(config.position.x, config.position.y); + element.position.set(config.x, config.y); element.config = { ...element.config, - position: config.position, + position: { x: config.x, y: config.y }, cells: config.cells, - itemSize: config.itemSize, + itemSize: { + width: config.itemTemplate.width, + height: config.itemTemplate.height, + }, }; addItemElements(element, config.cells, config.itemSize); return element; @@ -25,7 +29,7 @@ export const createGrid = (config) => { const pipelineKeys = ['show', 'position', 'gridComponents']; export const updateGrid = (element, changes, options) => { - const validated = validate(changes, deepGridObject); + const validated = validate(changes, deepPartial(Grid)); if (isValidationError(validated)) throw validated; updateObject(element, changes, elementPipeline, pipelineKeys, options); }; diff --git a/src/display/elements/group.js b/src/display/elements/group.js index e1e14788..a8c61533 100644 --- a/src/display/elements/group.js +++ b/src/display/elements/group.js @@ -1,7 +1,8 @@ import { isValidationError } from 'zod-validation-error'; import { validate } from '../../utils/validator'; +import { deepPartial } from '../../utils/zod-deep-strict-partial'; import { elementPipeline } from '../change/pipeline/element'; -import { deepGroupObject } from '../data-schema/element-schema'; +import { Group } from '../data-schema/element-schema'; import { updateObject } from '../update/update-object'; import { createContainer } from '../utils'; @@ -12,7 +13,7 @@ export const createGroup = (config) => { const pipelineKeys = ['show', 'position']; export const updateGroup = (element, changes, options) => { - const validated = validate(changes, deepGroupObject); + const validated = validate(changes, deepPartial(Group)); 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 index ee1dd8ae..fb2625f0 100644 --- a/src/display/elements/item.js +++ b/src/display/elements/item.js @@ -1,25 +1,26 @@ import { isValidationError } from 'zod-validation-error'; import { validate } from '../../utils/validator'; +import { deepPartial } from '../../utils/zod-deep-strict-partial'; import { elementPipeline } from '../change/pipeline/element'; -import { deepSingleObject } from '../data-schema/element-schema'; +import { Item } from '../data-schema/element-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.position.set(config.x, config.y); + element.size = { width: config.width, height: config.height }; element.config = { ...element.config, - position: config.position, - size: config.size, + position: { x: config.x, y: config.y }, + size: element.size, }; return element; }; const pipelineKeys = ['show', 'position', 'components']; export const updateItem = (element, changes, options) => { - const validated = validate(changes, deepSingleObject); + const validated = validate(changes, deepPartial(Item)); if (isValidationError(validated)) throw validated; updateObject(element, changes, elementPipeline, pipelineKeys, options); }; diff --git a/src/display/elements/relations.js b/src/display/elements/relations.js index 205ca350..ded272bd 100644 --- a/src/display/elements/relations.js +++ b/src/display/elements/relations.js @@ -1,8 +1,9 @@ import { Graphics } from 'pixi.js'; import { isValidationError } from 'zod-validation-error'; import { validate } from '../../utils/validator'; +import { deepPartial } from '../../utils/zod-deep-strict-partial'; import { elementPipeline } from '../change/pipeline/element'; -import { deepRelationGroupObject } from '../data-schema/element-schema'; +import { Relations } from '../data-schema/element-schema'; import { updateObject } from '../update/update-object'; import { createContainer } from '../utils'; @@ -15,7 +16,7 @@ export const createRelations = (config) => { const pipelineKeys = ['show', 'strokeStyle', 'links']; export const updateRelations = (element, changes, options) => { - const validated = validate(changes, deepRelationGroupObject); + const validated = validate(changes, deepPartial(Relations)); if (isValidationError(validated)) throw validated; updateObject(element, changes, elementPipeline, pipelineKeys, options); }; From bebf2493e20feca7b51603e7c5304f9fdf7e6721 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 24 Jun 2025 18:30:00 +0900 Subject: [PATCH 08/66] update schema --- src/display/data-schema/element-schema.js | 2 +- src/display/data-schema/primitive-schema.js | 10 ++++++---- src/display/data-schema/primitive-schema.test.js | 4 ++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/display/data-schema/element-schema.js b/src/display/data-schema/element-schema.js index 744f04a8..96ae3164 100644 --- a/src/display/data-schema/element-schema.js +++ b/src/display/data-schema/element-schema.js @@ -2,7 +2,7 @@ import { z } from 'zod'; import { componentArraySchema } from './component-schema'; import { Base, Gap, Position, RelationsStyle, Size } from './primitive-schema'; -export const Group = Base.merge(Position).extend({ +export const Group = Base.extend({ type: z.literal('group'), children: z.array(z.lazy(() => elementTypes)), }); diff --git a/src/display/data-schema/primitive-schema.js b/src/display/data-schema/primitive-schema.js index defe3dd0..0684b9b1 100644 --- a/src/display/data-schema/primitive-schema.js +++ b/src/display/data-schema/primitive-schema.js @@ -7,10 +7,12 @@ export const Base = z }) .passthrough(); -export const Position = z.object({ - x: z.number().default(0), - y: z.number().default(0), -}); +export const Position = z + .object({ + x: z.number().default(0), + y: z.number().default(0), + }) + .partial(); export const Size = z.object({ width: z.number().nonnegative(), diff --git a/src/display/data-schema/primitive-schema.test.js b/src/display/data-schema/primitive-schema.test.js index c9fb81dd..5c2cbbe7 100644 --- a/src/display/data-schema/primitive-schema.test.js +++ b/src/display/data-schema/primitive-schema.test.js @@ -65,12 +65,12 @@ describe('Base Schema Tests', () => { { case: 'missing properties apply defaults', input: {}, - expected: { x: 0, y: 0 }, + expected: {}, }, { case: 'one missing property', input: { x: 55 }, - expected: { x: 55, y: 0 }, + expected: { x: 55 }, }, ])( 'should correctly parse position object for $case', From ed94c28ee8e43722614e38951e9e96fe8d77eaff Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 24 Jun 2025 19:15:44 +0900 Subject: [PATCH 09/66] render --- src/display/change/asset.js | 2 +- src/display/change/percent-size.js | 30 +++++++------- src/display/change/pipeline/component.js | 8 ++-- src/display/change/pipeline/element.js | 6 +-- src/display/change/position.js | 2 +- src/display/change/size.js | 2 +- src/display/change/stroke-style.js | 14 +++---- src/display/change/texture.js | 6 +-- src/display/data-schema/component-schema.js | 41 +++++++++++-------- .../data-schema/component-schema.test.js | 10 ++--- src/display/data-schema/data.d.ts | 16 ++++---- src/display/data-schema/element-schema.js | 21 ++++++---- src/display/data-schema/primitive-schema.js | 27 +++++++----- src/display/draw.js | 2 +- src/display/elements/grid.js | 16 +++----- src/display/elements/group.js | 2 +- src/display/elements/item.js | 2 +- src/display/elements/relations.js | 2 +- src/display/update/update-components.js | 8 +--- src/display/update/update-object.js | 2 +- src/utils/convert.js | 4 +- 21 files changed, 110 insertions(+), 113 deletions(-) diff --git a/src/display/change/asset.js b/src/display/change/asset.js index 3472a384..14a71c30 100644 --- a/src/display/change/asset.js +++ b/src/display/change/asset.js @@ -2,7 +2,7 @@ import { getTexture } from '../../assets/textures/texture'; import { getViewport } from '../../utils/get'; import { isConfigMatch, updateConfig } from './utils'; -export const changeAsset = (object, { asset: assetConfig }, { theme }) => { +export const changeAsset = (object, { source: assetConfig }, { theme }) => { if (isConfigMatch(object, 'asset', assetConfig)) { return; } diff --git a/src/display/change/percent-size.js b/src/display/change/percent-size.js index 3fa5ae07..0b4e7d1d 100644 --- a/src/display/change/percent-size.js +++ b/src/display/change/percent-size.js @@ -5,41 +5,41 @@ import { isConfigMatch, killTweensOf, updateConfig } from './utils'; export const changePercentSize = ( object, { - percentWidth = object.config.percentWidth, - percentHeight = object.config.percentHeight, + width = object.config.width, + height = object.config.height, margin = object.config.margin, animationDuration = object.config.animationDuration, }, { animationContext }, ) => { if ( - isConfigMatch(object, 'percentWidth', percentWidth) && - isConfigMatch(object, 'percentHeight', percentHeight) && + isConfigMatch(object, 'width', width) && + isConfigMatch(object, 'height', height) && isConfigMatch(object, 'margin', margin) ) { return; } - if (Number.isFinite(percentWidth)) { - changeWidth(object, percentWidth, margin); + if (width.unit === '%') { + changeWidth(object, width, margin); } - if (Number.isFinite(percentHeight)) { - changeHeight(object, percentHeight, margin); + if (height.unit === '%') { + changeHeight(object, height, margin); } updateConfig(object, { - percentWidth, - percentHeight, + width, + height, margin, animationDuration, }); - function changeWidth(component, percentWidth, marginObj) { + function changeWidth(component, width, marginObj) { const maxWidth = component.parent.size.width - (marginObj.left + marginObj.right); - component.width = maxWidth * percentWidth; + component.width = maxWidth * (width.value / 100); } - function changeHeight(component, percentHeight) { + function changeHeight(component, height) { const maxHeight = component.parent.size.height - (margin.top + margin.bottom); @@ -47,14 +47,14 @@ export const changePercentSize = ( animationContext.add(() => { killTweensOf(component); gsap.to(component, { - pixi: { height: maxHeight * percentHeight }, + pixi: { height: maxHeight * (height.value / 100) }, duration: animationDuration / 1000, ease: 'power2.inOut', onUpdate: () => changePlacement(component, {}), }); }); } else { - component.height = maxHeight * percentHeight; + component.height = maxHeight * height; } } }; diff --git a/src/display/change/pipeline/component.js b/src/display/change/pipeline/component.js index 6df2ee61..d05f37ff 100644 --- a/src/display/change/pipeline/component.js +++ b/src/display/change/pipeline/component.js @@ -10,19 +10,19 @@ export const componentPipeline = { handler: createCommandHandler(Commands.TintCommand, change.changeTint), }, texture: { - keys: ['texture'], + keys: ['source'], handler: (component, config, options) => { change.changeTexture(component, config, options); }, }, asset: { - keys: ['asset'], + keys: ['source'], handler: (component, config, options) => { change.changeAsset(component, config, options); }, }, textureTransform: { - keys: ['texture'], + keys: ['source'], handler: (component) => { change.changeTextureTransform(component); }, @@ -34,7 +34,7 @@ export const componentPipeline = { }, }, percentSize: { - keys: ['percentWidth', 'percentHeight', 'margin'], + keys: ['width', 'height', 'margin'], handler: (component, config, options) => { change.changePercentSize(component, config, options); change.changePlacement(component, {}); diff --git a/src/display/change/pipeline/element.js b/src/display/change/pipeline/element.js index c23c3fe0..8ce1e697 100644 --- a/src/display/change/pipeline/element.js +++ b/src/display/change/pipeline/element.js @@ -14,10 +14,10 @@ export const elementPipeline = { ), }, gridComponents: { - keys: ['components'], + keys: ['itemTemplate'], handler: (element, config, options) => { for (const cell of element.children) { - updateComponents(cell, config, options); + updateComponents(cell, config.itemTemplate, options); } }, }, @@ -30,7 +30,7 @@ export const elementPipeline = { handler: change.changeLinks, }, strokeStyle: { - keys: ['strokeStyle'], + keys: ['style'], handler: change.changeStrokeStyle, }, }; diff --git a/src/display/change/position.js b/src/display/change/position.js index aa1abe88..c0db10ad 100644 --- a/src/display/change/position.js +++ b/src/display/change/position.js @@ -2,5 +2,5 @@ import { updateConfig } from './utils'; export const changePosition = (object, { x, y }) => { object.position.set(x, y); - updateConfig(object, { position }); + updateConfig(object, { x, y }); }; diff --git a/src/display/change/size.js b/src/display/change/size.js index da7cf193..ca6612da 100644 --- a/src/display/change/size.js +++ b/src/display/change/size.js @@ -1,6 +1,6 @@ import { updateConfig } from './utils'; export const changeSize = (object, { size = object.config.size }) => { - object.setSize(size); + object.setSize(size.value); updateConfig(object, { size }); }; diff --git a/src/display/change/stroke-style.js b/src/display/change/stroke-style.js index e3e72627..9aa1e55f 100644 --- a/src/display/change/stroke-style.js +++ b/src/display/change/stroke-style.js @@ -2,23 +2,19 @@ import { getColor } from '../../utils/get'; import { selector } from '../../utils/selector/selector'; import { updateConfig } from './utils'; -export const changeStrokeStyle = ( - object, - { strokeStyle, links }, - { theme }, -) => { +export const changeStrokeStyle = (object, { style, links }, { theme }) => { const path = selector(object, '$.children[?(@.type==="path")]')[0]; if (!path) return; - if ('color' in strokeStyle) { - strokeStyle.color = getColor(theme, strokeStyle.color); + if ('color' in style) { + style.color = getColor(theme, style.color); } - path.setStrokeStyle({ ...path.strokeStyle, ...strokeStyle }); + path.setStrokeStyle({ ...path.strokeStyle, ...style }); if (!links && path.links.length > 0) { reRenderPath(path); } - updateConfig(object, { strokeStyle }); + updateConfig(object, { style }); function reRenderPath(path) { path.clear(); diff --git a/src/display/change/texture.js b/src/display/change/texture.js index 485a57c4..ec3f16b5 100644 --- a/src/display/change/texture.js +++ b/src/display/change/texture.js @@ -3,11 +3,7 @@ import { deepMerge } from '../../utils/deepmerge/deepmerge'; import { getViewport } from '../../utils/get'; import { isConfigMatch, updateConfig } from './utils'; -export const changeTexture = ( - object, - { texture: textureConfig }, - { theme }, -) => { +export const changeTexture = (object, { source: textureConfig }, { theme }) => { if (isConfigMatch(object, 'texture', textureConfig)) { return; } diff --git a/src/display/data-schema/component-schema.js b/src/display/data-schema/component-schema.js index 8e47ccfa..ceb1d874 100644 --- a/src/display/data-schema/component-schema.js +++ b/src/display/data-schema/component-schema.js @@ -13,35 +13,40 @@ import { export const Background = Base.extend({ type: z.literal('background'), source: z.union([TextureStyle, z.string()]), -}); +}).passthrough(); export const Bar = Base.merge(Position) .merge(PxOrPercentSize) .extend({ type: z.literal('bar'), source: TextureStyle, - placemnet: Placement.default('bottom'), + placement: Placement.default('bottom'), margin: Margin.default(0), animation: z.boolean().default(true), animationDuration: z.number().default(200), - }); + }) + .passthrough(); -export const Icon = Base.merge(Position).extend({ - type: z.literal('icon'), - source: z.string(), - placemnet: Placement.default('center'), - margin: Margin.default(0), - size: pxOrPercentSchema, -}); +export const Icon = Base.merge(Position) + .extend({ + type: z.literal('icon'), + source: z.string(), + placement: Placement.default('center'), + margin: Margin.default(0), + size: pxOrPercentSchema, + }) + .passthrough(); -export const Text = Base.merge(Position).extend({ - type: z.literal('text'), - placemnet: Placement.default('center'), - margin: Margin.default(0), - text: z.string().default(''), - style: TextStyle, - split: z.number().int().default(0), -}); +export const Text = Base.merge(Position) + .extend({ + type: z.literal('text'), + placement: Placement.default('center'), + margin: Margin.default(0), + text: z.string().default(''), + style: TextStyle, + split: z.number().int().default(0), + }) + .passthrough(); export const componentSchema = z.discriminatedUnion('type', [ Background, diff --git a/src/display/data-schema/component-schema.test.js b/src/display/data-schema/component-schema.test.js index a88ff20e..395c226c 100644 --- a/src/display/data-schema/component-schema.test.js +++ b/src/display/data-schema/component-schema.test.js @@ -64,7 +64,7 @@ describe('Component Schema Tests (Final Version)', () => { it('should parse a valid bar and apply all defaults', () => { const parsed = Bar.parse(baseBar); - expect(parsed.placemnet).toBe('bottom'); + expect(parsed.placement).toBe('bottom'); // Margin preprocesses to a full object expect(parsed.margin).toEqual({ top: 0, right: 0, bottom: 0, left: 0 }); expect(parsed.animation).toBe(true); @@ -77,12 +77,12 @@ describe('Component Schema Tests (Final Version)', () => { it('should correctly override default values', () => { const data = { ...baseBar, - placemnet: 'top', + placement: 'top', margin: { x: 10, y: 20 }, // Use object syntax for margin animation: false, }; const parsed = Bar.parse(data); - expect(parsed.placemnet).toBe('top'); + expect(parsed.placement).toBe('top'); expect(parsed.margin).toEqual({ top: 20, right: 10, @@ -114,7 +114,7 @@ describe('Component Schema Tests (Final Version)', () => { const parsed = Icon.parse(data); expect(parsed.size).toEqual(expected); // Check if defaults are applied - expect(parsed.placemnet).toBe('center'); + expect(parsed.placement).toBe('center'); }); // Edge cases for the `size` property (which uses pxOrPercentSchema) @@ -146,7 +146,7 @@ describe('Component Schema Tests (Final Version)', () => { expect(parsed.style.fill).toBe('black'); expect(parsed.style.fontWeight).toBe(400); expect(parsed.split).toBe(0); - expect(parsed.placemnet).toBe('center'); + expect(parsed.placement).toBe('center'); }); it('should correctly merge provided styles with defaults', () => { diff --git a/src/display/data-schema/data.d.ts b/src/display/data-schema/data.d.ts index 4850394b..8ce306b9 100644 --- a/src/display/data-schema/data.d.ts +++ b/src/display/data-schema/data.d.ts @@ -105,9 +105,9 @@ export interface Grid { * height: 100, * components: [ * { type: 'background', id: 'bg1', source: { fill: '#fff', borderColor: '#ddd', borderWidth: 1 } }, - * { type: 'text', id: 'label1', text: 'Main Server', placemnet: 'top', margin: 8 }, + * { type: 'text', id: 'label1', text: 'Main Server', placement: 'top', margin: 8 }, * { type: 'bar', id: 'cpu-bar', source: { fill: 'lightblue' }, width: '80%', height: 8, y: 40 }, - * { type: 'icon', id: 'status-icon', source: 'ok.svg', size: 16, placemnet: 'bottom-right', margin: 4 } + * { type: 'icon', id: 'status-icon', source: 'ok.svg', size: 16, placement: 'bottom-right', margin: 4 } * ] * } */ @@ -192,7 +192,7 @@ export interface Background { * source: { fill: 'green' }, * width: '80%', // 80% of the parent item's width * height: 10, // 10 pixels high - * placemnet: 'bottom' + * placement: 'bottom' * } */ export interface Bar { @@ -204,7 +204,7 @@ export interface Bar { width: PxOrPercent; height: PxOrPercent; source: TextureStyle; - placemnet?: Placement; // Default: 'bottom' + placement?: Placement; // Default: 'bottom' margin?: Margin; // Default: 0 animation?: boolean; // Default: true animationDuration?: number; // Default: 200 @@ -220,7 +220,7 @@ export interface Bar { * id: 'icon1', * source: 'warning.svg', * size: 24, // 24px - * placemnet: 'left-top', + * placement: 'left-top', * margin: { x: 4, y: 4 } * } */ @@ -231,7 +231,7 @@ export interface Icon { x?: number; // Default: 0 y?: number; // Default: 0 source: string; - placemnet?: Placement; // Default: 'center' + placement?: Placement; // Default: 'center' margin?: Margin; // Default: 0 size?: PxOrPercent; [key: string]: unknown; // Allows other properties @@ -245,7 +245,7 @@ export interface Icon { * type: 'text', * id: 'text1', * text: 'Hello World', - * placemnet: 'center', + * placement: 'center', * style: { fill: '#333', fontSize: 14, fontWeight: 'bold' } * } */ @@ -255,7 +255,7 @@ export interface Text { show?: boolean; // Default: true x?: number; // Default: 0 y?: number; // Default: 0 - placemnet?: Placement; // Default: 'center' + placement?: Placement; // Default: 'center' margin?: Margin; // Default: 0 text?: string; // Default: '' style?: Record; // Corresponds to PIXI.TextStyle diff --git a/src/display/data-schema/element-schema.js b/src/display/data-schema/element-schema.js index 96ae3164..c56dc201 100644 --- a/src/display/data-schema/element-schema.js +++ b/src/display/data-schema/element-schema.js @@ -5,27 +5,30 @@ import { Base, Gap, Position, RelationsStyle, Size } from './primitive-schema'; export const Group = Base.extend({ type: z.literal('group'), children: z.array(z.lazy(() => elementTypes)), -}); +}).passthrough(); -export const Grid = Base.merge(Position).extend({ - type: z.literal('grid'), - cells: z.array(z.array(z.union([z.literal(0), z.literal(1)]))), - gap: Gap, - itemTemplate: z.object({ components: componentArraySchema }).merge(Size), -}); +export const Grid = Base.merge(Position) + .extend({ + type: z.literal('grid'), + cells: z.array(z.array(z.union([z.literal(0), z.literal(1)]))), + gap: Gap, + itemTemplate: z.object({ components: componentArraySchema }).merge(Size), + }) + .passthrough(); export const Item = Base.merge(Position) .merge(Size) .extend({ type: z.literal('item'), components: componentArraySchema, - }); + }) + .passthrough(); export const Relations = Base.extend({ type: z.literal('relations'), links: z.array(z.object({ source: z.string(), target: z.string() })), style: RelationsStyle, -}); +}).passthrough(); const elementTypes = z.discriminatedUnion('type', [ Group, diff --git a/src/display/data-schema/primitive-schema.js b/src/display/data-schema/primitive-schema.js index 0684b9b1..78c07173 100644 --- a/src/display/data-schema/primitive-schema.js +++ b/src/display/data-schema/primitive-schema.js @@ -1,11 +1,10 @@ import { z } from 'zod'; +import { uid } from '../../utils/uuid'; -export const Base = z - .object({ - show: z.boolean().default(true), - id: z.string().default(() => uid()), - }) - .passthrough(); +export const Base = z.object({ + show: z.boolean().default(true), + id: z.string().default(() => uid()), +}); export const Position = z .object({ @@ -20,11 +19,19 @@ export const Size = z.object({ }); export const pxOrPercentSchema = z - .union([z.number().nonnegative(), z.string().regex(/^\d+(\.\d+)?%$/)]) + .union([ + z.number().nonnegative(), + z.string().regex(/^\d+(\.\d+)?%$/), + z.object({ value: z.number().nonnegative(), unit: z.enum(['px', '%']) }), + ]) .transform((val) => { - return typeof val === 'number' - ? { value: val, unit: 'px' } - : { value: Number.parseFloat(val.slice(0, -1)), unit: '%' }; + if (typeof val === 'number') { + return { value: val, unit: 'px' }; + } + if (typeof val === 'string') { + return { value: Number.parseFloat(val.slice(0, -1)), unit: '%' }; + } + return val; }); export const PxOrPercentSize = z.object({ diff --git a/src/display/draw.js b/src/display/draw.js index 599a58c5..35094472 100644 --- a/src/display/draw.js +++ b/src/display/draw.js @@ -26,7 +26,7 @@ export const draw = (context, data) => { parent.addChild(element); if (config.type === 'group') { - render(element, config.items); + render(element, config.children); } } } diff --git a/src/display/elements/grid.js b/src/display/elements/grid.js index 5b69f76b..abbe9834 100644 --- a/src/display/elements/grid.js +++ b/src/display/elements/grid.js @@ -23,7 +23,7 @@ export const createGrid = (config) => { height: config.itemTemplate.height, }, }; - addItemElements(element, config.cells, config.itemSize); + addItemElements(element, config.cells, element.config.itemSize); return element; }; @@ -31,7 +31,7 @@ const pipelineKeys = ['show', 'position', 'gridComponents']; export const updateGrid = (element, changes, options) => { const validated = validate(changes, deepPartial(Grid)); if (isValidationError(validated)) throw validated; - updateObject(element, changes, elementPipeline, pipelineKeys, options); + updateObject(element, validated, elementPipeline, pipelineKeys, options); }; const addItemElements = (container, cells, cellSize) => { @@ -44,14 +44,10 @@ const addItemElements = (container, cells, cellSize) => { 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, - }, + x: colIndex * (cellSize.width + GRID_OBJECT_CONFIG.margin), + y: rowIndex * (cellSize.height + GRID_OBJECT_CONFIG.margin), + width: cellSize.width, + height: cellSize.height, metadata: { index: colIndex + row.length * rowIndex, }, diff --git a/src/display/elements/group.js b/src/display/elements/group.js index a8c61533..ef7f695e 100644 --- a/src/display/elements/group.js +++ b/src/display/elements/group.js @@ -15,5 +15,5 @@ const pipelineKeys = ['show', 'position']; export const updateGroup = (element, changes, options) => { const validated = validate(changes, deepPartial(Group)); if (isValidationError(validated)) throw validated; - updateObject(element, changes, elementPipeline, pipelineKeys, options); + updateObject(element, validated, elementPipeline, pipelineKeys, options); }; diff --git a/src/display/elements/item.js b/src/display/elements/item.js index fb2625f0..b6a70af7 100644 --- a/src/display/elements/item.js +++ b/src/display/elements/item.js @@ -22,5 +22,5 @@ const pipelineKeys = ['show', 'position', 'components']; export const updateItem = (element, changes, options) => { const validated = validate(changes, deepPartial(Item)); if (isValidationError(validated)) throw validated; - updateObject(element, changes, elementPipeline, pipelineKeys, options); + updateObject(element, validated, elementPipeline, pipelineKeys, options); }; diff --git a/src/display/elements/relations.js b/src/display/elements/relations.js index ded272bd..9cf5ca69 100644 --- a/src/display/elements/relations.js +++ b/src/display/elements/relations.js @@ -18,7 +18,7 @@ const pipelineKeys = ['show', 'strokeStyle', 'links']; export const updateRelations = (element, changes, options) => { const validated = validate(changes, deepPartial(Relations)); if (isValidationError(validated)) throw validated; - updateObject(element, changes, elementPipeline, pipelineKeys, options); + updateObject(element, validated, elementPipeline, pipelineKeys, options); }; const createPath = () => { diff --git a/src/display/update/update-components.js b/src/display/update/update-components.js index 7c7f0fce..6c9a938a 100644 --- a/src/display/update/update-components.js +++ b/src/display/update/update-components.js @@ -1,6 +1,4 @@ -import { isValidationError } from 'zod-validation-error'; import { findIndexByPriority } from '../../utils/findIndexByPriority'; -import { validate } from '../../utils/validator'; import { backgroundComponent, updateBackgroundComponent, @@ -8,7 +6,6 @@ import { 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: { @@ -37,7 +34,7 @@ export const updateComponents = ( if (!componentConfig) return; const itemComponents = [...item.children]; - for (let config of componentConfig) { + for (const config of componentConfig) { const idx = findIndexByPriority(itemComponents, config); let component = null; @@ -45,9 +42,6 @@ export const updateComponents = ( 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); diff --git a/src/display/update/update-object.js b/src/display/update/update-object.js index 40f3470a..e71050f2 100644 --- a/src/display/update/update-object.js +++ b/src/display/update/update-object.js @@ -1,6 +1,6 @@ import { changeProperty } from '../change'; -const DEFAULT_EXCEPTION_KEYS = new Set(['position']); +const DEFAULT_EXCEPTION_KEYS = new Set(['position', 'children']); export const updateObject = ( object, diff --git a/src/utils/convert.js b/src/utils/convert.js index 8df03854..a5555a4f 100644 --- a/src/utils/convert.js +++ b/src/utils/convert.js @@ -113,14 +113,14 @@ export const convertLegacyData = (data) => { fill: 'white', borderWidth: 2, borderColor: 'primary.default', - radius: 4, + radius: 6, }, }, { type: 'icon', id: uid(), source: key === 'combines' ? 'combiner' : key.slice(0, -1), - size: 20, + size: 24, color: 'primary.default', placement: 'center', }, From d4483f10f7a0aceb7374c69dbd1ff83981f8704c Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 26 Jun 2025 11:08:42 +0900 Subject: [PATCH 10/66] fix schema --- package-lock.json | 8 +-- package.json | 2 +- src/display/change/pipeline/element.js | 4 +- src/display/data-schema/component-schema.js | 56 +++++++++------------ src/display/data-schema/element-schema.js | 31 +++++------- src/display/data-schema/primitive-schema.js | 33 ++++++------ src/display/elements/grid.js | 4 +- src/utils/convert.js | 2 +- 8 files changed, 60 insertions(+), 80 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5b146325..e87e927c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "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": { @@ -5589,9 +5589,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..3bc58d8f 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "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": { diff --git a/src/display/change/pipeline/element.js b/src/display/change/pipeline/element.js index 8ce1e697..bd8a59c6 100644 --- a/src/display/change/pipeline/element.js +++ b/src/display/change/pipeline/element.js @@ -14,10 +14,10 @@ export const elementPipeline = { ), }, gridComponents: { - keys: ['itemTemplate'], + keys: ['item'], handler: (element, config, options) => { for (const cell of element.children) { - updateComponents(cell, config.itemTemplate, options); + updateComponents(cell, config.item, options); } }, }, diff --git a/src/display/data-schema/component-schema.js b/src/display/data-schema/component-schema.js index ceb1d874..def0bec4 100644 --- a/src/display/data-schema/component-schema.js +++ b/src/display/data-schema/component-schema.js @@ -3,50 +3,40 @@ import { Base, Margin, Placement, - Position, PxOrPercentSize, TextStyle, TextureStyle, - pxOrPercentSchema, } from './primitive-schema'; export const Background = Base.extend({ type: z.literal('background'), source: z.union([TextureStyle, z.string()]), -}).passthrough(); +}); -export const Bar = Base.merge(Position) - .merge(PxOrPercentSize) - .extend({ - type: z.literal('bar'), - source: TextureStyle, - placement: Placement.default('bottom'), - margin: Margin.default(0), - animation: z.boolean().default(true), - animationDuration: z.number().default(200), - }) - .passthrough(); +export const Bar = Base.merge(PxOrPercentSize).extend({ + type: z.literal('bar'), + source: TextureStyle, + placement: Placement.default('bottom'), + margin: Margin.default(0), + animation: z.boolean().default(true), + animationDuration: z.number().default(200), +}); -export const Icon = Base.merge(Position) - .extend({ - type: z.literal('icon'), - source: z.string(), - placement: Placement.default('center'), - margin: Margin.default(0), - size: pxOrPercentSchema, - }) - .passthrough(); +export const Icon = Base.merge(PxOrPercentSize).extend({ + type: z.literal('icon'), + source: z.string(), + placement: Placement.default('center'), + margin: Margin.default(0), +}); -export const Text = Base.merge(Position) - .extend({ - type: z.literal('text'), - placement: Placement.default('center'), - margin: Margin.default(0), - text: z.string().default(''), - style: TextStyle, - split: z.number().int().default(0), - }) - .passthrough(); +export const Text = Base.extend({ + type: z.literal('text'), + placement: Placement.default('center'), + margin: Margin.default(0), + text: z.string().default(''), + style: TextStyle, + split: z.number().int().default(0), +}); export const componentSchema = z.discriminatedUnion('type', [ Background, diff --git a/src/display/data-schema/element-schema.js b/src/display/data-schema/element-schema.js index c56dc201..ffc83e6b 100644 --- a/src/display/data-schema/element-schema.js +++ b/src/display/data-schema/element-schema.js @@ -1,34 +1,29 @@ import { z } from 'zod'; import { componentArraySchema } from './component-schema'; -import { Base, Gap, Position, RelationsStyle, Size } from './primitive-schema'; +import { Base, Gap, RelationsStyle, Size } from './primitive-schema'; export const Group = Base.extend({ type: z.literal('group'), children: z.array(z.lazy(() => elementTypes)), -}).passthrough(); +}); -export const Grid = Base.merge(Position) - .extend({ - type: z.literal('grid'), - cells: z.array(z.array(z.union([z.literal(0), z.literal(1)]))), - gap: Gap, - itemTemplate: z.object({ components: componentArraySchema }).merge(Size), - }) - .passthrough(); +export const Grid = 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 }).merge(Size), +}); -export const Item = Base.merge(Position) - .merge(Size) - .extend({ - type: z.literal('item'), - components: componentArraySchema, - }) - .passthrough(); +export const Item = Base.merge(Size).extend({ + type: z.literal('item'), + components: componentArraySchema, +}); export const Relations = Base.extend({ type: z.literal('relations'), links: z.array(z.object({ source: z.string(), target: z.string() })), style: RelationsStyle, -}).passthrough(); +}); const elementTypes = z.discriminatedUnion('type', [ Group, diff --git a/src/display/data-schema/primitive-schema.js b/src/display/data-schema/primitive-schema.js index 78c07173..ec1d4b0c 100644 --- a/src/display/data-schema/primitive-schema.js +++ b/src/display/data-schema/primitive-schema.js @@ -1,17 +1,13 @@ import { z } from 'zod'; import { uid } from '../../utils/uuid'; -export const Base = z.object({ - show: z.boolean().default(true), - id: z.string().default(() => uid()), -}); - -export const Position = z +export const Base = z .object({ - x: z.number().default(0), - y: z.number().default(0), + show: z.boolean().default(true), + id: z.string().default(() => uid()), + attrs: z.record(z.string(), z.unknown()).optional(), }) - .partial(); + .strict(); export const Size = z.object({ width: z.number().nonnegative(), @@ -35,8 +31,9 @@ export const pxOrPercentSchema = z }); export const PxOrPercentSize = z.object({ - width: pxOrPercentSchema, - height: pxOrPercentSchema, + width: pxOrPercentSchema.optional(), + height: pxOrPercentSchema.optional(), + size: pxOrPercentSchema.optional(), }); export const Placement = z.enum([ @@ -53,9 +50,7 @@ export const Placement = z.enum([ ]); export const Gap = z.preprocess( - (val) => { - return typeof val === 'number' ? { x: val, y: val } : val; - }, + (val) => (typeof val === 'number' ? { x: val, y: val } : val), z .object({ x: z.number().nonnegative().default(0), @@ -82,7 +77,7 @@ export const Margin = z.preprocess( bottom: z.number().default(0), left: z.number().default(0), }) - .default({ top: 0, right: 0, bottom: 0, left: 0 }), + .default({}), ); export const TextureStyle = z @@ -96,8 +91,8 @@ export const TextureStyle = z .partial(); export const RelationsStyle = z.preprocess( - (val) => ({ color: 'black', ...val }), - z.record(z.unknown()), + (val) => ({ color: 'black', ...(val ?? {}) }), + z.record(z.string(), z.unknown()), ); // https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html export const TextStyle = z.preprocess( @@ -105,7 +100,7 @@ export const TextStyle = z.preprocess( fontFamily: 'FiraCode', fontWeight: 400, fill: 'black', - ...val, + ...(val ?? {}), }), - z.record(z.unknown()), + z.record(z.string(), z.unknown()), ); diff --git a/src/display/elements/grid.js b/src/display/elements/grid.js index abbe9834..6b75ad9a 100644 --- a/src/display/elements/grid.js +++ b/src/display/elements/grid.js @@ -19,8 +19,8 @@ export const createGrid = (config) => { position: { x: config.x, y: config.y }, cells: config.cells, itemSize: { - width: config.itemTemplate.width, - height: config.itemTemplate.height, + width: config.item.width, + height: config.item.height, }, }; addItemElements(element, config.cells, element.config.itemSize); diff --git a/src/utils/convert.js b/src/utils/convert.js index a5555a4f..7b65ac09 100644 --- a/src/utils/convert.js +++ b/src/utils/convert.js @@ -32,7 +32,7 @@ export const convertLegacyData = (data) => { y: transform.y, angle: transform.rotation, gap: 4, - itemTemplate: { + item: { width: props.spec.width * 40, height: props.spec.height * 40, components: [ From b00af2d2f0346542900b69091a2e9e4e001fef33 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 26 Jun 2025 12:33:39 +0900 Subject: [PATCH 11/66] fix schema test --- src/display/data-schema/component-schema.js | 36 +- .../data-schema/component-schema.test.js | 195 ++++----- src/display/data-schema/data.d.ts | 230 +++++----- src/display/data-schema/element-schema.js | 16 +- .../data-schema/element-schema.test.js | 392 ++++++++---------- src/display/data-schema/primitive-schema.js | 24 +- .../data-schema/primitive-schema.test.js | 386 ++++++++--------- 7 files changed, 618 insertions(+), 661 deletions(-) diff --git a/src/display/data-schema/component-schema.js b/src/display/data-schema/component-schema.js index def0bec4..747265e3 100644 --- a/src/display/data-schema/component-schema.js +++ b/src/display/data-schema/component-schema.js @@ -11,23 +11,27 @@ import { export const Background = Base.extend({ type: z.literal('background'), source: z.union([TextureStyle, z.string()]), -}); +}).strict(); -export const Bar = Base.merge(PxOrPercentSize).extend({ - type: z.literal('bar'), - source: TextureStyle, - placement: Placement.default('bottom'), - margin: Margin.default(0), - animation: z.boolean().default(true), - animationDuration: z.number().default(200), -}); +export const Bar = Base.merge(PxOrPercentSize) + .extend({ + type: z.literal('bar'), + source: TextureStyle, + placement: Placement.default('bottom'), + margin: Margin.default(0), + animation: z.boolean().default(true), + animationDuration: z.number().default(200), + }) + .strict(); -export const Icon = Base.merge(PxOrPercentSize).extend({ - type: z.literal('icon'), - source: z.string(), - placement: Placement.default('center'), - margin: Margin.default(0), -}); +export const Icon = Base.merge(PxOrPercentSize) + .extend({ + type: z.literal('icon'), + source: z.string(), + placement: Placement.default('center'), + margin: Margin.default(0), + }) + .strict(); export const Text = Base.extend({ type: z.literal('text'), @@ -36,7 +40,7 @@ export const Text = Base.extend({ text: z.string().default(''), style: TextStyle, split: z.number().int().default(0), -}); +}).strict(); export const componentSchema = z.discriminatedUnion('type', [ Background, diff --git a/src/display/data-schema/component-schema.test.js b/src/display/data-schema/component-schema.test.js index 395c226c..26d0bf21 100644 --- a/src/display/data-schema/component-schema.test.js +++ b/src/display/data-schema/component-schema.test.js @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; - -// We import the actual schemas from the provided files for integration testing. +import { uid } from '../../utils/uuid'; import { Background, Bar, @@ -10,138 +9,136 @@ import { componentSchema, } from './component-schema.js'; -// --- Global Setup --- - // Mocking a unique ID generator for predictable test outcomes. -// This is used by the `Base` schema in `primitive-schema.js`. -let idCounter = 0; -const uid = vi.fn(() => `mock-id-${idCounter++}`); -global.uid = uid; +vi.mock('../../utils/uuid', () => ({ + uid: vi.fn(), +})); beforeEach(() => { - // Reset counter before each test to ensure test isolation. - idCounter = 0; - uid.mockClear(); + vi.mocked(uid).mockClear(); + vi.mocked(uid).mockReturnValue('mock-id-0'); }); -// --- Test Suites --- - -describe('Component Schema Tests (Final Version)', () => { - // --- Test each component schema individually --- - +describe('Component Schemas', () => { describe('Background Schema', () => { - it.each([ - { - case: 'with a string source', - source: 'image.png', - expected: 'image.png', - }, - { - case: 'with a TextureStyle object source', + it('should parse with a string source', () => { + const data = { type: 'background', source: 'image.png' }; + const parsed = Background.parse(data); + expect(parsed.source).toBe('image.png'); + expect(parsed.id).toBe('mock-id-0'); // check default from Base + }); + + it('should parse with a TextureStyle object source', () => { + const data = { + type: 'background', source: { type: 'rect', fill: 'red' }, - expected: { type: 'rect', fill: 'red' }, - }, - ])('should parse a valid background $case', ({ source, expected }) => { - const data = { type: 'background', source }; + }; const parsed = Background.parse(data); - expect(parsed.source).toEqual(expected); + expect(parsed.source).toEqual({ type: 'rect', fill: 'red' }); }); it('should fail with an invalid source type', () => { - const data = { type: 'background', source: 123 }; // Source must be string or TextureStyle + const data = { type: 'background', source: 123 }; + expect(() => Background.parse(data)).toThrow(); + }); + + it('should fail if an unknown property is provided', () => { + const data = { + type: 'background', + source: 'image.png', + unknown: 'property', + }; expect(() => Background.parse(data)).toThrow(); }); }); describe('Bar Schema', () => { - // Base valid data for Bar tests - const baseBar = { - type: 'bar', - width: 100, - height: 20, - source: { fill: 'blue' }, - }; - - it('should parse a valid bar and apply all defaults', () => { + const baseBar = { type: 'bar', source: { type: 'rect', fill: 'blue' } }; + + it('should parse a minimal valid bar and apply all defaults', () => { const parsed = Bar.parse(baseBar); expect(parsed.placement).toBe('bottom'); - // Margin preprocesses to a full object expect(parsed.margin).toEqual({ top: 0, right: 0, bottom: 0, left: 0 }); expect(parsed.animation).toBe(true); expect(parsed.animationDuration).toBe(200); - // Check if PxOrPercentSize transformation worked - expect(parsed.width.unit).toBe('px'); - expect(parsed.height.value).toBe(20); + expect(parsed.width).toBeUndefined(); + expect(parsed.height).toBeUndefined(); }); - it('should correctly override default values', () => { + it('should correctly parse all properties and override defaults', () => { const data = { ...baseBar, + width: '50%', + height: 20, placement: 'top', - margin: { x: 10, y: 20 }, // Use object syntax for margin + margin: { x: 10, y: -20 }, // Negative margin is allowed animation: false, + animationDuration: 1000, }; const parsed = Bar.parse(data); expect(parsed.placement).toBe('top'); expect(parsed.margin).toEqual({ - top: 20, + top: -20, right: 10, - bottom: 20, + bottom: -20, left: 10, }); expect(parsed.animation).toBe(false); + expect(parsed.animationDuration).toBe(1000); + // Check if PxOrPercentSize transformation worked + expect(parsed.width).toEqual({ value: 50, unit: '%' }); + expect(parsed.height).toEqual({ value: 20, unit: 'px' }); }); - it('should fail if required properties from merged schemas (PxOrPercentSize) are missing', () => { - const { width, ...rest } = baseBar; // Missing width - expect(() => Bar.parse(rest)).toThrow(); + it('should fail if source is missing or has invalid type', () => { + const { source, ...rest } = baseBar; + expect(() => Bar.parse(rest)).toThrow(); // Missing source + expect(() => Bar.parse({ ...baseBar, source: 'a-string' })).toThrow(); // Invalid source type + }); + + it('should fail if an unknown property is provided', () => { + const data = { ...baseBar, width: 100, another: 'property' }; + expect(() => Bar.parse(data)).toThrow(); }); }); describe('Icon Schema', () => { const baseIcon = { type: 'icon', source: 'icon.svg' }; - it.each([ - { case: 'a number size', size: 50, expected: { value: 50, unit: 'px' } }, - { - case: 'a percentage string size', - size: '75%', - expected: { value: 75, unit: '%' }, - }, - { case: 'a zero size', size: 0, expected: { value: 0, unit: 'px' } }, - ])('should parse a valid icon with $case', ({ size, expected }) => { - const data = { ...baseIcon, size }; - const parsed = Icon.parse(data); - expect(parsed.size).toEqual(expected); - // Check if defaults are applied + it('should parse a minimal valid icon and apply defaults', () => { + const parsed = Icon.parse(baseIcon); + expect(parsed.source).toBe('icon.svg'); expect(parsed.placement).toBe('center'); + expect(parsed.margin).toEqual({ top: 0, right: 0, bottom: 0, left: 0 }); + expect(parsed.size).toBeUndefined(); }); - // Edge cases for the `size` property (which uses pxOrPercentSchema) - it.each([ - { case: 'a negative number', size: -10 }, - { case: 'a malformed percentage string', size: '50 percent' }, - { case: 'an invalid type like an object', size: { value: 50 } }, - { case: 'null or undefined', size: undefined }, - ])('should fail for an invalid size like $case', ({ size }) => { - const data = { ...baseIcon, size }; - // `size: undefined` will fail because the property is required - expect(() => Icon.parse(data)).toThrow(); + it('should parse correctly with size properties', () => { + const data = { ...baseIcon, size: '75%' }; + const parsed = Icon.parse(data); + expect(parsed.size).toEqual({ value: 75, unit: '%' }); }); it('should fail if required `source` is missing', () => { const data = { type: 'icon', size: 50 }; expect(() => Icon.parse(data)).toThrow(); }); + + it('should fail if `source` is not a string', () => { + const data = { type: 'icon', source: {} }; + expect(() => Icon.parse(data)).toThrow(); + }); + + it('should fail if an unknown property is provided', () => { + const data = { ...baseIcon, extra: 'property' }; + expect(() => Icon.parse(data)).toThrow(); + }); }); describe('Text Schema', () => { - const baseText = { type: 'text' }; - it('should parse valid text and apply all defaults', () => { - const parsed = Text.parse(baseText); + const parsed = Text.parse({ type: 'text' }); expect(parsed.text).toBe(''); - // Check for default style properties from TextStyle's preprocess expect(parsed.style.fontFamily).toBe('FiraCode'); expect(parsed.style.fill).toBe('black'); expect(parsed.style.fontWeight).toBe(400); @@ -151,26 +148,31 @@ describe('Component Schema Tests (Final Version)', () => { it('should correctly merge provided styles with defaults', () => { const data = { - ...baseText, - style: { fill: 'red', fontSize: 24, customProp: true }, + type: 'text', + style: { fill: 'red', fontSize: 24 }, }; const parsed = Text.parse(data); expect(parsed.style.fill).toBe('red'); // Overridden expect(parsed.style.fontSize).toBe(24); // Added expect(parsed.style.fontFamily).toBe('FiraCode'); // Default maintained - expect(parsed.style.customProp).toBe(true); // Passthrough via z.record(z.unknown()) + }); + + it('should fail if `split` is not an integer', () => { + const data = { type: 'text', split: 1.5 }; + expect(() => Text.parse(data)).toThrow(); + }); + + it('should fail if an unknown property is provided', () => { + const data = { type: 'text', text: 'hello', somethingElse: 'test' }; + expect(() => Text.parse(data)).toThrow(); }); }); - // --- Test the discriminated union and array schemas --- describe('componentSchema (Discriminated Union)', () => { it.each([ - { case: 'a valid background', data: { type: 'background', source: '' } }, - { - case: 'a valid bar', - data: { type: 'bar', width: 1, height: 1, source: {} }, - }, - { case: 'a valid icon', data: { type: 'icon', size: 1, source: '' } }, + { case: 'a valid background', data: { type: 'background', source: 'a' } }, + { case: 'a valid bar', data: { type: 'bar', source: {} } }, + { case: 'a valid icon', data: { type: 'icon', source: 'a' } }, { case: 'a valid text', data: { type: 'text' } }, ])('should correctly parse $case', ({ data }) => { expect(() => componentSchema.parse(data)).not.toThrow(); @@ -180,43 +182,42 @@ describe('Component Schema Tests (Final Version)', () => { const data = { type: 'chart', value: 100 }; const result = componentSchema.safeParse(data); expect(result.success).toBe(false); - // Check for the specific discriminated union error expect(result.error.issues[0].code).toBe('invalid_union_discriminator'); }); it('should fail for a known type with missing required properties', () => { - // 'icon' requires 'source' and 'size' - const data = { type: 'icon', source: 'image.png' }; + // 'icon' requires 'source' + const data = { type: 'icon' }; const result = componentSchema.safeParse(data); expect(result.success).toBe(false); - // The error should point to the missing 'size' field - expect(result.error.issues[0].path).toEqual(['size']); + expect(result.error.issues[0].path).toEqual(['source']); }); }); describe('componentArraySchema', () => { - it('should parse a valid array of mixed, well-formed components', () => { + 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: 'bar', source: { fill: 'green' }, width: 100 }, ]; expect(() => componentArraySchema.parse(data)).not.toThrow(); }); - it('should correctly parse an empty array', () => { + it('should parse an empty array', () => { expect(() => componentArraySchema.parse([])).not.toThrow(); }); it('should fail if any single element in the array is invalid', () => { const data = [ { type: 'text', text: 'Valid' }, - { type: 'icon', source: 'missing-size.svg' }, // This one is invalid + { type: 'bar' }, // Invalid: missing 'source' ]; const result = componentArraySchema.safeParse(data); expect(result.success).toBe(false); - // The error path should correctly point to the invalid element in the array - expect(result.error.issues[0].path).toEqual([1, 'size']); + // The error path should correctly point to the invalid element. + expect(result.error.issues[0].path).toEqual([1, 'source']); }); }); }); diff --git a/src/display/data-schema/data.d.ts b/src/display/data-schema/data.d.ts index 8ce306b9..450fed99 100644 --- a/src/display/data-schema/data.d.ts +++ b/src/display/data-schema/data.d.ts @@ -1,10 +1,10 @@ /** * data.d.ts * - * This file contains TypeScript definitions generated from the Zod schemas. - * It is designed for developers to understand the data structure they need to provide. - * All properties are explicitly defined in each interface for readability, - * avoiding the need to trace `extends` clauses. + * 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'. */ //================================================================================ @@ -12,20 +12,20 @@ //================================================================================ /** - * The root of the map data, which is an array of elements. + * The root structure of the entire map data, which is an array of Element objects. * * @example * const mapData: MapData = [ - * { type: 'grid', id: 'grid1', ... }, - * { type: 'item', id: 'item1', ... }, - * { type: 'relations', id: 'rels1', ... } + * { type: 'grid', id: 'g1', ... }, + * { type: 'item', id: 'i1', ... }, + * { type: 'relations', id: 'r1', ... }, * ]; */ export type MapData = Element[]; /** - * A discriminated union of all possible root-level elements. - * The `type` property is used to determine the specific element 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 type Element = Group | Grid | Item | Relations; @@ -34,44 +34,46 @@ export type Element = Group | Grid | Item | Relations; //================================================================================ /** - * Groups a collection of other elements, applying a position to all children. + * Groups multiple elements to apply common properties. + * You can specify coordinates (x, y) for the entire group via 'attrs'. * * @example * { * type: 'group', - * id: 'group1', - * x: 100, - * y: 200, + * id: 'group-api-servers', * children: [ - * { type: 'item', id: 'childItem', width: 50, height: 50, components: [] } + * { type: 'item', id: 'server-1', width: 80, height: 80 }, + * { type: 'item', id: 'server-2', width: 80, height: 80 } * ] + * attrs: { x: 100, y: 50 }, * } */ export interface Group { type: 'group'; id: string; show?: boolean; // Default: true - x?: number; // Default: 0 - y?: number; // Default: 0 children: Element[]; - [key: string]: unknown; // Allows other properties + attrs?: Record; } /** - * Creates a grid layout of items based on a template. + * Lays out items in a grid format. + * The visibility of an item is determined by 1 or 0 in the 'cells' array. * * @example * { * type: 'grid', - * id: 'grid1', - * cells: [[1, 1, 0], [1, 0, 1]], + * id: 'server-rack', * gap: 10, - * itemTemplate: { - * width: 64, - * height: 64, + * cells: [ + * [1, 1, 0], + * [1, 0, 1] + * ], + * item: { + * width: 60, + * height: 60, * components: [ - * { type: 'background', id: 'bg-tpl', source: { fill: '#eee', radius: 4 } }, - * { type: 'icon', id: 'icon-tpl', source: 'default.svg', size: '50%' } + * { type: 'background', source: { fill: '#eee', radius: 4 } } * ] * } * } @@ -80,61 +82,58 @@ export interface Grid { type: 'grid'; id: string; show?: boolean; // Default: true - x?: number; // Default: 0 - y?: number; // Default: 0 cells: (0 | 1)[][]; - gap: Gap; - itemTemplate: { - components: Component[]; + gap?: Gap; + item: { width: number; height: number; + components?: Component[]; }; - [key: string]: unknown; // Allows other properties + attrs?: Record; } /** - * A single, placeable item that contains visual components. + * The most basic single element that constitutes the map. + * It contains various components (Background, Text, Icon, etc.) to form its visual representation. * * @example * { * type: 'item', - * id: 'server1', - * x: 50, - * y: 50, + * id: 'main-server', * width: 120, * height: 100, * components: [ - * { type: 'background', id: 'bg1', source: { fill: '#fff', borderColor: '#ddd', borderWidth: 1 } }, - * { type: 'text', id: 'label1', text: 'Main Server', placement: 'top', margin: 8 }, - * { type: 'bar', id: 'cpu-bar', source: { fill: 'lightblue' }, width: '80%', height: 8, y: 40 }, - * { type: 'icon', id: 'status-icon', source: 'ok.svg', size: 16, placement: 'bottom-right', margin: 4 } + * { type: 'background', source: { fill: '#fff', borderColor: '#ddd', borderWidth: 1 } }, + * { type: 'text', text: 'Main Server', placement: 'top', margin: 8 }, + * { type: 'bar', source: { fill: 'lightblue' }, width: '80%', height: 8 }, + * { type: 'icon', source: 'ok.svg', size: 16, placement: 'bottom-right', margin: 4 } * ] + * attrs: { x: 300, y: 150 }, * } */ export interface Item { type: 'item'; id: string; show?: boolean; // Default: true - x?: number; // Default: 0 - y?: number; // Default: 0 width: number; height: number; - components: Component[]; - [key: string]: unknown; // Allows other properties + components?: Component[]; + attrs?: Record; } /** - * Defines visual links between elements. + * Represents relationships between elements by connecting them with lines. + * Specify the IDs of the elements to connect in the 'links' array. * * @example * { * type: 'relations', - * id: 'relations1', + * id: 'server-connections', * links: [ - * { source: 'server1', target: 'server2' }, - * { source: 'server1', target: 'switch1' } + * { source: 'main-server', target: 'sub-server-1' }, + * { source: 'main-server', target: 'sub-server-2' } * ], - * style: { color: 'rgba(0,0,255,0.5)', width: 2 } + * style: { color: '#083967', width: 2 } * } */ export interface Relations { @@ -142,8 +141,8 @@ export interface Relations { id: string; show?: boolean; // Default: true links: { source: string; target: string }[]; - style?: Record; // Corresponds to PIXI.ConvertedStrokeStyle - [key: string]: unknown; // Allows other properties + style?: RelationsStyle; + attrs?: Record; } //================================================================================ @@ -151,27 +150,25 @@ export interface Relations { //================================================================================ /** - * A discriminated union of all possible visual components within an Item. + * A union type for all visual components that can be included inside an Item. */ export type Component = Background | Bar | Icon | Text; /** - * A background for an item. The source can be a color/style object or an image URL. + * The background of an Item. Can be specified as a style object or an image URL. * * @example - * // Provide a style object + * // As a style object * { * type: 'background', - * id: 'bg1', - * source: { fill: 'rgba(0,0,0,0.1)', radius: 8 } + * source: { fill: '#1A1A1A', radius: 8 } * } * * @example - * // Provide an image URL + * // As an image URL * { * type: 'background', - * id: 'bg2', - * source: 'bg.png' + * source: 'path/to/background-image.png' * } */ export interface Background { @@ -179,19 +176,18 @@ export interface Background { id: string; show?: boolean; // Default: true source: TextureStyle | string; - [key: string]: unknown; // Allows other properties + attrs?: Record; } /** - * A progress bar or indicator. + * A component for progress bars or bar graphs. * * @example * { * type: 'bar', - * id: 'bar1', * source: { fill: 'green' }, - * width: '80%', // 80% of the parent item's width - * height: 10, // 10 pixels high + * width: '80%', // 80% of the parent Item's width + * height: 10, // 10px height * placement: 'bottom' * } */ @@ -199,27 +195,25 @@ export interface Bar { type: 'bar'; id: string; show?: boolean; // Default: true - x?: number; // Default: 0 - y?: number; // Default: 0 - width: PxOrPercent; - height: PxOrPercent; source: TextureStyle; + width?: PxOrPercent; + height?: PxOrPercent; + size?: PxOrPercent; placement?: Placement; // Default: 'bottom' margin?: Margin; // Default: 0 animation?: boolean; // Default: true animationDuration?: number; // Default: 200 - [key: string]: unknown; // Allows other properties + attrs?: Record; } /** - * An icon image. + * A component for displaying an icon image. * * @example * { * type: 'icon', - * id: 'icon1', - * source: 'warning.svg', - * size: 24, // 24px + * source: 'path/to/warning-icon.svg', + * size: 24, // 24px x 24px * placement: 'left-top', * margin: { x: 4, y: 4 } * } @@ -228,23 +222,22 @@ export interface Icon { type: 'icon'; id: string; show?: boolean; // Default: true - x?: number; // Default: 0 - y?: number; // Default: 0 source: string; + width?: PxOrPercent; + height?: PxOrPercent; + size?: PxOrPercent; placement?: Placement; // Default: 'center' margin?: Margin; // Default: 0 - size?: PxOrPercent; - [key: string]: unknown; // Allows other properties + attrs?: Record; } /** - * A text label. + * A text label component. * * @example * { * type: 'text', - * id: 'text1', - * text: 'Hello World', + * text: 'CPU Usage', * placement: 'center', * style: { fill: '#333', fontSize: 14, fontWeight: 'bold' } * } @@ -253,14 +246,12 @@ export interface Text { type: 'text'; id: string; show?: boolean; // Default: true - x?: number; // Default: 0 - y?: number; // Default: 0 + text?: string; // Default: '' placement?: Placement; // Default: 'center' margin?: Margin; // Default: 0 - text?: string; // Default: '' - style?: Record; // Corresponds to PIXI.TextStyle + style?: TextStyle; split?: number; // Default: 0 - [key: string]: unknown; // Allows other properties + attrs?: Record; } //================================================================================ @@ -268,7 +259,8 @@ export interface Text { //================================================================================ /** - * A value that can be specified in pixels (number) or as a percentage (string). + * 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 width: @@ -277,11 +269,15 @@ export interface Text { * @example * // For a 75% height: * height: '75%' + * + * @example + * // As an object: + * size: { value: 50, unit: '%' } */ -export type PxOrPercent = number | string; +export type PxOrPercent = number | string | { value: number; unit: 'px' | '%' }; /** - * Defines the placement of a component within its parent item. + * Specifies the position of a component within its parent Item. */ export type Placement = | 'left' @@ -296,14 +292,14 @@ export type Placement = | 'none'; /** - * Defines the gap between grid cells. + * Defines the gap between cells in a Grid. * * @example - * // To apply a 10px gap for both x and y: + * // To set a 10px gap for both x and y: * gap: 10 * * @example - * // To apply a 5px horizontal and 15px vertical gap: + * // To set a 5px horizontal and 15px vertical gap: * gap: { x: 5, y: 15 } */ export type Gap = @@ -314,10 +310,10 @@ export type Gap = }; /** - * Defines margin around a component. + * Defines the margin around a component. * * @example - * // To apply a 10px margin to all four sides: + * // To apply a 10px margin on all four sides: * margin: 10 * * @example @@ -325,24 +321,17 @@ export type Gap = * margin: { y: 10, x: 5 } * * @example - * // To apply margins for each side individually: + * // To apply individual margins for each side: * 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; - }; + | { x?: number; y?: number } + | { top?: number; right?: number; bottom?: number; left?: number }; /** - * Defines the visual style of a rectangular texture. All properties are optional. + * Defines the style for a rectangular texture, used for backgrounds, bars, etc. + * All properties are optional. * * @example * const style: TextureStyle = { @@ -359,3 +348,34 @@ export interface TextureStyle { borderColor?: string | null; radius?: number | null; } + +/** + * Defines the line style for a Relations element. + * You can pass an object similar to PIXI.Graphics' lineStyle options. + * https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html + * + * @example + * { + * 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. + * https://pixijs.download/release/docs/text.TextStyleOptions.html + * + * Defaults: `{ fontFamily: 'FiraCode', fontWeight: 400, fill: 'black' }` + * + * @example + * { + * fontFamily: 'Arial', + * fontSize: 24, + * fill: 'white', + * stroke: { color: 'black', width: 2 } + * } + */ +export type TextStyle = Record; diff --git a/src/display/data-schema/element-schema.js b/src/display/data-schema/element-schema.js index ffc83e6b..5e35b53f 100644 --- a/src/display/data-schema/element-schema.js +++ b/src/display/data-schema/element-schema.js @@ -5,25 +5,27 @@ import { Base, Gap, RelationsStyle, Size } from './primitive-schema'; export const Group = Base.extend({ type: z.literal('group'), children: z.array(z.lazy(() => elementTypes)), -}); +}).strict(); export const Grid = 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 }).merge(Size), -}); +}).strict(); -export const Item = Base.merge(Size).extend({ - type: z.literal('item'), - components: componentArraySchema, -}); +export const Item = Base.merge(Size) + .extend({ + type: z.literal('item'), + components: componentArraySchema, + }) + .strict(); export const Relations = Base.extend({ type: z.literal('relations'), links: z.array(z.object({ source: z.string(), target: z.string() })), style: RelationsStyle, -}); +}).strict(); const elementTypes = z.discriminatedUnion('type', [ Group, diff --git a/src/display/data-schema/element-schema.test.js b/src/display/data-schema/element-schema.test.js index e4350b2f..fc6574a7 100644 --- a/src/display/data-schema/element-schema.test.js +++ b/src/display/data-schema/element-schema.test.js @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; - +import { uid } from '../../utils/uuid'; import { Grid, Group, @@ -9,304 +9,254 @@ import { mapDataSchema, } from './element-schema.js'; -// We still mock component-schema as its details are not relevant for this test. +// Mock component-schema as its details are not relevant for these element tests. vi.mock('./component-schema', () => ({ componentArraySchema: z.array(z.any()).default([]), })); -// --- Global Setup --- - -// Mocking a unique ID generator for predictable test outcomes. -let idCounter = 0; -const uid = vi.fn(() => `mock-id-${idCounter++}`); -global.uid = uid; +// Mock the uid generator for predictable test outcomes. +vi.mock('../../utils/uuid', () => ({ + uid: vi.fn(), +})); beforeEach(() => { - // Reset counter before each test to ensure test isolation - idCounter = 0; - uid.mockClear(); + vi.mocked(uid).mockClear(); }); -// --- Test Suites --- - -describe('Element Schema Tests (with real dependencies)', () => { - // A minimal valid item for use in other tests - const validItem = { - type: 'item', - id: 'item-1', - width: 100, - height: 100, - }; - +describe('Element Schemas', () => { describe('Group Schema', () => { - it('should parse a valid group with no children', () => { - const groupData = { type: 'group', id: 'group-1', children: [] }; - expect(() => Group.parse(groupData)).not.toThrow(); - }); - - it('should parse a valid group with nested elements (lazy schema)', () => { + it('should parse a valid group with nested elements', () => { const groupData = { type: 'group', id: 'group-1', - children: [validItem], + children: [{ type: 'item', id: 'item-1', width: 100, height: 100 }], }; const parsed = Group.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(() => Group.parse(groupData)).not.toThrow(); + }); + it('should fail if children contains an invalid element', () => { const invalidGroupData = { type: 'group', id: 'group-1', - children: [{ type: 'invalid-type' }], // an object that doesn't match any element type + children: [{ type: 'invalid-type' }], }; expect(() => Group.parse(invalidGroupData)).toThrow(); }); + + it('should fail if an unknown property is provided', () => { + const groupData = { + type: 'group', + id: 'group-1', + children: [], + extra: 'property', + }; + expect(() => Group.parse(groupData)).toThrow(); + }); }); describe('Grid Schema', () => { - // This test data is valid for the real Gap schema from base-schema const baseGrid = { type: 'grid', id: 'grid-1', - itemTemplate: { width: 50, height: 50, components: [] }, - gap: 5, // Using a number, which the real Gap schema preprocesses + cells: [[1]], + item: { width: 50, height: 50 }, }; - it.each([ - { - case: 'a standard 2x2 grid', - cells: [ - [1, 0], - [0, 1], - ], - }, - { case: 'an empty grid', cells: [] }, - { case: 'a grid with an empty row', cells: [[]] }, - { case: 'a ragged grid', cells: [[1], [0, 1]] }, - ])('should parse a valid grid for $case', ({ cells }) => { - const gridData = { ...baseGrid, cells }; - const parsed = Grid.parse(gridData); - // Check if the real Gap schema's preprocess worked - expect(parsed.gap).toEqual({ x: 5, y: 5 }); + it('should parse a valid grid and preprocess gap', () => { + const parsed = Grid.parse(baseGrid); + expect(parsed.gap).toEqual({ x: 0, y: 0 }); + expect(parsed.item).toEqual({ width: 50, height: 50, components: [] }); }); - it.each([ - { case: 'cells containing a non-array', cells: [1, [0, 1]] }, - { - case: 'cells containing invalid numbers', - cells: [ - [1, 2], - [0, 1], - ], - }, - { case: 'cells being not an array', cells: {} }, - ])( - 'should throw an error for invalid cells property for $case', - ({ cells }) => { - const gridData = { ...baseGrid, cells }; - expect(() => Grid.parse(gridData)).toThrow(); - }, - ); + it('should fail if cells contains invalid values', () => { + const gridData = { ...baseGrid, cells: [[1, 2]] }; + expect(() => Grid.parse(gridData)).toThrow(); + }); - it('should fail if itemTemplate is missing required size', () => { - const gridData = { ...baseGrid, itemTemplate: { components: [] } }; - expect(() => Grid.parse(gridData)).toThrow(); // width/height are required in Size schema + it('should fail if item is missing required size', () => { + const gridData = { ...baseGrid, item: { components: [] } }; + expect(() => Grid.parse(gridData)).toThrow(); + }); + + it('should fail if required properties are missing', () => { + expect(() => Grid.parse({ type: 'grid', id: 'g1' })).toThrow(); // missing cells, item + }); + + it('should fail if an unknown property is provided', () => { + const gridData = { ...baseGrid, unknown: 'property' }; + expect(() => Grid.parse(gridData)).toThrow(); }); }); describe('Item Schema', () => { - it('should parse a full valid item', () => { + it('should parse a valid item with required properties', () => { + const itemData = { type: 'item', id: 'item-1', width: 100, height: 200 }; + const parsed = Item.parse(itemData); + expect(parsed.width).toBe(100); + expect(parsed.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(() => Item.parse(itemData)).toThrow(); + }); + + it('should fail if an unknown property is provided', () => { const itemData = { type: 'item', id: 'item-1', - x: 10, - y: 20, width: 100, - height: 200, - components: [{ type: 'text', content: 'hello' }], + height: 100, + x: 50, // This is an unknown property }; - expect(() => Item.parse(itemData)).not.toThrow(); - }); - - it('should fail if required properties from merged schemas are missing', () => { - // Item merges with Size, which requires width and height - const itemData = { type: 'item', id: 'item-1' }; expect(() => Item.parse(itemData)).toThrow(); }); }); describe('Relations Schema', () => { - // The `style` property is required. Pass an empty object to trigger its preprocess. - const baseRelations = { type: 'relations', id: 'rel-1', style: {} }; - - it.each([ - { case: 'a valid links array', links: [{ source: 'a', target: 'b' }] }, - { case: 'an empty links array', links: [] }, - ])('should parse valid relations for $case', ({ links }) => { - const relationsData = { ...baseRelations, links }; + it('should parse valid relations and apply default style', () => { + const relationsData = { + type: 'relations', + id: 'rel-1', + links: [{ source: 'a', target: 'b' }], + }; const parsed = Relations.parse(relationsData); - expect(parsed.links).toEqual(links); - // Check if the real RelationsStyle schema's preprocess worked + expect(parsed.links).toHaveLength(1); expect(parsed.style).toEqual({ color: 'black' }); }); - it.each([ - { case: 'links not being an array', links: {} }, - { - case: 'links array with invalid object (missing source)', - links: [{ target: 'b' }], - }, - { - case: 'links array with invalid object (source is not a string)', - links: [{ source: 123, target: 'b' }], - }, - ])( - 'should throw an error for invalid links property for $case', - ({ links }) => { - const relationsData = { ...baseRelations, links }; - expect(() => Relations.parse(relationsData)).toThrow(); - }, - ); + it('should accept an overridden style', () => { + const relationsData = { + type: 'relations', + id: 'rel-1', + links: [], + style: { color: 'blue', lineWidth: 2 }, + }; + const parsed = Relations.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(() => Relations.parse(relationsData)).toThrow(); + }); + + it('should fail if an unknown property is provided', () => { + const relationsData = { + type: 'relations', + id: 'rel-1', + links: [], + extra: 'data', + }; + expect(() => Relations.parse(relationsData)).toThrow(); + }); }); - describe('mapDataSchema (Integration and ID Uniqueness)', () => { - it('should parse a valid array of different elements with unique IDs', () => { + describe('mapDataSchema (Full Integration)', () => { + it('should parse a valid array of mixed elements with unique IDs', () => { const data = [ - { type: 'item', id: 'item-10', width: 10, height: 10 }, - { type: 'group', id: 'group-20', children: [] }, + { type: 'item', id: 'item-1', width: 10, height: 10 }, + { + type: 'group', + id: 'group-1', + children: [{ type: 'item', id: 'item-2', width: 10, height: 10 }], + }, ]; expect(() => mapDataSchema.parse(data)).not.toThrow(); }); - it('should parse correctly with default IDs applied', () => { + 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', width: 10, height: 10 }, // no id - { type: 'item', width: 10, height: 10 }, // no id + { type: 'item', width: 10, height: 10 }, + { type: 'item', width: 10, height: 10 }, ]; - // uid() will be called, returning mock-id-0, mock-id-1, etc. const parsed = mapDataSchema.parse(data); expect(parsed[0].id).toBe('mock-id-0'); expect(parsed[1].id).toBe('mock-id-1'); - expect(uid).toHaveBeenCalledTimes(2); }); - // --- Extreme Edge Cases for ID Uniqueness --- - describe('ID uniqueness validation (superRefine)', () => { + 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); - if (result.success) return null; - return result.error.issues[0].message; + return result.success ? null : result.error.issues[0].message; }; - it.each([ - { - case: 'duplicate IDs at the root level', - data: [ - { type: 'item', id: 'dup-id', width: 10, height: 10 }, - { type: 'item', id: 'dup-id', width: 10, height: 10 }, - ], - expectedError: 'Duplicate id: dup-id at 1', - }, - { - case: 'duplicate ID inside a nested group', - data: [ - { - type: 'group', - id: 'group-1', - children: [ - { type: 'item', id: 'nested-dup', width: 10, height: 10 }, - { type: 'item', id: 'nested-dup', width: 10, height: 10 }, - ], - }, - ], - expectedError: 'Duplicate id: nested-dup at 0.children.1', - }, - { - case: 'ID at root is duplicated in a nested group', - data: [ - { type: 'item', id: 'cross-level-dup', width: 10, height: 10 }, - { - type: 'group', - id: 'group-1', - children: [ - { type: 'item', id: 'cross-level-dup', width: 10, height: 10 }, - ], - }, - ], - expectedError: 'Duplicate id: cross-level-dup at 1.children.0', - }, - { - case: 'ID in a nested group is duplicated at the root', - data: [ - { - type: 'group', - id: 'group-1', - children: [ - { type: 'item', id: 'cross-level-dup', width: 10, height: 10 }, - ], - }, - { type: 'item', id: 'cross-level-dup', width: 10, height: 10 }, - ], - expectedError: 'Duplicate id: cross-level-dup at 1', - }, - { - case: 'duplicate IDs in deeply nested groups', - data: [ - { - type: 'group', - id: 'group-1', - children: [ - { - type: 'group', - id: 'group-2', - children: [ - { type: 'item', id: 'deep-dup', width: 10, height: 10 }, - ], - }, - { - type: 'group', - id: 'group-3', - children: [ - { type: 'item', id: 'deep-dup', width: 10, height: 10 }, - ], - }, - ], - }, - ], - expectedError: 'Duplicate id: deep-dup at 0.children.1.children.0', - }, - { - case: 'duplicate with default IDs', - data: [ - { type: 'item', id: 'mock-id-0', width: 10, height: 10 }, - { type: 'item', width: 10, height: 10 }, // This will get default id 'mock-id-0' - ], - expectedError: 'Duplicate id: mock-id-0 at 1', - }, - ])('should fail with error: $case', ({ data, expectedError }) => { - const message = getFirstError(data); - expect(message).toBe(expectedError); + it('should fail for duplicate IDs at the root level', () => { + const data = [ + { type: 'item', id: 'dup-id', width: 10, height: 10 }, + { type: 'item', id: 'dup-id', width: 10, height: 10 }, + ]; + expect(getFirstError(data)).toBe('Duplicate id: dup-id at 1'); }); - }); - // --- Discriminated Union Edge Cases --- - describe('Discriminated Union validation', () => { - it('should fail if an element has an unknown type', () => { - const data = [{ type: 'rectangle', id: 'rect-1' }]; - expect(() => mapDataSchema.parse(data)).toThrow(); + it('should fail for duplicate ID between root and a nested group', () => { + const data = [ + { type: 'item', id: 'cross-level-dup', width: 10, height: 10 }, + { + type: 'group', + id: 'group-1', + children: [ + { type: 'item', id: 'cross-level-dup', width: 10, height: 10 }, + ], + }, + ]; + expect(getFirstError(data)).toBe( + 'Duplicate id: cross-level-dup at 1.children.0', + ); }); - it('should fail if an element has a correct type but incorrect properties', () => { - // 'grid' type requires a 'cells' property + it('should fail for duplicate ID in deeply nested groups', () => { const data = [ - { type: 'grid', id: 'grid-1', itemTemplate: { width: 1, height: 1 } }, + { + type: 'group', + id: 'g1', + children: [ + { + type: 'group', + id: 'g2', + children: [ + { type: 'item', id: 'deep-dup', width: 1, height: 1 }, + ], + }, + { type: 'item', id: 'deep-dup', width: 1, height: 1 }, + ], + }, ]; - const result = mapDataSchema.safeParse(data); - expect(result.success).toBe(false); - expect(result.error.issues[0].message).toBe('Required'); // Zod's error for missing property - expect(result.error.issues[0].path).toEqual([0, 'cells']); + 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', width: 10, height: 10 }, + { type: 'item', 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 index ec1d4b0c..57050276 100644 --- a/src/display/data-schema/primitive-schema.js +++ b/src/display/data-schema/primitive-schema.js @@ -30,11 +30,13 @@ export const pxOrPercentSchema = z return val; }); -export const PxOrPercentSize = z.object({ - width: pxOrPercentSchema.optional(), - height: pxOrPercentSchema.optional(), - size: pxOrPercentSchema.optional(), -}); +export const PxOrPercentSize = z + .object({ + width: pxOrPercentSchema, + height: pxOrPercentSchema, + size: pxOrPercentSchema, + }) + .partial(); export const Placement = z.enum([ 'left', @@ -83,18 +85,20 @@ export const Margin = z.preprocess( export const TextureStyle = z .object({ type: z.enum(['rect']), - fill: z.nullable(z.string()), - borderWidth: z.nullable(z.number()), - borderColor: z.nullable(z.string()), - radius: z.nullable(z.number()), + fill: z.string(), + borderWidth: z.number(), + borderColor: z.string(), + radius: z.number(), }) .partial(); +// https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html export const RelationsStyle = z.preprocess( (val) => ({ color: 'black', ...(val ?? {}) }), z.record(z.string(), z.unknown()), -); // https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html +); +// https://pixijs.download/release/docs/text.TextStyleOptions.html export const TextStyle = z.preprocess( (val) => ({ fontFamily: 'FiraCode', diff --git a/src/display/data-schema/primitive-schema.test.js b/src/display/data-schema/primitive-schema.test.js index 5c2cbbe7..d723e38e 100644 --- a/src/display/data-schema/primitive-schema.test.js +++ b/src/display/data-schema/primitive-schema.test.js @@ -1,122 +1,71 @@ import { describe, expect, it, vi } from 'vitest'; +import { uid } from '../../utils/uuid'; import { Base, Gap, Margin, Placement, - Position, PxOrPercentSize, RelationsStyle, Size, TextStyle, TextureStyle, -} from './primitive-schema'; // Assuming the schemas are in './base-schema.js' + pxOrPercentSchema, +} from './primitive-schema'; -// --- Mocks --- +vi.mock('../../utils/uuid'); +vi.mocked(uid).mockReturnValue('mock-uid-123'); -// Mock for the global uid function used in the Base schema. -// Vitest's `vi.fn()` provides mocking capabilities. -const uid = vi.fn(() => 'mock-uid-123'); -global.uid = uid; - -// --- Test Suites --- - -describe('Base Schema Tests', () => { - // Test suite for the Base schema +describe('Primitive Schema Tests', () => { describe('Base Schema', () => { - it('should parse a valid object with all properties and allow passthrough', () => { - const data = { show: false, id: 'custom-id', extra: 'passthrough-value' }; + it('should parse a valid object with all properties', () => { + const data = { show: false, id: 'custom-id', attrs: { extra: 'value' } }; const result = Base.parse(data); - expect(result).toEqual({ - show: false, - id: 'custom-id', - extra: 'passthrough-value', - }); + expect(result).toEqual(data); }); - it('should apply default values for missing properties', () => { + it('should apply default values for missing optional properties', () => { const data = {}; const result = Base.parse(data); - // The mock uid function should be called to generate a default id. expect(result).toEqual({ show: true, id: 'mock-uid-123' }); expect(uid).toHaveBeenCalled(); }); - }); - // Test suite for the Position schema - describe('Position Schema', () => { - it.each([ - { - case: 'standard positive integers', - input: { x: 100, y: 200 }, - expected: { x: 100, y: 200 }, - }, - { case: 'zero values', input: { x: 0, y: 0 }, expected: { x: 0, y: 0 } }, - { - case: 'negative values', - input: { x: -50, y: -150 }, - expected: { x: -50, y: -150 }, - }, - { - case: 'floating point numbers', - input: { x: 10.5, y: 20.5 }, - expected: { x: 10.5, y: 20.5 }, - }, - { - case: 'missing properties apply defaults', - input: {}, - expected: {}, - }, - { - case: 'one missing property', - input: { x: 55 }, - expected: { x: 55 }, - }, - ])( - 'should correctly parse position object for $case', - ({ input, expected }) => { - // The imported Position is now a Zod schema, so we can use it directly. - expect(Position.parse(input)).toEqual(expected); - }, - ); + it('should throw an error for unknown properties due to .strict()', () => { + const data = { show: true, unknownProperty: 'test' }; + expect(() => Base.parse(data)).toThrow(); + }); - it('should fail parsing with invalid types', () => { - expect(() => Position.parse({ x: '100', y: '200' })).toThrow(); - expect(() => Position.parse({ x: 100, y: null })).toThrow(); + it('should allow "attrs" to contain any data type', () => { + const data = { + attrs: { + aNumber: 123, + aString: 'hello', + aBoolean: false, + aNull: null, + anObject: { nested: true }, + }, + }; + const result = Base.parse(data); + expect(result.attrs).toEqual(data.attrs); }); }); - // Test suite for the Size schema describe('Size Schema', () => { it.each([ - { - case: 'standard positive integers', - input: { width: 100, height: 200 }, - expected: { width: 100, height: 200 }, - }, - { - case: 'zero values', - input: { width: 0, height: 0 }, - expected: { width: 0, height: 0 }, - }, - { - case: 'floating point numbers', - input: { width: 10.5, height: 20.5 }, - expected: { width: 10.5, height: 20.5 }, - }, - ])( - 'should correctly parse size object for $case', - ({ input, expected }) => { - // The imported Size is now a Zod schema. - expect(Size.parse(input)).toEqual(expected); - }, - ); + { case: 'positive integers', input: { width: 100, height: 200 } }, + { case: 'zero values', input: { width: 0, height: 0 } }, + { case: 'floating point numbers', input: { width: 10.5, height: 20.5 } }, + ])('should correctly parse valid size object for $case', ({ input }) => { + expect(Size.parse(input)).toEqual(input); + }); it.each([ - { case: 'negative numbers', input: { width: -100, height: 100 } }, - { case: 'one negative number', input: { width: 100, height: -1 } }, + { case: 'negative width', input: { width: -100, height: 100 } }, + { case: 'negative height', input: { width: 100, height: -1 } }, { case: 'invalid type (string)', input: { width: '100', height: 100 } }, - { case: 'missing property (no defaults)', input: { width: 100 } }, + { case: 'missing height property', input: { width: 100 } }, + { case: 'NaN value', input: { width: 100, height: Number.NaN } }, ])( 'should throw an error for invalid size object for $case', ({ input }) => { @@ -125,157 +74,176 @@ describe('Base Schema Tests', () => { ); }); - // Test suite for the PxOrPercentSize schema - describe('PxOrPercentSize Schema', () => { + describe('pxOrPercentSchema', () => { it.each([ { - case: 'pixel values (numbers)', - input: { width: 100, height: 50 }, - expected: { - width: { value: 100, unit: 'px' }, - height: { value: 50, unit: 'px' }, - }, + case: 'pixel number', + input: 100, + expected: { value: 100, unit: 'px' }, }, { - case: 'percentage values (strings)', - input: { width: '80%', height: '100%' }, - expected: { - width: { value: 80, unit: '%' }, - height: { value: 100, unit: '%' }, - }, + case: 'percentage string', + input: '80%', + expected: { value: 80, unit: '%' }, }, { - case: 'mix of pixel and percentage', - input: { width: 150, height: '75%' }, - expected: { - width: { value: 150, unit: 'px' }, - height: { value: 75, unit: '%' }, - }, + case: 'float percentage string', + input: '33.3%', + expected: { value: 33.3, unit: '%' }, }, { - case: 'floating point values', - input: { width: 99.9, height: '33.3%' }, - expected: { - width: { value: 99.9, unit: 'px' }, - height: { value: 33.3, unit: '%' }, - }, + case: 'zero pixel', + input: 0, + expected: { value: 0, unit: 'px' }, }, { - case: 'zero values', - input: { width: 0, height: '0%' }, - expected: { - width: { value: 0, unit: 'px' }, - height: { value: 0, unit: '%' }, - }, + case: 'zero percent', + input: '0%', + expected: { value: 0, unit: '%' }, }, - ])( - 'should correctly parse and transform for $case', - ({ input, expected }) => { - // The imported PxOrPercentSize is now a Zod schema. - expect(PxOrPercentSize.parse(input)).toEqual(expected); + { + 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: { width: -100, height: 50 } }, + { case: 'negative number', input: -100 }, + { case: 'malformed percentage string', input: '100' }, + { case: 'percentage with space', input: '50 %' }, + { case: 'invalid unit string', input: '100em' }, { - case: 'malformed percentage string', - input: { width: '100', height: '50%' }, + case: 'invalid pre-formatted object unit', + input: { value: 50, unit: 'em' }, }, - { - case: 'percentage with space', - input: { width: '50 %', height: '50%' }, - }, - { case: 'invalid unit', input: { width: '100em', height: '50%' } }, - { case: 'missing property', input: { width: 100 } }, + { case: 'null input', input: null }, ])('should throw an error for invalid input for $case', ({ input }) => { - expect(() => PxOrPercentSize.parse(input)).toThrow(); + expect(() => pxOrPercentSchema.parse(input)).toThrow(); + }); + }); + + describe('PxOrPercentSize Schema', () => { + it('should parse and transform mixed pixel and percentage values', () => { + 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 correctly parse the new "size" property', () => { + const input = { size: '50%' }; + const expected = { size: { value: 50, unit: '%' } }; + expect(PxOrPercentSize.parse(input)).toEqual(expected); + }); + + it('should parse an empty object', () => { + expect(PxOrPercentSize.parse({})).toEqual({}); + }); + + it('should handle all properties at once', () => { + const input = { width: 100, height: '50%', size: 25 }; + const expected = { + width: { value: 100, unit: 'px' }, + height: { value: 50, unit: '%' }, + size: { value: 25, unit: 'px' }, + }; + expect(PxOrPercentSize.parse(input)).toEqual(expected); }); }); - // Test suite for the Placement schema describe('Placement Schema', () => { it.each([ - { placement: 'left' }, - { placement: 'left-top' }, - { placement: 'left-bottom' }, - { placement: 'top' }, - { placement: 'right' }, - { placement: 'right-top' }, - { placement: 'right-bottom' }, - { placement: 'bottom' }, - { placement: 'center' }, - { placement: 'none' }, - ])('should accept valid placement value: $placement', ({ placement }) => { + '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('should reject an invalid placement value', () => { - expect(() => Placement.parse('top-left')).toThrow(); // Invalid enum - }); + it.each(['top-left', 'center-top', 'invalid-placement', '', null])( + 'should reject invalid placement value: %s', + (placement) => { + expect(() => Placement.parse(placement)).toThrow(); + }, + ); }); - // Test suite for the Gap schema describe('Gap Schema', () => { it.each([ { case: 'a single number', input: 20, expected: { x: 20, y: 20 } }, { - case: 'an object with x and y', + case: 'object with x and y', input: { x: 10, y: 30 }, expected: { x: 10, y: 30 }, }, { - case: 'an object with only x', + case: 'object with only x', input: { x: 15 }, expected: { x: 15, y: 0 }, }, { - case: 'an object with only y', + case: 'object with only y', input: { y: 25 }, - expected: { y: 25, x: 0 }, + expected: { x: 0, y: 25 }, }, - { case: 'an empty object', input: {}, expected: { x: 0, y: 0 } }, + { 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('should throw an error for negative numbers', () => { - expect(() => Gap.parse(-10)).toThrow(); - expect(() => Gap.parse({ x: -10, y: 10 })).toThrow(); + 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(); }); }); - // Test suite for the Margin schema describe('Margin Schema', () => { it.each([ { - case: 'a single number', + case: 'single number', input: 15, expected: { top: 15, right: 15, bottom: 15, left: 15 }, }, { - case: 'an object with x and y', + case: 'object with x and y', input: { x: 10, y: 20 }, expected: { top: 20, right: 10, bottom: 20, left: 10 }, }, { - case: 'a full object', + case: 'full object', input: { top: 5, right: 10, bottom: 15, left: 20 }, expected: { top: 5, right: 10, bottom: 15, left: 20 }, }, { - case: 'an object with only x', + case: 'object with only x', input: { x: 30 }, expected: { top: 0, right: 30, bottom: 0, left: 30 }, }, { - case: 'an object with only y', + case: 'object with only y', input: { y: 40 }, expected: { top: 40, right: 0, bottom: 40, left: 0 }, }, { - case: 'an empty object', + case: 'empty object', input: {}, expected: { top: 0, right: 0, bottom: 0, left: 0 }, }, @@ -284,12 +252,33 @@ describe('Base Schema Tests', () => { 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(); + }); }); - // Test suite for the TextureStyle schema describe('TextureStyle Schema', () => { it('should parse a full valid object', () => { const data = { @@ -302,28 +291,19 @@ describe('Base Schema Tests', () => { expect(TextureStyle.parse(data)).toEqual(data); }); - it('should parse a partial object', () => { - const data = { fill: '#FFF' }; - expect(TextureStyle.parse(data)).toEqual({ fill: '#FFF' }); - }); - - it('should accept null values', () => { - const data = { - fill: null, - borderWidth: null, - borderColor: null, - radius: null, - }; - expect(TextureStyle.parse(data)).toEqual(data); + it('should parse an empty object', () => { + expect(TextureStyle.parse({})).toEqual({}); }); - it('should fail on invalid enum for type', () => { - const data = { type: 'circle' }; - expect(() => TextureStyle.parse(data)).toThrow(); + 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(); }); }); - // Test suite for the RelationsStyle schema describe('RelationsStyle Schema', () => { it('should add default color if not provided', () => { const data = { lineWidth: 2 }; @@ -334,25 +314,21 @@ describe('Base Schema Tests', () => { }); it('should not override provided color', () => { - const data = { color: 'blue', lineStyle: 'dashed' }; - expect(RelationsStyle.parse(data)).toEqual({ - color: 'blue', - lineStyle: 'dashed', - }); + const data = { color: 'blue' }; + expect(RelationsStyle.parse(data)).toEqual({ color: 'blue' }); }); - it('should accept any other properties', () => { - const data = { customProp: true }; - expect(RelationsStyle.parse(data)).toEqual({ - color: 'black', - customProp: true, - }); + it.each([ + { case: 'undefined', input: undefined }, + { case: 'null', input: null }, + { case: 'empty object', input: {} }, + ])('should return default object for $case input', ({ input }) => { + expect(RelationsStyle.parse(input)).toEqual({ color: 'black' }); }); }); - // Test suite for the TextStyle schema describe('TextStyle Schema', () => { - it('should apply default styles', () => { + it('should apply default styles for a partial object', () => { const data = { fontSize: 16 }; expect(TextStyle.parse(data)).toEqual({ fontFamily: 'FiraCode', @@ -363,23 +339,23 @@ describe('Base Schema Tests', () => { }); it('should not override provided styles', () => { - const data = { fontFamily: 'Arial', fill: 'red' }; + const data = { fontFamily: 'Arial', fill: 'red', fontWeight: 'bold' }; expect(TextStyle.parse(data)).toEqual({ fontFamily: 'Arial', - fontWeight: 400, + fontWeight: 'bold', fill: 'red', }); }); - it('should accept any other valid text properties', () => { - const data = { align: 'center', stroke: 'white', strokeThickness: 2 }; - expect(TextStyle.parse(data)).toEqual({ + it.each([ + { case: 'undefined', input: undefined }, + { case: 'null', input: null }, + { case: 'empty object', input: {} }, + ])('should return full default object for $case input', ({ input }) => { + expect(TextStyle.parse(input)).toEqual({ fontFamily: 'FiraCode', fontWeight: 400, fill: 'black', - align: 'center', - stroke: 'white', - strokeThickness: 2, }); }); }); From daad30513373e33f003120ce1baa14d597c33c85 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 26 Jun 2025 15:10:36 +0900 Subject: [PATCH 12/66] fix jsdoc --- src/display/data-schema/component-schema.js | 20 ++++++++++++++++++ src/display/data-schema/data.d.ts | 15 +++++++++++--- src/display/data-schema/element-schema.js | 23 +++++++++++++++++++++ src/display/data-schema/primitive-schema.js | 1 + 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/display/data-schema/component-schema.js b/src/display/data-schema/component-schema.js index 747265e3..8019bcca 100644 --- a/src/display/data-schema/component-schema.js +++ b/src/display/data-schema/component-schema.js @@ -8,11 +8,21 @@ import { TextureStyle, } from './primitive-schema'; +/** + * 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 Background = Base.extend({ type: z.literal('background'), source: z.union([TextureStyle, z.string()]), }).strict(); +/** + * 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 Bar = Base.merge(PxOrPercentSize) .extend({ type: z.literal('bar'), @@ -24,6 +34,11 @@ export const Bar = Base.merge(PxOrPercentSize) }) .strict(); +/** + * A component for displaying an icon image. + * Visually represented by a `Sprite`. + * @see {@link https://pixijs.download/release/docs/scene.Sprite.html} + */ export const Icon = Base.merge(PxOrPercentSize) .extend({ type: z.literal('icon'), @@ -33,6 +48,11 @@ export const Icon = Base.merge(PxOrPercentSize) }) .strict(); +/** + * A text label component. + * Visually represented by a `BitmapText`. + * @see {@link https://pixijs.download/release/docs/scene.BitmapText.html} + */ export const Text = Base.extend({ type: z.literal('text'), placement: Placement.default('center'), diff --git a/src/display/data-schema/data.d.ts b/src/display/data-schema/data.d.ts index 450fed99..cc7d10fa 100644 --- a/src/display/data-schema/data.d.ts +++ b/src/display/data-schema/data.d.ts @@ -36,6 +36,7 @@ export type Element = Group | Grid | Item | Relations; /** * 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 * { @@ -59,6 +60,7 @@ export interface Group { /** * 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 * { @@ -95,6 +97,7 @@ export interface Grid { /** * 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 * { @@ -124,6 +127,7 @@ export interface Item { /** * 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 * { @@ -155,7 +159,8 @@ export interface Relations { export type Component = Background | Bar | Icon | Text; /** - * The background of an Item. Can be specified as a style object or an image URL. + * 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 @@ -181,6 +186,7 @@ export interface Background { /** * A component for progress bars or bar graphs. + * @see {@link https://pixijs.download/release/docs/scene.NineSliceSprite.html} * * @example * { @@ -208,6 +214,7 @@ export interface Bar { /** * A component for displaying an icon image. + * @see {@link https://pixijs.download/release/docs/scene.Sprite.html} * * @example * { @@ -233,6 +240,7 @@ export interface Icon { /** * A text label component. + * @see {@link https://pixijs.download/release/docs/scene.BitmapText.html} * * @example * { @@ -352,7 +360,7 @@ export interface TextureStyle { /** * Defines the line style for a Relations element. * You can pass an object similar to PIXI.Graphics' lineStyle options. - * https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html + * @see {@link https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html} * * @example * { @@ -366,10 +374,11 @@ export type RelationsStyle = Record; /** * Defines the text style for a Text component. * You can pass an object similar to PIXI.TextStyle options. - * https://pixijs.download/release/docs/text.TextStyleOptions.html * * Defaults: `{ fontFamily: 'FiraCode', fontWeight: 400, fill: 'black' }` * + * @see {@link https://pixijs.download/release/docs/text.TextStyleOptions.html} + * * @example * { * fontFamily: 'Arial', diff --git a/src/display/data-schema/element-schema.js b/src/display/data-schema/element-schema.js index 5e35b53f..498683a8 100644 --- a/src/display/data-schema/element-schema.js +++ b/src/display/data-schema/element-schema.js @@ -2,11 +2,22 @@ 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 Group = 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 Grid = Base.extend({ type: z.literal('grid'), cells: z.array(z.array(z.union([z.literal(0), z.literal(1)]))), @@ -14,6 +25,12 @@ export const Grid = Base.extend({ item: z.object({ components: componentArraySchema }).merge(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 Item = Base.merge(Size) .extend({ type: z.literal('item'), @@ -21,6 +38,12 @@ export const Item = Base.merge(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 Relations = Base.extend({ type: z.literal('relations'), links: z.array(z.object({ source: z.string(), target: z.string() })), diff --git a/src/display/data-schema/primitive-schema.js b/src/display/data-schema/primitive-schema.js index 57050276..754cc6a8 100644 --- a/src/display/data-schema/primitive-schema.js +++ b/src/display/data-schema/primitive-schema.js @@ -5,6 +5,7 @@ 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(); From 47f1310163859a8e573ab898f600ffe965cd52ef Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 26 Jun 2025 15:59:38 +0900 Subject: [PATCH 13/66] add tint type --- src/display/data-schema/color-schema.js | 40 ++++++++++++ src/display/data-schema/color.d.ts | 65 +++++++++++++++++++ src/display/data-schema/component-schema.js | 5 ++ src/display/data-schema/data.d.ts | 52 +++++++++++++++ src/display/data-schema/primitive-schema.js | 38 ++++++++++- .../data-schema/primitive-schema.test.js | 48 ++++++++++++++ 6 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 src/display/data-schema/color-schema.js create mode 100644 src/display/data-schema/color.d.ts 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 8019bcca..5198eebf 100644 --- a/src/display/data-schema/component-schema.js +++ b/src/display/data-schema/component-schema.js @@ -6,6 +6,7 @@ import { PxOrPercentSize, TextStyle, TextureStyle, + Tint, } from './primitive-schema'; /** @@ -16,6 +17,7 @@ import { export const Background = Base.extend({ type: z.literal('background'), source: z.union([TextureStyle, z.string()]), + tint: Tint.optional(), }).strict(); /** @@ -29,6 +31,7 @@ export const Bar = Base.merge(PxOrPercentSize) source: TextureStyle, placement: Placement.default('bottom'), margin: Margin.default(0), + tint: Tint.optional(), animation: z.boolean().default(true), animationDuration: z.number().default(200), }) @@ -45,6 +48,7 @@ export const Icon = Base.merge(PxOrPercentSize) source: z.string(), placement: Placement.default('center'), margin: Margin.default(0), + tint: Tint.optional(), }) .strict(); @@ -57,6 +61,7 @@ export const Text = Base.extend({ type: z.literal('text'), placement: Placement.default('center'), margin: Margin.default(0), + tint: Tint.optional(), text: z.string().default(''), style: TextStyle, split: z.number().int().default(0), diff --git a/src/display/data-schema/data.d.ts b/src/display/data-schema/data.d.ts index cc7d10fa..37b8ed96 100644 --- a/src/display/data-schema/data.d.ts +++ b/src/display/data-schema/data.d.ts @@ -7,6 +7,16 @@ * rather than using 'extends'. */ +import type { + Color, + HslColor, + HslaColor, + HsvColor, + HsvaColor, + RgbColor, + RgbaColor, +} from './color'; + //================================================================================ // 1. Top-Level Data Structure //================================================================================ @@ -181,6 +191,7 @@ export interface Background { id: string; show?: boolean; // Default: true source: TextureStyle | string; + tint?: Tint; attrs?: Record; } @@ -207,6 +218,7 @@ export interface Bar { size?: PxOrPercent; placement?: Placement; // Default: 'bottom' margin?: Margin; // Default: 0 + tint?: Tint; animation?: boolean; // Default: true animationDuration?: number; // Default: 200 attrs?: Record; @@ -235,6 +247,7 @@ export interface Icon { size?: PxOrPercent; placement?: Placement; // Default: 'center' margin?: Margin; // Default: 0 + tint?: Tint; attrs?: Record; } @@ -257,6 +270,7 @@ export interface Text { text?: string; // Default: '' placement?: Placement; // Default: 'center' margin?: Margin; // Default: 0 + tint?: Tint; style?: TextStyle; split?: number; // Default: 0 attrs?: Record; @@ -388,3 +402,41 @@ export type RelationsStyle = Record; * } */ 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) + * tint: 'primary.main' + * + * @example + * // As a hex string + * tint: '#ff0000' + * + * @example + * // As a hex number + * tint: 0xff0000 + * + * @example + * // As an RGB object + * 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/primitive-schema.js b/src/display/data-schema/primitive-schema.js index 754cc6a8..5e9de36c 100644 --- a/src/display/data-schema/primitive-schema.js +++ b/src/display/data-schema/primitive-schema.js @@ -1,5 +1,14 @@ 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({ @@ -86,20 +95,24 @@ export const Margin = z.preprocess( export const TextureStyle = z .object({ type: z.enum(['rect']), - fill: z.string(), + fill: z.string().default('white'), borderWidth: z.number(), borderColor: z.string(), radius: z.number(), }) .partial(); -// https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html +/** + * @see {@link https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html} + */ export const RelationsStyle = z.preprocess( (val) => ({ color: 'black', ...(val ?? {}) }), z.record(z.string(), z.unknown()), ); -// https://pixijs.download/release/docs/text.TextStyleOptions.html +/** + * @see {@link https://pixijs.download/release/docs/text.TextStyleOptions.html} + */ export const TextStyle = z.preprocess( (val) => ({ fontFamily: 'FiraCode', @@ -109,3 +122,22 @@ export const TextStyle = z.preprocess( }), 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 index d723e38e..bc496b42 100644 --- a/src/display/data-schema/primitive-schema.test.js +++ b/src/display/data-schema/primitive-schema.test.js @@ -10,6 +10,7 @@ import { Size, TextStyle, TextureStyle, + Tint, pxOrPercentSchema, } from './primitive-schema'; @@ -17,6 +18,53 @@ 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 }) => { + // Since the schema is z.union([z.string(), z.any()]), + // it should not throw for any of these valid ColorSource formats. + 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', attrs: { extra: 'value' } }; From eb7e1cbfd692a47319e875c6c478c08ec7521377 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 26 Jun 2025 15:59:48 +0900 Subject: [PATCH 14/66] fix convert func --- src/utils/convert.js | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/src/utils/convert.js b/src/utils/convert.js index 7b65ac09..ea7753f9 100644 --- a/src/utils/convert.js +++ b/src/utils/convert.js @@ -28,9 +28,6 @@ export const convertLegacyData = (data) => { cells: value.children.map((row) => row.map((child) => (child === '0' ? 0 : 1)), ), - x: transform.x, - y: transform.y, - angle: transform.rotation, gap: 4, item: { width: props.spec.width * 40, @@ -38,10 +35,8 @@ export const convertLegacyData = (data) => { components: [ { type: 'background', - id: uid(), source: { type: 'rect', - fill: 'white', borderWidth: 2, borderColor: 'primary.dark', radius: 6, @@ -49,17 +44,21 @@ export const convertLegacyData = (data) => { }, { type: 'bar', - id: uid(), + show: false, width: '100%', height: '100%', - source: { type: 'rect', fill: 'white', radius: 3 }, - color: 'primary.default', - show: false, + source: { type: 'rect', radius: 3 }, + tint: 'primary.default', margin: 3, }, ], }, - metadata: props, + attrs: { + x: transform.x, + y: transform.y, + angle: transform.rotation, + metadata: props, + }, }); } } else if (key === 'strings') { @@ -89,28 +88,27 @@ export const convertLegacyData = (data) => { 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].children.push({ type: 'item', id: value.id, label: value.name, - x: transform.x, - y: transform.y, width: 40, height: 40, components: [ { type: 'background', - id: uid(), source: { type: 'rect', - fill: 'white', borderWidth: 2, borderColor: 'primary.default', radius: 6, @@ -118,14 +116,17 @@ export const convertLegacyData = (data) => { }, { type: 'icon', - id: uid(), source: key === 'combines' ? 'combiner' : key.slice(0, -1), size: 24, - color: 'primary.default', + tint: 'primary.default', placement: 'center', }, ], - metadata: props, + attrs: { + x: transform.x, + y: transform.y, + metadata: props, + }, }); } } From 9c7a45bf070a9e8c20268f54904ef58f33ececd8 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 30 Jun 2025 17:22:13 +0900 Subject: [PATCH 15/66] add element class --- src/assets/icons/index.js | 2 + src/assets/icons/inverter-fill.svg | 6 ++ src/display/change/position.js | 3 +- src/display/data-schema/component-schema.js | 16 ++--- .../data-schema/component-schema.test.js | 46 ++++++++------- src/display/data-schema/element-schema.js | 16 ++--- .../data-schema/element-schema.test.js | 40 ++++++------- src/display/draw.js | 29 ++++------ src/display/elements/Element.js | 53 +++++++++++++++++ src/display/elements/Grid.js | 16 +++++ src/display/elements/Group.js | 17 ++++++ src/display/elements/Item.js | 16 +++++ src/display/elements/Relations.js | 24 ++++++++ src/display/elements/grid.js | 58 ------------------- src/display/elements/group.js | 19 ------ src/display/elements/index.js | 4 ++ src/display/elements/item.js | 26 --------- src/display/elements/relations.js | 28 --------- src/display/update/update-object.js | 2 +- src/display/update/update.js | 16 +---- src/display/utils.js | 31 ++++++++-- src/patchmap.js | 29 ++++------ 22 files changed, 249 insertions(+), 248 deletions(-) create mode 100644 src/assets/icons/inverter-fill.svg create mode 100644 src/display/elements/Element.js create mode 100644 src/display/elements/Grid.js create mode 100644 src/display/elements/Group.js create mode 100644 src/display/elements/Item.js create mode 100644 src/display/elements/Relations.js delete mode 100644 src/display/elements/grid.js delete mode 100644 src/display/elements/group.js create mode 100644 src/display/elements/index.js delete mode 100644 src/display/elements/item.js delete mode 100644 src/display/elements/relations.js diff --git a/src/assets/icons/index.js b/src/assets/icons/index.js index 68be18ba..0e3a03d1 100644 --- a/src/assets/icons/index.js +++ b/src/assets/icons/index.js @@ -1,6 +1,7 @@ import combiner from './combiner.svg'; import device from './device.svg'; import edge from './edge.svg'; +import inverterFill from './inverter-fill.svg'; import inverter from './inverter.svg'; import loading from './loading.svg'; import object from './object.svg'; @@ -16,4 +17,5 @@ export const icons = { loading, warning, wifi, + inverterFill, }; diff --git a/src/assets/icons/inverter-fill.svg b/src/assets/icons/inverter-fill.svg new file mode 100644 index 00000000..add6b576 --- /dev/null +++ b/src/assets/icons/inverter-fill.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/display/change/position.js b/src/display/change/position.js index c0db10ad..7fdff1db 100644 --- a/src/display/change/position.js +++ b/src/display/change/position.js @@ -1,6 +1,7 @@ import { updateConfig } from './utils'; export const changePosition = (object, { x, y }) => { - object.position.set(x, y); + const position = object.position; + object.position.set(x ?? position.x, y ?? position.y); updateConfig(object, { x, y }); }; diff --git a/src/display/data-schema/component-schema.js b/src/display/data-schema/component-schema.js index 5198eebf..f75ed94b 100644 --- a/src/display/data-schema/component-schema.js +++ b/src/display/data-schema/component-schema.js @@ -14,7 +14,7 @@ import { * Visually represented by a `NineSliceSprite`. * @see {@link https://pixijs.download/release/docs/scene.NineSliceSprite.html} */ -export const Background = Base.extend({ +export const backgroundSchema = Base.extend({ type: z.literal('background'), source: z.union([TextureStyle, z.string()]), tint: Tint.optional(), @@ -25,7 +25,7 @@ export const Background = Base.extend({ * Visually represented by a `NineSliceSprite`. * @see {@link https://pixijs.download/release/docs/scene.NineSliceSprite.html} */ -export const Bar = Base.merge(PxOrPercentSize) +export const barSchema = Base.merge(PxOrPercentSize) .extend({ type: z.literal('bar'), source: TextureStyle, @@ -42,7 +42,7 @@ export const Bar = Base.merge(PxOrPercentSize) * Visually represented by a `Sprite`. * @see {@link https://pixijs.download/release/docs/scene.Sprite.html} */ -export const Icon = Base.merge(PxOrPercentSize) +export const iconSchema = Base.merge(PxOrPercentSize) .extend({ type: z.literal('icon'), source: z.string(), @@ -57,7 +57,7 @@ export const Icon = Base.merge(PxOrPercentSize) * Visually represented by a `BitmapText`. * @see {@link https://pixijs.download/release/docs/scene.BitmapText.html} */ -export const Text = Base.extend({ +export const textSchema = Base.extend({ type: z.literal('text'), placement: Placement.default('center'), margin: Margin.default(0), @@ -68,10 +68,10 @@ export const Text = Base.extend({ }).strict(); export const componentSchema = z.discriminatedUnion('type', [ - Background, - Bar, - Icon, - Text, + backgroundSchema, + barSchema, + iconSchema, + textSchema, ]); 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 26d0bf21..858dc5aa 100644 --- a/src/display/data-schema/component-schema.test.js +++ b/src/display/data-schema/component-schema.test.js @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { uid } from '../../utils/uuid'; import { - Background, - Bar, - Icon, - Text, + backgroundSchema, + barSchema, componentArraySchema, componentSchema, + iconSchema, + textSchema, } from './component-schema.js'; // Mocking a unique ID generator for predictable test outcomes. @@ -23,7 +23,7 @@ describe('Component Schemas', () => { describe('Background Schema', () => { it('should parse with a string source', () => { const data = { type: 'background', source: 'image.png' }; - const parsed = Background.parse(data); + const parsed = backgroundSchema.parse(data); expect(parsed.source).toBe('image.png'); expect(parsed.id).toBe('mock-id-0'); // check default from Base }); @@ -33,13 +33,13 @@ describe('Component Schemas', () => { type: 'background', source: { type: 'rect', fill: 'red' }, }; - const parsed = Background.parse(data); + const parsed = backgroundSchema.parse(data); expect(parsed.source).toEqual({ type: 'rect', fill: 'red' }); }); it('should fail with an invalid source type', () => { const data = { type: 'background', source: 123 }; - expect(() => Background.parse(data)).toThrow(); + expect(() => backgroundSchema.parse(data)).toThrow(); }); it('should fail if an unknown property is provided', () => { @@ -48,7 +48,7 @@ describe('Component Schemas', () => { source: 'image.png', unknown: 'property', }; - expect(() => Background.parse(data)).toThrow(); + expect(() => backgroundSchema.parse(data)).toThrow(); }); }); @@ -56,7 +56,7 @@ describe('Component Schemas', () => { const baseBar = { type: 'bar', source: { type: 'rect', fill: 'blue' } }; it('should parse a minimal valid bar and apply all defaults', () => { - const parsed = Bar.parse(baseBar); + const parsed = barSchema.parse(baseBar); expect(parsed.placement).toBe('bottom'); expect(parsed.margin).toEqual({ top: 0, right: 0, bottom: 0, left: 0 }); expect(parsed.animation).toBe(true); @@ -75,7 +75,7 @@ describe('Component Schemas', () => { animation: false, animationDuration: 1000, }; - const parsed = Bar.parse(data); + const parsed = barSchema.parse(data); expect(parsed.placement).toBe('top'); expect(parsed.margin).toEqual({ top: -20, @@ -92,13 +92,15 @@ describe('Component Schemas', () => { it('should fail if source is missing or has invalid type', () => { const { source, ...rest } = baseBar; - expect(() => Bar.parse(rest)).toThrow(); // Missing source - expect(() => Bar.parse({ ...baseBar, source: 'a-string' })).toThrow(); // Invalid source type + expect(() => barSchema.parse(rest)).toThrow(); // Missing source + expect(() => + barSchema.parse({ ...baseBar, source: 'a-string' }), + ).toThrow(); // Invalid source type }); it('should fail if an unknown property is provided', () => { const data = { ...baseBar, width: 100, another: 'property' }; - expect(() => Bar.parse(data)).toThrow(); + expect(() => barSchema.parse(data)).toThrow(); }); }); @@ -106,7 +108,7 @@ describe('Component Schemas', () => { const baseIcon = { type: 'icon', source: 'icon.svg' }; it('should parse a minimal valid icon and apply defaults', () => { - const parsed = Icon.parse(baseIcon); + const parsed = iconSchema.parse(baseIcon); expect(parsed.source).toBe('icon.svg'); expect(parsed.placement).toBe('center'); expect(parsed.margin).toEqual({ top: 0, right: 0, bottom: 0, left: 0 }); @@ -115,29 +117,29 @@ describe('Component Schemas', () => { it('should parse correctly with size properties', () => { const data = { ...baseIcon, size: '75%' }; - const parsed = Icon.parse(data); + const parsed = iconSchema.parse(data); expect(parsed.size).toEqual({ value: 75, unit: '%' }); }); it('should fail if required `source` is missing', () => { const data = { type: 'icon', size: 50 }; - expect(() => Icon.parse(data)).toThrow(); + expect(() => iconSchema.parse(data)).toThrow(); }); it('should fail if `source` is not a string', () => { const data = { type: 'icon', source: {} }; - expect(() => Icon.parse(data)).toThrow(); + expect(() => iconSchema.parse(data)).toThrow(); }); it('should fail if an unknown property is provided', () => { const data = { ...baseIcon, extra: 'property' }; - expect(() => Icon.parse(data)).toThrow(); + expect(() => iconSchema.parse(data)).toThrow(); }); }); describe('Text Schema', () => { it('should parse valid text and apply all defaults', () => { - const parsed = Text.parse({ type: 'text' }); + const parsed = textSchema.parse({ type: 'text' }); expect(parsed.text).toBe(''); expect(parsed.style.fontFamily).toBe('FiraCode'); expect(parsed.style.fill).toBe('black'); @@ -151,7 +153,7 @@ describe('Component Schemas', () => { type: 'text', style: { fill: 'red', fontSize: 24 }, }; - const parsed = Text.parse(data); + const parsed = textSchema.parse(data); expect(parsed.style.fill).toBe('red'); // Overridden expect(parsed.style.fontSize).toBe(24); // Added expect(parsed.style.fontFamily).toBe('FiraCode'); // Default maintained @@ -159,12 +161,12 @@ describe('Component Schemas', () => { it('should fail if `split` is not an integer', () => { const data = { type: 'text', split: 1.5 }; - expect(() => Text.parse(data)).toThrow(); + expect(() => textSchema.parse(data)).toThrow(); }); it('should fail if an unknown property is provided', () => { const data = { type: 'text', text: 'hello', somethingElse: 'test' }; - expect(() => Text.parse(data)).toThrow(); + expect(() => textSchema.parse(data)).toThrow(); }); }); diff --git a/src/display/data-schema/element-schema.js b/src/display/data-schema/element-schema.js index 498683a8..3dc137e9 100644 --- a/src/display/data-schema/element-schema.js +++ b/src/display/data-schema/element-schema.js @@ -7,7 +7,7 @@ import { Base, Gap, RelationsStyle, Size } from './primitive-schema'; * Visually represented by a `Container`. * @see {@link https://pixijs.download/release/docs/scene.Container.html} */ -export const Group = Base.extend({ +export const groupSchema = Base.extend({ type: z.literal('group'), children: z.array(z.lazy(() => elementTypes)), }).strict(); @@ -18,7 +18,7 @@ export const Group = Base.extend({ * Visually represented by a `Container`. * @see {@link https://pixijs.download/release/docs/scene.Container.html} */ -export const Grid = Base.extend({ +export const gridSchema = Base.extend({ type: z.literal('grid'), cells: z.array(z.array(z.union([z.literal(0), z.literal(1)]))), gap: Gap, @@ -31,7 +31,7 @@ export const Grid = Base.extend({ * Visually represented by a `Container`. * @see {@link https://pixijs.download/release/docs/scene.Container.html} */ -export const Item = Base.merge(Size) +export const itemSchema = Base.merge(Size) .extend({ type: z.literal('item'), components: componentArraySchema, @@ -44,17 +44,17 @@ export const Item = Base.merge(Size) * Visually represented by a `Container`. * @see {@link https://pixijs.download/release/docs/scene.Container.html} */ -export const Relations = Base.extend({ +export const relationsSchema = Base.extend({ type: z.literal('relations'), links: z.array(z.object({ source: z.string(), target: z.string() })), style: RelationsStyle, }).strict(); const elementTypes = z.discriminatedUnion('type', [ - Group, - Grid, - Item, - Relations, + groupSchema, + gridSchema, + itemSchema, + relationsSchema, ]); export const mapDataSchema = z diff --git a/src/display/data-schema/element-schema.test.js b/src/display/data-schema/element-schema.test.js index fc6574a7..3b709770 100644 --- a/src/display/data-schema/element-schema.test.js +++ b/src/display/data-schema/element-schema.test.js @@ -2,11 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; import { uid } from '../../utils/uuid'; import { - Grid, - Group, - Item, - Relations, + gridSchema, + groupSchema, + itemSchema, mapDataSchema, + relationsSchema, } from './element-schema.js'; // Mock component-schema as its details are not relevant for these element tests. @@ -31,14 +31,14 @@ describe('Element Schemas', () => { id: 'group-1', children: [{ type: 'item', id: 'item-1', width: 100, height: 100 }], }; - const parsed = Group.parse(groupData); + 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(() => Group.parse(groupData)).not.toThrow(); + expect(() => groupSchema.parse(groupData)).not.toThrow(); }); it('should fail if children contains an invalid element', () => { @@ -47,7 +47,7 @@ describe('Element Schemas', () => { id: 'group-1', children: [{ type: 'invalid-type' }], }; - expect(() => Group.parse(invalidGroupData)).toThrow(); + expect(() => groupSchema.parse(invalidGroupData)).toThrow(); }); it('should fail if an unknown property is provided', () => { @@ -57,7 +57,7 @@ describe('Element Schemas', () => { children: [], extra: 'property', }; - expect(() => Group.parse(groupData)).toThrow(); + expect(() => groupSchema.parse(groupData)).toThrow(); }); }); @@ -70,35 +70,35 @@ describe('Element Schemas', () => { }; it('should parse a valid grid and preprocess gap', () => { - const parsed = Grid.parse(baseGrid); + const parsed = gridSchema.parse(baseGrid); expect(parsed.gap).toEqual({ x: 0, y: 0 }); expect(parsed.item).toEqual({ width: 50, height: 50, components: [] }); }); it('should fail if cells contains invalid values', () => { const gridData = { ...baseGrid, cells: [[1, 2]] }; - expect(() => Grid.parse(gridData)).toThrow(); + expect(() => gridSchema.parse(gridData)).toThrow(); }); it('should fail if item is missing required size', () => { const gridData = { ...baseGrid, item: { components: [] } }; - expect(() => Grid.parse(gridData)).toThrow(); + expect(() => gridSchema.parse(gridData)).toThrow(); }); it('should fail if required properties are missing', () => { - expect(() => Grid.parse({ type: 'grid', id: 'g1' })).toThrow(); // missing cells, item + 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(() => Grid.parse(gridData)).toThrow(); + expect(() => gridSchema.parse(gridData)).toThrow(); }); }); describe('Item Schema', () => { it('should parse a valid item with required properties', () => { const itemData = { type: 'item', id: 'item-1', width: 100, height: 200 }; - const parsed = Item.parse(itemData); + const parsed = itemSchema.parse(itemData); expect(parsed.width).toBe(100); expect(parsed.height).toBe(200); expect(parsed.components).toEqual([]); // default value @@ -106,7 +106,7 @@ describe('Element Schemas', () => { it('should fail if required size properties are missing', () => { const itemData = { type: 'item', id: 'item-1' }; - expect(() => Item.parse(itemData)).toThrow(); + expect(() => itemSchema.parse(itemData)).toThrow(); }); it('should fail if an unknown property is provided', () => { @@ -117,7 +117,7 @@ describe('Element Schemas', () => { height: 100, x: 50, // This is an unknown property }; - expect(() => Item.parse(itemData)).toThrow(); + expect(() => itemSchema.parse(itemData)).toThrow(); }); }); @@ -128,7 +128,7 @@ describe('Element Schemas', () => { id: 'rel-1', links: [{ source: 'a', target: 'b' }], }; - const parsed = Relations.parse(relationsData); + const parsed = relationsSchema.parse(relationsData); expect(parsed.links).toHaveLength(1); expect(parsed.style).toEqual({ color: 'black' }); }); @@ -140,7 +140,7 @@ describe('Element Schemas', () => { links: [], style: { color: 'blue', lineWidth: 2 }, }; - const parsed = Relations.parse(relationsData); + const parsed = relationsSchema.parse(relationsData); expect(parsed.style).toEqual({ color: 'blue', lineWidth: 2 }); }); @@ -150,7 +150,7 @@ describe('Element Schemas', () => { id: 'rel-1', links: [{ source: 'a' }], // missing target }; - expect(() => Relations.parse(relationsData)).toThrow(); + expect(() => relationsSchema.parse(relationsData)).toThrow(); }); it('should fail if an unknown property is provided', () => { @@ -160,7 +160,7 @@ describe('Element Schemas', () => { links: [], extra: 'data', }; - expect(() => Relations.parse(relationsData)).toThrow(); + expect(() => relationsSchema.parse(relationsData)).toThrow(); }); }); diff --git a/src/display/draw.js b/src/display/draw.js index 35094472..7348ada0 100644 --- a/src/display/draw.js +++ b/src/display/draw.js @@ -1,14 +1,11 @@ -import { createGrid } from './elements/grid'; -import { createGroup } from './elements/group'; -import { createItem } from './elements/item'; -import { createRelations } from './elements/relations'; +import { Grid, Group, Item, Relations } from './elements'; import { update } from './update/update'; -const elementcreators = { - group: createGroup, - grid: createGrid, - item: createItem, - relations: createRelations, +const Creator = { + group: Group, + grid: Grid, + item: Item, + relations: Relations, }; export const draw = (context, data) => { @@ -18,16 +15,12 @@ export const draw = (context, 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); + const element = new Creator[config.type](viewport); + update(context, { elements: element, changes: config }); + parent.addChild(element); - if (config.type === 'group') { - render(element, config.children); - } + if (config.type === 'group') { + render(element, config.children); } } } diff --git a/src/display/elements/Element.js b/src/display/elements/Element.js new file mode 100644 index 00000000..aee83208 --- /dev/null +++ b/src/display/elements/Element.js @@ -0,0 +1,53 @@ +import { Viewport } from 'pixi-viewport'; +import { Container } from 'pixi.js'; +import { z } from 'zod'; +import { isValidationError } from 'zod-validation-error'; +import { validate } from '../../utils/validator'; +import { deepPartial } from '../../utils/zod-deep-strict-partial'; +import { elementPipeline } from '../change/pipeline/element'; +import { updateObject } from '../update/update-object'; + +const createSchema = z.object({ + type: z.string(), + viewport: z.instanceof(Viewport), + isRenderGroup: z.boolean().default(false), + pipelines: z.array(z.string()).default([]), +}); + +export default class Element extends Container { + /** + * The type of the element. This property is read-only. + * @private + * @type {string} + */ + #type; + + #pipelines; + + constructor(options) { + const validated = validate(options, createSchema); + if (isValidationError(validated)) throw validated; + const { type, pipelines, ...rest } = validated; + super(Object.assign(rest, { eventMode: 'static' })); + this.#type = type; + this.#pipelines = pipelines; + } + + /** + * Returns the type of the element. + * @returns {string} + */ + get type() { + return this.#type; + } + + get pipelines() { + return this.#pipelines; + } + + update(changes, schema, options) { + const validated = validate(changes, deepPartial(schema)); + if (isValidationError(validated)) throw validated; + updateObject(this, validated, elementPipeline, this.pipelines, options); + } +} diff --git a/src/display/elements/Grid.js b/src/display/elements/Grid.js new file mode 100644 index 00000000..2b6af24c --- /dev/null +++ b/src/display/elements/Grid.js @@ -0,0 +1,16 @@ +import { gridSchema } from '../data-schema/element-schema'; +import Element from './Element'; + +export class Grid extends Element { + constructor(viewport) { + super({ + type: 'grid', + viewport, + pipelines: ['show', 'position', 'gridComponents'], + }); + } + + 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..7af79329 --- /dev/null +++ b/src/display/elements/Group.js @@ -0,0 +1,17 @@ +import { groupSchema } from '../data-schema/element-schema'; +import Element from './Element'; + +export class Group extends Element { + constructor(viewport) { + super({ + type: 'group', + pipelines: ['show', 'position'], + viewport, + 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..f8835ee3 --- /dev/null +++ b/src/display/elements/Item.js @@ -0,0 +1,16 @@ +import { itemSchema } from '../data-schema/element-schema'; +import Element from './Element'; + +export class Item extends Element { + constructor(viewport) { + super({ + type: 'item', + viewport, + pipelines: ['show', 'position', 'components'], + }); + } + + 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..de5bfcb6 --- /dev/null +++ b/src/display/elements/Relations.js @@ -0,0 +1,24 @@ +import { Graphics } from 'pixi.js'; +import { relationsSchema } from '../data-schema/element-schema'; +import Element from './Element'; + +export class Relations extends Element { + constructor(viewport) { + super({ + type: 'relations', + viewport, + pipelines: ['show', 'strokeStyle', 'links'], + }); + this.initPath(); + } + + update(changes, options) { + super.update(changes, relationsSchema, options); + } + + initPath() { + const path = new Graphics(); + Object.assign(path, { type: 'path', links: [] }); + this.addChild(path); + } +} diff --git a/src/display/elements/grid.js b/src/display/elements/grid.js deleted file mode 100644 index 6b75ad9a..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 { deepPartial } from '../../utils/zod-deep-strict-partial'; -import { elementPipeline } from '../change/pipeline/element'; -import { Grid } from '../data-schema/element-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.x, config.y); - element.config = { - ...element.config, - position: { x: config.x, y: config.y }, - cells: config.cells, - itemSize: { - width: config.item.width, - height: config.item.height, - }, - }; - addItemElements(element, config.cells, element.config.itemSize); - return element; -}; - -const pipelineKeys = ['show', 'position', 'gridComponents']; -export const updateGrid = (element, changes, options) => { - const validated = validate(changes, deepPartial(Grid)); - if (isValidationError(validated)) throw validated; - updateObject(element, validated, 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}`, - x: colIndex * (cellSize.width + GRID_OBJECT_CONFIG.margin), - y: rowIndex * (cellSize.height + GRID_OBJECT_CONFIG.margin), - 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 ef7f695e..00000000 --- a/src/display/elements/group.js +++ /dev/null @@ -1,19 +0,0 @@ -import { isValidationError } from 'zod-validation-error'; -import { validate } from '../../utils/validator'; -import { deepPartial } from '../../utils/zod-deep-strict-partial'; -import { elementPipeline } from '../change/pipeline/element'; -import { Group } from '../data-schema/element-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, deepPartial(Group)); - if (isValidationError(validated)) throw validated; - updateObject(element, validated, elementPipeline, pipelineKeys, options); -}; diff --git a/src/display/elements/index.js b/src/display/elements/index.js new file mode 100644 index 00000000..929bed53 --- /dev/null +++ b/src/display/elements/index.js @@ -0,0 +1,4 @@ +export { Grid } from './Grid'; +export { Group } from './Group'; +export { Item } from './Item'; +export { Relations } from './Relations'; diff --git a/src/display/elements/item.js b/src/display/elements/item.js deleted file mode 100644 index b6a70af7..00000000 --- a/src/display/elements/item.js +++ /dev/null @@ -1,26 +0,0 @@ -import { isValidationError } from 'zod-validation-error'; -import { validate } from '../../utils/validator'; -import { deepPartial } from '../../utils/zod-deep-strict-partial'; -import { elementPipeline } from '../change/pipeline/element'; -import { Item } from '../data-schema/element-schema'; -import { updateObject } from '../update/update-object'; -import { createContainer } from '../utils'; - -export const createItem = (config) => { - const element = createContainer(config); - element.position.set(config.x, config.y); - element.size = { width: config.width, height: config.height }; - element.config = { - ...element.config, - position: { x: config.x, y: config.y }, - size: element.size, - }; - return element; -}; - -const pipelineKeys = ['show', 'position', 'components']; -export const updateItem = (element, changes, options) => { - const validated = validate(changes, deepPartial(Item)); - if (isValidationError(validated)) throw validated; - updateObject(element, validated, elementPipeline, pipelineKeys, options); -}; diff --git a/src/display/elements/relations.js b/src/display/elements/relations.js deleted file mode 100644 index 9cf5ca69..00000000 --- a/src/display/elements/relations.js +++ /dev/null @@ -1,28 +0,0 @@ -import { Graphics } from 'pixi.js'; -import { isValidationError } from 'zod-validation-error'; -import { validate } from '../../utils/validator'; -import { deepPartial } from '../../utils/zod-deep-strict-partial'; -import { elementPipeline } from '../change/pipeline/element'; -import { Relations } from '../data-schema/element-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, deepPartial(Relations)); - if (isValidationError(validated)) throw validated; - updateObject(element, validated, elementPipeline, pipelineKeys, options); -}; - -const createPath = () => { - const path = new Graphics(); - Object.assign(path, { type: 'path', links: [] }); - return path; -}; diff --git a/src/display/update/update-object.js b/src/display/update/update-object.js index e71050f2..ca86eb9a 100644 --- a/src/display/update/update-object.js +++ b/src/display/update/update-object.js @@ -1,6 +1,6 @@ import { changeProperty } from '../change'; -const DEFAULT_EXCEPTION_KEYS = new Set(['position', 'children']); +const DEFAULT_EXCEPTION_KEYS = new Set(['position', 'children', 'type']); export const updateObject = ( object, diff --git a/src/display/update/update.js b/src/display/update/update.js index c8e93f85..02530f28 100644 --- a/src/display/update/update.js +++ b/src/display/update/update.js @@ -4,10 +4,6 @@ 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), @@ -16,13 +12,6 @@ const updateSchema = z.object({ 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; @@ -41,10 +30,7 @@ export const update = (context, opts) => { elConfig.changes = applyRelativeTransform(element, elConfig.changes); } - const updater = elementUpdaters[element.type]; - if (updater) { - updater(element, elConfig.changes, { historyId, ...otherContext }); - } + element.update(elConfig.changes, { historyId, ...otherContext }); } }; diff --git a/src/display/utils.js b/src/display/utils.js index 86ce2b65..0ad99de3 100644 --- a/src/display/utils.js +++ b/src/display/utils.js @@ -1,9 +1,28 @@ import { Container } from 'pixi.js'; -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; +export const createElement = ({ type, viewport, isRenderGroup = false }) => { + return new Element({ type, viewport, isRenderGroup, eventMode: 'static' }); }; + +export class Element extends Container { + /** + * The type of the element. This property is read-only. + * @private + * @type {string} + */ + #type; + + constructor(options) { + const { type, ...rest } = options; + super(rest); + this.#type = type; + } + + /** + * Returns the type of the element. + * @returns {string} + */ + get type() { + return this.#type; + } +} diff --git a/src/patchmap.js b/src/patchmap.js index abfa0e04..69426b33 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -122,37 +122,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) { From 140d81b21d18fb715b30b995fdc79d483cdb3588 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 30 Jun 2025 18:02:51 +0900 Subject: [PATCH 16/66] fix component logic --- src/display/components/Background.js | 27 +++++++++++++++ src/display/components/Bar.js | 34 +++++++++++++++++++ src/display/components/Icon.js | 27 +++++++++++++++ src/display/components/Text.js | 27 +++++++++++++++ src/display/components/background.js | 16 --------- src/display/components/bar.js | 23 ------------- src/display/components/icon.js | 16 --------- src/display/components/index.js | 4 +++ src/display/components/text.js | 16 --------- src/display/components/validate-update.js | 17 ++++++++++ src/display/update/update-components.js | 40 +++++++---------------- 11 files changed, 147 insertions(+), 100 deletions(-) create mode 100644 src/display/components/Background.js create mode 100644 src/display/components/Bar.js create mode 100644 src/display/components/Icon.js create mode 100644 src/display/components/Text.js delete mode 100644 src/display/components/background.js delete mode 100644 src/display/components/bar.js delete mode 100644 src/display/components/icon.js create mode 100644 src/display/components/index.js delete mode 100644 src/display/components/text.js create mode 100644 src/display/components/validate-update.js diff --git a/src/display/components/Background.js b/src/display/components/Background.js new file mode 100644 index 00000000..c571a382 --- /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 { validateUpdate } from './validate-update'; + +export class Background extends NineSliceSprite { + #type; + + #pipelines; + + constructor() { + super({ texture: Texture.WHITE }); + this.#type = 'background'; + this.#pipelines = ['show', 'texture', 'textureTransform', 'tint']; + } + + get type() { + return this.#type; + } + + get pipelines() { + return this.#pipelines; + } + + update(changes, options) { + validateUpdate(this, changes, backgroundSchema, options); + } +} diff --git a/src/display/components/Bar.js b/src/display/components/Bar.js new file mode 100644 index 00000000..6679bffb --- /dev/null +++ b/src/display/components/Bar.js @@ -0,0 +1,34 @@ +import { NineSliceSprite, Texture } from 'pixi.js'; +import { barSchema } from '../data-schema/component-schema'; +import { validateUpdate } from './validate-update'; + +export class Bar extends NineSliceSprite { + #type; + + #pipelines; + + constructor() { + super({ texture: Texture.WHITE }); + this.#type = 'bar'; + this.#pipelines = [ + 'animation', + 'show', + 'texture', + 'tint', + 'percentSize', + 'placement', + ]; + } + + get type() { + return this.#type; + } + + get pipelines() { + return this.#pipelines; + } + + update(changes, options) { + validateUpdate(this, changes, barSchema, options); + } +} diff --git a/src/display/components/Icon.js b/src/display/components/Icon.js new file mode 100644 index 00000000..412f9821 --- /dev/null +++ b/src/display/components/Icon.js @@ -0,0 +1,27 @@ +import { Sprite, Texture } from 'pixi.js'; +import { iconSchema } from '../data-schema/component-schema'; +import { validateUpdate } from './validate-update'; + +export class Icon extends Sprite { + #type; + + #pipelines; + + constructor() { + super({ texture: Texture.WHITE }); + this.#type = 'icon'; + this.#pipelines = ['show', 'asset', 'size', 'tint', 'placement']; + } + + get type() { + return this.#type; + } + + get pipelines() { + return this.#pipelines; + } + + update(changes, options) { + validateUpdate(this, changes, iconSchema, options); + } +} diff --git a/src/display/components/Text.js b/src/display/components/Text.js new file mode 100644 index 00000000..5c3b0f38 --- /dev/null +++ b/src/display/components/Text.js @@ -0,0 +1,27 @@ +import { BitmapText } from 'pixi.js'; +import { textSchema } from '../data-schema/component-schema'; +import { validateUpdate } from './validate-update'; + +export class Text extends BitmapText { + #type; + + #pipelines; + + constructor() { + super({ text: '' }); + this.#type = 'text'; + this.#pipelines = ['show', 'text', 'textStyle', 'placement']; + } + + get type() { + return this.#type; + } + + get pipelines() { + return this.#pipelines; + } + + update(changes, options) { + validateUpdate(this, 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/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/index.js b/src/display/components/index.js new file mode 100644 index 00000000..7605cefe --- /dev/null +++ b/src/display/components/index.js @@ -0,0 +1,4 @@ +export { Background } from './Background'; +export { Bar } from './Bar'; +export { Icon } from './Icon'; +export { Text } from './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/components/validate-update.js b/src/display/components/validate-update.js new file mode 100644 index 00000000..e9ff4f1a --- /dev/null +++ b/src/display/components/validate-update.js @@ -0,0 +1,17 @@ +import { isValidationError } from 'zod-validation-error'; +import { validate } from '../../utils/validator'; +import { deepPartial } from '../../utils/zod-deep-strict-partial'; +import { componentPipeline } from '../change/pipeline/component'; +import { updateObject } from '../update/update-object'; + +export const validateUpdate = (context, changes, schema, options) => { + const validated = validate(changes, deepPartial(schema)); + if (isValidationError(validated)) throw validated; + updateObject( + context, + validated, + componentPipeline, + context.pipelines, + options, + ); +}; diff --git a/src/display/update/update-components.js b/src/display/update/update-components.js index 6c9a938a..28402204 100644 --- a/src/display/update/update-components.js +++ b/src/display/update/update-components.js @@ -1,29 +1,11 @@ import { findIndexByPriority } from '../../utils/findIndexByPriority'; -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 { Background, Bar, Icon, Text } from '../components'; -const componentFn = { - background: { - create: backgroundComponent, - update: updateBackgroundComponent, - }, - icon: { - create: iconComponent, - update: updateIconComponent, - }, - bar: { - create: barComponent, - update: updateBarComponent, - }, - text: { - create: textComponent, - update: updateTextComponent, - }, +const Creator = { + background: Background, + bar: Bar, + icon: Icon, + text: Text, }; export const updateComponents = ( @@ -34,25 +16,25 @@ export const updateComponents = ( if (!componentConfig) return; const itemComponents = [...item.children]; - for (const config of componentConfig) { - const idx = findIndexByPriority(itemComponents, config); + for (const changes of componentConfig) { + const idx = findIndexByPriority(itemComponents, changes); let component = null; if (idx !== -1) { component = itemComponents[idx]; itemComponents.splice(idx, 1); } else { - component = createComponent(config); + component = createComponent(changes); if (!component) continue; item.addChild(component); } - componentFn[component.type].update(component, config, options); + component.update(changes, options); } }; const createComponent = (config) => { - const component = componentFn[config.type].create({ ...config }); + const component = new Creator[config.type](); component.config = { ...component.config }; return component; }; From fe5fc822a4b6c173d094fae432dd8cf16b870285 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 1 Jul 2025 11:46:24 +0900 Subject: [PATCH 17/66] fix item element schema --- src/display/change/size.js | 2 +- src/display/change/text-style.js | 2 +- src/display/data-schema/element-schema.js | 13 ++-- .../data-schema/element-schema.test.js | 62 +++++++++++++------ src/display/update/update-object.js | 18 ++++-- src/utils/convert.js | 9 +-- 6 files changed, 68 insertions(+), 38 deletions(-) diff --git a/src/display/change/size.js b/src/display/change/size.js index ca6612da..99b66653 100644 --- a/src/display/change/size.js +++ b/src/display/change/size.js @@ -1,6 +1,6 @@ import { updateConfig } from './utils'; -export const changeSize = (object, { size = object.config.size }) => { +export const changeSize = (object, { size = object.size }) => { object.setSize(size.value); updateConfig(object, { size }); }; diff --git a/src/display/change/text-style.js b/src/display/change/text-style.js index c706f80e..f5092ce7 100644 --- a/src/display/change/text-style.js +++ b/src/display/change/text-style.js @@ -29,7 +29,7 @@ export const changeTextStyle = ( function setAutoFontSize(component, margin) { component.visible = false; - const { width, height } = component.parent.getSize(); + const { width, height } = component.parent.size; const parentSize = { width: width - margin.left - margin.right, height: height - margin.top - margin.bottom, diff --git a/src/display/data-schema/element-schema.js b/src/display/data-schema/element-schema.js index 3dc137e9..db934888 100644 --- a/src/display/data-schema/element-schema.js +++ b/src/display/data-schema/element-schema.js @@ -22,7 +22,7 @@ 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 }).merge(Size), + item: z.object({ components: componentArraySchema, size: Size }), }).strict(); /** @@ -31,12 +31,11 @@ export const gridSchema = Base.extend({ * Visually represented by a `Container`. * @see {@link https://pixijs.download/release/docs/scene.Container.html} */ -export const itemSchema = Base.merge(Size) - .extend({ - type: z.literal('item'), - components: componentArraySchema, - }) - .strict(); +export const itemSchema = Base.extend({ + type: z.literal('item'), + components: componentArraySchema, + size: Size, +}).strict(); /** * Represents relationships between elements by connecting them with lines. diff --git a/src/display/data-schema/element-schema.test.js b/src/display/data-schema/element-schema.test.js index 3b709770..6736ba76 100644 --- a/src/display/data-schema/element-schema.test.js +++ b/src/display/data-schema/element-schema.test.js @@ -29,7 +29,9 @@ describe('Element Schemas', () => { const groupData = { type: 'group', id: 'group-1', - children: [{ type: 'item', id: 'item-1', width: 100, height: 100 }], + children: [ + { type: 'item', id: 'item-1', size: { width: 100, height: 100 } }, + ], }; const parsed = groupSchema.parse(groupData); expect(parsed.children).toHaveLength(1); @@ -66,13 +68,16 @@ describe('Element Schemas', () => { type: 'grid', id: 'grid-1', cells: [[1]], - item: { width: 50, height: 50 }, + 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({ width: 50, height: 50, components: [] }); + expect(parsed.item).toEqual({ + size: { width: 50, height: 50 }, + components: [], + }); }); it('should fail if cells contains invalid values', () => { @@ -97,10 +102,14 @@ describe('Element Schemas', () => { describe('Item Schema', () => { it('should parse a valid item with required properties', () => { - const itemData = { type: 'item', id: 'item-1', width: 100, height: 200 }; + const itemData = { + type: 'item', + id: 'item-1', + size: { width: 100, height: 200 }, + }; const parsed = itemSchema.parse(itemData); - expect(parsed.width).toBe(100); - expect(parsed.height).toBe(200); + expect(parsed.size.width).toBe(100); + expect(parsed.size.height).toBe(200); expect(parsed.components).toEqual([]); // default value }); @@ -113,8 +122,7 @@ describe('Element Schemas', () => { const itemData = { type: 'item', id: 'item-1', - width: 100, - height: 100, + size: { width: 100, height: 100 }, x: 50, // This is an unknown property }; expect(() => itemSchema.parse(itemData)).toThrow(); @@ -167,11 +175,13 @@ describe('Element Schemas', () => { describe('mapDataSchema (Full Integration)', () => { it('should parse a valid array of mixed elements with unique IDs', () => { const data = [ - { type: 'item', id: 'item-1', width: 10, height: 10 }, + { type: 'item', id: 'item-1', size: { width: 10, height: 10 } }, { type: 'group', id: 'group-1', - children: [{ type: 'item', id: 'item-2', width: 10, height: 10 }], + children: [ + { type: 'item', id: 'item-2', size: { width: 10, height: 10 } }, + ], }, ]; expect(() => mapDataSchema.parse(data)).not.toThrow(); @@ -182,8 +192,8 @@ describe('Element Schemas', () => { .mockReturnValueOnce('mock-id-0') .mockReturnValueOnce('mock-id-1'); const data = [ - { type: 'item', width: 10, height: 10 }, - { type: 'item', width: 10, height: 10 }, + { 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'); @@ -206,20 +216,28 @@ describe('Element Schemas', () => { it('should fail for duplicate IDs at the root level', () => { const data = [ - { type: 'item', id: 'dup-id', width: 10, height: 10 }, - { type: 'item', id: 'dup-id', width: 10, height: 10 }, + { 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', width: 10, height: 10 }, + { + type: 'item', + id: 'cross-level-dup', + size: { width: 10, height: 10 }, + }, { type: 'group', id: 'group-1', children: [ - { type: 'item', id: 'cross-level-dup', width: 10, height: 10 }, + { + type: 'item', + id: 'cross-level-dup', + size: { width: 10, height: 10 }, + }, ], }, ]; @@ -238,10 +256,14 @@ describe('Element Schemas', () => { type: 'group', id: 'g2', children: [ - { type: 'item', id: 'deep-dup', width: 1, height: 1 }, + { + type: 'item', + id: 'deep-dup', + size: { width: 1, height: 1 }, + }, ], }, - { type: 'item', id: 'deep-dup', width: 1, height: 1 }, + { type: 'item', id: 'deep-dup', size: { width: 1, height: 1 } }, ], }, ]; @@ -253,8 +275,8 @@ describe('Element Schemas', () => { 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', width: 10, height: 10 }, - { type: 'item', width: 10, height: 10 }, // This will get default id 'mock-id-0' + { 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/update/update-object.js b/src/display/update/update-object.js index ca86eb9a..b67644f7 100644 --- a/src/display/update/update-object.js +++ b/src/display/update/update-object.js @@ -11,18 +11,26 @@ export const updateObject = ( ) => { 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 attrs = changes.attrs; + + if (attrs) { + for (const [key, value] of Object.entries(attrs)) { + changeProperty(object, key, value); } } + const pipelines = pipelineKeys.map((key) => pipeline[key]).filter(Boolean); 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); } } + + for (const { keys, handler } of pipelines) { + const hasMatch = keys.some((key) => key in changes); + if (hasMatch) { + handler(object, changes, options); + } + } }; diff --git a/src/utils/convert.js b/src/utils/convert.js index ea7753f9..c66ebbf2 100644 --- a/src/utils/convert.js +++ b/src/utils/convert.js @@ -30,8 +30,10 @@ export const convertLegacyData = (data) => { ), gap: 4, item: { - width: props.spec.width * 40, - height: props.spec.height * 40, + size: { + width: props.spec.width * 40, + height: props.spec.height * 40, + }, components: [ { type: 'background', @@ -102,8 +104,7 @@ export const convertLegacyData = (data) => { type: 'item', id: value.id, label: value.name, - width: 40, - height: 40, + size: { width: 40, height: 40 }, components: [ { type: 'background', From 51b22fa045364a29ddd72a69593938e833804f9f Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 1 Jul 2025 12:19:12 +0900 Subject: [PATCH 18/66] fix grid item render --- src/display/change/pipeline/element.js | 1 + src/display/data-schema/primitive-schema.js | 2 +- src/display/elements/Grid.js | 34 +++++++++++++++++++++ src/utils/convert.js | 2 ++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/display/change/pipeline/element.js b/src/display/change/pipeline/element.js index bd8a59c6..39968998 100644 --- a/src/display/change/pipeline/element.js +++ b/src/display/change/pipeline/element.js @@ -19,6 +19,7 @@ export const elementPipeline = { for (const cell of element.children) { updateComponents(cell, config.item, options); } + change.changeProperty(element, 'item', config.item); }, }, components: { diff --git a/src/display/data-schema/primitive-schema.js b/src/display/data-schema/primitive-schema.js index 5e9de36c..fc55ae66 100644 --- a/src/display/data-schema/primitive-schema.js +++ b/src/display/data-schema/primitive-schema.js @@ -95,7 +95,7 @@ export const Margin = z.preprocess( export const TextureStyle = z .object({ type: z.enum(['rect']), - fill: z.string().default('white'), + fill: z.string(), borderWidth: z.number(), borderColor: z.string(), radius: z.number(), diff --git a/src/display/elements/Grid.js b/src/display/elements/Grid.js index 2b6af24c..e763b972 100644 --- a/src/display/elements/Grid.js +++ b/src/display/elements/Grid.js @@ -1,5 +1,7 @@ +import { selector } from '../../utils/selector/selector'; import { gridSchema } from '../data-schema/element-schema'; import Element from './Element'; +import { Item } from './Item'; export class Grid extends Element { constructor(viewport) { @@ -12,5 +14,37 @@ export class Grid extends Element { update(changes, options) { super.update(changes, gridSchema, options); + this.updateItem(changes, options); + } + + updateItem(changes, options) { + const { gap = this.gap, cells = this.cells, item = this.item } = changes; + 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 element = + selector( + this, + `$.children[?(@.id==="${this.id}.${rowIndex}.${colIndex}")]`, + )[0] ?? new Item(this.viewport); + + element.update( + { + id: `${this.id}.${rowIndex}.${colIndex}`, + components: item.components, + size: { width: item.size.width, height: item.size.height }, + attrs: { + x: colIndex * (item.size.width + gap.x), + y: rowIndex * (item.size.height + gap.y), + }, + }, + options, + ); + this.addChild(element); + } + } } } diff --git a/src/utils/convert.js b/src/utils/convert.js index c66ebbf2..98284a5e 100644 --- a/src/utils/convert.js +++ b/src/utils/convert.js @@ -39,6 +39,7 @@ export const convertLegacyData = (data) => { type: 'background', source: { type: 'rect', + fill: 'white', borderWidth: 2, borderColor: 'primary.dark', radius: 6, @@ -110,6 +111,7 @@ export const convertLegacyData = (data) => { type: 'background', source: { type: 'rect', + fill: 'white', borderWidth: 2, borderColor: 'primary.default', radius: 6, From 9c5f58c9d4856c4613f392841d576204a2ebd6f1 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 1 Jul 2025 12:58:55 +0900 Subject: [PATCH 19/66] fix size schema --- src/display/change/percent-size.js | 14 +- src/display/change/pipeline/component.js | 2 +- src/display/change/size.js | 4 +- src/display/data-schema/component-schema.js | 38 ++-- .../data-schema/component-schema.test.js | 191 +++++++++++------- src/display/data-schema/primitive-schema.js | 28 ++- .../data-schema/primitive-schema.test.js | 137 ++++++------- src/utils/convert.js | 5 +- 8 files changed, 223 insertions(+), 196 deletions(-) diff --git a/src/display/change/percent-size.js b/src/display/change/percent-size.js index 0b4e7d1d..96faf012 100644 --- a/src/display/change/percent-size.js +++ b/src/display/change/percent-size.js @@ -5,21 +5,24 @@ import { isConfigMatch, killTweensOf, updateConfig } from './utils'; export const changePercentSize = ( object, { - width = object.config.width, - height = object.config.height, + size = object.config.size, margin = object.config.margin, animationDuration = object.config.animationDuration, }, { animationContext }, ) => { if ( - isConfigMatch(object, 'width', width) && - isConfigMatch(object, 'height', height) && + isConfigMatch(object, 'size', size) && isConfigMatch(object, 'margin', margin) ) { return; } + const { + width = object.config.size.width, + height = object.config.size.height, + } = size; + if (width.unit === '%') { changeWidth(object, width, margin); } @@ -27,8 +30,7 @@ export const changePercentSize = ( changeHeight(object, height, margin); } updateConfig(object, { - width, - height, + size, margin, animationDuration, }); diff --git a/src/display/change/pipeline/component.js b/src/display/change/pipeline/component.js index d05f37ff..289f1f7a 100644 --- a/src/display/change/pipeline/component.js +++ b/src/display/change/pipeline/component.js @@ -34,7 +34,7 @@ export const componentPipeline = { }, }, percentSize: { - keys: ['width', 'height', 'margin'], + keys: ['size', 'margin'], handler: (component, config, options) => { change.changePercentSize(component, config, options); change.changePlacement(component, {}); diff --git a/src/display/change/size.js b/src/display/change/size.js index 99b66653..50e01c2c 100644 --- a/src/display/change/size.js +++ b/src/display/change/size.js @@ -1,6 +1,6 @@ import { updateConfig } from './utils'; -export const changeSize = (object, { size = object.size }) => { - object.setSize(size.value); +export const changeSize = (object, { size = object.config.size }) => { + object.setSize(size.width.value, size.height.value); updateConfig(object, { size }); }; diff --git a/src/display/data-schema/component-schema.js b/src/display/data-schema/component-schema.js index f75ed94b..4f8b6c1c 100644 --- a/src/display/data-schema/component-schema.js +++ b/src/display/data-schema/component-schema.js @@ -25,32 +25,30 @@ export const backgroundSchema = Base.extend({ * Visually represented by a `NineSliceSprite`. * @see {@link https://pixijs.download/release/docs/scene.NineSliceSprite.html} */ -export const barSchema = Base.merge(PxOrPercentSize) - .extend({ - type: z.literal('bar'), - source: TextureStyle, - placement: Placement.default('bottom'), - margin: Margin.default(0), - tint: Tint.optional(), - animation: z.boolean().default(true), - animationDuration: z.number().default(200), - }) - .strict(); +export const barSchema = Base.extend({ + type: z.literal('bar'), + source: TextureStyle, + size: PxOrPercentSize, + placement: Placement.default('bottom'), + margin: Margin.default(0), + tint: Tint.optional(), + animation: z.boolean().default(true), + animationDuration: z.number().default(200), +}).strict(); /** * 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.merge(PxOrPercentSize) - .extend({ - type: z.literal('icon'), - source: z.string(), - placement: Placement.default('center'), - margin: Margin.default(0), - tint: Tint.optional(), - }) - .strict(); +export const iconSchema = Base.extend({ + type: z.literal('icon'), + source: z.string(), + size: PxOrPercentSize, + placement: Placement.default('center'), + margin: Margin.default(0), + tint: Tint.optional(), +}).strict(); /** * A text label component. diff --git a/src/display/data-schema/component-schema.test.js b/src/display/data-schema/component-schema.test.js index 858dc5aa..72eb1772 100644 --- a/src/display/data-schema/component-schema.test.js +++ b/src/display/data-schema/component-schema.test.js @@ -53,53 +53,80 @@ describe('Component Schemas', () => { }); describe('Bar Schema', () => { - const baseBar = { type: 'bar', source: { type: 'rect', fill: 'blue' } }; + const baseBar = { + type: 'bar', + source: { type: 'rect', fill: 'blue' }, + }; - it('should parse a minimal valid bar and apply all defaults', () => { - const parsed = barSchema.parse(baseBar); + 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); - expect(parsed.width).toBeUndefined(); - expect(parsed.height).toBeUndefined(); + // 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 correctly parse all properties and override defaults', () => { + it('should correctly parse an object size', () => { const data = { ...baseBar, - width: '50%', - height: 20, - placement: 'top', - margin: { x: 10, y: -20 }, // Negative margin is allowed - animation: false, - animationDuration: 1000, + size: { width: '50%', height: 20 }, }; const parsed = barSchema.parse(data); - expect(parsed.placement).toBe('top'); - expect(parsed.margin).toEqual({ - top: -20, - right: 10, - bottom: -20, - left: 10, + // The size object is parsed correctly. + expect(parsed.size).toEqual({ + width: { value: 50, unit: '%' }, + height: { value: 20, unit: 'px' }, }); - expect(parsed.animation).toBe(false); - expect(parsed.animationDuration).toBe(1000); - // Check if PxOrPercentSize transformation worked - expect(parsed.width).toEqual({ value: 50, unit: '%' }); - expect(parsed.height).toEqual({ value: 20, unit: 'px' }); }); - it('should fail if source is missing or has invalid type', () => { - const { source, ...rest } = baseBar; - expect(() => barSchema.parse(rest)).toThrow(); // Missing source - expect(() => - barSchema.parse({ ...baseBar, source: 'a-string' }), - ).toThrow(); // Invalid source type + it.each([ + { + case: 'single number', + input: 150, + expected: { + width: { value: 150, unit: 'px' }, + height: { value: 150, unit: 'px' }, + }, + }, + { + case: 'percentage string', + input: '75%', + expected: { + width: { value: 75, unit: '%' }, + height: { value: 75, unit: '%' }, + }, + }, + ])( + '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); + }, + ); + + 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 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 an unknown property is provided', () => { - const data = { ...baseBar, width: 100, another: 'property' }; + const data = { ...baseBar, size: 100, another: 'property' }; expect(() => barSchema.parse(data)).toThrow(); }); }); @@ -107,32 +134,60 @@ describe('Component Schemas', () => { describe('Icon Schema', () => { const baseIcon = { type: 'icon', source: 'icon.svg' }; - it('should parse a minimal valid icon and apply defaults', () => { - const parsed = iconSchema.parse(baseIcon); - expect(parsed.source).toBe('icon.svg'); + 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 }); - expect(parsed.size).toBeUndefined(); + // 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 parse correctly with size properties', () => { - const data = { ...baseIcon, size: '75%' }; - const parsed = iconSchema.parse(data); - expect(parsed.size).toEqual({ value: 75, unit: '%' }); - }); + it.each([ + { + case: 'percentage string', + input: '75%', + expected: { + width: { value: 75, unit: '%' }, + height: { value: 75, unit: '%' }, + }, + }, + { + 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` is missing', () => { - const data = { type: 'icon', size: 50 }; - expect(() => iconSchema.parse(data)).toThrow(); + 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 `source` is not a string', () => { - const data = { type: 'icon', source: {} }; - expect(() => iconSchema.parse(data)).toThrow(); + 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, extra: 'property' }; + const data = { ...baseIcon, size: 50, extra: 'property' }; expect(() => iconSchema.parse(data)).toThrow(); }); }); @@ -158,23 +213,19 @@ describe('Component Schemas', () => { expect(parsed.style.fontSize).toBe(24); // Added expect(parsed.style.fontFamily).toBe('FiraCode'); // Default maintained }); - - it('should fail if `split` is not an integer', () => { - const data = { type: 'text', split: 1.5 }; - expect(() => textSchema.parse(data)).toThrow(); - }); - - it('should fail if an unknown property is provided', () => { - const data = { type: 'text', text: 'hello', somethingElse: 'test' }; - expect(() => textSchema.parse(data)).toThrow(); - }); }); describe('componentSchema (Discriminated Union)', () => { it.each([ { case: 'a valid background', data: { type: 'background', source: 'a' } }, - { case: 'a valid bar', data: { type: 'bar', source: {} } }, - { case: 'a valid icon', data: { type: 'icon', 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(); @@ -186,14 +237,6 @@ describe('Component Schemas', () => { expect(result.success).toBe(false); expect(result.error.issues[0].code).toBe('invalid_union_discriminator'); }); - - it('should fail for a known type with missing required properties', () => { - // 'icon' requires 'source' - const data = { type: 'icon' }; - const result = componentSchema.safeParse(data); - expect(result.success).toBe(false); - expect(result.error.issues[0].path).toEqual(['source']); - }); }); describe('componentArraySchema', () => { @@ -202,24 +245,24 @@ describe('Component Schemas', () => { { type: 'background', source: 'bg.png' }, { type: 'text', text: 'Hello World' }, { type: 'icon', source: 'icon.svg', size: '10%' }, - { type: 'bar', source: { fill: 'green' }, width: 100 }, + { + type: 'bar', + source: { fill: 'green' }, + size: { width: 100, height: '20%' }, + }, ]; expect(() => componentArraySchema.parse(data)).not.toThrow(); }); - it('should parse an empty array', () => { - expect(() => componentArraySchema.parse([])).not.toThrow(); - }); - it('should fail if any single element in the array is invalid', () => { const data = [ { type: 'text', text: 'Valid' }, - { type: 'bar' }, // Invalid: missing 'source' + { 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. - expect(result.error.issues[0].path).toEqual([1, 'source']); + // 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/primitive-schema.js b/src/display/data-schema/primitive-schema.js index fc55ae66..d0417f80 100644 --- a/src/display/data-schema/primitive-schema.js +++ b/src/display/data-schema/primitive-schema.js @@ -19,16 +19,24 @@ export const Base = z }) .strict(); -export const Size = z.object({ - width: z.number().nonnegative(), - height: z.number().nonnegative(), -}); +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.object({ value: z.number().nonnegative(), unit: z.enum(['px', '%']) }), + z + .object({ value: z.number().nonnegative(), unit: z.enum(['px', '%']) }) + .strict(), ]) .transform((val) => { if (typeof val === 'number') { @@ -40,13 +48,13 @@ export const pxOrPercentSchema = z return val; }); -export const PxOrPercentSize = z - .object({ +export const PxOrPercentSize = z.union([ + pxOrPercentSchema.transform((val) => ({ width: val, height: val })), + z.object({ width: pxOrPercentSchema, height: pxOrPercentSchema, - size: pxOrPercentSchema, - }) - .partial(); + }), +]); export const Placement = z.enum([ 'left', diff --git a/src/display/data-schema/primitive-schema.test.js b/src/display/data-schema/primitive-schema.test.js index bc496b42..f8ca2fb5 100644 --- a/src/display/data-schema/primitive-schema.test.js +++ b/src/display/data-schema/primitive-schema.test.js @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { uid } from '../../utils/uuid'; +import { deepPartial } from '../../utils/zod-deep-strict-partial'; import { Base, Gap, @@ -56,8 +57,6 @@ describe('Primitive Schema Tests', () => { it.each(validColorSourceCases)( 'should correctly parse various color source types: $case', ({ value }) => { - // Since the schema is z.union([z.string(), z.any()]), - // it should not throw for any of these valid ColorSource formats. expect(() => Tint.parse(value)).not.toThrow(); const parsed = Tint.parse(value); expect(parsed).toEqual(value); @@ -67,7 +66,12 @@ describe('Primitive Schema Tests', () => { describe('Base Schema', () => { it('should parse a valid object with all properties', () => { - const data = { show: false, id: 'custom-id', attrs: { extra: 'value' } }; + const data = { + show: false, + id: 'custom-id', + label: 'My Base', + attrs: { extra: 'value' }, + }; const result = Base.parse(data); expect(result).toEqual(data); }); @@ -83,43 +87,28 @@ describe('Primitive Schema Tests', () => { const data = { show: true, unknownProperty: 'test' }; expect(() => Base.parse(data)).toThrow(); }); - - it('should allow "attrs" to contain any data type', () => { - const data = { - attrs: { - aNumber: 123, - aString: 'hello', - aBoolean: false, - aNull: null, - anObject: { nested: true }, - }, - }; - const result = Base.parse(data); - expect(result.attrs).toEqual(data.attrs); - }); }); describe('Size Schema', () => { - it.each([ - { case: 'positive integers', input: { width: 100, height: 200 } }, - { case: 'zero values', input: { width: 0, height: 0 } }, - { case: 'floating point numbers', input: { width: 10.5, height: 20.5 } }, - ])('should correctly parse valid size object for $case', ({ input }) => { + 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 width', input: { width: -100, height: 100 } }, - { case: 'negative height', input: { width: 100, height: -1 } }, - { case: 'invalid type (string)', input: { width: '100', height: 100 } }, - { case: 'missing height property', input: { width: 100 } }, - { case: 'NaN value', input: { width: 100, height: Number.NaN } }, - ])( - 'should throw an error for invalid size object for $case', - ({ input }) => { - expect(() => Size.parse(input)).toThrow(); - }, - ); + { 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', () => { @@ -134,21 +123,6 @@ describe('Primitive Schema Tests', () => { input: '80%', expected: { value: 80, unit: '%' }, }, - { - case: 'float percentage string', - input: '33.3%', - expected: { value: 33.3, unit: '%' }, - }, - { - case: 'zero pixel', - input: 0, - expected: { value: 0, unit: 'px' }, - }, - { - case: 'zero percent', - input: '0%', - expected: { value: 0, unit: '%' }, - }, { case: 'pre-formatted object', input: { value: 50, unit: 'px' }, @@ -161,46 +135,62 @@ describe('Primitive Schema Tests', () => { it.each([ { case: 'negative number', input: -100 }, { case: 'malformed percentage string', input: '100' }, - { case: 'percentage with space', input: '50 %' }, - { case: 'invalid unit string', input: '100em' }, { case: 'invalid pre-formatted object unit', input: { value: 50, unit: 'em' }, }, - { case: 'null input', input: null }, ])('should throw an error for invalid input for $case', ({ input }) => { expect(() => pxOrPercentSchema.parse(input)).toThrow(); }); }); describe('PxOrPercentSize Schema', () => { - it('should parse and transform mixed pixel and percentage values', () => { - const input = { width: 150, height: '75%' }; + it('should transform a single number into a full px width/height object', () => { + const input = 100; const expected = { - width: { value: 150, unit: 'px' }, - height: { value: 75, unit: '%' }, + width: { value: 100, unit: 'px' }, + height: { value: 100, unit: 'px' }, }; expect(PxOrPercentSize.parse(input)).toEqual(expected); }); - it('should correctly parse the new "size" property', () => { - const input = { size: '50%' }; - const expected = { size: { value: 50, unit: '%' } }; + 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 parse an empty object', () => { - expect(PxOrPercentSize.parse({})).toEqual({}); + 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 handle all properties at once', () => { - const input = { width: 100, height: '50%', size: 25 }; + it('', () => { + const input = { + width: { value: 150, unit: 'px' }, + height: { value: 75, unit: '%' }, + }; const expected = { - width: { value: 100, unit: 'px' }, - height: { value: 50, unit: '%' }, - size: { value: 25, unit: 'px' }, + width: { value: 150, unit: 'px' }, + height: { value: 75, unit: '%' }, }; - expect(PxOrPercentSize.parse(input)).toEqual(expected); + console.log(PxOrPercentSize._def); + 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(); }); }); @@ -220,7 +210,7 @@ describe('Primitive Schema Tests', () => { expect(() => Placement.parse(placement)).not.toThrow(); }); - it.each(['top-left', 'center-top', 'invalid-placement', '', null])( + it.each(['top-left', 'invalid-placement', null])( 'should reject invalid placement value: %s', (placement) => { expect(() => Placement.parse(placement)).toThrow(); @@ -360,19 +350,6 @@ describe('Primitive Schema Tests', () => { lineWidth: 2, }); }); - - it('should not override provided color', () => { - const data = { color: 'blue' }; - expect(RelationsStyle.parse(data)).toEqual({ color: 'blue' }); - }); - - it.each([ - { case: 'undefined', input: undefined }, - { case: 'null', input: null }, - { case: 'empty object', input: {} }, - ])('should return default object for $case input', ({ input }) => { - expect(RelationsStyle.parse(input)).toEqual({ color: 'black' }); - }); }); describe('TextStyle Schema', () => { diff --git a/src/utils/convert.js b/src/utils/convert.js index 98284a5e..3e7bc6b9 100644 --- a/src/utils/convert.js +++ b/src/utils/convert.js @@ -48,8 +48,7 @@ export const convertLegacyData = (data) => { { type: 'bar', show: false, - width: '100%', - height: '100%', + size: '100%', source: { type: 'rect', radius: 3 }, tint: 'primary.default', margin: 3, @@ -105,7 +104,7 @@ export const convertLegacyData = (data) => { type: 'item', id: value.id, label: value.name, - size: { width: 40, height: 40 }, + size: 40, components: [ { type: 'background', From 2e96866f0c6eed1b8e0bc07670aacfed4fd40635 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 1 Jul 2025 18:04:56 +0900 Subject: [PATCH 20/66] fix config management --- src/display/change/animation.js | 7 ++++--- src/display/change/asset.js | 6 +++--- src/display/change/links.js | 4 ++-- src/display/change/percent-size.js | 26 ++++++++------------------ src/display/change/placement.js | 6 +++--- src/display/change/position.js | 3 --- src/display/change/show.js | 3 --- src/display/change/size.js | 4 ++-- src/display/change/stroke-style.js | 4 ++-- src/display/change/text-style.js | 11 ++++------- src/display/change/text.js | 13 +++++-------- src/display/change/texture.js | 6 +++--- src/display/change/tint.js | 2 -- src/display/change/utils.js | 15 ++++++++------- src/display/draw.js | 10 +++++----- src/display/update/update.js | 2 +- src/utils/convert.js | 2 +- src/utils/diff/diff-json.test.js | 6 ++++++ 18 files changed, 57 insertions(+), 73 deletions(-) diff --git a/src/display/change/animation.js b/src/display/change/animation.js index 14427c5f..d4c16839 100644 --- a/src/display/change/animation.js +++ b/src/display/change/animation.js @@ -1,11 +1,12 @@ -import { isConfigMatch, tweensOf, updateConfig } from './utils'; +import { isMatch, mergeProps, tweensOf } from './utils'; export const changeAnimation = (object, { animation }) => { - if (isConfigMatch(object, 'animation', animation)) { + if (isMatch(object, { animation })) { return; } + if (!animation) { tweensOf(object).forEach((tween) => tween.progress(1).kill()); } - updateConfig(object, { animation }); + mergeProps(object, { animation }); }; diff --git a/src/display/change/asset.js b/src/display/change/asset.js index 14a71c30..bd4ee0f4 100644 --- a/src/display/change/asset.js +++ b/src/display/change/asset.js @@ -1,9 +1,9 @@ import { getTexture } from '../../assets/textures/texture'; import { getViewport } from '../../utils/get'; -import { isConfigMatch, updateConfig } from './utils'; +import { isMatch, mergeProps } from './utils'; export const changeAsset = (object, { source: assetConfig }, { theme }) => { - if (isConfigMatch(object, 'asset', assetConfig)) { + if (isMatch(object, { source: assetConfig })) { return; } @@ -13,5 +13,5 @@ export const changeAsset = (object, { source: assetConfig }, { theme }) => { console.warn(`Asset not found for config: ${JSON.stringify(assetConfig)}`); } object.texture = asset ?? null; - updateConfig(object, { asset: assetConfig }); + mergeProps(object, { source: assetConfig }); }; diff --git a/src/display/change/links.js b/src/display/change/links.js index fb1bbddd..c209d5e6 100644 --- a/src/display/change/links.js +++ b/src/display/change/links.js @@ -1,7 +1,7 @@ import { getScaleBounds } from '../../utils/canvas'; import { deepMerge } from '../../utils/deepmerge/deepmerge'; import { selector } from '../../utils/selector/selector'; -import { updateConfig } from './utils'; +import { mergeProps } from './utils'; export const changeLinks = (object, { links }) => { const path = selector(object, '$.children[?(@.type==="path")]')[0]; @@ -25,7 +25,7 @@ export const changeLinks = (object, { links }) => { path.links.push({ sourcePoint, targetPoint }); } path.stroke(); - updateConfig(object, { links }, true); + mergeProps(object, { links }, true); deepMerge(object, { metadata: { linkedIds: Object.keys(objs) } }); function collectLinkedObjects(viewport, links) { diff --git a/src/display/change/percent-size.js b/src/display/change/percent-size.js index 96faf012..4c26b461 100644 --- a/src/display/change/percent-size.js +++ b/src/display/change/percent-size.js @@ -1,27 +1,21 @@ import gsap from 'gsap'; import { changePlacement } from './placement'; -import { isConfigMatch, killTweensOf, updateConfig } from './utils'; +import { isMatch, killTweensOf, mergeProps } from './utils'; export const changePercentSize = ( object, { - size = object.config.size, - margin = object.config.margin, - animationDuration = object.config.animationDuration, + size = object.size, + margin = object.margin, + animationDuration = object.animationDuration, }, { animationContext }, ) => { - if ( - isConfigMatch(object, 'size', size) && - isConfigMatch(object, 'margin', margin) - ) { + if (isMatch(object, { size, margin })) { return; } - const { - width = object.config.size.width, - height = object.config.size.height, - } = size; + const { width = object.size.width, height = object.size.height } = size; if (width.unit === '%') { changeWidth(object, width, margin); @@ -29,11 +23,7 @@ export const changePercentSize = ( if (height.unit === '%') { changeHeight(object, height, margin); } - updateConfig(object, { - size, - margin, - animationDuration, - }); + mergeProps(object, { size, margin, animationDuration }); function changeWidth(component, width, marginObj) { const maxWidth = @@ -45,7 +35,7 @@ export const changePercentSize = ( const maxHeight = component.parent.size.height - (margin.top + margin.bottom); - if (object.config.animation) { + if (object.animation) { animationContext.add(() => { killTweensOf(component); gsap.to(component, { diff --git a/src/display/change/placement.js b/src/display/change/placement.js index 54c29bb4..f058cb1e 100644 --- a/src/display/change/placement.js +++ b/src/display/change/placement.js @@ -1,8 +1,8 @@ -import { updateConfig } from './utils'; +import { mergeProps } from './utils'; export const changePlacement = ( object, - { placement = object.config.placement, margin = object.config.margin }, + { placement = object.placement, margin = object.margin }, ) => { if (!placement || !margin) return; @@ -22,7 +22,7 @@ export const changePlacement = ( const y = getVerticalPosition(object, directions.v, margin); object.position.set(x, y); object.visible = true; - updateConfig(object, { placement, margin }); + mergeProps(object, { placement, margin }); function getHorizontalPosition(component, alignment, margin) { const parentWidth = component.parent.size.width; diff --git a/src/display/change/position.js b/src/display/change/position.js index 7fdff1db..8ad120a6 100644 --- a/src/display/change/position.js +++ b/src/display/change/position.js @@ -1,7 +1,4 @@ -import { updateConfig } from './utils'; - export const changePosition = (object, { x, y }) => { const position = object.position; object.position.set(x ?? position.x, y ?? position.y); - updateConfig(object, { x, y }); }; diff --git a/src/display/change/show.js b/src/display/change/show.js index 735f171f..834f097f 100644 --- a/src/display/change/show.js +++ b/src/display/change/show.js @@ -1,6 +1,3 @@ -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 index 50e01c2c..50859b75 100644 --- a/src/display/change/size.js +++ b/src/display/change/size.js @@ -1,6 +1,6 @@ -import { updateConfig } from './utils'; +import { mergeProps } from './utils'; export const changeSize = (object, { size = object.config.size }) => { object.setSize(size.width.value, size.height.value); - updateConfig(object, { size }); + mergeProps(object, { size }); }; diff --git a/src/display/change/stroke-style.js b/src/display/change/stroke-style.js index 9aa1e55f..1bc0e305 100644 --- a/src/display/change/stroke-style.js +++ b/src/display/change/stroke-style.js @@ -1,6 +1,6 @@ import { getColor } from '../../utils/get'; import { selector } from '../../utils/selector/selector'; -import { updateConfig } from './utils'; +import { mergeProps } from './utils'; export const changeStrokeStyle = (object, { style, links }, { theme }) => { const path = selector(object, '$.children[?(@.type==="path")]')[0]; @@ -14,7 +14,7 @@ export const changeStrokeStyle = (object, { style, links }, { theme }) => { if (!links && path.links.length > 0) { reRenderPath(path); } - updateConfig(object, { style }); + mergeProps(object, { style }); function reRenderPath(path) { path.clear(); diff --git a/src/display/change/text-style.js b/src/display/change/text-style.js index f5092ce7..602fcde9 100644 --- a/src/display/change/text-style.js +++ b/src/display/change/text-style.js @@ -1,16 +1,13 @@ import { getColor } from '../../utils/get'; import { FONT_WEIGHT } from '../components/config'; -import { isConfigMatch, updateConfig } from './utils'; +import { isMatch, mergeProps } from './utils'; export const changeTextStyle = ( object, - { style = object.config.style, margin = object.config.margin }, + { style = object.style, margin = object.margin }, { theme }, ) => { - if ( - isConfigMatch(object, 'style', style) && - isConfigMatch(object, 'margin', margin) - ) { + if (isMatch(object, { style, margin })) { return; } @@ -25,7 +22,7 @@ export const changeTextStyle = ( object.style[key] = style[key]; } } - updateConfig(object, { style, margin }); + mergeProps(object, { style, margin }); function setAutoFontSize(component, margin) { component.visible = false; diff --git a/src/display/change/text.js b/src/display/change/text.js index 1126d28b..5ab78e54 100644 --- a/src/display/change/text.js +++ b/src/display/change/text.js @@ -1,24 +1,21 @@ import { changeTextStyle } from './text-style'; -import { isConfigMatch, updateConfig } from './utils'; +import { isMatch, mergeProps } from './utils'; export const changeText = ( object, - { text = object.config.text, split = object.config.split }, + { text = object.text, split = object.split }, { theme }, ) => { - if ( - isConfigMatch(object, 'text', text) && - isConfigMatch(object, 'split', split) - ) { + if (isMatch(object, { text, split })) { return; } object.text = splitText(text, split); - if (object.config?.style?.fontSize === 'auto') { + if (object?.style?.fontSize === 'auto') { changeTextStyle(object, { style: { fontSize: 'auto' } }, { theme }); } - updateConfig(object, { text, split }); + mergeProps(object, { text, split }); function splitText(text, chunkSize) { if (chunkSize === 0 || chunkSize == null) { diff --git a/src/display/change/texture.js b/src/display/change/texture.js index ec3f16b5..b228e2d3 100644 --- a/src/display/change/texture.js +++ b/src/display/change/texture.js @@ -1,10 +1,10 @@ import { getTexture } from '../../assets/textures/texture'; import { deepMerge } from '../../utils/deepmerge/deepmerge'; import { getViewport } from '../../utils/get'; -import { isConfigMatch, updateConfig } from './utils'; +import { isMatch, mergeProps } from './utils'; export const changeTexture = (object, { source: textureConfig }, { theme }) => { - if (isConfigMatch(object, 'texture', textureConfig)) { + if (isMatch(object, { source: textureConfig })) { return; } @@ -16,5 +16,5 @@ export const changeTexture = (object, { source: textureConfig }, { theme }) => { ); object.texture = texture ?? null; Object.assign(object, { ...texture.metadata.slice }); - updateConfig(object, { texture: textureConfig }); + mergeProps(object, { source: textureConfig }); }; diff --git a/src/display/change/tint.js b/src/display/change/tint.js index 1cba08b4..77d51e3d 100644 --- a/src/display/change/tint.js +++ b/src/display/change/tint.js @@ -1,8 +1,6 @@ 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 index 08b5d5e4..ab49d8e2 100644 --- a/src/display/change/utils.js +++ b/src/display/change/utils.js @@ -2,15 +2,16 @@ 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 isMatch = (object, props) => { + return Object.keys(props).every((key) => { + const value = props[key]; + return value === undefined || isSame(object[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 mergeProps = (object, props = {}, overwrite = false) => { + for (const [key, value] of Object.entries(props)) { + object[key] = overwrite ? value : deepMerge(object[key], value); } }; diff --git a/src/display/draw.js b/src/display/draw.js index 7348ada0..eec9050b 100644 --- a/src/display/draw.js +++ b/src/display/draw.js @@ -14,13 +14,13 @@ export const draw = (context, data) => { render(viewport, data); function render(parent, data) { - for (const config of data) { - const element = new Creator[config.type](viewport); - update(context, { elements: element, changes: config }); + for (const changes of data) { + const element = new Creator[changes.type](viewport); + update(context, { elements: element, changes }); parent.addChild(element); - if (config.type === 'group') { - render(element, config.children); + if (changes.type === 'group') { + render(element, changes.children); } } } diff --git a/src/display/update/update.js b/src/display/update/update.js index 02530f28..b46cc463 100644 --- a/src/display/update/update.js +++ b/src/display/update/update.js @@ -16,7 +16,7 @@ export const update = (context, opts) => { const config = validate(opts, updateSchema.passthrough()); if (isValidationError(config)) throw config; - const { viewport = null, ...otherContext } = context; + const { viewport, ...otherContext } = context; const historyId = createHistoryId(config.saveToHistory); const elements = 'elements' in config ? convertArray(config.elements) : []; if (viewport && config.path) { diff --git a/src/utils/convert.js b/src/utils/convert.js index 3e7bc6b9..8e66afd6 100644 --- a/src/utils/convert.js +++ b/src/utils/convert.js @@ -49,7 +49,7 @@ export const convertLegacyData = (data) => { type: 'bar', show: false, size: '100%', - source: { type: 'rect', radius: 3 }, + source: { type: 'rect', radius: 3, fill: 'white' }, tint: 'primary.default', margin: 3, }, 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 }, From a46390124b963e597b1b067909d0ca5e34392647 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 1 Jul 2025 19:10:37 +0900 Subject: [PATCH 21/66] fix get color func --- src/utils/get.js | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/utils/get.js b/src/utils/get.js index 0e2513ab..08907c3a 100644 --- a/src/utils/get.js +++ b/src/utils/get.js @@ -1,16 +1,20 @@ -export const getNestedValue = (object, path = null) => { - if (!path) return null; - return path +export const getNestedValue = (object, path) => { + if (typeof path !== 'string' || !path) { + return null; + } + + const value = path .split('.') .reduce((acc, key) => (acc && acc[key] != null ? acc[key] : null), object); + return typeof value === 'string' ? value : null; }; export const getColor = (theme, color) => { - return ( - (typeof color === 'string' && color.startsWith('#') - ? color - : getNestedValue(theme, color)) ?? '#000' - ); + if (typeof color !== 'string') { + return color; + } + const themeColor = getNestedValue(theme, color); + return themeColor ?? color; }; export const getViewport = (object) => { From 97900d64566b8bc56ba3f661fd0a47cb6d2a3f91 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 2 Jul 2025 11:03:25 +0900 Subject: [PATCH 22/66] fix relativeTransform --- src/display/update/update.js | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/display/update/update.js b/src/display/update/update.js index b46cc463..2c3b0ce1 100644 --- a/src/display/update/update.js +++ b/src/display/update/update.js @@ -24,25 +24,27 @@ export const update = (context, opts) => { } for (const element of elements) { - if (!element) continue; - const elConfig = { ...config }; - if (elConfig.relativeTransform) { - elConfig.changes = applyRelativeTransform(element, elConfig.changes); + if (!element) { + continue; } - - element.update(elConfig.changes, { historyId, ...otherContext }); + const { relativeTransform } = config; + const changes = JSON.parse(JSON.stringify(config.changes)); + if (relativeTransform && changes.attrs) { + changes.attrs = applyRelativeTransform(element, changes.attrs); + } + element.update(changes, { historyId, ...otherContext }); } }; const applyRelativeTransform = (element, changes) => { - const newChanges = { ...changes }; - const { position, rotation, angle } = newChanges; + const newChanges = JSON.parse(JSON.stringify(changes)); + const { x, y, rotation, angle } = newChanges; - if (position) { - newChanges.position = { - x: element.x + position.x, - y: element.y + position.y, - }; + if (x) { + newChanges.x = element.x + x; + } + if (y) { + newChanges.y = element.y + y; } if (rotation) { newChanges.rotation = element.rotation + rotation; From 303adecf5148525887d60abdf69357c37d18e5d1 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 2 Jul 2025 11:03:36 +0900 Subject: [PATCH 23/66] apply validate --- src/display/elements/Grid.js | 7 ++++++- src/display/update/update-components.js | 9 +++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/display/elements/Grid.js b/src/display/elements/Grid.js index e763b972..89771bbe 100644 --- a/src/display/elements/Grid.js +++ b/src/display/elements/Grid.js @@ -1,4 +1,7 @@ +import { isValidationError } from 'zod-validation-error'; import { selector } from '../../utils/selector/selector'; +import { validate } from '../../utils/validator'; +import { deepPartial } from '../../utils/zod-deep-strict-partial'; import { gridSchema } from '../data-schema/element-schema'; import Element from './Element'; import { Item } from './Item'; @@ -17,7 +20,9 @@ export class Grid extends Element { this.updateItem(changes, options); } - updateItem(changes, options) { + updateItem(opts, options) { + const changes = validate(opts, deepPartial(gridSchema)); + if (isValidationError(changes)) throw changes; const { gap = this.gap, cells = this.cells, item = this.item } = changes; for (let rowIndex = 0; rowIndex < cells.length; rowIndex++) { const row = cells[rowIndex]; diff --git a/src/display/update/update-components.js b/src/display/update/update-components.js index 28402204..3dba305b 100644 --- a/src/display/update/update-components.js +++ b/src/display/update/update-components.js @@ -1,5 +1,8 @@ +import { isValidationError } from 'zod-validation-error'; import { findIndexByPriority } from '../../utils/findIndexByPriority'; +import { validate } from '../../utils/validator'; import { Background, Bar, Icon, Text } from '../components'; +import { componentSchema } from '../data-schema/component-schema'; const Creator = { background: Background, @@ -16,7 +19,7 @@ export const updateComponents = ( if (!componentConfig) return; const itemComponents = [...item.children]; - for (const changes of componentConfig) { + for (let changes of componentConfig) { const idx = findIndexByPriority(itemComponents, changes); let component = null; @@ -24,6 +27,9 @@ export const updateComponents = ( component = itemComponents[idx]; itemComponents.splice(idx, 1); } else { + changes = validate(changes, componentSchema); + if (isValidationError(changes)) throw changes; + component = createComponent(changes); if (!component) continue; item.addChild(component); @@ -35,6 +41,5 @@ export const updateComponents = ( const createComponent = (config) => { const component = new Creator[config.type](); - component.config = { ...component.config }; return component; }; From 6da7c54c9197f13aa851ed681e30faea54b39feb Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 2 Jul 2025 11:14:19 +0900 Subject: [PATCH 24/66] fix history --- src/command/commands/show.js | 2 +- src/display/change/show.js | 3 +++ src/display/data-schema/primitive-schema.test.js | 1 - src/display/update/update.js | 10 +++++----- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/command/commands/show.js b/src/command/commands/show.js index e31c240f..0c477a37 100644 --- a/src/command/commands/show.js +++ b/src/command/commands/show.js @@ -21,7 +21,7 @@ export class ShowCommand extends Command { super('show_object'); this.object = object; this._config = parsePick(config, optionKeys); - this._prevConfig = parsePick(object.config, optionKeys); + this._prevConfig = parsePick(object, optionKeys); } get config() { diff --git a/src/display/change/show.js b/src/display/change/show.js index 834f097f..f4a5e6ac 100644 --- a/src/display/change/show.js +++ b/src/display/change/show.js @@ -1,3 +1,6 @@ +import { mergeProps } from './utils'; + export const changeShow = (object, { show }) => { object.renderable = show; + mergeProps(object, { show }); }; diff --git a/src/display/data-schema/primitive-schema.test.js b/src/display/data-schema/primitive-schema.test.js index f8ca2fb5..da21a941 100644 --- a/src/display/data-schema/primitive-schema.test.js +++ b/src/display/data-schema/primitive-schema.test.js @@ -181,7 +181,6 @@ describe('Primitive Schema Tests', () => { width: { value: 150, unit: 'px' }, height: { value: 75, unit: '%' }, }; - console.log(PxOrPercentSize._def); expect(deepPartial(PxOrPercentSize).parse(input)).toEqual(expected); }); diff --git a/src/display/update/update.js b/src/display/update/update.js index 2c3b0ce1..93d6e65f 100644 --- a/src/display/update/update.js +++ b/src/display/update/update.js @@ -8,7 +8,7 @@ import { validate } from '../../utils/validator'; 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), + history: z.union([z.boolean(), z.string()]).default(false), relativeTransform: z.boolean().default(false), }); @@ -17,7 +17,7 @@ export const update = (context, opts) => { if (isValidationError(config)) throw config; const { viewport, ...otherContext } = context; - const historyId = createHistoryId(config.saveToHistory); + const historyId = createHistoryId(config.history); const elements = 'elements' in config ? convertArray(config.elements) : []; if (viewport && config.path) { elements.push(...selector(viewport, config.path)); @@ -55,10 +55,10 @@ const applyRelativeTransform = (element, changes) => { return newChanges; }; -const createHistoryId = (saveToHistory) => { +const createHistoryId = (history) => { let historyId = null; - if (saveToHistory) { - historyId = typeof saveToHistory === 'string' ? saveToHistory : uid(); + if (history) { + historyId = typeof history === 'string' ? history : uid(); } return historyId; }; From 13b28eec7099cd1cefc9261c0c37145a02c36e25 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 2 Jul 2025 12:33:14 +0900 Subject: [PATCH 25/66] fix data.d.ts --- src/display/data-schema/data.d.ts | 245 +++++++++++------- .../data-schema/element-schema.test.js | 18 ++ 2 files changed, 174 insertions(+), 89 deletions(-) diff --git a/src/display/data-schema/data.d.ts b/src/display/data-schema/data.d.ts index 37b8ed96..e96b7a83 100644 --- a/src/display/data-schema/data.d.ts +++ b/src/display/data-schema/data.d.ts @@ -26,9 +26,9 @@ import type { * * @example * const mapData: MapData = [ - * { type: 'grid', id: 'g1', ... }, - * { type: 'item', id: 'i1', ... }, - * { type: 'relations', id: 'r1', ... }, + * { 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 MapData = Element[]; @@ -49,19 +49,20 @@ export type Element = Group | Grid | Item | Relations; * @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', width: 80, height: 80 }, - * { type: 'item', id: 'server-2', width: 80, height: 80 } - * ] + * { 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; + id?: string; // Default: uid + label?: string; show?: boolean; // Default: true children: Element[]; attrs?: Record; @@ -73,33 +74,35 @@ export interface Group { * @see {@link https://pixijs.download/release/docs/scene.Container.html} * * @example - * { + * const gridExample: Grid = { * type: 'grid', * id: 'server-rack', - * gap: 10, + * gap: { x: 10, y: 10 }, * cells: [ * [1, 1, 0], * [1, 0, 1] * ], * item: { - * width: 60, - * height: 60, * components: [ - * { type: 'background', source: { fill: '#eee', radius: 4 } } - * ] - * } - * } + * { + * type: 'background', + * source: { type: 'rect', fill: '#eee', radius: 4 }, + * } + * ], + * size: 60, + * }, + * }; */ export interface Grid { type: 'grid'; - id: string; + id?: string; // Default: uid + label?: string; show?: boolean; // Default: true cells: (0 | 1)[][]; gap?: Gap; item: { - width: number; - height: number; components?: Component[]; + size: Size; }; attrs?: Record; } @@ -110,27 +113,43 @@ export interface Grid { * @see {@link https://pixijs.download/release/docs/scene.Container.html} * * @example - * { + * const itemExample: Item = { * type: 'item', * id: 'main-server', - * width: 120, - * height: 100, + * size: { width: 120, height: 100 }, * components: [ - * { type: 'background', source: { fill: '#fff', borderColor: '#ddd', borderWidth: 1 } }, - * { type: 'text', text: 'Main Server', placement: 'top', margin: 8 }, - * { type: 'bar', source: { fill: 'lightblue' }, width: '80%', height: 8 }, - * { type: 'icon', source: 'ok.svg', size: 16, placement: 'bottom-right', margin: 4 } - * ] + * { + * 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 { type: 'item'; - id: string; + id?: string; // Default: uid + label?: string; show?: boolean; // Default: true - width: number; - height: number; components?: Component[]; + size: Size; attrs?: Record; } @@ -140,19 +159,20 @@ export interface Item { * @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 } - * } + * style: { color: '#083967', width: 2, cap: 'round', join: 'round' } + * }; */ export interface Relations { type: 'relations'; - id: string; + id?: string; // Default: uid + label?: string; show?: boolean; // Default: true links: { source: string; target: string }[]; style?: RelationsStyle; @@ -174,21 +194,24 @@ export type Component = Background | Bar | Icon | Text; * * @example * // As a style object - * { + * const backgroundStyleExample: Background = { * type: 'background', - * source: { fill: '#1A1A1A', radius: 8 } - * } + * id: 'bg-rect', + * source: { type: 'rect', fill: '#1A1A1A', radius: 8 } + * }; * * @example * // As an image URL - * { + * const backgroundUrlExample: Background = { * type: 'background', - * source: 'path/to/background-image.png' - * } + * id: 'bg-image', + * source: 'background-image.png' + * }; */ export interface Background { type: 'background'; - id: string; + id?: string; // Default: uid + label?: string; show?: boolean; // Default: true source: TextureStyle | string; tint?: Tint; @@ -200,22 +223,23 @@ export interface Background { * @see {@link https://pixijs.download/release/docs/scene.NineSliceSprite.html} * * @example - * { + * const barExample: Bar = { * type: 'bar', - * source: { fill: 'green' }, - * width: '80%', // 80% of the parent Item's width - * height: 10, // 10px height - * placement: 'bottom' - * } + * 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 Bar { type: 'bar'; - id: string; + id?: string; // Default: uid + label?: string; show?: boolean; // Default: true source: TextureStyle; - width?: PxOrPercent; - height?: PxOrPercent; - size?: PxOrPercent; + size: PxOrPercentSize; placement?: Placement; // Default: 'bottom' margin?: Margin; // Default: 0 tint?: Tint; @@ -229,22 +253,21 @@ export interface Bar { * @see {@link https://pixijs.download/release/docs/scene.Sprite.html} * * @example - * { + * const iconExample: Icon = { * type: 'icon', - * source: 'path/to/warning-icon.svg', + * id: 'warning-icon', + * source: 'warning-icon.svg', * size: 24, // 24px x 24px * placement: 'left-top', - * margin: { x: 4, y: 4 } - * } + * }; */ export interface Icon { type: 'icon'; - id: string; + id?: string; // Default: uid + label?: string; show?: boolean; // Default: true source: string; - width?: PxOrPercent; - height?: PxOrPercent; - size?: PxOrPercent; + size: PxOrPercentSize; placement?: Placement; // Default: 'center' margin?: Margin; // Default: 0 tint?: Tint; @@ -256,16 +279,19 @@ export interface Icon { * @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' } - * } + * style: { fill: '#333', fontSize: 14, fontWeight: 'bold' }, + * split: 0, + * }; */ export interface Text { type: 'text'; - id: string; + id?: string; // Default: uid + label?: string; show?: boolean; // Default: true text?: string; // Default: '' placement?: Placement; // Default: 'center' @@ -285,19 +311,59 @@ export interface Text { * or as an object with value and unit. * * @example - * // For a 100px width: - * width: 100 + * // For a 100px value: + * const pxValue: PxOrPercent = 100; * * @example - * // For a 75% height: - * height: '75%' + * // For a 75% value: + * const percentValue: PxOrPercent = '75%'; * * @example * // As an object: - * size: { value: 50, unit: '%' } + * const objectValue: PxOrPercent = { value: 50, unit: '%' }; */ export type PxOrPercent = number | string | { value: number; unit: 'px' | '%' }; +/** + * 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. */ @@ -318,11 +384,11 @@ export type Placement = * * @example * // To set a 10px gap for both x and y: - * gap: 10 + * const uniformGap: Gap = 10; * * @example * // To set a 5px horizontal and 15px vertical gap: - * gap: { x: 5, y: 15 } + * const customGap: Gap = { x: 5, y: 15 }; */ export type Gap = | number @@ -336,15 +402,15 @@ export type Gap = * * @example * // To apply a 10px margin on all four sides: - * margin: 10 + * const uniformMargin: Margin = 10; * * @example * // To apply 10px top/bottom and 5px left/right margins: - * margin: { y: 10, x: 5 } + * const axisMargin: Margin = { y: 10, x: 5 }; * * @example * // To apply individual margins for each side: - * margin: { top: 1, right: 2, bottom: 3, left: 4 } + * const detailedMargin: Margin = { top: 1, right: 2, bottom: 3, left: 4 }; */ export type Margin = | number @@ -356,19 +422,20 @@ export type Margin = * All properties are optional. * * @example - * const style: TextureStyle = { + * const textureStyleExample: TextureStyle = { + * type: 'rect', * fill: '#ff0000', * borderWidth: 2, * borderColor: '#000000', * radius: 5 - * } + * }; */ export interface TextureStyle { - type?: 'rect'; - fill?: string | null; - borderWidth?: number | null; - borderColor?: string | null; - radius?: number | null; + type: 'rect'; + fill?: string; + borderWidth?: number; + borderColor?: string; + radius?: number; } /** @@ -377,11 +444,11 @@ export interface TextureStyle { * @see {@link https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html} * * @example - * { + * const relationsStyleExample: RelationsStyle = { * color: 'red', * width: 2, * cap: 'square' - * } + * }; */ export type RelationsStyle = Record; @@ -394,12 +461,12 @@ export type RelationsStyle = Record; * @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; @@ -410,19 +477,19 @@ export type TextStyle = Record; * * @example * // As a theme key (string) - * tint: 'primary.main' + * const tintThemeKey: Tint = 'primary.default'; * * @example * // As a hex string - * tint: '#ff0000' + * const tintHexString: Tint = '#ff0000'; * * @example * // As a hex number - * tint: 0xff0000 + * const tintHexNumber: Tint = 0xff0000; * * @example * // As an RGB object - * tint: { r: 255, g: 0, b: 0 } + * const tintRgbObject: Tint = { r: 255, g: 0, b: 0 }; * * @see {@link https://pixijs.download/release/docs/color.ColorSource.html} */ diff --git a/src/display/data-schema/element-schema.test.js b/src/display/data-schema/element-schema.test.js index 6736ba76..b7bda3cc 100644 --- a/src/display/data-schema/element-schema.test.js +++ b/src/display/data-schema/element-schema.test.js @@ -43,6 +43,11 @@ describe('Element Schemas', () => { 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', @@ -90,6 +95,11 @@ describe('Element Schemas', () => { 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 }); @@ -170,6 +180,14 @@ describe('Element Schemas', () => { }; 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)', () => { From 667655b1fa363c94d283e5956a01972c0826ce9d Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 2 Jul 2025 13:02:13 +0900 Subject: [PATCH 26/66] fix Grid updateItem method --- src/display/elements/Grid.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/display/elements/Grid.js b/src/display/elements/Grid.js index 89771bbe..15a6511b 100644 --- a/src/display/elements/Grid.js +++ b/src/display/elements/Grid.js @@ -1,4 +1,5 @@ import { isValidationError } from 'zod-validation-error'; +import { deepMerge } from '../../utils/deepmerge/deepmerge'; import { selector } from '../../utils/selector/selector'; import { validate } from '../../utils/validator'; import { deepPartial } from '../../utils/zod-deep-strict-partial'; @@ -23,7 +24,10 @@ export class Grid extends Element { updateItem(opts, options) { const changes = validate(opts, deepPartial(gridSchema)); if (isValidationError(changes)) throw changes; - const { gap = this.gap, cells = this.cells, item = this.item } = changes; + const gap = deepMerge(this.gap, changes.gap); + const cells = deepMerge(this.cells, changes.cells); + const item = deepMerge(this.item, changes.item); + for (let rowIndex = 0; rowIndex < cells.length; rowIndex++) { const row = cells[rowIndex]; for (let colIndex = 0; colIndex < row.length; colIndex++) { From d41761d014eda2dc7236543a19df724028d3082e Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 2 Jul 2025 13:02:19 +0900 Subject: [PATCH 27/66] update readme --- README.md | 96 ++++++++++++++++++++++++++++------------------------ README_KR.md | 96 ++++++++++++++++++++++++++++------------------------ 2 files changed, 102 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 84ee0957..57efba85 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); @@ -226,7 +232,7 @@ Updates the state of specific objects on the canvas. Use this to change properti - `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. +- `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. ```js @@ -234,10 +240,10 @@ Updates the state of specific objects on the canvas. Use this to change properti 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 +258,10 @@ patchmap.update({ patchmap.update({ path: `$..children[?(@.type=="group")].children[?(@.type=="grid")]`, changes: { - components: [ - { type: 'icon', tint: 'black' } - ] - } + item: { + components: [{ type: 'icon', tint: 'red' }], + }, + }, }); ``` diff --git a/README_KR.md b/README_KR.md index a7c6a2f2..ff3bdd2f 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); @@ -225,7 +231,7 @@ draw method가 요구하는 **데이터 구조**입니다. - `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와 일치하는 문자열이 제공되면, 두 기록이 하나의 실행 취소/재실행 단계로 병합됩니다. +- `history`(optional, boolean \| string) - 해당 `update` 메소드에 의한 변경 사항을 `undoRedoManager`에 기록할 것인지 결정합니다. 이전에 저장된 기록의 historyId와 일치하는 문자열이 제공되면, 두 기록이 하나의 실행 취소/재실행 단계로 병합됩니다. - `relativeTransform`(optional, boolean) - `position`, `rotation`, `angle` 값에 대해서 상대값을 이용할 지 결정합니다. 만약, `true` 라면 전달된 값을 객체의 값에 더합니다. ```js @@ -233,10 +239,10 @@ draw method가 요구하는 **데이터 구조**입니다. patchmap.update({ path: `$..children[?(@.label=="grid-label-1")]`, changes: { - components: [ - { type: 'icon', asset: 'wifi' } - ] - } + item: { + components: [{ type: 'icon', source: 'wifi' }], + }, + }, }); // type이 "group"인 객체들에 대해 변경 사항 적용 @@ -251,10 +257,10 @@ patchmap.update({ patchmap.update({ path: `$..children[?(@.type=="group")].children[?(@.type=="grid")]`, changes: { - components: [ - { type: 'icon', tint: 'black' } - ] - } + item: { + components: [{ type: 'icon', tint: 'red' }], + }, + }, }); ``` From cd4c10b7ed55b3878295909646a0101e95c40178 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 2 Jul 2025 15:39:17 +0900 Subject: [PATCH 28/66] fix --- src/assets/icons/index.js | 2 -- src/assets/icons/inverter-fill.svg | 6 ------ 2 files changed, 8 deletions(-) delete mode 100644 src/assets/icons/inverter-fill.svg diff --git a/src/assets/icons/index.js b/src/assets/icons/index.js index 0e3a03d1..68be18ba 100644 --- a/src/assets/icons/index.js +++ b/src/assets/icons/index.js @@ -1,7 +1,6 @@ import combiner from './combiner.svg'; import device from './device.svg'; import edge from './edge.svg'; -import inverterFill from './inverter-fill.svg'; import inverter from './inverter.svg'; import loading from './loading.svg'; import object from './object.svg'; @@ -17,5 +16,4 @@ export const icons = { loading, warning, wifi, - inverterFill, }; diff --git a/src/assets/icons/inverter-fill.svg b/src/assets/icons/inverter-fill.svg deleted file mode 100644 index add6b576..00000000 --- a/src/assets/icons/inverter-fill.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - From 049e20c2fa914be1167659a2817b25f86a2baa81 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 2 Jul 2025 17:22:59 +0900 Subject: [PATCH 29/66] update bar size function --- .../change/{percent-size.js => bar-size.js} | 28 +++++++++---------- src/display/change/index.js | 2 +- src/display/change/pipeline/component.js | 4 +-- src/display/components/Bar.js | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) rename src/display/change/{percent-size.js => bar-size.js} (59%) diff --git a/src/display/change/percent-size.js b/src/display/change/bar-size.js similarity index 59% rename from src/display/change/percent-size.js rename to src/display/change/bar-size.js index 4c26b461..df0f093a 100644 --- a/src/display/change/percent-size.js +++ b/src/display/change/bar-size.js @@ -2,7 +2,7 @@ import gsap from 'gsap'; import { changePlacement } from './placement'; import { isMatch, killTweensOf, mergeProps } from './utils'; -export const changePercentSize = ( +export const changeBarSize = ( object, { size = object.size, @@ -17,36 +17,36 @@ export const changePercentSize = ( const { width = object.size.width, height = object.size.height } = size; - if (width.unit === '%') { - changeWidth(object, width, margin); - } - if (height.unit === '%') { - changeHeight(object, height, margin); - } + changeWidth(object, width, margin); + changeHeight(object, height, margin); mergeProps(object, { size, margin, animationDuration }); - function changeWidth(component, width, marginObj) { - const maxWidth = - component.parent.size.width - (marginObj.left + marginObj.right); - component.width = maxWidth * (width.value / 100); + function changeWidth(component, width, margin) { + const maxWidth = component.parent.size.width - (margin.left + margin.right); + component.width = + width.unit === '%' ? maxWidth * (width.value / 100) : width.value; } - function changeHeight(component, height) { + function changeHeight(component, height, margin) { const maxHeight = component.parent.size.height - (margin.top + margin.bottom); + const heightValue = + height.unit === '%' ? maxHeight * (height.value / 100) : height.value; if (object.animation) { animationContext.add(() => { killTweensOf(component); gsap.to(component, { - pixi: { height: maxHeight * (height.value / 100) }, + pixi: { + height: heightValue, + }, duration: animationDuration / 1000, ease: 'power2.inOut', onUpdate: () => changePlacement(component, {}), }); }); } else { - component.height = maxHeight * height; + component.height = heightValue; } } }; diff --git a/src/display/change/index.js b/src/display/change/index.js index e4883cce..1d9d5dd0 100644 --- a/src/display/change/index.js +++ b/src/display/change/index.js @@ -7,7 +7,7 @@ export { changeTint } from './tint'; export { changeTexture } from './texture'; export { changeAsset } from './asset'; export { changeTextureTransform } from './texture-transform'; -export { changePercentSize } from './percent-size'; +export { changeBarSize } from './bar-size'; export { changeSize } from './size'; export { changePlacement } from './placement'; export { changeText } from './text'; diff --git a/src/display/change/pipeline/component.js b/src/display/change/pipeline/component.js index 289f1f7a..a6fef0bf 100644 --- a/src/display/change/pipeline/component.js +++ b/src/display/change/pipeline/component.js @@ -33,10 +33,10 @@ export const componentPipeline = { change.changeAnimation(component, config); }, }, - percentSize: { + barSize: { keys: ['size', 'margin'], handler: (component, config, options) => { - change.changePercentSize(component, config, options); + change.changeBarSize(component, config, options); change.changePlacement(component, {}); }, }, diff --git a/src/display/components/Bar.js b/src/display/components/Bar.js index 6679bffb..c169f5a3 100644 --- a/src/display/components/Bar.js +++ b/src/display/components/Bar.js @@ -15,7 +15,7 @@ export class Bar extends NineSliceSprite { 'show', 'texture', 'tint', - 'percentSize', + 'barSize', 'placement', ]; } From d92d3b57fa17182b81ac7672ff49a8e806f9dac0 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 2 Jul 2025 17:33:10 +0900 Subject: [PATCH 30/66] update size function --- src/display/change/pipeline/component.js | 2 +- src/display/change/size.js | 21 ++++++++++++++++++--- src/display/change/utils.js | 5 +++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/display/change/pipeline/component.js b/src/display/change/pipeline/component.js index a6fef0bf..96c604fb 100644 --- a/src/display/change/pipeline/component.js +++ b/src/display/change/pipeline/component.js @@ -41,7 +41,7 @@ export const componentPipeline = { }, }, size: { - keys: ['size'], + keys: ['size', 'margin'], handler: (component, config) => { change.changeSize(component, config); change.changePlacement(component, {}); diff --git a/src/display/change/size.js b/src/display/change/size.js index 50859b75..038f9536 100644 --- a/src/display/change/size.js +++ b/src/display/change/size.js @@ -1,6 +1,21 @@ -import { mergeProps } from './utils'; +import { getMaxSize, mergeProps } from './utils'; -export const changeSize = (object, { size = object.config.size }) => { - object.setSize(size.width.value, size.height.value); +export const changeSize = ( + object, + { size = object.size, margin = object.margin }, +) => { + const { width: maxWidth, height: maxHeight } = getMaxSize( + object.parent.size, + margin, + ); + + object.setSize( + size.width.unit === '%' + ? maxWidth * (size.width.value / 100) + : size.width.value, + size.height.unit === '%' + ? maxHeight * (size.height.value / 100) + : size.height.value, + ); mergeProps(object, { size }); }; diff --git a/src/display/change/utils.js b/src/display/change/utils.js index ab49d8e2..c2cebc32 100644 --- a/src/display/change/utils.js +++ b/src/display/change/utils.js @@ -18,3 +18,8 @@ export const mergeProps = (object, props = {}, overwrite = false) => { export const tweensOf = (object) => gsap.getTweensOf(object); export const killTweensOf = (object) => gsap.killTweensOf(object); + +export const getMaxSize = (size, margin) => ({ + width: size.width - (margin.left + margin.right), + height: size.height - (margin.top + margin.bottom), +}); From 27902216d665d24cb77f6f6f6410c90c809881cf Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 2 Jul 2025 17:34:31 +0900 Subject: [PATCH 31/66] fix bar-size func --- src/display/change/bar-size.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/display/change/bar-size.js b/src/display/change/bar-size.js index df0f093a..b35b32cf 100644 --- a/src/display/change/bar-size.js +++ b/src/display/change/bar-size.js @@ -1,6 +1,6 @@ import gsap from 'gsap'; import { changePlacement } from './placement'; -import { isMatch, killTweensOf, mergeProps } from './utils'; +import { getMaxSize, isMatch, killTweensOf, mergeProps } from './utils'; export const changeBarSize = ( object, @@ -22,14 +22,13 @@ export const changeBarSize = ( mergeProps(object, { size, margin, animationDuration }); function changeWidth(component, width, margin) { - const maxWidth = component.parent.size.width - (margin.left + margin.right); + const { width: maxWidth } = getMaxSize(component.parent.size, margin); component.width = width.unit === '%' ? maxWidth * (width.value / 100) : width.value; } function changeHeight(component, height, margin) { - const maxHeight = - component.parent.size.height - (margin.top + margin.bottom); + const { height: maxHeight } = getMaxSize(component.parent.size, margin); const heightValue = height.unit === '%' ? maxHeight * (height.value / 100) : height.value; From 7b23b40dd0d988625d4174a8ab0ca33ddcf5d1da Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 2 Jul 2025 17:37:36 +0900 Subject: [PATCH 32/66] rename texture-transform --- .../change/{texture-transform.js => background-transform.js} | 2 +- src/display/change/index.js | 2 +- src/display/change/pipeline/component.js | 4 ++-- src/display/components/Background.js | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename src/display/change/{texture-transform.js => background-transform.js} (82%) diff --git a/src/display/change/texture-transform.js b/src/display/change/background-transform.js similarity index 82% rename from src/display/change/texture-transform.js rename to src/display/change/background-transform.js index 40354414..1331fe4e 100644 --- a/src/display/change/texture-transform.js +++ b/src/display/change/background-transform.js @@ -1,4 +1,4 @@ -export const changeTextureTransform = (object) => { +export const changeBackgroundTransform = (object) => { const borderWidth = object.texture.metadata.borderWidth; if (!borderWidth) return; const parentSize = object.parent.size; diff --git a/src/display/change/index.js b/src/display/change/index.js index 1d9d5dd0..e9c0a88d 100644 --- a/src/display/change/index.js +++ b/src/display/change/index.js @@ -6,7 +6,7 @@ export { changeStrokeStyle } from './stroke-style'; export { changeTint } from './tint'; export { changeTexture } from './texture'; export { changeAsset } from './asset'; -export { changeTextureTransform } from './texture-transform'; +export { changeBackgroundTransform } from './background-transform'; export { changeBarSize } from './bar-size'; export { changeSize } from './size'; export { changePlacement } from './placement'; diff --git a/src/display/change/pipeline/component.js b/src/display/change/pipeline/component.js index 96c604fb..a23cfcde 100644 --- a/src/display/change/pipeline/component.js +++ b/src/display/change/pipeline/component.js @@ -21,10 +21,10 @@ export const componentPipeline = { change.changeAsset(component, config, options); }, }, - textureTransform: { + backgroundTransform: { keys: ['source'], handler: (component) => { - change.changeTextureTransform(component); + change.changeBackgroundTransform(component); }, }, animation: { diff --git a/src/display/components/Background.js b/src/display/components/Background.js index c571a382..ce035be5 100644 --- a/src/display/components/Background.js +++ b/src/display/components/Background.js @@ -10,7 +10,7 @@ export class Background extends NineSliceSprite { constructor() { super({ texture: Texture.WHITE }); this.#type = 'background'; - this.#pipelines = ['show', 'texture', 'textureTransform', 'tint']; + this.#pipelines = ['show', 'texture', 'backgroundTransform', 'tint']; } get type() { From d7cb7748d13c29d37e53a998b800f567ed8ade02 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 3 Jul 2025 15:23:37 +0900 Subject: [PATCH 33/66] apply getter/setter --- src/display/Base.js | 84 +++++++++++++++++++++++ src/display/components/Background.js | 26 ++----- src/display/components/Bar.js | 33 ++------- src/display/components/Icon.js | 26 ++----- src/display/components/Text.js | 26 ++----- src/display/components/validate-update.js | 17 ----- src/display/draw.js | 2 +- src/display/elements/Element.js | 51 +------------- src/display/elements/Grid.js | 55 ++------------- src/display/elements/Group.js | 13 ++-- src/display/elements/Item.js | 12 ++-- src/display/elements/Relations.js | 12 ++-- src/display/update/update.js | 5 +- src/init.js | 4 +- src/patchmap.js | 8 +-- 15 files changed, 133 insertions(+), 241 deletions(-) create mode 100644 src/display/Base.js delete mode 100644 src/display/components/validate-update.js diff --git a/src/display/Base.js b/src/display/Base.js new file mode 100644 index 00000000..79d04e86 --- /dev/null +++ b/src/display/Base.js @@ -0,0 +1,84 @@ +import { isValidationError } from 'zod-validation-error'; +import { validate } from '../utils/validator'; +import { deepPartial } from '../utils/zod-deep-strict-partial'; +import { changeProperty } from './change'; + +const EXCEPTION_KEYS = new Set(['type', 'children']); + +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; + } + }; +}; + +export const Context = (superClass) => { + return class extends superClass { + #context; + + constructor(options = {}) { + const { context = null, ...rest } = options; + super(rest); + this.#context = context; + } + + get context() { + return this.#context; + } + }; +}; + +export const Base = (superClass) => { + return class extends Context(Type(superClass)) { + get show() { + return this.renderable; + } + + set show(value) { + this.renderable = value; + } + + update(changes, schema) { + const validated = validate(changes, deepPartial(schema)); + if (isValidationError(validated)) throw validated; + + const { attrs = null, ...restChanges } = changes; + + if (attrs) { + for (const [key, value] of Object.entries(attrs)) { + 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); + continue; + } + + 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); + continue; + } + changeProperty(this, key, value); + } + } + + for (const [key, value] of Object.entries(restChanges)) { + if (!EXCEPTION_KEYS.has(key)) { + changeProperty(this, key, value); + } + } + } + }; +}; diff --git a/src/display/components/Background.js b/src/display/components/Background.js index ce035be5..99c8d22b 100644 --- a/src/display/components/Background.js +++ b/src/display/components/Background.js @@ -1,27 +1,13 @@ import { NineSliceSprite, Texture } from 'pixi.js'; +import { Base } from '../Base'; import { backgroundSchema } from '../data-schema/component-schema'; -import { validateUpdate } from './validate-update'; -export class Background extends NineSliceSprite { - #type; - - #pipelines; - - constructor() { - super({ texture: Texture.WHITE }); - this.#type = 'background'; - this.#pipelines = ['show', 'texture', 'backgroundTransform', 'tint']; - } - - get type() { - return this.#type; - } - - get pipelines() { - return this.#pipelines; +export class Background extends Base(NineSliceSprite) { + constructor(context) { + super({ type: 'background', context, texture: Texture.WHITE }); } - update(changes, options) { - validateUpdate(this, changes, backgroundSchema, options); + update(changes) { + super.update(changes, backgroundSchema); } } diff --git a/src/display/components/Bar.js b/src/display/components/Bar.js index c169f5a3..f77e466d 100644 --- a/src/display/components/Bar.js +++ b/src/display/components/Bar.js @@ -1,34 +1,13 @@ import { NineSliceSprite, Texture } from 'pixi.js'; +import { Base } from '../Base'; import { barSchema } from '../data-schema/component-schema'; -import { validateUpdate } from './validate-update'; -export class Bar extends NineSliceSprite { - #type; - - #pipelines; - - constructor() { - super({ texture: Texture.WHITE }); - this.#type = 'bar'; - this.#pipelines = [ - 'animation', - 'show', - 'texture', - 'tint', - 'barSize', - 'placement', - ]; - } - - get type() { - return this.#type; - } - - get pipelines() { - return this.#pipelines; +export class Bar extends Base(NineSliceSprite) { + constructor(context) { + super({ type: 'bar', context, texture: Texture.WHITE }); } - update(changes, options) { - validateUpdate(this, changes, barSchema, options); + update(changes) { + super.update(changes, barSchema); } } diff --git a/src/display/components/Icon.js b/src/display/components/Icon.js index 412f9821..2d2d5623 100644 --- a/src/display/components/Icon.js +++ b/src/display/components/Icon.js @@ -1,27 +1,13 @@ import { Sprite, Texture } from 'pixi.js'; +import { Base } from '../Base'; import { iconSchema } from '../data-schema/component-schema'; -import { validateUpdate } from './validate-update'; -export class Icon extends Sprite { - #type; - - #pipelines; - - constructor() { - super({ texture: Texture.WHITE }); - this.#type = 'icon'; - this.#pipelines = ['show', 'asset', 'size', 'tint', 'placement']; - } - - get type() { - return this.#type; - } - - get pipelines() { - return this.#pipelines; +export class Icon extends Base(Sprite) { + constructor(context) { + super({ type: 'icon', context, texture: Texture.WHITE }); } - update(changes, options) { - validateUpdate(this, changes, iconSchema, options); + update(changes) { + super.update(changes, iconSchema); } } diff --git a/src/display/components/Text.js b/src/display/components/Text.js index 5c3b0f38..29b5a0eb 100644 --- a/src/display/components/Text.js +++ b/src/display/components/Text.js @@ -1,27 +1,13 @@ import { BitmapText } from 'pixi.js'; +import { Base } from '../Base'; import { textSchema } from '../data-schema/component-schema'; -import { validateUpdate } from './validate-update'; -export class Text extends BitmapText { - #type; - - #pipelines; - - constructor() { - super({ text: '' }); - this.#type = 'text'; - this.#pipelines = ['show', 'text', 'textStyle', 'placement']; - } - - get type() { - return this.#type; - } - - get pipelines() { - return this.#pipelines; +export class Text extends Base(BitmapText) { + constructor(context) { + super({ type: 'text', context, text: '' }); } - update(changes, options) { - validateUpdate(this, changes, textSchema, options); + update(changes) { + super.update(changes, textSchema); } } diff --git a/src/display/components/validate-update.js b/src/display/components/validate-update.js deleted file mode 100644 index e9ff4f1a..00000000 --- a/src/display/components/validate-update.js +++ /dev/null @@ -1,17 +0,0 @@ -import { isValidationError } from 'zod-validation-error'; -import { validate } from '../../utils/validator'; -import { deepPartial } from '../../utils/zod-deep-strict-partial'; -import { componentPipeline } from '../change/pipeline/component'; -import { updateObject } from '../update/update-object'; - -export const validateUpdate = (context, changes, schema, options) => { - const validated = validate(changes, deepPartial(schema)); - if (isValidationError(validated)) throw validated; - updateObject( - context, - validated, - componentPipeline, - context.pipelines, - options, - ); -}; diff --git a/src/display/draw.js b/src/display/draw.js index eec9050b..435c0ccc 100644 --- a/src/display/draw.js +++ b/src/display/draw.js @@ -15,7 +15,7 @@ export const draw = (context, data) => { function render(parent, data) { for (const changes of data) { - const element = new Creator[changes.type](viewport); + const element = new Creator[changes.type](context); update(context, { elements: element, changes }); parent.addChild(element); diff --git a/src/display/elements/Element.js b/src/display/elements/Element.js index aee83208..af2944dd 100644 --- a/src/display/elements/Element.js +++ b/src/display/elements/Element.js @@ -1,53 +1,8 @@ -import { Viewport } from 'pixi-viewport'; import { Container } from 'pixi.js'; -import { z } from 'zod'; -import { isValidationError } from 'zod-validation-error'; -import { validate } from '../../utils/validator'; -import { deepPartial } from '../../utils/zod-deep-strict-partial'; -import { elementPipeline } from '../change/pipeline/element'; -import { updateObject } from '../update/update-object'; - -const createSchema = z.object({ - type: z.string(), - viewport: z.instanceof(Viewport), - isRenderGroup: z.boolean().default(false), - pipelines: z.array(z.string()).default([]), -}); - -export default class Element extends Container { - /** - * The type of the element. This property is read-only. - * @private - * @type {string} - */ - #type; - - #pipelines; +import { Base } from '../Base'; +export default class Element extends Base(Container) { constructor(options) { - const validated = validate(options, createSchema); - if (isValidationError(validated)) throw validated; - const { type, pipelines, ...rest } = validated; - super(Object.assign(rest, { eventMode: 'static' })); - this.#type = type; - this.#pipelines = pipelines; - } - - /** - * Returns the type of the element. - * @returns {string} - */ - get type() { - return this.#type; - } - - get pipelines() { - return this.#pipelines; - } - - update(changes, schema, options) { - const validated = validate(changes, deepPartial(schema)); - if (isValidationError(validated)) throw validated; - updateObject(this, validated, elementPipeline, this.pipelines, options); + super(Object.assign(options, { eventMode: 'static' })); } } diff --git a/src/display/elements/Grid.js b/src/display/elements/Grid.js index 15a6511b..7640bf9b 100644 --- a/src/display/elements/Grid.js +++ b/src/display/elements/Grid.js @@ -1,59 +1,12 @@ -import { isValidationError } from 'zod-validation-error'; -import { deepMerge } from '../../utils/deepmerge/deepmerge'; -import { selector } from '../../utils/selector/selector'; -import { validate } from '../../utils/validator'; -import { deepPartial } from '../../utils/zod-deep-strict-partial'; import { gridSchema } from '../data-schema/element-schema'; import Element from './Element'; -import { Item } from './Item'; export class Grid extends Element { - constructor(viewport) { - super({ - type: 'grid', - viewport, - pipelines: ['show', 'position', 'gridComponents'], - }); + constructor(context) { + super({ type: 'grid', context }); } - update(changes, options) { - super.update(changes, gridSchema, options); - this.updateItem(changes, options); - } - - updateItem(opts, options) { - const changes = validate(opts, deepPartial(gridSchema)); - if (isValidationError(changes)) throw changes; - const gap = deepMerge(this.gap, changes.gap); - const cells = deepMerge(this.cells, changes.cells); - const item = deepMerge(this.item, changes.item); - - 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 element = - selector( - this, - `$.children[?(@.id==="${this.id}.${rowIndex}.${colIndex}")]`, - )[0] ?? new Item(this.viewport); - - element.update( - { - id: `${this.id}.${rowIndex}.${colIndex}`, - components: item.components, - size: { width: item.size.width, height: item.size.height }, - attrs: { - x: colIndex * (item.size.width + gap.x), - y: rowIndex * (item.size.height + gap.y), - }, - }, - options, - ); - this.addChild(element); - } - } + update(changes) { + super.update(changes, gridSchema); } } diff --git a/src/display/elements/Group.js b/src/display/elements/Group.js index 7af79329..6f39411a 100644 --- a/src/display/elements/Group.js +++ b/src/display/elements/Group.js @@ -2,16 +2,11 @@ import { groupSchema } from '../data-schema/element-schema'; import Element from './Element'; export class Group extends Element { - constructor(viewport) { - super({ - type: 'group', - pipelines: ['show', 'position'], - viewport, - isRenderGroup: true, - }); + constructor(context) { + super({ type: 'group', context, isRenderGroup: true }); } - update(changes, options) { - super.update(changes, groupSchema, options); + update(changes) { + super.update(changes, groupSchema); } } diff --git a/src/display/elements/Item.js b/src/display/elements/Item.js index f8835ee3..067ec397 100644 --- a/src/display/elements/Item.js +++ b/src/display/elements/Item.js @@ -2,15 +2,11 @@ import { itemSchema } from '../data-schema/element-schema'; import Element from './Element'; export class Item extends Element { - constructor(viewport) { - super({ - type: 'item', - viewport, - pipelines: ['show', 'position', 'components'], - }); + constructor(context) { + super({ type: 'item', context }); } - update(changes, options) { - super.update(changes, itemSchema, options); + update(changes) { + super.update(changes, itemSchema); } } diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index de5bfcb6..19e50ad8 100644 --- a/src/display/elements/Relations.js +++ b/src/display/elements/Relations.js @@ -3,17 +3,13 @@ import { relationsSchema } from '../data-schema/element-schema'; import Element from './Element'; export class Relations extends Element { - constructor(viewport) { - super({ - type: 'relations', - viewport, - pipelines: ['show', 'strokeStyle', 'links'], - }); + constructor(context) { + super({ type: 'relations', context }); this.initPath(); } - update(changes, options) { - super.update(changes, relationsSchema, options); + update(changes) { + super.update(changes, relationsSchema); } initPath() { diff --git a/src/display/update/update.js b/src/display/update/update.js index 93d6e65f..4faf552e 100644 --- a/src/display/update/update.js +++ b/src/display/update/update.js @@ -12,11 +12,10 @@ const updateSchema = z.object({ relativeTransform: z.boolean().default(false), }); -export const update = (context, opts) => { +export const update = (viewport, opts) => { const config = validate(opts, updateSchema.passthrough()); if (isValidationError(config)) throw config; - const { viewport, ...otherContext } = context; const historyId = createHistoryId(config.history); const elements = 'elements' in config ? convertArray(config.elements) : []; if (viewport && config.path) { @@ -32,7 +31,7 @@ export const update = (context, opts) => { if (relativeTransform && changes.attrs) { changes.attrs = applyRelativeTransform(element, changes.attrs); } - element.update(changes, { historyId, ...otherContext }); + element.update(changes, { historyId }); } }; diff --git a/src/init.js b/src/init.js index b5e82b9f..9b3a32c1 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/Base'; 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), diff --git a/src/patchmap.js b/src/patchmap.js index 69426b33..d980ed83 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -156,13 +156,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) { From 5adeca0ddf73b3245067b62cd0ac0c61a98ce0de Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 3 Jul 2025 17:15:13 +0900 Subject: [PATCH 34/66] apply mixin composition architecture --- biome.json | 3 +- package-lock.json | 17 +++ package.json | 1 + src/display/Base.js | 84 --------------- src/display/change/utils.js | 14 ++- src/display/components/Background.js | 12 ++- src/display/components/Bar.js | 25 ++++- src/display/components/Icon.js | 22 +++- src/display/components/Text.js | 21 +++- src/display/data-schema/component-schema.js | 7 ++ src/display/data-schema/element-schema.js | 2 +- src/display/draw.js | 11 +- src/display/elements/Element.js | 7 +- src/display/elements/Grid.js | 6 +- src/display/elements/Group.js | 5 +- src/display/elements/Item.js | 5 +- src/display/elements/Relations.js | 6 +- src/display/mixins/Animationable.js | 21 ++++ src/display/mixins/Animationsizeable.js | 43 ++++++++ src/display/mixins/Base.js | 111 ++++++++++++++++++++ src/display/mixins/Cellsable.js | 47 +++++++++ src/display/mixins/Childrenable.js | 40 +++++++ src/display/mixins/Componentsable.js | 47 +++++++++ src/display/mixins/Componentsizeable.js | 21 ++++ src/display/mixins/Itemable.js | 33 ++++++ src/display/mixins/Itemsizeable.js | 19 ++++ src/display/mixins/Placementable.js | 60 +++++++++++ src/display/mixins/Relationstyleable.js | 26 +++++ src/display/mixins/Showable.js | 18 ++++ src/display/mixins/Sourceable.js | 21 ++++ src/display/mixins/Textable.js | 29 +++++ src/display/mixins/Textstyleable.js | 59 +++++++++++ src/display/mixins/Tintable.js | 19 ++++ src/display/mixins/Type.js | 15 +++ src/display/mixins/constants.js | 29 +++++ src/display/mixins/linksable.js | 57 ++++++++++ src/display/mixins/utils.js | 75 +++++++++++++ src/display/update/update-components.js | 4 +- src/init.js | 2 +- src/utils/transform.js | 109 +++++++++++++++++++ 40 files changed, 1038 insertions(+), 115 deletions(-) delete mode 100644 src/display/Base.js create mode 100644 src/display/mixins/Animationable.js create mode 100644 src/display/mixins/Animationsizeable.js create mode 100644 src/display/mixins/Base.js create mode 100644 src/display/mixins/Cellsable.js create mode 100644 src/display/mixins/Childrenable.js create mode 100644 src/display/mixins/Componentsable.js create mode 100644 src/display/mixins/Componentsizeable.js create mode 100644 src/display/mixins/Itemable.js create mode 100644 src/display/mixins/Itemsizeable.js create mode 100644 src/display/mixins/Placementable.js create mode 100644 src/display/mixins/Relationstyleable.js create mode 100644 src/display/mixins/Showable.js create mode 100644 src/display/mixins/Sourceable.js create mode 100644 src/display/mixins/Textable.js create mode 100644 src/display/mixins/Textstyleable.js create mode 100644 src/display/mixins/Tintable.js create mode 100644 src/display/mixins/Type.js create mode 100644 src/display/mixins/constants.js create mode 100644 src/display/mixins/linksable.js create mode 100644 src/display/mixins/utils.js create mode 100644 src/utils/transform.js 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 e87e927c..ad97327e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "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", @@ -461,6 +462,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 +478,13 @@ "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/@rollup/plugin-commonjs": { "version": "28.0.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.3.tgz", diff --git a/package.json b/package.json index 3bc58d8f..49ad7f6d 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "dist" ], "dependencies": { + "@pixi-essentials/bounds": "^3.0.0", "gsap": "^3.12.7", "is-plain-object": "^5.0.0", "jsonpath-plus": "^10.3.0", diff --git a/src/display/Base.js b/src/display/Base.js deleted file mode 100644 index 79d04e86..00000000 --- a/src/display/Base.js +++ /dev/null @@ -1,84 +0,0 @@ -import { isValidationError } from 'zod-validation-error'; -import { validate } from '../utils/validator'; -import { deepPartial } from '../utils/zod-deep-strict-partial'; -import { changeProperty } from './change'; - -const EXCEPTION_KEYS = new Set(['type', 'children']); - -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; - } - }; -}; - -export const Context = (superClass) => { - return class extends superClass { - #context; - - constructor(options = {}) { - const { context = null, ...rest } = options; - super(rest); - this.#context = context; - } - - get context() { - return this.#context; - } - }; -}; - -export const Base = (superClass) => { - return class extends Context(Type(superClass)) { - get show() { - return this.renderable; - } - - set show(value) { - this.renderable = value; - } - - update(changes, schema) { - const validated = validate(changes, deepPartial(schema)); - if (isValidationError(validated)) throw validated; - - const { attrs = null, ...restChanges } = changes; - - if (attrs) { - for (const [key, value] of Object.entries(attrs)) { - 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); - continue; - } - - 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); - continue; - } - changeProperty(this, key, value); - } - } - - for (const [key, value] of Object.entries(restChanges)) { - if (!EXCEPTION_KEYS.has(key)) { - changeProperty(this, key, value); - } - } - } - }; -}; diff --git a/src/display/change/utils.js b/src/display/change/utils.js index c2cebc32..d4531e77 100644 --- a/src/display/change/utils.js +++ b/src/display/change/utils.js @@ -19,7 +19,13 @@ export const tweensOf = (object) => gsap.getTweensOf(object); export const killTweensOf = (object) => gsap.killTweensOf(object); -export const getMaxSize = (size, margin) => ({ - width: size.width - (margin.left + margin.right), - height: size.height - (margin.top + margin.bottom), -}); +export const getMaxSize = ( + size, + margin = { top: 0, right: 0, bottom: 0, left: 0 }, +) => { + const { top = 0, right = 0, bottom = 0, left = 0 } = margin || {}; + return { + width: size.width - (left + right), + height: size.height - (top + bottom), + }; +}; diff --git a/src/display/components/Background.js b/src/display/components/Background.js index 99c8d22b..1e70dff6 100644 --- a/src/display/components/Background.js +++ b/src/display/components/Background.js @@ -1,8 +1,16 @@ import { NineSliceSprite, Texture } from 'pixi.js'; -import { Base } from '../Base'; 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'; -export class Background extends Base(NineSliceSprite) { +const ComposedBackground = ComponentSizeable( + Tintable(Sourceable(Showable(Base(NineSliceSprite)))), +); + +export class Background extends ComposedBackground { constructor(context) { super({ type: 'background', context, texture: Texture.WHITE }); } diff --git a/src/display/components/Bar.js b/src/display/components/Bar.js index f77e466d..15047918 100644 --- a/src/display/components/Bar.js +++ b/src/display/components/Bar.js @@ -1,10 +1,31 @@ import { NineSliceSprite, Texture } from 'pixi.js'; -import { Base } from '../Base'; 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'; -export class Bar extends Base(NineSliceSprite) { +const EXTRA_KEYS = { + PLACEMENT: ['size'], +}; + +const ComposedBar = Placementable( + AnimationSizeable( + Animationable(Tintable(Sourceable(Showable(Base(NineSliceSprite))))), + ), +); + +export class Bar extends ComposedBar { constructor(context) { super({ type: 'bar', context, texture: Texture.WHITE }); + + this.constructor.registerHandler( + EXTRA_KEYS.PLACEMENT, + this._applyPlacement, + ); } update(changes) { diff --git a/src/display/components/Icon.js b/src/display/components/Icon.js index 2d2d5623..da828f1a 100644 --- a/src/display/components/Icon.js +++ b/src/display/components/Icon.js @@ -1,10 +1,28 @@ import { Sprite, Texture } from 'pixi.js'; -import { Base } from '../Base'; 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'; -export class Icon extends Base(Sprite) { +const EXTRA_KEYS = { + PLACEMENT: ['size'], +}; + +const ComposedIcon = Placementable( + ComponentSizeable(Tintable(Sourceable(Showable(Base(Sprite))))), +); + +export class Icon extends ComposedIcon { constructor(context) { super({ type: 'icon', context, texture: Texture.WHITE }); + + this.constructor.registerHandler( + EXTRA_KEYS.PLACEMENT, + this._applyPlacement, + ); } update(changes) { diff --git a/src/display/components/Text.js b/src/display/components/Text.js index 29b5a0eb..6b4bc3db 100644 --- a/src/display/components/Text.js +++ b/src/display/components/Text.js @@ -1,10 +1,27 @@ import { BitmapText } from 'pixi.js'; -import { Base } from '../Base'; 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'; -export class Text extends Base(BitmapText) { +const EXTRA_KEYS = { + PLACEMENT: ['text', 'split'], +}; + +const ComposedText = Placementable( + Textstyleable(Textable(Showable(Base(BitmapText)))), +); + +export class Text extends ComposedText { constructor(context) { super({ type: 'text', context, text: '' }); + + this.constructor.registerHandler( + EXTRA_KEYS.PLACEMENT, + this._applyPlacement, + ); } update(changes) { diff --git a/src/display/data-schema/component-schema.js b/src/display/data-schema/component-schema.js index 4f8b6c1c..0242717c 100644 --- a/src/display/data-schema/component-schema.js +++ b/src/display/data-schema/component-schema.js @@ -17,6 +17,13 @@ import { export const backgroundSchema = Base.extend({ type: z.literal('background'), source: z.union([TextureStyle, z.string()]), + size: z + .any() + .optional() + .transform(() => ({ + width: { value: 100, unit: '%' }, + height: { value: 100, unit: '%' }, + })), tint: Tint.optional(), }).strict(); diff --git a/src/display/data-schema/element-schema.js b/src/display/data-schema/element-schema.js index db934888..38794454 100644 --- a/src/display/data-schema/element-schema.js +++ b/src/display/data-schema/element-schema.js @@ -49,7 +49,7 @@ export const relationsSchema = Base.extend({ style: RelationsStyle, }).strict(); -const elementTypes = z.discriminatedUnion('type', [ +export const elementTypes = z.discriminatedUnion('type', [ groupSchema, gridSchema, itemSchema, diff --git a/src/display/draw.js b/src/display/draw.js index 435c0ccc..df26138e 100644 --- a/src/display/draw.js +++ b/src/display/draw.js @@ -1,7 +1,6 @@ import { Grid, Group, Item, Relations } from './elements'; -import { update } from './update/update'; -const Creator = { +export const elementCreator = { group: Group, grid: Grid, item: Item, @@ -15,13 +14,9 @@ export const draw = (context, data) => { function render(parent, data) { for (const changes of data) { - const element = new Creator[changes.type](context); - update(context, { elements: element, changes }); + const element = new elementCreator[changes.type](context); + element.update(changes); parent.addChild(element); - - if (changes.type === 'group') { - render(element, changes.children); - } } } }; diff --git a/src/display/elements/Element.js b/src/display/elements/Element.js index af2944dd..64cbc94f 100644 --- a/src/display/elements/Element.js +++ b/src/display/elements/Element.js @@ -1,7 +1,10 @@ import { Container } from 'pixi.js'; -import { Base } from '../Base'; +import { Base } from '../mixins/Base'; +import { Showable } from '../mixins/Showable'; -export default class Element extends Base(Container) { +const ComposedElement = Showable(Base(Container)); + +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 index 7640bf9b..e80b5a1a 100644 --- a/src/display/elements/Grid.js +++ b/src/display/elements/Grid.js @@ -1,7 +1,11 @@ import { gridSchema } from '../data-schema/element-schema'; +import { Cellsable } from '../mixins/Cellsable'; +import { Itemable } from '../mixins/Itemable'; import Element from './Element'; -export class Grid extends Element { +const ComposedGrid = Itemable(Cellsable(Element)); + +export class Grid extends ComposedGrid { constructor(context) { super({ type: 'grid', context }); } diff --git a/src/display/elements/Group.js b/src/display/elements/Group.js index 6f39411a..f2031da7 100644 --- a/src/display/elements/Group.js +++ b/src/display/elements/Group.js @@ -1,7 +1,10 @@ import { groupSchema } from '../data-schema/element-schema'; +import { Childrenable } from '../mixins/Childrenable'; import Element from './Element'; -export class Group extends Element { +const ComposedGroup = Childrenable(Element); + +export class Group extends ComposedGroup { constructor(context) { super({ type: 'group', context, isRenderGroup: true }); } diff --git a/src/display/elements/Item.js b/src/display/elements/Item.js index 067ec397..bba04b3c 100644 --- a/src/display/elements/Item.js +++ b/src/display/elements/Item.js @@ -1,7 +1,10 @@ import { itemSchema } from '../data-schema/element-schema'; +import { Componentsable } from '../mixins/Componentsable'; import Element from './Element'; -export class Item extends Element { +const ComposedItem = Componentsable(Element); + +export class Item extends ComposedItem { constructor(context) { super({ type: 'item', context }); } diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index 19e50ad8..15abb509 100644 --- a/src/display/elements/Relations.js +++ b/src/display/elements/Relations.js @@ -1,8 +1,12 @@ import { Graphics } from 'pixi.js'; import { relationsSchema } from '../data-schema/element-schema'; +import { Relationstyleable } from '../mixins/Relationstyleable'; +import { Linksable } from '../mixins/linksable'; import Element from './Element'; -export class Relations extends Element { +const ComposedRelations = Relationstyleable(Linksable(Element)); + +export class Relations extends ComposedRelations { constructor(context) { super({ type: 'relations', context }); this.initPath(); 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..808e040f --- /dev/null +++ b/src/display/mixins/Base.js @@ -0,0 +1,111 @@ +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) { + const validatedChanges = validate(changes, deepPartial(schema)); + if (isValidationError(validatedChanges)) throw validatedChanges; + + const { + id, + label, + attrs = {}, + ...diffChanges + } = diffJson(this.props, validatedChanges) ?? {}; + this.props = deepMerge(this.props, validatedChanges); + + if (id || label || attrs) { + this._applyRaw({ id, label, ...attrs }); + } + + const tasks = new Map(); + for (const key in diffChanges) { + 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); + }); + } + + _applyRaw(attrs) { + 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); + } + } + } + + _updateProperty(key, value) { + deepMerge(this, { [key]: value }); + } + }; +}; diff --git a/src/display/mixins/Cellsable.js b/src/display/mixins/Cellsable.js new file mode 100644 index 00000000..32ce7c43 --- /dev/null +++ b/src/display/mixins/Cellsable.js @@ -0,0 +1,47 @@ +import { selector } from '../../utils/selector/selector'; +import { Item } from '../elements'; +import { UPDATE_STAGES } from './constants'; + +const KEYS = ['cells']; + +export const Cellsable = (superClass) => { + const MixedClass = class extends superClass { + _applyCells(relevantChanges) { + const { cells } = relevantChanges; + + for (let rowIndex = 0; rowIndex < cells.length; rowIndex++) { + const row = cells[rowIndex]; + for (let colIndex = 0; colIndex < row.length; colIndex++) { + const col = row[colIndex]; + + let item = selector( + this.context.viewport, + '$.children[?(@.id==="${this.id}.${rowIndex}.${colIndex}")]', + )[0]; + if (col === 0 && item) { + this.removeChild(item); + } else if (col === 1 && !item) { + item = new Item(this.context); + const itemProps = this.props.item; + item.update({ + id: `${this.id}.${rowIndex}.${colIndex}`, + components: itemProps.components, + size: itemProps.size, + attrs: { + x: colIndex * (itemProps.size.width + this.props.gap.x), + y: rowIndex * (itemProps.size.height + this.props.gap.y), + }, + }); + this.addChild(item); + } + } + } + } + }; + 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..013a25e0 --- /dev/null +++ b/src/display/mixins/Childrenable.js @@ -0,0 +1,40 @@ +import { isValidationError } from 'zod-validation-error'; +import { findIndexByPriority } from '../../utils/findIndexByPriority'; +import { validate } from '../../utils/validator'; +import { elementTypes } from '../data-schema/element-schema'; +import { elementCreator } from '../draw'; +import { UPDATE_STAGES } from './constants'; + +const KEYS = ['children']; + +export const Childrenable = (superClass) => { + const MixedClass = class extends superClass { + _applyChildren(relevantChanges) { + const { children } = relevantChanges; + + const elements = [...this.children]; + 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 = new elementCreator[childChange.type](this.context); + this.addChild(element); + } + element.update(childChange); + } + } + }; + 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..2aa6fe45 --- /dev/null +++ b/src/display/mixins/Componentsable.js @@ -0,0 +1,47 @@ +import { isValidationError } from 'zod-validation-error'; +import { findIndexByPriority } from '../../utils/findIndexByPriority'; +import { validate } from '../../utils/validator'; +import { Background, Bar, Icon, Text } from '../components'; +import { componentSchema } from '../data-schema/component-schema'; +import { UPDATE_STAGES } from './constants'; + +const ComponentCreator = { + background: Background, + bar: Bar, + icon: Icon, + text: Text, +}; + +const KEYS = ['components']; + +export const Componentsable = (superClass) => { + const MixedClass = class extends superClass { + _applyComponents(relevantChanges) { + const { components: componentsChanges } = relevantChanges; + + const components = [...this.children]; + 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 = new ComponentCreator[componentChange.type](this.context); + this.addChild(component); + } + component.update(componentChange); + } + } + }; + 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..1a400a95 --- /dev/null +++ b/src/display/mixins/Itemable.js @@ -0,0 +1,33 @@ +import { UPDATE_STAGES } from './constants'; + +const KEYS = ['cells', 'item', 'gap']; + +export const Itemable = (superClass) => { + const MixedClass = class extends superClass { + _applyItem(relevantChanges) { + const { cells, item, gap } = relevantChanges; + + const childrenLength = this.children.length; + const colSize = cells[0].length; + for (let index = 0; index < childrenLength; index++) { + const rowIndex = Math.floor(index / colSize); + const colIndex = index % colSize; + + const child = this.children[index]; + child.update({ + ...item, + attrs: { + x: colIndex * (item.size.width + gap.x), + y: rowIndex * (item.size.height + gap.y), + }, + }); + } + } + }; + 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..f50a73b1 --- /dev/null +++ b/src/display/mixins/Itemsizeable.js @@ -0,0 +1,19 @@ +import { deepMerge } from '../../utils/deepmerge/deepmerge'; +import { UPDATE_STAGES } from './constants'; + +const KEYS = ['size']; + +export const ItemSizeable = (superClass) => { + const MixedClass = class extends superClass { + _applyItemSize(relevantChanges) { + const { size } = relevantChanges; + this.props.size = deepMerge(this.props.size, size); + } + }; + 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..7d52ba8c --- /dev/null +++ b/src/display/mixins/Placementable.js @@ -0,0 +1,60 @@ +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') { + result = (parentWidth - component.width) / 2; + } + 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') { + result = (parentHeight - component.height) / 2; + } + return result; +}; diff --git a/src/display/mixins/Relationstyleable.js b/src/display/mixins/Relationstyleable.js new file mode 100644 index 00000000..90eed71b --- /dev/null +++ b/src/display/mixins/Relationstyleable.js @@ -0,0 +1,26 @@ +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 }); + } + }; + 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/mixins/constants.js b/src/display/mixins/constants.js new file mode 100644 index 00000000..6d387e50 --- /dev/null +++ b/src/display/mixins/constants.js @@ -0,0 +1,29 @@ +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', + 300: 'light', + 400: 'regular', + 500: 'medium', + 600: 'semibold', + 700: 'bold', + 800: 'extrabold', + 900: 'black', + thin: 'thin', + extralight: 'extralight', + light: 'light', + regular: 'regular', + medium: 'medium', + semibold: 'semibold', + bold: 'bold', + extrabold: 'extrabold', + black: 'black', +}; diff --git a/src/display/mixins/linksable.js b/src/display/mixins/linksable.js new file mode 100644 index 00000000..622ddb9b --- /dev/null +++ b/src/display/mixins/linksable.js @@ -0,0 +1,57 @@ +import { selector } from '../../utils/selector/selector'; +import { UPDATE_STAGES } from './constants'; +import { calcOrientedBounds } from './utils'; + +const KEYS = ['links', 'style']; + +export const Linksable = (superClass) => { + const MixedClass = class extends superClass { + async _applyLinks(relevantChanges) { + // Ensure this runs after all other objects have been rendered + await new Promise((resolve) => setTimeout(resolve, 0)); + + const { links } = relevantChanges; + const path = selector(this, '$.children[?(@.type==="path")]')[0]; + if (!path) return; + path.clear(); + let lastPoint = null; + + const viewport = this.context.viewport; + const linkedObjects = uniqueLinked(viewport, links); + for (const link of links) { + const sourceBounds = this.toLocal( + calcOrientedBounds(linkedObjects[link.source]).center, + ); + const targetBounds = this.toLocal( + calcOrientedBounds(linkedObjects[link.target]).center, + ); + + const sourcePoint = [sourceBounds.x, sourceBounds.y]; + const targetPoint = [targetBounds.x, targetBounds.y]; + if ( + !lastPoint || + JSON.stringify(lastPoint) === JSON.stringify(sourcePoint) + ) { + path.moveTo(...sourcePoint); + } + path.lineTo(...targetPoint); + lastPoint = targetPoint; + } + path.stroke(); + } + }; + 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..c6189f21 --- /dev/null +++ b/src/display/mixins/utils.js @@ -0,0 +1,75 @@ +import { OrientedBounds } from '@pixi-essentials/bounds'; +import gsap from 'gsap'; +import { Matrix, Transform } from 'pixi.js'; +import { + decomposeTransform, + getBoundsFromPoints, + getCentroid, + getObjectWorldCorners, +} from '../../utils/transform'; + +export const tweensOf = (object) => gsap.getTweensOf(object); + +export const killTweensOf = (object) => gsap.killTweensOf(object); + +export const getMaxSize = ( + size, + margin = { top: 0, right: 0, bottom: 0, left: 0 }, +) => { + const { top = 0, right = 0, bottom = 0, left = 0 } = margin || {}; + return { + width: size.width - (left + right), + height: size.height - (top + bottom), + }; +}; + +export const calcSize = (component, { source, size, margin }) => { + const { width: maxWidth, height: maxHeight } = getMaxSize( + component.parent.props.size, + margin, + ); + + const borderWidth = + typeof source === 'object' ? (source?.borderWidth ?? 0) : 0; + + return { + width: + (size.width.unit === '%' + ? maxWidth * (size.width.value / 100) + : size.width.value) + borderWidth, + height: + (size.height.unit === '%' + ? maxHeight * (size.height.value / 100) + : size.height.value) + borderWidth, + borderWidth: borderWidth, + }; +}; + +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; +}; diff --git a/src/display/update/update-components.js b/src/display/update/update-components.js index 3dba305b..6eea7187 100644 --- a/src/display/update/update-components.js +++ b/src/display/update/update-components.js @@ -4,7 +4,7 @@ import { validate } from '../../utils/validator'; import { Background, Bar, Icon, Text } from '../components'; import { componentSchema } from '../data-schema/component-schema'; -const Creator = { +export const ComponentCreator = { background: Background, bar: Bar, icon: Icon, @@ -40,6 +40,6 @@ export const updateComponents = ( }; const createComponent = (config) => { - const component = new Creator[config.type](); + const component = new ComponentCreator[config.type](); return component; }; diff --git a/src/init.js b/src/init.js index 9b3a32c1..440626ad 100644 --- a/src/init.js +++ b/src/init.js @@ -4,7 +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/Base'; +import { Type } from './display/mixins/Type'; import { deepMerge } from './utils/deepmerge/deepmerge'; import { plugin } from './utils/event/viewport'; import { uid } from './utils/uuid'; 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; +}; From 2666e0f55fbf88d4987c2c515968bd69d5826051 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Fri, 4 Jul 2025 16:28:31 +0900 Subject: [PATCH 35/66] delete lagecy --- src/command/commands/index.js | 3 - src/command/commands/position.js | 48 --------------- src/command/commands/show.js | 48 --------------- src/command/commands/tint.js | 55 ----------------- src/display/change/animation.js | 12 ---- src/display/change/asset.js | 17 ------ src/display/change/background-transform.js | 10 ---- src/display/change/bar-size.js | 51 ---------------- src/display/change/index.js | 15 ----- src/display/change/links.js | 62 -------------------- src/display/change/pipeline/base.js | 10 ---- src/display/change/pipeline/component.js | 68 ---------------------- src/display/change/pipeline/element.js | 37 ------------ src/display/change/pipeline/utils.js | 12 ---- src/display/change/placement.js | 46 --------------- src/display/change/position.js | 4 -- src/display/change/property.js | 5 -- src/display/change/show.js | 6 -- src/display/change/size.js | 21 ------- src/display/change/stroke-style.js | 35 ----------- src/display/change/text-style.js | 54 ----------------- src/display/change/text.js | 30 ---------- src/display/change/texture.js | 20 ------- src/display/change/tint.js | 6 -- src/display/change/utils.js | 31 ---------- src/display/components/config.js | 20 ------- src/display/{update => }/update.js | 8 +-- src/display/update/update-components.js | 45 -------------- src/display/update/update-object.js | 36 ------------ src/patch-map.ts | 1 - src/patchmap.js | 2 +- 31 files changed, 5 insertions(+), 813 deletions(-) delete mode 100644 src/command/commands/position.js delete mode 100644 src/command/commands/show.js delete mode 100644 src/command/commands/tint.js delete mode 100644 src/display/change/animation.js delete mode 100644 src/display/change/asset.js delete mode 100644 src/display/change/background-transform.js delete mode 100644 src/display/change/bar-size.js delete mode 100644 src/display/change/index.js delete mode 100644 src/display/change/links.js delete mode 100644 src/display/change/pipeline/base.js delete mode 100644 src/display/change/pipeline/component.js delete mode 100644 src/display/change/pipeline/element.js delete mode 100644 src/display/change/pipeline/utils.js delete mode 100644 src/display/change/placement.js delete mode 100644 src/display/change/position.js delete mode 100644 src/display/change/property.js delete mode 100644 src/display/change/show.js delete mode 100644 src/display/change/size.js delete mode 100644 src/display/change/stroke-style.js delete mode 100644 src/display/change/text-style.js delete mode 100644 src/display/change/text.js delete mode 100644 src/display/change/texture.js delete mode 100644 src/display/change/tint.js delete mode 100644 src/display/change/utils.js delete mode 100644 src/display/components/config.js rename src/display/{update => }/update.js (89%) delete mode 100644 src/display/update/update-components.js delete mode 100644 src/display/update/update-object.js 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 0c477a37..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, 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 d4c16839..00000000 --- a/src/display/change/animation.js +++ /dev/null @@ -1,12 +0,0 @@ -import { isMatch, mergeProps, tweensOf } from './utils'; - -export const changeAnimation = (object, { animation }) => { - if (isMatch(object, { animation })) { - return; - } - - if (!animation) { - tweensOf(object).forEach((tween) => tween.progress(1).kill()); - } - mergeProps(object, { animation }); -}; diff --git a/src/display/change/asset.js b/src/display/change/asset.js deleted file mode 100644 index bd4ee0f4..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 { isMatch, mergeProps } from './utils'; - -export const changeAsset = (object, { source: assetConfig }, { theme }) => { - if (isMatch(object, { source: 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; - mergeProps(object, { source: assetConfig }); -}; diff --git a/src/display/change/background-transform.js b/src/display/change/background-transform.js deleted file mode 100644 index 1331fe4e..00000000 --- a/src/display/change/background-transform.js +++ /dev/null @@ -1,10 +0,0 @@ -export const changeBackgroundTransform = (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/bar-size.js b/src/display/change/bar-size.js deleted file mode 100644 index b35b32cf..00000000 --- a/src/display/change/bar-size.js +++ /dev/null @@ -1,51 +0,0 @@ -import gsap from 'gsap'; -import { changePlacement } from './placement'; -import { getMaxSize, isMatch, killTweensOf, mergeProps } from './utils'; - -export const changeBarSize = ( - object, - { - size = object.size, - margin = object.margin, - animationDuration = object.animationDuration, - }, - { animationContext }, -) => { - if (isMatch(object, { size, margin })) { - return; - } - - const { width = object.size.width, height = object.size.height } = size; - - changeWidth(object, width, margin); - changeHeight(object, height, margin); - mergeProps(object, { size, margin, animationDuration }); - - function changeWidth(component, width, margin) { - const { width: maxWidth } = getMaxSize(component.parent.size, margin); - component.width = - width.unit === '%' ? maxWidth * (width.value / 100) : width.value; - } - - function changeHeight(component, height, margin) { - const { height: maxHeight } = getMaxSize(component.parent.size, margin); - const heightValue = - height.unit === '%' ? maxHeight * (height.value / 100) : height.value; - - if (object.animation) { - animationContext.add(() => { - killTweensOf(component); - gsap.to(component, { - pixi: { - height: heightValue, - }, - duration: animationDuration / 1000, - ease: 'power2.inOut', - onUpdate: () => changePlacement(component, {}), - }); - }); - } else { - component.height = heightValue; - } - } -}; diff --git a/src/display/change/index.js b/src/display/change/index.js deleted file mode 100644 index e9c0a88d..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 { changeBackgroundTransform } from './background-transform'; -export { changeBarSize } from './bar-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 c209d5e6..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 { mergeProps } 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(); - mergeProps(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/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 a23cfcde..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: ['source'], - handler: (component, config, options) => { - change.changeTexture(component, config, options); - }, - }, - asset: { - keys: ['source'], - handler: (component, config, options) => { - change.changeAsset(component, config, options); - }, - }, - backgroundTransform: { - keys: ['source'], - handler: (component) => { - change.changeBackgroundTransform(component); - }, - }, - animation: { - keys: ['animation'], - handler: (component, config) => { - change.changeAnimation(component, config); - }, - }, - barSize: { - keys: ['size', 'margin'], - handler: (component, config, options) => { - change.changeBarSize(component, config, options); - change.changePlacement(component, {}); - }, - }, - size: { - keys: ['size', 'margin'], - 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 39968998..00000000 --- a/src/display/change/pipeline/element.js +++ /dev/null @@ -1,37 +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: ['x', 'y'], - handler: createCommandHandler( - Commands.PositionCommand, - change.changePosition, - ), - }, - gridComponents: { - keys: ['item'], - handler: (element, config, options) => { - for (const cell of element.children) { - updateComponents(cell, config.item, options); - } - change.changeProperty(element, 'item', config.item); - }, - }, - components: { - keys: ['components'], - handler: updateComponents, - }, - links: { - keys: ['links'], - handler: change.changeLinks, - }, - strokeStyle: { - keys: ['style'], - 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 f058cb1e..00000000 --- a/src/display/change/placement.js +++ /dev/null @@ -1,46 +0,0 @@ -import { mergeProps } from './utils'; - -export const changePlacement = ( - object, - { placement = object.placement, margin = object.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 [first, second] = placement.split('-'); - const directions = second ? { h: first, v: second } : directionMap[first]; - - object.visible = false; - const x = getHorizontalPosition(object, directions.h, margin); - const y = getVerticalPosition(object, directions.v, margin); - object.position.set(x, y); - object.visible = true; - mergeProps(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 8ad120a6..00000000 --- a/src/display/change/position.js +++ /dev/null @@ -1,4 +0,0 @@ -export const changePosition = (object, { x, y }) => { - const position = object.position; - object.position.set(x ?? position.x, y ?? position.y); -}; 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 f4a5e6ac..00000000 --- a/src/display/change/show.js +++ /dev/null @@ -1,6 +0,0 @@ -import { mergeProps } from './utils'; - -export const changeShow = (object, { show }) => { - object.renderable = show; - mergeProps(object, { show }); -}; diff --git a/src/display/change/size.js b/src/display/change/size.js deleted file mode 100644 index 038f9536..00000000 --- a/src/display/change/size.js +++ /dev/null @@ -1,21 +0,0 @@ -import { getMaxSize, mergeProps } from './utils'; - -export const changeSize = ( - object, - { size = object.size, margin = object.margin }, -) => { - const { width: maxWidth, height: maxHeight } = getMaxSize( - object.parent.size, - margin, - ); - - object.setSize( - size.width.unit === '%' - ? maxWidth * (size.width.value / 100) - : size.width.value, - size.height.unit === '%' - ? maxHeight * (size.height.value / 100) - : size.height.value, - ); - mergeProps(object, { size }); -}; diff --git a/src/display/change/stroke-style.js b/src/display/change/stroke-style.js deleted file mode 100644 index 1bc0e305..00000000 --- a/src/display/change/stroke-style.js +++ /dev/null @@ -1,35 +0,0 @@ -import { getColor } from '../../utils/get'; -import { selector } from '../../utils/selector/selector'; -import { mergeProps } from './utils'; - -export const changeStrokeStyle = (object, { style, links }, { theme }) => { - const path = selector(object, '$.children[?(@.type==="path")]')[0]; - if (!path) return; - - if ('color' in style) { - style.color = getColor(theme, style.color); - } - - path.setStrokeStyle({ ...path.strokeStyle, ...style }); - if (!links && path.links.length > 0) { - reRenderPath(path); - } - mergeProps(object, { style }); - - 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 602fcde9..00000000 --- a/src/display/change/text-style.js +++ /dev/null @@ -1,54 +0,0 @@ -import { getColor } from '../../utils/get'; -import { FONT_WEIGHT } from '../components/config'; -import { isMatch, mergeProps } from './utils'; - -export const changeTextStyle = ( - object, - { style = object.style, margin = object.margin }, - { theme }, -) => { - if (isMatch(object, { style, 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') { - setAutoFontSize(object, margin); - } else { - object.style[key] = style[key]; - } - } - mergeProps(object, { style, margin }); - - function setAutoFontSize(component, margin) { - component.visible = false; - const { width, height } = component.parent.size; - 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 5ab78e54..00000000 --- a/src/display/change/text.js +++ /dev/null @@ -1,30 +0,0 @@ -import { changeTextStyle } from './text-style'; -import { isMatch, mergeProps } from './utils'; - -export const changeText = ( - object, - { text = object.text, split = object.split }, - { theme }, -) => { - if (isMatch(object, { text, split })) { - return; - } - - object.text = splitText(text, split); - - if (object?.style?.fontSize === 'auto') { - changeTextStyle(object, { style: { fontSize: 'auto' } }, { theme }); - } - mergeProps(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.js b/src/display/change/texture.js deleted file mode 100644 index b228e2d3..00000000 --- a/src/display/change/texture.js +++ /dev/null @@ -1,20 +0,0 @@ -import { getTexture } from '../../assets/textures/texture'; -import { deepMerge } from '../../utils/deepmerge/deepmerge'; -import { getViewport } from '../../utils/get'; -import { isMatch, mergeProps } from './utils'; - -export const changeTexture = (object, { source: textureConfig }, { theme }) => { - if (isMatch(object, { source: 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 }); - mergeProps(object, { source: textureConfig }); -}; diff --git a/src/display/change/tint.js b/src/display/change/tint.js deleted file mode 100644 index 77d51e3d..00000000 --- a/src/display/change/tint.js +++ /dev/null @@ -1,6 +0,0 @@ -import { getColor } from '../../utils/get'; - -export const changeTint = (object, { color, tint }, { theme }) => { - const hexColor = getColor(theme, tint ?? color); - object.tint = hexColor; -}; diff --git a/src/display/change/utils.js b/src/display/change/utils.js deleted file mode 100644 index d4531e77..00000000 --- a/src/display/change/utils.js +++ /dev/null @@ -1,31 +0,0 @@ -import gsap from 'gsap'; -import { deepMerge } from '../../utils/deepmerge/deepmerge'; -import { isSame } from '../../utils/diff/isSame'; - -export const isMatch = (object, props) => { - return Object.keys(props).every((key) => { - const value = props[key]; - return value === undefined || isSame(object[key], value); - }); -}; - -export const mergeProps = (object, props = {}, overwrite = false) => { - for (const [key, value] of Object.entries(props)) { - object[key] = overwrite ? value : deepMerge(object[key], value); - } -}; - -export const tweensOf = (object) => gsap.getTweensOf(object); - -export const killTweensOf = (object) => gsap.killTweensOf(object); - -export const getMaxSize = ( - size, - margin = { top: 0, right: 0, bottom: 0, left: 0 }, -) => { - const { top = 0, right = 0, bottom = 0, left = 0 } = margin || {}; - return { - width: size.width - (left + right), - height: size.height - (top + bottom), - }; -}; diff --git a/src/display/components/config.js b/src/display/components/config.js deleted file mode 100644 index 0f04d03c..00000000 --- a/src/display/components/config.js +++ /dev/null @@ -1,20 +0,0 @@ -export const FONT_WEIGHT = { - 100: 'thin', - 200: 'extralight', - 300: 'light', - 400: 'regular', - 500: 'medium', - 600: 'semibold', - 700: 'bold', - 800: 'extrabold', - 900: 'black', - thin: 'thin', - extralight: 'extralight', - light: 'light', - regular: 'regular', - medium: 'medium', - semibold: 'semibold', - bold: 'bold', - extrabold: 'extrabold', - black: 'black', -}; diff --git a/src/display/update/update.js b/src/display/update.js similarity index 89% rename from src/display/update/update.js rename to src/display/update.js index 4faf552e..a607d6dc 100644 --- a/src/display/update/update.js +++ b/src/display/update.js @@ -1,9 +1,9 @@ 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 { 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), diff --git a/src/display/update/update-components.js b/src/display/update/update-components.js deleted file mode 100644 index 6eea7187..00000000 --- a/src/display/update/update-components.js +++ /dev/null @@ -1,45 +0,0 @@ -import { isValidationError } from 'zod-validation-error'; -import { findIndexByPriority } from '../../utils/findIndexByPriority'; -import { validate } from '../../utils/validator'; -import { Background, Bar, Icon, Text } from '../components'; -import { componentSchema } from '../data-schema/component-schema'; - -export const ComponentCreator = { - background: Background, - bar: Bar, - icon: Icon, - text: Text, -}; - -export const updateComponents = ( - item, - { components: componentConfig }, - options, -) => { - if (!componentConfig) return; - - const itemComponents = [...item.children]; - for (let changes of componentConfig) { - const idx = findIndexByPriority(itemComponents, changes); - let component = null; - - if (idx !== -1) { - component = itemComponents[idx]; - itemComponents.splice(idx, 1); - } else { - changes = validate(changes, componentSchema); - if (isValidationError(changes)) throw changes; - - component = createComponent(changes); - if (!component) continue; - item.addChild(component); - } - - component.update(changes, options); - } -}; - -const createComponent = (config) => { - const component = new ComponentCreator[config.type](); - return component; -}; diff --git a/src/display/update/update-object.js b/src/display/update/update-object.js deleted file mode 100644 index b67644f7..00000000 --- a/src/display/update/update-object.js +++ /dev/null @@ -1,36 +0,0 @@ -import { changeProperty } from '../change'; - -const DEFAULT_EXCEPTION_KEYS = new Set(['position', 'children', 'type']); - -export const updateObject = ( - object, - changes, - pipeline, - pipelineKeys, - options, -) => { - if (!object) return; - - const attrs = changes.attrs; - - if (attrs) { - for (const [key, value] of Object.entries(attrs)) { - changeProperty(object, key, value); - } - } - - const pipelines = pipelineKeys.map((key) => pipeline[key]).filter(Boolean); - 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); - } - } - - for (const { keys, handler } of pipelines) { - const hasMatch = keys.some((key) => key in changes); - if (hasMatch) { - handler(object, changes, options); - } - } -}; 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 d980ed83..4192a559 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'; From afda740a9fb3ca80bc4922a9a09c92dde3f63a31 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Fri, 4 Jul 2025 18:45:59 +0900 Subject: [PATCH 36/66] fix default options --- src/display/components/Text.js | 7 ++- src/display/data-schema/component-schema.js | 2 +- .../data-schema/component-schema.test.js | 4 -- src/display/data-schema/element-schema.js | 2 +- .../data-schema/element-schema.test.js | 1 - src/display/data-schema/primitive-schema.js | 15 +----- .../data-schema/primitive-schema.test.js | 26 ++-------- src/display/elements/Relations.js | 50 ++++++++++++++++++- src/display/elements/RenderElement.js | 11 ++++ src/display/mixins/linksable.js | 36 ++----------- 10 files changed, 75 insertions(+), 79 deletions(-) create mode 100644 src/display/elements/RenderElement.js diff --git a/src/display/components/Text.js b/src/display/components/Text.js index 6b4bc3db..40256357 100644 --- a/src/display/components/Text.js +++ b/src/display/components/Text.js @@ -16,7 +16,12 @@ const ComposedText = Placementable( export class Text extends ComposedText { constructor(context) { - super({ type: 'text', context, text: '' }); + super({ + type: 'text', + context, + text: '', + style: { fontFamily: 'FiraCode regular', fill: 'black' }, + }); this.constructor.registerHandler( EXTRA_KEYS.PLACEMENT, diff --git a/src/display/data-schema/component-schema.js b/src/display/data-schema/component-schema.js index 0242717c..9964a575 100644 --- a/src/display/data-schema/component-schema.js +++ b/src/display/data-schema/component-schema.js @@ -68,7 +68,7 @@ export const textSchema = Base.extend({ margin: Margin.default(0), tint: Tint.optional(), text: z.string().default(''), - style: TextStyle, + style: TextStyle.optional(), split: z.number().int().default(0), }).strict(); diff --git a/src/display/data-schema/component-schema.test.js b/src/display/data-schema/component-schema.test.js index 72eb1772..e2743447 100644 --- a/src/display/data-schema/component-schema.test.js +++ b/src/display/data-schema/component-schema.test.js @@ -196,9 +196,6 @@ describe('Component Schemas', () => { it('should parse valid text and apply all defaults', () => { const parsed = textSchema.parse({ type: 'text' }); expect(parsed.text).toBe(''); - expect(parsed.style.fontFamily).toBe('FiraCode'); - expect(parsed.style.fill).toBe('black'); - expect(parsed.style.fontWeight).toBe(400); expect(parsed.split).toBe(0); expect(parsed.placement).toBe('center'); }); @@ -211,7 +208,6 @@ describe('Component Schemas', () => { const parsed = textSchema.parse(data); expect(parsed.style.fill).toBe('red'); // Overridden expect(parsed.style.fontSize).toBe(24); // Added - expect(parsed.style.fontFamily).toBe('FiraCode'); // Default maintained }); }); diff --git a/src/display/data-schema/element-schema.js b/src/display/data-schema/element-schema.js index 38794454..ce726cc6 100644 --- a/src/display/data-schema/element-schema.js +++ b/src/display/data-schema/element-schema.js @@ -46,7 +46,7 @@ export const itemSchema = Base.extend({ export const relationsSchema = Base.extend({ type: z.literal('relations'), links: z.array(z.object({ source: z.string(), target: z.string() })), - style: RelationsStyle, + style: RelationsStyle.optional(), }).strict(); export const elementTypes = z.discriminatedUnion('type', [ diff --git a/src/display/data-schema/element-schema.test.js b/src/display/data-schema/element-schema.test.js index b7bda3cc..caf19adc 100644 --- a/src/display/data-schema/element-schema.test.js +++ b/src/display/data-schema/element-schema.test.js @@ -148,7 +148,6 @@ describe('Element Schemas', () => { }; const parsed = relationsSchema.parse(relationsData); expect(parsed.links).toHaveLength(1); - expect(parsed.style).toEqual({ color: 'black' }); }); it('should accept an overridden style', () => { diff --git a/src/display/data-schema/primitive-schema.js b/src/display/data-schema/primitive-schema.js index d0417f80..abdca231 100644 --- a/src/display/data-schema/primitive-schema.js +++ b/src/display/data-schema/primitive-schema.js @@ -113,23 +113,12 @@ export const TextureStyle = z /** * @see {@link https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html} */ -export const RelationsStyle = z.preprocess( - (val) => ({ color: 'black', ...(val ?? {}) }), - z.record(z.string(), z.unknown()), -); +export const RelationsStyle = z.record(z.string(), z.unknown()); /** * @see {@link https://pixijs.download/release/docs/text.TextStyleOptions.html} */ -export const TextStyle = z.preprocess( - (val) => ({ - fontFamily: 'FiraCode', - fontWeight: 400, - fill: 'black', - ...(val ?? {}), - }), - z.record(z.string(), z.unknown()), -); +export const TextStyle = z.record(z.string(), z.unknown()); /** * @see {@link https://pixijs.download/release/docs/color.ColorSource.html} diff --git a/src/display/data-schema/primitive-schema.test.js b/src/display/data-schema/primitive-schema.test.js index da21a941..a1322838 100644 --- a/src/display/data-schema/primitive-schema.test.js +++ b/src/display/data-schema/primitive-schema.test.js @@ -172,7 +172,7 @@ describe('Primitive Schema Tests', () => { expect(PxOrPercentSize.parse(input)).toEqual(expected); }); - it('', () => { + it('should allow partial PxOrPercentSize objects with deepPartial', () => { const input = { width: { value: 150, unit: 'px' }, height: { value: 75, unit: '%' }, @@ -344,22 +344,14 @@ describe('Primitive Schema Tests', () => { describe('RelationsStyle Schema', () => { it('should add default color if not provided', () => { const data = { lineWidth: 2 }; - expect(RelationsStyle.parse(data)).toEqual({ - color: 'black', - 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({ - fontFamily: 'FiraCode', - fontWeight: 400, - fill: 'black', - fontSize: 16, - }); + expect(TextStyle.parse(data)).toEqual({ fontSize: 16 }); }); it('should not override provided styles', () => { @@ -370,17 +362,5 @@ describe('Primitive Schema Tests', () => { fill: 'red', }); }); - - it.each([ - { case: 'undefined', input: undefined }, - { case: 'null', input: null }, - { case: 'empty object', input: {} }, - ])('should return full default object for $case input', ({ input }) => { - expect(TextStyle.parse(input)).toEqual({ - fontFamily: 'FiraCode', - fontWeight: 400, - fill: 'black', - }); - }); }); }); diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index 15abb509..c0773f62 100644 --- a/src/display/elements/Relations.js +++ b/src/display/elements/Relations.js @@ -1,24 +1,70 @@ import { Graphics } from 'pixi.js'; +import { selector } from '../../utils/selector/selector'; import { relationsSchema } from '../data-schema/element-schema'; import { Relationstyleable } from '../mixins/Relationstyleable'; import { Linksable } from '../mixins/linksable'; -import Element from './Element'; +import { calcOrientedBounds } from '../mixins/utils'; +import RenderElement from './RenderElement'; -const ComposedRelations = Relationstyleable(Linksable(Element)); +const ComposedRelations = Relationstyleable(Linksable(RenderElement)); export class Relations extends ComposedRelations { + allowChildren = true; + constructor(context) { super({ type: 'relations', context }); this.initPath(); + this._renderDirty = true; } update(changes) { + if (changes.links || changes.style) { + this._renderDirty = true; + } super.update(changes, relationsSchema); } initPath() { const path = new Graphics(); + path.setStrokeStyle({ color: 'black' }); Object.assign(path, { type: 'path', links: [] }); this.addChild(path); } + + render(renderer) { + if (this._renderDirty) { + this.renderLink(); + this._renderDirty = false; + } + super.render(renderer); + } + + 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 sourceBounds = this.toLocal( + calcOrientedBounds(this.linkedObjects[link.source]).center, + ); + const targetBounds = this.toLocal( + calcOrientedBounds(this.linkedObjects[link.target]).center, + ); + + const sourcePoint = [sourceBounds.x, sourceBounds.y]; + const targetPoint = [targetBounds.x, targetBounds.y]; + if ( + !lastPoint || + JSON.stringify(lastPoint) === JSON.stringify(sourcePoint) + ) { + path.moveTo(...sourcePoint); + } + path.lineTo(...targetPoint); + lastPoint = targetPoint; + } + path.stroke(); + } } diff --git a/src/display/elements/RenderElement.js b/src/display/elements/RenderElement.js new file mode 100644 index 00000000..12f2d615 --- /dev/null +++ b/src/display/elements/RenderElement.js @@ -0,0 +1,11 @@ +import { RenderContainer } from 'pixi.js'; +import { Base } from '../mixins/Base'; +import { Showable } from '../mixins/Showable'; + +const ComposedRenderElement = Showable(Base(RenderContainer)); + +export default class RenderElement extends ComposedRenderElement { + constructor(options) { + super(Object.assign(options, { eventMode: 'static' })); + } +} diff --git a/src/display/mixins/linksable.js b/src/display/mixins/linksable.js index 622ddb9b..84f513b3 100644 --- a/src/display/mixins/linksable.js +++ b/src/display/mixins/linksable.js @@ -1,43 +1,13 @@ import { selector } from '../../utils/selector/selector'; import { UPDATE_STAGES } from './constants'; -import { calcOrientedBounds } from './utils'; -const KEYS = ['links', 'style']; +const KEYS = ['links']; export const Linksable = (superClass) => { const MixedClass = class extends superClass { - async _applyLinks(relevantChanges) { - // Ensure this runs after all other objects have been rendered - await new Promise((resolve) => setTimeout(resolve, 0)); - + _applyLinks(relevantChanges) { const { links } = relevantChanges; - const path = selector(this, '$.children[?(@.type==="path")]')[0]; - if (!path) return; - path.clear(); - let lastPoint = null; - - const viewport = this.context.viewport; - const linkedObjects = uniqueLinked(viewport, links); - for (const link of links) { - const sourceBounds = this.toLocal( - calcOrientedBounds(linkedObjects[link.source]).center, - ); - const targetBounds = this.toLocal( - calcOrientedBounds(linkedObjects[link.target]).center, - ); - - const sourcePoint = [sourceBounds.x, sourceBounds.y]; - const targetPoint = [targetBounds.x, targetBounds.y]; - if ( - !lastPoint || - JSON.stringify(lastPoint) === JSON.stringify(sourcePoint) - ) { - path.moveTo(...sourcePoint); - } - path.lineTo(...targetPoint); - lastPoint = targetPoint; - } - path.stroke(); + this.linkedObjects = uniqueLinked(this.context.viewport, links); } }; MixedClass.registerHandler( From d25216edb8df460ca1855da8b157680b050345d5 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Fri, 4 Jul 2025 19:07:10 +0900 Subject: [PATCH 37/66] add overwrite --- src/display/components/Background.js | 4 ++-- src/display/components/Bar.js | 4 ++-- src/display/components/Icon.js | 4 ++-- src/display/components/Text.js | 4 ++-- src/display/elements/Item.js | 3 ++- src/display/mixins/Base.js | 14 +++++++------- src/display/mixins/Itemsizeable.js | 12 ++++++++---- 7 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/display/components/Background.js b/src/display/components/Background.js index 1e70dff6..1cb3e543 100644 --- a/src/display/components/Background.js +++ b/src/display/components/Background.js @@ -15,7 +15,7 @@ export class Background extends ComposedBackground { super({ type: 'background', context, texture: Texture.WHITE }); } - update(changes) { - super.update(changes, backgroundSchema); + update(changes, options) { + super.update(changes, backgroundSchema, options); } } diff --git a/src/display/components/Bar.js b/src/display/components/Bar.js index 15047918..aad89c69 100644 --- a/src/display/components/Bar.js +++ b/src/display/components/Bar.js @@ -28,7 +28,7 @@ export class Bar extends ComposedBar { ); } - update(changes) { - super.update(changes, barSchema); + update(changes, options) { + super.update(changes, barSchema, options); } } diff --git a/src/display/components/Icon.js b/src/display/components/Icon.js index da828f1a..9e845bdd 100644 --- a/src/display/components/Icon.js +++ b/src/display/components/Icon.js @@ -25,7 +25,7 @@ export class Icon extends ComposedIcon { ); } - update(changes) { - super.update(changes, iconSchema); + update(changes, options) { + super.update(changes, iconSchema, options); } } diff --git a/src/display/components/Text.js b/src/display/components/Text.js index 40256357..c2c13c54 100644 --- a/src/display/components/Text.js +++ b/src/display/components/Text.js @@ -29,7 +29,7 @@ export class Text extends ComposedText { ); } - update(changes) { - super.update(changes, textSchema); + update(changes, options) { + super.update(changes, textSchema, options); } } diff --git a/src/display/elements/Item.js b/src/display/elements/Item.js index bba04b3c..d4069b41 100644 --- a/src/display/elements/Item.js +++ b/src/display/elements/Item.js @@ -1,8 +1,9 @@ import { itemSchema } from '../data-schema/element-schema'; import { Componentsable } from '../mixins/Componentsable'; +import { ItemSizeable } from '../mixins/Itemsizeable'; import Element from './Element'; -const ComposedItem = Componentsable(Element); +const ComposedItem = ItemSizeable(Componentsable(Element)); export class Item extends ComposedItem { constructor(context) { diff --git a/src/display/mixins/Base.js b/src/display/mixins/Base.js index 808e040f..1e001cf0 100644 --- a/src/display/mixins/Base.js +++ b/src/display/mixins/Base.js @@ -40,16 +40,16 @@ export const Base = (superClass) => { }); } - update(changes, schema) { + update(changes, schema, options = {}) { const validatedChanges = validate(changes, deepPartial(schema)); if (isValidationError(validatedChanges)) throw validatedChanges; - const { - id, - label, - attrs = {}, - ...diffChanges - } = diffJson(this.props, validatedChanges) ?? {}; + const { overwrite = false } = options; + + const diff = overwrite + ? validatedChanges + : (diffJson(this.props, validatedChanges) ?? {}); + const { id, label, attrs = {}, ...diffChanges } = diff; this.props = deepMerge(this.props, validatedChanges); if (id || label || attrs) { diff --git a/src/display/mixins/Itemsizeable.js b/src/display/mixins/Itemsizeable.js index f50a73b1..b2482a8b 100644 --- a/src/display/mixins/Itemsizeable.js +++ b/src/display/mixins/Itemsizeable.js @@ -1,13 +1,17 @@ -import { deepMerge } from '../../utils/deepmerge/deepmerge'; import { UPDATE_STAGES } from './constants'; const KEYS = ['size']; export const ItemSizeable = (superClass) => { const MixedClass = class extends superClass { - _applyItemSize(relevantChanges) { - const { size } = relevantChanges; - this.props.size = deepMerge(this.props.size, size); + _applyItemSize() { + for (const child of this.children) { + if ('size' in child.props) { + child.update({ size: child.props.size }, { overwrite: true }); + } else if ('text' in child.props) { + child.update({ text: child.props.text }, { overwrite: true }); + } + } } }; MixedClass.registerHandler( From 51143b286d12bfc67687e36abac487d27d54e872 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 7 Jul 2025 11:13:48 +0900 Subject: [PATCH 38/66] add mixin util --- src/display/components/Background.js | 22 +++++++++++++------- src/display/components/Bar.js | 30 ++++++++++++++++++---------- src/display/components/Icon.js | 25 +++++++++++++++-------- src/display/components/Text.js | 22 +++++++++++++------- src/display/elements/Element.js | 6 +++--- src/display/elements/Grid.js | 6 +++--- src/display/elements/Group.js | 5 +++-- src/display/elements/Item.js | 6 +++--- src/display/elements/Relations.js | 7 +++---- src/display/mixins/index.js | 18 +++++++++++++++++ src/display/mixins/utils.js | 4 ++++ 11 files changed, 103 insertions(+), 48 deletions(-) create mode 100644 src/display/mixins/index.js diff --git a/src/display/components/Background.js b/src/display/components/Background.js index 1cb3e543..eaa18227 100644 --- a/src/display/components/Background.js +++ b/src/display/components/Background.js @@ -1,13 +1,21 @@ 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 { + Base, + ComponentSizeable, + Showable, + Sourceable, + Tintable, +} from '../mixins'; +import { mixins } from '../mixins/utils'; -const ComposedBackground = ComponentSizeable( - Tintable(Sourceable(Showable(Base(NineSliceSprite)))), +const ComposedBackground = mixins( + NineSliceSprite, + Base, + Showable, + Sourceable, + Tintable, + ComponentSizeable, ); export class Background extends ComposedBackground { diff --git a/src/display/components/Bar.js b/src/display/components/Bar.js index aad89c69..680d0366 100644 --- a/src/display/components/Bar.js +++ b/src/display/components/Bar.js @@ -1,21 +1,29 @@ 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 { + AnimationSizeable, + Animationable, + Base, + Placementable, + Showable, + Sourceable, + Tintable, +} from '../mixins'; +import { mixins } from '../mixins/utils'; const EXTRA_KEYS = { PLACEMENT: ['size'], }; -const ComposedBar = Placementable( - AnimationSizeable( - Animationable(Tintable(Sourceable(Showable(Base(NineSliceSprite))))), - ), +const ComposedBar = mixins( + NineSliceSprite, + Base, + Showable, + Sourceable, + Tintable, + Animationable, + AnimationSizeable, + Placementable, ); export class Bar extends ComposedBar { diff --git a/src/display/components/Icon.js b/src/display/components/Icon.js index 9e845bdd..c05eb03f 100644 --- a/src/display/components/Icon.js +++ b/src/display/components/Icon.js @@ -1,18 +1,27 @@ 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 { + Base, + ComponentSizeable, + Placementable, + Showable, + Sourceable, + Tintable, +} from '../mixins'; +import { mixins } from '../mixins/utils'; const EXTRA_KEYS = { PLACEMENT: ['size'], }; -const ComposedIcon = Placementable( - ComponentSizeable(Tintable(Sourceable(Showable(Base(Sprite))))), +const ComposedIcon = mixins( + Sprite, + Base, + Showable, + Sourceable, + Tintable, + ComponentSizeable, + Placementable, ); export class Icon extends ComposedIcon { diff --git a/src/display/components/Text.js b/src/display/components/Text.js index c2c13c54..6692811d 100644 --- a/src/display/components/Text.js +++ b/src/display/components/Text.js @@ -1,17 +1,25 @@ 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 { + Base, + Placementable, + Showable, + Textable, + Textstyleable, +} from '../mixins'; +import { mixins } from '../mixins/utils'; const EXTRA_KEYS = { PLACEMENT: ['text', 'split'], }; -const ComposedText = Placementable( - Textstyleable(Textable(Showable(Base(BitmapText)))), +const ComposedText = mixins( + BitmapText, + Base, + Showable, + Textable, + Textstyleable, + Placementable, ); export class Text extends ComposedText { diff --git a/src/display/elements/Element.js b/src/display/elements/Element.js index 64cbc94f..df248f31 100644 --- a/src/display/elements/Element.js +++ b/src/display/elements/Element.js @@ -1,8 +1,8 @@ import { Container } from 'pixi.js'; -import { Base } from '../mixins/Base'; -import { Showable } from '../mixins/Showable'; +import { Base, Showable } from '../mixins'; +import { mixins } from '../mixins/utils'; -const ComposedElement = Showable(Base(Container)); +const ComposedElement = mixins(Container, Base, Showable); export default class Element extends ComposedElement { constructor(options) { diff --git a/src/display/elements/Grid.js b/src/display/elements/Grid.js index e80b5a1a..34fa0a87 100644 --- a/src/display/elements/Grid.js +++ b/src/display/elements/Grid.js @@ -1,9 +1,9 @@ import { gridSchema } from '../data-schema/element-schema'; -import { Cellsable } from '../mixins/Cellsable'; -import { Itemable } from '../mixins/Itemable'; +import { Cellsable, Itemable } from '../mixins'; +import { mixins } from '../mixins/utils'; import Element from './Element'; -const ComposedGrid = Itemable(Cellsable(Element)); +const ComposedGrid = mixins(Element, Cellsable, Itemable); export class Grid extends ComposedGrid { constructor(context) { diff --git a/src/display/elements/Group.js b/src/display/elements/Group.js index f2031da7..ff891137 100644 --- a/src/display/elements/Group.js +++ b/src/display/elements/Group.js @@ -1,8 +1,9 @@ import { groupSchema } from '../data-schema/element-schema'; -import { Childrenable } from '../mixins/Childrenable'; +import { Childrenable } from '../mixins'; +import { mixins } from '../mixins/utils'; import Element from './Element'; -const ComposedGroup = Childrenable(Element); +const ComposedGroup = mixins(Element, Childrenable); export class Group extends ComposedGroup { constructor(context) { diff --git a/src/display/elements/Item.js b/src/display/elements/Item.js index d4069b41..da47e170 100644 --- a/src/display/elements/Item.js +++ b/src/display/elements/Item.js @@ -1,9 +1,9 @@ import { itemSchema } from '../data-schema/element-schema'; -import { Componentsable } from '../mixins/Componentsable'; -import { ItemSizeable } from '../mixins/Itemsizeable'; +import { Componentsable, ItemSizeable } from '../mixins'; +import { mixins } from '../mixins/utils'; import Element from './Element'; -const ComposedItem = ItemSizeable(Componentsable(Element)); +const ComposedItem = mixins(Element, Componentsable, ItemSizeable); export class Item extends ComposedItem { constructor(context) { diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index c0773f62..989bfe5a 100644 --- a/src/display/elements/Relations.js +++ b/src/display/elements/Relations.js @@ -1,12 +1,11 @@ import { Graphics } from 'pixi.js'; import { selector } from '../../utils/selector/selector'; import { relationsSchema } from '../data-schema/element-schema'; -import { Relationstyleable } from '../mixins/Relationstyleable'; -import { Linksable } from '../mixins/linksable'; -import { calcOrientedBounds } from '../mixins/utils'; +import { Linksable, Relationstyleable } from '../mixins'; +import { calcOrientedBounds, mixins } from '../mixins/utils'; import RenderElement from './RenderElement'; -const ComposedRelations = Relationstyleable(Linksable(RenderElement)); +const ComposedRelations = mixins(RenderElement, Linksable, Relationstyleable); export class Relations extends ComposedRelations { allowChildren = true; diff --git a/src/display/mixins/index.js b/src/display/mixins/index.js new file mode 100644 index 00000000..eb27ac86 --- /dev/null +++ b/src/display/mixins/index.js @@ -0,0 +1,18 @@ +export { TextStyle } from '../data-schema/primitive-schema'; +export { Animationable } from './Animationable'; +export { AnimationSizeable } from './Animationsizeable'; +export { Base } from './Base'; +export { Cellsable } from './Cellsable'; +export { Childrenable } from './Childrenable'; +export { Componentsable } from './Componentsable'; +export { ComponentSizeable } from './Componentsizeable'; +export { Itemable } from './Itemable'; +export { ItemSizeable } from './Itemsizeable'; +export { Linksable } from './linksable'; +export { Placementable } from './Placementable'; +export { Relationstyleable } from './Relationstyleable'; +export { Showable } from './Showable'; +export { Sourceable } from './Sourceable'; +export { Textable } from './Textable'; +export { Textstyleable } from './Textstyleable'; +export { Tintable } from './Tintable'; diff --git a/src/display/mixins/utils.js b/src/display/mixins/utils.js index c6189f21..f6b5461e 100644 --- a/src/display/mixins/utils.js +++ b/src/display/mixins/utils.js @@ -73,3 +73,7 @@ export const calcOrientedBounds = (object, bounds = tempBounds) => { resultBounds.update(); return resultBounds; }; + +export const mixins = (baseClass, ...mixins) => { + return mixins.reduce((target, mixin) => mixin(target), baseClass); +}; From 0f578f52a92a6739f5d14c4c5ee9aec017688b9c Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 7 Jul 2025 12:01:53 +0900 Subject: [PATCH 39/66] fix --- src/display/components/Background.js | 12 +++++------- src/display/components/Bar.js | 16 +++++++--------- src/display/components/Icon.js | 14 ++++++-------- src/display/components/Text.js | 12 +++++------- src/display/elements/Element.js | 3 ++- src/display/elements/Grid.js | 3 ++- src/display/elements/Group.js | 2 +- src/display/elements/Item.js | 3 ++- src/display/elements/Relations.js | 3 ++- src/display/elements/RenderElement.js | 3 ++- src/display/mixins/index.js | 18 ------------------ 11 files changed, 34 insertions(+), 55 deletions(-) delete mode 100644 src/display/mixins/index.js diff --git a/src/display/components/Background.js b/src/display/components/Background.js index eaa18227..63264ba6 100644 --- a/src/display/components/Background.js +++ b/src/display/components/Background.js @@ -1,12 +1,10 @@ import { NineSliceSprite, Texture } from 'pixi.js'; import { backgroundSchema } from '../data-schema/component-schema'; -import { - Base, - ComponentSizeable, - Showable, - Sourceable, - Tintable, -} from '../mixins'; +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( diff --git a/src/display/components/Bar.js b/src/display/components/Bar.js index 680d0366..d129dd45 100644 --- a/src/display/components/Bar.js +++ b/src/display/components/Bar.js @@ -1,14 +1,12 @@ import { NineSliceSprite, Texture } from 'pixi.js'; import { barSchema } from '../data-schema/component-schema'; -import { - AnimationSizeable, - Animationable, - Base, - Placementable, - Showable, - Sourceable, - Tintable, -} from '../mixins'; +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 = { diff --git a/src/display/components/Icon.js b/src/display/components/Icon.js index c05eb03f..5cec7d7f 100644 --- a/src/display/components/Icon.js +++ b/src/display/components/Icon.js @@ -1,13 +1,11 @@ import { Sprite, Texture } from 'pixi.js'; import { iconSchema } from '../data-schema/component-schema'; -import { - Base, - ComponentSizeable, - Placementable, - Showable, - Sourceable, - Tintable, -} from '../mixins'; +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 = { diff --git a/src/display/components/Text.js b/src/display/components/Text.js index 6692811d..3d559eb2 100644 --- a/src/display/components/Text.js +++ b/src/display/components/Text.js @@ -1,12 +1,10 @@ import { BitmapText } from 'pixi.js'; import { textSchema } from '../data-schema/component-schema'; -import { - Base, - Placementable, - Showable, - Textable, - Textstyleable, -} from '../mixins'; +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 = { diff --git a/src/display/elements/Element.js b/src/display/elements/Element.js index df248f31..b027f4df 100644 --- a/src/display/elements/Element.js +++ b/src/display/elements/Element.js @@ -1,5 +1,6 @@ import { Container } from 'pixi.js'; -import { Base, Showable } from '../mixins'; +import { Base } from '../mixins/Base'; +import { Showable } from '../mixins/Showable'; import { mixins } from '../mixins/utils'; const ComposedElement = mixins(Container, Base, Showable); diff --git a/src/display/elements/Grid.js b/src/display/elements/Grid.js index 34fa0a87..148634e9 100644 --- a/src/display/elements/Grid.js +++ b/src/display/elements/Grid.js @@ -1,5 +1,6 @@ import { gridSchema } from '../data-schema/element-schema'; -import { Cellsable, Itemable } from '../mixins'; +import { Cellsable } from '../mixins/Cellsable'; +import { Itemable } from '../mixins/Itemable'; import { mixins } from '../mixins/utils'; import Element from './Element'; diff --git a/src/display/elements/Group.js b/src/display/elements/Group.js index ff891137..f22d1d87 100644 --- a/src/display/elements/Group.js +++ b/src/display/elements/Group.js @@ -1,5 +1,5 @@ import { groupSchema } from '../data-schema/element-schema'; -import { Childrenable } from '../mixins'; +import { Childrenable } from '../mixins/Childrenable'; import { mixins } from '../mixins/utils'; import Element from './Element'; diff --git a/src/display/elements/Item.js b/src/display/elements/Item.js index da47e170..ecc9f587 100644 --- a/src/display/elements/Item.js +++ b/src/display/elements/Item.js @@ -1,5 +1,6 @@ import { itemSchema } from '../data-schema/element-schema'; -import { Componentsable, ItemSizeable } from '../mixins'; +import { Componentsable } from '../mixins/Componentsable'; +import { ItemSizeable } from '../mixins/Itemsizeable'; import { mixins } from '../mixins/utils'; import Element from './Element'; diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index 989bfe5a..4e2c9823 100644 --- a/src/display/elements/Relations.js +++ b/src/display/elements/Relations.js @@ -1,7 +1,8 @@ import { Graphics } from 'pixi.js'; import { selector } from '../../utils/selector/selector'; import { relationsSchema } from '../data-schema/element-schema'; -import { Linksable, Relationstyleable } from '../mixins'; +import { Relationstyleable } from '../mixins/Relationstyleable'; +import { Linksable } from '../mixins/linksable'; import { calcOrientedBounds, mixins } from '../mixins/utils'; import RenderElement from './RenderElement'; diff --git a/src/display/elements/RenderElement.js b/src/display/elements/RenderElement.js index 12f2d615..486e0de2 100644 --- a/src/display/elements/RenderElement.js +++ b/src/display/elements/RenderElement.js @@ -1,8 +1,9 @@ import { RenderContainer } from 'pixi.js'; import { Base } from '../mixins/Base'; import { Showable } from '../mixins/Showable'; +import { mixins } from '../mixins/utils'; -const ComposedRenderElement = Showable(Base(RenderContainer)); +const ComposedRenderElement = mixins(RenderContainer, Base, Showable); export default class RenderElement extends ComposedRenderElement { constructor(options) { diff --git a/src/display/mixins/index.js b/src/display/mixins/index.js deleted file mode 100644 index eb27ac86..00000000 --- a/src/display/mixins/index.js +++ /dev/null @@ -1,18 +0,0 @@ -export { TextStyle } from '../data-schema/primitive-schema'; -export { Animationable } from './Animationable'; -export { AnimationSizeable } from './Animationsizeable'; -export { Base } from './Base'; -export { Cellsable } from './Cellsable'; -export { Childrenable } from './Childrenable'; -export { Componentsable } from './Componentsable'; -export { ComponentSizeable } from './Componentsizeable'; -export { Itemable } from './Itemable'; -export { ItemSizeable } from './Itemsizeable'; -export { Linksable } from './linksable'; -export { Placementable } from './Placementable'; -export { Relationstyleable } from './Relationstyleable'; -export { Showable } from './Showable'; -export { Sourceable } from './Sourceable'; -export { Textable } from './Textable'; -export { Textstyleable } from './Textstyleable'; -export { Tintable } from './Tintable'; From 06e1e01e67fd6c7755c9345aaf7cb91a423de430 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 7 Jul 2025 12:42:44 +0900 Subject: [PATCH 40/66] fix itemsizeable --- src/display/mixins/Itemsizeable.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/display/mixins/Itemsizeable.js b/src/display/mixins/Itemsizeable.js index b2482a8b..d71e7b75 100644 --- a/src/display/mixins/Itemsizeable.js +++ b/src/display/mixins/Itemsizeable.js @@ -6,11 +6,7 @@ export const ItemSizeable = (superClass) => { const MixedClass = class extends superClass { _applyItemSize() { for (const child of this.children) { - if ('size' in child.props) { - child.update({ size: child.props.size }, { overwrite: true }); - } else if ('text' in child.props) { - child.update({ text: child.props.text }, { overwrite: true }); - } + child.update(child.props, { overwrite: true }); } } }; From c9d32136ee33acfab02ab265cdfba5d390d967a0 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 7 Jul 2025 14:12:06 +0900 Subject: [PATCH 41/66] add update overwrite --- src/display/elements/Grid.js | 4 ++-- src/display/elements/Group.js | 4 ++-- src/display/elements/Item.js | 4 ++-- src/display/elements/Relations.js | 7 ++----- src/display/mixins/Base.js | 16 ++++++++-------- src/display/mixins/Itemsizeable.js | 2 +- src/display/mixins/Relationstyleable.js | 1 + src/display/mixins/linksable.js | 1 + src/display/update.js | 5 +++-- 9 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/display/elements/Grid.js b/src/display/elements/Grid.js index 148634e9..4394a030 100644 --- a/src/display/elements/Grid.js +++ b/src/display/elements/Grid.js @@ -11,7 +11,7 @@ export class Grid extends ComposedGrid { super({ type: 'grid', context }); } - update(changes) { - super.update(changes, gridSchema); + update(changes, options) { + super.update(changes, gridSchema, options); } } diff --git a/src/display/elements/Group.js b/src/display/elements/Group.js index f22d1d87..411ff0aa 100644 --- a/src/display/elements/Group.js +++ b/src/display/elements/Group.js @@ -10,7 +10,7 @@ export class Group extends ComposedGroup { super({ type: 'group', context, isRenderGroup: true }); } - update(changes) { - super.update(changes, groupSchema); + update(changes, options) { + super.update(changes, groupSchema, options); } } diff --git a/src/display/elements/Item.js b/src/display/elements/Item.js index ecc9f587..b3153cf3 100644 --- a/src/display/elements/Item.js +++ b/src/display/elements/Item.js @@ -11,7 +11,7 @@ export class Item extends ComposedItem { super({ type: 'item', context }); } - update(changes) { - super.update(changes, itemSchema); + update(changes, options) { + super.update(changes, itemSchema, options); } } diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index 4e2c9823..a09314dc 100644 --- a/src/display/elements/Relations.js +++ b/src/display/elements/Relations.js @@ -17,11 +17,8 @@ export class Relations extends ComposedRelations { this._renderDirty = true; } - update(changes) { - if (changes.links || changes.style) { - this._renderDirty = true; - } - super.update(changes, relationsSchema); + update(changes, options) { + super.update(changes, relationsSchema, options); } initPath() { diff --git a/src/display/mixins/Base.js b/src/display/mixins/Base.js index 1e001cf0..acf02bda 100644 --- a/src/display/mixins/Base.js +++ b/src/display/mixins/Base.js @@ -41,23 +41,23 @@ export const Base = (superClass) => { } update(changes, schema, options = {}) { - const validatedChanges = validate(changes, deepPartial(schema)); - if (isValidationError(validatedChanges)) throw validatedChanges; - const { overwrite = false } = options; + const effectiveChanges = overwrite && !changes ? this.props : changes; + const validatedChanges = validate(effectiveChanges, deepPartial(schema)); + if (isValidationError(validatedChanges)) throw validatedChanges; - const diff = overwrite - ? validatedChanges - : (diffJson(this.props, validatedChanges) ?? {}); - const { id, label, attrs = {}, ...diffChanges } = diff; + const keysToProcess = overwrite + ? Object.keys(validatedChanges) + : Object.keys(diffJson(this.props, validatedChanges) ?? {}); this.props = deepMerge(this.props, validatedChanges); + const { id, label, attrs } = validatedChanges; if (id || label || attrs) { this._applyRaw({ id, label, ...attrs }); } const tasks = new Map(); - for (const key in diffChanges) { + for (const key of keysToProcess) { const handlers = this.constructor._handlerMap.get(key); if (handlers) { handlers.forEach((handler) => { diff --git a/src/display/mixins/Itemsizeable.js b/src/display/mixins/Itemsizeable.js index d71e7b75..ef993bf7 100644 --- a/src/display/mixins/Itemsizeable.js +++ b/src/display/mixins/Itemsizeable.js @@ -6,7 +6,7 @@ export const ItemSizeable = (superClass) => { const MixedClass = class extends superClass { _applyItemSize() { for (const child of this.children) { - child.update(child.props, { overwrite: true }); + child.update(null, { overwrite: true }); } } }; diff --git a/src/display/mixins/Relationstyleable.js b/src/display/mixins/Relationstyleable.js index 90eed71b..fdd84e8d 100644 --- a/src/display/mixins/Relationstyleable.js +++ b/src/display/mixins/Relationstyleable.js @@ -15,6 +15,7 @@ export const Relationstyleable = (superClass) => { style.color = getColor(this.context.theme, style.color); } path.setStrokeStyle({ ...path.strokeStyle, ...style }); + this._renderDirty = true; } }; MixedClass.registerHandler( diff --git a/src/display/mixins/linksable.js b/src/display/mixins/linksable.js index 84f513b3..d898f1d3 100644 --- a/src/display/mixins/linksable.js +++ b/src/display/mixins/linksable.js @@ -8,6 +8,7 @@ export const Linksable = (superClass) => { _applyLinks(relevantChanges) { const { links } = relevantChanges; this.linkedObjects = uniqueLinked(this.context.viewport, links); + this._renderDirty = true; } }; MixedClass.registerHandler( diff --git a/src/display/update.js b/src/display/update.js index a607d6dc..01c13420 100644 --- a/src/display/update.js +++ b/src/display/update.js @@ -7,9 +7,10 @@ import { validate } from '../utils/validator'; const updateSchema = z.object({ path: z.nullable(z.string()).default(null), - changes: z.record(z.unknown()), + changes: z.record(z.unknown()).nullable().default(null), history: z.union([z.boolean(), z.string()]).default(false), relativeTransform: z.boolean().default(false), + overwrite: z.boolean().default(false), }); export const update = (viewport, opts) => { @@ -31,7 +32,7 @@ export const update = (viewport, opts) => { if (relativeTransform && changes.attrs) { changes.attrs = applyRelativeTransform(element, changes.attrs); } - element.update(changes, { historyId }); + element.update(changes, { historyId, overwrite: config.overwrite }); } }; From 8500e2e5957daa2323fa1df5102c0594aff86798 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 7 Jul 2025 14:27:54 +0900 Subject: [PATCH 42/66] fix readme --- README.md | 16 ++++++++++++++-- README_KR.md | 15 +++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 57efba85..7a3be6db 100644 --- a/README.md +++ b/README.md @@ -226,15 +226,21 @@ 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, it applies only what has changed, but the overwrite option can be used to forcibly recalculate and re-render specific or all properties. #### **`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). +- `changes` (optional, object) - New properties to apply (e.g., color, text visibility). If the `overwrite` 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. +- `overwrite`(optional, boolean) - Controls the update behavior. + - `false` (Default): The update logic runs only for properties in `changes` that have actually changed. + - true: + - If `changes` is provided: Forcibly re-runs the update logic for **all properties** passed in `changes`, even if the values are the same as before. + - If `changes` is omitted: Performs a full update based on **all of the object's existing properties**, effectively "refreshing" it. This is useful for updating a child object in response to a parent's state change. + ```js // Apply changes to objects with the label "grid-label-1" patchmap.update({ @@ -263,6 +269,12 @@ patchmap.update({ }, }, }); + +// Force a full property update (refresh) for all objects of type "relations" using overwrite: true +patchmap.update({ + path: `$..children[?(@.type==="relations")]`, + overwrite: true +}); ```
diff --git a/README_KR.md b/README_KR.md index ff3bdd2f..a4613b7b 100644 --- a/README_KR.md +++ b/README_KR.md @@ -225,14 +225,19 @@ draw method가 요구하는 **데이터 구조**입니다.
### `update(options)` -캔버스에 이미 렌더링된 객체의 상태를 업데이트합니다. 색상이나 텍스트 가시성 같은 속성을 변경하는 데 사용하세요. +캔버스에 렌더링된 객체의 속성을 업데이트합니다. 기본적으로 변경된 속성만 반영하지만, `overwrite` 옵션을 통해 특정 또는 전체 속성을 강제로 재계산하고 다시 렌더링할 수 있습니다. #### **`Options`** - `path`(optional, string) - [jsonpath](https://github.com/JSONPath-Plus/JSONPath) 문법에 따른 selector로, 이벤트가 적용될 객체를 선택합니다. - `elements`(optional, object \| array) - 업데이트할 하나 이상의 객체에 대한 직접 참조입니다. 단일 객체 또는 배열을 허용합니다. ([selector](#selectorpath)에서 반환된 객체 등). -- `changes`(required, object) - 적용할 새로운 속성 (예: 색상, 텍스트 가시성). +- `changes`(optional, object) - 적용할 새로운 속성 (예: 색상, 텍스트 가시성). `overwrite` 옵션을 `true`로 설정할 경우 생략할 수 있습니다. - `history`(optional, boolean \| string) - 해당 `update` 메소드에 의한 변경 사항을 `undoRedoManager`에 기록할 것인지 결정합니다. 이전에 저장된 기록의 historyId와 일치하는 문자열이 제공되면, 두 기록이 하나의 실행 취소/재실행 단계로 병합됩니다. - `relativeTransform`(optional, boolean) - `position`, `rotation`, `angle` 값에 대해서 상대값을 이용할 지 결정합니다. 만약, `true` 라면 전달된 값을 객체의 값에 더합니다. +- `overwrite`(optional, boolean) - 업데이트 동작을 제어합니다. + - `false` (기본값): `changes`로 전달된 값 중 실제로 변경된 속성에 대해서만 업데이트됩니다. + - `true`: + - `changes`가 있을 경우: `changes`로 전달된 **모든 속성**에 대해 강제로 업데이트 로직을 다시 실행합니다. (값이 이전과 같더라도 실행됩니다.) + - `changes`가 없을 경우: 객체가 가진 **기존의 모든 속성**을 기반으로 전체 업데이트를 수행하여 객체를 "새로고침"합니다. 부모의 상태 변화에 따라 자식 객체를 업데이트할 때 유용합니다. ```js // label이 "grid-label-1"인 객체들에 대해 변경 사항 적용 @@ -262,6 +267,12 @@ patchmap.update({ }, }, }); + +// type이 "relations"인 모든 객체를 찾아서(overwrite: true로) 강제로 전체 속성 업데이트(새로고침) 수행 +patchmap.update({ + path: `$..children[?(@.type==="relations")]`, + overwrite: true +}); ```
From 11c0b88ae12bb62af39a5dafe82752f3e5001b87 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 7 Jul 2025 15:12:32 +0900 Subject: [PATCH 43/66] fix --- src/display/data-schema/data.d.ts | 7 ++++--- src/display/mixins/Base.js | 12 ++++++++---- src/display/update.js | 6 +++--- src/utils/deepmerge/deepmerge.js | 7 ++++++- src/utils/deepmerge/deepmerge.test.js | 28 +++++++++++++++++++++++++++ 5 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/display/data-schema/data.d.ts b/src/display/data-schema/data.d.ts index e96b7a83..7a2ca728 100644 --- a/src/display/data-schema/data.d.ts +++ b/src/display/data-schema/data.d.ts @@ -137,7 +137,9 @@ export interface Grid { * }, * { * type: 'icon', - * source: 'ok.svg', size: 16, placement: 'right-bottom' + * source: 'ok.svg', + * size: 16, + * placement: 'right-bottom' * } * ], * attrs: { x: 300, y: 150 }, @@ -441,6 +443,7 @@ export interface TextureStyle { /** * 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 @@ -456,8 +459,6 @@ export type RelationsStyle = Record; * Defines the text style for a Text component. * You can pass an object similar to PIXI.TextStyle options. * - * Defaults: `{ fontFamily: 'FiraCode', fontWeight: 400, fill: 'black' }` - * * @see {@link https://pixijs.download/release/docs/text.TextStyleOptions.html} * * @example diff --git a/src/display/mixins/Base.js b/src/display/mixins/Base.js index acf02bda..04fdffa9 100644 --- a/src/display/mixins/Base.js +++ b/src/display/mixins/Base.js @@ -46,10 +46,14 @@ export const Base = (superClass) => { 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: 'overwrite', + }); + const keysToProcess = overwrite - ? Object.keys(validatedChanges) - : Object.keys(diffJson(this.props, validatedChanges) ?? {}); - this.props = deepMerge(this.props, validatedChanges); + ? Object.keys(this.props) + : Object.keys(diffJson(prevProps, this.props) ?? {}); const { id, label, attrs } = validatedChanges; if (id || label || attrs) { @@ -105,7 +109,7 @@ export const Base = (superClass) => { } _updateProperty(key, value) { - deepMerge(this, { [key]: value }); + deepMerge(this, { [key]: value }, { arrayMerge: 'overwrite' }); } }; }; diff --git a/src/display/update.js b/src/display/update.js index 01c13420..e707f966 100644 --- a/src/display/update.js +++ b/src/display/update.js @@ -17,7 +17,8 @@ export const update = (viewport, opts) => { const config = validate(opts, updateSchema.passthrough()); if (isValidationError(config)) throw config; - const historyId = createHistoryId(config.history); + const { history, relativeTransform, overwrite } = config; + const historyId = createHistoryId(history); const elements = 'elements' in config ? convertArray(config.elements) : []; if (viewport && config.path) { elements.push(...selector(viewport, config.path)); @@ -27,12 +28,11 @@ export const update = (viewport, opts) => { if (!element) { continue; } - const { relativeTransform } = config; const changes = JSON.parse(JSON.stringify(config.changes)); if (relativeTransform && changes.attrs) { changes.attrs = applyRelativeTransform(element, changes.attrs); } - element.update(changes, { historyId, overwrite: config.overwrite }); + element.update(changes, { historyId, overwrite }); } }; diff --git a/src/utils/deepmerge/deepmerge.js b/src/utils/deepmerge/deepmerge.js index 5019c40f..b477a059 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 === 'overwrite') { + 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..d3851c79 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 overwrite array when arrayMerge is "overwrite"', + left: { arr: [1, 2, 3] }, + right: { arr: [4, 5] }, + options: { arrayMerge: 'overwrite' }, + 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 "overwrite" at top level', + left: { nested: { arr: ['a', 'b'] } }, + right: { nested: { arr: ['c'] } }, + options: { arrayMerge: 'overwrite' }, + expected: { nested: { arr: ['c'] } }, + }, + ])('$name', ({ left, right, options, expected }) => { + expect(deepMerge(left, right, options)).toEqual(expected); + }); +}); From a5058121f2be51cecb5806dd33d2085cb3db2a3d Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 7 Jul 2025 15:50:30 +0900 Subject: [PATCH 44/66] fix bounds --- src/display/elements/Relations.js | 3 +- src/display/mixins/utils.js | 37 --------------------- src/display/utils.js | 28 ---------------- src/events/focus-fit.js | 25 ++++++--------- src/utils/bounds.js | 53 +++++++++++++++++++++++++++++++ src/utils/canvas.js | 10 ------ 6 files changed, 64 insertions(+), 92 deletions(-) delete mode 100644 src/display/utils.js create mode 100644 src/utils/bounds.js diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index a09314dc..452d9b9f 100644 --- a/src/display/elements/Relations.js +++ b/src/display/elements/Relations.js @@ -1,9 +1,10 @@ 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 { calcOrientedBounds, mixins } from '../mixins/utils'; +import { mixins } from '../mixins/utils'; import RenderElement from './RenderElement'; const ComposedRelations = mixins(RenderElement, Linksable, Relationstyleable); diff --git a/src/display/mixins/utils.js b/src/display/mixins/utils.js index f6b5461e..48c19bb1 100644 --- a/src/display/mixins/utils.js +++ b/src/display/mixins/utils.js @@ -1,12 +1,4 @@ -import { OrientedBounds } from '@pixi-essentials/bounds'; import gsap from 'gsap'; -import { Matrix, Transform } from 'pixi.js'; -import { - decomposeTransform, - getBoundsFromPoints, - getCentroid, - getObjectWorldCorners, -} from '../../utils/transform'; export const tweensOf = (object) => gsap.getTweensOf(object); @@ -45,35 +37,6 @@ export const calcSize = (component, { source, size, margin }) => { }; }; -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 mixins = (baseClass, ...mixins) => { return mixins.reduce((target, mixin) => mixin(target), baseClass); }; diff --git a/src/display/utils.js b/src/display/utils.js deleted file mode 100644 index 0ad99de3..00000000 --- a/src/display/utils.js +++ /dev/null @@ -1,28 +0,0 @@ -import { Container } from 'pixi.js'; - -export const createElement = ({ type, viewport, isRenderGroup = false }) => { - return new Element({ type, viewport, isRenderGroup, eventMode: 'static' }); -}; - -export class Element extends Container { - /** - * The type of the element. This property is read-only. - * @private - * @type {string} - */ - #type; - - constructor(options) { - const { type, ...rest } = options; - super(rest); - this.#type = type; - } - - /** - * Returns the type of the element. - * @returns {string} - */ - get type() { - return this.#type; - } -} 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/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 index bb1a921c..f4fd47db 100644 --- a/src/utils/canvas.js +++ b/src/utils/canvas.js @@ -1,13 +1,3 @@ -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; From bdda73a9ce47e3dcbf1560ccca658f906e290d27 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 7 Jul 2025 16:10:48 +0900 Subject: [PATCH 45/66] chore --- src/assets/utils.js | 14 -------------- src/events/drag-select.js | 3 +-- src/events/single-select.js | 3 +-- src/events/utils.js | 6 ++++++ src/utils/canvas.js | 5 ----- 5 files changed, 8 insertions(+), 23 deletions(-) delete mode 100644 src/assets/utils.js delete mode 100644 src/utils/canvas.js 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/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/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/utils/canvas.js b/src/utils/canvas.js deleted file mode 100644 index f4fd47db..00000000 --- a/src/utils/canvas.js +++ /dev/null @@ -1,5 +0,0 @@ -export const getPointerPosition = (viewport) => { - const renderer = viewport?.app?.renderer; - const global = renderer?.events.pointer.global; - return viewport ? viewport.toWorld(global.x, global.y) : global; -}; From 6b116f3c39cf0a4765aa7455150114c35ce2971e Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 7 Jul 2025 17:36:49 +0900 Subject: [PATCH 46/66] fix get viewport --- src/utils/get.js | 5 ----- src/utils/intersects/intersect-point.js | 5 ++--- src/utils/intersects/intersect.js | 3 +-- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/utils/get.js b/src/utils/get.js index 08907c3a..5850ba61 100644 --- a/src/utils/get.js +++ b/src/utils/get.js @@ -16,8 +16,3 @@ export const getColor = (theme, color) => { const themeColor = getNestedValue(theme, color); return themeColor ?? color; }; - -export const getViewport = (object) => { - if (!object) return null; - return object.viewport ?? getViewport(object.parent); -}; diff --git a/src/utils/intersects/intersect-point.js b/src/utils/intersects/intersect-point.js index 329c7d22..e2e4cc1e 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); From cd3c4bccb455f8ebf436f2695a9c330f7582ab42 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 7 Jul 2025 17:39:27 +0900 Subject: [PATCH 47/66] fix --- src/utils/intersects/intersect-point.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/intersects/intersect-point.js b/src/utils/intersects/intersect-point.js index e2e4cc1e..cc9ecda9 100644 --- a/src/utils/intersects/intersect-point.js +++ b/src/utils/intersects/intersect-point.js @@ -2,7 +2,7 @@ import { Polygon } from 'pixi.js'; import { getPoints } from './get-points'; export const intersectPoint = (obj, point) => { - const viewport = obj.context.viewport; + const viewport = obj?.context?.viewport; if (!viewport) return false; if ('containsPoint' in obj) { From f366fb5bf74537b6d51b4a6a3c1a93bfd5c91ccd Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 7 Jul 2025 18:02:39 +0900 Subject: [PATCH 48/66] fix --- src/display/elements/Relations.js | 3 ++- src/utils/get.js | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index 452d9b9f..089f9b1b 100644 --- a/src/display/elements/Relations.js +++ b/src/display/elements/Relations.js @@ -56,7 +56,8 @@ export class Relations extends ComposedRelations { const targetPoint = [targetBounds.x, targetBounds.y]; if ( !lastPoint || - JSON.stringify(lastPoint) === JSON.stringify(sourcePoint) + lastPoint[0] !== sourcePoint[0] || + lastPoint[1] !== sourcePoint[1] ) { path.moveTo(...sourcePoint); } diff --git a/src/utils/get.js b/src/utils/get.js index 5850ba61..29c403d9 100644 --- a/src/utils/get.js +++ b/src/utils/get.js @@ -3,10 +3,9 @@ export const getNestedValue = (object, path) => { return null; } - const value = path + return path .split('.') .reduce((acc, key) => (acc && acc[key] != null ? acc[key] : null), object); - return typeof value === 'string' ? value : null; }; export const getColor = (theme, color) => { From 7d779e189a0a9d9b78ed6d3b804ee52d66ca5543 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 7 Jul 2025 18:57:16 +0900 Subject: [PATCH 49/66] fix Relations --- src/display/elements/Relations.js | 26 +++++++++++++++++++------- src/display/elements/RenderElement.js | 12 ------------ 2 files changed, 19 insertions(+), 19 deletions(-) delete mode 100644 src/display/elements/RenderElement.js diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index 089f9b1b..71e0709b 100644 --- a/src/display/elements/Relations.js +++ b/src/display/elements/Relations.js @@ -5,17 +5,20 @@ import { relationsSchema } from '../data-schema/element-schema'; import { Relationstyleable } from '../mixins/Relationstyleable'; import { Linksable } from '../mixins/linksable'; import { mixins } from '../mixins/utils'; -import RenderElement from './RenderElement'; +import Element from './Element'; -const ComposedRelations = mixins(RenderElement, Linksable, Relationstyleable); +const ComposedRelations = mixins(Element, Linksable, Relationstyleable); export class Relations extends ComposedRelations { - allowChildren = true; + _renderDirty = true; + _renderOnNextTick = false; constructor(context) { super({ type: 'relations', context }); this.initPath(); - this._renderDirty = true; + + this._updateTransform = this._updateTransform.bind(this); + this.context.viewport.app.ticker.add(this._updateTransform); } update(changes, options) { @@ -29,12 +32,21 @@ export class Relations extends ComposedRelations { this.addChild(path); } - render(renderer) { - if (this._renderDirty) { + _updateTransform() { + if (this._renderOnNextTick) { this.renderLink(); + this._renderOnNextTick = false; + } + + if (this._renderDirty) { + this._renderOnNextTick = true; this._renderDirty = false; } - super.render(renderer); + } + + destroy(options) { + this.context.viewport.app.ticker.remove(this._updateTransform); + super.destroy(options); } renderLink() { diff --git a/src/display/elements/RenderElement.js b/src/display/elements/RenderElement.js deleted file mode 100644 index 486e0de2..00000000 --- a/src/display/elements/RenderElement.js +++ /dev/null @@ -1,12 +0,0 @@ -import { RenderContainer } from 'pixi.js'; -import { Base } from '../mixins/Base'; -import { Showable } from '../mixins/Showable'; -import { mixins } from '../mixins/utils'; - -const ComposedRenderElement = mixins(RenderContainer, Base, Showable); - -export default class RenderElement extends ComposedRenderElement { - constructor(options) { - super(Object.assign(options, { eventMode: 'static' })); - } -} From 52a8cb1f0bab0f18826aca13a607d9b706efe567 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 7 Jul 2025 19:04:46 +0900 Subject: [PATCH 50/66] chore --- src/display/update.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/display/update.js b/src/display/update.js index e707f966..4ab235bc 100644 --- a/src/display/update.js +++ b/src/display/update.js @@ -17,8 +17,7 @@ export const update = (viewport, opts) => { const config = validate(opts, updateSchema.passthrough()); if (isValidationError(config)) throw config; - const { history, relativeTransform, overwrite } = config; - const historyId = createHistoryId(history); + const historyId = createHistoryId(config.history); const elements = 'elements' in config ? convertArray(config.elements) : []; if (viewport && config.path) { elements.push(...selector(viewport, config.path)); @@ -29,10 +28,10 @@ export const update = (viewport, opts) => { continue; } const changes = JSON.parse(JSON.stringify(config.changes)); - if (relativeTransform && changes.attrs) { + if (config.relativeTransform && changes.attrs) { changes.attrs = applyRelativeTransform(element, changes.attrs); } - element.update(changes, { historyId, overwrite }); + element.update(changes, { historyId, overwrite: config.overwrite }); } }; From 64a256f790683d0cb74cd9a580cefa227e494f37 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 7 Jul 2025 19:06:28 +0900 Subject: [PATCH 51/66] fix update --- src/display/update.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/display/update.js b/src/display/update.js index 4ab235bc..72b64be0 100644 --- a/src/display/update.js +++ b/src/display/update.js @@ -36,22 +36,21 @@ export const update = (viewport, opts) => { }; const applyRelativeTransform = (element, changes) => { - const newChanges = JSON.parse(JSON.stringify(changes)); - const { x, y, rotation, angle } = newChanges; + const { x, y, rotation, angle } = changes; if (x) { - newChanges.x = element.x + x; + changes.x = element.x + x; } if (y) { - newChanges.y = element.y + y; + changes.y = element.y + y; } if (rotation) { - newChanges.rotation = element.rotation + rotation; + changes.rotation = element.rotation + rotation; } if (angle) { - newChanges.angle = element.angle + angle; + changes.angle = element.angle + angle; } - return newChanges; + return changes; }; const createHistoryId = (history) => { From e89eb8c63c4325679d9222bfa52445f888de43e9 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 8 Jul 2025 14:34:23 +0900 Subject: [PATCH 52/66] add new element & component --- src/display/components/creator.js | 10 ++++++++++ src/display/draw.js | 11 ++--------- src/display/elements/creator.js | 10 ++++++++++ src/display/mixins/Cellsable.js | 4 ++-- src/display/mixins/Childrenable.js | 4 ++-- src/display/mixins/Componentsable.js | 11 ++--------- 6 files changed, 28 insertions(+), 22 deletions(-) create mode 100644 src/display/components/creator.js create mode 100644 src/display/elements/creator.js diff --git a/src/display/components/creator.js b/src/display/components/creator.js new file mode 100644 index 00000000..268f0d2a --- /dev/null +++ b/src/display/components/creator.js @@ -0,0 +1,10 @@ +import { Background, Bar, Icon, Text } from '.'; + +const creator = { + background: Background, + bar: Bar, + icon: Icon, + text: Text, +}; + +export const newComponent = (type, context) => new creator[type](context); diff --git a/src/display/draw.js b/src/display/draw.js index df26138e..be4bb408 100644 --- a/src/display/draw.js +++ b/src/display/draw.js @@ -1,11 +1,4 @@ -import { Grid, Group, Item, Relations } from './elements'; - -export const elementCreator = { - group: Group, - grid: Grid, - item: Item, - relations: Relations, -}; +import { newElement } from './elements/creator'; export const draw = (context, data) => { const { viewport } = context; @@ -14,7 +7,7 @@ export const draw = (context, data) => { function render(parent, data) { for (const changes of data) { - const element = new elementCreator[changes.type](context); + const element = newElement(changes.type, context); element.update(changes); parent.addChild(element); } diff --git a/src/display/elements/creator.js b/src/display/elements/creator.js new file mode 100644 index 00000000..00f30fd7 --- /dev/null +++ b/src/display/elements/creator.js @@ -0,0 +1,10 @@ +import { Grid, Group, Item, Relations } from '.'; + +const creator = { + group: Group, + grid: Grid, + item: Item, + relations: Relations, +}; + +export const newElement = (type, context) => new creator[type](context); diff --git a/src/display/mixins/Cellsable.js b/src/display/mixins/Cellsable.js index 32ce7c43..d25f0ae5 100644 --- a/src/display/mixins/Cellsable.js +++ b/src/display/mixins/Cellsable.js @@ -1,5 +1,5 @@ import { selector } from '../../utils/selector/selector'; -import { Item } from '../elements'; +import { newElement } from '../elements/creator'; import { UPDATE_STAGES } from './constants'; const KEYS = ['cells']; @@ -21,7 +21,7 @@ export const Cellsable = (superClass) => { if (col === 0 && item) { this.removeChild(item); } else if (col === 1 && !item) { - item = new Item(this.context); + item = newElement('item', this.context); const itemProps = this.props.item; item.update({ id: `${this.id}.${rowIndex}.${colIndex}`, diff --git a/src/display/mixins/Childrenable.js b/src/display/mixins/Childrenable.js index 013a25e0..77480157 100644 --- a/src/display/mixins/Childrenable.js +++ b/src/display/mixins/Childrenable.js @@ -2,7 +2,7 @@ import { isValidationError } from 'zod-validation-error'; import { findIndexByPriority } from '../../utils/findIndexByPriority'; import { validate } from '../../utils/validator'; import { elementTypes } from '../data-schema/element-schema'; -import { elementCreator } from '../draw'; +import { newElement } from '../elements/creator'; import { UPDATE_STAGES } from './constants'; const KEYS = ['children']; @@ -24,7 +24,7 @@ export const Childrenable = (superClass) => { childChange = validate(childChange, elementTypes); if (isValidationError(childChange)) throw childChange; - element = new elementCreator[childChange.type](this.context); + element = newElement(childChange.type, this.context); this.addChild(element); } element.update(childChange); diff --git a/src/display/mixins/Componentsable.js b/src/display/mixins/Componentsable.js index 2aa6fe45..b6eacbba 100644 --- a/src/display/mixins/Componentsable.js +++ b/src/display/mixins/Componentsable.js @@ -1,17 +1,10 @@ import { isValidationError } from 'zod-validation-error'; import { findIndexByPriority } from '../../utils/findIndexByPriority'; import { validate } from '../../utils/validator'; -import { Background, Bar, Icon, Text } from '../components'; +import { newComponent } from '../components/creator'; import { componentSchema } from '../data-schema/component-schema'; import { UPDATE_STAGES } from './constants'; -const ComponentCreator = { - background: Background, - bar: Bar, - icon: Icon, - text: Text, -}; - const KEYS = ['components']; export const Componentsable = (superClass) => { @@ -31,7 +24,7 @@ export const Componentsable = (superClass) => { componentChange = validate(componentChange, componentSchema); if (isValidationError(componentChange)) throw componentChange; - component = new ComponentCreator[componentChange.type](this.context); + component = newComponent(componentChange.type, this.context); this.addChild(component); } component.update(componentChange); From 5beec5a93e8e97658acf3ac2f9ce6b272e706dff Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 8 Jul 2025 14:53:47 +0900 Subject: [PATCH 53/66] fix circular dependency --- src/display/components/creator.js | 25 ++++++++++++++++++------- src/display/components/index.js | 4 ---- src/display/components/registry.js | 10 ++++++++++ src/display/elements/creator.js | 25 ++++++++++++++++++------- src/display/elements/index.js | 4 ---- src/display/elements/registry.js | 10 ++++++++++ src/patchmap.js | 2 ++ 7 files changed, 58 insertions(+), 22 deletions(-) delete mode 100644 src/display/components/index.js create mode 100644 src/display/components/registry.js delete mode 100644 src/display/elements/index.js create mode 100644 src/display/elements/registry.js diff --git a/src/display/components/creator.js b/src/display/components/creator.js index 268f0d2a..f0f67b0c 100644 --- a/src/display/components/creator.js +++ b/src/display/components/creator.js @@ -1,10 +1,21 @@ -import { Background, Bar, Icon, Text } from '.'; +/** + * @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 = { - background: Background, - bar: Bar, - icon: Icon, - text: Text, +const creator = {}; + +export const registerComponent = (type, componentClass) => { + creator[type] = componentClass; }; -export const newComponent = (type, context) => new creator[type](context); +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/index.js b/src/display/components/index.js deleted file mode 100644 index 7605cefe..00000000 --- a/src/display/components/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export { Background } from './Background'; -export { Bar } from './Bar'; -export { Icon } from './Icon'; -export { Text } from './Text'; 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/elements/creator.js b/src/display/elements/creator.js index 00f30fd7..54e256df 100644 --- a/src/display/elements/creator.js +++ b/src/display/elements/creator.js @@ -1,10 +1,21 @@ -import { Grid, Group, Item, Relations } from '.'; +/** + * @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 = { - group: Group, - grid: Grid, - item: Item, - relations: Relations, +const creator = {}; + +export const registerElement = (type, elementClass) => { + creator[type] = elementClass; }; -export const newElement = (type, context) => new creator[type](context); +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/index.js b/src/display/elements/index.js deleted file mode 100644 index 929bed53..00000000 --- a/src/display/elements/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export { Grid } from './Grid'; -export { Group } from './Group'; -export { Item } from './Item'; -export { Relations } from './Relations'; 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/patchmap.js b/src/patchmap.js index 4192a559..5ba8ca9a 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -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() { From d2c3ea06e3664d06abb4562cdbcbf759c5364d90 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 8 Jul 2025 15:52:17 +0900 Subject: [PATCH 54/66] fix overwrite --- src/display/mixins/Base.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/display/mixins/Base.js b/src/display/mixins/Base.js index 04fdffa9..5fbc7d53 100644 --- a/src/display/mixins/Base.js +++ b/src/display/mixins/Base.js @@ -48,7 +48,7 @@ export const Base = (superClass) => { const prevProps = JSON.parse(JSON.stringify(this.props)); this.props = deepMerge(prevProps, validatedChanges, { - arrayMerge: 'overwrite', + arrayMerge: overwrite ? 'overwrite' : null, }); const keysToProcess = overwrite @@ -57,7 +57,7 @@ export const Base = (superClass) => { const { id, label, attrs } = validatedChanges; if (id || label || attrs) { - this._applyRaw({ id, label, ...attrs }); + this._applyRaw({ id, label, ...attrs }, overwrite); } const tasks = new Map(); @@ -89,7 +89,7 @@ export const Base = (superClass) => { }); } - _applyRaw(attrs) { + _applyRaw(attrs, overwrite) { for (const [key, value] of Object.entries(attrs)) { if (value === undefined) continue; @@ -103,13 +103,17 @@ export const Base = (superClass) => { key === 'height' ? value : (attrs?.height ?? this.height); this.setSize(width, height); } else { - this._updateProperty(key, value); + this._updateProperty(key, value, overwrite); } } } - _updateProperty(key, value) { - deepMerge(this, { [key]: value }, { arrayMerge: 'overwrite' }); + _updateProperty(key, value, overwrite = false) { + deepMerge( + this, + { [key]: value }, + { arrayMerge: overwrite ? 'overwrite' : null }, + ); } }; }; From 0443c1f64f89cac3dd1e040a4bbe6b1c1393e9f7 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 8 Jul 2025 16:03:42 +0900 Subject: [PATCH 55/66] fix grid mixins --- src/display/mixins/Cellsable.js | 54 ++++++++++++++++++--------------- src/display/mixins/Itemable.js | 31 ++++++++++--------- 2 files changed, 45 insertions(+), 40 deletions(-) diff --git a/src/display/mixins/Cellsable.js b/src/display/mixins/Cellsable.js index d25f0ae5..6a7f98e6 100644 --- a/src/display/mixins/Cellsable.js +++ b/src/display/mixins/Cellsable.js @@ -1,4 +1,3 @@ -import { selector } from '../../utils/selector/selector'; import { newElement } from '../elements/creator'; import { UPDATE_STAGES } from './constants'; @@ -9,33 +8,38 @@ export const Cellsable = (superClass) => { _applyCells(relevantChanges) { const { cells } = relevantChanges; - for (let rowIndex = 0; rowIndex < cells.length; rowIndex++) { - const row = cells[rowIndex]; - for (let colIndex = 0; colIndex < row.length; colIndex++) { - const col = row[colIndex]; + const { gap, item: itemProps } = this.props; + const currentItemIds = new Set(this.children.map((child) => child.id)); + const requiredItemIds = new Set(); - let item = selector( - this.context.viewport, - '$.children[?(@.id==="${this.id}.${rowIndex}.${colIndex}")]', - )[0]; - if (col === 0 && item) { - this.removeChild(item); - } else if (col === 1 && !item) { - item = newElement('item', this.context); - const itemProps = this.props.item; - item.update({ - id: `${this.id}.${rowIndex}.${colIndex}`, - components: itemProps.components, - size: itemProps.size, - attrs: { - x: colIndex * (itemProps.size.width + this.props.gap.x), - y: rowIndex * (itemProps.size.height + this.props.gap.y), - }, - }); - this.addChild(item); + 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( diff --git a/src/display/mixins/Itemable.js b/src/display/mixins/Itemable.js index 1a400a95..a2b0dc15 100644 --- a/src/display/mixins/Itemable.js +++ b/src/display/mixins/Itemable.js @@ -1,26 +1,27 @@ import { UPDATE_STAGES } from './constants'; -const KEYS = ['cells', 'item', 'gap']; +const KEYS = ['item', 'gap']; export const Itemable = (superClass) => { const MixedClass = class extends superClass { _applyItem(relevantChanges) { - const { cells, item, gap } = relevantChanges; + const { item: itemProps, gap } = relevantChanges; - const childrenLength = this.children.length; - const colSize = cells[0].length; - for (let index = 0; index < childrenLength; index++) { - const rowIndex = Math.floor(index / colSize); - const colIndex = index % colSize; + 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); - const child = this.children[index]; - child.update({ - ...item, - attrs: { - x: colIndex * (item.size.width + gap.x), - y: rowIndex * (item.size.height + gap.y), - }, - }); + 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), + }, + }); + } } } }; From ca36a328534b0819397d53c37123fcb320272ecf Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 8 Jul 2025 17:52:38 +0900 Subject: [PATCH 56/66] replace array merge --- README.md | 27 +++++++++++++-------------- README_KR.md | 25 ++++++++++++------------- src/display/elements/Relations.js | 16 ++++++++++++++-- src/display/mixins/Base.js | 26 ++++++++++---------------- src/display/mixins/Childrenable.js | 14 +++++++++++--- src/display/mixins/Componentsable.js | 14 +++++++++++--- src/display/mixins/Itemable.js | 17 ++++++++++------- src/display/mixins/Itemsizeable.js | 2 +- src/display/update.js | 9 +++++++-- src/utils/deepmerge/deepmerge.js | 2 +- src/utils/deepmerge/deepmerge.test.js | 8 ++++---- 11 files changed, 94 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 7a3be6db..4c249582 100644 --- a/README.md +++ b/README.md @@ -226,20 +226,19 @@ For **detailed type definitions**, refer to the [data.d.ts](src/display/data-sch
### `update(options)` -Updates the properties of objects rendered on the canvas. By default, it applies only what has changed, but the overwrite option can be used to forcibly recalculate and re-render specific or all properties. +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` (optional, object) - New properties to apply (e.g., color, text visibility). If the `overwrite` 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. - -- `overwrite`(optional, boolean) - Controls the update behavior. - - `false` (Default): The update logic runs only for properties in `changes` that have actually changed. - - true: - - If `changes` is provided: Forcibly re-runs the update logic for **all properties** passed in `changes`, even if the values are the same as before. - - If `changes` is omitted: Performs a full update based on **all of the object's existing properties**, effectively "refreshing" it. This is useful for updating a child object in response to a parent's state change. +- `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" @@ -270,10 +269,10 @@ patchmap.update({ }, }); -// Force a full property update (refresh) for all objects of type "relations" using overwrite: true +// Force a full property update (refresh) for all objects of type "relations" using refresh: true patchmap.update({ path: `$..children[?(@.type==="relations")]`, - overwrite: true + refresh: true }); ``` diff --git a/README_KR.md b/README_KR.md index a4613b7b..d91ffbd8 100644 --- a/README_KR.md +++ b/README_KR.md @@ -225,19 +225,18 @@ draw method가 요구하는 **데이터 구조**입니다.
### `update(options)` -캔버스에 렌더링된 객체의 속성을 업데이트합니다. 기본적으로 변경된 속성만 반영하지만, `overwrite` 옵션을 통해 특정 또는 전체 속성을 강제로 재계산하고 다시 렌더링할 수 있습니다. +캔버스에 렌더링된 객체의 속성을 업데이트합니다. 기본적으로 변경된 속성만 반영하지만, refresh 또는 arrayMerge 옵션을 통해 업데이트 동작을 정밀하게 제어할 수 있습니다. #### **`Options`** -- `path`(optional, string) - [jsonpath](https://github.com/JSONPath-Plus/JSONPath) 문법에 따른 selector로, 이벤트가 적용될 객체를 선택합니다. -- `elements`(optional, object \| array) - 업데이트할 하나 이상의 객체에 대한 직접 참조입니다. 단일 객체 또는 배열을 허용합니다. ([selector](#selectorpath)에서 반환된 객체 등). -- `changes`(optional, object) - 적용할 새로운 속성 (예: 색상, 텍스트 가시성). `overwrite` 옵션을 `true`로 설정할 경우 생략할 수 있습니다. -- `history`(optional, boolean \| string) - 해당 `update` 메소드에 의한 변경 사항을 `undoRedoManager`에 기록할 것인지 결정합니다. 이전에 저장된 기록의 historyId와 일치하는 문자열이 제공되면, 두 기록이 하나의 실행 취소/재실행 단계로 병합됩니다. -- `relativeTransform`(optional, boolean) - `position`, `rotation`, `angle` 값에 대해서 상대값을 이용할 지 결정합니다. 만약, `true` 라면 전달된 값을 객체의 값에 더합니다. -- `overwrite`(optional, boolean) - 업데이트 동작을 제어합니다. - - `false` (기본값): `changes`로 전달된 값 중 실제로 변경된 속성에 대해서만 업데이트됩니다. - - `true`: - - `changes`가 있을 경우: `changes`로 전달된 **모든 속성**에 대해 강제로 업데이트 로직을 다시 실행합니다. (값이 이전과 같더라도 실행됩니다.) - - `changes`가 없을 경우: 객체가 가진 **기존의 모든 속성**을 기반으로 전체 업데이트를 수행하여 객체를 "새로고침"합니다. 부모의 상태 변화에 따라 자식 객체를 업데이트할 때 유용합니다. +- `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"인 객체들에 대해 변경 사항 적용 @@ -268,10 +267,10 @@ patchmap.update({ }, }); -// type이 "relations"인 모든 객체를 찾아서(overwrite: true로) 강제로 전체 속성 업데이트(새로고침) 수행 +// type이 "relations"인 모든 객체를 찾아서(refresh: true로) 강제로 전체 속성 업데이트(새로고침) 수행 patchmap.update({ path: `$..children[?(@.type==="relations")]`, - overwrite: true + refresh: true }); ``` diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index 71e0709b..abd2c601 100644 --- a/src/display/elements/Relations.js +++ b/src/display/elements/Relations.js @@ -57,11 +57,23 @@ export class Relations extends ComposedRelations { 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(this.linkedObjects[link.source]).center, + calcOrientedBounds(sourceObject).center, ); const targetBounds = this.toLocal( - calcOrientedBounds(this.linkedObjects[link.target]).center, + calcOrientedBounds(targetObject).center, ); const sourcePoint = [sourceBounds.x, sourceBounds.y]; diff --git a/src/display/mixins/Base.js b/src/display/mixins/Base.js index 5fbc7d53..d16e19da 100644 --- a/src/display/mixins/Base.js +++ b/src/display/mixins/Base.js @@ -41,23 +41,21 @@ export const Base = (superClass) => { } update(changes, schema, options = {}) { - const { overwrite = false } = options; - const effectiveChanges = overwrite && !changes ? this.props : changes; + const { arrayMerge = 'merge', refresh = false } = options; + const effectiveChanges = refresh && !changes ? this.props : 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: overwrite ? 'overwrite' : null, - }); + this.props = deepMerge(prevProps, validatedChanges, { arrayMerge }); - const keysToProcess = overwrite + 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 }, overwrite); + this._applyRaw({ id, label, ...attrs }, arrayMerge); } const tasks = new Map(); @@ -85,11 +83,11 @@ export const Base = (superClass) => { fullPayload[key] = this.props[key]; } }); - handler.call(this, fullPayload); + handler.call(this, fullPayload, { arrayMerge, refresh }); }); } - _applyRaw(attrs, overwrite) { + _applyRaw(attrs, arrayMerge) { for (const [key, value] of Object.entries(attrs)) { if (value === undefined) continue; @@ -103,17 +101,13 @@ export const Base = (superClass) => { key === 'height' ? value : (attrs?.height ?? this.height); this.setSize(width, height); } else { - this._updateProperty(key, value, overwrite); + this._updateProperty(key, value, arrayMerge); } } } - _updateProperty(key, value, overwrite = false) { - deepMerge( - this, - { [key]: value }, - { arrayMerge: overwrite ? 'overwrite' : null }, - ); + _updateProperty(key, value, arrayMerge) { + deepMerge(this, { [key]: value }, { arrayMerge }); } }; }; diff --git a/src/display/mixins/Childrenable.js b/src/display/mixins/Childrenable.js index 77480157..60a3e8d0 100644 --- a/src/display/mixins/Childrenable.js +++ b/src/display/mixins/Childrenable.js @@ -9,10 +9,18 @@ const KEYS = ['children']; export const Childrenable = (superClass) => { const MixedClass = class extends superClass { - _applyChildren(relevantChanges) { + _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 = []; + } - const elements = [...this.children]; for (let childChange of children) { const idx = findIndexByPriority(elements, childChange); let element = null; @@ -27,7 +35,7 @@ export const Childrenable = (superClass) => { element = newElement(childChange.type, this.context); this.addChild(element); } - element.update(childChange); + element.update(childChange, options); } } }; diff --git a/src/display/mixins/Componentsable.js b/src/display/mixins/Componentsable.js index b6eacbba..5cb31b12 100644 --- a/src/display/mixins/Componentsable.js +++ b/src/display/mixins/Componentsable.js @@ -9,10 +9,18 @@ const KEYS = ['components']; export const Componentsable = (superClass) => { const MixedClass = class extends superClass { - _applyComponents(relevantChanges) { + _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 = []; + } - const components = [...this.children]; for (let componentChange of componentsChanges) { const idx = findIndexByPriority(components, componentChange); let component = null; @@ -27,7 +35,7 @@ export const Componentsable = (superClass) => { component = newComponent(componentChange.type, this.context); this.addChild(component); } - component.update(componentChange); + component.update(componentChange, options); } } }; diff --git a/src/display/mixins/Itemable.js b/src/display/mixins/Itemable.js index a2b0dc15..448b928f 100644 --- a/src/display/mixins/Itemable.js +++ b/src/display/mixins/Itemable.js @@ -4,7 +4,7 @@ const KEYS = ['item', 'gap']; export const Itemable = (superClass) => { const MixedClass = class extends superClass { - _applyItem(relevantChanges) { + _applyItem(relevantChanges, options) { const { item: itemProps, gap } = relevantChanges; const gridIdPrefix = `${this.id}.`; @@ -14,13 +14,16 @@ export const Itemable = (superClass) => { 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), + child.update( + { + ...itemProps, + attrs: { + x: colIndex * (itemProps.size.width + gap.x), + y: rowIndex * (itemProps.size.height + gap.y), + }, }, - }); + options, + ); } } } diff --git a/src/display/mixins/Itemsizeable.js b/src/display/mixins/Itemsizeable.js index ef993bf7..2c3e369b 100644 --- a/src/display/mixins/Itemsizeable.js +++ b/src/display/mixins/Itemsizeable.js @@ -6,7 +6,7 @@ export const ItemSizeable = (superClass) => { const MixedClass = class extends superClass { _applyItemSize() { for (const child of this.children) { - child.update(null, { overwrite: true }); + child.update(null, { refresh: true }); } } }; diff --git a/src/display/update.js b/src/display/update.js index 72b64be0..fec59f9c 100644 --- a/src/display/update.js +++ b/src/display/update.js @@ -10,7 +10,8 @@ const updateSchema = z.object({ changes: z.record(z.unknown()).nullable().default(null), history: z.union([z.boolean(), z.string()]).default(false), relativeTransform: z.boolean().default(false), - overwrite: z.boolean().default(false), + arrayMerge: z.enum(['merge', 'replace']).default('merge'), + refresh: z.boolean().default(false), }); export const update = (viewport, opts) => { @@ -31,7 +32,11 @@ export const update = (viewport, opts) => { if (config.relativeTransform && changes.attrs) { changes.attrs = applyRelativeTransform(element, changes.attrs); } - element.update(changes, { historyId, overwrite: config.overwrite }); + element.update(changes, { + historyId, + arrayMerge: config.arrayMerge, + refresh: config.refresh, + }); } }; diff --git a/src/utils/deepmerge/deepmerge.js b/src/utils/deepmerge/deepmerge.js index b477a059..908304b6 100644 --- a/src/utils/deepmerge/deepmerge.js +++ b/src/utils/deepmerge/deepmerge.js @@ -58,7 +58,7 @@ const _deepMerge = (target, source, options, visited) => { const mergeArray = (target, source, options, visited) => { const { mergeBy, arrayMerge = null } = options; - if (arrayMerge === 'overwrite') { + if (arrayMerge === 'replace') { return source; } diff --git a/src/utils/deepmerge/deepmerge.test.js b/src/utils/deepmerge/deepmerge.test.js index d3851c79..0ce099ba 100644 --- a/src/utils/deepmerge/deepmerge.test.js +++ b/src/utils/deepmerge/deepmerge.test.js @@ -345,10 +345,10 @@ describe('deepMerge – additional edge‑case coverage', () => { describe('deepMerge – arrayMerge option', () => { test.each([ { - name: 'should overwrite array when arrayMerge is "overwrite"', + name: 'should replace array when arrayMerge is "replace"', left: { arr: [1, 2, 3] }, right: { arr: [4, 5] }, - options: { arrayMerge: 'overwrite' }, + options: { arrayMerge: 'replace' }, expected: { arr: [4, 5] }, }, { @@ -359,10 +359,10 @@ describe('deepMerge – arrayMerge option', () => { expected: { arr: [4, 5, 3] }, }, { - name: 'should merge nested arrays when arrayMerge is "overwrite" at top level', + name: 'should merge nested arrays when arrayMerge is "replace" at top level', left: { nested: { arr: ['a', 'b'] } }, right: { nested: { arr: ['c'] } }, - options: { arrayMerge: 'overwrite' }, + options: { arrayMerge: 'replace' }, expected: { nested: { arr: ['c'] } }, }, ])('$name', ({ left, right, options, expected }) => { From dfb8c671b29e6995364227c1c9f65765e267d5ab Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 9 Jul 2025 11:52:37 +0900 Subject: [PATCH 57/66] add bottom-up --- src/display/mixins/Base.js | 8 ++++++++ src/display/mixins/Childrenable.js | 15 +++++++++++++++ src/display/mixins/Componentsable.js | 17 +++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/src/display/mixins/Base.js b/src/display/mixins/Base.js index d16e19da..fe7f5eaa 100644 --- a/src/display/mixins/Base.js +++ b/src/display/mixins/Base.js @@ -85,6 +85,14 @@ export const Base = (superClass) => { }); handler.call(this, fullPayload, { arrayMerge, refresh }); }); + + if (this.parent?._onChildUpdate) { + this.parent._onChildUpdate( + this.id, + diffJson(prevProps, this.props), + arrayMerge, + ); + } } _applyRaw(attrs, arrayMerge) { diff --git a/src/display/mixins/Childrenable.js b/src/display/mixins/Childrenable.js index 60a3e8d0..adaa5242 100644 --- a/src/display/mixins/Childrenable.js +++ b/src/display/mixins/Childrenable.js @@ -1,4 +1,5 @@ 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'; @@ -38,6 +39,20 @@ export const Childrenable = (superClass) => { 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, diff --git a/src/display/mixins/Componentsable.js b/src/display/mixins/Componentsable.js index 5cb31b12..8e4c5042 100644 --- a/src/display/mixins/Componentsable.js +++ b/src/display/mixins/Componentsable.js @@ -1,4 +1,5 @@ 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'; @@ -38,6 +39,22 @@ export const Componentsable = (superClass) => { 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, From f3e7f02ae6e33997e1b210378b96fde173c54ff5 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 9 Jul 2025 11:53:10 +0900 Subject: [PATCH 58/66] fix source key --- src/display/components/Bar.js | 2 +- src/display/components/Icon.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/display/components/Bar.js b/src/display/components/Bar.js index d129dd45..c2c05450 100644 --- a/src/display/components/Bar.js +++ b/src/display/components/Bar.js @@ -10,7 +10,7 @@ import { Tintable } from '../mixins/Tintable'; import { mixins } from '../mixins/utils'; const EXTRA_KEYS = { - PLACEMENT: ['size'], + PLACEMENT: ['source', 'size'], }; const ComposedBar = mixins( diff --git a/src/display/components/Icon.js b/src/display/components/Icon.js index 5cec7d7f..23abdc02 100644 --- a/src/display/components/Icon.js +++ b/src/display/components/Icon.js @@ -9,7 +9,7 @@ import { Tintable } from '../mixins/Tintable'; import { mixins } from '../mixins/utils'; const EXTRA_KEYS = { - PLACEMENT: ['size'], + PLACEMENT: ['source', 'size'], }; const ComposedIcon = mixins( From 7921a587e0589d17550781e2cbc45d885576af43 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 10 Jul 2025 12:56:14 +0900 Subject: [PATCH 59/66] init browser test --- package-lock.json | 2034 ++++++++++++++++++++++++++++++++++++++++++--- package.json | 6 +- vitest.config.js | 27 + 3 files changed, 1933 insertions(+), 134 deletions(-) create mode 100644 vitest.config.js diff --git a/package-lock.json b/package-lock.json index ad97327e..ba08948e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,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" @@ -39,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", @@ -64,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", @@ -366,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" ], @@ -383,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", @@ -485,6 +1072,13 @@ "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", @@ -567,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", @@ -614,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", @@ -687,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" }, @@ -704,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" }, @@ -719,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": { @@ -741,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": { @@ -754,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" }, @@ -783,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": { @@ -834,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", @@ -884,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", @@ -928,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", @@ -952,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", @@ -1007,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": { @@ -1021,7 +1854,7 @@ "pathval": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/chalk": { @@ -1047,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", @@ -2211,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", @@ -2224,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", @@ -2235,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": { @@ -2289,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", @@ -2299,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", @@ -2309,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", @@ -2319,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", @@ -2342,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", @@ -2449,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", @@ -2477,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", @@ -2490,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": { @@ -2551,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", @@ -2674,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", @@ -3107,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", @@ -3259,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", @@ -3275,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", @@ -3464,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", @@ -3538,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", @@ -3781,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" }, @@ -3800,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", @@ -3912,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", @@ -3960,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", @@ -3970,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", @@ -3996,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", @@ -4003,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", @@ -4019,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", @@ -4110,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", @@ -4155,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": { @@ -4222,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": [ { @@ -4243,7 +5527,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4251,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", @@ -4258,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", @@ -4301,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", @@ -4614,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", @@ -4659,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", @@ -4679,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", @@ -5069,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", @@ -5095,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", @@ -5140,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": { @@ -5157,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": { @@ -5177,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", @@ -5199,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", @@ -5209,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", @@ -5394,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" @@ -5417,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": { @@ -5458,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": "*" }, @@ -5487,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", @@ -5536,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", diff --git a/package.json b/package.json index 49ad7f6d..3b14f89f 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "lint": "biome check --staged", "lint:fix": "biome check --staged --write", "test:unit": "vitest", + "test:browser": "vitest --browser=chromium", + "test:headless": "vitest --browser.headless", "prepare": "husky", "release": "standard-version" }, @@ -61,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/vitest.config.js b/vitest.config.js new file mode 100644 index 00000000..c6857ebf --- /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: ['./src/patchmap.test.js'], + name: 'unit', + environment: 'node', + }, + }, + { + test: { + include: ['./src/patchmap.test.js'], + name: 'browser', + browser: { + provider: 'playwright', + enabled: true, + instances: [{ browser: 'chromium' }], + }, + }, + }, + ], + }, +}); From 881c219c59da2af61a84bc2f3bd2629b9858dbc1 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 10 Jul 2025 12:56:26 +0900 Subject: [PATCH 60/66] chore --- src/init.js | 2 +- src/patchmap.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/init.js b/src/init.js index 440626ad..c1c9538f 100644 --- a/src/init.js +++ b/src/init.js @@ -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/patchmap.js b/src/patchmap.js index 5ba8ca9a..1e6f6d87 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -103,6 +103,8 @@ class Patchmap { } destroy() { + if (!this.isInit) return; + this.undoRedoManager.destroy(); this.animationContext.revert(); event.removeAllEvent(this.viewport); From 74e858ecbc5d885a060a1200a66a738c587648b1 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 10 Jul 2025 18:20:00 +0900 Subject: [PATCH 61/66] add px or percent size calc --- src/display/data-schema/primitive-schema.js | 75 ++++- .../data-schema/primitive-schema.test.js | 46 +++ src/display/mixins/Placementable.js | 8 +- src/display/mixins/utils.js | 66 ++-- .../render/components/Background.test.js | 162 +++++++++ src/tests/render/components/Icon.test.js | 314 ++++++++++++++++++ src/tests/render/patchmap.setup.js | 30 ++ src/tests/render/patchmap.test.js | 80 +++++ src/utils/convert.js | 3 +- vitest.config.js | 4 +- 10 files changed, 757 insertions(+), 31 deletions(-) create mode 100644 src/tests/render/components/Background.test.js create mode 100644 src/tests/render/components/Icon.test.js create mode 100644 src/tests/render/patchmap.setup.js create mode 100644 src/tests/render/patchmap.test.js diff --git a/src/display/data-schema/primitive-schema.js b/src/display/data-schema/primitive-schema.js index abdca231..81cfb39c 100644 --- a/src/display/data-schema/primitive-schema.js +++ b/src/display/data-schema/primitive-schema.js @@ -34,6 +34,7 @@ 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(), @@ -42,11 +43,81 @@ export const pxOrPercentSchema = z if (typeof val === 'number') { return { value: val, unit: 'px' }; } - if (typeof val === 'string') { + 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 })), diff --git a/src/display/data-schema/primitive-schema.test.js b/src/display/data-schema/primitive-schema.test.js index a1322838..3ec81e25 100644 --- a/src/display/data-schema/primitive-schema.test.js +++ b/src/display/data-schema/primitive-schema.test.js @@ -363,4 +363,50 @@ describe('Primitive Schema Tests', () => { }); }); }); + + 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/mixins/Placementable.js b/src/display/mixins/Placementable.js index 7d52ba8c..d14f4952 100644 --- a/src/display/mixins/Placementable.js +++ b/src/display/mixins/Placementable.js @@ -41,7 +41,9 @@ const getHorizontalPosition = (component, align, margin) => { } else if (align === 'right') { result = parentWidth - component.width - margin.right; } else if (align === 'center') { - result = (parentWidth - component.width) / 2; + const marginWidth = component.width + margin.left + margin.right; + const blockStartPosition = (parentWidth - marginWidth) / 2; + result = blockStartPosition + margin.left; } return result; }; @@ -54,7 +56,9 @@ const getVerticalPosition = (component, align, margin) => { } else if (align === 'bottom') { result = parentHeight - component.height - margin.bottom; } else if (align === 'center') { - result = (parentHeight - component.height) / 2; + 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/utils.js b/src/display/mixins/utils.js index 48c19bb1..733e1597 100644 --- a/src/display/mixins/utils.js +++ b/src/display/mixins/utils.js @@ -4,35 +4,55 @@ export const tweensOf = (object) => gsap.getTweensOf(object); export const killTweensOf = (object) => gsap.killTweensOf(object); -export const getMaxSize = ( - size, - margin = { top: 0, right: 0, bottom: 0, left: 0 }, -) => { - const { top = 0, right = 0, bottom = 0, left = 0 } = margin || {}; - return { - width: size.width - (left + right), - height: size.height - (top + bottom), - }; -}; +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/); -export const calcSize = (component, { source, size, margin }) => { - const { width: maxWidth, height: maxHeight } = getMaxSize( - component.parent.props.size, - margin, - ); + 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: - (size.width.unit === '%' - ? maxWidth * (size.width.value / 100) - : size.width.value) + borderWidth, - height: - (size.height.unit === '%' - ? maxHeight * (size.height.value / 100) - : size.height.value) + borderWidth, + width: finalWidth + borderWidth, + height: finalHeight + borderWidth, borderWidth: borderWidth, }; }; 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..7fe46cd3 --- /dev/null +++ b/src/tests/render/components/Icon.test.js @@ -0,0 +1,314 @@ +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); + }, + ); + }); +}); diff --git a/src/tests/render/patchmap.setup.js b/src/tests/render/patchmap.setup.js new file mode 100644 index 00000000..6102a02d --- /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/convert.js b/src/utils/convert.js index 8e66afd6..641e966f 100644 --- a/src/utils/convert.js +++ b/src/utils/convert.js @@ -48,10 +48,9 @@ export const convertLegacyData = (data) => { { type: 'bar', show: false, - size: '100%', + size: 'calc(100% - 6px)', source: { type: 'rect', radius: 3, fill: 'white' }, tint: 'primary.default', - margin: 3, }, ], }, diff --git a/vitest.config.js b/vitest.config.js index c6857ebf..1c491bc3 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -6,14 +6,14 @@ export default defineConfig({ { test: { include: ['./src/**/*.{test,spec}.js'], - exclude: ['./src/patchmap.test.js'], + exclude: ['**/tests/**'], name: 'unit', environment: 'node', }, }, { test: { - include: ['./src/patchmap.test.js'], + include: ['**/tests/**/*.{test,spec}.js'], name: 'browser', browser: { provider: 'playwright', From fca997c851ed27eb119e769bfdb362809498bbed Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 10 Jul 2025 18:48:49 +0900 Subject: [PATCH 62/66] fix icon test --- src/tests/render/components/Icon.test.js | 64 ++++++++++++++++++++++++ src/tests/render/patchmap.setup.js | 12 ++--- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/tests/render/components/Icon.test.js b/src/tests/render/components/Icon.test.js index 7fe46cd3..2035689e 100644 --- a/src/tests/render/components/Icon.test.js +++ b/src/tests/render/components/Icon.test.js @@ -311,4 +311,68 @@ describe('Icon Component Tests', () => { }, ); }); + + 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 index 6102a02d..78ba475c 100644 --- a/src/tests/render/patchmap.setup.js +++ b/src/tests/render/patchmap.setup.js @@ -16,12 +16,12 @@ export const setupPatchmapTests = () => { }); afterEach(() => { - // if (patchmap) { - // patchmap.destroy(); - // } - // if (element?.parentElement) { - // document.body.removeChild(element); - // } + if (patchmap) { + patchmap.destroy(); + } + if (element?.parentElement) { + document.body.removeChild(element); + } }); return { From 5ea76786277025420da15e98773b078440b80f2c Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 10 Jul 2025 18:53:41 +0900 Subject: [PATCH 63/66] fix --- src/utils/convert.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/convert.js b/src/utils/convert.js index 641e966f..dfad52dd 100644 --- a/src/utils/convert.js +++ b/src/utils/convert.js @@ -51,6 +51,7 @@ export const convertLegacyData = (data) => { size: 'calc(100% - 6px)', source: { type: 'rect', radius: 3, fill: 'white' }, tint: 'primary.default', + margin: { bottom: 3 }, }, ], }, From c778a0984fb4d3cde7d6969c4632fdfce2dc8955 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 14 Jul 2025 15:11:54 +0900 Subject: [PATCH 64/66] fix --- package.json | 2 +- src/display/mixins/Base.js | 2 +- src/utils/deepmerge/deepmerge.js | 2 +- src/utils/findIndexByPriority.js | 8 ++++++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 3b14f89f..fac34c03 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "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", diff --git a/src/display/mixins/Base.js b/src/display/mixins/Base.js index fe7f5eaa..8a53af26 100644 --- a/src/display/mixins/Base.js +++ b/src/display/mixins/Base.js @@ -42,7 +42,7 @@ export const Base = (superClass) => { update(changes, schema, options = {}) { const { arrayMerge = 'merge', refresh = false } = options; - const effectiveChanges = refresh && !changes ? this.props : changes; + const effectiveChanges = refresh && !changes ? {} : changes; const validatedChanges = validate(effectiveChanges, deepPartial(schema)); if (isValidationError(validatedChanges)) throw validatedChanges; diff --git a/src/utils/deepmerge/deepmerge.js b/src/utils/deepmerge/deepmerge.js index 908304b6..7bcf9887 100644 --- a/src/utils/deepmerge/deepmerge.js +++ b/src/utils/deepmerge/deepmerge.js @@ -56,7 +56,7 @@ const _deepMerge = (target, source, options, visited) => { }; const mergeArray = (target, source, options, visited) => { - const { mergeBy, arrayMerge = null } = options; + const { mergeBy = ['id', 'label', 'type'], arrayMerge = null } = options; if (arrayMerge === 'replace') { return source; diff --git a/src/utils/findIndexByPriority.js b/src/utils/findIndexByPriority.js index 02525e86..1fa69e8e 100644 --- a/src/utils/findIndexByPriority.js +++ b/src/utils/findIndexByPriority.js @@ -16,9 +16,13 @@ const schema = z.object({ export const findIndexByPriority = ( arr, criteria, - usedIndexes = new Set(), - priorityKeys = ['id', 'label', 'type'], + usedIndexes, + priorityKeys, ) => { + if (!priorityKeys || priorityKeys.length === 0) { + return -1; + } + const validation = schema.safeParse({ arr, criteria }); if (!validation.success) { throw new TypeError(validation.error.message); From ad6fed416f83011986cad29cbcc640f9384c599d Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 14 Jul 2025 15:35:49 +0900 Subject: [PATCH 65/66] fix findIndexByPriority --- src/utils/deepmerge/deepmerge.js | 2 +- src/utils/findIndexByPriority.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/deepmerge/deepmerge.js b/src/utils/deepmerge/deepmerge.js index 7bcf9887..908304b6 100644 --- a/src/utils/deepmerge/deepmerge.js +++ b/src/utils/deepmerge/deepmerge.js @@ -56,7 +56,7 @@ const _deepMerge = (target, source, options, visited) => { }; const mergeArray = (target, source, options, visited) => { - const { mergeBy = ['id', 'label', 'type'], arrayMerge = null } = options; + const { mergeBy, arrayMerge = null } = options; if (arrayMerge === 'replace') { return source; diff --git a/src/utils/findIndexByPriority.js b/src/utils/findIndexByPriority.js index 1fa69e8e..823d82d8 100644 --- a/src/utils/findIndexByPriority.js +++ b/src/utils/findIndexByPriority.js @@ -16,8 +16,8 @@ const schema = z.object({ export const findIndexByPriority = ( arr, criteria, - usedIndexes, - priorityKeys, + usedIndexes = new Set(), + priorityKeys = ['id', 'label', 'type'], ) => { if (!priorityKeys || priorityKeys.length === 0) { return -1; From 94d5acdb8d1f19333e7aebda0511c37efcd06721 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Mon, 14 Jul 2025 15:37:17 +0900 Subject: [PATCH 66/66] chore --- src/utils/findIndexByPriority.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/utils/findIndexByPriority.js b/src/utils/findIndexByPriority.js index 823d82d8..02525e86 100644 --- a/src/utils/findIndexByPriority.js +++ b/src/utils/findIndexByPriority.js @@ -19,10 +19,6 @@ export const findIndexByPriority = ( usedIndexes = new Set(), priorityKeys = ['id', 'label', 'type'], ) => { - if (!priorityKeys || priorityKeys.length === 0) { - return -1; - } - const validation = schema.safeParse({ arr, criteria }); if (!validation.success) { throw new TypeError(validation.error.message);