From 6b6987e487fbde46d1d60b2e2d4cedd2448f0fea Mon Sep 17 00:00:00 2001 From: perhapsspy Date: Wed, 31 Dec 2025 14:46:51 +0900 Subject: [PATCH 1/8] feat: add world rotation and flip support --- src/display/World.js | 17 ++++++ src/display/components/Icon.js | 6 ++ src/display/components/Text.js | 6 ++ src/display/draw.js | 9 +-- src/display/elements/Relations.js | 4 +- src/display/utils/world-flip.js | 29 ++++++++++ src/events/focus-fit.js | 20 +++++-- src/patchmap.js | 91 +++++++++++++++++++++++++++++++ src/tests/render/patchmap.test.js | 4 +- src/utils/viewport-rotation.js | 36 ++++++++++++ 10 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 src/display/World.js create mode 100644 src/display/utils/world-flip.js create mode 100644 src/utils/viewport-rotation.js diff --git a/src/display/World.js b/src/display/World.js new file mode 100644 index 00000000..8cb7432c --- /dev/null +++ b/src/display/World.js @@ -0,0 +1,17 @@ +import { Container } from 'pixi.js'; +import { canvasSchema } from './data-schema/element-schema'; +import { Base } from './mixins/Base'; +import { Childrenable } from './mixins/Childrenable'; +import { mixins } from './mixins/utils'; + +const ComposedWorld = mixins(Container, Base, Childrenable); + +export default class World extends ComposedWorld { + constructor(options) { + super({ type: 'canvas', ...options }); + } + + apply(changes, options) { + super.apply(changes, canvasSchema, options); + } +} diff --git a/src/display/components/Icon.js b/src/display/components/Icon.js index 692e7420..d4b505a6 100644 --- a/src/display/components/Icon.js +++ b/src/display/components/Icon.js @@ -7,6 +7,7 @@ import { Showable } from '../mixins/Showable'; import { Sourceable } from '../mixins/Sourceable'; import { Tintable } from '../mixins/Tintable'; import { mixins } from '../mixins/utils'; +import { applyWorldFlip } from '../utils/world-flip'; const EXTRA_KEYS = { PLACEMENT: ['source', 'size'], @@ -34,5 +35,10 @@ export class Icon extends ComposedIcon { apply(changes, options) { super.apply(changes, iconSchema, options); + this._applyWorldFlip(); + } + + _applyWorldFlip() { + applyWorldFlip(this, this.context?.view); } } diff --git a/src/display/components/Text.js b/src/display/components/Text.js index 399e81b1..cebd5c3d 100644 --- a/src/display/components/Text.js +++ b/src/display/components/Text.js @@ -7,6 +7,7 @@ import { Textable } from '../mixins/Textable'; import { Textstyleable } from '../mixins/Textstyleable'; import { Tintable } from '../mixins/Tintable'; import { mixins } from '../mixins/utils'; +import { applyWorldFlip } from '../utils/world-flip'; const EXTRA_KEYS = { PLACEMENT: ['text', 'style', 'split'], @@ -34,5 +35,10 @@ export class Text extends ComposedText { apply(changes, options) { super.apply(changes, textSchema, options); + this._applyWorldFlip(); + } + + _applyWorldFlip() { + applyWorldFlip(this, this.context?.view); } } diff --git a/src/display/draw.js b/src/display/draw.js index 4a978f67..d9e953b4 100644 --- a/src/display/draw.js +++ b/src/display/draw.js @@ -1,12 +1,9 @@ import Element from './elements/Element'; export const draw = (context, data) => { - const { viewport } = context; - destroyChildren(viewport); - viewport.apply( - { type: 'canvas', children: data }, - { mergeStrategy: 'replace' }, - ); + const root = context.world ?? context.viewport; + destroyChildren(root); + root.apply({ type: 'canvas', children: data }, { mergeStrategy: 'replace' }); }; const destroyChildren = (parent) => { diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index 89a1495a..9b5f4946 100644 --- a/src/display/elements/Relations.js +++ b/src/display/elements/Relations.js @@ -78,10 +78,10 @@ export class Relations extends ComposedRelations { continue; } - const sourceBounds = this.context.viewport.toLocal( + const sourceBounds = this.toLocal( calcOrientedBounds(sourceObject).center, ); - const targetBounds = this.context.viewport.toLocal( + const targetBounds = this.toLocal( calcOrientedBounds(targetObject).center, ); diff --git a/src/display/utils/world-flip.js b/src/display/utils/world-flip.js new file mode 100644 index 00000000..23145261 --- /dev/null +++ b/src/display/utils/world-flip.js @@ -0,0 +1,29 @@ +export const applyWorldFlip = (displayObject, view) => { + if (!displayObject || !view) return; + + const prevState = displayObject._flipState ?? { x: false, y: false }; + const nextState = { x: !!view.flipX, y: !!view.flipY }; + const width = displayObject.width ?? 0; + const height = displayObject.height ?? 0; + const absScaleX = Math.abs(displayObject.scale?.x ?? 1); + const absScaleY = Math.abs(displayObject.scale?.y ?? 1); + + let baseX = displayObject.x; + let baseY = displayObject.y; + if (prevState.x) { + baseX -= width; + } + if (prevState.y) { + baseY -= height; + } + + displayObject.scale.set( + absScaleX * (nextState.x ? -1 : 1), + absScaleY * (nextState.y ? -1 : 1), + ); + displayObject.position.set( + nextState.x ? baseX + width : baseX, + nextState.y ? baseY + height : baseY, + ); + displayObject._flipState = nextState; +}; diff --git a/src/events/focus-fit.js b/src/events/focus-fit.js index 77133be0..7f53e61d 100644 --- a/src/events/focus-fit.js +++ b/src/events/focus-fit.js @@ -2,6 +2,7 @@ import { isValidationError } from 'zod-validation-error'; import { calcGroupOrientedBounds } from '../utils/bounds'; import { selector } from '../utils/selector/selector'; import { validate } from '../utils/validator'; +import { moveViewportCenter } from '../utils/viewport-rotation'; import { focusFitIdsSchema } from './schema'; export const focus = (viewport, ids) => centerViewport(viewport, ids, false); @@ -21,13 +22,18 @@ const centerViewport = (viewport, ids, shouldFit = false) => { const bounds = calcGroupOrientedBounds(objects); const center = viewport.toLocal(bounds.center); if (bounds) { - viewport.moveCenter(center.x, center.y); + moveViewportCenter(viewport, center); if (shouldFit) { - viewport.fit( - true, - bounds.innerBounds.width / viewport.scale.x, - bounds.innerBounds.height / viewport.scale.y, + const width = bounds.innerBounds.width / viewport.scale.x; + const height = bounds.innerBounds.height / viewport.scale.y; + const scale = Math.min( + viewport.screenWidth / width, + viewport.screenHeight / height, ); + viewport.scale.set(scale); + const clampZoom = viewport.plugins?.get?.('clamp-zoom', true); + clampZoom?.clamp?.(); + moveViewportCenter(viewport, center); } } }; @@ -46,7 +52,9 @@ const getObjectsById = (viewport, ids) => { viewport, '$..children[?(@.type != null && @.parent.type !== "item" && @.parent.type !== "relations")]', ).reduce((acc, curr) => { - acc[curr.id] = curr; + if (curr.id) { + acc[curr.id] = curr; + } return acc; }, {}); return idsArr.flatMap((i) => objs[i]).filter((obj) => obj); diff --git a/src/patchmap.js b/src/patchmap.js index 9a1ce682..07dc5874 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -4,6 +4,7 @@ import { isValidationError } from 'zod-validation-error'; import { UndoRedoManager } from './command/UndoRedoManager'; import { draw } from './display/draw'; import { update } from './display/update'; +import World from './display/World'; import { fit as fitViewport, focus } from './events/focus-fit'; import { initApp, @@ -17,6 +18,10 @@ import { event } from './utils/event/canvas'; import { selector } from './utils/selector/selector'; import { themeStore } from './utils/theme'; import { validateMapData } from './utils/validator'; +import { + getViewportWorldCenter, + getWorldLocalCenter, +} from './utils/viewport-rotation'; import './display/elements/registry'; import './display/components/registry'; import StateManager from './events/StateManager'; @@ -34,6 +39,8 @@ class Patchmap extends WildcardEventEmitter { _animationContext = gsap.context(() => {}); _transformer = null; _stateManager = null; + _world = null; + _viewState = { flipX: false, flipY: false }; get app() { return this._app; @@ -47,6 +54,10 @@ class Patchmap extends WildcardEventEmitter { this._viewport = value; } + get world() { + return this._world; + } + get theme() { return this._theme.get(); } @@ -133,7 +144,11 @@ class Patchmap extends WildcardEventEmitter { theme: this.theme, animationContext: this.animationContext, }; + context.view = this._viewState; this.viewport = initViewport(this.app, viewportOptions, context); + this._world = new World({ context }); + context.world = this._world; + this.viewport.addChild(this._world); await initAsset(assetsOptions); initCanvas(element, this.app); @@ -171,6 +186,8 @@ class Patchmap extends WildcardEventEmitter { this._animationContext = gsap.context(() => {}); this._transformer = null; this._stateManager = null; + this._world = null; + this._viewState = { flipX: false, flipY: false }; this.emit('patchmap:destroyed', { target: this }); this.removeAllListeners(); } @@ -184,6 +201,7 @@ class Patchmap extends WildcardEventEmitter { const context = { viewport: this.viewport, + world: this.world, undoRedoManager: this.undoRedoManager, theme: this.theme, animationContext: this.animationContext, @@ -234,9 +252,82 @@ class Patchmap extends WildcardEventEmitter { fitViewport(this.viewport, ids); } + getRotation() { + return this.world?.angle ?? 0; + } + + setRotation(angle) { + if (!this.viewport || !this.world) return; + const nextAngle = Number(angle); + if (Number.isNaN(nextAngle)) return; + this.#applyWorldTransform({ angle: nextAngle }); + this.emit('patchmap:rotated', { angle: nextAngle, target: this }); + } + + rotateBy(delta) { + const currentAngle = this.getRotation(); + const nextAngle = Number(delta) + currentAngle; + this.setRotation(nextAngle); + } + + resetRotation() { + this.setRotation(0); + } + + getFlip() { + return { x: this._viewState.flipX, y: this._viewState.flipY }; + } + + setFlip({ x, y } = {}) { + if (typeof x === 'boolean') { + this._viewState.flipX = x; + } + if (typeof y === 'boolean') { + this._viewState.flipY = y; + } + if (!this.viewport || !this.world) return; + this.#applyWorldTransform(); + this.#syncViewFlip(); + this.emit('patchmap:flipped', { ...this.getFlip(), target: this }); + } + + toggleFlipX() { + this.setFlip({ x: !this._viewState.flipX }); + } + + toggleFlipY() { + this.setFlip({ y: !this._viewState.flipY }); + } + + resetFlip() { + this.setFlip({ x: false, y: false }); + } + selector(path, opts) { return selector(this.viewport, path, opts); } + + #applyWorldTransform({ angle = this.world?.angle ?? 0 } = {}) { + if (!this.viewport || !this.world) return; + const center = getViewportWorldCenter(this.viewport); + const localCenter = getWorldLocalCenter(this.viewport, this.world); + this.world.pivot.set(localCenter.x, localCenter.y); + this.world.position.set(center.x, center.y); + this.world.angle = angle; + this.world.scale.set( + this._viewState.flipX ? -1 : 1, + this._viewState.flipY ? -1 : 1, + ); + } + + #syncViewFlip() { + const texts = selector(this.viewport, '$..[?(@.type=="text")]'); + const icons = selector(this.viewport, '$..[?(@.type=="icon")]'); + [...texts, ...icons].forEach((element) => { + if (typeof element?._applyWorldFlip !== 'function') return; + element._applyWorldFlip(); + }); + } } export { Patchmap }; diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js index 308b60e1..3f41b2a9 100644 --- a/src/tests/render/patchmap.test.js +++ b/src/tests/render/patchmap.test.js @@ -75,7 +75,9 @@ describe('patchmap test', () => { it('draw', () => { const patchmap = getPatchmap(); patchmap.draw(sampleData); - expect(patchmap.viewport.children.length).toBe(2); + expect(patchmap.world).toBeDefined(); + expect(patchmap.viewport.children).toContain(patchmap.world); + expect(patchmap.world.children.length).toBe(2); const group = patchmap.selector('$..[?(@.id=="group-1")]')[0]; expect(group).toBeDefined(); diff --git a/src/utils/viewport-rotation.js b/src/utils/viewport-rotation.js new file mode 100644 index 00000000..309faf59 --- /dev/null +++ b/src/utils/viewport-rotation.js @@ -0,0 +1,36 @@ +import { Point } from 'pixi.js'; + +const tempPoint = new Point(); + +export const moveViewportCenter = (viewport, center) => { + if (!viewport || !center) return; + const angle = viewport.rotation ?? 0; + if (!angle) { + viewport.moveCenter(center.x, center.y); + return; + } + + const scaleX = viewport.scale?.x ?? 1; + const scaleY = viewport.scale?.y ?? 1; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const screenCenterX = viewport.screenWidth / 2; + const screenCenterY = viewport.screenHeight / 2; + const rotatedX = cos * scaleX * center.x - sin * scaleY * center.y; + const rotatedY = sin * scaleX * center.x + cos * scaleY * center.y; + + viewport.position.set(screenCenterX - rotatedX, screenCenterY - rotatedY); + viewport.plugins?.reset?.(); + viewport.dirty = true; +}; + +export const getViewportWorldCenter = (viewport) => { + if (!viewport) return { x: 0, y: 0 }; + return viewport.toWorld(viewport.screenWidth / 2, viewport.screenHeight / 2); +}; + +export const getWorldLocalCenter = (viewport, world) => { + if (!viewport || !world) return { x: 0, y: 0 }; + tempPoint.set(viewport.screenWidth / 2, viewport.screenHeight / 2); + return world.toLocal(tempPoint); +}; From 7e0ce462cb1cb5920ab4fe10fdffa84e78e7cb83 Mon Sep 17 00:00:00 2001 From: perhapsspy Date: Wed, 31 Dec 2025 16:05:43 +0900 Subject: [PATCH 2/8] fix: stabilize flipped text/icon positioning --- src/display/utils/world-flip.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/display/utils/world-flip.js b/src/display/utils/world-flip.js index 23145261..142e3078 100644 --- a/src/display/utils/world-flip.js +++ b/src/display/utils/world-flip.js @@ -5,16 +5,18 @@ export const applyWorldFlip = (displayObject, view) => { const nextState = { x: !!view.flipX, y: !!view.flipY }; const width = displayObject.width ?? 0; const height = displayObject.height ?? 0; + const prevWidth = prevState.width ?? width; + const prevHeight = prevState.height ?? height; const absScaleX = Math.abs(displayObject.scale?.x ?? 1); const absScaleY = Math.abs(displayObject.scale?.y ?? 1); let baseX = displayObject.x; let baseY = displayObject.y; if (prevState.x) { - baseX -= width; + baseX -= prevWidth; } if (prevState.y) { - baseY -= height; + baseY -= prevHeight; } displayObject.scale.set( @@ -25,5 +27,10 @@ export const applyWorldFlip = (displayObject, view) => { nextState.x ? baseX + width : baseX, nextState.y ? baseY + height : baseY, ); - displayObject._flipState = nextState; + displayObject._flipState = { + x: nextState.x, + y: nextState.y, + width, + height, + }; }; From 521562c3e566ba0ca7250e473c6ae024fece91ef Mon Sep 17 00:00:00 2001 From: perhapsspy Date: Wed, 31 Dec 2025 12:39:52 +0900 Subject: [PATCH 3/8] Add playground UI and data editor refactor --- README.md | 8 +- README_KR.md | 8 +- package.json | 5 +- playground/data-editor.js | 1484 +++++++++++++++++++++++++++++++++++++ playground/index.html | 138 ++++ playground/main.js | 281 +++++++ playground/scenarios.js | 1026 +++++++++++++++++++++++++ playground/style.css | 757 +++++++++++++++++++ playground/vite.config.js | 21 + 9 files changed, 3723 insertions(+), 5 deletions(-) create mode 100644 playground/data-editor.js create mode 100644 playground/index.html create mode 100644 playground/main.js create mode 100644 playground/scenarios.js create mode 100644 playground/style.css create mode 100644 playground/vite.config.js diff --git a/README.md b/README.md index 0751c634..16ea2422 100644 --- a/README.md +++ b/README.md @@ -683,10 +683,14 @@ This is the list of events that can be subscribed to with this update. You can s ## πŸ§‘β€πŸ’» Development +### Playground +The local playground lives in `playground/` and is served via Vite. It is excluded from the published package (only `dist/` is shipped). + ### Setting up the development environment ```sh npm install # Install dependencies -npm run dev # Start development server +npm run dev # Start playground server +npm run playground # Start playground server (alias) npm run build # Build the library npm run lint:fix # Fix code formatting ``` @@ -728,4 +732,4 @@ The file `src/utils/zod-deep-strict-partial.js` contains code originally license ## Fira Code This project incorporates the [Fira Code](https://github.com/tonsky/FiraCode) font to enhance code readability. -Fira Code is distributed under the [SIL Open Font License, Version 1.1](https://scripts.sil.org/OFL), and a copy of the license is provided in [OFL-1.1.txt](./src/assets/fonts/OFL-1.1.txt). \ No newline at end of file +Fira Code is distributed under the [SIL Open Font License, Version 1.1](https://scripts.sil.org/OFL), and a copy of the license is provided in [OFL-1.1.txt](./src/assets/fonts/OFL-1.1.txt). diff --git a/README_KR.md b/README_KR.md index 5d9cd31c..a664e685 100644 --- a/README_KR.md +++ b/README_KR.md @@ -692,11 +692,15 @@ undoRedoManager.redo(); ## πŸ§‘β€πŸ’» 개발 +### Playground +둜컬 playgroundλŠ” `playground/`에 있으며 Vite둜 μ‹€ν–‰λ©λ‹ˆλ‹€. 배포 νŒ¨ν‚€μ§€μ—λŠ” ν¬ν•¨λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€ (`dist/`만 배포). + ### 개발 ν™˜κ²½ μ„ΈνŒ… ```sh npm install # μ˜μ‘΄μ„± μ„€μΉ˜ -npm run dev # 개발 μ„œλ²„ μ‹œμž‘ +npm run dev # playground μ„œλ²„ μ‹œμž‘ +npm run playground # playground μ„œλ²„ μ‹œμž‘ (alias) npm run build # 라이브러리 λΉŒλ“œ npm run lint:fix # μ½”λ“œ ν¬λ§·νŒ… μˆ˜μ • ``` @@ -740,4 +744,4 @@ npm run lint:fix # μ½”λ“œ ν¬λ§·νŒ… μˆ˜μ • ## Fira Code 이 ν”„λ‘œμ νŠΈλŠ” μΊ”λ²„μŠ€ μƒμ—μ„œ 문자 가독성을 높이기 μœ„ν•΄ [Fira Code](https://github.com/tonsky/FiraCode) 폰트λ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€. -Fira CodeλŠ” [SIL Open Font License, Version 1.1](https://scripts.sil.org/OFL) ν•˜μ— 배포되며, λΌμ΄μ„ μŠ€ 사본은 [OFL-1.1.txt](./src/assets/fonts/OFL-1.1.txt)에 μ œκ³΅λ©λ‹ˆλ‹€. \ No newline at end of file +Fira CodeλŠ” [SIL Open Font License, Version 1.1](https://scripts.sil.org/OFL) ν•˜μ— 배포되며, λΌμ΄μ„ μŠ€ 사본은 [OFL-1.1.txt](./src/assets/fonts/OFL-1.1.txt)에 μ œκ³΅λ©λ‹ˆλ‹€. diff --git a/package.json b/package.json index 1aabcc18..1174d7a5 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,12 @@ "umd": "dist/index.umd.js", "types": "dist/types/src/patchmap.d.ts", "scripts": { + "dev": "vite --config playground/vite.config.js", "build": "tsc && rollup -c", "format": "biome format", "lint": "biome check --staged", "lint:fix": "biome check --staged --write", + "playground": "vite --config playground/vite.config.js", "test:unit": "vitest --project unit", "test:browser": "vitest --browser=chromium", "test:headless": "vitest --browser.headless", @@ -72,6 +74,7 @@ "rollup-plugin-copy": "^3.5.0", "standard-version": "^9.5.0", "typescript": "^5.9.3", + "vite": "^5.4.10", "vitest": "^4.0.8", "lint-staged": "^15.2.10" }, @@ -80,4 +83,4 @@ "biome check --write --no-errors-on-unmatched" ] } -} \ No newline at end of file +} diff --git a/playground/data-editor.js b/playground/data-editor.js new file mode 100644 index 00000000..c1f6af83 --- /dev/null +++ b/playground/data-editor.js @@ -0,0 +1,1484 @@ +import { componentSchema } from '../src/display/data-schema/component-schema.js'; +import { elementTypes } from '../src/display/data-schema/element-schema.js'; + +const componentTypes = new Set(['background', 'bar', 'icon', 'text']); +const colorPresets = [ + { value: 'primary.default', label: 'primary.default' }, + { value: 'primary.dark', label: 'primary.dark' }, + { value: 'primary.accent', label: 'primary.accent' }, + { value: 'gray.dark', label: 'gray.dark' }, + { value: 'gray.light', label: 'gray.light' }, + { value: 'black', label: 'black' }, + { value: 'white', label: 'white' }, + { value: '#111111', label: '#111111' }, + { value: '#ffffff', label: '#ffffff' }, +]; +const colorPresetValues = new Set(colorPresets.map((preset) => preset.value)); + +export const createDataEditor = ({ patchmap, elements, setLastAction }) => { + let currentData = []; + let nodeIndex = new Map(); + let treeItemById = new Map(); + let selectedNodeId = null; + + const setDataMode = (mode) => { + const isJson = mode === 'json'; + elements.dataJsonView.hidden = !isJson; + elements.dataFormView.hidden = isJson; + elements.dataModeJson.classList.toggle('is-active', isJson); + elements.dataModeForm.classList.toggle('is-active', !isJson); + elements.dataModeJson.setAttribute('aria-selected', String(isJson)); + elements.dataModeForm.setAttribute('aria-selected', String(!isJson)); + if (isJson) { + closeAddPopover(); + } + if (!isJson) { + renderTree(); + renderInspector(selectedNodeId); + } + }; + + const setCurrentData = (data, { updateEditor = true } = {}) => { + currentData = data; + if (updateEditor) { + setEditorValue(data); + } + renderTree(); + renderInspector(selectedNodeId); + }; + + const updateSelection = (target, fallbackId = null) => { + const id = target?.id ?? fallbackId ?? null; + selectedNodeId = id; + elements.selectedId.textContent = id ?? 'None'; + if (patchmap.transformer) { + patchmap.transformer.elements = target ? [target] : []; + } + highlightTree(id); + renderInspector(id); + updateAddParentOptions(); + }; + + const getSelectedNodeId = () => selectedNodeId; + + const applyEditorData = () => { + const data = parseEditorValue(); + if (!data) return; + if (!Array.isArray(data)) { + setEditorError('Root must be an array of elements.'); + return; + } + + try { + patchmap.draw(data); + patchmap.fit(); + setCurrentData(data, { updateEditor: false }); + updateSelection(null); + clearEditorError(); + setLastAction('Applied editor data'); + } catch (error) { + setEditorError(formatError(error)); + } + }; + + const prettifyEditor = () => { + const data = parseEditorValue(); + if (!data) return; + setEditorValue(data); + clearEditorError(); + setLastAction('Prettified editor'); + }; + + const selectNodeById = (id) => { + if (!id) return; + const target = patchmap.selector(`$..[?(@.id=="${id}")]`)[0]; + updateSelection(target, id); + }; + + const renderTree = () => { + if (!elements.dataTree) return; + elements.dataTree.replaceChildren(); + nodeIndex = new Map(); + treeItemById = new Map(); + if (!Array.isArray(currentData)) return; + + const fragment = document.createDocumentFragment(); + const walk = (nodes, depth = 0, parentId = null) => { + nodes.forEach((node) => { + if (!node || !node.id) return; + if (nodeIndex.has(node.id)) { + return; + } + nodeIndex.set(node.id, { node, parentId, depth }); + + const row = document.createElement('div'); + row.className = 'tree-row'; + row.style.paddingLeft = `${6 + depth * 12}px`; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'tree-label'; + button.dataset.nodeId = node.id; + + const label = document.createElement('span'); + label.className = 'tree-id'; + label.textContent = node.id; + const type = document.createElement('span'); + type.className = 'tree-type'; + type.textContent = node.type ?? 'node'; + + button.append(label, type); + if (node.id === selectedNodeId) { + button.classList.add('is-active'); + } + + const actions = document.createElement('div'); + actions.className = 'tree-actions'; + + const addButton = document.createElement('button'); + addButton.type = 'button'; + addButton.className = 'tree-action'; + addButton.dataset.action = 'add'; + addButton.dataset.parentId = + node.type === 'grid' || node.type === 'item' + ? node.id + : (parentId ?? '__root__'); + addButton.textContent = '+'; + + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.className = 'tree-action'; + deleteButton.dataset.action = 'delete'; + deleteButton.dataset.nodeId = node.id; + deleteButton.textContent = 'βˆ’'; + + actions.append(addButton, deleteButton); + row.append(button, actions); + + treeItemById.set(node.id, button); + fragment.append(row); + + if (Array.isArray(node.children)) { + walk(node.children, depth + 1, node.id); + } + if (node.type === 'item' && Array.isArray(node.components)) { + walk(node.components, depth + 1, node.id); + } + if (node.type === 'grid' && Array.isArray(node.item?.components)) { + walk(node.item.components, depth + 1, node.id); + } + }); + }; + + walk(currentData); + fragment.append(buildRootAddRow()); + elements.dataTree.append(fragment); + updateAddParentOptions(); + }; + + const highlightTree = (id) => { + treeItemById.forEach((item) => item.classList.remove('is-active')); + if (!id) return; + const target = treeItemById.get(id); + if (target) { + target.classList.add('is-active'); + } + }; + + const renderInspector = (id) => { + const container = elements.inspectorContent ?? elements.dataInspector; + if (!container) return; + container.replaceChildren(); + + if (!id) { + container.append(buildInspectorEmpty('Select a node')); + return; + } + + const entry = nodeIndex.get(id); + if (!entry) { + container.append(buildInspectorEmpty('No editable data')); + return; + } + + const { node } = entry; + const resolved = resolveNodeSchema(node); + const data = resolved.parsed ?? node; + const buildInput = (value, options = {}) => { + let input; + if (options.options) { + input = document.createElement('select'); + options.options.forEach((option) => { + const item = document.createElement('option'); + item.value = option.value; + item.textContent = option.label; + input.append(item); + }); + if (value != null) { + input.value = String(value); + } + } else if (options.type === 'boolean') { + input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = Boolean(value); + } else { + input = document.createElement('input'); + input.type = options.type ?? 'text'; + input.value = value ?? ''; + } + + input.className = 'inspector-input'; + if (options.compact) { + input.classList.add('inspector-input--compact'); + } + + if (options.readOnly) { + input.readOnly = true; + } else if (options.path) { + input.addEventListener('change', (event) => { + const nextValue = + options.type === 'boolean' + ? event.target.checked + : event.target.value; + handleInspectorChange( + id, + node, + options.path, + nextValue, + options.type, + options.originalValue, + ); + }); + } + return input; + }; + + const addField = (label, value, options = {}) => { + const field = document.createElement('div'); + field.className = 'inspector-field'; + + const labelEl = document.createElement('div'); + labelEl.className = 'inspector-label'; + labelEl.textContent = label; + + const input = buildInput(value, options); + field.append(labelEl, input); + container.append(field); + }; + + const addInlineFields = (label, fields) => { + const field = document.createElement('div'); + field.className = 'inspector-field inspector-field--inline'; + + const labelEl = document.createElement('div'); + labelEl.className = 'inspector-label'; + labelEl.textContent = label; + + const group = document.createElement('div'); + group.className = 'inspector-inline'; + + fields.forEach((item) => { + const wrap = document.createElement('div'); + wrap.className = 'inspector-inline-item'; + + const tag = document.createElement('div'); + tag.className = 'inspector-inline-label'; + tag.textContent = item.short; + + const input = buildInput(item.value, { + ...item.options, + path: item.path, + type: item.type, + originalValue: item.originalValue, + compact: true, + }); + + wrap.append(tag, input); + group.append(wrap); + }); + + field.append(labelEl, group); + container.append(field); + }; + + const addColorField = (label, value, path) => { + const field = document.createElement('div'); + field.className = 'inspector-field inspector-field--inline'; + + const labelEl = document.createElement('div'); + labelEl.className = 'inspector-label'; + labelEl.textContent = label; + + const group = document.createElement('div'); + group.className = 'inspector-inline'; + + const select = document.createElement('select'); + select.className = + 'inspector-input inspector-input--compact color-select'; + + const customOption = document.createElement('option'); + customOption.value = '__custom__'; + customOption.textContent = 'Custom'; + select.append(customOption); + + colorPresets.forEach((preset) => { + const option = document.createElement('option'); + option.value = preset.value; + option.textContent = preset.label; + select.append(option); + }); + + const stringValue = value == null ? '' : String(value); + select.value = colorPresetValues.has(stringValue) + ? stringValue + : '__custom__'; + + const input = document.createElement('input'); + input.className = 'inspector-input color-input'; + input.type = 'text'; + input.value = stringValue; + input.placeholder = '#ffffff'; + + select.addEventListener('change', () => { + if (select.value === '__custom__') { + input.focus(); + return; + } + input.value = select.value; + handleInspectorChange(id, node, path, select.value, 'text', value); + }); + + input.addEventListener('change', (event) => { + const nextValue = event.target.value; + select.value = colorPresetValues.has(nextValue) + ? nextValue + : '__custom__'; + handleInspectorChange(id, node, path, nextValue, 'text', value); + }); + + group.append(select, input); + field.append(labelEl, group); + container.append(field); + }; + + addField('Id', data.id, { readOnly: true }); + addField('Type', data.type ?? '', { readOnly: true }); + addField('Label', data.label ?? '', { path: 'label', type: 'text' }); + addField('Show', data.show ?? true, { path: 'show', type: 'boolean' }); + + if (typeof data.text === 'string') { + addField('Text', data.text, { path: 'text', type: 'text' }); + } + + if (typeof data.source === 'string') { + addField('Source', data.source, { path: 'source', type: 'text' }); + } + + if (data.tint != null) { + addColorField('Tint', data.tint, 'tint'); + } + + if (data.type !== 'relations' && resolved.kind === 'element') { + addField('X', data.attrs?.x ?? '', { + path: 'attrs.x', + type: 'number', + originalValue: data.attrs?.x, + }); + addField('Y', data.attrs?.y ?? '', { + path: 'attrs.y', + type: 'number', + originalValue: data.attrs?.y, + }); + if (data.attrs?.angle != null) { + addField('Angle', data.attrs.angle ?? '', { + path: 'attrs.angle', + type: 'number', + originalValue: data.attrs?.angle, + }); + } + if (data.attrs?.rotation != null) { + addField('Rot', data.attrs.rotation ?? '', { + path: 'attrs.rotation', + type: 'number', + originalValue: data.attrs?.rotation, + }); + } + } + + if (data.size != null) { + if (resolved.kind === 'component') { + const widthValue = formatPxPercent(data.size?.width); + const heightValue = formatPxPercent(data.size?.height); + addInlineFields('Size', [ + { + short: 'W', + value: widthValue ?? '', + path: 'size.width', + type: 'text', + }, + { + short: 'H', + value: heightValue ?? '', + path: 'size.height', + type: 'text', + }, + ]); + } else { + addInlineFields('Size', [ + { + short: 'W', + value: data.size?.width ?? '', + path: 'size.width', + type: 'number', + originalValue: data.size?.width, + }, + { + short: 'H', + value: data.size?.height ?? '', + path: 'size.height', + type: 'number', + originalValue: data.size?.height, + }, + ]); + } + } + + if (data.gap != null) { + addInlineFields('Gap', [ + { + short: 'X', + value: data.gap?.x ?? '', + path: 'gap.x', + type: 'number', + originalValue: data.gap?.x, + }, + { + short: 'Y', + value: data.gap?.y ?? '', + path: 'gap.y', + type: 'number', + originalValue: data.gap?.y, + }, + ]); + } + + if (data.padding != null && resolved.kind === 'element') { + addInlineFields('Pad', [ + { + short: 'T', + value: data.padding?.top ?? '', + path: 'padding.top', + type: 'number', + originalValue: data.padding?.top, + }, + { + short: 'R', + value: data.padding?.right ?? '', + path: 'padding.right', + type: 'number', + originalValue: data.padding?.right, + }, + { + short: 'B', + value: data.padding?.bottom ?? '', + path: 'padding.bottom', + type: 'number', + originalValue: data.padding?.bottom, + }, + { + short: 'L', + value: data.padding?.left ?? '', + path: 'padding.left', + type: 'number', + originalValue: data.padding?.left, + }, + ]); + } + + if (data.placement && resolved.kind === 'component') { + addField('Place', data.placement, { + path: 'placement', + type: 'text', + options: [ + { value: 'left', label: 'left' }, + { value: 'left-top', label: 'left-top' }, + { value: 'left-bottom', label: 'left-bottom' }, + { value: 'top', label: 'top' }, + { value: 'right', label: 'right' }, + { value: 'right-top', label: 'right-top' }, + { value: 'right-bottom', label: 'right-bottom' }, + { value: 'bottom', label: 'bottom' }, + { value: 'center', label: 'center' }, + ], + }); + } + + if (data.margin && resolved.kind === 'component') { + addInlineFields('Margin', [ + { + short: 'T', + value: data.margin?.top ?? '', + path: 'margin.top', + type: 'number', + originalValue: data.margin?.top, + }, + { + short: 'R', + value: data.margin?.right ?? '', + path: 'margin.right', + type: 'number', + originalValue: data.margin?.right, + }, + { + short: 'B', + value: data.margin?.bottom ?? '', + path: 'margin.bottom', + type: 'number', + originalValue: data.margin?.bottom, + }, + { + short: 'L', + value: data.margin?.left ?? '', + path: 'margin.left', + type: 'number', + originalValue: data.margin?.left, + }, + ]); + } + + if ( + (data.type === 'background' || data.type === 'bar') && + data.source && + typeof data.source === 'object' + ) { + addColorField('Fill', data.source.fill ?? '', 'source.fill'); + addColorField( + 'Border', + data.source.borderColor ?? '', + 'source.borderColor', + ); + addField('B Width', data.source.borderWidth ?? '', { + path: 'source.borderWidth', + type: 'number', + originalValue: data.source.borderWidth, + }); + if (typeof data.source.radius === 'number') { + addField('Radius', data.source.radius ?? '', { + path: 'source.radius', + type: 'number', + originalValue: data.source.radius, + }); + } else if (data.source.radius && typeof data.source.radius === 'object') { + addInlineFields('Radius', [ + { + short: 'TL', + value: data.source.radius.topLeft ?? '', + path: 'source.radius.topLeft', + type: 'number', + originalValue: data.source.radius.topLeft, + }, + { + short: 'TR', + value: data.source.radius.topRight ?? '', + path: 'source.radius.topRight', + type: 'number', + originalValue: data.source.radius.topRight, + }, + { + short: 'BR', + value: data.source.radius.bottomRight ?? '', + path: 'source.radius.bottomRight', + type: 'number', + originalValue: data.source.radius.bottomRight, + }, + { + short: 'BL', + value: data.source.radius.bottomLeft ?? '', + path: 'source.radius.bottomLeft', + type: 'number', + originalValue: data.source.radius.bottomLeft, + }, + ]); + } + } + + if (data.type === 'relations' && data.style?.color != null) { + addColorField('Color', data.style.color ?? '', 'style.color'); + } + + if (data.type === 'relations' && data.style?.width != null) { + addField('Width', data.style.width ?? '', { + path: 'style.width', + type: 'number', + originalValue: data.style.width, + }); + } + + if (data.type === 'text') { + addField('Split', data.split ?? '', { + path: 'split', + type: 'number', + originalValue: data.split, + }); + addField('F Size', data.style?.fontSize ?? '', { + path: 'style.fontSize', + type: 'text', + }); + addField('F Weight', data.style?.fontWeight ?? '', { + path: 'style.fontWeight', + type: 'text', + }); + addField('F Family', data.style?.fontFamily ?? '', { + path: 'style.fontFamily', + type: 'text', + }); + addColorField('Fill', data.style?.fill ?? '', 'style.fill'); + addField('Wrap', data.style?.wordWrapWidth ?? '', { + path: 'style.wordWrapWidth', + type: 'text', + }); + addField('Overflow', data.style?.overflow ?? '', { + path: 'style.overflow', + type: 'text', + options: [ + { value: 'visible', label: 'visible' }, + { value: 'hidden', label: 'hidden' }, + { value: 'ellipsis', label: 'ellipsis' }, + ], + }); + addInlineFields('Auto', [ + { + short: 'Min', + value: data.style?.autoFont?.min ?? '', + path: 'style.autoFont.min', + type: 'number', + originalValue: data.style?.autoFont?.min, + }, + { + short: 'Max', + value: data.style?.autoFont?.max ?? '', + path: 'style.autoFont.max', + type: 'number', + originalValue: data.style?.autoFont?.max, + }, + ]); + } + + if (data.type === 'bar') { + addField('Anim', data.animation ?? true, { + path: 'animation', + type: 'boolean', + }); + addField('Anim Ms', data.animationDuration ?? '', { + path: 'animationDuration', + type: 'number', + originalValue: data.animationDuration, + }); + } + + if (data.type === 'grid') { + addInlineFields('Item Size', [ + { + short: 'W', + value: data.item?.size?.width ?? '', + path: 'item.size.width', + type: 'number', + originalValue: data.item?.size?.width, + }, + { + short: 'H', + value: data.item?.size?.height ?? '', + path: 'item.size.height', + type: 'number', + originalValue: data.item?.size?.height, + }, + ]); + addInlineFields('Item Pad', [ + { + short: 'T', + value: data.item?.padding?.top ?? '', + path: 'item.padding.top', + type: 'number', + originalValue: data.item?.padding?.top, + }, + { + short: 'R', + value: data.item?.padding?.right ?? '', + path: 'item.padding.right', + type: 'number', + originalValue: data.item?.padding?.right, + }, + { + short: 'B', + value: data.item?.padding?.bottom ?? '', + path: 'item.padding.bottom', + type: 'number', + originalValue: data.item?.padding?.bottom, + }, + { + short: 'L', + value: data.item?.padding?.left ?? '', + path: 'item.padding.left', + type: 'number', + originalValue: data.item?.padding?.left, + }, + ]); + } + + if (data.type === 'relations') { + renderRelationsEditor(container, node, id); + } + + if (data.type === 'grid') { + renderGridEditor(container, node, id); + } + }; + + const buildInspectorEmpty = (text) => { + const empty = document.createElement('div'); + empty.className = 'inspector-empty'; + empty.textContent = text; + return empty; + }; + + const renderRelationsEditor = (container, node, id) => { + const editor = document.createElement('div'); + editor.className = 'relations-editor'; + + const title = document.createElement('div'); + title.className = 'relations-title'; + title.textContent = 'Links'; + + const controls = document.createElement('div'); + controls.className = 'relations-controls'; + + const sourceSelect = document.createElement('select'); + sourceSelect.className = 'relations-select'; + const targetSelect = document.createElement('select'); + targetSelect.className = 'relations-select'; + + const options = buildRelationsOptions(); + options.forEach((option) => { + const sourceOption = document.createElement('option'); + sourceOption.value = option.value; + sourceOption.textContent = option.label; + sourceSelect.append(sourceOption); + + const targetOption = document.createElement('option'); + targetOption.value = option.value; + targetOption.textContent = option.label; + targetSelect.append(targetOption); + }); + + if (options.length > 1) { + targetSelect.selectedIndex = 1; + } + + const addButton = document.createElement('button'); + addButton.type = 'button'; + addButton.className = 'relations-add'; + addButton.textContent = 'Add'; + + controls.append(sourceSelect, targetSelect, addButton); + + const list = document.createElement('div'); + list.className = 'relations-list'; + + const renderList = () => { + list.replaceChildren(); + const links = node.links ?? []; + if (links.length === 0) { + const empty = document.createElement('div'); + empty.className = 'relations-empty'; + empty.textContent = 'No links'; + list.append(empty); + return; + } + links.forEach((link, index) => { + const row = document.createElement('div'); + row.className = 'relations-row'; + + const label = document.createElement('div'); + label.className = 'relations-label'; + label.textContent = `${link.source} β†’ ${link.target}`; + + const removeButton = document.createElement('button'); + removeButton.type = 'button'; + removeButton.className = 'relations-delete'; + removeButton.textContent = 'βˆ’'; + removeButton.addEventListener('click', () => { + const nextLinks = node.links.filter((_, idx) => idx !== index); + applyRelationsLinks(node, id, nextLinks); + renderList(); + }); + + row.append(label, removeButton); + list.append(row); + }); + }; + + addButton.addEventListener('click', () => { + const source = sourceSelect.value; + const target = targetSelect.value; + if (!source || !target) return; + const nextLinks = [...(node.links ?? [])]; + if ( + nextLinks.some( + (link) => link.source === source && link.target === target, + ) + ) { + return; + } + nextLinks.push({ source, target }); + applyRelationsLinks(node, id, nextLinks); + renderList(); + }); + + renderList(); + editor.append(title, controls, list); + container.append(editor); + }; + + const applyRelationsLinks = (node, id, nextLinks) => { + const draft = { ...node, links: nextLinks }; + const validation = validateNode(draft, node.type); + if (!validation.success) { + setEditorError(validation.message); + return; + } + + node.links = nextLinks; + patchmap.update({ + path: `$..[?(@.id=="${id}")]`, + changes: { links: nextLinks }, + mergeStrategy: 'replace', + }); + setEditorValue(currentData); + clearEditorError(); + setLastAction(`Updated ${id} links`); + }; + + const buildRelationsOptions = () => { + const options = []; + nodeIndex.forEach((entry, id) => { + const type = entry.node?.type; + if (!type || componentTypes.has(type)) return; + options.push({ value: id, label: id }); + if (type === 'grid' && Array.isArray(entry.node.cells)) { + entry.node.cells.forEach((row, rowIndex) => { + row.forEach((cell, colIndex) => { + if (cell === 0 || cell == null) return; + options.push({ + value: `${id}.${rowIndex}.${colIndex}`, + label: `${id}.${rowIndex}.${colIndex}`, + }); + }); + }); + } + }); + return options; + }; + + const renderGridEditor = (container, node, id) => { + if (!Array.isArray(node.cells)) return; + const editor = document.createElement('div'); + editor.className = 'grid-editor'; + + const title = document.createElement('div'); + title.className = 'grid-title'; + title.textContent = 'Cells'; + + const controls = document.createElement('div'); + controls.className = 'grid-controls'; + + const addRow = buildGridControl('+ Row', 'add-row'); + const removeRow = buildGridControl('- Row', 'remove-row'); + const addCol = buildGridControl('+ Col', 'add-col'); + const removeCol = buildGridControl('- Col', 'remove-col'); + + controls.append(addRow, removeRow, addCol, removeCol); + + const grid = document.createElement('div'); + grid.className = 'grid-cells'; + + const renderCells = () => { + grid.replaceChildren(); + const rows = node.cells.length; + const cols = Math.max(1, ...node.cells.map((row) => row.length || 0)); + grid.style.setProperty('--grid-cols', String(cols)); + grid.style.setProperty('--grid-rows', String(rows)); + + node.cells.forEach((row, rowIndex) => { + for (let colIndex = 0; colIndex < cols; colIndex += 1) { + const value = row[colIndex]; + const cell = document.createElement('button'); + cell.type = 'button'; + cell.className = 'grid-cell'; + cell.dataset.row = String(rowIndex); + cell.dataset.col = String(colIndex); + + if (typeof value === 'string') { + cell.dataset.original = value; + cell.dataset.value = value; + } + + if (value !== 0 && value !== undefined) { + cell.classList.add('is-active'); + } + + cell.textContent = ''; + grid.append(cell); + } + }); + }; + + const updateCells = (nextCells) => { + node.cells = nextCells; + patchmap.update({ + path: `$..[?(@.id=="${id}")]`, + changes: { cells: nextCells }, + mergeStrategy: 'replace', + }); + setEditorValue(currentData); + clearEditorError(); + }; + + editor.addEventListener('click', (event) => { + const actionButton = event.target.closest('[data-grid-action]'); + if (actionButton) { + const action = actionButton.dataset.gridAction; + const cols = Math.max(1, ...node.cells.map((row) => row.length || 0)); + if (action === 'add-row') { + node.cells.push(Array.from({ length: cols }, () => 1)); + } + if (action === 'remove-row' && node.cells.length > 1) { + node.cells.pop(); + } + if (action === 'add-col') { + node.cells.forEach((row) => row.push(1)); + } + if (action === 'remove-col' && cols > 1) { + node.cells.forEach((row) => row.pop()); + } + updateCells(node.cells); + renderCells(); + setLastAction(`Updated ${id} cells`); + return; + } + + const cell = event.target.closest('.grid-cell'); + if (!cell) return; + const row = Number(cell.dataset.row); + const col = Number(cell.dataset.col); + const currentValue = node.cells[row]?.[col] ?? 0; + let nextValue = 0; + + if (currentValue === 0 || currentValue == null) { + nextValue = cell.dataset.original ?? 1; + } + + node.cells[row][col] = nextValue; + updateCells(node.cells); + renderCells(); + setLastAction(`Updated ${id} cells`); + }); + + renderCells(); + editor.append(title, controls, grid); + container.append(editor); + }; + + const buildGridControl = (label, action) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'grid-control'; + button.dataset.gridAction = action; + button.textContent = label; + return button; + }; + + const handleInspectorChange = ( + id, + node, + path, + rawValue, + inputType, + originalValue, + ) => { + const value = coerceValue(rawValue, inputType, originalValue); + if (value === null) return; + + const draft = JSON.parse(JSON.stringify(node)); + setNodeValue(draft, path, value); + const validation = validateNode(draft, node.type); + if (!validation.success) { + setEditorError(validation.message); + renderInspector(id); + return; + } + + setNodeValue(node, path, value); + patchmap.update({ + path: `$..[?(@.id=="${id}")]`, + changes: buildChangesFromPath(path, value), + }); + + setEditorValue(currentData); + clearEditorError(); + setLastAction(`Updated ${id}`); + }; + + const coerceValue = (rawValue, inputType, originalValue) => { + if (inputType === 'number') { + if (rawValue === '') return null; + const numberValue = Number(rawValue); + return Number.isNaN(numberValue) ? null : numberValue; + } + if (inputType === 'boolean') { + return Boolean(rawValue); + } + if (typeof originalValue === 'number') { + const numberValue = Number(rawValue); + if (!Number.isNaN(numberValue)) { + return numberValue; + } + } + return rawValue; + }; + + const setNodeValue = (node, path, value) => { + const keys = path.split('.'); + let current = node; + keys.forEach((key, index) => { + if (index === keys.length - 1) { + current[key] = value; + return; + } + if (typeof current[key] !== 'object' || current[key] === null) { + current[key] = {}; + } + current = current[key]; + }); + }; + + const buildChangesFromPath = (path, value) => { + const keys = path.split('.'); + const changes = {}; + let current = changes; + keys.forEach((key, index) => { + if (index === keys.length - 1) { + current[key] = value; + return; + } + current[key] = {}; + current = current[key]; + }); + return changes; + }; + + const updateAddParentOptions = () => { + if (!elements.dataAddParent) return; + const previous = elements.dataAddParent.value; + const options = buildAddParentOptions(); + elements.dataAddParent.replaceChildren( + ...options.map((option) => { + const item = document.createElement('option'); + item.value = option.value; + item.textContent = option.label; + return item; + }), + ); + + const preferred = resolveAddParentSelection( + previous, + selectedNodeId, + options, + ); + elements.dataAddParent.value = preferred; + updateAddTypeOptions(); + }; + + const openAddPopover = (parentId = '__root__') => { + if (!elements.dataPopover) return; + updateAddParentOptions(); + if (elements.dataAddParent) { + const target = parentId ?? '__root__'; + const optionExists = Array.from(elements.dataAddParent.options).some( + (option) => option.value === target, + ); + elements.dataAddParent.value = optionExists ? target : '__root__'; + updateAddTypeOptions(); + } + elements.dataPopover.hidden = false; + elements.dataAddId?.focus(); + }; + + const closeAddPopover = () => { + if (!elements.dataPopover) return; + elements.dataPopover.hidden = true; + }; + + const resolveAddParentSelection = (previous, selectedId, options) => { + const values = new Set(options.map((option) => option.value)); + if (previous && values.has(previous)) return previous; + if (selectedId && values.has(selectedId)) return selectedId; + return '__root__'; + }; + + const buildAddParentOptions = () => { + const options = [{ value: '__root__', label: 'Root' }]; + nodeIndex.forEach((entry, id) => { + const type = entry.node?.type; + if (type === 'group' || type === 'item' || type === 'grid') { + options.push({ value: id, label: `${type}:${id}` }); + } + }); + return options; + }; + + const updateAddTypeOptions = () => { + if (!elements.dataAddType || !elements.dataAddParent) return; + const previous = elements.dataAddType.value; + const parentEntry = nodeIndex.get(elements.dataAddParent.value); + const parentType = parentEntry?.node?.type; + const types = + parentType === 'item' || parentType === 'grid' + ? ['background', 'bar', 'icon', 'text'] + : ['group', 'grid', 'item', 'relations']; + + elements.dataAddType.replaceChildren( + ...types.map((type) => { + const option = document.createElement('option'); + option.value = type; + option.textContent = type; + return option; + }), + ); + + if (types.includes(previous)) { + elements.dataAddType.value = previous; + } + }; + + const handleAddElement = () => { + if (!elements.dataAddType || !elements.dataAddParent) return; + const parentId = elements.dataAddParent.value; + const type = elements.dataAddType.value; + const idInput = elements.dataAddId?.value.trim(); + const labelInput = elements.dataAddLabel?.value.trim(); + const id = idInput || generateId(type); + + if (nodeIndex.has(id)) { + setEditorError(`Duplicate id: ${id}`); + return; + } + + const node = buildNewNode(type, id, labelInput); + if (!node) { + setEditorError('Unsupported type'); + return; + } + + const validation = validateNode(node, type); + if (!validation.success) { + setEditorError(validation.message); + return; + } + + if (parentId === '__root__') { + currentData.push(node); + } else { + const parentEntry = nodeIndex.get(parentId); + if (!parentEntry?.node) { + setEditorError('Invalid parent'); + return; + } + if (parentEntry.node.type === 'item') { + parentEntry.node.components = parentEntry.node.components ?? []; + parentEntry.node.components.push(node); + } else if (parentEntry.node.type === 'grid') { + parentEntry.node.item = parentEntry.node.item ?? { + size: { width: 40, height: 80 }, + padding: 0, + components: [], + }; + parentEntry.node.item.components = + parentEntry.node.item.components ?? []; + parentEntry.node.item.components.push(node); + } else { + parentEntry.node.children = parentEntry.node.children ?? []; + parentEntry.node.children.push(node); + } + } + + clearEditorError(); + if (elements.dataAddId) elements.dataAddId.value = ''; + if (elements.dataAddLabel) elements.dataAddLabel.value = ''; + + patchmap.draw(currentData); + patchmap.fit(); + setCurrentData(currentData); + selectNodeById(id); + setLastAction(`Added ${id}`); + closeAddPopover(); + }; + + const buildNewNode = (type, id, label) => { + const base = { type, id }; + if (label) { + base.label = label; + } + + switch (type) { + case 'group': + return { ...base, children: [] }; + case 'grid': + return { + ...base, + cells: [[1]], + gap: 4, + item: { + size: { width: 40, height: 80 }, + padding: 6, + components: [ + { + type: 'background', + source: { + type: 'rect', + fill: '#ffffff', + borderWidth: 1, + borderColor: '#111111', + radius: 4, + }, + tint: '#ffffff', + }, + ], + }, + }; + case 'item': + return { + ...base, + size: { width: 120, height: 80 }, + padding: 0, + components: [], + }; + case 'relations': + return { ...base, links: [], style: { width: 2 } }; + case 'background': + return { + ...base, + source: { + type: 'rect', + fill: '#ffffff', + borderWidth: 1, + borderColor: '#111111', + radius: 4, + }, + tint: '#ffffff', + }; + case 'bar': + return { + ...base, + source: { type: 'rect', fill: '#111111', radius: 4 }, + size: { width: '50%', height: 10 }, + placement: 'bottom', + margin: 0, + tint: '#111111', + }; + case 'icon': + return { + ...base, + source: 'wifi', + size: 16, + placement: 'center', + margin: 0, + tint: '#111111', + }; + case 'text': + return { + ...base, + text: 'Label', + style: {}, + placement: 'center', + margin: 0, + tint: '#111111', + }; + default: + return null; + } + }; + + const generateId = (type) => { + const base = type.replace(/[^a-z0-9]+/gi, '-').toLowerCase(); + let candidate = `${base}-${Math.random().toString(36).slice(2, 6)}`; + while (nodeIndex.has(candidate)) { + candidate = `${base}-${Math.random().toString(36).slice(2, 6)}`; + } + return candidate; + }; + + const deleteNodeById = (nodeId) => { + if (!nodeId) return; + const entry = nodeIndex.get(nodeId); + if (!entry) return; + + const { parentId } = entry; + let removed = false; + if (!parentId) { + const index = currentData.findIndex((node) => node.id === nodeId); + if (index >= 0) { + currentData.splice(index, 1); + removed = true; + } + } else { + const parentEntry = nodeIndex.get(parentId); + if (parentEntry?.node) { + if (parentEntry.node.type === 'item') { + parentEntry.node.components = ( + parentEntry.node.components ?? [] + ).filter((child) => child.id !== nodeId); + removed = true; + } else if (parentEntry.node.type === 'grid') { + parentEntry.node.item = parentEntry.node.item ?? { + size: { width: 40, height: 80 }, + padding: 0, + components: [], + }; + parentEntry.node.item.components = ( + parentEntry.node.item.components ?? [] + ).filter((child) => child.id !== nodeId); + removed = true; + } else { + parentEntry.node.children = (parentEntry.node.children ?? []).filter( + (child) => child.id !== nodeId, + ); + removed = true; + } + } + } + + if (!removed) return; + + patchmap.draw(currentData); + patchmap.fit(); + setCurrentData(currentData); + updateSelection(null); + setLastAction(`Deleted ${nodeId}`); + }; + + const buildRootAddRow = () => { + const row = document.createElement('div'); + row.className = 'tree-row'; + + const label = document.createElement('div'); + label.className = 'tree-root-label'; + label.textContent = 'Root'; + + const actions = document.createElement('div'); + actions.className = 'tree-actions'; + + const addButton = document.createElement('button'); + addButton.type = 'button'; + addButton.className = 'tree-action'; + addButton.dataset.action = 'add'; + addButton.dataset.parentId = '__root__'; + addButton.textContent = '+'; + + actions.append(addButton); + row.append(label, actions); + return row; + }; + + const formatError = (error) => { + if (error?.message) return error.message; + try { + return JSON.stringify(error); + } catch { + return String(error); + } + }; + + const setEditorValue = (data) => { + elements.editor.value = JSON.stringify(data, null, 2); + }; + + const parseEditorValue = () => { + try { + return JSON.parse(elements.editor.value); + } catch (error) { + setEditorError(`JSON parse error: ${error.message}`); + return null; + } + }; + + const setEditorError = (message) => { + elements.editorError.textContent = message; + }; + + const clearEditorError = () => { + elements.editorError.textContent = ''; + }; + + const resolveNodeSchema = (node) => { + if (!node?.type) { + return { parsed: node, schema: null, kind: 'unknown', error: null }; + } + + const schema = componentTypes.has(node.type) + ? componentSchema + : elementTypes; + const result = schema.safeParse(node); + if (!result.success) { + return { + parsed: node, + schema, + kind: componentTypes.has(node.type) ? 'component' : 'element', + error: result.error, + }; + } + + return { + parsed: result.data, + schema, + kind: componentTypes.has(node.type) ? 'component' : 'element', + error: null, + }; + }; + + const validateNode = (node, type) => { + const schema = componentTypes.has(type) ? componentSchema : elementTypes; + const result = schema.safeParse(node); + if (result.success) { + return { success: true, message: '' }; + } + return { + success: false, + message: result.error.issues?.[0]?.message ?? 'Invalid data', + }; + }; + + const formatPxPercent = (value) => { + if (value == null) return null; + if (typeof value === 'string' || typeof value === 'number') return value; + if (typeof value === 'object' && 'value' in value && 'unit' in value) { + return `${value.value}${value.unit}`; + } + return null; + }; + + return { + setDataMode, + setCurrentData, + updateSelection, + getSelectedNodeId, + applyEditorData, + prettifyEditor, + selectNodeById, + openAddPopover, + closeAddPopover, + updateAddTypeOptions, + handleAddElement, + deleteNodeById, + clearEditorError, + }; +}; diff --git a/playground/index.html b/playground/index.html new file mode 100644 index 00000000..e95574cb --- /dev/null +++ b/playground/index.html @@ -0,0 +1,138 @@ + + + + + + Patchmap Playground + + + + + + +
+
+
+ +
Playground
+
+
+ +
+ + +
+
+ + + + + +
+
+
+
+ Drag to pan, scroll to zoom, click to select. +
+
+
+
+ +
+
+ Selection + None + | + Last + + Ready + +
+
+
+ + + + diff --git a/playground/main.js b/playground/main.js new file mode 100644 index 00000000..ad4c81a8 --- /dev/null +++ b/playground/main.js @@ -0,0 +1,281 @@ +import { Patchmap, Transformer } from '@patchmap'; +import { createDataEditor } from './data-editor.js'; +import { scenarios } from './scenarios.js'; + +const $ = (selector) => document.querySelector(selector); + +const elements = { + stage: $('#patchmap-root'), + scenario: $('#scenario'), + draw: $('#draw'), + editor: $('#data-editor'), + applyData: $('#apply-data'), + prettifyData: $('#prettify-data'), + resetData: $('#reset-data'), + editorError: $('#editor-error'), + dataModeJson: $('#data-mode-json'), + dataModeForm: $('#data-mode-form'), + dataJsonView: $('#data-json'), + dataFormView: $('#data-form'), + dataTree: $('#data-tree'), + dataInspector: $('#data-inspector'), + inspectorContent: $('#inspector-content'), + dataPopover: $('#data-popover'), + addCancel: $('#add-cancel'), + dataAddParent: $('#add-parent'), + dataAddType: $('#add-type'), + dataAddId: $('#add-id'), + dataAddLabel: $('#add-label'), + dataAddButton: $('#add-element'), + randomize: $('#randomize'), + shuffle: $('#shuffle-links'), + focus: $('#focus-target'), + fit: $('#fit-view'), + reset: $('#reset-view'), + sceneName: $('#scene-name'), + sceneTitle: $('#scene-title'), + sceneDescription: $('#scene-description'), + selectedId: $('#selected-id'), + lastAction: $('#last-action'), +}; + +const patchmap = new Patchmap(); +let currentScenario = scenarios[0]; +let linkSetIndex = 0; + +const setLastAction = (text) => { + elements.lastAction.textContent = text; +}; + +const dataEditor = createDataEditor({ patchmap, elements, setLastAction }); + +const init = async () => { + await patchmap.init(elements.stage); + patchmap.transformer = new Transformer(); + + patchmap.stateManager.setState('selection', { + onClick: (target) => dataEditor.updateSelection(target), + }); + + setupScenarioOptions(); + bindControls(); + applyScenario(currentScenario, { shouldFit: true }); + dataEditor.setDataMode('json'); + + window.patchmap = patchmap; + window.patchmapScenarios = scenarios; +}; + +const setupScenarioOptions = () => { + scenarios.forEach((scenario) => { + const option = document.createElement('option'); + option.value = scenario.id; + option.textContent = scenario.name; + elements.scenario.append(option); + }); + elements.scenario.value = currentScenario.id; +}; + +const bindControls = () => { + elements.scenario.addEventListener('change', (event) => { + const scenario = scenarios.find((item) => item.id === event.target.value); + if (scenario) { + applyScenario(scenario, { shouldFit: true }); + } + }); + + elements.draw.addEventListener('click', () => { + applyScenario(currentScenario, { shouldFit: true }); + }); + + elements.applyData.addEventListener('click', () => { + dataEditor.applyEditorData(); + }); + + elements.prettifyData.addEventListener('click', () => { + dataEditor.prettifyEditor(); + }); + + elements.resetData.addEventListener('click', () => { + applyScenario(currentScenario, { shouldFit: true }); + setLastAction('Reset to scenario'); + }); + + elements.dataModeJson.addEventListener('click', () => { + dataEditor.setDataMode('json'); + }); + + elements.dataModeForm.addEventListener('click', () => { + dataEditor.setDataMode('form'); + }); + + if (elements.dataTree) { + elements.dataTree.addEventListener('click', (event) => { + const actionButton = event.target.closest('[data-action]'); + if (actionButton) { + const action = actionButton.dataset.action; + if (action === 'add') { + dataEditor.openAddPopover( + actionButton.dataset.parentId ?? '__root__', + ); + } + if (action === 'delete') { + dataEditor.deleteNodeById(actionButton.dataset.nodeId); + } + return; + } + + const button = event.target.closest('[data-node-id]'); + if (!button) return; + dataEditor.selectNodeById(button.dataset.nodeId); + }); + } + + if (elements.dataAddParent) { + elements.dataAddParent.addEventListener('change', () => { + dataEditor.updateAddTypeOptions(); + }); + } + + if (elements.addCancel) { + elements.addCancel.addEventListener('click', () => { + dataEditor.closeAddPopover(); + }); + } + + if (elements.dataAddButton) { + elements.dataAddButton.addEventListener('click', () => { + dataEditor.handleAddElement(); + }); + } + + elements.randomize.addEventListener('click', () => { + randomizeMetrics(); + }); + + elements.shuffle.addEventListener('click', () => { + shuffleLinks(); + }); + + elements.focus.addEventListener('click', () => { + const targetId = dataEditor.getSelectedNodeId() ?? currentScenario.focusId; + if (targetId) { + patchmap.focus(targetId); + setLastAction(`Focus ${targetId}`); + } + }); + + elements.fit.addEventListener('click', () => { + patchmap.fit(); + setLastAction('Fit to content'); + }); + + elements.reset.addEventListener('click', () => { + patchmap.viewport.setZoom(1, true); + patchmap.viewport.moveCenter(0, 0); + setLastAction('Reset zoom'); + }); +}; + +const applyScenario = (scenario, { shouldFit = true } = {}) => { + currentScenario = scenario; + linkSetIndex = 0; + + const data = currentScenario.data(); + patchmap.draw(data); + if (shouldFit) { + patchmap.fit(); + } + updateSceneInfo(); + dataEditor.setCurrentData(data); + dataEditor.updateSelection(null); + dataEditor.clearEditorError(); + updateActionButtons(); + setLastAction(`Loaded ${currentScenario.name}`); +}; + +const updateSceneInfo = () => { + if (elements.sceneName) { + elements.sceneName.textContent = currentScenario.name; + } + if (elements.sceneTitle) { + elements.sceneTitle.textContent = currentScenario.name; + } + if (elements.sceneDescription) { + elements.sceneDescription.textContent = currentScenario.description; + } +}; + +const updateActionButtons = () => { + const dynamic = currentScenario.dynamic ?? {}; + elements.randomize.disabled = (dynamic.bars ?? []).length === 0; + elements.shuffle.disabled = + !dynamic.relationsId || (dynamic.linkSets ?? []).length < 2; +}; + +const randomizeMetrics = () => { + const bars = currentScenario.dynamic?.bars ?? []; + bars.forEach((bar) => { + if (bar.path) { + const targets = patchmap.selector(bar.path); + targets.forEach((target) => { + const value = randomInt(bar.min ?? 10, bar.max ?? 90); + patchmap.update({ + elements: target, + changes: { size: buildBarSize(bar, value) }, + }); + }); + return; + } + + if (!bar.id) return; + const value = randomInt(bar.min ?? 10, bar.max ?? 90); + patchmap.update({ + path: `$..[?(@.id=="${bar.id}")]`, + changes: { size: buildBarSize(bar, value) }, + }); + + if (bar.textId) { + const label = bar.label ?? 'Metric'; + const suffix = bar.unit ?? (label === 'Latency' ? 'ms' : '%'); + const text = `${label} ${value}${suffix}`; + patchmap.update({ + path: `$..[?(@.id=="${bar.textId}")]`, + changes: { text }, + }); + } + }); + + setLastAction('Randomized metrics'); +}; + +const shuffleLinks = () => { + const links = currentScenario.dynamic?.linkSets ?? []; + if (!currentScenario.dynamic?.relationsId || links.length === 0) { + return; + } + + linkSetIndex = (linkSetIndex + 1) % links.length; + + patchmap.update({ + path: `$..[?(@.id=="${currentScenario.dynamic.relationsId}")]`, + changes: { links: links[linkSetIndex] }, + mergeStrategy: 'replace', + }); + + setLastAction('Rerouted links'); +}; + +const buildBarSize = (bar, value) => { + const axis = bar.axis ?? 'width'; + if (axis === 'height') { + return { width: bar.width ?? '100%', height: `${value}%` }; + } + return { width: `${value}%`, height: bar.height ?? 10 }; +}; + +const randomInt = (min, max) => { + return Math.floor(Math.random() * (max - min + 1)) + min; +}; + +init(); diff --git a/playground/scenarios.js b/playground/scenarios.js new file mode 100644 index 00000000..2820c24a --- /dev/null +++ b/playground/scenarios.js @@ -0,0 +1,1026 @@ +const clone = (value) => { + if (typeof structuredClone === 'function') { + return structuredClone(value); + } + return JSON.parse(JSON.stringify(value)); +}; + +const plantOpsData = [ + { + type: 'group', + id: 'plant-1', + label: 'Plant Alpha', + attrs: { x: 80, y: 80 }, + children: [ + { + type: 'grid', + id: 'grid-panels', + label: 'Array A', + cells: [ + [1, 0, 1], + [1, 1, 1], + ], + gap: 4, + item: { + size: { width: 40, height: 80 }, + padding: { x: 6, y: 6 }, + components: [ + { + type: 'background', + id: 'panel-bg', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.dark', + radius: 4, + }, + tint: 'white', + }, + { + type: 'bar', + id: 'panel-metric', + source: { type: 'rect', fill: 'primary.default', radius: 4 }, + size: { width: '100%', height: '60%' }, + placement: 'bottom', + }, + { type: 'icon', source: 'loading', tint: 'black', size: 16 }, + ], + }, + attrs: { x: 0, y: 0 }, + }, + { + type: 'item', + id: 'icon-beacon', + label: 'Beacon', + size: 56, + components: [ + { + type: 'icon', + source: 'wifi', + size: 36, + tint: 'primary.default', + }, + ], + attrs: { x: 170, y: 18 }, + }, + { + type: 'item', + id: 'icon-inverter', + label: 'Inverter Icon', + size: 56, + components: [ + { + type: 'icon', + source: 'inverter', + size: 36, + tint: 'primary.default', + }, + ], + attrs: { x: 250, y: 18 }, + }, + { + type: 'grid', + id: 'grid-panels-lite', + label: 'Array B', + cells: [ + [1, 1, 1], + [1, 0, 1], + ], + gap: 4, + item: { + size: { width: 40, height: 80 }, + padding: { x: 6, y: 6 }, + components: [ + { + type: 'background', + id: 'panel-bg', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.dark', + radius: 4, + }, + tint: 'white', + }, + { + type: 'bar', + id: 'panel-metric', + source: { type: 'rect', fill: 'primary.default', radius: 4 }, + size: { width: '100%', height: '45%' }, + placement: 'bottom', + }, + ], + }, + attrs: { x: 0, y: 200 }, + }, + { + type: 'grid', + id: 'grid-panels-mini', + label: 'Array D', + cells: [ + [1, 1, 1], + [0, 1, 1], + ], + gap: 4, + item: { + size: { width: 40, height: 80 }, + padding: { x: 6, y: 6 }, + components: [ + { + type: 'background', + id: 'panel-bg', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.dark', + radius: 4, + }, + tint: 'white', + }, + { + type: 'bar', + id: 'panel-metric', + source: { type: 'rect', fill: 'primary.default', radius: 4 }, + size: { width: '100%', height: '35%' }, + placement: 'bottom', + }, + ], + }, + attrs: { x: 0, y: 380 }, + }, + { + type: 'grid', + id: 'grid-panels-tilt', + label: 'Array C', + cells: [ + [1, 1, 0], + [1, 1, 1], + ], + gap: 4, + item: { + size: { width: 40, height: 80 }, + padding: { x: 6, y: 6 }, + components: [ + { + type: 'background', + id: 'panel-bg', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.dark', + radius: 4, + }, + tint: 'white', + }, + { + type: 'bar', + id: 'panel-metric', + source: { type: 'rect', fill: 'primary.default', radius: 4 }, + size: { width: '100%', height: '70%' }, + placement: 'bottom', + }, + { type: 'icon', source: 'loading', tint: 'black', size: 16 }, + ], + }, + attrs: { x: 160, y: 220, angle: -12 }, + }, + { + type: 'grid', + id: 'grid-panels-tilt-2', + label: 'Array E', + cells: [ + [1, 1, 1], + [1, 1, 0], + ], + gap: 4, + item: { + size: { width: 40, height: 80 }, + padding: { x: 6, y: 6 }, + components: [ + { + type: 'background', + id: 'panel-bg', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.dark', + radius: 4, + }, + tint: 'white', + }, + { + type: 'bar', + id: 'panel-metric', + source: { type: 'rect', fill: 'primary.default', radius: 4 }, + size: { width: '100%', height: '55%' }, + placement: 'bottom', + }, + { type: 'icon', source: 'loading', tint: 'black', size: 16 }, + ], + }, + attrs: { x: 160, y: 440, angle: 10 }, + }, + { + type: 'item', + id: 'inverter-1', + label: 'Inverter 01', + size: { width: 210, height: 120 }, + padding: { x: 12, y: 10 }, + components: [ + { + type: 'background', + id: 'bg-inverter-1', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.default', + radius: 12, + }, + tint: 'gray.light', + }, + { + type: 'icon', + id: 'icon-inverter-1', + source: 'inverter', + size: 30, + placement: 'left-top', + margin: { left: 4, top: 4 }, + tint: 'primary.default', + }, + { + type: 'text', + id: 'text-inverter-1-title', + text: 'INV-01', + placement: 'left-top', + margin: { left: 44, top: 6 }, + style: { fontSize: 16, fill: 'black' }, + }, + { + type: 'text', + id: 'text-inverter-1-status', + text: 'Nominal', + placement: 'left-bottom', + margin: { left: 44, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'text', + id: 'text-inverter-1-metric', + text: 'Load 42%', + placement: 'right-bottom', + margin: { right: 12, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'bar', + id: 'bar-inverter-1', + source: { type: 'rect', fill: 'primary.default', radius: 6 }, + size: { width: '42%', height: 10 }, + placement: 'bottom', + margin: { left: 12, right: 12, bottom: 30 }, + }, + ], + attrs: { x: 330, y: 0 }, + }, + { + type: 'item', + id: 'inverter-2', + label: 'Inverter 02', + size: { width: 210, height: 120 }, + padding: { x: 12, y: 10 }, + components: [ + { + type: 'background', + id: 'bg-inverter-2', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.default', + radius: 12, + }, + tint: 'gray.light', + }, + { + type: 'icon', + id: 'icon-inverter-2', + source: 'inverter', + size: 30, + placement: 'left-top', + margin: { left: 4, top: 4 }, + tint: 'primary.default', + }, + { + type: 'text', + id: 'text-inverter-2-title', + text: 'INV-02', + placement: 'left-top', + margin: { left: 44, top: 6 }, + style: { fontSize: 16, fill: 'black' }, + }, + { + type: 'text', + id: 'text-inverter-2-status', + text: 'Nominal', + placement: 'left-bottom', + margin: { left: 44, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'text', + id: 'text-inverter-2-metric', + text: 'Load 58%', + placement: 'right-bottom', + margin: { right: 12, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'bar', + id: 'bar-inverter-2', + source: { type: 'rect', fill: 'primary.default', radius: 6 }, + size: { width: '58%', height: 10 }, + placement: 'bottom', + margin: { left: 12, right: 12, bottom: 30 }, + }, + ], + attrs: { x: 330, y: 140 }, + }, + { + type: 'item', + id: 'gateway-1', + label: 'Gateway', + size: { width: 220, height: 120 }, + padding: { x: 12, y: 10 }, + components: [ + { + type: 'background', + id: 'bg-gateway-1', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'gray.dark', + radius: 14, + }, + tint: 'gray.light', + }, + { + type: 'icon', + id: 'icon-gateway-1', + source: 'edge', + size: 32, + placement: 'left-top', + margin: { left: 4, top: 4 }, + tint: 'primary.default', + }, + { + type: 'text', + id: 'text-gateway-1-title', + text: 'Gateway', + placement: 'left-top', + margin: { left: 48, top: 6 }, + style: { fontSize: 16, fill: 'black' }, + }, + { + type: 'text', + id: 'text-gateway-1-status', + text: 'Online', + placement: 'left-bottom', + margin: { left: 48, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'text', + id: 'text-gateway-1-metric', + text: 'Throughput 73%', + placement: 'right-bottom', + margin: { right: 12, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'bar', + id: 'bar-gateway-1', + source: { type: 'rect', fill: 'primary.default', radius: 6 }, + size: { width: '73%', height: 10 }, + placement: 'bottom', + margin: { left: 12, right: 12, bottom: 30 }, + }, + ], + attrs: { x: 600, y: 70 }, + }, + ], + }, + { + type: 'item', + id: 'ops-console', + label: 'Ops Console', + size: { width: 260, height: 120 }, + padding: { x: 16, y: 12 }, + components: [ + { + type: 'background', + id: 'bg-ops-console', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.default', + }, + tint: 'gray.light', + }, + { + type: 'icon', + id: 'icon-ops-console', + source: 'object', + size: 34, + placement: 'left-top', + margin: { left: 6, top: 6 }, + tint: 'primary.default', + }, + { + type: 'text', + id: 'text-ops-title', + text: 'Ops Console', + placement: 'left-top', + margin: { left: 52, top: 8 }, + style: { fontSize: 16, fill: 'black' }, + }, + { + type: 'text', + id: 'text-ops-status', + text: 'Queue 4 tasks', + placement: 'left-bottom', + margin: { left: 52, bottom: 10 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'bar', + id: 'bar-ops-queue', + source: { type: 'rect', fill: 'primary.default', radius: 6 }, + size: { width: '40%', height: 10 }, + placement: 'bottom', + margin: { left: 14, right: 14, bottom: 34 }, + }, + ], + attrs: { x: 480, y: 560 }, + }, + { + type: 'relations', + id: 'rel-power', + links: [ + { source: 'grid-panels.0.0', target: 'grid-panels.1.0' }, + { source: 'grid-panels.1.0', target: 'grid-panels.1.1' }, + { source: 'grid-panels.1.1', target: 'grid-panels.1.2' }, + { source: 'grid-panels.0.2', target: 'grid-panels.1.2' }, + ], + style: { width: 3, color: 'primary.default' }, + }, +]; + +const meshData = [ + { + type: 'group', + id: 'mesh-1', + label: 'Edge Mesh', + attrs: { x: 80, y: 80 }, + children: [ + { + type: 'item', + id: 'node-a', + label: 'Node A', + size: { width: 180, height: 110 }, + padding: { x: 12, y: 10 }, + components: [ + { + type: 'background', + id: 'bg-node-a', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'gray.dark', + radius: 14, + }, + tint: 'gray.light', + }, + { + type: 'icon', + id: 'icon-node-a', + source: 'device', + size: 30, + placement: 'left-top', + margin: { left: 4, top: 4 }, + tint: 'primary.default', + }, + { + type: 'text', + id: 'text-node-a-title', + text: 'Node A', + placement: 'left-top', + margin: { left: 44, top: 6 }, + style: { fontSize: 16, fill: 'black' }, + }, + { + type: 'text', + id: 'text-node-a-status', + text: 'Stable', + placement: 'left-bottom', + margin: { left: 44, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'text', + id: 'text-node-a-metric', + text: 'Latency 18ms', + placement: 'right-bottom', + margin: { right: 12, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'bar', + id: 'bar-node-a', + source: { type: 'rect', fill: 'primary.default', radius: 6 }, + size: { width: '35%', height: 10 }, + placement: 'bottom', + margin: { left: 12, right: 12, bottom: 30 }, + }, + ], + attrs: { x: 0, y: 0 }, + }, + { + type: 'item', + id: 'node-b', + label: 'Node B', + size: { width: 180, height: 110 }, + padding: { x: 12, y: 10 }, + components: [ + { + type: 'background', + id: 'bg-node-b', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'gray.dark', + radius: 14, + }, + tint: 'gray.light', + }, + { + type: 'icon', + id: 'icon-node-b', + source: 'wifi', + size: 30, + placement: 'left-top', + margin: { left: 4, top: 4 }, + tint: 'primary.default', + }, + { + type: 'text', + id: 'text-node-b-title', + text: 'Node B', + placement: 'left-top', + margin: { left: 44, top: 6 }, + style: { fontSize: 16, fill: 'black' }, + }, + { + type: 'text', + id: 'text-node-b-status', + text: 'Stable', + placement: 'left-bottom', + margin: { left: 44, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'text', + id: 'text-node-b-metric', + text: 'Latency 22ms', + placement: 'right-bottom', + margin: { right: 12, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'bar', + id: 'bar-node-b', + source: { type: 'rect', fill: 'primary.default', radius: 6 }, + size: { width: '44%', height: 10 }, + placement: 'bottom', + margin: { left: 12, right: 12, bottom: 30 }, + }, + ], + attrs: { x: 240, y: 0 }, + }, + { + type: 'item', + id: 'node-c', + label: 'Node C', + size: { width: 180, height: 110 }, + padding: { x: 12, y: 10 }, + components: [ + { + type: 'background', + id: 'bg-node-c', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'gray.dark', + radius: 14, + }, + tint: 'gray.light', + }, + { + type: 'icon', + id: 'icon-node-c', + source: 'edge', + size: 30, + placement: 'left-top', + margin: { left: 4, top: 4 }, + tint: 'primary.default', + }, + { + type: 'text', + id: 'text-node-c-title', + text: 'Node C', + placement: 'left-top', + margin: { left: 44, top: 6 }, + style: { fontSize: 16, fill: 'black' }, + }, + { + type: 'text', + id: 'text-node-c-status', + text: 'Stable', + placement: 'left-bottom', + margin: { left: 44, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'text', + id: 'text-node-c-metric', + text: 'Latency 30ms', + placement: 'right-bottom', + margin: { right: 12, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'bar', + id: 'bar-node-c', + source: { type: 'rect', fill: 'primary.default', radius: 6 }, + size: { width: '52%', height: 10 }, + placement: 'bottom', + margin: { left: 12, right: 12, bottom: 30 }, + }, + ], + attrs: { x: 0, y: 150 }, + }, + { + type: 'item', + id: 'node-d', + label: 'Node D', + size: { width: 180, height: 110 }, + padding: { x: 12, y: 10 }, + components: [ + { + type: 'background', + id: 'bg-node-d', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'gray.dark', + radius: 14, + }, + tint: 'gray.light', + }, + { + type: 'icon', + id: 'icon-node-d', + source: 'combiner', + size: 30, + placement: 'left-top', + margin: { left: 4, top: 4 }, + tint: 'primary.default', + }, + { + type: 'text', + id: 'text-node-d-title', + text: 'Node D', + placement: 'left-top', + margin: { left: 44, top: 6 }, + style: { fontSize: 16, fill: 'black' }, + }, + { + type: 'text', + id: 'text-node-d-status', + text: 'Stable', + placement: 'left-bottom', + margin: { left: 44, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'text', + id: 'text-node-d-metric', + text: 'Latency 26ms', + placement: 'right-bottom', + margin: { right: 12, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'bar', + id: 'bar-node-d', + source: { type: 'rect', fill: 'primary.default', radius: 6 }, + size: { width: '38%', height: 10 }, + placement: 'bottom', + margin: { left: 12, right: 12, bottom: 30 }, + }, + ], + attrs: { x: 240, y: 150 }, + }, + ], + }, + { + type: 'relations', + id: 'rel-mesh', + links: [ + { source: 'node-a', target: 'node-b' }, + { source: 'node-b', target: 'node-d' }, + { source: 'node-a', target: 'node-c' }, + ], + style: { width: 2, color: 'gray.dark' }, + }, +]; + +export const scenarios = [ + { + id: 'plant-ops', + name: 'Example 1', + description: '', + data: () => clone(plantOpsData), + focusId: 'gateway-1', + dynamic: { + bars: [ + { + path: '$..[?(@.id=="panel-metric")]', + axis: 'height', + min: 10, + max: 100, + width: '100%', + }, + { + id: 'bar-inverter-1', + textId: 'text-inverter-1-metric', + label: 'Load', + min: 20, + max: 95, + height: 10, + }, + { + id: 'bar-inverter-2', + textId: 'text-inverter-2-metric', + label: 'Load', + min: 20, + max: 95, + height: 10, + }, + { + id: 'bar-gateway-1', + textId: 'text-gateway-1-metric', + label: 'Throughput', + min: 10, + max: 100, + height: 10, + }, + { + id: 'bar-ops-queue', + textId: 'text-ops-status', + label: 'Queue', + unit: ' tasks', + min: 10, + max: 85, + height: 10, + }, + ], + statuses: [ + { + backgroundId: 'bg-inverter-1', + iconId: 'icon-inverter-1', + textId: 'text-inverter-1-status', + ok: { + tint: 'gray.light', + icon: 'inverter', + iconTint: 'primary.default', + text: 'Nominal', + textTint: 'gray.dark', + }, + alert: { + tint: 'primary.accent', + icon: 'warning', + iconTint: 'primary.accent', + text: 'Overheat', + textTint: 'primary.accent', + }, + }, + { + backgroundId: 'bg-inverter-2', + iconId: 'icon-inverter-2', + textId: 'text-inverter-2-status', + ok: { + tint: 'gray.light', + icon: 'inverter', + iconTint: 'primary.default', + text: 'Nominal', + textTint: 'gray.dark', + }, + alert: { + tint: 'primary.accent', + icon: 'warning', + iconTint: 'primary.accent', + text: 'Overheat', + textTint: 'primary.accent', + }, + }, + { + backgroundId: 'bg-gateway-1', + iconId: 'icon-gateway-1', + textId: 'text-gateway-1-status', + ok: { + tint: 'gray.light', + icon: 'edge', + iconTint: 'primary.default', + text: 'Online', + textTint: 'gray.dark', + }, + alert: { + tint: 'primary.accent', + icon: 'warning', + iconTint: 'primary.accent', + text: 'Link Loss', + textTint: 'primary.accent', + }, + }, + ], + relationsId: 'rel-power', + linkSets: [ + [ + { source: 'grid-panels.0.0', target: 'grid-panels.1.0' }, + { source: 'grid-panels.1.0', target: 'grid-panels.1.1' }, + { source: 'grid-panels.1.1', target: 'grid-panels.1.2' }, + { source: 'grid-panels.0.2', target: 'grid-panels.1.2' }, + ], + [ + { source: 'grid-panels.0.0', target: 'grid-panels.1.0' }, + { source: 'grid-panels.1.0', target: 'grid-panels.1.1' }, + ], + [ + { source: 'grid-panels.0.2', target: 'grid-panels.1.2' }, + { source: 'grid-panels.1.2', target: 'grid-panels.1.1' }, + ], + ], + }, + }, + { + id: 'mesh-lab', + name: 'Example 2', + description: '', + data: () => clone(meshData), + focusId: 'node-b', + dynamic: { + bars: [ + { + id: 'bar-node-a', + textId: 'text-node-a-metric', + label: 'Latency', + min: 10, + max: 90, + height: 10, + }, + { + id: 'bar-node-b', + textId: 'text-node-b-metric', + label: 'Latency', + min: 10, + max: 90, + height: 10, + }, + { + id: 'bar-node-c', + textId: 'text-node-c-metric', + label: 'Latency', + min: 10, + max: 90, + height: 10, + }, + { + id: 'bar-node-d', + textId: 'text-node-d-metric', + label: 'Latency', + min: 10, + max: 90, + height: 10, + }, + ], + statuses: [ + { + backgroundId: 'bg-node-a', + iconId: 'icon-node-a', + textId: 'text-node-a-status', + ok: { + tint: 'gray.light', + icon: 'device', + iconTint: 'primary.default', + text: 'Stable', + textTint: 'gray.dark', + }, + alert: { + tint: 'primary.accent', + icon: 'warning', + iconTint: 'primary.accent', + text: 'Packet Loss', + textTint: 'primary.accent', + }, + }, + { + backgroundId: 'bg-node-b', + iconId: 'icon-node-b', + textId: 'text-node-b-status', + ok: { + tint: 'gray.light', + icon: 'wifi', + iconTint: 'primary.default', + text: 'Stable', + textTint: 'gray.dark', + }, + alert: { + tint: 'primary.accent', + icon: 'warning', + iconTint: 'primary.accent', + text: 'Congested', + textTint: 'primary.accent', + }, + }, + { + backgroundId: 'bg-node-c', + iconId: 'icon-node-c', + textId: 'text-node-c-status', + ok: { + tint: 'gray.light', + icon: 'edge', + iconTint: 'primary.default', + text: 'Stable', + textTint: 'gray.dark', + }, + alert: { + tint: 'primary.accent', + icon: 'warning', + iconTint: 'primary.accent', + text: 'Jitter', + textTint: 'primary.accent', + }, + }, + { + backgroundId: 'bg-node-d', + iconId: 'icon-node-d', + textId: 'text-node-d-status', + ok: { + tint: 'gray.light', + icon: 'combiner', + iconTint: 'primary.default', + text: 'Stable', + textTint: 'gray.dark', + }, + alert: { + tint: 'primary.accent', + icon: 'warning', + iconTint: 'primary.accent', + text: 'Overloaded', + textTint: 'primary.accent', + }, + }, + ], + relationsId: 'rel-mesh', + linkSets: [ + [ + { source: 'node-a', target: 'node-b' }, + { source: 'node-b', target: 'node-d' }, + { source: 'node-a', target: 'node-c' }, + ], + [ + { source: 'node-c', target: 'node-b' }, + { source: 'node-b', target: 'node-a' }, + { source: 'node-d', target: 'node-c' }, + ], + [ + { source: 'node-a', target: 'node-d' }, + { source: 'node-d', target: 'node-b' }, + { source: 'node-b', target: 'node-c' }, + ], + ], + }, + }, +]; diff --git a/playground/style.css b/playground/style.css new file mode 100644 index 00000000..5fbdd197 --- /dev/null +++ b/playground/style.css @@ -0,0 +1,757 @@ +:root { + color-scheme: light; + --bg: #f2f3f5; + --ink: #1f2428; + --muted: #6b737b; + --accent: #1f2933; + --panel: #ffffff; + --panel-border: rgba(31, 36, 40, 0.16); + --mono: 'IBM Plex Mono', monospace; + --radius-lg: 22px; + --radius-md: 14px; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + min-height: 100%; + font-family: 'Space Grotesk', sans-serif; + color: var(--ink); + background: var(--bg); + overflow: hidden; +} + +.app { + padding: 0; + display: flex; + flex-direction: column; + gap: 0; + height: 100%; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 4px 8px; + background: transparent; + border-bottom: 1px solid var(--panel-border); + border-radius: 0; + box-shadow: none; + backdrop-filter: none; + animation: float-in 0.6s ease-out; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; +} + +.logo { + width: 32px; + height: 32px; + border-radius: 10px; + background: #e6e9ec; + display: grid; + place-items: center; + font-weight: 600; + letter-spacing: 0.04em; +} + +.title { + font-size: 12px; + font-weight: 500; + letter-spacing: 0.04em; +} + +.content { + display: grid; + grid-template-columns: minmax(220px, 3fr) minmax(0, 7fr); + grid-template-rows: minmax(0, 1fr); + gap: 0; + flex: 1; + min-height: 0; +} + +.data-panel { + display: flex; + flex-direction: column; + gap: 6px; + padding: 4px 6px; + background: transparent; + border-radius: 0; + border-right: 1px solid var(--panel-border); + box-shadow: none; + backdrop-filter: none; + animation: float-in 0.75s ease-out; + min-height: 0; + height: 100%; + overflow: hidden; +} + +.panel-section { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.data-panel .field { + flex: 1; +} + +select { + padding: 4px 6px; + border-radius: 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-family: inherit; + font-size: 11px; +} + +.btn-row { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.data-controls { + align-items: center; +} + +.data-controls select { + min-width: 120px; +} + +.data-tabs { + display: flex; + gap: 6px; + align-items: center; + border-bottom: 1px solid var(--panel-border); + padding-bottom: 2px; +} + +.data-tab { + padding: 2px 0; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + font-size: 9px; + letter-spacing: 0.1em; + color: var(--muted); + cursor: pointer; +} + +.data-tab.is-active { + color: var(--ink); + border-bottom-color: var(--ink); + font-weight: 600; +} + +.data-tab:focus-visible { + outline: 1px solid var(--panel-border); + outline-offset: 2px; +} + +.data-view { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.data-view[hidden] { + display: none !important; +} + +.data-form { + display: grid; + grid-template-rows: minmax(0, 1fr) minmax(120px, 35vh); + gap: 2px; + min-height: 0; + overflow: hidden; + position: relative; + height: 100%; +} + +#data-json { + flex: 1; + position: relative; +} + +#data-json textarea { + flex: 1; + min-height: 0; + max-height: none; + padding-bottom: 26px; +} + +.data-tree { + overflow-y: auto; + overflow-x: hidden; + min-height: 0; + max-height: 100%; + padding: 0; + border-bottom: 1px solid var(--panel-border); +} + +.tree-row { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 2px 6px; + min-width: 0; +} + +.tree-row:hover { + background: #e9ecef; +} + +.tree-label { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + border: 0; + background: transparent; + color: var(--ink); + font-size: 10px; + text-align: left; + cursor: pointer; + padding: 0; + min-width: 0; +} + +.tree-label.is-active { + font-weight: 600; +} + +.tree-id { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tree-type { + font-size: 9px; + color: var(--muted); +} + +.tree-actions { + display: flex; + gap: 4px; +} + +.tree-action { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 12px; + line-height: 1; + display: grid; + place-items: center; + padding: 0; + cursor: pointer; +} + +.tree-action:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.tree-root-label { + font-size: 9px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; + padding-left: 6px; +} + +.data-inspector { + display: flex; + flex-direction: column; + gap: 6px; + overflow: hidden; + min-height: 0; + overflow-x: hidden; +} + +.data-popover { + position: absolute; + top: 4px; + left: 6px; + right: 6px; + padding: 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + display: grid; + gap: 4px; + z-index: 10; +} + +.data-popover[hidden] { + display: none; +} + +.data-popover-actions { + display: flex; + gap: 4px; +} + +.inspector-content { + display: grid; + gap: 6px; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; + padding-right: 2px; +} + +.inspector-empty { + font-size: 10px; + color: var(--muted); + padding: 4px 2px; +} + +.inspector-field { + display: grid; + grid-template-columns: 56px minmax(0, 1fr); + gap: 6px; + align-items: center; + min-width: 0; +} + +.inspector-field--inline { + align-items: start; +} + +.inspector-inline { + display: flex; + gap: 6px; + min-width: 0; + flex-wrap: wrap; +} + +.inspector-inline-item { + display: flex; + align-items: center; + gap: 4px; +} + +.inspector-inline-label { + font-size: 9px; + color: var(--muted); +} + +.inspector-label { + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.inspector-input { + width: 100%; + padding: 4px 6px; + border-radius: 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 10px; + color: var(--ink); + min-width: 0; +} + +.inspector-input--compact { + width: 64px; + padding: 2px 6px; +} + +.color-select { + width: 110px; +} + +.color-input { + width: 120px; +} + +.inspector-input[readonly] { + background: #f3f4f6; + color: var(--muted); +} + +.relations-editor { + display: grid; + gap: 6px; +} + +.relations-title { + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.relations-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + gap: 4px; + align-items: center; +} + +.relations-select { + padding: 4px 6px; + border-radius: 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 10px; + color: var(--ink); + min-width: 0; +} + +.relations-add { + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 10px; + cursor: pointer; +} + +.relations-list { + display: grid; + gap: 4px; +} + +.relations-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.relations-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10px; +} + +.relations-delete { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 12px; + line-height: 1; + display: grid; + place-items: center; + padding: 0; + cursor: pointer; +} + +.relations-empty { + font-size: 10px; + color: var(--muted); +} + +.grid-editor { + display: grid; + gap: 6px; +} + +.grid-title { + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.grid-controls { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.grid-control { + padding: 2px 6px; + border-radius: 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 9px; + cursor: pointer; +} + +.grid-cells { + display: grid; + gap: 2px; + grid-template-columns: repeat(var(--grid-cols), 16px); +} + +.grid-cell { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid var(--panel-border); + background: transparent; + cursor: pointer; + padding: 0; +} + +.grid-cell.is-active { + background: var(--ink); +} + +.btn { + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 10px; + font-weight: 500; + text-align: center; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease, border 0.2s ease; + white-space: nowrap; +} + +.btn:hover { + transform: none; + border-color: rgba(31, 36, 40, 0.35); + box-shadow: none; +} + +.btn.is-active { + border-color: rgba(31, 36, 40, 0.45); + font-weight: 600; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +textarea { + min-height: 200px; + resize: vertical; + border-radius: 6px; + border: 1px solid var(--panel-border); + padding: 6px 8px; + font-family: var(--mono); + font-size: 10px; + line-height: 1.35; + background: var(--panel); + color: var(--ink); +} + +.data-panel textarea { + flex: 1; +} + +.editor-error { + position: absolute; + left: 6px; + right: 6px; + bottom: 6px; + padding: 4px 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 10px; + color: #b42318; + pointer-events: none; +} + +.editor-error:empty { + display: none; +} + +.stage { + display: flex; + flex-direction: column; + gap: 0; + padding: 0; + border-radius: 0; + border: 0; + background: transparent; + box-shadow: none; + backdrop-filter: none; + min-height: 0; + height: 100%; + animation: float-in 0.9s ease-out; +} + +.stage-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + padding: 4px 6px; + border-bottom: 1px solid var(--panel-border); +} + +.stage-header { + display: none; +} + +.stage-title { + font-size: 18px; + font-weight: 600; +} + +.stage-description { + color: var(--muted); + font-size: 12px; +} + +.stage-canvas { + position: relative; + flex: 1; + min-height: 0; + border-radius: 0; + overflow: hidden; + background: #f7f7f8; + border: 0; +} + +#patchmap-root { + position: absolute; + inset: 0; +} + +#patchmap-root > div { + width: 100%; + height: 100%; +} + +.stage-hint { + position: absolute; + right: 12px; + bottom: 12px; + padding: 4px 8px; + border-radius: 999px; + background: #ffffff; + border: 1px solid var(--panel-border); + font-size: 10px; + color: var(--muted); +} + +.statusbar { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 8px; + background: transparent; + border-top: 1px solid var(--panel-border); + border-radius: 0; + box-shadow: none; + backdrop-filter: none; + animation: float-in 1s ease-out; + overflow: hidden; +} + +.status-line { + display: flex; + align-items: center; + gap: 8px; + font-size: 10px; + white-space: nowrap; + width: 100%; +} + +.status-label { + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted); +} + +.status-divider { + color: rgba(31, 45, 61, 0.35); +} + +.status-value { + font-family: var(--mono); + font-size: 11px; + color: var(--ink); +} + +.status-value--grow { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +@keyframes float-in { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 980px) { + .content { + grid-template-columns: 1fr; + } + + .data-panel { + border-right: 0; + border-bottom: 1px solid var(--panel-border); + } + + .stage { + order: 1; + } +} + +@media (max-width: 640px) { + .app { + padding: 0; + } + + .topbar { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .stage { + padding: 0; + } + + .stage-canvas { + min-height: 400px; + } +} diff --git a/playground/vite.config.js b/playground/vite.config.js new file mode 100644 index 00000000..fc9780d7 --- /dev/null +++ b/playground/vite.config.js @@ -0,0 +1,21 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite'; + +const playgroundRoot = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(playgroundRoot, '..'); + +export default defineConfig({ + root: playgroundRoot, + server: { + port: 5173, + fs: { + allow: [repoRoot], + }, + }, + resolve: { + alias: { + '@patchmap': resolve(repoRoot, 'src/patch-map.ts'), + }, + }, +}); From eb1e3a99ef60097c02aa4e7c78fbbd5a01451779 Mon Sep 17 00:00:00 2001 From: perhapsspy Date: Wed, 31 Dec 2025 14:47:28 +0900 Subject: [PATCH 4/8] feat: refine playground controls and selection --- playground/index.html | 31 +++++- playground/main.js | 246 +++++++++++++++++++++++++++++++++++++++++- playground/style.css | 29 +++++ 3 files changed, 303 insertions(+), 3 deletions(-) diff --git a/playground/index.html b/playground/index.html index e95574cb..e7a38c17 100644 --- a/playground/index.html +++ b/playground/index.html @@ -110,11 +110,40 @@ + + +
+ Rotate + + + + + +
- Drag to pan, scroll to zoom, click to select. + Middle-drag or Space+drag to pan, drag to select, Cmd/Ctrl-click + to add, right-click to clear.
diff --git a/playground/main.js b/playground/main.js index ad4c81a8..2bd5375b 100644 --- a/playground/main.js +++ b/playground/main.js @@ -32,6 +32,13 @@ const elements = { focus: $('#focus-target'), fit: $('#fit-view'), reset: $('#reset-view'), + rotateRange: $('#rotate-angle'), + rotateInput: $('#rotate-input'), + rotateLeft: $('#rotate-left'), + rotateRight: $('#rotate-right'), + rotateReset: $('#rotate-reset'), + flipX: $('#flip-x'), + flipY: $('#flip-y'), sceneName: $('#scene-name'), sceneTitle: $('#scene-title'), sceneDescription: $('#scene-description'), @@ -42,6 +49,8 @@ const elements = { const patchmap = new Patchmap(); let currentScenario = scenarios[0]; let linkSetIndex = 0; +let isSpaceDown = false; +let ignoreClickAfterDrag = false; const setLastAction = (text) => { elements.lastAction.textContent = text; @@ -49,18 +58,122 @@ const setLastAction = (text) => { const dataEditor = createDataEditor({ patchmap, elements, setLastAction }); +const getSelectionList = () => { + if (!patchmap.transformer) return []; + return Array.isArray(patchmap.transformer.elements) + ? patchmap.transformer.elements + : [patchmap.transformer.elements].filter(Boolean); +}; + +const setSelectionList = (list) => { + if (!patchmap.transformer) return; + patchmap.transformer.elements = list; +}; + +const clearSelection = () => { + dataEditor.updateSelection(null); + elements.selectedId.textContent = 'None'; +}; + +const applySelection = (list) => { + const next = list.filter(Boolean); + if (next.length === 0) { + clearSelection(); + return; + } + if (next.length === 1) { + dataEditor.updateSelection(next[0]); + return; + } + dataEditor.updateSelection(null); + setSelectionList(next); + elements.selectedId.textContent = `Multiple (${next.length})`; +}; + +const toggleSelection = (target) => { + if (!target) return; + const current = getSelectionList(); + const exists = current.some((item) => item.id === target.id); + const next = exists + ? current.filter((item) => item.id !== target.id) + : [...current, target]; + applySelection(next); +}; + +const updateSelectionDraggable = (enabled) => { + const state = patchmap.stateManager?.getCurrentState?.(); + if (state?.config) { + state.config.draggable = enabled; + } +}; + +const setDragButtons = (buttons) => { + const dragPlugin = patchmap.viewport?.plugins?.get('drag'); + if (dragPlugin?.mouseButtons) { + dragPlugin.mouseButtons(buttons); + } +}; + const init = async () => { - await patchmap.init(elements.stage); + await patchmap.init(elements.stage, { + viewport: { + disableOnContextMenu: true, + plugins: { + drag: { mouseButtons: 'middle' }, + }, + }, + }); patchmap.transformer = new Transformer(); patchmap.stateManager.setState('selection', { - onClick: (target) => dataEditor.updateSelection(target), + onDown: () => { + if (ignoreClickAfterDrag) { + ignoreClickAfterDrag = false; + } + }, + onDragStart: () => { + ignoreClickAfterDrag = true; + }, + onClick: (target, event) => { + if (ignoreClickAfterDrag) { + ignoreClickAfterDrag = false; + return; + } + if (isSpaceDown) return; + const mod = event?.metaKey || event?.ctrlKey; + if (mod) { + toggleSelection(target); + return; + } + dataEditor.updateSelection(target); + }, + draggable: true, + onDragEnd: (selected, event) => { + ignoreClickAfterDrag = true; + if (!selected || selected.length === 0 || isSpaceDown) { + return; + } + const mod = event?.metaKey || event?.ctrlKey; + if (mod) { + const current = getSelectionList(); + const ids = new Set(current.map((item) => item.id)); + const merged = [ + ...current, + ...selected.filter((item) => !ids.has(item.id)), + ]; + applySelection(merged); + return; + } + applySelection(selected); + }, }); setupScenarioOptions(); bindControls(); applyScenario(currentScenario, { shouldFit: true }); dataEditor.setDataMode('json'); + syncRotationUI(); + syncFlipUI(); window.patchmap = patchmap; window.patchmapScenarios = scenarios; @@ -171,10 +284,106 @@ const bindControls = () => { }); elements.reset.addEventListener('click', () => { + patchmap.resetRotation(); + patchmap.resetFlip(); patchmap.viewport.setZoom(1, true); patchmap.viewport.moveCenter(0, 0); + syncRotationUI(0); + syncFlipUI(); setLastAction('Reset zoom'); }); + + if (elements.rotateRange) { + elements.rotateRange.addEventListener('input', (event) => { + applyRotation(event.target.value, { updateAction: false }); + }); + elements.rotateRange.addEventListener('change', (event) => { + applyRotation(event.target.value); + }); + } + + if (elements.rotateInput) { + elements.rotateInput.addEventListener('change', (event) => { + applyRotation(event.target.value); + }); + elements.rotateInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + applyRotation(event.target.value); + } + }); + } + + if (elements.rotateLeft) { + elements.rotateLeft.addEventListener('click', () => { + patchmap.rotateBy(-15); + syncRotationUI(); + setLastAction(`Rotate ${patchmap.getRotation()}Β°`); + }); + } + + if (elements.rotateRight) { + elements.rotateRight.addEventListener('click', () => { + patchmap.rotateBy(15); + syncRotationUI(); + setLastAction(`Rotate ${patchmap.getRotation()}Β°`); + }); + } + + if (elements.rotateReset) { + elements.rotateReset.addEventListener('click', () => { + patchmap.resetRotation(); + syncRotationUI(0); + setLastAction('Rotate 0Β°'); + }); + } + + if (elements.flipX) { + elements.flipX.addEventListener('click', () => { + patchmap.toggleFlipX(); + syncFlipUI(); + setLastAction(patchmap.getFlip().x ? 'Flip X on' : 'Flip X off'); + }); + } + + if (elements.flipY) { + elements.flipY.addEventListener('click', () => { + patchmap.toggleFlipY(); + syncFlipUI(); + setLastAction(patchmap.getFlip().y ? 'Flip Y on' : 'Flip Y off'); + }); + } + + if (elements.stage) { + elements.stage.addEventListener('contextmenu', (event) => { + event.preventDefault(); + clearSelection(); + }); + } + + window.addEventListener('keydown', (event) => { + if (event.code !== 'Space') return; + const target = event.target; + if ( + target?.tagName === 'INPUT' || + target?.tagName === 'TEXTAREA' || + target?.isContentEditable + ) { + return; + } + if (isSpaceDown) return; + event.preventDefault(); + isSpaceDown = true; + updateSelectionDraggable(false); + setDragButtons('left middle'); + }); + + window.addEventListener('keyup', (event) => { + if (event.code !== 'Space') return; + if (!isSpaceDown) return; + isSpaceDown = false; + updateSelectionDraggable(true); + setDragButtons('middle'); + }); }; const applyScenario = (scenario, { shouldFit = true } = {}) => { @@ -191,6 +400,8 @@ const applyScenario = (scenario, { shouldFit = true } = {}) => { dataEditor.updateSelection(null); dataEditor.clearEditorError(); updateActionButtons(); + syncRotationUI(); + syncFlipUI(); setLastAction(`Loaded ${currentScenario.name}`); }; @@ -274,6 +485,37 @@ const buildBarSize = (bar, value) => { return { width: `${value}%`, height: bar.height ?? 10 }; }; +const clampRotation = (value) => { + const parsed = Number(value); + if (Number.isNaN(parsed)) return 0; + return Math.min(180, Math.max(-180, parsed)); +}; + +const syncRotationUI = (angle = patchmap.getRotation()) => { + const nextAngle = clampRotation(angle); + if (elements.rotateRange) { + elements.rotateRange.value = String(nextAngle); + } + if (elements.rotateInput) { + elements.rotateInput.value = String(nextAngle); + } +}; + +const applyRotation = (value, { updateAction = true } = {}) => { + const nextAngle = clampRotation(value); + patchmap.setRotation(nextAngle); + syncRotationUI(nextAngle); + if (updateAction) { + setLastAction(`Rotate ${nextAngle}Β°`); + } +}; + +const syncFlipUI = () => { + const { x, y } = patchmap.getFlip(); + elements.flipX?.classList.toggle('is-active', x); + elements.flipY?.classList.toggle('is-active', y); +}; + const randomInt = (min, max) => { return Math.floor(Math.random() * (max - min + 1)) + min; }; diff --git a/playground/style.css b/playground/style.css index 5fbdd197..cd96e106 100644 --- a/playground/style.css +++ b/playground/style.css @@ -618,6 +618,35 @@ textarea { border-bottom: 1px solid var(--panel-border); } +.rotate-controls { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.rotate-label { + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.rotate-range { + width: 120px; + accent-color: var(--ink); +} + +.rotate-input { + width: 56px; + padding: 2px 6px; + border-radius: 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 10px; + color: var(--ink); +} + .stage-header { display: none; } From f730f88fb01bc34b18f30edcaca773ade921557c Mon Sep 17 00:00:00 2001 From: perhapsspy Date: Wed, 31 Dec 2025 15:25:05 +0900 Subject: [PATCH 5/8] refactor(playground): split data editor modules --- playground/data-editor.js | 1507 ++--------------- playground/data-editor/add-popover.js | 310 ++++ playground/data-editor/constants.js | 17 + playground/data-editor/inspector-fields.js | 385 +++++ playground/data-editor/inspector-grid.js | 127 ++ playground/data-editor/inspector-relations.js | 150 ++ playground/data-editor/inspector.js | 273 +++ playground/data-editor/tree.js | 116 ++ playground/data-editor/utils.js | 105 ++ 9 files changed, 1584 insertions(+), 1406 deletions(-) create mode 100644 playground/data-editor/add-popover.js create mode 100644 playground/data-editor/constants.js create mode 100644 playground/data-editor/inspector-fields.js create mode 100644 playground/data-editor/inspector-grid.js create mode 100644 playground/data-editor/inspector-relations.js create mode 100644 playground/data-editor/inspector.js create mode 100644 playground/data-editor/tree.js create mode 100644 playground/data-editor/utils.js diff --git a/playground/data-editor.js b/playground/data-editor.js index c1f6af83..98259a39 100644 --- a/playground/data-editor.js +++ b/playground/data-editor.js @@ -1,25 +1,53 @@ -import { componentSchema } from '../src/display/data-schema/component-schema.js'; -import { elementTypes } from '../src/display/data-schema/element-schema.js'; - -const componentTypes = new Set(['background', 'bar', 'icon', 'text']); -const colorPresets = [ - { value: 'primary.default', label: 'primary.default' }, - { value: 'primary.dark', label: 'primary.dark' }, - { value: 'primary.accent', label: 'primary.accent' }, - { value: 'gray.dark', label: 'gray.dark' }, - { value: 'gray.light', label: 'gray.light' }, - { value: 'black', label: 'black' }, - { value: 'white', label: 'white' }, - { value: '#111111', label: '#111111' }, - { value: '#ffffff', label: '#ffffff' }, -]; -const colorPresetValues = new Set(colorPresets.map((preset) => preset.value)); +import { createAddPopover } from './data-editor/add-popover.js'; +import { + colorPresets, + colorPresetValues, + componentTypes, +} from './data-editor/constants.js'; +import { createInspector } from './data-editor/inspector.js'; +import { createTree } from './data-editor/tree.js'; +import { + buildChangesFromPath, + coerceValue, + formatError, + formatPxPercent, + resolveNodeSchema, + setNodeValue, + validateNode, +} from './data-editor/utils.js'; export const createDataEditor = ({ patchmap, elements, setLastAction }) => { - let currentData = []; - let nodeIndex = new Map(); - let treeItemById = new Map(); - let selectedNodeId = null; + const state = { + currentData: [], + nodeIndex: new Map(), + treeItemById: new Map(), + selectedNodeId: null, + }; + + let tree; + let inspector; + let addPopover; + + const setEditorValue = (data) => { + elements.editor.value = JSON.stringify(data, null, 2); + }; + + const parseEditorValue = () => { + try { + return JSON.parse(elements.editor.value); + } catch (error) { + setEditorError(`JSON parse error: ${error.message}`); + return null; + } + }; + + const setEditorError = (message) => { + elements.editorError.textContent = message; + }; + + const clearEditorError = () => { + elements.editorError.textContent = ''; + }; const setDataMode = (mode) => { const isJson = mode === 'json'; @@ -30,36 +58,36 @@ export const createDataEditor = ({ patchmap, elements, setLastAction }) => { elements.dataModeJson.setAttribute('aria-selected', String(isJson)); elements.dataModeForm.setAttribute('aria-selected', String(!isJson)); if (isJson) { - closeAddPopover(); + addPopover.closeAddPopover(); } if (!isJson) { - renderTree(); - renderInspector(selectedNodeId); + tree.renderTree(); + inspector.renderInspector(state.selectedNodeId); } }; const setCurrentData = (data, { updateEditor = true } = {}) => { - currentData = data; + state.currentData = data; if (updateEditor) { setEditorValue(data); } - renderTree(); - renderInspector(selectedNodeId); + tree.renderTree(); + inspector.renderInspector(state.selectedNodeId); }; const updateSelection = (target, fallbackId = null) => { const id = target?.id ?? fallbackId ?? null; - selectedNodeId = id; + state.selectedNodeId = id; elements.selectedId.textContent = id ?? 'None'; if (patchmap.transformer) { patchmap.transformer.elements = target ? [target] : []; } - highlightTree(id); - renderInspector(id); - updateAddParentOptions(); + tree.highlightTree(id); + inspector.renderInspector(id); + addPopover.updateAddParentOptions(); }; - const getSelectedNodeId = () => selectedNodeId; + const getSelectedNodeId = () => state.selectedNodeId; const applyEditorData = () => { const data = parseEditorValue(); @@ -95,1376 +123,43 @@ export const createDataEditor = ({ patchmap, elements, setLastAction }) => { updateSelection(target, id); }; - const renderTree = () => { - if (!elements.dataTree) return; - elements.dataTree.replaceChildren(); - nodeIndex = new Map(); - treeItemById = new Map(); - if (!Array.isArray(currentData)) return; - - const fragment = document.createDocumentFragment(); - const walk = (nodes, depth = 0, parentId = null) => { - nodes.forEach((node) => { - if (!node || !node.id) return; - if (nodeIndex.has(node.id)) { - return; - } - nodeIndex.set(node.id, { node, parentId, depth }); - - const row = document.createElement('div'); - row.className = 'tree-row'; - row.style.paddingLeft = `${6 + depth * 12}px`; - - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'tree-label'; - button.dataset.nodeId = node.id; - - const label = document.createElement('span'); - label.className = 'tree-id'; - label.textContent = node.id; - const type = document.createElement('span'); - type.className = 'tree-type'; - type.textContent = node.type ?? 'node'; - - button.append(label, type); - if (node.id === selectedNodeId) { - button.classList.add('is-active'); - } - - const actions = document.createElement('div'); - actions.className = 'tree-actions'; - - const addButton = document.createElement('button'); - addButton.type = 'button'; - addButton.className = 'tree-action'; - addButton.dataset.action = 'add'; - addButton.dataset.parentId = - node.type === 'grid' || node.type === 'item' - ? node.id - : (parentId ?? '__root__'); - addButton.textContent = '+'; - - const deleteButton = document.createElement('button'); - deleteButton.type = 'button'; - deleteButton.className = 'tree-action'; - deleteButton.dataset.action = 'delete'; - deleteButton.dataset.nodeId = node.id; - deleteButton.textContent = 'βˆ’'; - - actions.append(addButton, deleteButton); - row.append(button, actions); - - treeItemById.set(node.id, button); - fragment.append(row); - - if (Array.isArray(node.children)) { - walk(node.children, depth + 1, node.id); - } - if (node.type === 'item' && Array.isArray(node.components)) { - walk(node.components, depth + 1, node.id); - } - if (node.type === 'grid' && Array.isArray(node.item?.components)) { - walk(node.item.components, depth + 1, node.id); - } - }); - }; - - walk(currentData); - fragment.append(buildRootAddRow()); - elements.dataTree.append(fragment); - updateAddParentOptions(); - }; - - const highlightTree = (id) => { - treeItemById.forEach((item) => item.classList.remove('is-active')); - if (!id) return; - const target = treeItemById.get(id); - if (target) { - target.classList.add('is-active'); - } - }; - - const renderInspector = (id) => { - const container = elements.inspectorContent ?? elements.dataInspector; - if (!container) return; - container.replaceChildren(); - - if (!id) { - container.append(buildInspectorEmpty('Select a node')); - return; - } - - const entry = nodeIndex.get(id); - if (!entry) { - container.append(buildInspectorEmpty('No editable data')); - return; - } - - const { node } = entry; - const resolved = resolveNodeSchema(node); - const data = resolved.parsed ?? node; - const buildInput = (value, options = {}) => { - let input; - if (options.options) { - input = document.createElement('select'); - options.options.forEach((option) => { - const item = document.createElement('option'); - item.value = option.value; - item.textContent = option.label; - input.append(item); - }); - if (value != null) { - input.value = String(value); - } - } else if (options.type === 'boolean') { - input = document.createElement('input'); - input.type = 'checkbox'; - input.checked = Boolean(value); - } else { - input = document.createElement('input'); - input.type = options.type ?? 'text'; - input.value = value ?? ''; - } - - input.className = 'inspector-input'; - if (options.compact) { - input.classList.add('inspector-input--compact'); - } - - if (options.readOnly) { - input.readOnly = true; - } else if (options.path) { - input.addEventListener('change', (event) => { - const nextValue = - options.type === 'boolean' - ? event.target.checked - : event.target.value; - handleInspectorChange( - id, - node, - options.path, - nextValue, - options.type, - options.originalValue, - ); - }); - } - return input; - }; - - const addField = (label, value, options = {}) => { - const field = document.createElement('div'); - field.className = 'inspector-field'; - - const labelEl = document.createElement('div'); - labelEl.className = 'inspector-label'; - labelEl.textContent = label; - - const input = buildInput(value, options); - field.append(labelEl, input); - container.append(field); - }; - - const addInlineFields = (label, fields) => { - const field = document.createElement('div'); - field.className = 'inspector-field inspector-field--inline'; - - const labelEl = document.createElement('div'); - labelEl.className = 'inspector-label'; - labelEl.textContent = label; - - const group = document.createElement('div'); - group.className = 'inspector-inline'; - - fields.forEach((item) => { - const wrap = document.createElement('div'); - wrap.className = 'inspector-inline-item'; - - const tag = document.createElement('div'); - tag.className = 'inspector-inline-label'; - tag.textContent = item.short; - - const input = buildInput(item.value, { - ...item.options, - path: item.path, - type: item.type, - originalValue: item.originalValue, - compact: true, - }); - - wrap.append(tag, input); - group.append(wrap); - }); - - field.append(labelEl, group); - container.append(field); - }; - - const addColorField = (label, value, path) => { - const field = document.createElement('div'); - field.className = 'inspector-field inspector-field--inline'; - - const labelEl = document.createElement('div'); - labelEl.className = 'inspector-label'; - labelEl.textContent = label; - - const group = document.createElement('div'); - group.className = 'inspector-inline'; - - const select = document.createElement('select'); - select.className = - 'inspector-input inspector-input--compact color-select'; - - const customOption = document.createElement('option'); - customOption.value = '__custom__'; - customOption.textContent = 'Custom'; - select.append(customOption); - - colorPresets.forEach((preset) => { - const option = document.createElement('option'); - option.value = preset.value; - option.textContent = preset.label; - select.append(option); - }); - - const stringValue = value == null ? '' : String(value); - select.value = colorPresetValues.has(stringValue) - ? stringValue - : '__custom__'; - - const input = document.createElement('input'); - input.className = 'inspector-input color-input'; - input.type = 'text'; - input.value = stringValue; - input.placeholder = '#ffffff'; - - select.addEventListener('change', () => { - if (select.value === '__custom__') { - input.focus(); - return; - } - input.value = select.value; - handleInspectorChange(id, node, path, select.value, 'text', value); - }); - - input.addEventListener('change', (event) => { - const nextValue = event.target.value; - select.value = colorPresetValues.has(nextValue) - ? nextValue - : '__custom__'; - handleInspectorChange(id, node, path, nextValue, 'text', value); - }); - - group.append(select, input); - field.append(labelEl, group); - container.append(field); - }; - - addField('Id', data.id, { readOnly: true }); - addField('Type', data.type ?? '', { readOnly: true }); - addField('Label', data.label ?? '', { path: 'label', type: 'text' }); - addField('Show', data.show ?? true, { path: 'show', type: 'boolean' }); - - if (typeof data.text === 'string') { - addField('Text', data.text, { path: 'text', type: 'text' }); - } - - if (typeof data.source === 'string') { - addField('Source', data.source, { path: 'source', type: 'text' }); - } - - if (data.tint != null) { - addColorField('Tint', data.tint, 'tint'); - } - - if (data.type !== 'relations' && resolved.kind === 'element') { - addField('X', data.attrs?.x ?? '', { - path: 'attrs.x', - type: 'number', - originalValue: data.attrs?.x, - }); - addField('Y', data.attrs?.y ?? '', { - path: 'attrs.y', - type: 'number', - originalValue: data.attrs?.y, - }); - if (data.attrs?.angle != null) { - addField('Angle', data.attrs.angle ?? '', { - path: 'attrs.angle', - type: 'number', - originalValue: data.attrs?.angle, - }); - } - if (data.attrs?.rotation != null) { - addField('Rot', data.attrs.rotation ?? '', { - path: 'attrs.rotation', - type: 'number', - originalValue: data.attrs?.rotation, - }); - } - } - - if (data.size != null) { - if (resolved.kind === 'component') { - const widthValue = formatPxPercent(data.size?.width); - const heightValue = formatPxPercent(data.size?.height); - addInlineFields('Size', [ - { - short: 'W', - value: widthValue ?? '', - path: 'size.width', - type: 'text', - }, - { - short: 'H', - value: heightValue ?? '', - path: 'size.height', - type: 'text', - }, - ]); - } else { - addInlineFields('Size', [ - { - short: 'W', - value: data.size?.width ?? '', - path: 'size.width', - type: 'number', - originalValue: data.size?.width, - }, - { - short: 'H', - value: data.size?.height ?? '', - path: 'size.height', - type: 'number', - originalValue: data.size?.height, - }, - ]); - } - } - - if (data.gap != null) { - addInlineFields('Gap', [ - { - short: 'X', - value: data.gap?.x ?? '', - path: 'gap.x', - type: 'number', - originalValue: data.gap?.x, - }, - { - short: 'Y', - value: data.gap?.y ?? '', - path: 'gap.y', - type: 'number', - originalValue: data.gap?.y, - }, - ]); - } - - if (data.padding != null && resolved.kind === 'element') { - addInlineFields('Pad', [ - { - short: 'T', - value: data.padding?.top ?? '', - path: 'padding.top', - type: 'number', - originalValue: data.padding?.top, - }, - { - short: 'R', - value: data.padding?.right ?? '', - path: 'padding.right', - type: 'number', - originalValue: data.padding?.right, - }, - { - short: 'B', - value: data.padding?.bottom ?? '', - path: 'padding.bottom', - type: 'number', - originalValue: data.padding?.bottom, - }, - { - short: 'L', - value: data.padding?.left ?? '', - path: 'padding.left', - type: 'number', - originalValue: data.padding?.left, - }, - ]); - } - - if (data.placement && resolved.kind === 'component') { - addField('Place', data.placement, { - path: 'placement', - type: 'text', - options: [ - { value: 'left', label: 'left' }, - { value: 'left-top', label: 'left-top' }, - { value: 'left-bottom', label: 'left-bottom' }, - { value: 'top', label: 'top' }, - { value: 'right', label: 'right' }, - { value: 'right-top', label: 'right-top' }, - { value: 'right-bottom', label: 'right-bottom' }, - { value: 'bottom', label: 'bottom' }, - { value: 'center', label: 'center' }, - ], - }); - } - - if (data.margin && resolved.kind === 'component') { - addInlineFields('Margin', [ - { - short: 'T', - value: data.margin?.top ?? '', - path: 'margin.top', - type: 'number', - originalValue: data.margin?.top, - }, - { - short: 'R', - value: data.margin?.right ?? '', - path: 'margin.right', - type: 'number', - originalValue: data.margin?.right, - }, - { - short: 'B', - value: data.margin?.bottom ?? '', - path: 'margin.bottom', - type: 'number', - originalValue: data.margin?.bottom, - }, - { - short: 'L', - value: data.margin?.left ?? '', - path: 'margin.left', - type: 'number', - originalValue: data.margin?.left, - }, - ]); - } - - if ( - (data.type === 'background' || data.type === 'bar') && - data.source && - typeof data.source === 'object' - ) { - addColorField('Fill', data.source.fill ?? '', 'source.fill'); - addColorField( - 'Border', - data.source.borderColor ?? '', - 'source.borderColor', - ); - addField('B Width', data.source.borderWidth ?? '', { - path: 'source.borderWidth', - type: 'number', - originalValue: data.source.borderWidth, - }); - if (typeof data.source.radius === 'number') { - addField('Radius', data.source.radius ?? '', { - path: 'source.radius', - type: 'number', - originalValue: data.source.radius, - }); - } else if (data.source.radius && typeof data.source.radius === 'object') { - addInlineFields('Radius', [ - { - short: 'TL', - value: data.source.radius.topLeft ?? '', - path: 'source.radius.topLeft', - type: 'number', - originalValue: data.source.radius.topLeft, - }, - { - short: 'TR', - value: data.source.radius.topRight ?? '', - path: 'source.radius.topRight', - type: 'number', - originalValue: data.source.radius.topRight, - }, - { - short: 'BR', - value: data.source.radius.bottomRight ?? '', - path: 'source.radius.bottomRight', - type: 'number', - originalValue: data.source.radius.bottomRight, - }, - { - short: 'BL', - value: data.source.radius.bottomLeft ?? '', - path: 'source.radius.bottomLeft', - type: 'number', - originalValue: data.source.radius.bottomLeft, - }, - ]); - } - } - - if (data.type === 'relations' && data.style?.color != null) { - addColorField('Color', data.style.color ?? '', 'style.color'); - } - - if (data.type === 'relations' && data.style?.width != null) { - addField('Width', data.style.width ?? '', { - path: 'style.width', - type: 'number', - originalValue: data.style.width, - }); - } - - if (data.type === 'text') { - addField('Split', data.split ?? '', { - path: 'split', - type: 'number', - originalValue: data.split, - }); - addField('F Size', data.style?.fontSize ?? '', { - path: 'style.fontSize', - type: 'text', - }); - addField('F Weight', data.style?.fontWeight ?? '', { - path: 'style.fontWeight', - type: 'text', - }); - addField('F Family', data.style?.fontFamily ?? '', { - path: 'style.fontFamily', - type: 'text', - }); - addColorField('Fill', data.style?.fill ?? '', 'style.fill'); - addField('Wrap', data.style?.wordWrapWidth ?? '', { - path: 'style.wordWrapWidth', - type: 'text', - }); - addField('Overflow', data.style?.overflow ?? '', { - path: 'style.overflow', - type: 'text', - options: [ - { value: 'visible', label: 'visible' }, - { value: 'hidden', label: 'hidden' }, - { value: 'ellipsis', label: 'ellipsis' }, - ], - }); - addInlineFields('Auto', [ - { - short: 'Min', - value: data.style?.autoFont?.min ?? '', - path: 'style.autoFont.min', - type: 'number', - originalValue: data.style?.autoFont?.min, - }, - { - short: 'Max', - value: data.style?.autoFont?.max ?? '', - path: 'style.autoFont.max', - type: 'number', - originalValue: data.style?.autoFont?.max, - }, - ]); - } - - if (data.type === 'bar') { - addField('Anim', data.animation ?? true, { - path: 'animation', - type: 'boolean', - }); - addField('Anim Ms', data.animationDuration ?? '', { - path: 'animationDuration', - type: 'number', - originalValue: data.animationDuration, - }); - } - - if (data.type === 'grid') { - addInlineFields('Item Size', [ - { - short: 'W', - value: data.item?.size?.width ?? '', - path: 'item.size.width', - type: 'number', - originalValue: data.item?.size?.width, - }, - { - short: 'H', - value: data.item?.size?.height ?? '', - path: 'item.size.height', - type: 'number', - originalValue: data.item?.size?.height, - }, - ]); - addInlineFields('Item Pad', [ - { - short: 'T', - value: data.item?.padding?.top ?? '', - path: 'item.padding.top', - type: 'number', - originalValue: data.item?.padding?.top, - }, - { - short: 'R', - value: data.item?.padding?.right ?? '', - path: 'item.padding.right', - type: 'number', - originalValue: data.item?.padding?.right, - }, - { - short: 'B', - value: data.item?.padding?.bottom ?? '', - path: 'item.padding.bottom', - type: 'number', - originalValue: data.item?.padding?.bottom, - }, - { - short: 'L', - value: data.item?.padding?.left ?? '', - path: 'item.padding.left', - type: 'number', - originalValue: data.item?.padding?.left, - }, - ]); - } - - if (data.type === 'relations') { - renderRelationsEditor(container, node, id); - } - - if (data.type === 'grid') { - renderGridEditor(container, node, id); - } - }; - - const buildInspectorEmpty = (text) => { - const empty = document.createElement('div'); - empty.className = 'inspector-empty'; - empty.textContent = text; - return empty; - }; - - const renderRelationsEditor = (container, node, id) => { - const editor = document.createElement('div'); - editor.className = 'relations-editor'; - - const title = document.createElement('div'); - title.className = 'relations-title'; - title.textContent = 'Links'; - - const controls = document.createElement('div'); - controls.className = 'relations-controls'; - - const sourceSelect = document.createElement('select'); - sourceSelect.className = 'relations-select'; - const targetSelect = document.createElement('select'); - targetSelect.className = 'relations-select'; - - const options = buildRelationsOptions(); - options.forEach((option) => { - const sourceOption = document.createElement('option'); - sourceOption.value = option.value; - sourceOption.textContent = option.label; - sourceSelect.append(sourceOption); - - const targetOption = document.createElement('option'); - targetOption.value = option.value; - targetOption.textContent = option.label; - targetSelect.append(targetOption); - }); - - if (options.length > 1) { - targetSelect.selectedIndex = 1; - } - - const addButton = document.createElement('button'); - addButton.type = 'button'; - addButton.className = 'relations-add'; - addButton.textContent = 'Add'; - - controls.append(sourceSelect, targetSelect, addButton); - - const list = document.createElement('div'); - list.className = 'relations-list'; - - const renderList = () => { - list.replaceChildren(); - const links = node.links ?? []; - if (links.length === 0) { - const empty = document.createElement('div'); - empty.className = 'relations-empty'; - empty.textContent = 'No links'; - list.append(empty); - return; - } - links.forEach((link, index) => { - const row = document.createElement('div'); - row.className = 'relations-row'; - - const label = document.createElement('div'); - label.className = 'relations-label'; - label.textContent = `${link.source} β†’ ${link.target}`; - - const removeButton = document.createElement('button'); - removeButton.type = 'button'; - removeButton.className = 'relations-delete'; - removeButton.textContent = 'βˆ’'; - removeButton.addEventListener('click', () => { - const nextLinks = node.links.filter((_, idx) => idx !== index); - applyRelationsLinks(node, id, nextLinks); - renderList(); - }); - - row.append(label, removeButton); - list.append(row); - }); - }; - - addButton.addEventListener('click', () => { - const source = sourceSelect.value; - const target = targetSelect.value; - if (!source || !target) return; - const nextLinks = [...(node.links ?? [])]; - if ( - nextLinks.some( - (link) => link.source === source && link.target === target, - ) - ) { - return; - } - nextLinks.push({ source, target }); - applyRelationsLinks(node, id, nextLinks); - renderList(); - }); - - renderList(); - editor.append(title, controls, list); - container.append(editor); - }; - - const applyRelationsLinks = (node, id, nextLinks) => { - const draft = { ...node, links: nextLinks }; - const validation = validateNode(draft, node.type); - if (!validation.success) { - setEditorError(validation.message); - return; - } - - node.links = nextLinks; - patchmap.update({ - path: `$..[?(@.id=="${id}")]`, - changes: { links: nextLinks }, - mergeStrategy: 'replace', - }); - setEditorValue(currentData); - clearEditorError(); - setLastAction(`Updated ${id} links`); - }; - - const buildRelationsOptions = () => { - const options = []; - nodeIndex.forEach((entry, id) => { - const type = entry.node?.type; - if (!type || componentTypes.has(type)) return; - options.push({ value: id, label: id }); - if (type === 'grid' && Array.isArray(entry.node.cells)) { - entry.node.cells.forEach((row, rowIndex) => { - row.forEach((cell, colIndex) => { - if (cell === 0 || cell == null) return; - options.push({ - value: `${id}.${rowIndex}.${colIndex}`, - label: `${id}.${rowIndex}.${colIndex}`, - }); - }); - }); - } - }); - return options; - }; - - const renderGridEditor = (container, node, id) => { - if (!Array.isArray(node.cells)) return; - const editor = document.createElement('div'); - editor.className = 'grid-editor'; - - const title = document.createElement('div'); - title.className = 'grid-title'; - title.textContent = 'Cells'; - - const controls = document.createElement('div'); - controls.className = 'grid-controls'; - - const addRow = buildGridControl('+ Row', 'add-row'); - const removeRow = buildGridControl('- Row', 'remove-row'); - const addCol = buildGridControl('+ Col', 'add-col'); - const removeCol = buildGridControl('- Col', 'remove-col'); - - controls.append(addRow, removeRow, addCol, removeCol); - - const grid = document.createElement('div'); - grid.className = 'grid-cells'; - - const renderCells = () => { - grid.replaceChildren(); - const rows = node.cells.length; - const cols = Math.max(1, ...node.cells.map((row) => row.length || 0)); - grid.style.setProperty('--grid-cols', String(cols)); - grid.style.setProperty('--grid-rows', String(rows)); - - node.cells.forEach((row, rowIndex) => { - for (let colIndex = 0; colIndex < cols; colIndex += 1) { - const value = row[colIndex]; - const cell = document.createElement('button'); - cell.type = 'button'; - cell.className = 'grid-cell'; - cell.dataset.row = String(rowIndex); - cell.dataset.col = String(colIndex); - - if (typeof value === 'string') { - cell.dataset.original = value; - cell.dataset.value = value; - } - - if (value !== 0 && value !== undefined) { - cell.classList.add('is-active'); - } - - cell.textContent = ''; - grid.append(cell); - } - }); - }; - - const updateCells = (nextCells) => { - node.cells = nextCells; - patchmap.update({ - path: `$..[?(@.id=="${id}")]`, - changes: { cells: nextCells }, - mergeStrategy: 'replace', - }); - setEditorValue(currentData); - clearEditorError(); - }; - - editor.addEventListener('click', (event) => { - const actionButton = event.target.closest('[data-grid-action]'); - if (actionButton) { - const action = actionButton.dataset.gridAction; - const cols = Math.max(1, ...node.cells.map((row) => row.length || 0)); - if (action === 'add-row') { - node.cells.push(Array.from({ length: cols }, () => 1)); - } - if (action === 'remove-row' && node.cells.length > 1) { - node.cells.pop(); - } - if (action === 'add-col') { - node.cells.forEach((row) => row.push(1)); - } - if (action === 'remove-col' && cols > 1) { - node.cells.forEach((row) => row.pop()); - } - updateCells(node.cells); - renderCells(); - setLastAction(`Updated ${id} cells`); - return; - } - - const cell = event.target.closest('.grid-cell'); - if (!cell) return; - const row = Number(cell.dataset.row); - const col = Number(cell.dataset.col); - const currentValue = node.cells[row]?.[col] ?? 0; - let nextValue = 0; - - if (currentValue === 0 || currentValue == null) { - nextValue = cell.dataset.original ?? 1; - } - - node.cells[row][col] = nextValue; - updateCells(node.cells); - renderCells(); - setLastAction(`Updated ${id} cells`); - }); - - renderCells(); - editor.append(title, controls, grid); - container.append(editor); - }; - - const buildGridControl = (label, action) => { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'grid-control'; - button.dataset.gridAction = action; - button.textContent = label; - return button; - }; - - const handleInspectorChange = ( - id, - node, - path, - rawValue, - inputType, - originalValue, - ) => { - const value = coerceValue(rawValue, inputType, originalValue); - if (value === null) return; - - const draft = JSON.parse(JSON.stringify(node)); - setNodeValue(draft, path, value); - const validation = validateNode(draft, node.type); - if (!validation.success) { - setEditorError(validation.message); - renderInspector(id); - return; - } - - setNodeValue(node, path, value); - patchmap.update({ - path: `$..[?(@.id=="${id}")]`, - changes: buildChangesFromPath(path, value), - }); - - setEditorValue(currentData); - clearEditorError(); - setLastAction(`Updated ${id}`); - }; - - const coerceValue = (rawValue, inputType, originalValue) => { - if (inputType === 'number') { - if (rawValue === '') return null; - const numberValue = Number(rawValue); - return Number.isNaN(numberValue) ? null : numberValue; - } - if (inputType === 'boolean') { - return Boolean(rawValue); - } - if (typeof originalValue === 'number') { - const numberValue = Number(rawValue); - if (!Number.isNaN(numberValue)) { - return numberValue; - } - } - return rawValue; - }; - - const setNodeValue = (node, path, value) => { - const keys = path.split('.'); - let current = node; - keys.forEach((key, index) => { - if (index === keys.length - 1) { - current[key] = value; - return; - } - if (typeof current[key] !== 'object' || current[key] === null) { - current[key] = {}; - } - current = current[key]; - }); - }; - - const buildChangesFromPath = (path, value) => { - const keys = path.split('.'); - const changes = {}; - let current = changes; - keys.forEach((key, index) => { - if (index === keys.length - 1) { - current[key] = value; - return; - } - current[key] = {}; - current = current[key]; - }); - return changes; - }; - - const updateAddParentOptions = () => { - if (!elements.dataAddParent) return; - const previous = elements.dataAddParent.value; - const options = buildAddParentOptions(); - elements.dataAddParent.replaceChildren( - ...options.map((option) => { - const item = document.createElement('option'); - item.value = option.value; - item.textContent = option.label; - return item; - }), - ); - - const preferred = resolveAddParentSelection( - previous, - selectedNodeId, - options, - ); - elements.dataAddParent.value = preferred; - updateAddTypeOptions(); - }; - - const openAddPopover = (parentId = '__root__') => { - if (!elements.dataPopover) return; - updateAddParentOptions(); - if (elements.dataAddParent) { - const target = parentId ?? '__root__'; - const optionExists = Array.from(elements.dataAddParent.options).some( - (option) => option.value === target, - ); - elements.dataAddParent.value = optionExists ? target : '__root__'; - updateAddTypeOptions(); - } - elements.dataPopover.hidden = false; - elements.dataAddId?.focus(); - }; - - const closeAddPopover = () => { - if (!elements.dataPopover) return; - elements.dataPopover.hidden = true; - }; - - const resolveAddParentSelection = (previous, selectedId, options) => { - const values = new Set(options.map((option) => option.value)); - if (previous && values.has(previous)) return previous; - if (selectedId && values.has(selectedId)) return selectedId; - return '__root__'; - }; - - const buildAddParentOptions = () => { - const options = [{ value: '__root__', label: 'Root' }]; - nodeIndex.forEach((entry, id) => { - const type = entry.node?.type; - if (type === 'group' || type === 'item' || type === 'grid') { - options.push({ value: id, label: `${type}:${id}` }); - } - }); - return options; - }; - - const updateAddTypeOptions = () => { - if (!elements.dataAddType || !elements.dataAddParent) return; - const previous = elements.dataAddType.value; - const parentEntry = nodeIndex.get(elements.dataAddParent.value); - const parentType = parentEntry?.node?.type; - const types = - parentType === 'item' || parentType === 'grid' - ? ['background', 'bar', 'icon', 'text'] - : ['group', 'grid', 'item', 'relations']; - - elements.dataAddType.replaceChildren( - ...types.map((type) => { - const option = document.createElement('option'); - option.value = type; - option.textContent = type; - return option; - }), - ); - - if (types.includes(previous)) { - elements.dataAddType.value = previous; - } - }; - - const handleAddElement = () => { - if (!elements.dataAddType || !elements.dataAddParent) return; - const parentId = elements.dataAddParent.value; - const type = elements.dataAddType.value; - const idInput = elements.dataAddId?.value.trim(); - const labelInput = elements.dataAddLabel?.value.trim(); - const id = idInput || generateId(type); - - if (nodeIndex.has(id)) { - setEditorError(`Duplicate id: ${id}`); - return; - } - - const node = buildNewNode(type, id, labelInput); - if (!node) { - setEditorError('Unsupported type'); - return; - } - - const validation = validateNode(node, type); - if (!validation.success) { - setEditorError(validation.message); - return; - } - - if (parentId === '__root__') { - currentData.push(node); - } else { - const parentEntry = nodeIndex.get(parentId); - if (!parentEntry?.node) { - setEditorError('Invalid parent'); - return; - } - if (parentEntry.node.type === 'item') { - parentEntry.node.components = parentEntry.node.components ?? []; - parentEntry.node.components.push(node); - } else if (parentEntry.node.type === 'grid') { - parentEntry.node.item = parentEntry.node.item ?? { - size: { width: 40, height: 80 }, - padding: 0, - components: [], - }; - parentEntry.node.item.components = - parentEntry.node.item.components ?? []; - parentEntry.node.item.components.push(node); - } else { - parentEntry.node.children = parentEntry.node.children ?? []; - parentEntry.node.children.push(node); - } - } - - clearEditorError(); - if (elements.dataAddId) elements.dataAddId.value = ''; - if (elements.dataAddLabel) elements.dataAddLabel.value = ''; - - patchmap.draw(currentData); - patchmap.fit(); - setCurrentData(currentData); - selectNodeById(id); - setLastAction(`Added ${id}`); - closeAddPopover(); - }; - - const buildNewNode = (type, id, label) => { - const base = { type, id }; - if (label) { - base.label = label; - } - - switch (type) { - case 'group': - return { ...base, children: [] }; - case 'grid': - return { - ...base, - cells: [[1]], - gap: 4, - item: { - size: { width: 40, height: 80 }, - padding: 6, - components: [ - { - type: 'background', - source: { - type: 'rect', - fill: '#ffffff', - borderWidth: 1, - borderColor: '#111111', - radius: 4, - }, - tint: '#ffffff', - }, - ], - }, - }; - case 'item': - return { - ...base, - size: { width: 120, height: 80 }, - padding: 0, - components: [], - }; - case 'relations': - return { ...base, links: [], style: { width: 2 } }; - case 'background': - return { - ...base, - source: { - type: 'rect', - fill: '#ffffff', - borderWidth: 1, - borderColor: '#111111', - radius: 4, - }, - tint: '#ffffff', - }; - case 'bar': - return { - ...base, - source: { type: 'rect', fill: '#111111', radius: 4 }, - size: { width: '50%', height: 10 }, - placement: 'bottom', - margin: 0, - tint: '#111111', - }; - case 'icon': - return { - ...base, - source: 'wifi', - size: 16, - placement: 'center', - margin: 0, - tint: '#111111', - }; - case 'text': - return { - ...base, - text: 'Label', - style: {}, - placement: 'center', - margin: 0, - tint: '#111111', - }; - default: - return null; - } - }; - - const generateId = (type) => { - const base = type.replace(/[^a-z0-9]+/gi, '-').toLowerCase(); - let candidate = `${base}-${Math.random().toString(36).slice(2, 6)}`; - while (nodeIndex.has(candidate)) { - candidate = `${base}-${Math.random().toString(36).slice(2, 6)}`; - } - return candidate; - }; - - const deleteNodeById = (nodeId) => { - if (!nodeId) return; - const entry = nodeIndex.get(nodeId); - if (!entry) return; - - const { parentId } = entry; - let removed = false; - if (!parentId) { - const index = currentData.findIndex((node) => node.id === nodeId); - if (index >= 0) { - currentData.splice(index, 1); - removed = true; - } - } else { - const parentEntry = nodeIndex.get(parentId); - if (parentEntry?.node) { - if (parentEntry.node.type === 'item') { - parentEntry.node.components = ( - parentEntry.node.components ?? [] - ).filter((child) => child.id !== nodeId); - removed = true; - } else if (parentEntry.node.type === 'grid') { - parentEntry.node.item = parentEntry.node.item ?? { - size: { width: 40, height: 80 }, - padding: 0, - components: [], - }; - parentEntry.node.item.components = ( - parentEntry.node.item.components ?? [] - ).filter((child) => child.id !== nodeId); - removed = true; - } else { - parentEntry.node.children = (parentEntry.node.children ?? []).filter( - (child) => child.id !== nodeId, - ); - removed = true; - } - } - } - - if (!removed) return; - - patchmap.draw(currentData); - patchmap.fit(); - setCurrentData(currentData); - updateSelection(null); - setLastAction(`Deleted ${nodeId}`); - }; - - const buildRootAddRow = () => { - const row = document.createElement('div'); - row.className = 'tree-row'; - - const label = document.createElement('div'); - label.className = 'tree-root-label'; - label.textContent = 'Root'; - - const actions = document.createElement('div'); - actions.className = 'tree-actions'; - - const addButton = document.createElement('button'); - addButton.type = 'button'; - addButton.className = 'tree-action'; - addButton.dataset.action = 'add'; - addButton.dataset.parentId = '__root__'; - addButton.textContent = '+'; - - actions.append(addButton); - row.append(label, actions); - return row; - }; - - const formatError = (error) => { - if (error?.message) return error.message; - try { - return JSON.stringify(error); - } catch { - return String(error); - } - }; - - const setEditorValue = (data) => { - elements.editor.value = JSON.stringify(data, null, 2); - }; - - const parseEditorValue = () => { - try { - return JSON.parse(elements.editor.value); - } catch (error) { - setEditorError(`JSON parse error: ${error.message}`); - return null; - } - }; - - const setEditorError = (message) => { - elements.editorError.textContent = message; - }; - - const clearEditorError = () => { - elements.editorError.textContent = ''; - }; - - const resolveNodeSchema = (node) => { - if (!node?.type) { - return { parsed: node, schema: null, kind: 'unknown', error: null }; - } - - const schema = componentTypes.has(node.type) - ? componentSchema - : elementTypes; - const result = schema.safeParse(node); - if (!result.success) { - return { - parsed: node, - schema, - kind: componentTypes.has(node.type) ? 'component' : 'element', - error: result.error, - }; - } - - return { - parsed: result.data, - schema, - kind: componentTypes.has(node.type) ? 'component' : 'element', - error: null, - }; - }; - - const validateNode = (node, type) => { - const schema = componentTypes.has(type) ? componentSchema : elementTypes; - const result = schema.safeParse(node); - if (result.success) { - return { success: true, message: '' }; - } - return { - success: false, - message: result.error.issues?.[0]?.message ?? 'Invalid data', - }; - }; - - const formatPxPercent = (value) => { - if (value == null) return null; - if (typeof value === 'string' || typeof value === 'number') return value; - if (typeof value === 'object' && 'value' in value && 'unit' in value) { - return `${value.value}${value.unit}`; - } - return null; - }; + tree = createTree({ + elements, + state, + updateAddParentOptions: () => addPopover?.updateAddParentOptions?.(), + }); + + inspector = createInspector({ + patchmap, + elements, + state, + componentTypes, + colorPresets, + colorPresetValues, + resolveNodeSchema, + validateNode, + formatPxPercent, + coerceValue, + setNodeValue, + buildChangesFromPath, + setEditorValue, + setEditorError, + clearEditorError, + setLastAction, + }); + + addPopover = createAddPopover({ + patchmap, + elements, + state, + validateNode, + setCurrentData, + selectNodeById, + updateSelection, + setEditorError, + clearEditorError, + setLastAction, + }); return { setDataMode, @@ -1474,11 +169,11 @@ export const createDataEditor = ({ patchmap, elements, setLastAction }) => { applyEditorData, prettifyEditor, selectNodeById, - openAddPopover, - closeAddPopover, - updateAddTypeOptions, - handleAddElement, - deleteNodeById, + openAddPopover: (...args) => addPopover.openAddPopover(...args), + closeAddPopover: () => addPopover.closeAddPopover(), + updateAddTypeOptions: () => addPopover.updateAddTypeOptions(), + handleAddElement: () => addPopover.handleAddElement(), + deleteNodeById: (...args) => addPopover.deleteNodeById(...args), clearEditorError, }; }; diff --git a/playground/data-editor/add-popover.js b/playground/data-editor/add-popover.js new file mode 100644 index 00000000..304419a2 --- /dev/null +++ b/playground/data-editor/add-popover.js @@ -0,0 +1,310 @@ +export const createAddPopover = ({ + patchmap, + elements, + state, + validateNode, + setCurrentData, + selectNodeById, + updateSelection, + setEditorError, + clearEditorError, + setLastAction, +}) => { + const updateAddParentOptions = () => { + if (!elements.dataAddParent) return; + const previous = elements.dataAddParent.value; + const options = buildAddParentOptions(); + elements.dataAddParent.replaceChildren( + ...options.map((option) => { + const item = document.createElement('option'); + item.value = option.value; + item.textContent = option.label; + return item; + }), + ); + + const preferred = resolveAddParentSelection( + previous, + state.selectedNodeId, + options, + ); + elements.dataAddParent.value = preferred; + updateAddTypeOptions(); + }; + + const openAddPopover = (parentId = '__root__') => { + if (!elements.dataPopover) return; + updateAddParentOptions(); + if (elements.dataAddParent) { + const target = parentId ?? '__root__'; + const optionExists = Array.from(elements.dataAddParent.options).some( + (option) => option.value === target, + ); + elements.dataAddParent.value = optionExists ? target : '__root__'; + updateAddTypeOptions(); + } + elements.dataPopover.hidden = false; + elements.dataAddId?.focus(); + }; + + const closeAddPopover = () => { + if (!elements.dataPopover) return; + elements.dataPopover.hidden = true; + }; + + const updateAddTypeOptions = () => { + if (!elements.dataAddType || !elements.dataAddParent) return; + const previous = elements.dataAddType.value; + const parentEntry = state.nodeIndex.get(elements.dataAddParent.value); + const parentType = parentEntry?.node?.type; + const types = + parentType === 'item' || parentType === 'grid' + ? ['background', 'bar', 'icon', 'text'] + : ['group', 'grid', 'item', 'relations']; + + elements.dataAddType.replaceChildren( + ...types.map((type) => { + const option = document.createElement('option'); + option.value = type; + option.textContent = type; + return option; + }), + ); + + if (types.includes(previous)) { + elements.dataAddType.value = previous; + } + }; + + const handleAddElement = () => { + if (!elements.dataAddType || !elements.dataAddParent) return; + const parentId = elements.dataAddParent.value; + const type = elements.dataAddType.value; + const idInput = elements.dataAddId?.value.trim(); + const labelInput = elements.dataAddLabel?.value.trim(); + const id = idInput || generateId(type); + + if (state.nodeIndex.has(id)) { + setEditorError(`Duplicate id: ${id}`); + return; + } + + const node = buildNewNode(type, id, labelInput); + if (!node) { + setEditorError('Unsupported type'); + return; + } + + const validation = validateNode(node, type); + if (!validation.success) { + setEditorError(validation.message); + return; + } + + if (parentId === '__root__') { + state.currentData.push(node); + } else { + const parentEntry = state.nodeIndex.get(parentId); + if (!parentEntry?.node) { + setEditorError('Invalid parent'); + return; + } + if (parentEntry.node.type === 'item') { + parentEntry.node.components = parentEntry.node.components ?? []; + parentEntry.node.components.push(node); + } else if (parentEntry.node.type === 'grid') { + parentEntry.node.item = parentEntry.node.item ?? { + size: { width: 40, height: 80 }, + padding: 0, + components: [], + }; + parentEntry.node.item.components = + parentEntry.node.item.components ?? []; + parentEntry.node.item.components.push(node); + } else { + parentEntry.node.children = parentEntry.node.children ?? []; + parentEntry.node.children.push(node); + } + } + + clearEditorError(); + if (elements.dataAddId) elements.dataAddId.value = ''; + if (elements.dataAddLabel) elements.dataAddLabel.value = ''; + + patchmap.draw(state.currentData); + patchmap.fit(); + setCurrentData(state.currentData); + selectNodeById(id); + setLastAction(`Added ${id}`); + closeAddPopover(); + }; + + const deleteNodeById = (nodeId) => { + if (!nodeId) return; + const entry = state.nodeIndex.get(nodeId); + if (!entry) return; + + const { parentId } = entry; + let removed = false; + if (!parentId) { + const index = state.currentData.findIndex((node) => node.id === nodeId); + if (index >= 0) { + state.currentData.splice(index, 1); + removed = true; + } + } else { + const parentEntry = state.nodeIndex.get(parentId); + if (parentEntry?.node) { + if (parentEntry.node.type === 'item') { + parentEntry.node.components = ( + parentEntry.node.components ?? [] + ).filter((child) => child.id !== nodeId); + removed = true; + } else if (parentEntry.node.type === 'grid') { + parentEntry.node.item = parentEntry.node.item ?? { + size: { width: 40, height: 80 }, + padding: 0, + components: [], + }; + parentEntry.node.item.components = ( + parentEntry.node.item.components ?? [] + ).filter((child) => child.id !== nodeId); + removed = true; + } else { + parentEntry.node.children = (parentEntry.node.children ?? []).filter( + (child) => child.id !== nodeId, + ); + removed = true; + } + } + } + + if (!removed) return; + + patchmap.draw(state.currentData); + patchmap.fit(); + setCurrentData(state.currentData); + updateSelection(null); + setLastAction(`Deleted ${nodeId}`); + }; + + return { + updateAddParentOptions, + openAddPopover, + closeAddPopover, + updateAddTypeOptions, + handleAddElement, + deleteNodeById, + }; + + function buildAddParentOptions() { + const options = [{ value: '__root__', label: 'Root' }]; + state.nodeIndex.forEach((entry, entryId) => { + const type = entry.node?.type; + if (type === 'group' || type === 'item' || type === 'grid') { + options.push({ value: entryId, label: `${type}:${entryId}` }); + } + }); + return options; + } + + function generateId(type) { + const base = type.replace(/[^a-z0-9]+/gi, '-').toLowerCase(); + let candidate = `${base}-${Math.random().toString(36).slice(2, 6)}`; + while (state.nodeIndex.has(candidate)) { + candidate = `${base}-${Math.random().toString(36).slice(2, 6)}`; + } + return candidate; + } +}; + +const resolveAddParentSelection = (previous, selectedId, options) => { + const values = new Set(options.map((option) => option.value)); + if (previous && values.has(previous)) return previous; + if (selectedId && values.has(selectedId)) return selectedId; + return '__root__'; +}; +const buildNewNode = (type, id, label) => { + const base = { type, id }; + if (label) { + base.label = label; + } + + switch (type) { + case 'group': + return { ...base, children: [] }; + case 'grid': + return { + ...base, + cells: [[1]], + gap: 4, + item: { + size: { width: 40, height: 80 }, + padding: 6, + components: [ + { + type: 'background', + source: { + type: 'rect', + fill: '#ffffff', + borderWidth: 1, + borderColor: '#111111', + radius: 4, + }, + tint: '#ffffff', + }, + ], + }, + }; + case 'item': + return { + ...base, + size: { width: 120, height: 80 }, + padding: 0, + components: [], + }; + case 'relations': + return { ...base, links: [], style: { width: 2 } }; + case 'background': + return { + ...base, + source: { + type: 'rect', + fill: '#ffffff', + borderWidth: 1, + borderColor: '#111111', + radius: 4, + }, + tint: '#ffffff', + }; + case 'bar': + return { + ...base, + source: { type: 'rect', fill: '#111111', radius: 4 }, + size: { width: '50%', height: 10 }, + placement: 'bottom', + margin: 0, + tint: '#111111', + }; + case 'icon': + return { + ...base, + source: 'wifi', + size: 16, + placement: 'center', + margin: 0, + tint: '#111111', + }; + case 'text': + return { + ...base, + text: 'Label', + style: {}, + placement: 'center', + margin: 0, + tint: '#111111', + }; + default: + return null; + } +}; diff --git a/playground/data-editor/constants.js b/playground/data-editor/constants.js new file mode 100644 index 00000000..d18118d6 --- /dev/null +++ b/playground/data-editor/constants.js @@ -0,0 +1,17 @@ +export const componentTypes = new Set(['background', 'bar', 'icon', 'text']); + +export const colorPresets = [ + { value: 'primary.default', label: 'primary.default' }, + { value: 'primary.dark', label: 'primary.dark' }, + { value: 'primary.accent', label: 'primary.accent' }, + { value: 'gray.dark', label: 'gray.dark' }, + { value: 'gray.light', label: 'gray.light' }, + { value: 'black', label: 'black' }, + { value: 'white', label: 'white' }, + { value: '#111111', label: '#111111' }, + { value: '#ffffff', label: '#ffffff' }, +]; + +export const colorPresetValues = new Set( + colorPresets.map((preset) => preset.value), +); diff --git a/playground/data-editor/inspector-fields.js b/playground/data-editor/inspector-fields.js new file mode 100644 index 00000000..0fe17fba --- /dev/null +++ b/playground/data-editor/inspector-fields.js @@ -0,0 +1,385 @@ +export const renderInspectorFields = ({ + container, + node, + id, + data, + resolved, + addField, + addInlineFields, + addColorField, + formatPxPercent, + renderRelationsEditor, + renderGridEditor, +}) => { + addField('Id', data.id, { readOnly: true }); + addField('Type', data.type ?? '', { readOnly: true }); + addField('Label', data.label ?? '', { path: 'label', type: 'text' }); + addField('Show', data.show ?? true, { path: 'show', type: 'boolean' }); + + if (typeof data.text === 'string') { + addField('Text', data.text, { path: 'text', type: 'text' }); + } + + if (typeof data.source === 'string') { + addField('Source', data.source, { path: 'source', type: 'text' }); + } + + if (data.tint != null) { + addColorField('Tint', data.tint, 'tint'); + } + + if (data.type !== 'relations' && resolved.kind === 'element') { + addField('X', data.attrs?.x ?? '', { + path: 'attrs.x', + type: 'number', + originalValue: data.attrs?.x, + }); + addField('Y', data.attrs?.y ?? '', { + path: 'attrs.y', + type: 'number', + originalValue: data.attrs?.y, + }); + if (data.attrs?.angle != null) { + addField('Angle', data.attrs.angle ?? '', { + path: 'attrs.angle', + type: 'number', + originalValue: data.attrs?.angle, + }); + } + if (data.attrs?.rotation != null) { + addField('Rot', data.attrs.rotation ?? '', { + path: 'attrs.rotation', + type: 'number', + originalValue: data.attrs?.rotation, + }); + } + } + + if (data.size != null) { + if (resolved.kind === 'component') { + const widthValue = formatPxPercent(data.size?.width); + const heightValue = formatPxPercent(data.size?.height); + addInlineFields('Size', [ + { + short: 'W', + value: widthValue ?? '', + path: 'size.width', + type: 'text', + }, + { + short: 'H', + value: heightValue ?? '', + path: 'size.height', + type: 'text', + }, + ]); + } else { + addInlineFields('Size', [ + { + short: 'W', + value: data.size?.width ?? '', + path: 'size.width', + type: 'number', + originalValue: data.size?.width, + }, + { + short: 'H', + value: data.size?.height ?? '', + path: 'size.height', + type: 'number', + originalValue: data.size?.height, + }, + ]); + } + } + + if (data.gap != null) { + addInlineFields('Gap', [ + { + short: 'X', + value: data.gap?.x ?? '', + path: 'gap.x', + type: 'number', + originalValue: data.gap?.x, + }, + { + short: 'Y', + value: data.gap?.y ?? '', + path: 'gap.y', + type: 'number', + originalValue: data.gap?.y, + }, + ]); + } + + if (data.padding != null && resolved.kind === 'element') { + addInlineFields('Pad', [ + { + short: 'T', + value: data.padding?.top ?? '', + path: 'padding.top', + type: 'number', + originalValue: data.padding?.top, + }, + { + short: 'R', + value: data.padding?.right ?? '', + path: 'padding.right', + type: 'number', + originalValue: data.padding?.right, + }, + { + short: 'B', + value: data.padding?.bottom ?? '', + path: 'padding.bottom', + type: 'number', + originalValue: data.padding?.bottom, + }, + { + short: 'L', + value: data.padding?.left ?? '', + path: 'padding.left', + type: 'number', + originalValue: data.padding?.left, + }, + ]); + } + + if (data.placement && resolved.kind === 'component') { + addField('Place', data.placement, { + path: 'placement', + type: 'text', + options: [ + { value: 'left', label: 'left' }, + { value: 'left-top', label: 'left-top' }, + { value: 'left-bottom', label: 'left-bottom' }, + { value: 'top', label: 'top' }, + { value: 'right', label: 'right' }, + { value: 'right-top', label: 'right-top' }, + { value: 'right-bottom', label: 'right-bottom' }, + { value: 'bottom', label: 'bottom' }, + { value: 'center', label: 'center' }, + ], + }); + } + + if (data.margin && resolved.kind === 'component') { + addInlineFields('Margin', [ + { + short: 'T', + value: data.margin?.top ?? '', + path: 'margin.top', + type: 'number', + originalValue: data.margin?.top, + }, + { + short: 'R', + value: data.margin?.right ?? '', + path: 'margin.right', + type: 'number', + originalValue: data.margin?.right, + }, + { + short: 'B', + value: data.margin?.bottom ?? '', + path: 'margin.bottom', + type: 'number', + originalValue: data.margin?.bottom, + }, + { + short: 'L', + value: data.margin?.left ?? '', + path: 'margin.left', + type: 'number', + originalValue: data.margin?.left, + }, + ]); + } + + if ( + (data.type === 'background' || data.type === 'bar') && + data.source && + typeof data.source === 'object' + ) { + addColorField('Fill', data.source.fill ?? '', 'source.fill'); + addColorField( + 'Border', + data.source.borderColor ?? '', + 'source.borderColor', + ); + addField('B Width', data.source.borderWidth ?? '', { + path: 'source.borderWidth', + type: 'number', + originalValue: data.source.borderWidth, + }); + if (typeof data.source.radius === 'number') { + addField('Radius', data.source.radius ?? '', { + path: 'source.radius', + type: 'number', + originalValue: data.source.radius, + }); + } else if (data.source.radius && typeof data.source.radius === 'object') { + addInlineFields('Radius', [ + { + short: 'TL', + value: data.source.radius.topLeft ?? '', + path: 'source.radius.topLeft', + type: 'number', + originalValue: data.source.radius.topLeft, + }, + { + short: 'TR', + value: data.source.radius.topRight ?? '', + path: 'source.radius.topRight', + type: 'number', + originalValue: data.source.radius.topRight, + }, + { + short: 'BR', + value: data.source.radius.bottomRight ?? '', + path: 'source.radius.bottomRight', + type: 'number', + originalValue: data.source.radius.bottomRight, + }, + { + short: 'BL', + value: data.source.radius.bottomLeft ?? '', + path: 'source.radius.bottomLeft', + type: 'number', + originalValue: data.source.radius.bottomLeft, + }, + ]); + } + } + + if (data.type === 'relations' && data.style?.color != null) { + addColorField('Color', data.style.color ?? '', 'style.color'); + } + + if (data.type === 'relations' && data.style?.width != null) { + addField('Width', data.style.width ?? '', { + path: 'style.width', + type: 'number', + originalValue: data.style.width, + }); + } + + if (data.type === 'text') { + addField('Split', data.split ?? '', { + path: 'split', + type: 'number', + originalValue: data.split, + }); + addField('F Size', data.style?.fontSize ?? '', { + path: 'style.fontSize', + type: 'text', + }); + addField('F Weight', data.style?.fontWeight ?? '', { + path: 'style.fontWeight', + type: 'text', + }); + addField('F Family', data.style?.fontFamily ?? '', { + path: 'style.fontFamily', + type: 'text', + }); + addColorField('Fill', data.style?.fill ?? '', 'style.fill'); + addField('Wrap', data.style?.wordWrapWidth ?? '', { + path: 'style.wordWrapWidth', + type: 'text', + }); + addField('Overflow', data.style?.overflow ?? '', { + path: 'style.overflow', + type: 'text', + options: [ + { value: 'visible', label: 'visible' }, + { value: 'hidden', label: 'hidden' }, + { value: 'ellipsis', label: 'ellipsis' }, + ], + }); + addInlineFields('Auto', [ + { + short: 'Min', + value: data.style?.autoFont?.min ?? '', + path: 'style.autoFont.min', + type: 'number', + originalValue: data.style?.autoFont?.min, + }, + { + short: 'Max', + value: data.style?.autoFont?.max ?? '', + path: 'style.autoFont.max', + type: 'number', + originalValue: data.style?.autoFont?.max, + }, + ]); + } + + if (data.type === 'bar') { + addField('Anim', data.animation ?? true, { + path: 'animation', + type: 'boolean', + }); + addField('Anim Ms', data.animationDuration ?? '', { + path: 'animationDuration', + type: 'number', + originalValue: data.animationDuration, + }); + } + + if (data.type === 'grid') { + addInlineFields('Item Size', [ + { + short: 'W', + value: data.item?.size?.width ?? '', + path: 'item.size.width', + type: 'number', + originalValue: data.item?.size?.width, + }, + { + short: 'H', + value: data.item?.size?.height ?? '', + path: 'item.size.height', + type: 'number', + originalValue: data.item?.size?.height, + }, + ]); + addInlineFields('Item Pad', [ + { + short: 'T', + value: data.item?.padding?.top ?? '', + path: 'item.padding.top', + type: 'number', + originalValue: data.item?.padding?.top, + }, + { + short: 'R', + value: data.item?.padding?.right ?? '', + path: 'item.padding.right', + type: 'number', + originalValue: data.item?.padding?.right, + }, + { + short: 'B', + value: data.item?.padding?.bottom ?? '', + path: 'item.padding.bottom', + type: 'number', + originalValue: data.item?.padding?.bottom, + }, + { + short: 'L', + value: data.item?.padding?.left ?? '', + path: 'item.padding.left', + type: 'number', + originalValue: data.item?.padding?.left, + }, + ]); + } + + if (data.type === 'relations') { + renderRelationsEditor(container, node, id); + } + + if (data.type === 'grid') { + renderGridEditor(container, node, id); + } +}; diff --git a/playground/data-editor/inspector-grid.js b/playground/data-editor/inspector-grid.js new file mode 100644 index 00000000..9c4a9820 --- /dev/null +++ b/playground/data-editor/inspector-grid.js @@ -0,0 +1,127 @@ +export const createGridEditor = ({ + patchmap, + state, + setEditorValue, + clearEditorError, + setLastAction, +}) => { + const renderGridEditor = (container, node, id) => { + if (!Array.isArray(node.cells)) return; + const editor = document.createElement('div'); + editor.className = 'grid-editor'; + + const title = document.createElement('div'); + title.className = 'grid-title'; + title.textContent = 'Cells'; + + const controls = document.createElement('div'); + controls.className = 'grid-controls'; + + const addRow = buildGridControl('+ Row', 'add-row'); + const removeRow = buildGridControl('- Row', 'remove-row'); + const addCol = buildGridControl('+ Col', 'add-col'); + const removeCol = buildGridControl('- Col', 'remove-col'); + + controls.append(addRow, removeRow, addCol, removeCol); + + const grid = document.createElement('div'); + grid.className = 'grid-cells'; + + const renderCells = () => { + grid.replaceChildren(); + const rows = node.cells.length; + const cols = Math.max(1, ...node.cells.map((row) => row.length || 0)); + grid.style.setProperty('--grid-cols', String(cols)); + grid.style.setProperty('--grid-rows', String(rows)); + + node.cells.forEach((row, rowIndex) => { + for (let colIndex = 0; colIndex < cols; colIndex += 1) { + const value = row[colIndex]; + const cell = document.createElement('button'); + cell.type = 'button'; + cell.className = 'grid-cell'; + cell.dataset.row = String(rowIndex); + cell.dataset.col = String(colIndex); + + if (typeof value === 'string') { + cell.dataset.original = value; + cell.dataset.value = value; + } + + if (value !== 0 && value !== undefined) { + cell.classList.add('is-active'); + } + + cell.textContent = ''; + grid.append(cell); + } + }); + }; + + const updateCells = (nextCells) => { + node.cells = nextCells; + patchmap.update({ + path: `$..[?(@.id=="${id}")]`, + changes: { cells: nextCells }, + mergeStrategy: 'replace', + }); + setEditorValue(state.currentData); + clearEditorError(); + }; + + editor.addEventListener('click', (event) => { + const actionButton = event.target.closest('[data-grid-action]'); + if (actionButton) { + const action = actionButton.dataset.gridAction; + const cols = Math.max(1, ...node.cells.map((row) => row.length || 0)); + if (action === 'add-row') { + node.cells.push(Array.from({ length: cols }, () => 1)); + } + if (action === 'remove-row' && node.cells.length > 1) { + node.cells.pop(); + } + if (action === 'add-col') { + node.cells.forEach((row) => row.push(1)); + } + if (action === 'remove-col' && cols > 1) { + node.cells.forEach((row) => row.pop()); + } + updateCells(node.cells); + renderCells(); + setLastAction(`Updated ${id} cells`); + return; + } + + const cell = event.target.closest('.grid-cell'); + if (!cell) return; + const row = Number(cell.dataset.row); + const col = Number(cell.dataset.col); + const currentValue = node.cells[row]?.[col] ?? 0; + let nextValue = 0; + + if (currentValue === 0 || currentValue == null) { + nextValue = cell.dataset.original ?? 1; + } + + node.cells[row][col] = nextValue; + updateCells(node.cells); + renderCells(); + setLastAction(`Updated ${id} cells`); + }); + + renderCells(); + editor.append(title, controls, grid); + container.append(editor); + }; + + const buildGridControl = (label, action) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'grid-control'; + button.dataset.gridAction = action; + button.textContent = label; + return button; + }; + + return { renderGridEditor }; +}; diff --git a/playground/data-editor/inspector-relations.js b/playground/data-editor/inspector-relations.js new file mode 100644 index 00000000..312dc658 --- /dev/null +++ b/playground/data-editor/inspector-relations.js @@ -0,0 +1,150 @@ +export const createRelationsEditor = ({ + patchmap, + state, + componentTypes, + validateNode, + setEditorValue, + setEditorError, + clearEditorError, + setLastAction, +}) => { + const renderRelationsEditor = (container, node, id) => { + const editor = document.createElement('div'); + editor.className = 'relations-editor'; + + const title = document.createElement('div'); + title.className = 'relations-title'; + title.textContent = 'Links'; + + const controls = document.createElement('div'); + controls.className = 'relations-controls'; + + const sourceSelect = document.createElement('select'); + sourceSelect.className = 'relations-select'; + const targetSelect = document.createElement('select'); + targetSelect.className = 'relations-select'; + + const options = buildRelationsOptions(); + options.forEach((option) => { + const sourceOption = document.createElement('option'); + sourceOption.value = option.value; + sourceOption.textContent = option.label; + sourceSelect.append(sourceOption); + + const targetOption = document.createElement('option'); + targetOption.value = option.value; + targetOption.textContent = option.label; + targetSelect.append(targetOption); + }); + + if (options.length > 1) { + targetSelect.selectedIndex = 1; + } + + const addButton = document.createElement('button'); + addButton.type = 'button'; + addButton.className = 'relations-add'; + addButton.textContent = 'Add'; + + controls.append(sourceSelect, targetSelect, addButton); + + const list = document.createElement('div'); + list.className = 'relations-list'; + + const renderList = () => { + list.replaceChildren(); + const links = node.links ?? []; + if (links.length === 0) { + const empty = document.createElement('div'); + empty.className = 'relations-empty'; + empty.textContent = 'No links'; + list.append(empty); + return; + } + links.forEach((link, index) => { + const row = document.createElement('div'); + row.className = 'relations-row'; + + const label = document.createElement('div'); + label.className = 'relations-label'; + label.textContent = `${link.source} β†’ ${link.target}`; + + const removeButton = document.createElement('button'); + removeButton.type = 'button'; + removeButton.className = 'relations-delete'; + removeButton.textContent = 'βˆ’'; + removeButton.addEventListener('click', () => { + const nextLinks = node.links.filter((_, idx) => idx !== index); + applyRelationsLinks(node, id, nextLinks); + renderList(); + }); + + row.append(label, removeButton); + list.append(row); + }); + }; + + addButton.addEventListener('click', () => { + const source = sourceSelect.value; + const target = targetSelect.value; + if (!source || !target) return; + const nextLinks = [...(node.links ?? [])]; + if ( + nextLinks.some( + (link) => link.source === source && link.target === target, + ) + ) { + return; + } + nextLinks.push({ source, target }); + applyRelationsLinks(node, id, nextLinks); + renderList(); + }); + + renderList(); + editor.append(title, controls, list); + container.append(editor); + }; + + const applyRelationsLinks = (node, id, nextLinks) => { + const draft = { ...node, links: nextLinks }; + const validation = validateNode(draft, node.type); + if (!validation.success) { + setEditorError(validation.message); + return; + } + + node.links = nextLinks; + patchmap.update({ + path: `$..[?(@.id=="${id}")]`, + changes: { links: nextLinks }, + mergeStrategy: 'replace', + }); + setEditorValue(state.currentData); + clearEditorError(); + setLastAction(`Updated ${id} links`); + }; + + const buildRelationsOptions = () => { + const options = []; + state.nodeIndex.forEach((entry, entryId) => { + const type = entry.node?.type; + if (!type || componentTypes.has(type)) return; + options.push({ value: entryId, label: entryId }); + if (type === 'grid' && Array.isArray(entry.node.cells)) { + entry.node.cells.forEach((row, rowIndex) => { + row.forEach((cell, colIndex) => { + if (cell === 0 || cell == null) return; + options.push({ + value: `${entryId}.${rowIndex}.${colIndex}`, + label: `${entryId}.${rowIndex}.${colIndex}`, + }); + }); + }); + } + }); + return options; + }; + + return { renderRelationsEditor }; +}; diff --git a/playground/data-editor/inspector.js b/playground/data-editor/inspector.js new file mode 100644 index 00000000..479369cc --- /dev/null +++ b/playground/data-editor/inspector.js @@ -0,0 +1,273 @@ +import { renderInspectorFields } from './inspector-fields.js'; +import { createGridEditor } from './inspector-grid.js'; +import { createRelationsEditor } from './inspector-relations.js'; + +export const createInspector = ({ + patchmap, + elements, + state, + componentTypes, + colorPresets, + colorPresetValues, + resolveNodeSchema, + validateNode, + formatPxPercent, + coerceValue, + setNodeValue, + buildChangesFromPath, + setEditorValue, + setEditorError, + clearEditorError, + setLastAction, +}) => { + const { renderRelationsEditor } = createRelationsEditor({ + patchmap, + state, + componentTypes, + validateNode, + setEditorValue, + setEditorError, + clearEditorError, + setLastAction, + }); + + const { renderGridEditor } = createGridEditor({ + patchmap, + state, + setEditorValue, + clearEditorError, + setLastAction, + }); + + const renderInspector = (id) => { + const container = elements.inspectorContent ?? elements.dataInspector; + if (!container) return; + container.replaceChildren(); + + if (!id) { + container.append(buildInspectorEmpty('Select a node')); + return; + } + + const entry = state.nodeIndex.get(id); + if (!entry) { + container.append(buildInspectorEmpty('No editable data')); + return; + } + + const { node } = entry; + const resolved = resolveNodeSchema(node); + const data = resolved.parsed ?? node; + + const buildInput = (value, options = {}) => { + let input; + if (options.options) { + input = document.createElement('select'); + options.options.forEach((option) => { + const item = document.createElement('option'); + item.value = option.value; + item.textContent = option.label; + input.append(item); + }); + if (value != null) { + input.value = String(value); + } + } else if (options.type === 'boolean') { + input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = Boolean(value); + } else { + input = document.createElement('input'); + input.type = options.type ?? 'text'; + input.value = value ?? ''; + } + + input.className = 'inspector-input'; + if (options.compact) { + input.classList.add('inspector-input--compact'); + } + + if (options.readOnly) { + input.readOnly = true; + } else if (options.path) { + input.addEventListener('change', (event) => { + const nextValue = + options.type === 'boolean' + ? event.target.checked + : event.target.value; + handleInspectorChange( + id, + node, + options.path, + nextValue, + options.type, + options.originalValue, + ); + }); + } + return input; + }; + + const addField = (label, value, options = {}) => { + const field = document.createElement('div'); + field.className = 'inspector-field'; + + const labelEl = document.createElement('div'); + labelEl.className = 'inspector-label'; + labelEl.textContent = label; + + const input = buildInput(value, options); + field.append(labelEl, input); + container.append(field); + }; + + const addInlineFields = (label, fields) => { + const field = document.createElement('div'); + field.className = 'inspector-field inspector-field--inline'; + + const labelEl = document.createElement('div'); + labelEl.className = 'inspector-label'; + labelEl.textContent = label; + + const group = document.createElement('div'); + group.className = 'inspector-inline'; + + fields.forEach((item) => { + const wrap = document.createElement('div'); + wrap.className = 'inspector-inline-item'; + + const tag = document.createElement('div'); + tag.className = 'inspector-inline-label'; + tag.textContent = item.short; + + const input = buildInput(item.value, { + ...item.options, + path: item.path, + type: item.type, + originalValue: item.originalValue, + compact: true, + }); + + wrap.append(tag, input); + group.append(wrap); + }); + + field.append(labelEl, group); + container.append(field); + }; + + const addColorField = (label, value, path) => { + const field = document.createElement('div'); + field.className = 'inspector-field inspector-field--inline'; + + const labelEl = document.createElement('div'); + labelEl.className = 'inspector-label'; + labelEl.textContent = label; + + const group = document.createElement('div'); + group.className = 'inspector-inline'; + + const select = document.createElement('select'); + select.className = + 'inspector-input inspector-input--compact color-select'; + + const customOption = document.createElement('option'); + customOption.value = '__custom__'; + customOption.textContent = 'Custom'; + select.append(customOption); + + colorPresets.forEach((preset) => { + const option = document.createElement('option'); + option.value = preset.value; + option.textContent = preset.label; + select.append(option); + }); + + const stringValue = value == null ? '' : String(value); + select.value = colorPresetValues.has(stringValue) + ? stringValue + : '__custom__'; + + const input = document.createElement('input'); + input.className = 'inspector-input color-input'; + input.type = 'text'; + input.value = stringValue; + input.placeholder = '#ffffff'; + + select.addEventListener('change', () => { + if (select.value === '__custom__') { + input.focus(); + return; + } + input.value = select.value; + handleInspectorChange(id, node, path, select.value, 'text', value); + }); + + input.addEventListener('change', (event) => { + const nextValue = event.target.value; + select.value = colorPresetValues.has(nextValue) + ? nextValue + : '__custom__'; + handleInspectorChange(id, node, path, nextValue, 'text', value); + }); + + group.append(select, input); + field.append(labelEl, group); + container.append(field); + }; + + renderInspectorFields({ + container, + node, + id, + data, + resolved, + addField, + addInlineFields, + addColorField, + formatPxPercent, + renderRelationsEditor, + renderGridEditor, + }); + }; + + const buildInspectorEmpty = (text) => { + const empty = document.createElement('div'); + empty.className = 'inspector-empty'; + empty.textContent = text; + return empty; + }; + + const handleInspectorChange = ( + id, + node, + path, + rawValue, + inputType, + originalValue, + ) => { + const value = coerceValue(rawValue, inputType, originalValue); + if (value === null) return; + + const draft = JSON.parse(JSON.stringify(node)); + setNodeValue(draft, path, value); + const validation = validateNode(draft, node.type); + if (!validation.success) { + setEditorError(validation.message); + renderInspector(id); + return; + } + + setNodeValue(node, path, value); + patchmap.update({ + path: `$..[?(@.id=="${id}")]`, + changes: buildChangesFromPath(path, value), + }); + + setEditorValue(state.currentData); + clearEditorError(); + setLastAction(`Updated ${id}`); + }; + + return { renderInspector }; +}; diff --git a/playground/data-editor/tree.js b/playground/data-editor/tree.js new file mode 100644 index 00000000..f2acc298 --- /dev/null +++ b/playground/data-editor/tree.js @@ -0,0 +1,116 @@ +export const createTree = ({ elements, state, updateAddParentOptions }) => { + const renderTree = () => { + if (!elements.dataTree) return; + elements.dataTree.replaceChildren(); + state.nodeIndex = new Map(); + state.treeItemById = new Map(); + if (!Array.isArray(state.currentData)) return; + + const fragment = document.createDocumentFragment(); + const walk = (nodes, depth = 0, parentId = null) => { + nodes.forEach((node) => { + if (!node || !node.id) return; + if (state.nodeIndex.has(node.id)) { + return; + } + state.nodeIndex.set(node.id, { node, parentId, depth }); + + const row = document.createElement('div'); + row.className = 'tree-row'; + row.style.paddingLeft = `${6 + depth * 12}px`; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'tree-label'; + button.dataset.nodeId = node.id; + + const label = document.createElement('span'); + label.className = 'tree-id'; + label.textContent = node.id; + const type = document.createElement('span'); + type.className = 'tree-type'; + type.textContent = node.type ?? 'node'; + + button.append(label, type); + if (node.id === state.selectedNodeId) { + button.classList.add('is-active'); + } + + const actions = document.createElement('div'); + actions.className = 'tree-actions'; + + const addButton = document.createElement('button'); + addButton.type = 'button'; + addButton.className = 'tree-action'; + addButton.dataset.action = 'add'; + addButton.dataset.parentId = + node.type === 'grid' || node.type === 'item' + ? node.id + : (parentId ?? '__root__'); + addButton.textContent = '+'; + + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.className = 'tree-action'; + deleteButton.dataset.action = 'delete'; + deleteButton.dataset.nodeId = node.id; + deleteButton.textContent = 'βˆ’'; + + actions.append(addButton, deleteButton); + row.append(button, actions); + + state.treeItemById.set(node.id, button); + fragment.append(row); + + if (Array.isArray(node.children)) { + walk(node.children, depth + 1, node.id); + } + if (node.type === 'item' && Array.isArray(node.components)) { + walk(node.components, depth + 1, node.id); + } + if (node.type === 'grid' && Array.isArray(node.item?.components)) { + walk(node.item.components, depth + 1, node.id); + } + }); + }; + + walk(state.currentData); + fragment.append(buildRootAddRow()); + elements.dataTree.append(fragment); + updateAddParentOptions?.(); + }; + + const highlightTree = (id) => { + state.treeItemById.forEach((item) => item.classList.remove('is-active')); + if (!id) return; + const target = state.treeItemById.get(id); + if (target) { + target.classList.add('is-active'); + } + }; + + return { renderTree, highlightTree }; +}; + +const buildRootAddRow = () => { + const row = document.createElement('div'); + row.className = 'tree-row'; + + const label = document.createElement('div'); + label.className = 'tree-root-label'; + label.textContent = 'Root'; + + const actions = document.createElement('div'); + actions.className = 'tree-actions'; + + const addButton = document.createElement('button'); + addButton.type = 'button'; + addButton.className = 'tree-action'; + addButton.dataset.action = 'add'; + addButton.dataset.parentId = '__root__'; + addButton.textContent = '+'; + + actions.append(addButton); + row.append(label, actions); + return row; +}; diff --git a/playground/data-editor/utils.js b/playground/data-editor/utils.js new file mode 100644 index 00000000..a747c409 --- /dev/null +++ b/playground/data-editor/utils.js @@ -0,0 +1,105 @@ +import { componentSchema } from '../../src/display/data-schema/component-schema.js'; +import { elementTypes } from '../../src/display/data-schema/element-schema.js'; +import { componentTypes } from './constants.js'; + +export const formatError = (error) => { + if (error?.message) return error.message; + try { + return JSON.stringify(error); + } catch { + return String(error); + } +}; + +export const coerceValue = (rawValue, inputType, originalValue) => { + if (inputType === 'number') { + if (rawValue === '') return null; + const numberValue = Number(rawValue); + return Number.isNaN(numberValue) ? null : numberValue; + } + if (inputType === 'boolean') { + return Boolean(rawValue); + } + if (typeof originalValue === 'number') { + const numberValue = Number(rawValue); + if (!Number.isNaN(numberValue)) { + return numberValue; + } + } + return rawValue; +}; + +export const setNodeValue = (node, path, value) => { + const keys = path.split('.'); + let current = node; + keys.forEach((key, index) => { + if (index === keys.length - 1) { + current[key] = value; + return; + } + if (typeof current[key] !== 'object' || current[key] === null) { + current[key] = {}; + } + current = current[key]; + }); +}; + +export const buildChangesFromPath = (path, value) => { + const keys = path.split('.'); + const changes = {}; + let current = changes; + keys.forEach((key, index) => { + if (index === keys.length - 1) { + current[key] = value; + return; + } + current[key] = {}; + current = current[key]; + }); + return changes; +}; + +export const formatPxPercent = (value) => { + if (value == null) return null; + if (typeof value === 'string' || typeof value === 'number') return value; + if (typeof value === 'object' && 'value' in value && 'unit' in value) { + return `${value.value}${value.unit}`; + } + return null; +}; + +export const resolveNodeSchema = (node) => { + if (!node?.type) { + return { parsed: node, schema: null, kind: 'unknown', error: null }; + } + + const schema = componentTypes.has(node.type) ? componentSchema : elementTypes; + const result = schema.safeParse(node); + if (!result.success) { + return { + parsed: node, + schema, + kind: componentTypes.has(node.type) ? 'component' : 'element', + error: result.error, + }; + } + + return { + parsed: result.data, + schema, + kind: componentTypes.has(node.type) ? 'component' : 'element', + error: null, + }; +}; + +export const validateNode = (node, type) => { + const schema = componentTypes.has(type) ? componentSchema : elementTypes; + const result = schema.safeParse(node); + if (result.success) { + return { success: true, message: '' }; + } + return { + success: false, + message: result.error.issues?.[0]?.message ?? 'Invalid data', + }; +}; From 8152630770ee2007e0d11960e4ef0e844158b8a9 Mon Sep 17 00:00:00 2001 From: perhapsspy Date: Wed, 31 Dec 2025 15:25:20 +0900 Subject: [PATCH 6/8] refactor(playground): split app controllers --- playground/main.js | 398 +++-------------------------- playground/scenario-controller.js | 143 +++++++++++ playground/selection-controller.js | 149 +++++++++++ playground/view-controls.js | 112 ++++++++ 4 files changed, 443 insertions(+), 359 deletions(-) create mode 100644 playground/scenario-controller.js create mode 100644 playground/selection-controller.js create mode 100644 playground/view-controls.js diff --git a/playground/main.js b/playground/main.js index 2bd5375b..329bc262 100644 --- a/playground/main.js +++ b/playground/main.js @@ -1,6 +1,9 @@ import { Patchmap, Transformer } from '@patchmap'; import { createDataEditor } from './data-editor.js'; +import { createScenarioController } from './scenario-controller.js'; import { scenarios } from './scenarios.js'; +import { createSelectionController } from './selection-controller.js'; +import { createViewControls } from './view-controls.js'; const $ = (selector) => document.querySelector(selector); @@ -47,72 +50,27 @@ const elements = { }; const patchmap = new Patchmap(); -let currentScenario = scenarios[0]; -let linkSetIndex = 0; -let isSpaceDown = false; -let ignoreClickAfterDrag = false; const setLastAction = (text) => { elements.lastAction.textContent = text; }; const dataEditor = createDataEditor({ patchmap, elements, setLastAction }); - -const getSelectionList = () => { - if (!patchmap.transformer) return []; - return Array.isArray(patchmap.transformer.elements) - ? patchmap.transformer.elements - : [patchmap.transformer.elements].filter(Boolean); -}; - -const setSelectionList = (list) => { - if (!patchmap.transformer) return; - patchmap.transformer.elements = list; -}; - -const clearSelection = () => { - dataEditor.updateSelection(null); - elements.selectedId.textContent = 'None'; -}; - -const applySelection = (list) => { - const next = list.filter(Boolean); - if (next.length === 0) { - clearSelection(); - return; - } - if (next.length === 1) { - dataEditor.updateSelection(next[0]); - return; - } - dataEditor.updateSelection(null); - setSelectionList(next); - elements.selectedId.textContent = `Multiple (${next.length})`; -}; - -const toggleSelection = (target) => { - if (!target) return; - const current = getSelectionList(); - const exists = current.some((item) => item.id === target.id); - const next = exists - ? current.filter((item) => item.id !== target.id) - : [...current, target]; - applySelection(next); -}; - -const updateSelectionDraggable = (enabled) => { - const state = patchmap.stateManager?.getCurrentState?.(); - if (state?.config) { - state.config.draggable = enabled; - } -}; - -const setDragButtons = (buttons) => { - const dragPlugin = patchmap.viewport?.plugins?.get('drag'); - if (dragPlugin?.mouseButtons) { - dragPlugin.mouseButtons(buttons); - } -}; +const viewControls = createViewControls({ patchmap, elements, setLastAction }); +const scenarioController = createScenarioController({ + patchmap, + dataEditor, + elements, + scenarios, + setLastAction, + syncRotationUI: viewControls.syncRotationUI, + syncFlipUI: viewControls.syncFlipUI, +}); +const selectionController = createSelectionController({ + patchmap, + dataEditor, + elements, +}); const init = async () => { await patchmap.init(elements.stage, { @@ -125,80 +83,32 @@ const init = async () => { }); patchmap.transformer = new Transformer(); - patchmap.stateManager.setState('selection', { - onDown: () => { - if (ignoreClickAfterDrag) { - ignoreClickAfterDrag = false; - } - }, - onDragStart: () => { - ignoreClickAfterDrag = true; - }, - onClick: (target, event) => { - if (ignoreClickAfterDrag) { - ignoreClickAfterDrag = false; - return; - } - if (isSpaceDown) return; - const mod = event?.metaKey || event?.ctrlKey; - if (mod) { - toggleSelection(target); - return; - } - dataEditor.updateSelection(target); - }, - draggable: true, - onDragEnd: (selected, event) => { - ignoreClickAfterDrag = true; - if (!selected || selected.length === 0 || isSpaceDown) { - return; - } - const mod = event?.metaKey || event?.ctrlKey; - if (mod) { - const current = getSelectionList(); - const ids = new Set(current.map((item) => item.id)); - const merged = [ - ...current, - ...selected.filter((item) => !ids.has(item.id)), - ]; - applySelection(merged); - return; - } - applySelection(selected); - }, - }); + selectionController.bindSelectionState(); - setupScenarioOptions(); + scenarioController.setupScenarioOptions(); bindControls(); - applyScenario(currentScenario, { shouldFit: true }); + scenarioController.applyScenario(scenarioController.getCurrentScenario(), { + shouldFit: true, + }); dataEditor.setDataMode('json'); - syncRotationUI(); - syncFlipUI(); + viewControls.syncRotationUI(); + viewControls.syncFlipUI(); window.patchmap = patchmap; window.patchmapScenarios = scenarios; }; -const setupScenarioOptions = () => { - scenarios.forEach((scenario) => { - const option = document.createElement('option'); - option.value = scenario.id; - option.textContent = scenario.name; - elements.scenario.append(option); - }); - elements.scenario.value = currentScenario.id; -}; - const bindControls = () => { elements.scenario.addEventListener('change', (event) => { - const scenario = scenarios.find((item) => item.id === event.target.value); - if (scenario) { - applyScenario(scenario, { shouldFit: true }); - } + scenarioController.setScenarioById(event.target.value, { + shouldFit: true, + }); }); elements.draw.addEventListener('click', () => { - applyScenario(currentScenario, { shouldFit: true }); + scenarioController.applyScenario(scenarioController.getCurrentScenario(), { + shouldFit: true, + }); }); elements.applyData.addEventListener('click', () => { @@ -210,7 +120,9 @@ const bindControls = () => { }); elements.resetData.addEventListener('click', () => { - applyScenario(currentScenario, { shouldFit: true }); + scenarioController.applyScenario(scenarioController.getCurrentScenario(), { + shouldFit: true, + }); setLastAction('Reset to scenario'); }); @@ -263,14 +175,15 @@ const bindControls = () => { } elements.randomize.addEventListener('click', () => { - randomizeMetrics(); + scenarioController.randomizeMetrics(); }); elements.shuffle.addEventListener('click', () => { - shuffleLinks(); + scenarioController.shuffleLinks(); }); elements.focus.addEventListener('click', () => { + const currentScenario = scenarioController.getCurrentScenario(); const targetId = dataEditor.getSelectedNodeId() ?? currentScenario.focusId; if (targetId) { patchmap.focus(targetId); @@ -283,241 +196,8 @@ const bindControls = () => { setLastAction('Fit to content'); }); - elements.reset.addEventListener('click', () => { - patchmap.resetRotation(); - patchmap.resetFlip(); - patchmap.viewport.setZoom(1, true); - patchmap.viewport.moveCenter(0, 0); - syncRotationUI(0); - syncFlipUI(); - setLastAction('Reset zoom'); - }); - - if (elements.rotateRange) { - elements.rotateRange.addEventListener('input', (event) => { - applyRotation(event.target.value, { updateAction: false }); - }); - elements.rotateRange.addEventListener('change', (event) => { - applyRotation(event.target.value); - }); - } - - if (elements.rotateInput) { - elements.rotateInput.addEventListener('change', (event) => { - applyRotation(event.target.value); - }); - elements.rotateInput.addEventListener('keydown', (event) => { - if (event.key === 'Enter') { - applyRotation(event.target.value); - } - }); - } - - if (elements.rotateLeft) { - elements.rotateLeft.addEventListener('click', () => { - patchmap.rotateBy(-15); - syncRotationUI(); - setLastAction(`Rotate ${patchmap.getRotation()}Β°`); - }); - } - - if (elements.rotateRight) { - elements.rotateRight.addEventListener('click', () => { - patchmap.rotateBy(15); - syncRotationUI(); - setLastAction(`Rotate ${patchmap.getRotation()}Β°`); - }); - } - - if (elements.rotateReset) { - elements.rotateReset.addEventListener('click', () => { - patchmap.resetRotation(); - syncRotationUI(0); - setLastAction('Rotate 0Β°'); - }); - } - - if (elements.flipX) { - elements.flipX.addEventListener('click', () => { - patchmap.toggleFlipX(); - syncFlipUI(); - setLastAction(patchmap.getFlip().x ? 'Flip X on' : 'Flip X off'); - }); - } - - if (elements.flipY) { - elements.flipY.addEventListener('click', () => { - patchmap.toggleFlipY(); - syncFlipUI(); - setLastAction(patchmap.getFlip().y ? 'Flip Y on' : 'Flip Y off'); - }); - } - - if (elements.stage) { - elements.stage.addEventListener('contextmenu', (event) => { - event.preventDefault(); - clearSelection(); - }); - } - - window.addEventListener('keydown', (event) => { - if (event.code !== 'Space') return; - const target = event.target; - if ( - target?.tagName === 'INPUT' || - target?.tagName === 'TEXTAREA' || - target?.isContentEditable - ) { - return; - } - if (isSpaceDown) return; - event.preventDefault(); - isSpaceDown = true; - updateSelectionDraggable(false); - setDragButtons('left middle'); - }); - - window.addEventListener('keyup', (event) => { - if (event.code !== 'Space') return; - if (!isSpaceDown) return; - isSpaceDown = false; - updateSelectionDraggable(true); - setDragButtons('middle'); - }); -}; - -const applyScenario = (scenario, { shouldFit = true } = {}) => { - currentScenario = scenario; - linkSetIndex = 0; - - const data = currentScenario.data(); - patchmap.draw(data); - if (shouldFit) { - patchmap.fit(); - } - updateSceneInfo(); - dataEditor.setCurrentData(data); - dataEditor.updateSelection(null); - dataEditor.clearEditorError(); - updateActionButtons(); - syncRotationUI(); - syncFlipUI(); - setLastAction(`Loaded ${currentScenario.name}`); -}; - -const updateSceneInfo = () => { - if (elements.sceneName) { - elements.sceneName.textContent = currentScenario.name; - } - if (elements.sceneTitle) { - elements.sceneTitle.textContent = currentScenario.name; - } - if (elements.sceneDescription) { - elements.sceneDescription.textContent = currentScenario.description; - } -}; - -const updateActionButtons = () => { - const dynamic = currentScenario.dynamic ?? {}; - elements.randomize.disabled = (dynamic.bars ?? []).length === 0; - elements.shuffle.disabled = - !dynamic.relationsId || (dynamic.linkSets ?? []).length < 2; -}; - -const randomizeMetrics = () => { - const bars = currentScenario.dynamic?.bars ?? []; - bars.forEach((bar) => { - if (bar.path) { - const targets = patchmap.selector(bar.path); - targets.forEach((target) => { - const value = randomInt(bar.min ?? 10, bar.max ?? 90); - patchmap.update({ - elements: target, - changes: { size: buildBarSize(bar, value) }, - }); - }); - return; - } - - if (!bar.id) return; - const value = randomInt(bar.min ?? 10, bar.max ?? 90); - patchmap.update({ - path: `$..[?(@.id=="${bar.id}")]`, - changes: { size: buildBarSize(bar, value) }, - }); - - if (bar.textId) { - const label = bar.label ?? 'Metric'; - const suffix = bar.unit ?? (label === 'Latency' ? 'ms' : '%'); - const text = `${label} ${value}${suffix}`; - patchmap.update({ - path: `$..[?(@.id=="${bar.textId}")]`, - changes: { text }, - }); - } - }); - - setLastAction('Randomized metrics'); -}; - -const shuffleLinks = () => { - const links = currentScenario.dynamic?.linkSets ?? []; - if (!currentScenario.dynamic?.relationsId || links.length === 0) { - return; - } - - linkSetIndex = (linkSetIndex + 1) % links.length; - - patchmap.update({ - path: `$..[?(@.id=="${currentScenario.dynamic.relationsId}")]`, - changes: { links: links[linkSetIndex] }, - mergeStrategy: 'replace', - }); - - setLastAction('Rerouted links'); -}; - -const buildBarSize = (bar, value) => { - const axis = bar.axis ?? 'width'; - if (axis === 'height') { - return { width: bar.width ?? '100%', height: `${value}%` }; - } - return { width: `${value}%`, height: bar.height ?? 10 }; -}; - -const clampRotation = (value) => { - const parsed = Number(value); - if (Number.isNaN(parsed)) return 0; - return Math.min(180, Math.max(-180, parsed)); -}; - -const syncRotationUI = (angle = patchmap.getRotation()) => { - const nextAngle = clampRotation(angle); - if (elements.rotateRange) { - elements.rotateRange.value = String(nextAngle); - } - if (elements.rotateInput) { - elements.rotateInput.value = String(nextAngle); - } -}; - -const applyRotation = (value, { updateAction = true } = {}) => { - const nextAngle = clampRotation(value); - patchmap.setRotation(nextAngle); - syncRotationUI(nextAngle); - if (updateAction) { - setLastAction(`Rotate ${nextAngle}Β°`); - } -}; - -const syncFlipUI = () => { - const { x, y } = patchmap.getFlip(); - elements.flipX?.classList.toggle('is-active', x); - elements.flipY?.classList.toggle('is-active', y); -}; - -const randomInt = (min, max) => { - return Math.floor(Math.random() * (max - min + 1)) + min; + viewControls.bindViewControls(); + selectionController.bindSelectionShortcuts(); }; init(); diff --git a/playground/scenario-controller.js b/playground/scenario-controller.js new file mode 100644 index 00000000..caccc6c0 --- /dev/null +++ b/playground/scenario-controller.js @@ -0,0 +1,143 @@ +export const createScenarioController = ({ + patchmap, + dataEditor, + elements, + scenarios, + setLastAction, + syncRotationUI, + syncFlipUI, +}) => { + let currentScenario = scenarios[0]; + let linkSetIndex = 0; + + const setupScenarioOptions = () => { + scenarios.forEach((scenario) => { + const option = document.createElement('option'); + option.value = scenario.id; + option.textContent = scenario.name; + elements.scenario.append(option); + }); + elements.scenario.value = currentScenario.id; + }; + + const applyScenario = (scenario, { shouldFit = true } = {}) => { + currentScenario = scenario; + linkSetIndex = 0; + + const data = currentScenario.data(); + patchmap.draw(data); + if (shouldFit) { + patchmap.fit(); + } + updateSceneInfo(); + dataEditor.setCurrentData(data); + dataEditor.updateSelection(null); + dataEditor.clearEditorError(); + updateActionButtons(); + syncRotationUI(); + syncFlipUI(); + setLastAction(`Loaded ${currentScenario.name}`); + }; + + const setScenarioById = (scenarioId, options) => { + const scenario = scenarios.find((item) => item.id === scenarioId); + if (scenario) { + applyScenario(scenario, options); + } + }; + + const updateSceneInfo = () => { + if (elements.sceneName) { + elements.sceneName.textContent = currentScenario.name; + } + if (elements.sceneTitle) { + elements.sceneTitle.textContent = currentScenario.name; + } + if (elements.sceneDescription) { + elements.sceneDescription.textContent = currentScenario.description; + } + }; + + const updateActionButtons = () => { + const dynamic = currentScenario.dynamic ?? {}; + elements.randomize.disabled = (dynamic.bars ?? []).length === 0; + elements.shuffle.disabled = + !dynamic.relationsId || (dynamic.linkSets ?? []).length < 2; + }; + + const randomizeMetrics = () => { + const bars = currentScenario.dynamic?.bars ?? []; + bars.forEach((bar) => { + if (bar.path) { + const targets = patchmap.selector(bar.path); + targets.forEach((target) => { + const value = randomInt(bar.min ?? 10, bar.max ?? 90); + patchmap.update({ + elements: target, + changes: { size: buildBarSize(bar, value) }, + }); + }); + return; + } + + if (!bar.id) return; + const value = randomInt(bar.min ?? 10, bar.max ?? 90); + patchmap.update({ + path: `$..[?(@.id=="${bar.id}")]`, + changes: { size: buildBarSize(bar, value) }, + }); + + if (bar.textId) { + const label = bar.label ?? 'Metric'; + const suffix = bar.unit ?? (label === 'Latency' ? 'ms' : '%'); + const text = `${label} ${value}${suffix}`; + patchmap.update({ + path: `$..[?(@.id=="${bar.textId}")]`, + changes: { text }, + }); + } + }); + + setLastAction('Randomized metrics'); + }; + + const shuffleLinks = () => { + const links = currentScenario.dynamic?.linkSets ?? []; + if (!currentScenario.dynamic?.relationsId || links.length === 0) { + return; + } + + linkSetIndex = (linkSetIndex + 1) % links.length; + + patchmap.update({ + path: `$..[?(@.id=="${currentScenario.dynamic.relationsId}")]`, + changes: { links: links[linkSetIndex] }, + mergeStrategy: 'replace', + }); + + setLastAction('Rerouted links'); + }; + + const getCurrentScenario = () => currentScenario; + + const buildBarSize = (bar, value) => { + const axis = bar.axis ?? 'width'; + if (axis === 'height') { + return { width: bar.width ?? '100%', height: `${value}%` }; + } + return { width: `${value}%`, height: bar.height ?? 10 }; + }; + + const randomInt = (min, max) => { + return Math.floor(Math.random() * (max - min + 1)) + min; + }; + + return { + setupScenarioOptions, + applyScenario, + setScenarioById, + randomizeMetrics, + shuffleLinks, + getCurrentScenario, + }; +}; diff --git a/playground/selection-controller.js b/playground/selection-controller.js new file mode 100644 index 00000000..43917374 --- /dev/null +++ b/playground/selection-controller.js @@ -0,0 +1,149 @@ +export const createSelectionController = ({ + patchmap, + dataEditor, + elements, +}) => { + let isSpaceDown = false; + let ignoreClickAfterDrag = false; + + const getSelectionList = () => { + if (!patchmap.transformer) return []; + return Array.isArray(patchmap.transformer.elements) + ? patchmap.transformer.elements + : [patchmap.transformer.elements].filter(Boolean); + }; + + const setSelectionList = (list) => { + if (!patchmap.transformer) return; + patchmap.transformer.elements = list; + }; + + const clearSelection = () => { + dataEditor.updateSelection(null); + elements.selectedId.textContent = 'None'; + }; + + const applySelection = (list) => { + const next = list.filter(Boolean); + if (next.length === 0) { + clearSelection(); + return; + } + if (next.length === 1) { + dataEditor.updateSelection(next[0]); + return; + } + dataEditor.updateSelection(null); + setSelectionList(next); + elements.selectedId.textContent = `Multiple (${next.length})`; + }; + + const toggleSelection = (target) => { + if (!target) return; + const current = getSelectionList(); + const exists = current.some((item) => item.id === target.id); + const next = exists + ? current.filter((item) => item.id !== target.id) + : [...current, target]; + applySelection(next); + }; + + const updateSelectionDraggable = (enabled) => { + const state = patchmap.stateManager?.getCurrentState?.(); + if (state?.config) { + state.config.draggable = enabled; + } + }; + + const setDragButtons = (buttons) => { + const dragPlugin = patchmap.viewport?.plugins?.get('drag'); + if (dragPlugin?.mouseButtons) { + dragPlugin.mouseButtons(buttons); + } + }; + + const bindSelectionState = () => { + patchmap.stateManager.setState('selection', { + onDown: () => { + if (ignoreClickAfterDrag) { + ignoreClickAfterDrag = false; + } + }, + onDragStart: () => { + ignoreClickAfterDrag = true; + }, + onClick: (target, event) => { + if (ignoreClickAfterDrag) { + ignoreClickAfterDrag = false; + return; + } + if (isSpaceDown) return; + const mod = event?.metaKey || event?.ctrlKey; + if (mod) { + toggleSelection(target); + return; + } + dataEditor.updateSelection(target); + }, + draggable: true, + onDragEnd: (selected, event) => { + ignoreClickAfterDrag = true; + if (!selected || selected.length === 0 || isSpaceDown) { + return; + } + const mod = event?.metaKey || event?.ctrlKey; + if (mod) { + const current = getSelectionList(); + const ids = new Set(current.map((item) => item.id)); + const merged = [ + ...current, + ...selected.filter((item) => !ids.has(item.id)), + ]; + applySelection(merged); + return; + } + applySelection(selected); + }, + }); + }; + + const bindSelectionShortcuts = () => { + if (elements.stage) { + elements.stage.addEventListener('contextmenu', (event) => { + event.preventDefault(); + clearSelection(); + }); + } + + window.addEventListener('keydown', (event) => { + if (event.code !== 'Space') return; + const target = event.target; + if ( + target?.tagName === 'INPUT' || + target?.tagName === 'TEXTAREA' || + target?.isContentEditable + ) { + return; + } + if (isSpaceDown) return; + event.preventDefault(); + isSpaceDown = true; + updateSelectionDraggable(false); + setDragButtons('left middle'); + }); + + window.addEventListener('keyup', (event) => { + if (event.code !== 'Space') return; + if (!isSpaceDown) return; + isSpaceDown = false; + updateSelectionDraggable(true); + setDragButtons('middle'); + }); + }; + + return { + bindSelectionState, + bindSelectionShortcuts, + clearSelection, + }; +}; diff --git a/playground/view-controls.js b/playground/view-controls.js new file mode 100644 index 00000000..d2f35da2 --- /dev/null +++ b/playground/view-controls.js @@ -0,0 +1,112 @@ +export const createViewControls = ({ patchmap, elements, setLastAction }) => { + const clampRotation = (value) => { + const parsed = Number(value); + if (Number.isNaN(parsed)) return 0; + return Math.min(180, Math.max(-180, parsed)); + }; + + const syncRotationUI = (angle = patchmap.getRotation()) => { + const nextAngle = clampRotation(angle); + if (elements.rotateRange) { + elements.rotateRange.value = String(nextAngle); + } + if (elements.rotateInput) { + elements.rotateInput.value = String(nextAngle); + } + }; + + const applyRotation = (value, { updateAction = true } = {}) => { + const nextAngle = clampRotation(value); + patchmap.setRotation(nextAngle); + syncRotationUI(nextAngle); + if (updateAction) { + setLastAction(`Rotate ${nextAngle}Β°`); + } + }; + + const syncFlipUI = () => { + const { x, y } = patchmap.getFlip(); + elements.flipX?.classList.toggle('is-active', x); + elements.flipY?.classList.toggle('is-active', y); + }; + + const bindViewControls = () => { + if (elements.reset) { + elements.reset.addEventListener('click', () => { + patchmap.resetRotation(); + patchmap.resetFlip(); + patchmap.viewport.setZoom(1, true); + patchmap.viewport.moveCenter(0, 0); + syncRotationUI(0); + syncFlipUI(); + setLastAction('Reset zoom'); + }); + } + + if (elements.rotateRange) { + elements.rotateRange.addEventListener('input', (event) => { + applyRotation(event.target.value, { updateAction: false }); + }); + elements.rotateRange.addEventListener('change', (event) => { + applyRotation(event.target.value); + }); + } + + if (elements.rotateInput) { + elements.rotateInput.addEventListener('change', (event) => { + applyRotation(event.target.value); + }); + elements.rotateInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + applyRotation(event.target.value); + } + }); + } + + if (elements.rotateLeft) { + elements.rotateLeft.addEventListener('click', () => { + patchmap.rotateBy(-15); + syncRotationUI(); + setLastAction(`Rotate ${patchmap.getRotation()}Β°`); + }); + } + + if (elements.rotateRight) { + elements.rotateRight.addEventListener('click', () => { + patchmap.rotateBy(15); + syncRotationUI(); + setLastAction(`Rotate ${patchmap.getRotation()}Β°`); + }); + } + + if (elements.rotateReset) { + elements.rotateReset.addEventListener('click', () => { + patchmap.resetRotation(); + syncRotationUI(0); + setLastAction('Rotate 0Β°'); + }); + } + + if (elements.flipX) { + elements.flipX.addEventListener('click', () => { + patchmap.toggleFlipX(); + syncFlipUI(); + setLastAction(patchmap.getFlip().x ? 'Flip X on' : 'Flip X off'); + }); + } + + if (elements.flipY) { + elements.flipY.addEventListener('click', () => { + patchmap.toggleFlipY(); + syncFlipUI(); + setLastAction(patchmap.getFlip().y ? 'Flip Y on' : 'Flip Y off'); + }); + } + }; + + return { + bindViewControls, + syncRotationUI, + syncFlipUI, + }; +}; From ef55c117997e68540d411dd1c50b361178343414 Mon Sep 17 00:00:00 2001 From: perhapsspy Date: Wed, 31 Dec 2025 15:25:36 +0900 Subject: [PATCH 7/8] refactor(playground): split stylesheets --- playground/style.css | 792 +----------------------------- playground/styles/base.css | 41 ++ playground/styles/controls.css | 112 +++++ playground/styles/data-editor.css | 391 +++++++++++++++ playground/styles/layout.css | 134 +++++ playground/styles/responsive.css | 34 ++ playground/styles/stage.css | 69 +++ 7 files changed, 787 insertions(+), 786 deletions(-) create mode 100644 playground/styles/base.css create mode 100644 playground/styles/controls.css create mode 100644 playground/styles/data-editor.css create mode 100644 playground/styles/layout.css create mode 100644 playground/styles/responsive.css create mode 100644 playground/styles/stage.css diff --git a/playground/style.css b/playground/style.css index cd96e106..f416a278 100644 --- a/playground/style.css +++ b/playground/style.css @@ -1,786 +1,6 @@ -:root { - color-scheme: light; - --bg: #f2f3f5; - --ink: #1f2428; - --muted: #6b737b; - --accent: #1f2933; - --panel: #ffffff; - --panel-border: rgba(31, 36, 40, 0.16); - --mono: 'IBM Plex Mono', monospace; - --radius-lg: 22px; - --radius-md: 14px; -} - -* { - box-sizing: border-box; -} - -html, -body { - height: 100%; -} - -body { - margin: 0; - min-height: 100%; - font-family: 'Space Grotesk', sans-serif; - color: var(--ink); - background: var(--bg); - overflow: hidden; -} - -.app { - padding: 0; - display: flex; - flex-direction: column; - gap: 0; - height: 100%; -} - -.topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 4px 8px; - background: transparent; - border-bottom: 1px solid var(--panel-border); - border-radius: 0; - box-shadow: none; - backdrop-filter: none; - animation: float-in 0.6s ease-out; -} - -.brand { - display: flex; - align-items: center; - gap: 10px; -} - -.logo { - width: 32px; - height: 32px; - border-radius: 10px; - background: #e6e9ec; - display: grid; - place-items: center; - font-weight: 600; - letter-spacing: 0.04em; -} - -.title { - font-size: 12px; - font-weight: 500; - letter-spacing: 0.04em; -} - -.content { - display: grid; - grid-template-columns: minmax(220px, 3fr) minmax(0, 7fr); - grid-template-rows: minmax(0, 1fr); - gap: 0; - flex: 1; - min-height: 0; -} - -.data-panel { - display: flex; - flex-direction: column; - gap: 6px; - padding: 4px 6px; - background: transparent; - border-radius: 0; - border-right: 1px solid var(--panel-border); - box-shadow: none; - backdrop-filter: none; - animation: float-in 0.75s ease-out; - min-height: 0; - height: 100%; - overflow: hidden; -} - -.panel-section { - display: flex; - flex-direction: column; - gap: 2px; - flex: 1; - min-height: 0; - overflow: hidden; -} - -.field { - display: flex; - flex-direction: column; - gap: 4px; -} - -.data-panel .field { - flex: 1; -} - -select { - padding: 4px 6px; - border-radius: 6px; - border: 1px solid var(--panel-border); - background: var(--panel); - font-family: inherit; - font-size: 11px; -} - -.btn-row { - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.data-controls { - align-items: center; -} - -.data-controls select { - min-width: 120px; -} - -.data-tabs { - display: flex; - gap: 6px; - align-items: center; - border-bottom: 1px solid var(--panel-border); - padding-bottom: 2px; -} - -.data-tab { - padding: 2px 0; - border: 0; - border-bottom: 2px solid transparent; - background: transparent; - font-size: 9px; - letter-spacing: 0.1em; - color: var(--muted); - cursor: pointer; -} - -.data-tab.is-active { - color: var(--ink); - border-bottom-color: var(--ink); - font-weight: 600; -} - -.data-tab:focus-visible { - outline: 1px solid var(--panel-border); - outline-offset: 2px; -} - -.data-view { - display: flex; - flex-direction: column; - gap: 2px; - flex: 1; - min-height: 0; - overflow: hidden; -} - -.data-view[hidden] { - display: none !important; -} - -.data-form { - display: grid; - grid-template-rows: minmax(0, 1fr) minmax(120px, 35vh); - gap: 2px; - min-height: 0; - overflow: hidden; - position: relative; - height: 100%; -} - -#data-json { - flex: 1; - position: relative; -} - -#data-json textarea { - flex: 1; - min-height: 0; - max-height: none; - padding-bottom: 26px; -} - -.data-tree { - overflow-y: auto; - overflow-x: hidden; - min-height: 0; - max-height: 100%; - padding: 0; - border-bottom: 1px solid var(--panel-border); -} - -.tree-row { - display: flex; - align-items: center; - gap: 6px; - width: 100%; - padding: 2px 6px; - min-width: 0; -} - -.tree-row:hover { - background: #e9ecef; -} - -.tree-label { - display: flex; - align-items: center; - gap: 6px; - flex: 1; - border: 0; - background: transparent; - color: var(--ink); - font-size: 10px; - text-align: left; - cursor: pointer; - padding: 0; - min-width: 0; -} - -.tree-label.is-active { - font-weight: 600; -} - -.tree-id { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.tree-type { - font-size: 9px; - color: var(--muted); -} - -.tree-actions { - display: flex; - gap: 4px; -} - -.tree-action { - width: 16px; - height: 16px; - border-radius: 4px; - border: 1px solid var(--panel-border); - background: var(--panel); - font-size: 12px; - line-height: 1; - display: grid; - place-items: center; - padding: 0; - cursor: pointer; -} - -.tree-action:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.tree-root-label { - font-size: 9px; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.08em; - padding-left: 6px; -} - -.data-inspector { - display: flex; - flex-direction: column; - gap: 6px; - overflow: hidden; - min-height: 0; - overflow-x: hidden; -} - -.data-popover { - position: absolute; - top: 4px; - left: 6px; - right: 6px; - padding: 6px; - border: 1px solid var(--panel-border); - background: var(--panel); - display: grid; - gap: 4px; - z-index: 10; -} - -.data-popover[hidden] { - display: none; -} - -.data-popover-actions { - display: flex; - gap: 4px; -} - -.inspector-content { - display: grid; - gap: 6px; - overflow-y: auto; - overflow-x: hidden; - min-height: 0; - padding-right: 2px; -} - -.inspector-empty { - font-size: 10px; - color: var(--muted); - padding: 4px 2px; -} - -.inspector-field { - display: grid; - grid-template-columns: 56px minmax(0, 1fr); - gap: 6px; - align-items: center; - min-width: 0; -} - -.inspector-field--inline { - align-items: start; -} - -.inspector-inline { - display: flex; - gap: 6px; - min-width: 0; - flex-wrap: wrap; -} - -.inspector-inline-item { - display: flex; - align-items: center; - gap: 4px; -} - -.inspector-inline-label { - font-size: 9px; - color: var(--muted); -} - -.inspector-label { - font-size: 9px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--muted); -} - -.inspector-input { - width: 100%; - padding: 4px 6px; - border-radius: 6px; - border: 1px solid var(--panel-border); - background: var(--panel); - font-size: 10px; - color: var(--ink); - min-width: 0; -} - -.inspector-input--compact { - width: 64px; - padding: 2px 6px; -} - -.color-select { - width: 110px; -} - -.color-input { - width: 120px; -} - -.inspector-input[readonly] { - background: #f3f4f6; - color: var(--muted); -} - -.relations-editor { - display: grid; - gap: 6px; -} - -.relations-title { - font-size: 9px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--muted); -} - -.relations-controls { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; - gap: 4px; - align-items: center; -} - -.relations-select { - padding: 4px 6px; - border-radius: 6px; - border: 1px solid var(--panel-border); - background: var(--panel); - font-size: 10px; - color: var(--ink); - min-width: 0; -} - -.relations-add { - padding: 4px 8px; - border-radius: 6px; - border: 1px solid var(--panel-border); - background: var(--panel); - font-size: 10px; - cursor: pointer; -} - -.relations-list { - display: grid; - gap: 4px; -} - -.relations-row { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; -} - -.relations-label { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 10px; -} - -.relations-delete { - width: 16px; - height: 16px; - border-radius: 4px; - border: 1px solid var(--panel-border); - background: var(--panel); - font-size: 12px; - line-height: 1; - display: grid; - place-items: center; - padding: 0; - cursor: pointer; -} - -.relations-empty { - font-size: 10px; - color: var(--muted); -} - -.grid-editor { - display: grid; - gap: 6px; -} - -.grid-title { - font-size: 9px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--muted); -} - -.grid-controls { - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.grid-control { - padding: 2px 6px; - border-radius: 6px; - border: 1px solid var(--panel-border); - background: var(--panel); - font-size: 9px; - cursor: pointer; -} - -.grid-cells { - display: grid; - gap: 2px; - grid-template-columns: repeat(var(--grid-cols), 16px); -} - -.grid-cell { - width: 16px; - height: 16px; - border-radius: 4px; - border: 1px solid var(--panel-border); - background: transparent; - cursor: pointer; - padding: 0; -} - -.grid-cell.is-active { - background: var(--ink); -} - -.btn { - padding: 4px 8px; - border-radius: 6px; - border: 1px solid var(--panel-border); - background: var(--panel); - font-size: 10px; - font-weight: 500; - text-align: center; - cursor: pointer; - transition: transform 0.2s ease, box-shadow 0.2s ease, border 0.2s ease; - white-space: nowrap; -} - -.btn:hover { - transform: none; - border-color: rgba(31, 36, 40, 0.35); - box-shadow: none; -} - -.btn.is-active { - border-color: rgba(31, 36, 40, 0.45); - font-weight: 600; -} - -.btn:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -textarea { - min-height: 200px; - resize: vertical; - border-radius: 6px; - border: 1px solid var(--panel-border); - padding: 6px 8px; - font-family: var(--mono); - font-size: 10px; - line-height: 1.35; - background: var(--panel); - color: var(--ink); -} - -.data-panel textarea { - flex: 1; -} - -.editor-error { - position: absolute; - left: 6px; - right: 6px; - bottom: 6px; - padding: 4px 6px; - border: 1px solid var(--panel-border); - background: var(--panel); - font-size: 10px; - color: #b42318; - pointer-events: none; -} - -.editor-error:empty { - display: none; -} - -.stage { - display: flex; - flex-direction: column; - gap: 0; - padding: 0; - border-radius: 0; - border: 0; - background: transparent; - box-shadow: none; - backdrop-filter: none; - min-height: 0; - height: 100%; - animation: float-in 0.9s ease-out; -} - -.stage-controls { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px; - padding: 4px 6px; - border-bottom: 1px solid var(--panel-border); -} - -.rotate-controls { - display: flex; - align-items: center; - gap: 4px; - flex-wrap: wrap; -} - -.rotate-label { - font-size: 9px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--muted); -} - -.rotate-range { - width: 120px; - accent-color: var(--ink); -} - -.rotate-input { - width: 56px; - padding: 2px 6px; - border-radius: 6px; - border: 1px solid var(--panel-border); - background: var(--panel); - font-size: 10px; - color: var(--ink); -} - -.stage-header { - display: none; -} - -.stage-title { - font-size: 18px; - font-weight: 600; -} - -.stage-description { - color: var(--muted); - font-size: 12px; -} - -.stage-canvas { - position: relative; - flex: 1; - min-height: 0; - border-radius: 0; - overflow: hidden; - background: #f7f7f8; - border: 0; -} - -#patchmap-root { - position: absolute; - inset: 0; -} - -#patchmap-root > div { - width: 100%; - height: 100%; -} - -.stage-hint { - position: absolute; - right: 12px; - bottom: 12px; - padding: 4px 8px; - border-radius: 999px; - background: #ffffff; - border: 1px solid var(--panel-border); - font-size: 10px; - color: var(--muted); -} - -.statusbar { - display: flex; - align-items: center; - gap: 10px; - padding: 4px 8px; - background: transparent; - border-top: 1px solid var(--panel-border); - border-radius: 0; - box-shadow: none; - backdrop-filter: none; - animation: float-in 1s ease-out; - overflow: hidden; -} - -.status-line { - display: flex; - align-items: center; - gap: 8px; - font-size: 10px; - white-space: nowrap; - width: 100%; -} - -.status-label { - text-transform: uppercase; - letter-spacing: 0.12em; - color: var(--muted); -} - -.status-divider { - color: rgba(31, 45, 61, 0.35); -} - -.status-value { - font-family: var(--mono); - font-size: 11px; - color: var(--ink); -} - -.status-value--grow { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; -} - -@keyframes float-in { - from { - opacity: 0; - transform: translateY(12px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@media (max-width: 980px) { - .content { - grid-template-columns: 1fr; - } - - .data-panel { - border-right: 0; - border-bottom: 1px solid var(--panel-border); - } - - .stage { - order: 1; - } -} - -@media (max-width: 640px) { - .app { - padding: 0; - } - - .topbar { - flex-direction: column; - align-items: flex-start; - gap: 8px; - } - - .stage { - padding: 0; - } - - .stage-canvas { - min-height: 400px; - } -} +@import './styles/base.css'; +@import './styles/layout.css'; +@import './styles/controls.css'; +@import './styles/data-editor.css'; +@import './styles/stage.css'; +@import './styles/responsive.css'; diff --git a/playground/styles/base.css b/playground/styles/base.css new file mode 100644 index 00000000..ded19c55 --- /dev/null +++ b/playground/styles/base.css @@ -0,0 +1,41 @@ +:root { + color-scheme: light; + --bg: #f2f3f5; + --ink: #1f2428; + --muted: #6b737b; + --accent: #1f2933; + --panel: #ffffff; + --panel-border: rgba(31, 36, 40, 0.16); + --mono: 'IBM Plex Mono', monospace; + --radius-lg: 22px; + --radius-md: 14px; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + min-height: 100%; + font-family: 'Space Grotesk', sans-serif; + color: var(--ink); + background: var(--bg); + overflow: hidden; +} + +@keyframes float-in { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/playground/styles/controls.css b/playground/styles/controls.css new file mode 100644 index 00000000..dbbbc9b2 --- /dev/null +++ b/playground/styles/controls.css @@ -0,0 +1,112 @@ +select { + padding: 4px 6px; + border-radius: 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-family: inherit; + font-size: 11px; +} + +.btn-row { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.data-controls { + align-items: center; +} + +.data-controls select { + min-width: 120px; +} + +.data-tabs { + display: flex; + gap: 6px; + align-items: center; + border-bottom: 1px solid var(--panel-border); + padding-bottom: 2px; +} + +.data-tab { + padding: 2px 0; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + font-size: 9px; + letter-spacing: 0.1em; + color: var(--muted); + cursor: pointer; +} + +.data-tab.is-active { + color: var(--ink); + border-bottom-color: var(--ink); + font-weight: 600; +} + +.data-tab:focus-visible { + outline: 1px solid var(--panel-border); + outline-offset: 2px; +} + +.btn { + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 10px; + font-weight: 500; + text-align: center; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease, border 0.2s ease; + white-space: nowrap; +} + +.btn:hover { + transform: none; + border-color: rgba(31, 36, 40, 0.35); + box-shadow: none; +} + +.btn.is-active { + border-color: rgba(31, 36, 40, 0.45); + font-weight: 600; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.rotate-controls { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.rotate-label { + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.rotate-range { + width: 120px; + accent-color: var(--ink); +} + +.rotate-input { + width: 56px; + padding: 2px 6px; + border-radius: 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 10px; + color: var(--ink); +} diff --git a/playground/styles/data-editor.css b/playground/styles/data-editor.css new file mode 100644 index 00000000..3dcd17fd --- /dev/null +++ b/playground/styles/data-editor.css @@ -0,0 +1,391 @@ +.data-view { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.data-view[hidden] { + display: none !important; +} + +.data-form { + display: grid; + grid-template-rows: minmax(0, 1fr) minmax(120px, 35vh); + gap: 2px; + min-height: 0; + overflow: hidden; + position: relative; + height: 100%; +} + +#data-json { + flex: 1; + position: relative; +} + +#data-json textarea { + flex: 1; + min-height: 0; + max-height: none; + padding-bottom: 26px; +} + +.data-tree { + overflow-y: auto; + overflow-x: hidden; + min-height: 0; + max-height: 100%; + padding: 0; + border-bottom: 1px solid var(--panel-border); +} + +.tree-row { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 2px 6px; + min-width: 0; +} + +.tree-row:hover { + background: #e9ecef; +} + +.tree-label { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + border: 0; + background: transparent; + color: var(--ink); + font-size: 10px; + text-align: left; + cursor: pointer; + padding: 0; + min-width: 0; +} + +.tree-label.is-active { + font-weight: 600; +} + +.tree-id { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tree-type { + font-size: 9px; + color: var(--muted); +} + +.tree-actions { + display: flex; + gap: 4px; +} + +.tree-action { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 12px; + line-height: 1; + display: grid; + place-items: center; + padding: 0; + cursor: pointer; +} + +.tree-action:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.tree-root-label { + font-size: 9px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; + padding-left: 6px; +} + +.data-inspector { + display: flex; + flex-direction: column; + gap: 6px; + overflow: hidden; + min-height: 0; + overflow-x: hidden; +} + +.data-popover { + position: absolute; + top: 4px; + left: 6px; + right: 6px; + padding: 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + display: grid; + gap: 4px; + z-index: 10; +} + +.data-popover[hidden] { + display: none; +} + +.data-popover-actions { + display: flex; + gap: 4px; +} + +.inspector-content { + display: grid; + gap: 6px; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; + padding-right: 2px; +} + +.inspector-empty { + font-size: 10px; + color: var(--muted); + padding: 4px 2px; +} + +.inspector-field { + display: grid; + grid-template-columns: 56px minmax(0, 1fr); + gap: 6px; + align-items: center; + min-width: 0; +} + +.inspector-field--inline { + align-items: start; +} + +.inspector-inline { + display: flex; + gap: 6px; + min-width: 0; + flex-wrap: wrap; +} + +.inspector-inline-item { + display: flex; + align-items: center; + gap: 4px; +} + +.inspector-inline-label { + font-size: 9px; + color: var(--muted); +} + +.inspector-label { + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.inspector-input { + width: 100%; + padding: 4px 6px; + border-radius: 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 10px; + color: var(--ink); + min-width: 0; +} + +.inspector-input--compact { + width: 64px; + padding: 2px 6px; +} + +.color-select { + width: 110px; +} + +.color-input { + width: 120px; +} + +.inspector-input[readonly] { + background: #f3f4f6; + color: var(--muted); +} + +.relations-editor { + display: grid; + gap: 6px; +} + +.relations-title { + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.relations-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + gap: 4px; + align-items: center; +} + +.relations-select { + padding: 4px 6px; + border-radius: 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 10px; + color: var(--ink); + min-width: 0; +} + +.relations-add { + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 10px; + cursor: pointer; +} + +.relations-list { + display: grid; + gap: 4px; +} + +.relations-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.relations-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10px; +} + +.relations-delete { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 12px; + line-height: 1; + display: grid; + place-items: center; + padding: 0; + cursor: pointer; +} + +.relations-empty { + font-size: 10px; + color: var(--muted); +} + +.grid-editor { + display: grid; + gap: 6px; +} + +.grid-title { + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.grid-controls { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.grid-control { + padding: 2px 6px; + border-radius: 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 9px; + cursor: pointer; +} + +.grid-cells { + display: grid; + gap: 2px; + grid-template-columns: repeat(var(--grid-cols), 16px); +} + +.grid-cell { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid var(--panel-border); + background: transparent; + cursor: pointer; + padding: 0; +} + +.grid-cell.is-active { + background: var(--ink); +} + +textarea { + min-height: 200px; + resize: vertical; + border-radius: 6px; + border: 1px solid var(--panel-border); + padding: 6px 8px; + font-family: var(--mono); + font-size: 10px; + line-height: 1.35; + background: var(--panel); + color: var(--ink); +} + +.data-panel textarea { + flex: 1; +} + +.editor-error { + position: absolute; + left: 6px; + right: 6px; + bottom: 6px; + padding: 4px 6px; + border: 1px solid var(--panel-border); + background: var(--panel); + font-size: 10px; + color: #b42318; + pointer-events: none; +} + +.editor-error:empty { + display: none; +} diff --git a/playground/styles/layout.css b/playground/styles/layout.css new file mode 100644 index 00000000..abadb8e1 --- /dev/null +++ b/playground/styles/layout.css @@ -0,0 +1,134 @@ +.app { + padding: 0; + display: flex; + flex-direction: column; + gap: 0; + height: 100%; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 4px 8px; + background: transparent; + border-bottom: 1px solid var(--panel-border); + border-radius: 0; + box-shadow: none; + backdrop-filter: none; + animation: float-in 0.6s ease-out; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; +} + +.logo { + width: 32px; + height: 32px; + border-radius: 10px; + background: #e6e9ec; + display: grid; + place-items: center; + font-weight: 600; + letter-spacing: 0.04em; +} + +.title { + font-size: 12px; + font-weight: 500; + letter-spacing: 0.04em; +} + +.content { + display: grid; + grid-template-columns: minmax(220px, 3fr) minmax(0, 7fr); + grid-template-rows: minmax(0, 1fr); + gap: 0; + flex: 1; + min-height: 0; +} + +.data-panel { + display: flex; + flex-direction: column; + gap: 6px; + padding: 4px 6px; + background: transparent; + border-radius: 0; + border-right: 1px solid var(--panel-border); + box-shadow: none; + backdrop-filter: none; + animation: float-in 0.75s ease-out; + min-height: 0; + height: 100%; + overflow: hidden; +} + +.panel-section { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.data-panel .field { + flex: 1; +} + +.statusbar { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 8px; + background: transparent; + border-top: 1px solid var(--panel-border); + border-radius: 0; + box-shadow: none; + backdrop-filter: none; + animation: float-in 1s ease-out; + overflow: hidden; +} + +.status-line { + display: flex; + align-items: center; + gap: 8px; + font-size: 10px; + white-space: nowrap; + width: 100%; +} + +.status-label { + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted); +} + +.status-divider { + color: rgba(31, 45, 61, 0.35); +} + +.status-value { + font-family: var(--mono); + font-size: 11px; + color: var(--ink); +} + +.status-value--grow { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/playground/styles/responsive.css b/playground/styles/responsive.css new file mode 100644 index 00000000..da7075e0 --- /dev/null +++ b/playground/styles/responsive.css @@ -0,0 +1,34 @@ +@media (max-width: 980px) { + .content { + grid-template-columns: 1fr; + } + + .data-panel { + border-right: 0; + border-bottom: 1px solid var(--panel-border); + } + + .stage { + order: 1; + } +} + +@media (max-width: 640px) { + .app { + padding: 0; + } + + .topbar { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .stage { + padding: 0; + } + + .stage-canvas { + min-height: 400px; + } +} diff --git a/playground/styles/stage.css b/playground/styles/stage.css new file mode 100644 index 00000000..34234103 --- /dev/null +++ b/playground/styles/stage.css @@ -0,0 +1,69 @@ +.stage { + display: flex; + flex-direction: column; + gap: 0; + padding: 0; + border-radius: 0; + border: 0; + background: transparent; + box-shadow: none; + backdrop-filter: none; + min-height: 0; + height: 100%; + animation: float-in 0.9s ease-out; +} + +.stage-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + padding: 4px 6px; + border-bottom: 1px solid var(--panel-border); +} + +.stage-header { + display: none; +} + +.stage-title { + font-size: 18px; + font-weight: 600; +} + +.stage-description { + color: var(--muted); + font-size: 12px; +} + +.stage-canvas { + position: relative; + flex: 1; + min-height: 0; + border-radius: 0; + overflow: hidden; + background: #f7f7f8; + border: 0; +} + +#patchmap-root { + position: absolute; + inset: 0; +} + +#patchmap-root > div { + width: 100%; + height: 100%; +} + +.stage-hint { + position: absolute; + right: 12px; + bottom: 12px; + padding: 4px 8px; + border-radius: 999px; + background: #ffffff; + border: 1px solid var(--panel-border); + font-size: 10px; + color: var(--muted); +} From 17a597910506fbcf7a61da6db85ba2e109b5168f Mon Sep 17 00:00:00 2001 From: perhapsspy Date: Wed, 31 Dec 2025 15:36:44 +0900 Subject: [PATCH 8/8] refactor(playground): split examples into modules --- ...io-controller.js => example-controller.js} | 58 +- playground/examples/example-mesh-lab.js | 414 +++++++ playground/examples/example-plant-ops.js | 607 ++++++++++ playground/examples/index.js | 7 + playground/examples/utils.js | 6 + playground/main.js | 30 +- playground/scenarios.js | 1026 ----------------- 7 files changed, 1078 insertions(+), 1070 deletions(-) rename playground/{scenario-controller.js => example-controller.js} (66%) create mode 100644 playground/examples/example-mesh-lab.js create mode 100644 playground/examples/example-plant-ops.js create mode 100644 playground/examples/index.js create mode 100644 playground/examples/utils.js delete mode 100644 playground/scenarios.js diff --git a/playground/scenario-controller.js b/playground/example-controller.js similarity index 66% rename from playground/scenario-controller.js rename to playground/example-controller.js index caccc6c0..dd52fdf4 100644 --- a/playground/scenario-controller.js +++ b/playground/example-controller.js @@ -1,30 +1,30 @@ -export const createScenarioController = ({ +export const createExampleController = ({ patchmap, dataEditor, elements, - scenarios, + examples, setLastAction, syncRotationUI, syncFlipUI, }) => { - let currentScenario = scenarios[0]; + let currentExample = examples[0]; let linkSetIndex = 0; - const setupScenarioOptions = () => { - scenarios.forEach((scenario) => { + const setupExampleOptions = () => { + examples.forEach((example) => { const option = document.createElement('option'); - option.value = scenario.id; - option.textContent = scenario.name; + option.value = example.id; + option.textContent = example.name; elements.scenario.append(option); }); - elements.scenario.value = currentScenario.id; + elements.scenario.value = currentExample.id; }; - const applyScenario = (scenario, { shouldFit = true } = {}) => { - currentScenario = scenario; + const applyExample = (example, { shouldFit = true } = {}) => { + currentExample = example; linkSetIndex = 0; - const data = currentScenario.data(); + const data = currentExample.data(); patchmap.draw(data); if (shouldFit) { patchmap.fit(); @@ -36,37 +36,37 @@ export const createScenarioController = ({ updateActionButtons(); syncRotationUI(); syncFlipUI(); - setLastAction(`Loaded ${currentScenario.name}`); + setLastAction(`Loaded ${currentExample.name}`); }; - const setScenarioById = (scenarioId, options) => { - const scenario = scenarios.find((item) => item.id === scenarioId); - if (scenario) { - applyScenario(scenario, options); + const setExampleById = (exampleId, options) => { + const example = examples.find((item) => item.id === exampleId); + if (example) { + applyExample(example, options); } }; const updateSceneInfo = () => { if (elements.sceneName) { - elements.sceneName.textContent = currentScenario.name; + elements.sceneName.textContent = currentExample.name; } if (elements.sceneTitle) { - elements.sceneTitle.textContent = currentScenario.name; + elements.sceneTitle.textContent = currentExample.name; } if (elements.sceneDescription) { - elements.sceneDescription.textContent = currentScenario.description; + elements.sceneDescription.textContent = currentExample.description; } }; const updateActionButtons = () => { - const dynamic = currentScenario.dynamic ?? {}; + const dynamic = currentExample.dynamic ?? {}; elements.randomize.disabled = (dynamic.bars ?? []).length === 0; elements.shuffle.disabled = !dynamic.relationsId || (dynamic.linkSets ?? []).length < 2; }; const randomizeMetrics = () => { - const bars = currentScenario.dynamic?.bars ?? []; + const bars = currentExample.dynamic?.bars ?? []; bars.forEach((bar) => { if (bar.path) { const targets = patchmap.selector(bar.path); @@ -102,15 +102,15 @@ export const createScenarioController = ({ }; const shuffleLinks = () => { - const links = currentScenario.dynamic?.linkSets ?? []; - if (!currentScenario.dynamic?.relationsId || links.length === 0) { + const links = currentExample.dynamic?.linkSets ?? []; + if (!currentExample.dynamic?.relationsId || links.length === 0) { return; } linkSetIndex = (linkSetIndex + 1) % links.length; patchmap.update({ - path: `$..[?(@.id=="${currentScenario.dynamic.relationsId}")]`, + path: `$..[?(@.id=="${currentExample.dynamic.relationsId}")]`, changes: { links: links[linkSetIndex] }, mergeStrategy: 'replace', }); @@ -118,7 +118,7 @@ export const createScenarioController = ({ setLastAction('Rerouted links'); }; - const getCurrentScenario = () => currentScenario; + const getCurrentExample = () => currentExample; const buildBarSize = (bar, value) => { const axis = bar.axis ?? 'width'; @@ -133,11 +133,11 @@ export const createScenarioController = ({ }; return { - setupScenarioOptions, - applyScenario, - setScenarioById, + setupExampleOptions, + applyExample, + setExampleById, randomizeMetrics, shuffleLinks, - getCurrentScenario, + getCurrentExample, }; }; diff --git a/playground/examples/example-mesh-lab.js b/playground/examples/example-mesh-lab.js new file mode 100644 index 00000000..5a093c31 --- /dev/null +++ b/playground/examples/example-mesh-lab.js @@ -0,0 +1,414 @@ +import { clone } from './utils.js'; + +const meshData = [ + { + type: 'group', + id: 'mesh-1', + label: 'Edge Mesh', + attrs: { x: 80, y: 80 }, + children: [ + { + type: 'item', + id: 'node-a', + label: 'Node A', + size: { width: 180, height: 110 }, + padding: { x: 12, y: 10 }, + components: [ + { + type: 'background', + id: 'bg-node-a', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'gray.dark', + radius: 14, + }, + tint: 'gray.light', + }, + { + type: 'icon', + id: 'icon-node-a', + source: 'device', + size: 30, + placement: 'left-top', + margin: { left: 4, top: 4 }, + tint: 'primary.default', + }, + { + type: 'text', + id: 'text-node-a-title', + text: 'Node A', + placement: 'left-top', + margin: { left: 44, top: 6 }, + style: { fontSize: 16, fill: 'black' }, + }, + { + type: 'text', + id: 'text-node-a-status', + text: 'Stable', + placement: 'left-bottom', + margin: { left: 44, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'text', + id: 'text-node-a-metric', + text: 'Latency 18ms', + placement: 'right-bottom', + margin: { right: 12, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'bar', + id: 'bar-node-a', + source: { type: 'rect', fill: 'primary.default', radius: 6 }, + size: { width: '35%', height: 10 }, + placement: 'bottom', + margin: { left: 12, right: 12, bottom: 30 }, + }, + ], + attrs: { x: 0, y: 0 }, + }, + { + type: 'item', + id: 'node-b', + label: 'Node B', + size: { width: 180, height: 110 }, + padding: { x: 12, y: 10 }, + components: [ + { + type: 'background', + id: 'bg-node-b', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'gray.dark', + radius: 14, + }, + tint: 'gray.light', + }, + { + type: 'icon', + id: 'icon-node-b', + source: 'wifi', + size: 30, + placement: 'left-top', + margin: { left: 4, top: 4 }, + tint: 'primary.default', + }, + { + type: 'text', + id: 'text-node-b-title', + text: 'Node B', + placement: 'left-top', + margin: { left: 44, top: 6 }, + style: { fontSize: 16, fill: 'black' }, + }, + { + type: 'text', + id: 'text-node-b-status', + text: 'Stable', + placement: 'left-bottom', + margin: { left: 44, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'text', + id: 'text-node-b-metric', + text: 'Latency 22ms', + placement: 'right-bottom', + margin: { right: 12, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'bar', + id: 'bar-node-b', + source: { type: 'rect', fill: 'primary.default', radius: 6 }, + size: { width: '44%', height: 10 }, + placement: 'bottom', + margin: { left: 12, right: 12, bottom: 30 }, + }, + ], + attrs: { x: 240, y: 0 }, + }, + { + type: 'item', + id: 'node-c', + label: 'Node C', + size: { width: 180, height: 110 }, + padding: { x: 12, y: 10 }, + components: [ + { + type: 'background', + id: 'bg-node-c', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'gray.dark', + radius: 14, + }, + tint: 'gray.light', + }, + { + type: 'icon', + id: 'icon-node-c', + source: 'edge', + size: 30, + placement: 'left-top', + margin: { left: 4, top: 4 }, + tint: 'primary.default', + }, + { + type: 'text', + id: 'text-node-c-title', + text: 'Node C', + placement: 'left-top', + margin: { left: 44, top: 6 }, + style: { fontSize: 16, fill: 'black' }, + }, + { + type: 'text', + id: 'text-node-c-status', + text: 'Stable', + placement: 'left-bottom', + margin: { left: 44, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'text', + id: 'text-node-c-metric', + text: 'Latency 30ms', + placement: 'right-bottom', + margin: { right: 12, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'bar', + id: 'bar-node-c', + source: { type: 'rect', fill: 'primary.default', radius: 6 }, + size: { width: '52%', height: 10 }, + placement: 'bottom', + margin: { left: 12, right: 12, bottom: 30 }, + }, + ], + attrs: { x: 0, y: 150 }, + }, + { + type: 'item', + id: 'node-d', + label: 'Node D', + size: { width: 180, height: 110 }, + padding: { x: 12, y: 10 }, + components: [ + { + type: 'background', + id: 'bg-node-d', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'gray.dark', + radius: 14, + }, + tint: 'gray.light', + }, + { + type: 'icon', + id: 'icon-node-d', + source: 'combiner', + size: 30, + placement: 'left-top', + margin: { left: 4, top: 4 }, + tint: 'primary.default', + }, + { + type: 'text', + id: 'text-node-d-title', + text: 'Node D', + placement: 'left-top', + margin: { left: 44, top: 6 }, + style: { fontSize: 16, fill: 'black' }, + }, + { + type: 'text', + id: 'text-node-d-status', + text: 'Stable', + placement: 'left-bottom', + margin: { left: 44, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'text', + id: 'text-node-d-metric', + text: 'Latency 26ms', + placement: 'right-bottom', + margin: { right: 12, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'bar', + id: 'bar-node-d', + source: { type: 'rect', fill: 'primary.default', radius: 6 }, + size: { width: '38%', height: 10 }, + placement: 'bottom', + margin: { left: 12, right: 12, bottom: 30 }, + }, + ], + attrs: { x: 240, y: 150 }, + }, + ], + }, + { + type: 'relations', + id: 'rel-mesh', + links: [ + { source: 'node-a', target: 'node-b' }, + { source: 'node-b', target: 'node-d' }, + { source: 'node-a', target: 'node-c' }, + ], + style: { width: 2, color: 'gray.dark' }, + }, +]; + +export const exampleMeshLab = { + id: 'mesh-lab', + name: 'Example 2', + description: '', + data: () => clone(meshData), + focusId: 'node-b', + dynamic: { + bars: [ + { + id: 'bar-node-a', + textId: 'text-node-a-metric', + label: 'Latency', + min: 10, + max: 90, + height: 10, + }, + { + id: 'bar-node-b', + textId: 'text-node-b-metric', + label: 'Latency', + min: 10, + max: 90, + height: 10, + }, + { + id: 'bar-node-c', + textId: 'text-node-c-metric', + label: 'Latency', + min: 10, + max: 90, + height: 10, + }, + { + id: 'bar-node-d', + textId: 'text-node-d-metric', + label: 'Latency', + min: 10, + max: 90, + height: 10, + }, + ], + statuses: [ + { + backgroundId: 'bg-node-a', + iconId: 'icon-node-a', + textId: 'text-node-a-status', + ok: { + tint: 'gray.light', + icon: 'device', + iconTint: 'primary.default', + text: 'Stable', + textTint: 'gray.dark', + }, + alert: { + tint: 'primary.accent', + icon: 'warning', + iconTint: 'primary.accent', + text: 'Packet Loss', + textTint: 'primary.accent', + }, + }, + { + backgroundId: 'bg-node-b', + iconId: 'icon-node-b', + textId: 'text-node-b-status', + ok: { + tint: 'gray.light', + icon: 'wifi', + iconTint: 'primary.default', + text: 'Stable', + textTint: 'gray.dark', + }, + alert: { + tint: 'primary.accent', + icon: 'warning', + iconTint: 'primary.accent', + text: 'Congested', + textTint: 'primary.accent', + }, + }, + { + backgroundId: 'bg-node-c', + iconId: 'icon-node-c', + textId: 'text-node-c-status', + ok: { + tint: 'gray.light', + icon: 'edge', + iconTint: 'primary.default', + text: 'Stable', + textTint: 'gray.dark', + }, + alert: { + tint: 'primary.accent', + icon: 'warning', + iconTint: 'primary.accent', + text: 'Jitter', + textTint: 'primary.accent', + }, + }, + { + backgroundId: 'bg-node-d', + iconId: 'icon-node-d', + textId: 'text-node-d-status', + ok: { + tint: 'gray.light', + icon: 'combiner', + iconTint: 'primary.default', + text: 'Stable', + textTint: 'gray.dark', + }, + alert: { + tint: 'primary.accent', + icon: 'warning', + iconTint: 'primary.accent', + text: 'Overloaded', + textTint: 'primary.accent', + }, + }, + ], + relationsId: 'rel-mesh', + linkSets: [ + [ + { source: 'node-a', target: 'node-b' }, + { source: 'node-b', target: 'node-d' }, + { source: 'node-a', target: 'node-c' }, + ], + [ + { source: 'node-c', target: 'node-b' }, + { source: 'node-b', target: 'node-a' }, + { source: 'node-d', target: 'node-c' }, + ], + [ + { source: 'node-a', target: 'node-d' }, + { source: 'node-d', target: 'node-b' }, + { source: 'node-b', target: 'node-c' }, + ], + ], + }, +}; diff --git a/playground/examples/example-plant-ops.js b/playground/examples/example-plant-ops.js new file mode 100644 index 00000000..6cba8cc6 --- /dev/null +++ b/playground/examples/example-plant-ops.js @@ -0,0 +1,607 @@ +import { clone } from './utils.js'; + +const plantOpsData = [ + { + type: 'group', + id: 'plant-1', + label: 'Plant Alpha', + attrs: { x: 80, y: 80 }, + children: [ + { + type: 'grid', + id: 'grid-panels', + label: 'Array A', + cells: [ + [1, 0, 1], + [1, 1, 1], + ], + gap: 4, + item: { + size: { width: 40, height: 80 }, + padding: { x: 6, y: 6 }, + components: [ + { + type: 'background', + id: 'panel-bg', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.dark', + radius: 4, + }, + tint: 'white', + }, + { + type: 'bar', + id: 'panel-metric', + source: { type: 'rect', fill: 'primary.default', radius: 4 }, + size: { width: '100%', height: '60%' }, + placement: 'bottom', + }, + { type: 'icon', source: 'loading', tint: 'black', size: 16 }, + ], + }, + attrs: { x: 0, y: 0 }, + }, + { + type: 'item', + id: 'icon-beacon', + label: 'Beacon', + size: 56, + components: [ + { + type: 'icon', + source: 'wifi', + size: 36, + tint: 'primary.default', + }, + ], + attrs: { x: 170, y: 18 }, + }, + { + type: 'item', + id: 'icon-inverter', + label: 'Inverter Icon', + size: 56, + components: [ + { + type: 'icon', + source: 'inverter', + size: 36, + tint: 'primary.default', + }, + ], + attrs: { x: 250, y: 18 }, + }, + { + type: 'grid', + id: 'grid-panels-lite', + label: 'Array B', + cells: [ + [1, 1, 1], + [1, 0, 1], + ], + gap: 4, + item: { + size: { width: 40, height: 80 }, + padding: { x: 6, y: 6 }, + components: [ + { + type: 'background', + id: 'panel-bg', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.dark', + radius: 4, + }, + tint: 'white', + }, + { + type: 'bar', + id: 'panel-metric', + source: { type: 'rect', fill: 'primary.default', radius: 4 }, + size: { width: '100%', height: '45%' }, + placement: 'bottom', + }, + ], + }, + attrs: { x: 0, y: 200 }, + }, + { + type: 'grid', + id: 'grid-panels-mini', + label: 'Array D', + cells: [ + [1, 1, 1], + [0, 1, 1], + ], + gap: 4, + item: { + size: { width: 40, height: 80 }, + padding: { x: 6, y: 6 }, + components: [ + { + type: 'background', + id: 'panel-bg', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.dark', + radius: 4, + }, + tint: 'white', + }, + { + type: 'bar', + id: 'panel-metric', + source: { type: 'rect', fill: 'primary.default', radius: 4 }, + size: { width: '100%', height: '35%' }, + placement: 'bottom', + }, + ], + }, + attrs: { x: 0, y: 380 }, + }, + { + type: 'grid', + id: 'grid-panels-tilt', + label: 'Array C', + cells: [ + [1, 1, 0], + [1, 1, 1], + ], + gap: 4, + item: { + size: { width: 40, height: 80 }, + padding: { x: 6, y: 6 }, + components: [ + { + type: 'background', + id: 'panel-bg', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.dark', + radius: 4, + }, + tint: 'white', + }, + { + type: 'bar', + id: 'panel-metric', + source: { type: 'rect', fill: 'primary.default', radius: 4 }, + size: { width: '100%', height: '70%' }, + placement: 'bottom', + }, + { type: 'icon', source: 'loading', tint: 'black', size: 16 }, + ], + }, + attrs: { x: 160, y: 220, angle: -12 }, + }, + { + type: 'grid', + id: 'grid-panels-tilt-2', + label: 'Array E', + cells: [ + [1, 1, 1], + [1, 1, 0], + ], + gap: 4, + item: { + size: { width: 40, height: 80 }, + padding: { x: 6, y: 6 }, + components: [ + { + type: 'background', + id: 'panel-bg', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.dark', + radius: 4, + }, + tint: 'white', + }, + { + type: 'bar', + id: 'panel-metric', + source: { type: 'rect', fill: 'primary.default', radius: 4 }, + size: { width: '100%', height: '55%' }, + placement: 'bottom', + }, + { type: 'icon', source: 'loading', tint: 'black', size: 16 }, + ], + }, + attrs: { x: 160, y: 440, angle: 10 }, + }, + { + type: 'item', + id: 'inverter-1', + label: 'Inverter 01', + size: { width: 210, height: 120 }, + padding: { x: 12, y: 10 }, + components: [ + { + type: 'background', + id: 'bg-inverter-1', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.default', + radius: 12, + }, + tint: 'gray.light', + }, + { + type: 'icon', + id: 'icon-inverter-1', + source: 'inverter', + size: 30, + placement: 'left-top', + margin: { left: 4, top: 4 }, + tint: 'primary.default', + }, + { + type: 'text', + id: 'text-inverter-1-title', + text: 'INV-01', + placement: 'left-top', + margin: { left: 44, top: 6 }, + style: { fontSize: 16, fill: 'black' }, + }, + { + type: 'text', + id: 'text-inverter-1-status', + text: 'Nominal', + placement: 'left-bottom', + margin: { left: 44, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'text', + id: 'text-inverter-1-metric', + text: 'Load 42%', + placement: 'right-bottom', + margin: { right: 12, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'bar', + id: 'bar-inverter-1', + source: { type: 'rect', fill: 'primary.default', radius: 6 }, + size: { width: '42%', height: 10 }, + placement: 'bottom', + margin: { left: 12, right: 12, bottom: 30 }, + }, + ], + attrs: { x: 330, y: 0 }, + }, + { + type: 'item', + id: 'inverter-2', + label: 'Inverter 02', + size: { width: 210, height: 120 }, + padding: { x: 12, y: 10 }, + components: [ + { + type: 'background', + id: 'bg-inverter-2', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.default', + radius: 12, + }, + tint: 'gray.light', + }, + { + type: 'icon', + id: 'icon-inverter-2', + source: 'inverter', + size: 30, + placement: 'left-top', + margin: { left: 4, top: 4 }, + tint: 'primary.default', + }, + { + type: 'text', + id: 'text-inverter-2-title', + text: 'INV-02', + placement: 'left-top', + margin: { left: 44, top: 6 }, + style: { fontSize: 16, fill: 'black' }, + }, + { + type: 'text', + id: 'text-inverter-2-status', + text: 'Nominal', + placement: 'left-bottom', + margin: { left: 44, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'text', + id: 'text-inverter-2-metric', + text: 'Load 58%', + placement: 'right-bottom', + margin: { right: 12, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'bar', + id: 'bar-inverter-2', + source: { type: 'rect', fill: 'primary.default', radius: 6 }, + size: { width: '58%', height: 10 }, + placement: 'bottom', + margin: { left: 12, right: 12, bottom: 30 }, + }, + ], + attrs: { x: 330, y: 140 }, + }, + { + type: 'item', + id: 'gateway-1', + label: 'Gateway', + size: { width: 220, height: 120 }, + padding: { x: 12, y: 10 }, + components: [ + { + type: 'background', + id: 'bg-gateway-1', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'gray.dark', + radius: 14, + }, + tint: 'gray.light', + }, + { + type: 'icon', + id: 'icon-gateway-1', + source: 'edge', + size: 32, + placement: 'left-top', + margin: { left: 4, top: 4 }, + tint: 'primary.default', + }, + { + type: 'text', + id: 'text-gateway-1-title', + text: 'Gateway', + placement: 'left-top', + margin: { left: 48, top: 6 }, + style: { fontSize: 16, fill: 'black' }, + }, + { + type: 'text', + id: 'text-gateway-1-status', + text: 'Online', + placement: 'left-bottom', + margin: { left: 48, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'text', + id: 'text-gateway-1-metric', + text: 'Throughput 73%', + placement: 'right-bottom', + margin: { right: 12, bottom: 8 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'bar', + id: 'bar-gateway-1', + source: { type: 'rect', fill: 'primary.default', radius: 6 }, + size: { width: '73%', height: 10 }, + placement: 'bottom', + margin: { left: 12, right: 12, bottom: 30 }, + }, + ], + attrs: { x: 600, y: 70 }, + }, + ], + }, + { + type: 'item', + id: 'ops-console', + label: 'Ops Console', + size: { width: 260, height: 120 }, + padding: { x: 16, y: 12 }, + components: [ + { + type: 'background', + id: 'bg-ops-console', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.default', + }, + tint: 'gray.light', + }, + { + type: 'icon', + id: 'icon-ops-console', + source: 'object', + size: 34, + placement: 'left-top', + margin: { left: 6, top: 6 }, + tint: 'primary.default', + }, + { + type: 'text', + id: 'text-ops-title', + text: 'Ops Console', + placement: 'left-top', + margin: { left: 52, top: 8 }, + style: { fontSize: 16, fill: 'black' }, + }, + { + type: 'text', + id: 'text-ops-status', + text: 'Queue 4 tasks', + placement: 'left-bottom', + margin: { left: 52, bottom: 10 }, + style: { fontSize: 12, fill: 'gray.dark' }, + }, + { + type: 'bar', + id: 'bar-ops-queue', + source: { type: 'rect', fill: 'primary.default', radius: 6 }, + size: { width: '40%', height: 10 }, + placement: 'bottom', + margin: { left: 14, right: 14, bottom: 34 }, + }, + ], + attrs: { x: 480, y: 560 }, + }, + { + type: 'relations', + id: 'rel-power', + links: [ + { source: 'grid-panels.0.0', target: 'grid-panels.1.0' }, + { source: 'grid-panels.1.0', target: 'grid-panels.1.1' }, + { source: 'grid-panels.1.1', target: 'grid-panels.1.2' }, + { source: 'grid-panels.0.2', target: 'grid-panels.1.2' }, + ], + style: { width: 3, color: 'primary.default' }, + }, +]; + +export const examplePlantOps = { + id: 'plant-ops', + name: 'Example 1', + description: '', + data: () => clone(plantOpsData), + focusId: 'gateway-1', + dynamic: { + bars: [ + { + path: '$..[?(@.id=="panel-metric")]', + axis: 'height', + min: 10, + max: 100, + width: '100%', + }, + { + id: 'bar-inverter-1', + textId: 'text-inverter-1-metric', + label: 'Load', + min: 20, + max: 95, + height: 10, + }, + { + id: 'bar-inverter-2', + textId: 'text-inverter-2-metric', + label: 'Load', + min: 20, + max: 95, + height: 10, + }, + { + id: 'bar-gateway-1', + textId: 'text-gateway-1-metric', + label: 'Throughput', + min: 10, + max: 100, + height: 10, + }, + { + id: 'bar-ops-queue', + textId: 'text-ops-status', + label: 'Queue', + unit: ' tasks', + min: 10, + max: 85, + height: 10, + }, + ], + statuses: [ + { + backgroundId: 'bg-inverter-1', + iconId: 'icon-inverter-1', + textId: 'text-inverter-1-status', + ok: { + tint: 'gray.light', + icon: 'inverter', + iconTint: 'primary.default', + text: 'Nominal', + textTint: 'gray.dark', + }, + alert: { + tint: 'primary.accent', + icon: 'warning', + iconTint: 'primary.accent', + text: 'Overheat', + textTint: 'primary.accent', + }, + }, + { + backgroundId: 'bg-inverter-2', + iconId: 'icon-inverter-2', + textId: 'text-inverter-2-status', + ok: { + tint: 'gray.light', + icon: 'inverter', + iconTint: 'primary.default', + text: 'Nominal', + textTint: 'gray.dark', + }, + alert: { + tint: 'primary.accent', + icon: 'warning', + iconTint: 'primary.accent', + text: 'Overheat', + textTint: 'primary.accent', + }, + }, + { + backgroundId: 'bg-gateway-1', + iconId: 'icon-gateway-1', + textId: 'text-gateway-1-status', + ok: { + tint: 'gray.light', + icon: 'edge', + iconTint: 'primary.default', + text: 'Online', + textTint: 'gray.dark', + }, + alert: { + tint: 'primary.accent', + icon: 'warning', + iconTint: 'primary.accent', + text: 'Link Loss', + textTint: 'primary.accent', + }, + }, + ], + relationsId: 'rel-power', + linkSets: [ + [ + { source: 'grid-panels.0.0', target: 'grid-panels.1.0' }, + { source: 'grid-panels.1.0', target: 'grid-panels.1.1' }, + { source: 'grid-panels.1.1', target: 'grid-panels.1.2' }, + { source: 'grid-panels.0.2', target: 'grid-panels.1.2' }, + ], + [ + { source: 'grid-panels.0.0', target: 'grid-panels.1.0' }, + { source: 'grid-panels.1.0', target: 'grid-panels.1.1' }, + ], + [ + { source: 'grid-panels.0.2', target: 'grid-panels.1.2' }, + { source: 'grid-panels.1.2', target: 'grid-panels.1.1' }, + ], + ], + }, +}; diff --git a/playground/examples/index.js b/playground/examples/index.js new file mode 100644 index 00000000..5032c32e --- /dev/null +++ b/playground/examples/index.js @@ -0,0 +1,7 @@ +import { exampleMeshLab } from './example-mesh-lab.js'; +import { examplePlantOps } from './example-plant-ops.js'; + +export const examples = [examplePlantOps, exampleMeshLab]; + +export const findExampleById = (id) => + examples.find((example) => example.id === id); diff --git a/playground/examples/utils.js b/playground/examples/utils.js new file mode 100644 index 00000000..579a8e52 --- /dev/null +++ b/playground/examples/utils.js @@ -0,0 +1,6 @@ +export const clone = (value) => { + if (typeof structuredClone === 'function') { + return structuredClone(value); + } + return JSON.parse(JSON.stringify(value)); +}; diff --git a/playground/main.js b/playground/main.js index 329bc262..15a6f8c6 100644 --- a/playground/main.js +++ b/playground/main.js @@ -1,7 +1,7 @@ import { Patchmap, Transformer } from '@patchmap'; import { createDataEditor } from './data-editor.js'; -import { createScenarioController } from './scenario-controller.js'; -import { scenarios } from './scenarios.js'; +import { createExampleController } from './example-controller.js'; +import { examples } from './examples/index.js'; import { createSelectionController } from './selection-controller.js'; import { createViewControls } from './view-controls.js'; @@ -57,11 +57,11 @@ const setLastAction = (text) => { const dataEditor = createDataEditor({ patchmap, elements, setLastAction }); const viewControls = createViewControls({ patchmap, elements, setLastAction }); -const scenarioController = createScenarioController({ +const exampleController = createExampleController({ patchmap, dataEditor, elements, - scenarios, + examples, setLastAction, syncRotationUI: viewControls.syncRotationUI, syncFlipUI: viewControls.syncFlipUI, @@ -85,9 +85,9 @@ const init = async () => { selectionController.bindSelectionState(); - scenarioController.setupScenarioOptions(); + exampleController.setupExampleOptions(); bindControls(); - scenarioController.applyScenario(scenarioController.getCurrentScenario(), { + exampleController.applyExample(exampleController.getCurrentExample(), { shouldFit: true, }); dataEditor.setDataMode('json'); @@ -95,18 +95,18 @@ const init = async () => { viewControls.syncFlipUI(); window.patchmap = patchmap; - window.patchmapScenarios = scenarios; + window.patchmapExamples = examples; }; const bindControls = () => { elements.scenario.addEventListener('change', (event) => { - scenarioController.setScenarioById(event.target.value, { + exampleController.setExampleById(event.target.value, { shouldFit: true, }); }); elements.draw.addEventListener('click', () => { - scenarioController.applyScenario(scenarioController.getCurrentScenario(), { + exampleController.applyExample(exampleController.getCurrentExample(), { shouldFit: true, }); }); @@ -120,10 +120,10 @@ const bindControls = () => { }); elements.resetData.addEventListener('click', () => { - scenarioController.applyScenario(scenarioController.getCurrentScenario(), { + exampleController.applyExample(exampleController.getCurrentExample(), { shouldFit: true, }); - setLastAction('Reset to scenario'); + setLastAction('Reset to example'); }); elements.dataModeJson.addEventListener('click', () => { @@ -175,16 +175,16 @@ const bindControls = () => { } elements.randomize.addEventListener('click', () => { - scenarioController.randomizeMetrics(); + exampleController.randomizeMetrics(); }); elements.shuffle.addEventListener('click', () => { - scenarioController.shuffleLinks(); + exampleController.shuffleLinks(); }); elements.focus.addEventListener('click', () => { - const currentScenario = scenarioController.getCurrentScenario(); - const targetId = dataEditor.getSelectedNodeId() ?? currentScenario.focusId; + const currentExample = exampleController.getCurrentExample(); + const targetId = dataEditor.getSelectedNodeId() ?? currentExample.focusId; if (targetId) { patchmap.focus(targetId); setLastAction(`Focus ${targetId}`); diff --git a/playground/scenarios.js b/playground/scenarios.js deleted file mode 100644 index 2820c24a..00000000 --- a/playground/scenarios.js +++ /dev/null @@ -1,1026 +0,0 @@ -const clone = (value) => { - if (typeof structuredClone === 'function') { - return structuredClone(value); - } - return JSON.parse(JSON.stringify(value)); -}; - -const plantOpsData = [ - { - type: 'group', - id: 'plant-1', - label: 'Plant Alpha', - attrs: { x: 80, y: 80 }, - children: [ - { - type: 'grid', - id: 'grid-panels', - label: 'Array A', - cells: [ - [1, 0, 1], - [1, 1, 1], - ], - gap: 4, - item: { - size: { width: 40, height: 80 }, - padding: { x: 6, y: 6 }, - components: [ - { - type: 'background', - id: 'panel-bg', - source: { - type: 'rect', - fill: 'white', - borderWidth: 2, - borderColor: 'primary.dark', - radius: 4, - }, - tint: 'white', - }, - { - type: 'bar', - id: 'panel-metric', - source: { type: 'rect', fill: 'primary.default', radius: 4 }, - size: { width: '100%', height: '60%' }, - placement: 'bottom', - }, - { type: 'icon', source: 'loading', tint: 'black', size: 16 }, - ], - }, - attrs: { x: 0, y: 0 }, - }, - { - type: 'item', - id: 'icon-beacon', - label: 'Beacon', - size: 56, - components: [ - { - type: 'icon', - source: 'wifi', - size: 36, - tint: 'primary.default', - }, - ], - attrs: { x: 170, y: 18 }, - }, - { - type: 'item', - id: 'icon-inverter', - label: 'Inverter Icon', - size: 56, - components: [ - { - type: 'icon', - source: 'inverter', - size: 36, - tint: 'primary.default', - }, - ], - attrs: { x: 250, y: 18 }, - }, - { - type: 'grid', - id: 'grid-panels-lite', - label: 'Array B', - cells: [ - [1, 1, 1], - [1, 0, 1], - ], - gap: 4, - item: { - size: { width: 40, height: 80 }, - padding: { x: 6, y: 6 }, - components: [ - { - type: 'background', - id: 'panel-bg', - source: { - type: 'rect', - fill: 'white', - borderWidth: 2, - borderColor: 'primary.dark', - radius: 4, - }, - tint: 'white', - }, - { - type: 'bar', - id: 'panel-metric', - source: { type: 'rect', fill: 'primary.default', radius: 4 }, - size: { width: '100%', height: '45%' }, - placement: 'bottom', - }, - ], - }, - attrs: { x: 0, y: 200 }, - }, - { - type: 'grid', - id: 'grid-panels-mini', - label: 'Array D', - cells: [ - [1, 1, 1], - [0, 1, 1], - ], - gap: 4, - item: { - size: { width: 40, height: 80 }, - padding: { x: 6, y: 6 }, - components: [ - { - type: 'background', - id: 'panel-bg', - source: { - type: 'rect', - fill: 'white', - borderWidth: 2, - borderColor: 'primary.dark', - radius: 4, - }, - tint: 'white', - }, - { - type: 'bar', - id: 'panel-metric', - source: { type: 'rect', fill: 'primary.default', radius: 4 }, - size: { width: '100%', height: '35%' }, - placement: 'bottom', - }, - ], - }, - attrs: { x: 0, y: 380 }, - }, - { - type: 'grid', - id: 'grid-panels-tilt', - label: 'Array C', - cells: [ - [1, 1, 0], - [1, 1, 1], - ], - gap: 4, - item: { - size: { width: 40, height: 80 }, - padding: { x: 6, y: 6 }, - components: [ - { - type: 'background', - id: 'panel-bg', - source: { - type: 'rect', - fill: 'white', - borderWidth: 2, - borderColor: 'primary.dark', - radius: 4, - }, - tint: 'white', - }, - { - type: 'bar', - id: 'panel-metric', - source: { type: 'rect', fill: 'primary.default', radius: 4 }, - size: { width: '100%', height: '70%' }, - placement: 'bottom', - }, - { type: 'icon', source: 'loading', tint: 'black', size: 16 }, - ], - }, - attrs: { x: 160, y: 220, angle: -12 }, - }, - { - type: 'grid', - id: 'grid-panels-tilt-2', - label: 'Array E', - cells: [ - [1, 1, 1], - [1, 1, 0], - ], - gap: 4, - item: { - size: { width: 40, height: 80 }, - padding: { x: 6, y: 6 }, - components: [ - { - type: 'background', - id: 'panel-bg', - source: { - type: 'rect', - fill: 'white', - borderWidth: 2, - borderColor: 'primary.dark', - radius: 4, - }, - tint: 'white', - }, - { - type: 'bar', - id: 'panel-metric', - source: { type: 'rect', fill: 'primary.default', radius: 4 }, - size: { width: '100%', height: '55%' }, - placement: 'bottom', - }, - { type: 'icon', source: 'loading', tint: 'black', size: 16 }, - ], - }, - attrs: { x: 160, y: 440, angle: 10 }, - }, - { - type: 'item', - id: 'inverter-1', - label: 'Inverter 01', - size: { width: 210, height: 120 }, - padding: { x: 12, y: 10 }, - components: [ - { - type: 'background', - id: 'bg-inverter-1', - source: { - type: 'rect', - fill: 'white', - borderWidth: 2, - borderColor: 'primary.default', - radius: 12, - }, - tint: 'gray.light', - }, - { - type: 'icon', - id: 'icon-inverter-1', - source: 'inverter', - size: 30, - placement: 'left-top', - margin: { left: 4, top: 4 }, - tint: 'primary.default', - }, - { - type: 'text', - id: 'text-inverter-1-title', - text: 'INV-01', - placement: 'left-top', - margin: { left: 44, top: 6 }, - style: { fontSize: 16, fill: 'black' }, - }, - { - type: 'text', - id: 'text-inverter-1-status', - text: 'Nominal', - placement: 'left-bottom', - margin: { left: 44, bottom: 8 }, - style: { fontSize: 12, fill: 'gray.dark' }, - }, - { - type: 'text', - id: 'text-inverter-1-metric', - text: 'Load 42%', - placement: 'right-bottom', - margin: { right: 12, bottom: 8 }, - style: { fontSize: 12, fill: 'gray.dark' }, - }, - { - type: 'bar', - id: 'bar-inverter-1', - source: { type: 'rect', fill: 'primary.default', radius: 6 }, - size: { width: '42%', height: 10 }, - placement: 'bottom', - margin: { left: 12, right: 12, bottom: 30 }, - }, - ], - attrs: { x: 330, y: 0 }, - }, - { - type: 'item', - id: 'inverter-2', - label: 'Inverter 02', - size: { width: 210, height: 120 }, - padding: { x: 12, y: 10 }, - components: [ - { - type: 'background', - id: 'bg-inverter-2', - source: { - type: 'rect', - fill: 'white', - borderWidth: 2, - borderColor: 'primary.default', - radius: 12, - }, - tint: 'gray.light', - }, - { - type: 'icon', - id: 'icon-inverter-2', - source: 'inverter', - size: 30, - placement: 'left-top', - margin: { left: 4, top: 4 }, - tint: 'primary.default', - }, - { - type: 'text', - id: 'text-inverter-2-title', - text: 'INV-02', - placement: 'left-top', - margin: { left: 44, top: 6 }, - style: { fontSize: 16, fill: 'black' }, - }, - { - type: 'text', - id: 'text-inverter-2-status', - text: 'Nominal', - placement: 'left-bottom', - margin: { left: 44, bottom: 8 }, - style: { fontSize: 12, fill: 'gray.dark' }, - }, - { - type: 'text', - id: 'text-inverter-2-metric', - text: 'Load 58%', - placement: 'right-bottom', - margin: { right: 12, bottom: 8 }, - style: { fontSize: 12, fill: 'gray.dark' }, - }, - { - type: 'bar', - id: 'bar-inverter-2', - source: { type: 'rect', fill: 'primary.default', radius: 6 }, - size: { width: '58%', height: 10 }, - placement: 'bottom', - margin: { left: 12, right: 12, bottom: 30 }, - }, - ], - attrs: { x: 330, y: 140 }, - }, - { - type: 'item', - id: 'gateway-1', - label: 'Gateway', - size: { width: 220, height: 120 }, - padding: { x: 12, y: 10 }, - components: [ - { - type: 'background', - id: 'bg-gateway-1', - source: { - type: 'rect', - fill: 'white', - borderWidth: 2, - borderColor: 'gray.dark', - radius: 14, - }, - tint: 'gray.light', - }, - { - type: 'icon', - id: 'icon-gateway-1', - source: 'edge', - size: 32, - placement: 'left-top', - margin: { left: 4, top: 4 }, - tint: 'primary.default', - }, - { - type: 'text', - id: 'text-gateway-1-title', - text: 'Gateway', - placement: 'left-top', - margin: { left: 48, top: 6 }, - style: { fontSize: 16, fill: 'black' }, - }, - { - type: 'text', - id: 'text-gateway-1-status', - text: 'Online', - placement: 'left-bottom', - margin: { left: 48, bottom: 8 }, - style: { fontSize: 12, fill: 'gray.dark' }, - }, - { - type: 'text', - id: 'text-gateway-1-metric', - text: 'Throughput 73%', - placement: 'right-bottom', - margin: { right: 12, bottom: 8 }, - style: { fontSize: 12, fill: 'gray.dark' }, - }, - { - type: 'bar', - id: 'bar-gateway-1', - source: { type: 'rect', fill: 'primary.default', radius: 6 }, - size: { width: '73%', height: 10 }, - placement: 'bottom', - margin: { left: 12, right: 12, bottom: 30 }, - }, - ], - attrs: { x: 600, y: 70 }, - }, - ], - }, - { - type: 'item', - id: 'ops-console', - label: 'Ops Console', - size: { width: 260, height: 120 }, - padding: { x: 16, y: 12 }, - components: [ - { - type: 'background', - id: 'bg-ops-console', - source: { - type: 'rect', - fill: 'white', - borderWidth: 2, - borderColor: 'primary.default', - }, - tint: 'gray.light', - }, - { - type: 'icon', - id: 'icon-ops-console', - source: 'object', - size: 34, - placement: 'left-top', - margin: { left: 6, top: 6 }, - tint: 'primary.default', - }, - { - type: 'text', - id: 'text-ops-title', - text: 'Ops Console', - placement: 'left-top', - margin: { left: 52, top: 8 }, - style: { fontSize: 16, fill: 'black' }, - }, - { - type: 'text', - id: 'text-ops-status', - text: 'Queue 4 tasks', - placement: 'left-bottom', - margin: { left: 52, bottom: 10 }, - style: { fontSize: 12, fill: 'gray.dark' }, - }, - { - type: 'bar', - id: 'bar-ops-queue', - source: { type: 'rect', fill: 'primary.default', radius: 6 }, - size: { width: '40%', height: 10 }, - placement: 'bottom', - margin: { left: 14, right: 14, bottom: 34 }, - }, - ], - attrs: { x: 480, y: 560 }, - }, - { - type: 'relations', - id: 'rel-power', - links: [ - { source: 'grid-panels.0.0', target: 'grid-panels.1.0' }, - { source: 'grid-panels.1.0', target: 'grid-panels.1.1' }, - { source: 'grid-panels.1.1', target: 'grid-panels.1.2' }, - { source: 'grid-panels.0.2', target: 'grid-panels.1.2' }, - ], - style: { width: 3, color: 'primary.default' }, - }, -]; - -const meshData = [ - { - type: 'group', - id: 'mesh-1', - label: 'Edge Mesh', - attrs: { x: 80, y: 80 }, - children: [ - { - type: 'item', - id: 'node-a', - label: 'Node A', - size: { width: 180, height: 110 }, - padding: { x: 12, y: 10 }, - components: [ - { - type: 'background', - id: 'bg-node-a', - source: { - type: 'rect', - fill: 'white', - borderWidth: 2, - borderColor: 'gray.dark', - radius: 14, - }, - tint: 'gray.light', - }, - { - type: 'icon', - id: 'icon-node-a', - source: 'device', - size: 30, - placement: 'left-top', - margin: { left: 4, top: 4 }, - tint: 'primary.default', - }, - { - type: 'text', - id: 'text-node-a-title', - text: 'Node A', - placement: 'left-top', - margin: { left: 44, top: 6 }, - style: { fontSize: 16, fill: 'black' }, - }, - { - type: 'text', - id: 'text-node-a-status', - text: 'Stable', - placement: 'left-bottom', - margin: { left: 44, bottom: 8 }, - style: { fontSize: 12, fill: 'gray.dark' }, - }, - { - type: 'text', - id: 'text-node-a-metric', - text: 'Latency 18ms', - placement: 'right-bottom', - margin: { right: 12, bottom: 8 }, - style: { fontSize: 12, fill: 'gray.dark' }, - }, - { - type: 'bar', - id: 'bar-node-a', - source: { type: 'rect', fill: 'primary.default', radius: 6 }, - size: { width: '35%', height: 10 }, - placement: 'bottom', - margin: { left: 12, right: 12, bottom: 30 }, - }, - ], - attrs: { x: 0, y: 0 }, - }, - { - type: 'item', - id: 'node-b', - label: 'Node B', - size: { width: 180, height: 110 }, - padding: { x: 12, y: 10 }, - components: [ - { - type: 'background', - id: 'bg-node-b', - source: { - type: 'rect', - fill: 'white', - borderWidth: 2, - borderColor: 'gray.dark', - radius: 14, - }, - tint: 'gray.light', - }, - { - type: 'icon', - id: 'icon-node-b', - source: 'wifi', - size: 30, - placement: 'left-top', - margin: { left: 4, top: 4 }, - tint: 'primary.default', - }, - { - type: 'text', - id: 'text-node-b-title', - text: 'Node B', - placement: 'left-top', - margin: { left: 44, top: 6 }, - style: { fontSize: 16, fill: 'black' }, - }, - { - type: 'text', - id: 'text-node-b-status', - text: 'Stable', - placement: 'left-bottom', - margin: { left: 44, bottom: 8 }, - style: { fontSize: 12, fill: 'gray.dark' }, - }, - { - type: 'text', - id: 'text-node-b-metric', - text: 'Latency 22ms', - placement: 'right-bottom', - margin: { right: 12, bottom: 8 }, - style: { fontSize: 12, fill: 'gray.dark' }, - }, - { - type: 'bar', - id: 'bar-node-b', - source: { type: 'rect', fill: 'primary.default', radius: 6 }, - size: { width: '44%', height: 10 }, - placement: 'bottom', - margin: { left: 12, right: 12, bottom: 30 }, - }, - ], - attrs: { x: 240, y: 0 }, - }, - { - type: 'item', - id: 'node-c', - label: 'Node C', - size: { width: 180, height: 110 }, - padding: { x: 12, y: 10 }, - components: [ - { - type: 'background', - id: 'bg-node-c', - source: { - type: 'rect', - fill: 'white', - borderWidth: 2, - borderColor: 'gray.dark', - radius: 14, - }, - tint: 'gray.light', - }, - { - type: 'icon', - id: 'icon-node-c', - source: 'edge', - size: 30, - placement: 'left-top', - margin: { left: 4, top: 4 }, - tint: 'primary.default', - }, - { - type: 'text', - id: 'text-node-c-title', - text: 'Node C', - placement: 'left-top', - margin: { left: 44, top: 6 }, - style: { fontSize: 16, fill: 'black' }, - }, - { - type: 'text', - id: 'text-node-c-status', - text: 'Stable', - placement: 'left-bottom', - margin: { left: 44, bottom: 8 }, - style: { fontSize: 12, fill: 'gray.dark' }, - }, - { - type: 'text', - id: 'text-node-c-metric', - text: 'Latency 30ms', - placement: 'right-bottom', - margin: { right: 12, bottom: 8 }, - style: { fontSize: 12, fill: 'gray.dark' }, - }, - { - type: 'bar', - id: 'bar-node-c', - source: { type: 'rect', fill: 'primary.default', radius: 6 }, - size: { width: '52%', height: 10 }, - placement: 'bottom', - margin: { left: 12, right: 12, bottom: 30 }, - }, - ], - attrs: { x: 0, y: 150 }, - }, - { - type: 'item', - id: 'node-d', - label: 'Node D', - size: { width: 180, height: 110 }, - padding: { x: 12, y: 10 }, - components: [ - { - type: 'background', - id: 'bg-node-d', - source: { - type: 'rect', - fill: 'white', - borderWidth: 2, - borderColor: 'gray.dark', - radius: 14, - }, - tint: 'gray.light', - }, - { - type: 'icon', - id: 'icon-node-d', - source: 'combiner', - size: 30, - placement: 'left-top', - margin: { left: 4, top: 4 }, - tint: 'primary.default', - }, - { - type: 'text', - id: 'text-node-d-title', - text: 'Node D', - placement: 'left-top', - margin: { left: 44, top: 6 }, - style: { fontSize: 16, fill: 'black' }, - }, - { - type: 'text', - id: 'text-node-d-status', - text: 'Stable', - placement: 'left-bottom', - margin: { left: 44, bottom: 8 }, - style: { fontSize: 12, fill: 'gray.dark' }, - }, - { - type: 'text', - id: 'text-node-d-metric', - text: 'Latency 26ms', - placement: 'right-bottom', - margin: { right: 12, bottom: 8 }, - style: { fontSize: 12, fill: 'gray.dark' }, - }, - { - type: 'bar', - id: 'bar-node-d', - source: { type: 'rect', fill: 'primary.default', radius: 6 }, - size: { width: '38%', height: 10 }, - placement: 'bottom', - margin: { left: 12, right: 12, bottom: 30 }, - }, - ], - attrs: { x: 240, y: 150 }, - }, - ], - }, - { - type: 'relations', - id: 'rel-mesh', - links: [ - { source: 'node-a', target: 'node-b' }, - { source: 'node-b', target: 'node-d' }, - { source: 'node-a', target: 'node-c' }, - ], - style: { width: 2, color: 'gray.dark' }, - }, -]; - -export const scenarios = [ - { - id: 'plant-ops', - name: 'Example 1', - description: '', - data: () => clone(plantOpsData), - focusId: 'gateway-1', - dynamic: { - bars: [ - { - path: '$..[?(@.id=="panel-metric")]', - axis: 'height', - min: 10, - max: 100, - width: '100%', - }, - { - id: 'bar-inverter-1', - textId: 'text-inverter-1-metric', - label: 'Load', - min: 20, - max: 95, - height: 10, - }, - { - id: 'bar-inverter-2', - textId: 'text-inverter-2-metric', - label: 'Load', - min: 20, - max: 95, - height: 10, - }, - { - id: 'bar-gateway-1', - textId: 'text-gateway-1-metric', - label: 'Throughput', - min: 10, - max: 100, - height: 10, - }, - { - id: 'bar-ops-queue', - textId: 'text-ops-status', - label: 'Queue', - unit: ' tasks', - min: 10, - max: 85, - height: 10, - }, - ], - statuses: [ - { - backgroundId: 'bg-inverter-1', - iconId: 'icon-inverter-1', - textId: 'text-inverter-1-status', - ok: { - tint: 'gray.light', - icon: 'inverter', - iconTint: 'primary.default', - text: 'Nominal', - textTint: 'gray.dark', - }, - alert: { - tint: 'primary.accent', - icon: 'warning', - iconTint: 'primary.accent', - text: 'Overheat', - textTint: 'primary.accent', - }, - }, - { - backgroundId: 'bg-inverter-2', - iconId: 'icon-inverter-2', - textId: 'text-inverter-2-status', - ok: { - tint: 'gray.light', - icon: 'inverter', - iconTint: 'primary.default', - text: 'Nominal', - textTint: 'gray.dark', - }, - alert: { - tint: 'primary.accent', - icon: 'warning', - iconTint: 'primary.accent', - text: 'Overheat', - textTint: 'primary.accent', - }, - }, - { - backgroundId: 'bg-gateway-1', - iconId: 'icon-gateway-1', - textId: 'text-gateway-1-status', - ok: { - tint: 'gray.light', - icon: 'edge', - iconTint: 'primary.default', - text: 'Online', - textTint: 'gray.dark', - }, - alert: { - tint: 'primary.accent', - icon: 'warning', - iconTint: 'primary.accent', - text: 'Link Loss', - textTint: 'primary.accent', - }, - }, - ], - relationsId: 'rel-power', - linkSets: [ - [ - { source: 'grid-panels.0.0', target: 'grid-panels.1.0' }, - { source: 'grid-panels.1.0', target: 'grid-panels.1.1' }, - { source: 'grid-panels.1.1', target: 'grid-panels.1.2' }, - { source: 'grid-panels.0.2', target: 'grid-panels.1.2' }, - ], - [ - { source: 'grid-panels.0.0', target: 'grid-panels.1.0' }, - { source: 'grid-panels.1.0', target: 'grid-panels.1.1' }, - ], - [ - { source: 'grid-panels.0.2', target: 'grid-panels.1.2' }, - { source: 'grid-panels.1.2', target: 'grid-panels.1.1' }, - ], - ], - }, - }, - { - id: 'mesh-lab', - name: 'Example 2', - description: '', - data: () => clone(meshData), - focusId: 'node-b', - dynamic: { - bars: [ - { - id: 'bar-node-a', - textId: 'text-node-a-metric', - label: 'Latency', - min: 10, - max: 90, - height: 10, - }, - { - id: 'bar-node-b', - textId: 'text-node-b-metric', - label: 'Latency', - min: 10, - max: 90, - height: 10, - }, - { - id: 'bar-node-c', - textId: 'text-node-c-metric', - label: 'Latency', - min: 10, - max: 90, - height: 10, - }, - { - id: 'bar-node-d', - textId: 'text-node-d-metric', - label: 'Latency', - min: 10, - max: 90, - height: 10, - }, - ], - statuses: [ - { - backgroundId: 'bg-node-a', - iconId: 'icon-node-a', - textId: 'text-node-a-status', - ok: { - tint: 'gray.light', - icon: 'device', - iconTint: 'primary.default', - text: 'Stable', - textTint: 'gray.dark', - }, - alert: { - tint: 'primary.accent', - icon: 'warning', - iconTint: 'primary.accent', - text: 'Packet Loss', - textTint: 'primary.accent', - }, - }, - { - backgroundId: 'bg-node-b', - iconId: 'icon-node-b', - textId: 'text-node-b-status', - ok: { - tint: 'gray.light', - icon: 'wifi', - iconTint: 'primary.default', - text: 'Stable', - textTint: 'gray.dark', - }, - alert: { - tint: 'primary.accent', - icon: 'warning', - iconTint: 'primary.accent', - text: 'Congested', - textTint: 'primary.accent', - }, - }, - { - backgroundId: 'bg-node-c', - iconId: 'icon-node-c', - textId: 'text-node-c-status', - ok: { - tint: 'gray.light', - icon: 'edge', - iconTint: 'primary.default', - text: 'Stable', - textTint: 'gray.dark', - }, - alert: { - tint: 'primary.accent', - icon: 'warning', - iconTint: 'primary.accent', - text: 'Jitter', - textTint: 'primary.accent', - }, - }, - { - backgroundId: 'bg-node-d', - iconId: 'icon-node-d', - textId: 'text-node-d-status', - ok: { - tint: 'gray.light', - icon: 'combiner', - iconTint: 'primary.default', - text: 'Stable', - textTint: 'gray.dark', - }, - alert: { - tint: 'primary.accent', - icon: 'warning', - iconTint: 'primary.accent', - text: 'Overloaded', - textTint: 'primary.accent', - }, - }, - ], - relationsId: 'rel-mesh', - linkSets: [ - [ - { source: 'node-a', target: 'node-b' }, - { source: 'node-b', target: 'node-d' }, - { source: 'node-a', target: 'node-c' }, - ], - [ - { source: 'node-c', target: 'node-b' }, - { source: 'node-b', target: 'node-a' }, - { source: 'node-d', target: 'node-c' }, - ], - [ - { source: 'node-a', target: 'node-d' }, - { source: 'node-d', target: 'node-b' }, - { source: 'node-b', target: 'node-c' }, - ], - ], - }, - }, -];