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..98259a39 --- /dev/null +++ b/playground/data-editor.js @@ -0,0 +1,179 @@ +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 }) => { + 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'; + 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) { + addPopover.closeAddPopover(); + } + if (!isJson) { + tree.renderTree(); + inspector.renderInspector(state.selectedNodeId); + } + }; + + const setCurrentData = (data, { updateEditor = true } = {}) => { + state.currentData = data; + if (updateEditor) { + setEditorValue(data); + } + tree.renderTree(); + inspector.renderInspector(state.selectedNodeId); + }; + + const updateSelection = (target, fallbackId = null) => { + const id = target?.id ?? fallbackId ?? null; + state.selectedNodeId = id; + elements.selectedId.textContent = id ?? 'None'; + if (patchmap.transformer) { + patchmap.transformer.elements = target ? [target] : []; + } + tree.highlightTree(id); + inspector.renderInspector(id); + addPopover.updateAddParentOptions(); + }; + + const getSelectedNodeId = () => state.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); + }; + + 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, + setCurrentData, + updateSelection, + getSelectedNodeId, + applyEditorData, + prettifyEditor, + selectNodeById, + 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', + }; +}; diff --git a/playground/example-controller.js b/playground/example-controller.js new file mode 100644 index 00000000..dd52fdf4 --- /dev/null +++ b/playground/example-controller.js @@ -0,0 +1,143 @@ +export const createExampleController = ({ + patchmap, + dataEditor, + elements, + examples, + setLastAction, + syncRotationUI, + syncFlipUI, +}) => { + let currentExample = examples[0]; + let linkSetIndex = 0; + + const setupExampleOptions = () => { + examples.forEach((example) => { + const option = document.createElement('option'); + option.value = example.id; + option.textContent = example.name; + elements.scenario.append(option); + }); + elements.scenario.value = currentExample.id; + }; + + const applyExample = (example, { shouldFit = true } = {}) => { + currentExample = example; + linkSetIndex = 0; + + const data = currentExample.data(); + patchmap.draw(data); + if (shouldFit) { + patchmap.fit(); + } + updateSceneInfo(); + dataEditor.setCurrentData(data); + dataEditor.updateSelection(null); + dataEditor.clearEditorError(); + updateActionButtons(); + syncRotationUI(); + syncFlipUI(); + setLastAction(`Loaded ${currentExample.name}`); + }; + + 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 = currentExample.name; + } + if (elements.sceneTitle) { + elements.sceneTitle.textContent = currentExample.name; + } + if (elements.sceneDescription) { + elements.sceneDescription.textContent = currentExample.description; + } + }; + + const updateActionButtons = () => { + const dynamic = currentExample.dynamic ?? {}; + elements.randomize.disabled = (dynamic.bars ?? []).length === 0; + elements.shuffle.disabled = + !dynamic.relationsId || (dynamic.linkSets ?? []).length < 2; + }; + + const randomizeMetrics = () => { + const bars = currentExample.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 = currentExample.dynamic?.linkSets ?? []; + if (!currentExample.dynamic?.relationsId || links.length === 0) { + return; + } + + linkSetIndex = (linkSetIndex + 1) % links.length; + + patchmap.update({ + path: `$..[?(@.id=="${currentExample.dynamic.relationsId}")]`, + changes: { links: links[linkSetIndex] }, + mergeStrategy: 'replace', + }); + + setLastAction('Rerouted links'); + }; + + const getCurrentExample = () => currentExample; + + 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 { + setupExampleOptions, + applyExample, + setExampleById, + randomizeMetrics, + shuffleLinks, + 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/index.html b/playground/index.html new file mode 100644 index 00000000..e7a38c17 --- /dev/null +++ b/playground/index.html @@ -0,0 +1,167 @@ + + + + + + Patchmap Playground + + + + + + +
+
+
+ +
Playground
+
+
+ +
+ + +
+
+ + + + + + + +
+ Rotate + + + + + +
+
+
+
+
+ Middle-drag or Space+drag to pan, drag to select, Cmd/Ctrl-click + to add, right-click to clear. +
+
+
+
+ +
+
+ Selection + None + | + Last + + Ready + +
+
+
+ + + + diff --git a/playground/main.js b/playground/main.js new file mode 100644 index 00000000..15a6f8c6 --- /dev/null +++ b/playground/main.js @@ -0,0 +1,203 @@ +import { Patchmap, Transformer } from '@patchmap'; +import { createDataEditor } from './data-editor.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'; + +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'), + 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'), + selectedId: $('#selected-id'), + lastAction: $('#last-action'), +}; + +const patchmap = new Patchmap(); + +const setLastAction = (text) => { + elements.lastAction.textContent = text; +}; + +const dataEditor = createDataEditor({ patchmap, elements, setLastAction }); +const viewControls = createViewControls({ patchmap, elements, setLastAction }); +const exampleController = createExampleController({ + patchmap, + dataEditor, + elements, + examples, + setLastAction, + syncRotationUI: viewControls.syncRotationUI, + syncFlipUI: viewControls.syncFlipUI, +}); +const selectionController = createSelectionController({ + patchmap, + dataEditor, + elements, +}); + +const init = async () => { + await patchmap.init(elements.stage, { + viewport: { + disableOnContextMenu: true, + plugins: { + drag: { mouseButtons: 'middle' }, + }, + }, + }); + patchmap.transformer = new Transformer(); + + selectionController.bindSelectionState(); + + exampleController.setupExampleOptions(); + bindControls(); + exampleController.applyExample(exampleController.getCurrentExample(), { + shouldFit: true, + }); + dataEditor.setDataMode('json'); + viewControls.syncRotationUI(); + viewControls.syncFlipUI(); + + window.patchmap = patchmap; + window.patchmapExamples = examples; +}; + +const bindControls = () => { + elements.scenario.addEventListener('change', (event) => { + exampleController.setExampleById(event.target.value, { + shouldFit: true, + }); + }); + + elements.draw.addEventListener('click', () => { + exampleController.applyExample(exampleController.getCurrentExample(), { + shouldFit: true, + }); + }); + + elements.applyData.addEventListener('click', () => { + dataEditor.applyEditorData(); + }); + + elements.prettifyData.addEventListener('click', () => { + dataEditor.prettifyEditor(); + }); + + elements.resetData.addEventListener('click', () => { + exampleController.applyExample(exampleController.getCurrentExample(), { + shouldFit: true, + }); + setLastAction('Reset to example'); + }); + + 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', () => { + exampleController.randomizeMetrics(); + }); + + elements.shuffle.addEventListener('click', () => { + exampleController.shuffleLinks(); + }); + + elements.focus.addEventListener('click', () => { + const currentExample = exampleController.getCurrentExample(); + const targetId = dataEditor.getSelectedNodeId() ?? currentExample.focusId; + if (targetId) { + patchmap.focus(targetId); + setLastAction(`Focus ${targetId}`); + } + }); + + elements.fit.addEventListener('click', () => { + patchmap.fit(); + setLastAction('Fit to content'); + }); + + viewControls.bindViewControls(); + selectionController.bindSelectionShortcuts(); +}; + +init(); 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/style.css b/playground/style.css new file mode 100644 index 00000000..f416a278 --- /dev/null +++ b/playground/style.css @@ -0,0 +1,6 @@ +@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); +} 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, + }; +}; 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'), + }, + }, +}); 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..142e3078 --- /dev/null +++ b/src/display/utils/world-flip.js @@ -0,0 +1,36 @@ +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 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 -= prevWidth; + } + if (prevState.y) { + baseY -= prevHeight; + } + + 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 = { + x: nextState.x, + y: nextState.y, + width, + height, + }; +}; 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); +};