From 5ccab05c0639805fc61ec4de0437ebe373efea5a Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 29 Apr 2026 23:52:43 +0900 Subject: [PATCH 01/51] perf(patchmap): optimize panel rendering and selection --- src/display/draw.js | 16 +- src/display/mixins/Base.js | 57 +- src/display/mixins/Componentsable.js | 7 + src/display/mixins/WorldTransformable.js | 6 +- src/display/model/SceneIndex.js | 87 +++ src/display/renderers/PanelBarLayer.js | 663 +++++++++++++++++ .../renderers/panelComponentRenderer.js | 666 ++++++++++++++++++ src/display/update.js | 43 +- src/events/find.js | 217 +++++- src/init.js | 1 + src/patchmap.js | 67 +- src/utils/selector/selector.js | 47 ++ 12 files changed, 1830 insertions(+), 47 deletions(-) create mode 100644 src/display/model/SceneIndex.js create mode 100644 src/display/renderers/PanelBarLayer.js create mode 100644 src/display/renderers/panelComponentRenderer.js diff --git a/src/display/draw.js b/src/display/draw.js index 056bc8d5..c530d169 100644 --- a/src/display/draw.js +++ b/src/display/draw.js @@ -1,3 +1,6 @@ +import { SceneIndex } from './model/SceneIndex'; +import { primePanelComponentCache } from './renderers/panelComponentRenderer'; + export const draw = (store, data) => { resetElementIndex(store); destroyChildren(store.world); @@ -5,6 +8,7 @@ export const draw = (store, data) => { { type: 'canvas', children: data }, { mergeStrategy: 'replace', validateSchema: false }, ); + primePanelCaches(store); }; const destroyChildren = (parent) => { @@ -17,5 +21,15 @@ const destroyChildren = (parent) => { const resetElementIndex = (store) => { const targetStore = store.world?.store ?? store; - targetStore.elementById = new Map(); + targetStore.sceneIndex = new SceneIndex(); + targetStore.elementById = targetStore.sceneIndex.elementById; +}; + +const primePanelCaches = (store) => { + const targetStore = store.world?.store ?? store; + const items = targetStore.sceneIndex?.byType?.get('item'); + if (!items) return; + for (const item of items) { + primePanelComponentCache(item, { materializeHiddenBar: true }); + } }; diff --git a/src/display/mixins/Base.js b/src/display/mixins/Base.js index 87ca234e..0f81ac11 100644 --- a/src/display/mixins/Base.js +++ b/src/display/mixins/Base.js @@ -4,6 +4,7 @@ import { deepMerge } from '../../utils/deepmerge/deepmerge'; import { diffReplace } from '../../utils/diff/diff-replace'; import { isSame } from '../../utils/diff/is-same'; import { validate } from '../../utils/validator'; +import { getSceneIndexKeys } from '../model/SceneIndex'; import { normalizeChanges } from '../normalize'; import { Type } from './Type'; @@ -19,6 +20,7 @@ const TRANSFORM_SYNC_KEYS = new Set([ 'skew', 'pivot', ]); +const BOUNDS_SYNC_KEYS = new Set(['size', 'padding']); const getPatchDiff = (currentProps, changes) => { if ( @@ -55,15 +57,18 @@ export const Base = (superClass) => { super(rest); this.#store = store; this.props = rest?.type ? { type: rest.type } : {}; - this.onRender = () => { - if ( - this.#store?.viewport?.moving || - this.#store?.viewport?._suspendObjectAfterRender - ) { - return; - } - this._afterRender(); - }; + this.onRender = + this._afterRender === MixedClass.prototype._afterRender + ? null + : () => { + if ( + this.#store?.viewport?.moving || + this.#store?.viewport?._suspendObjectAfterRender + ) { + return; + } + this._afterRender(); + }; } get store() { @@ -183,10 +188,10 @@ export const Base = (superClass) => { this.props = validatedProps; if (RAW_SYNC_KEYS.some((key) => Object.hasOwn(diffProps, key))) { - const previousId = this.id; + const previousIndexKeys = getSceneIndexKeys(this); const { id, label, attrs } = diffProps; this._applyRaw({ id, label, ...attrs }, mergeStrategy); - this._syncStoreElementIndex(previousId); + this._syncStoreElementIndex(previousIndexKeys); } const handlerChanges = options.changes ?? normalizedChanges; @@ -197,6 +202,12 @@ export const Base = (superClass) => { normalize, changes: handlerChanges, }); + if ( + this.constructor.isSelectable && + keysToProcess.some((key) => BOUNDS_SYNC_KEYS.has(key)) + ) { + this.store?.sceneIndex?.touch(); + } if (this.parent?._onChildUpdate) { this.parent._onChildUpdate( @@ -214,6 +225,7 @@ export const Base = (superClass) => { if (keysToProcess.length === 0) return; const previousId = this.id; + const previousIndexKeys = getSceneIndexKeys(this); this.props = initialProps; if (RAW_SYNC_KEYS.some((key) => Object.hasOwn(initialProps, key))) { @@ -222,7 +234,7 @@ export const Base = (superClass) => { { id, label, ...attrs }, options.mergeStrategy ?? 'replace', ); - this._syncStoreElementIndex(previousId); + this._syncStoreElementIndex(previousIndexKeys ?? previousId); } this._applyHandlers(keysToProcess, { @@ -301,34 +313,45 @@ export const Base = (superClass) => { if (transformChanged) { this._emitObjectTransformed(); } + if (Object.hasOwn(attrs, 'alpha')) { + this.store?.panelBarLayer?.syncAlphaForSubtree?.(this); + } } _updateProperty(key, value, mergeStrategy) { deepMerge(this, { [key]: value }, { mergeStrategy }); } - _syncStoreElementIndex(previousId) { + _syncStoreElementIndex(previousIndexKeys) { const elementById = this.store?.elementById; - if (!elementById) return; + const sceneIndex = this.store?.sceneIndex; + if (!elementById && !sceneIndex) return; + + const previousId = + typeof previousIndexKeys === 'string' + ? previousIndexKeys + : previousIndexKeys?.id; if ( + elementById && previousId && previousId !== this.id && elementById.get(previousId) === this ) { elementById.delete(previousId); } - if (this.id) { + if (elementById && this.id) { elementById.set(this.id, this); } + sceneIndex?.update(this, previousIndexKeys); } _removeFromStoreElementIndex() { const elementById = this.store?.elementById; - if (!elementById || !this.id) return; - if (elementById.get(this.id) === this) { + if (elementById && this.id && elementById.get(this.id) === this) { elementById.delete(this.id); } + this.store?.sceneIndex?.remove(this); } }; diff --git a/src/display/mixins/Componentsable.js b/src/display/mixins/Componentsable.js index 82f34bdc..77739233 100644 --- a/src/display/mixins/Componentsable.js +++ b/src/display/mixins/Componentsable.js @@ -3,6 +3,7 @@ import { findIndexByPriority } from '../../utils/findIndexByPriority'; import { newComponent } from '../components/creator'; import { componentArraySchema } from '../data-schema/component-schema'; import { applyComponentDefaults } from '../default-props'; +import { tryApplyPanelComponentChanges } from '../renderers/panelComponentRenderer'; import { UPDATE_STAGES } from './constants'; import { validateAndPrepareChanges } from './utils'; @@ -22,6 +23,12 @@ export const Componentsable = (superClass) => { componentsChanges = componentsChanges ?? []; const components = [...this.children]; + if ( + tryApplyPanelComponentChanges(this, componentsChanges, childOptions) + ) { + return; + } + componentsChanges = validateAndPrepareChanges( components, componentsChanges, diff --git a/src/display/mixins/WorldTransformable.js b/src/display/mixins/WorldTransformable.js index d714d71d..f430a1dd 100644 --- a/src/display/mixins/WorldTransformable.js +++ b/src/display/mixins/WorldTransformable.js @@ -179,10 +179,10 @@ export const WorldTransformable = (superClass) => { this._markWorldTransformBoundsDirty(); } - _markWorldTransformBoundsDirty() { + _markWorldTransformBoundsDirty(frames = BOUNDS_SETTLE_FRAMES) { this._worldTransformBoundsCheckFrames = Math.max( this._worldTransformBoundsCheckFrames ?? 0, - BOUNDS_SETTLE_FRAMES, + frames, ); } @@ -194,7 +194,7 @@ export const WorldTransformable = (superClass) => { _applyHandlers(keysToProcess, options) { const result = super._applyHandlers?.(keysToProcess, options); if (keysToProcess?.length > 0) { - this._markWorldTransformBoundsDirty(); + this._markWorldTransformBoundsDirty(1); } return result; } diff --git a/src/display/model/SceneIndex.js b/src/display/model/SceneIndex.js new file mode 100644 index 00000000..20f100ec --- /dev/null +++ b/src/display/model/SceneIndex.js @@ -0,0 +1,87 @@ +export class SceneIndex { + constructor() { + this.elementById = new Map(); + this.byType = new Map(); + this.byDisplay = new Map(); + this.selectable = new Set(); + this.version = 0; + } + + add(node) { + const keys = getSceneIndexKeys(node); + if (!keys) return; + + if (keys.id) { + this.elementById.set(keys.id, node); + } + addToBucket(this.byType, keys.type, node); + addToBucket(this.byDisplay, keys.display, node); + if (keys.selectable) { + this.selectable.add(node); + } + this.version++; + } + + update(node, previousKeys) { + this.remove(node, previousKeys); + this.add(node); + } + + remove(node, keys = getSceneIndexKeys(node)) { + if (!keys) return; + + if (keys.id && this.elementById.get(keys.id) === node) { + this.elementById.delete(keys.id); + } + removeFromBucket(this.byType, keys.type, node); + removeFromBucket(this.byDisplay, keys.display, node); + this.selectable.delete(node); + this.version++; + } + + touch() { + this.version++; + } + + getById(id) { + return this.elementById.get(id) ?? null; + } + + getByType(type) { + return [...(this.byType.get(type) ?? [])]; + } + + getByDisplay(display) { + return [...(this.byDisplay.get(display) ?? [])]; + } +} + +export const getSceneIndexKeys = (node) => { + if (!node?.type) return null; + return { + id: node.id, + type: node.type, + display: node.display ?? node.props?.attrs?.display, + selectable: Boolean(node.constructor?.isSelectable), + }; +}; + +const addToBucket = (buckets, key, node) => { + if (key === undefined || key === null) return; + let bucket = buckets.get(key); + if (!bucket) { + bucket = new Set(); + buckets.set(key, bucket); + } + bucket.add(node); +}; + +const removeFromBucket = (buckets, key, node) => { + if (key === undefined || key === null) return; + const bucket = buckets.get(key); + if (!bucket) return; + bucket.delete(node); + if (bucket.size === 0) { + buckets.delete(key); + } +}; diff --git a/src/display/renderers/PanelBarLayer.js b/src/display/renderers/PanelBarLayer.js new file mode 100644 index 00000000..9859c1ba --- /dev/null +++ b/src/display/renderers/PanelBarLayer.js @@ -0,0 +1,663 @@ +import { + Particle, + ParticleContainer, + Point, + Rectangle, + Texture, +} from 'pixi.js'; +import { getTexture } from '../../assets/textures/texture'; +import { getColor } from '../../utils/get'; +import { calcSize, resolveComponentPlacement } from '../mixins/utils'; + +const DEFAULT_BOUNDS = new Rectangle( + -1_000_000, + -1_000_000, + 2_000_000, + 2_000_000, +); +const ZERO_POINT = new Point(); + +export class PanelBarLayer extends ParticleContainer { + constructor(store) { + super({ + boundsArea: DEFAULT_BOUNDS, + dynamicProperties: { + vertex: true, + position: true, + rotation: true, + uvs: true, + color: true, + }, + }); + this._patchmapInternal = true; + this.label = 'patchmap-panel-bar-layer'; + this.store = store; + this.zIndex = 0; + this._entries = new WeakMap(); + this._activeAnimations = new Set(); + this._animationFrame = null; + } + + canRender(bar) { + return Boolean(getBarTexture(bar)); + } + + syncBar(bar) { + if (!bar?.parent || bar.destroyed) return false; + + const texture = getBarTexture(bar); + if (!texture) return false; + + let entry = this._entries.get(bar); + if (!entry) { + entry = this._createEntry(bar, texture); + } else if (entry.texture !== texture) { + this._setEntryTexture(entry, texture); + } + + const alpha = this._resolveAlpha(bar); + const tint = getColor(bar.store.theme, bar.props?.tint ?? 0xffffff); + this._applyAppearance(entry, { alpha, tint }); + + if (alpha === 0) { + this._cancelEntryAnimation(entry); + return true; + } + + const nextState = this._resolveState(bar); + const shouldAnimate = Boolean(bar.props?.animation && entry.state); + if (shouldAnimate && this._animateEntry(entry, bar, texture, nextState)) { + return true; + } + + this._cancelEntryAnimation(entry); + this._applyState(entry, texture, nextState); + return true; + } + + hideBar(bar) { + const entry = this._entries.get(bar); + if (entry) { + this._cancelEntryAnimation(entry); + this._applyAppearance(entry, { alpha: 0 }); + } + } + + syncAlphaForSubtree(root) { + if (!root || root.destroyed) return; + + const stack = [root]; + while (stack.length > 0) { + const node = stack.pop(); + if (!node || node.destroyed) continue; + + const bar = node._panelBarComponent; + const entry = bar ? this._entries.get(bar) : null; + if (entry) { + this._applyAppearance(entry, { alpha: this._resolveAlpha(bar) }); + } + + if (node.children?.length) { + stack.push(...node.children); + } + } + } + + _createEntry(bar, texture) { + const entry = { + texture: null, + layout: null, + particles: [], + particle: null, + state: null, + job: null, + }; + this._setEntryTexture(entry, texture); + this._entries.set(bar, entry); + return entry; + } + + _setEntryTexture(entry, texture) { + const layout = getNineSliceLayout(texture); + const particles = layout.pieces.map( + (piece) => + new Particle({ + texture: piece.texture, + anchorX: 0, + anchorY: 0, + alpha: 0, + }), + ); + + this._removeEntryParticles(entry); + this.particleChildren.push(...particles); + entry.texture = texture; + entry.layout = layout; + entry.particles = particles; + entry.particle = particles[0] ?? null; + this.update(); + } + + _removeEntryParticles(entry) { + if (!entry.particles?.length) return; + + const particles = new Set(entry.particles); + for (let index = this.particleChildren.length - 1; index >= 0; index -= 1) { + if (particles.has(this.particleChildren[index])) { + this.particleChildren.splice(index, 1); + } + } + } + + _applyAppearance(entry, { alpha, tint }) { + if (alpha !== undefined) entry.alpha = alpha; + for (const particle of entry.particles) { + if (alpha !== undefined) particle.alpha = alpha; + if (tint !== undefined) particle.tint = tint; + } + } + + _animateEntry(entry, bar, texture, nextState) { + this._cancelEntryAnimation(entry); + const fromState = entry.state; + const durationMs = normalizeDuration(bar.props?.animationDuration); + if (durationMs === 0) { + this._applyState(entry, texture, nextState); + return true; + } + + entry.animation = { + texture, + from: fromState, + to: nextState, + durationMs, + startedAt: now(), + }; + this._activeAnimations.add(entry); + this._scheduleAnimationFrame(); + return true; + } + + _cancelEntryAnimation(entry) { + if (!entry?.animation) return; + entry.animation = null; + this._activeAnimations.delete(entry); + } + + _applyState(entry, texture, state, rotation = state.rotation) { + const normalizedState = normalizeState(state, rotation); + const layout = entry.layout ?? getNineSliceLayout(texture); + const targetSlices = resolveTargetSlices(layout, normalizedState); + const cos = Math.cos(normalizedState.rotation); + const sin = Math.sin(normalizedState.rotation); + + for (let index = 0; index < entry.particles.length; index += 1) { + const particle = entry.particles[index]; + const piece = layout.pieces[index]; + const target = targetSlices[index]; + if ( + !particle || + !piece || + !target || + target.width <= 0 || + target.height <= 0 + ) { + if (particle) particle.alpha = 0; + continue; + } + + particle.x = normalizedState.x + target.x * cos - target.y * sin; + particle.y = normalizedState.y + target.x * sin + target.y * cos; + particle.scaleX = target.width / piece.width; + particle.scaleY = target.height / piece.height; + particle.rotation = normalizedState.rotation; + if (entry.alpha !== undefined && particle.alpha !== entry.alpha) { + particle.alpha = entry.alpha; + } + } + entry.state = normalizedState; + } + + _resolveState(bar) { + const size = calcSize(bar, { + source: bar.props?.source, + size: bar.props?.size, + margin: bar.props?.margin, + }); + const placement = + bar._calcPlacementForSize?.({ + placement: resolveComponentPlacement(bar), + margin: bar.props?.margin, + width: size.width, + height: size.height, + }) ?? ZERO_POINT; + const worldPoint = bar.parent.toGlobal(new Point(placement.x, placement.y)); + const localPoint = this.parent + ? this.parent.toLocal(worldPoint) + : worldPoint; + + return { + x: localPoint.x, + y: localPoint.y, + width: size.width, + height: size.height, + rotation: getWorldRotation(bar.parent) - getWorldRotation(this), + }; + } + + _resolveAlpha(bar) { + if (bar.props?.show === false) return 0; + + let alpha = 1; + let current = bar; + while (current && current !== this.parent) { + alpha *= current.alpha ?? 1; + current = current.parent ?? null; + } + return alpha; + } + + _scheduleAnimationFrame() { + if (this._animationFrame !== null) return; + this._animationFrame = requestFrame((time) => { + this._animationFrame = null; + this._tickAnimations(time ?? now()); + }); + } + + _tickAnimations(time) { + for (const entry of this._activeAnimations) { + const animation = entry.animation; + if (!animation || entry.particles.length === 0) { + this._activeAnimations.delete(entry); + continue; + } + + const progress = clamp01( + (time - animation.startedAt) / animation.durationMs, + ); + this._applyState( + entry, + animation.texture, + interpolateState( + animation.from, + animation.to, + easePower2InOut(progress), + ), + ); + if (progress >= 1) { + entry.animation = null; + this._activeAnimations.delete(entry); + } + } + + if (this._activeAnimations.size > 0 && !this.destroyed) { + this._scheduleAnimationFrame(); + } + } +} + +export const ensurePanelBarLayer = (store) => { + if (!store?.world) return null; + let layer = store.panelBarLayer; + if (layer?.destroyed) { + layer = null; + } + if (!layer) { + layer = new PanelBarLayer(store); + store.panelBarLayer = layer; + } + placePanelBarLayer(store.world, layer); + return layer; +}; + +const placePanelBarLayer = (world, layer) => { + const relationIndex = world.children.findIndex( + (child) => child !== layer && child.type === 'relations', + ); + const currentIndex = world.children.indexOf(layer); + + if (currentIndex === -1) { + const insertIndex = + relationIndex === -1 ? world.children.length : relationIndex; + world.addChildAt(layer, insertIndex); + return; + } + + if (relationIndex === -1) return; + if (currentIndex < relationIndex) return; + world.setChildIndex(layer, relationIndex); +}; + +const getBarTexture = (bar) => { + const source = bar?.props?.source; + if (!source || source.type !== 'rect') return null; + if ( + bar._patchmapAggregateTextureSource === source && + bar._patchmapAggregateTexture + ) { + return bar._patchmapAggregateTexture; + } + const texture = getTexture( + bar.store.viewport.app.renderer, + bar.store.theme, + source, + ); + bar._patchmapAggregateTextureSource = source; + bar._patchmapAggregateTexture = texture; + return texture; +}; + +const getNineSliceLayout = (texture) => { + if (texture._patchmapNineSliceLayout) { + return texture._patchmapNineSliceLayout; + } + + const slice = normalizeSlice(texture); + if ( + slice.leftWidth + + slice.rightWidth + + slice.topHeight + + slice.bottomHeight === + 0 + ) { + texture._patchmapNineSliceLayout = { + texture, + slice, + pieces: [ + { + x: 0, + y: 0, + width: texture.width, + height: texture.height, + texture, + }, + ], + single: true, + }; + return texture._patchmapNineSliceLayout; + } + + if ((texture.metadata?.borderWidth ?? 0) === 0) { + texture._patchmapNineSliceLayout = createBorderlessNineSliceLayout( + texture, + slice, + ); + return texture._patchmapNineSliceLayout; + } + + const sourceColumns = buildSegments( + texture.width, + slice.leftWidth, + slice.rightWidth, + ); + const sourceRows = buildSegments( + texture.height, + slice.topHeight, + slice.bottomHeight, + ); + const pieces = []; + + for (const row of sourceRows) { + for (const column of sourceColumns) { + pieces.push({ + x: column.offset, + y: row.offset, + width: column.size, + height: row.size, + texture: createSubTexture( + texture, + column.offset, + row.offset, + column.size, + row.size, + ), + }); + } + } + + texture._patchmapNineSliceLayout = { + texture, + slice, + pieces, + }; + return texture._patchmapNineSliceLayout; +}; + +const createBorderlessNineSliceLayout = (texture, slice) => { + const centerWidth = Math.max( + 1, + texture.width - slice.leftWidth - slice.rightWidth, + ); + const centerHeight = Math.max( + 1, + texture.height - slice.topHeight - slice.bottomHeight, + ); + const centerX = slice.leftWidth; + const centerY = slice.topHeight; + const centerTexture = createSubTexture( + texture, + centerX, + centerY, + centerWidth, + centerHeight, + ); + + return { + texture, + slice, + borderless: true, + pieces: [ + { + width: slice.leftWidth, + height: slice.topHeight, + texture: createSubTexture( + texture, + 0, + 0, + slice.leftWidth, + slice.topHeight, + ), + }, + { + width: slice.rightWidth, + height: slice.topHeight, + texture: createSubTexture( + texture, + texture.width - slice.rightWidth, + 0, + slice.rightWidth, + slice.topHeight, + ), + }, + { + width: slice.leftWidth, + height: slice.bottomHeight, + texture: createSubTexture( + texture, + 0, + texture.height - slice.bottomHeight, + slice.leftWidth, + slice.bottomHeight, + ), + }, + { + width: slice.rightWidth, + height: slice.bottomHeight, + texture: createSubTexture( + texture, + texture.width - slice.rightWidth, + texture.height - slice.bottomHeight, + slice.rightWidth, + slice.bottomHeight, + ), + }, + { + width: centerWidth, + height: centerHeight, + texture: centerTexture, + }, + { + width: centerWidth, + height: centerHeight, + texture: centerTexture, + }, + ], + }; +}; + +const normalizeSlice = (texture) => { + const slice = texture?.metadata?.slice ?? {}; + return { + leftWidth: clampSlice(slice.leftWidth, texture.width), + rightWidth: clampSlice(slice.rightWidth, texture.width), + topHeight: clampSlice(slice.topHeight, texture.height), + bottomHeight: clampSlice(slice.bottomHeight, texture.height), + }; +}; + +const clampSlice = (value, limit) => + Math.max(0, Math.min(Number(value) || 0, limit / 2)); + +const buildSegments = (size, start, end) => { + const center = Math.max(0, size - start - end); + return [ + { offset: 0, size: start }, + { offset: start, size: center }, + { offset: start + center, size: end }, + ]; +}; + +const createSubTexture = (texture, x, y, width, height) => + new Texture({ + source: texture.source, + frame: new Rectangle( + texture.frame.x + x, + texture.frame.y + y, + width, + height, + ), + orig: new Rectangle(0, 0, width, height), + }); + +const resolveTargetSlices = (layout, state) => { + if (layout.single) { + return [ + { + x: 0, + y: 0, + width: state.width, + height: state.height, + }, + ]; + } + + if (layout.borderless) { + return resolveBorderlessTargetSlices(layout, state); + } + + const borderScale = resolveBorderScale(layout, state); + const left = layout.slice.leftWidth * borderScale; + const right = layout.slice.rightWidth * borderScale; + const top = layout.slice.topHeight * borderScale; + const bottom = layout.slice.bottomHeight * borderScale; + const targetColumns = buildSegments(state.width, left, right); + const targetRows = buildSegments(state.height, top, bottom); + const targets = []; + + for (const row of targetRows) { + for (const column of targetColumns) { + targets.push({ + x: column.offset, + y: row.offset, + width: column.size, + height: row.size, + }); + } + } + return targets; +}; + +const resolveBorderlessTargetSlices = (layout, state) => { + const borderScale = resolveBorderScale(layout, state); + const left = layout.slice.leftWidth * borderScale; + const right = layout.slice.rightWidth * borderScale; + const top = layout.slice.topHeight * borderScale; + const bottom = layout.slice.bottomHeight * borderScale; + const centerWidth = Math.max(0, state.width - left - right); + const centerHeight = Math.max(0, state.height - top - bottom); + + return [ + { x: 0, y: 0, width: left, height: top }, + { x: state.width - right, y: 0, width: right, height: top }, + { x: 0, y: state.height - bottom, width: left, height: bottom }, + { + x: state.width - right, + y: state.height - bottom, + width: right, + height: bottom, + }, + { x: 0, y: top, width: state.width, height: centerHeight }, + { x: left, y: 0, width: centerWidth, height: state.height }, + ]; +}; + +const resolveBorderScale = (layout, state) => + Math.min( + 1, + safeScale(state.width, layout.slice.leftWidth + layout.slice.rightWidth), + safeScale(state.height, layout.slice.topHeight + layout.slice.bottomHeight), + ); + +const safeScale = (size, borderSize) => { + if (borderSize <= 0) return 1; + return Math.max(0, size / borderSize); +}; + +const normalizeState = (state, rotation) => ({ + x: state.x, + y: state.y, + width: state.width ?? state.w, + height: state.height ?? state.h, + rotation: rotation ?? 0, +}); + +const requestFrame = (callback) => { + if (typeof requestAnimationFrame === 'function') { + return requestAnimationFrame(callback); + } + return setTimeout(() => callback(now()), 16); +}; + +const now = () => + typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); + +const normalizeDuration = (durationMs) => + Math.max(0, Number(durationMs ?? 200) || 0); + +const clamp01 = (value) => (value < 0 ? 0 : value > 1 ? 1 : value); + +const easePower2InOut = (progress) => + progress < 0.5 ? 2 * progress * progress : 1 - (-2 * progress + 2) ** 2 / 2; + +const interpolateState = (from, to, progress) => ({ + x: lerp(from.x, to.x, progress), + y: lerp(from.y, to.y, progress), + width: lerp(from.width, to.width, progress), + height: lerp(from.height, to.height, progress), + rotation: to.rotation, +}); + +const lerp = (from, to, progress) => from + (to - from) * progress; + +const getWorldRotation = (node) => { + let current = node; + let rotation = 0; + while (current) { + rotation += current.rotation ?? 0; + current = current.parent ?? null; + } + return rotation; +}; diff --git a/src/display/renderers/panelComponentRenderer.js b/src/display/renderers/panelComponentRenderer.js new file mode 100644 index 00000000..65ba3c1e --- /dev/null +++ b/src/display/renderers/panelComponentRenderer.js @@ -0,0 +1,666 @@ +import { findIndexByPriority } from '../../utils/findIndexByPriority'; +import { getColor } from '../../utils/get'; +import { newComponent } from '../components/creator'; +import { ensurePanelBarLayer } from './PanelBarLayer'; + +const SUPPORTED_TYPES = new Set(['background', 'bar', 'icon', 'text']); +const FRAME_BUDGET_MS = 2; +const QUEUE_BY_STORE = new WeakMap(); +const COMPONENT_CACHE_FIELDS = { + background: '_panelBackgroundComponent', + bar: '_panelBarComponent', + icon: '_panelIconComponent', + text: '_panelTextComponent', +}; + +export const tryApplyPanelComponentChanges = ( + item, + componentChanges, + options = {}, +) => { + if (!canUsePanelRenderer(item, componentChanges, options)) return false; + if (tryApplyPanelBarStateChange(item, componentChanges, options)) return true; + if (hasDuplicateUnkeyedTypes(componentChanges)) return false; + + const jobs = []; + for (const change of componentChanges) { + if (!SUPPORTED_TYPES.has(change?.type)) return false; + + const component = findPanelComponent(item, change); + if (!component) { + if (change.show === false) continue; + return false; + } + + if (!isNoopHiddenChange(component, change)) { + jobs.push({ component, change }); + } + } + + for (const { component, change } of jobs) { + applyPanelComponentChange(item, component, change, options); + } + return true; +}; + +export const primePanelComponentCache = ( + item, + { materializeHiddenBar = false } = {}, +) => { + if (item?.type !== 'item' || !Array.isArray(item.children)) return; + ensurePanelComponentCache(item); + if (materializeHiddenBar && !item._panelBarComponent) { + createPanelBarComponent(item); + } +}; + +const tryApplyPanelBarStateChange = (item, componentChanges, options) => { + if (!isPanelBarStateChange(componentChanges)) return false; + + const barChange = componentChanges[0]; + const bar = + getPanelComponentByType(item, 'bar') ?? createPanelBarComponent(item); + if (!bar) return false; + + applyBarProps(bar, barChange); + hidePanelComponent(getPanelComponentByType(item, 'icon')); + hidePanelComponent(getPanelComponentByType(item, 'text')); + markPanelBarVisualDirty(bar, barChange, options); + return true; +}; + +const isPanelBarStateChange = (componentChanges) => { + if (componentChanges.length !== 3) return false; + const [barChange, iconChange, textChange] = componentChanges; + return ( + barChange?.type === 'bar' && + !barChange.id && + !barChange.label && + iconChange?.type === 'icon' && + iconChange.show === false && + textChange?.type === 'text' && + textChange.show === false + ); +}; + +const getPanelComponentByType = (item, type) => { + const field = COMPONENT_CACHE_FIELDS[type]; + if (!field) return null; + ensurePanelComponentCache(item); + return item[field] ?? null; +}; + +const createPanelBarComponent = (item) => { + const baseProps = getParentComponentPropsByType(item, 'bar'); + if (!baseProps?.source) return null; + + const bar = newComponent('bar', item.store); + bar.props = { + type: 'bar', + show: false, + placement: 'bottom', + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + animation: true, + animationDuration: 200, + tint: 0xffffff, + ...baseProps, + }; + if (bar.props.id) bar.id = bar.props.id; + if (bar.props.label) bar.label = bar.props.label; + bar.renderable = false; + bar._patchmapNeedsInitialSource = true; + primeVisualQueueFields(bar); + item.addChild(bar); + item._panelBarComponent = bar; + item._panelComponentCacheLength = item.children.length; + const layer = ensurePanelBarLayer(item.store); + if (layer?.canRender(bar)) { + bar._patchmapUseAggregateBar = true; + } + return bar; +}; + +const getParentComponentPropsByType = (item, type) => { + const components = item.props?.components; + if (!Array.isArray(components)) return null; + for (const componentProps of components) { + if (componentProps?.type === type) return componentProps; + } + return null; +}; + +const ensurePanelComponentCache = (item) => { + if ( + item._panelComponentCacheLength === item.children.length && + !hasDestroyedCachedPanelComponent(item) + ) { + return; + } + + item._panelBackgroundComponent = null; + item._panelBarComponent = null; + item._panelIconComponent = null; + item._panelTextComponent = null; + + for (const child of item.children) { + const field = COMPONENT_CACHE_FIELDS[child?.type]; + if (field && !item[field]) item[field] = child; + primeVisualQueueFields(child); + } + item._panelComponentCacheLength = item.children.length; +}; + +const hasDestroyedCachedPanelComponent = (item) => + item._panelBackgroundComponent?.destroyed || + item._panelBarComponent?.destroyed || + item._panelIconComponent?.destroyed || + item._panelTextComponent?.destroyed; + +const primeVisualQueueFields = (component) => { + if (!component || component._patchmapVisualQueuePrimed) return; + component._patchmapQueuedVisualQueue = null; + component._patchmapQueuedVisualChange = null; + component._patchmapQueuedVisualOptions = null; + component._patchmapPanelBarDirty = false; + component._patchmapUseAggregateBar = false; + component._patchmapVisualQueuePrimed = true; +}; + +const applyBarProps = (bar, change) => { + const props = bar.props; + if (Object.hasOwn(change, 'show')) props.show = change.show; + if (Object.hasOwn(change, 'size')) { + props.size = mergeSize(props.size, change.size, props.type); + } + if (Object.hasOwn(change, 'tint')) props.tint = change.tint; + if (Object.hasOwn(change, 'animation')) props.animation = change.animation; + if (Object.hasOwn(change, 'animationDuration')) { + props.animationDuration = change.animationDuration; + } + if (Object.hasOwn(change, 'source')) { + props.source = mergeObject(props.source, change.source); + bar._patchmapAggregateTextureSource = null; + bar._patchmapAggregateTexture = null; + } + if (Object.hasOwn(change, 'margin')) props.margin = change.margin; +}; + +const applyTint = (component, tint) => { + if (component._patchmapAppliedTint === tint) return; + component.tint = getColor(component.store.theme, tint); + component._patchmapAppliedTint = tint; +}; + +const hidePanelComponent = (component) => { + if (!component || component.props?.show === false) return; + component.props.show = false; + component.renderable = false; +}; + +const hasDuplicateUnkeyedTypes = (componentChanges) => { + for (let index = 0; index < componentChanges.length; index += 1) { + const change = componentChanges[index]; + if (!change || change.id || change.label) continue; + for ( + let nextIndex = index + 1; + nextIndex < componentChanges.length; + nextIndex += 1 + ) { + const nextChange = componentChanges[nextIndex]; + if ( + nextChange && + !nextChange.id && + !nextChange.label && + nextChange.type === change.type + ) { + return true; + } + } + } + return false; +}; + +const findPanelComponent = (item, change) => { + if (change.id || change.label) { + const index = findIndexByPriority(item.children, change); + return index === -1 ? null : item.children[index]; + } + + return getPanelComponentByType(item, change.type); +}; + +const canUsePanelRenderer = (item, componentChanges, options) => + item?.type === 'item' && + options.validateSchema === false && + options.mergeStrategy !== 'replace' && + Array.isArray(componentChanges); + +const applyPanelComponentChange = (item, component, change, options) => { + if (isNoopHiddenChange(component, change)) return; + + if (component.type === 'bar') { + hideAggregatedBar(component); + } + + component.props = mergeComponentProps(component.props, change); + syncParentComponentProps(item, component, change, options.mergeStrategy); + + if (Object.hasOwn(change, 'show')) { + component.renderable = component.props.show; + } + if (Object.hasOwn(change, 'tint')) { + component.tint = getColor(component.store.theme, component.props.tint); + } + + if (needsDeferredVisualWork(component, change)) { + enqueueVisualChange(component, change, options); + return; + } + + if (component.type === 'text') { + applyTextChange(component, change, options); + } + + if (needsSize(component, change)) { + component._applyComponentSize?.({ + source: component.props.source, + size: component.props.size, + margin: component.props.margin, + }); + } + if (needsPlacement(change)) { + component._applyPlacement?.({ + placement: component.props.placement, + margin: component.props.margin, + }); + } +}; + +const enqueueVisualChange = ( + component, + change, + options, + { cloneChange = true } = {}, +) => { + const queue = ensureVisualQueue(component.store); + if (!queue) { + applyDeferredVisualChange(component, change, options); + return; + } + + if (component._patchmapQueuedVisualQueue === queue) { + mergeQueuedChange(component._patchmapQueuedVisualChange, change); + component._patchmapQueuedVisualOptions = options; + } else { + component._patchmapQueuedVisualQueue = queue; + component._patchmapQueuedVisualChange = cloneChange + ? { ...change } + : change; + component._patchmapQueuedVisualOptions = options; + queue.jobs.push(component); + } + scheduleFlush(queue); +}; + +const markPanelBarVisualDirty = (component, change, options) => { + const queue = ensureVisualQueue(component.store); + if (!queue) { + applyDeferredVisualChange(component, change, options); + return; + } + + const layer = ensurePanelBarLayer(component.store); + component._patchmapUseAggregateBar = Boolean(layer?.canRender(component)); + if (component._patchmapPanelBarDirty) { + mergeQueuedChange(component._patchmapQueuedVisualChange, change); + } else { + component._patchmapPanelBarDirty = true; + component._patchmapQueuedVisualChange = change; + } + component._patchmapQueuedVisualOptions = options; + queue.scanPanelBars = true; + scheduleFlush(queue); +}; + +const ensureVisualQueue = (store) => { + if (!store) return null; + let queue = QUEUE_BY_STORE.get(store); + if (!queue) { + queue = { + store, + jobs: [], + index: 0, + scheduled: false, + scanPanelBars: false, + scanItems: null, + scanIndex: 0, + }; + QUEUE_BY_STORE.set(store, queue); + } + return queue; +}; + +const scheduleFlush = (queue) => { + if (queue.scheduled) return; + queue.scheduled = true; + requestFrame(() => flushVisualQueue(queue)); +}; + +const flushVisualQueue = (queue) => { + queue.scheduled = false; + const startedAt = now(); + + while (queue.index < queue.jobs.length) { + const component = queue.jobs[queue.index]; + queue.index += 1; + const change = component._patchmapQueuedVisualChange; + const options = component._patchmapQueuedVisualOptions; + if (component._patchmapQueuedVisualQueue === queue) { + component._patchmapQueuedVisualQueue = null; + component._patchmapQueuedVisualChange = null; + component._patchmapQueuedVisualOptions = null; + } + if (!component.destroyed) { + applyDeferredVisualChange(component, change, options); + } + if ( + queue.index < queue.jobs.length && + now() - startedAt >= FRAME_BUDGET_MS + ) { + scheduleFlush(queue); + return; + } + } + + queue.jobs = []; + queue.index = 0; + + flushDirtyPanelBars(queue, startedAt); +}; + +const flushDirtyPanelBars = (queue, startedAt) => { + if (!queue.scanPanelBars) return; + if (!queue.scanItems) { + queue.scanItems = [...(queue.store.sceneIndex?.byType?.get('item') ?? [])]; + queue.scanIndex = 0; + } + + while (queue.scanIndex < queue.scanItems.length) { + const item = queue.scanItems[queue.scanIndex]; + queue.scanIndex += 1; + const bar = item?._panelBarComponent; + if (bar?._patchmapPanelBarDirty) { + const change = bar._patchmapQueuedVisualChange; + const options = bar._patchmapQueuedVisualOptions; + bar._patchmapPanelBarDirty = false; + bar._patchmapQueuedVisualChange = null; + bar._patchmapQueuedVisualOptions = null; + if (bar.destroyed) { + continue; + } + const layer = bar._patchmapUseAggregateBar + ? ensurePanelBarLayer(bar.store) + : null; + if (layer?.syncBar(bar)) { + bar.renderable = false; + bar._patchmapNeedsInitialSource = false; + } else { + hideAggregatedBar(bar); + applyDeferredVisualChange(bar, change, options); + } + } + if ( + queue.scanIndex < queue.scanItems.length && + now() - startedAt >= FRAME_BUDGET_MS + ) { + scheduleFlush(queue); + return; + } + } + + queue.scanPanelBars = false; + queue.scanItems = null; + queue.scanIndex = 0; +}; + +const applyDeferredVisualChange = (component, change, options) => { + if (Object.hasOwn(change, 'show')) { + component.renderable = component.props.show; + } + + if (Object.hasOwn(change, 'tint')) { + applyTint(component, change.tint); + } + + if ( + component._patchmapNeedsInitialSource || + Object.hasOwn(change, 'source') + ) { + component._patchmapNeedsInitialSource = false; + component._applySource?.({ source: component.props.source }); + } + + if (component.type === 'bar') { + applyBarChange(component, change); + return; + } + + if (component.type === 'text') { + applyTextChange(component, change, options); + } + + if (needsSize(component, change)) { + component._applyComponentSize?.({ + source: component.props.source, + size: component.props.size, + margin: component.props.margin, + }); + } + if (needsPlacement(change)) { + component._applyPlacement?.({ + placement: component.props.placement, + margin: component.props.margin, + }); + } +}; + +const mergeQueuedChange = (current, change) => { + Object.assign(current, change); + return current; +}; + +const applyBarChange = (bar, change) => { + if (needsAnimatedSize(change)) { + bar._applyAnimationSize?.({ + animation: bar.props.animation, + animationDuration: bar.props.animationDuration, + source: bar.props.source, + size: bar.props.size, + margin: bar.props.margin, + }); + } + if (needsPlacement(change) && !needsAnimatedSize(change)) { + bar._applyPlacement?.({ + placement: bar.props.placement, + margin: bar.props.margin, + }); + } +}; + +const applyTextChange = (text, change, options) => { + if (Object.hasOwn(change, 'text') || Object.hasOwn(change, 'split')) { + text._applyText?.({ text: text.props.text, split: text.props.split }); + } + if (Object.hasOwn(change, 'style')) { + text._applyTextstyle?.({ style: change.style }, options); + } +}; + +const hideAggregatedBar = (bar) => { + const layer = bar?.store?.panelBarLayer; + layer?.hideBar?.(bar); + if (bar) bar._patchmapUseAggregateBar = false; +}; + +const syncParentComponentProps = (item, component, change, mergeStrategy) => { + const parentComponents = item.props?.components; + if (!Array.isArray(parentComponents)) return; + + const index = getParentComponentPropsIndex(parentComponents, component); + if (index === -1) return; + parentComponents[index] = + mergeStrategy === 'replace' + ? { type: component.type, ...change } + : mergeComponentProps(parentComponents[index], change); +}; + +const getParentComponentPropsIndex = (parentComponents, component) => { + const cachedIndex = component._parentComponentPropsIndex; + if ( + Number.isInteger(cachedIndex) && + parentComponents[cachedIndex] && + matchesComponent(parentComponents[cachedIndex], component.props) + ) { + return cachedIndex; + } + + if (!component.props?.id && !component.props?.label) { + const index = getParentComponentTypeIndex(parentComponents, component.type); + component._parentComponentPropsIndex = index; + return index; + } + + const index = findIndexByPriority(parentComponents, component.props); + component._parentComponentPropsIndex = index; + return index; +}; + +const getParentComponentTypeIndex = (parentComponents, type) => { + for (let index = 0; index < parentComponents.length; index += 1) { + if (parentComponents[index]?.type === type) return index; + } + return -1; +}; + +const matchesComponent = (left, right) => + (right.id && left.id === right.id) || + (right.label && left.label === right.label) || + left.type === right.type; + +const mergeComponentProps = (props = {}, change = {}) => { + const next = { ...props, type: props.type ?? change.type }; + + for (const [key, value] of Object.entries(change)) { + if (key === 'type') { + next.type = value; + } else if (key === 'size') { + next.size = mergeSize(props.size, value, next.type); + } else if (key === 'source' || key === 'style') { + next[key] = mergeObject(props[key], value); + } else if (key === 'margin') { + next.margin = value; + } else { + next[key] = value; + } + } + return next; +}; + +const mergeObject = (current, patch) => { + if (!isPlainObject(current) || !isPlainObject(patch)) return patch; + return { ...current, ...patch }; +}; + +const mergeSize = (current, patch, type) => { + if (type === 'background') { + return { + width: { value: 100, unit: '%' }, + height: { value: 100, unit: '%' }, + }; + } + + const normalizedPatch = normalizeSizePatch(patch); + if ( + isPlainObject(current) && + isPlainObject(normalizedPatch) && + ('width' in normalizedPatch || 'height' in normalizedPatch) + ) { + return { ...current, ...normalizedPatch }; + } + return normalizedPatch; +}; + +const normalizeSizePatch = (size) => { + if (typeof size === 'number' || typeof size === 'string') { + const normalized = normalizePxOrPercent(size); + return { width: normalized, height: normalized }; + } + if (!isPlainObject(size)) return size; + + const next = { ...size }; + if (Object.hasOwn(next, 'width')) { + next.width = normalizePxOrPercent(next.width); + } + if (Object.hasOwn(next, 'height')) { + next.height = normalizePxOrPercent(next.height); + } + return next; +}; + +const normalizePxOrPercent = (value) => { + if (typeof value === 'number') return { value, unit: 'px' }; + if (typeof value === 'string' && value.endsWith('%')) { + return { value: Number.parseFloat(value), unit: '%' }; + } + return value; +}; + +const needsAnimatedSize = (change) => + Object.hasOwn(change, 'animation') || + Object.hasOwn(change, 'animationDuration') || + Object.hasOwn(change, 'source') || + Object.hasOwn(change, 'size') || + Object.hasOwn(change, 'margin'); + +const needsSize = (component, change) => + component.type !== 'text' && + (Object.hasOwn(change, 'source') || + Object.hasOwn(change, 'size') || + Object.hasOwn(change, 'margin')); + +const needsPlacement = (change) => + Object.hasOwn(change, 'placement') || Object.hasOwn(change, 'margin'); + +const needsDeferredVisualWork = (component, change) => + Object.hasOwn(change, 'source') || + needsSize(component, change) || + (component.type === 'bar' && needsAnimatedSize(change)) || + (component.type === 'text' && + (Object.hasOwn(change, 'text') || + Object.hasOwn(change, 'split') || + Object.hasOwn(change, 'style'))); + +const isPlainObject = (value) => + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype; + +const isNoopHiddenChange = (component, change) => + change?.show === false && + component.props?.show === false && + component.renderable === false && + Object.keys(change).every((key) => key === 'type' || key === 'show'); + +const requestFrame = (callback) => { + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(callback); + return; + } + setTimeout(callback, 0); +}; + +const now = () => + typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); diff --git a/src/display/update.js b/src/display/update.js index a5c3272a..16b31c89 100644 --- a/src/display/update.js +++ b/src/display/update.js @@ -3,6 +3,7 @@ import { convertArray } from '../utils/convert'; import { selector } from '../utils/selector/selector'; import { getCentroid, getObjectFrameWorldCorners } from '../utils/transform'; import { uid } from '../utils/uuid'; +import { tryApplyPanelComponentChanges } from './renderers/panelComponentRenderer'; const DEFAULT_UPDATE_CONFIG = Object.freeze({ path: null, @@ -18,6 +19,36 @@ const DEFAULT_UPDATE_CONFIG = Object.freeze({ const RADIANS_PER_DEGREE = Math.PI / 180; export const update = (root, opts = {}) => { + if (canUseDirectElementUpdate(opts)) { + const element = opts.elements; + if (!element) return []; + + const changes = + opts.relativeTransform && opts.changes?.attrs + ? { + ...opts.changes, + attrs: applyRelativeTransform(element, opts.changes?.attrs), + } + : opts.changes; + const applyOptions = { + historyId: null, + mergeStrategy: opts.mergeStrategy ?? DEFAULT_UPDATE_CONFIG.mergeStrategy, + refresh: opts.refresh ?? DEFAULT_UPDATE_CONFIG.refresh, + validateSchema: false, + normalize: opts.normalize ?? false, + }; + if ( + changes?.components && + tryApplyPanelComponentChanges(element, changes.components, applyOptions) + ) { + return [element]; + } + element.apply(changes, { + ...applyOptions, + }); + return [element]; + } + const config = { ...opts, ...DEFAULT_UPDATE_CONFIG, @@ -39,6 +70,8 @@ export const update = (root, opts = {}) => { } const baseChanges = config.changes ?? null; + const normalize = + config.normalize ?? (config.validateSchema === false ? false : undefined); for (const element of elements) { if (!element) { continue; @@ -58,12 +91,20 @@ export const update = (root, opts = {}) => { mergeStrategy: config.mergeStrategy, refresh: config.refresh, validateSchema: config.validateSchema, - normalize: config.normalize, + normalize, }); } return elements; }; +const canUseDirectElementUpdate = (opts) => + opts?.validateSchema === false && + !opts.path && + opts.elements && + !Array.isArray(opts.elements) && + !opts.history && + !opts.rotateOrigin; + const applyRelativeTransform = (element, changes) => { ['x', 'y', 'rotation', 'angle'].forEach((key) => { if (typeof changes[key] === 'number') changes[key] += element[key]; diff --git a/src/events/find.js b/src/events/find.js index beef1acf..4411b56c 100644 --- a/src/events/find.js +++ b/src/events/find.js @@ -23,11 +23,19 @@ import { } from './find-helpers'; import { getSelectObject } from './utils'; +const POINT_CANDIDATE_CACHE = new WeakMap(); +const DEFAULT_WARM_FRAME_BUDGET_MS = 2; + const getSelectableCandidates = (parent, config = {}) => { if (isInteractionLocked(parent)) { return []; } + const indexedCandidates = getIndexedSelectableCandidates(parent, config); + if (indexedCandidates) { + return indexedCandidates; + } + return collectCandidates( parent, (child) => @@ -37,6 +45,40 @@ const getSelectableCandidates = (parent, config = {}) => { ); }; +const getIndexedSelectableCandidates = (parent, config) => { + const sceneIndex = getSceneIndex(parent); + if (!sceneIndex) return null; + + const candidates = []; + for (const candidate of sceneIndex.selectable) { + if ( + candidate?.destroyed || + !isDescendantOf(candidate, parent) || + !isSelectableCandidate(candidate, parent) || + !canResolveCandidate(candidate, config) + ) { + continue; + } + candidates.push(candidate); + } + return candidates; +}; + +const getSceneIndex = (parent) => + parent?.store?.sceneIndex ?? + parent?.children?.find((child) => child?.type === 'canvas')?.store + ?.sceneIndex ?? + null; + +const isDescendantOf = (candidate, parent) => { + let current = candidate; + while (current) { + if (current === parent) return true; + current = current.parent ?? null; + } + return false; +}; + const canResolveCandidate = (candidate, { filter, selectUnit } = {}) => { if (selectUnit !== 'entity' || !filter) { return true; @@ -86,7 +128,15 @@ export const findIntersectObject = ( point, { filter, selectUnit, filterParent } = {}, ) => { - const candidates = getSelectableCandidates(parent, { filter, selectUnit }); + const indexedPointCandidates = getIndexedPointCandidates(parent, point, { + filter, + selectUnit, + }); + const candidates = + indexedPointCandidates ?? + getSelectableCandidates(parent, { filter, selectUnit }).sort((a, b) => + compareCandidatesByDisplayOrder(parent, a, b), + ); const mayContainPoint = createCandidatePointBoundsFilter(parent, selectUnit); const resolveSelection = createFindSelectionResolver(parent, { filter, @@ -95,12 +145,10 @@ export const findIntersectObject = ( }); return collectPointHit({ - candidates: candidates.sort((a, b) => - compareCandidatesByDisplayOrder(parent, a, b), - ), + candidates, point, intersectsPoint: intersectPoint, - mayContainPoint, + mayContainPoint: indexedPointCandidates ? undefined : mayContainPoint, resolveSelection, }); }; @@ -110,11 +158,18 @@ export const findIntersectObjects = ( selectionBox, { filter, selectUnit, filterParent } = {}, ) => { - const candidates = getSelectableCandidates(parent, { filter, selectUnit }); const selectionPolygon = toFlatPoints( getObjectLocalCorners(selectionBox, parent), ); const selectionBounds = getFlatBounds(selectionPolygon); + const indexedPolygonCandidates = getIndexedPolygonCandidates( + parent, + selectionBounds, + { filter, selectUnit }, + ); + const candidates = + indexedPolygonCandidates ?? + getSelectableCandidates(parent, { filter, selectUnit }); const mayIntersectPolygon = createCandidatePolygonBoundsFilter( parent, selectUnit, @@ -130,7 +185,9 @@ export const findIntersectObjects = ( polygon: selectionPolygon, intersectsPolygon: (polygon, target) => intersectLocalPoints(polygon, target, parent), - mayIntersectPolygon, + mayIntersectPolygon: indexedPolygonCandidates + ? undefined + : mayIntersectPolygon, resolveSelection, }); }; @@ -176,6 +233,152 @@ const createCandidatePointBoundsFilter = (viewport, selectUnit) => { boundsContainPoint(getObjectSizeLocalBounds(candidate, viewport), point); }; +const getIndexedPointCandidates = (parent, point, config = {}) => { + if (config.selectUnit !== 'entity') { + return null; + } + const sceneIndex = getSceneIndex(parent); + if (!sceneIndex) { + return null; + } + + const entries = getBoundsCandidateEntries(parent, sceneIndex); + const candidates = []; + for (const entry of entries) { + const candidate = entry.candidate; + if ( + candidate?.destroyed || + !canResolveCandidate(candidate, config) || + !boundsContainPoint(entry.bounds, point) + ) { + continue; + } + candidates.push(candidate); + } + + return candidates.sort((a, b) => + compareCandidatesByDisplayOrder(parent, a, b), + ); +}; + +const getIndexedPolygonCandidates = (parent, selectionBounds, config = {}) => { + if (config.selectUnit !== 'entity') { + return null; + } + const sceneIndex = getSceneIndex(parent); + if (!sceneIndex) { + return null; + } + + const entries = getBoundsCandidateEntries(parent, sceneIndex); + const candidates = []; + for (const entry of entries) { + const candidate = entry.candidate; + if ( + candidate?.destroyed || + !canResolveCandidate(candidate, config) || + !boundsIntersect(selectionBounds, entry.bounds) + ) { + continue; + } + candidates.push(candidate); + } + + return candidates; +}; + +const getBoundsCandidateEntries = (parent, sceneIndex) => { + const cached = POINT_CANDIDATE_CACHE.get(parent); + if ( + cached?.sceneIndex === sceneIndex && + cached.version === sceneIndex.version + ) { + return cached.entries; + } + + const entries = []; + for (const candidate of sceneIndex.selectable) { + if ( + candidate?.destroyed || + !isDescendantOf(candidate, parent) || + !isSelectableCandidate(candidate, parent) + ) { + continue; + } + entries.push({ + candidate, + bounds: getObjectSizeLocalBounds(candidate, parent), + }); + } + + POINT_CANDIDATE_CACHE.set(parent, { + sceneIndex, + version: sceneIndex.version, + entries, + }); + return entries; +}; + +export const warmFindBoundsCache = ( + parent, + { frameBudgetMs = DEFAULT_WARM_FRAME_BUDGET_MS } = {}, +) => { + const sceneIndex = getSceneIndex(parent); + if (!sceneIndex) return; + + const cached = POINT_CANDIDATE_CACHE.get(parent); + if ( + cached?.sceneIndex === sceneIndex && + cached.version === sceneIndex.version + ) { + return; + } + + const candidates = [...sceneIndex.selectable]; + const version = sceneIndex.version; + const entries = []; + let index = 0; + + const schedule = + globalThis.requestAnimationFrame ?? + ((callback) => globalThis.setTimeout(callback, 16)); + const now = () => globalThis.performance?.now?.() ?? Date.now(); + + const step = () => { + if (sceneIndex.version !== version || parent.destroyed) { + return; + } + + const startedAt = now(); + while (index < candidates.length) { + const candidate = candidates[index++]; + if ( + !candidate?.destroyed && + isDescendantOf(candidate, parent) && + isSelectableCandidate(candidate, parent) + ) { + entries.push({ + candidate, + bounds: getObjectSizeLocalBounds(candidate, parent), + }); + } + + if (now() - startedAt >= frameBudgetMs) { + schedule(step); + return; + } + } + + POINT_CANDIDATE_CACHE.set(parent, { + sceneIndex, + version, + entries, + }); + }; + + schedule(step); +}; + const createCandidatePolygonBoundsFilter = ( viewport, selectUnit, diff --git a/src/init.js b/src/init.js index ff89da74..e09cf83c 100644 --- a/src/init.js +++ b/src/init.js @@ -74,6 +74,7 @@ export const initViewport = (app, opts = {}, store) => { store.viewport = viewport; viewport.app = app; viewport.events = {}; + viewport.enableRenderGroup?.(); viewport.plugin = { add: (plugins) => plugin.add(viewport, plugins), remove: (keys) => plugin.remove(viewport, keys), diff --git a/src/patchmap.js b/src/patchmap.js index 39195051..f3c6ea2b 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -8,6 +8,7 @@ import './display/elements/registry'; import { update } from './display/update'; import ViewTransform from './display/view-transform/ViewTransform'; import World from './display/World'; +import { warmFindBoundsCache } from './events/find'; import { fit as fitViewport, focus } from './events/focus-fit'; import StateManager from './events/StateManager'; import SelectionState from './events/states/SelectionState'; @@ -39,6 +40,8 @@ class Patchmap extends WildcardEventEmitter { _world = null; _viewTransform = this._createViewTransform(); _drawToken = 0; + _drawCacheKey = null; + _drawCacheData = null; get app() { return this._app; @@ -184,6 +187,8 @@ class Patchmap extends WildcardEventEmitter { this._world = null; this._viewTransform = this._createViewTransform(); this._drawToken = 0; + this._drawCacheKey = null; + this._drawCacheData = null; this.emit('patchmap:destroyed', { target: this }); this.removeAllListeners(); } @@ -191,10 +196,19 @@ class Patchmap extends WildcardEventEmitter { draw(data) { if (!this.isInit) return; - const processedData = processData(JSON.parse(JSON.stringify(data))); + const drawCacheKey = createDrawCacheKey(data); + const canReuseCurrentScene = + this._drawCacheKey === drawCacheKey && + this.world?.children?.length > 0 && + hasOnlyManagedWorldChildren(this.world); + const processedData = canReuseCurrentScene + ? this._drawCacheData + : processData(JSON.parse(JSON.stringify(data))); if (!processedData) return; - const validatedData = validateMapData(processedData); + const validatedData = canReuseCurrentScene + ? processedData + : validateMapData(processedData); if (isValidationError(validatedData)) throw validatedData; const drawToken = ++this._drawToken; @@ -204,23 +218,30 @@ class Patchmap extends WildcardEventEmitter { this.undoRedoManager.clear(); this.animationContext.revert(); event.removeAllEvent(this.viewport); - draw(store, validatedData); - - // Force a refresh of all relation elements after the initial draw. This ensures - // that all link targets exist in the scene graph before the relations - // attempt to draw their links. - this.app.ticker.addOnce( - () => { - this.update({ - path: '$..[?(@.type=="relations")]', - refresh: true, - emit: false, - }); - }, - undefined, - UPDATE_PRIORITY.UTILITY, - ); + if (!canReuseCurrentScene) { + draw(store, validatedData); + this._drawCacheKey = drawCacheKey; + this._drawCacheData = validatedData; + } + + if (!canReuseCurrentScene) { + // Force a refresh of all relation elements after the initial draw. This ensures + // that all link targets exist in the scene graph before the relations + // attempt to draw their links. + this.app.ticker.addOnce( + () => { + this.update({ + path: '$..[?(@.type=="relations")]', + refresh: true, + emit: false, + }); + }, + undefined, + UPDATE_PRIORITY.UTILITY, + ); + } this.app.start(); + warmFindBoundsCache(this.viewport); scheduleUserVisibleTask(() => { if (!this.isInit || drawToken !== this._drawToken) return; this.emit('patchmap:draw', { data: validatedData, target: this }); @@ -301,6 +322,16 @@ class Patchmap extends WildcardEventEmitter { } } +function createDrawCacheKey(data) { + return JSON.stringify(data); +} + +function hasOnlyManagedWorldChildren(world) { + return (world?.children ?? []).every( + (child) => child?.type || child?._patchmapInternal, + ); +} + function scheduleUserVisibleTask(task) { const scheduler = globalThis.scheduler; if (scheduler?.postTask) { diff --git a/src/utils/selector/selector.js b/src/utils/selector/selector.js index 983d739a..c64df49a 100644 --- a/src/utils/selector/selector.js +++ b/src/utils/selector/selector.js @@ -1,6 +1,9 @@ import { JSONSearch } from './json-search'; export const selector = (json, path, options = {}) => { + const indexedResult = selectFromSceneIndex(json, path, options); + if (indexedResult) return indexedResult; + return JSONSearch({ searchableKeys: ['children'], flatten: true, @@ -9,3 +12,47 @@ export const selector = (json, path, options = {}) => { json: json ?? {}, }); }; + +const selectFromSceneIndex = (json, path, options) => { + if (options?.resultType || json?.type !== 'canvas') return null; + + const sceneIndex = json?.store?.sceneIndex; + if (!sceneIndex || typeof path !== 'string') return null; + + const id = matchExactIdPath(path); + if (id) { + const element = sceneIndex.getById(id); + return element ? [element] : []; + } + + const childrenPath = matchExactChildrenPath(path); + if (childrenPath) { + const elements = + childrenPath.key === 'display' + ? sceneIndex.getByDisplay(childrenPath.value) + : sceneIndex.getByType(childrenPath.value); + if (childrenPath.children) { + return elements.flatMap((element) => element.children ?? []); + } + return elements; + } + + return null; +}; + +const matchExactIdPath = (path) => { + const match = path.match(/^\$..\[\?\(@\.id\s*={2,3}\s*(["'])([^"']+)\1\)\]$/); + return match?.[2] ?? null; +}; + +const matchExactChildrenPath = (path) => { + const match = path.match( + /^\$..children\[\?\(@\.(display|type)\s*={2,3}\s*(["'])([^"']+)\2\)\](\.children)?$/, + ); + if (!match) return null; + return { + key: match[1], + value: match[3], + children: Boolean(match[4]), + }; +}; From 38c4cf04173de383bc0b746de42f58e4036eec19 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 29 Apr 2026 23:53:05 +0900 Subject: [PATCH 02/51] test(patchmap): cover patch service contracts --- src/display/mixins/Base.test.js | 7 +- .../render/patch-service-contract.test.js | 297 ++++++++++++++++++ 2 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 src/tests/render/patch-service-contract.test.js diff --git a/src/display/mixins/Base.test.js b/src/display/mixins/Base.test.js index 2c8f370b..c132ea46 100644 --- a/src/display/mixins/Base.test.js +++ b/src/display/mixins/Base.test.js @@ -10,6 +10,9 @@ class TestBase { } class StaticBaseElement extends Base(TestBase) {} +class AfterRenderElement extends StaticBaseElement { + _afterRender() {} +} describe('Base mixin', () => { it('emits object_transformed immediately when raw transform attrs change', () => { @@ -37,7 +40,7 @@ describe('Base mixin', () => { }); it('skips after-render work while the viewport is moving', () => { - const instance = new StaticBaseElement({ + const instance = new AfterRenderElement({ type: 'rect', store: { viewport: { moving: true } }, }); @@ -54,7 +57,7 @@ describe('Base mixin', () => { }); it('skips after-render work while object after-render is suspended', () => { - const instance = new StaticBaseElement({ + const instance = new AfterRenderElement({ type: 'rect', store: { viewport: { _suspendObjectAfterRender: true } }, }); diff --git a/src/tests/render/patch-service-contract.test.js b/src/tests/render/patch-service-contract.test.js new file mode 100644 index 00000000..57accf7b --- /dev/null +++ b/src/tests/render/patch-service-contract.test.js @@ -0,0 +1,297 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Transformer } from '../../patch-map'; +import { setupPatchmapTests } from './patchmap.setup'; + +const PANEL_ITEM_PATH = '$..children[?(@.display=="panelGroup")].children'; +const INVERTER_PATH = '$..children[?(@.display=="inverter")]'; +const RELATIONS_PATH = '$..children[?(@.type==="relations")]'; + +const panelComponents = [ + { + type: 'background', + source: { type: 'rect', fill: '#f8fafc', radius: 4 }, + size: '100%', + }, + { + type: 'bar', + show: false, + source: { type: 'rect', fill: 'white', radius: 3 }, + size: '100%', + tint: 'primary.default', + animation: false, + }, + { type: 'icon', show: false, source: 'warning', size: 20 }, + { type: 'text', show: false, text: '' }, +]; + +const plantMapData = [ + { + type: 'group', + id: 'strings', + children: [ + { + type: 'grid', + id: 'string-1', + attrs: { display: 'panelGroup', x: 100, y: 100 }, + cells: [ + [1, 1, 1], + [1, 0, 1], + ], + gap: 4, + item: { + size: { width: 36, height: 72 }, + components: panelComponents, + }, + }, + { + type: 'grid', + id: 'string-2', + attrs: { display: 'panelGroup', x: 260, y: 100 }, + cells: [[1, 1]], + gap: 4, + item: { + size: { width: 36, height: 72 }, + components: panelComponents, + }, + }, + { + type: 'item', + id: 'inverter-1', + attrs: { display: 'inverter', x: 180, y: 240 }, + size: { width: 80, height: 48 }, + components: [ + { + type: 'bar', + show: true, + source: { type: 'rect', fill: 'white', radius: 4 }, + size: '100%', + tint: 'gray.dark', + animation: false, + }, + { type: 'icon', show: false, source: 'loading', size: 20 }, + { type: 'text', show: true, text: 'INV' }, + ], + }, + ], + }, + { + type: 'relations', + id: 'plant-relations', + links: [ + { source: 'string-1.0.0', target: 'string-1.0.1' }, + { source: 'string-1.0.1', target: 'string-1.0.2' }, + { source: 'string-1.0.2', target: 'inverter-1' }, + ], + }, +]; + +const waitForScene = (ms = 80) => + new Promise((resolve) => setTimeout(resolve, ms)); + +const emitPointer = (viewport, type, position, extras = {}) => { + viewport.emit(type, { + global: viewport.toGlobal(position), + button: 0, + buttons: type === 'pointerup' ? 0 : 1, + pointerId: 1, + pointerType: 'mouse', + data: { pointerId: 1, pointerType: 'mouse' }, + stopPropagation: () => {}, + preventDefault: () => {}, + ...extras, + }); +}; + +const getComponent = (item, type) => + item.children.find((child) => child.type === type); + +describe('patch-service plant map contract', () => { + const { getPatchmap } = setupPatchmapTests(); + + it('keeps patch-service selector paths stable after draw', async () => { + const patchmap = getPatchmap(); + patchmap.draw(plantMapData); + await waitForScene(); + + const panelItems = patchmap.selector(PANEL_ITEM_PATH); + const inverterItems = patchmap.selector(INVERTER_PATH); + const relations = patchmap.selector(RELATIONS_PATH); + + expect(panelItems.map((item) => item.id)).toEqual([ + 'string-1.0.0', + 'string-1.0.1', + 'string-1.0.2', + 'string-1.1.0', + 'string-1.1.2', + 'string-2.0.0', + 'string-2.0.1', + ]); + expect(inverterItems.map((item) => item.id)).toEqual(['inverter-1']); + expect(relations).toHaveLength(1); + expect(relations[0].id).toBe('plant-relations'); + }); + + it('keeps display/type indexes compatible with selector paths after updates', async () => { + const patchmap = getPatchmap(); + patchmap.draw(plantMapData); + await waitForScene(); + + patchmap.update({ + path: '$..[?(@.id=="string-2")]', + changes: { attrs: { display: 'panelGroupInactive' } }, + validateSchema: false, + emit: false, + }); + + expect(patchmap.selector(PANEL_ITEM_PATH).map((item) => item.id)).toEqual([ + 'string-1.0.0', + 'string-1.0.1', + 'string-1.0.2', + 'string-1.1.0', + 'string-1.1.2', + ]); + expect( + patchmap + .selector('$..children[?(@.display=="panelGroupInactive")]') + .map((item) => item.id), + ).toEqual(['string-2']); + expect(patchmap.selector(RELATIONS_PATH)[0].id).toBe('plant-relations'); + }); + + it('supports patch-service item-by-item animated panel bar updates', async () => { + const patchmap = getPatchmap(); + patchmap.draw(plantMapData); + await waitForScene(); + + const panelItems = patchmap.selector(PANEL_ITEM_PATH); + for (const [index, item] of panelItems.entries()) { + const percent = 8 + index * 11; + patchmap.update({ + elements: item, + changes: { + components: [ + { + type: 'bar', + show: true, + size: { height: `${percent}%` }, + tint: percent > 50 ? '#0C73BF' : '#1099FF', + animation: true, + }, + { type: 'icon', show: false }, + { type: 'text', show: false }, + ], + }, + validateSchema: false, + emit: false, + }); + } + await waitForScene(260); + + const worldChildren = patchmap.world.children; + const groupIndex = worldChildren.findIndex( + (child) => child.id === 'strings', + ); + const barLayerIndex = worldChildren.findIndex( + (child) => child.label === 'patchmap-panel-bar-layer', + ); + const relationsIndex = worldChildren.findIndex( + (child) => child.id === 'plant-relations', + ); + + expect(groupIndex).toBeGreaterThanOrEqual(0); + expect(barLayerIndex).toBeGreaterThan(groupIndex); + expect(barLayerIndex).toBeLessThan(relationsIndex); + + for (const [index, item] of panelItems.entries()) { + const percent = 8 + index * 11; + const bar = getComponent(item, 'bar'); + const icon = getComponent(item, 'icon'); + const text = getComponent(item, 'text'); + + expect(bar.visible).toBe(true); + expect(bar.props.size.height).toMatchObject({ + value: percent, + unit: '%', + }); + expect(bar.props.animation).toBe(true); + expect(icon?.renderable ?? false).toBe(false); + expect(text?.renderable ?? false).toBe(false); + } + }); + + it('supports report-style panel background and relations path updates', async () => { + const patchmap = getPatchmap(); + patchmap.draw(plantMapData); + await waitForScene(); + + const panelItems = patchmap.selector(PANEL_ITEM_PATH); + for (const item of panelItems) { + patchmap.update({ + elements: item, + changes: { + components: [ + { + type: 'background', + size: '100%', + source: { + type: 'rect', + fill: '#22c55e', + borderWidth: 2, + }, + }, + ], + }, + validateSchema: false, + emit: false, + }); + } + + patchmap.update({ + path: RELATIONS_PATH, + changes: { show: false }, + validateSchema: false, + emit: false, + }); + await waitForScene(); + + expect( + panelItems.every( + (item) => + getComponent(item, 'background')?.props.source.fill === '#22c55e', + ), + ).toBe(true); + expect(patchmap.selector(RELATIONS_PATH)[0].renderable).toBe(false); + }); + + it('keeps transformer selection callbacks compatible with panel items', async () => { + const patchmap = getPatchmap(); + patchmap.transformer = new Transformer(); + patchmap.draw(plantMapData); + await waitForScene(); + + const onClick = vi.fn((target) => { + patchmap.transformer.elements = target ? [target] : []; + }); + patchmap.stateManager.setState('selection', { + enabled: true, + draggable: true, + selectUnit: 'entity', + filter: (target) => target.type === 'item', + onClick, + }); + + emitPointer(patchmap.viewport, 'pointerdown', { x: 118, y: 136 }); + emitPointer(patchmap.viewport, 'pointerup', { x: 118, y: 136 }); + patchmap.viewport.emit('click', { + global: patchmap.viewport.toGlobal({ x: 118, y: 136 }), + detail: 1, + stopPropagation: () => {}, + }); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0]?.id).toBe('string-1.0.0'); + expect(patchmap.transformer.elements.map((item) => item.id)).toEqual([ + 'string-1.0.0', + ]); + }); +}); From 8307b22c5d11417886d50976dbb9ce662eb92b10 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 29 Apr 2026 23:53:11 +0900 Subject: [PATCH 03/51] chore(gitignore): ignore local benchmark artifacts --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 61c8ac19..37baceb8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules dist *.tgz src/tests/**/__screenshots__ +.gstack/ From 320043a754f717c7cc9f1e97c98066ae157e5d63 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 30 Apr 2026 00:06:31 +0900 Subject: [PATCH 04/51] perf(patchmap): flush dirty panel bars directly --- .../renderers/panelComponentRenderer.js | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/display/renderers/panelComponentRenderer.js b/src/display/renderers/panelComponentRenderer.js index 65ba3c1e..31dddc84 100644 --- a/src/display/renderers/panelComponentRenderer.js +++ b/src/display/renderers/panelComponentRenderer.js @@ -316,9 +316,9 @@ const markPanelBarVisualDirty = (component, change, options) => { } else { component._patchmapPanelBarDirty = true; component._patchmapQueuedVisualChange = change; + queue.dirtyPanelBars.push(component); } component._patchmapQueuedVisualOptions = options; - queue.scanPanelBars = true; scheduleFlush(queue); }; @@ -331,9 +331,8 @@ const ensureVisualQueue = (store) => { jobs: [], index: 0, scheduled: false, - scanPanelBars: false, - scanItems: null, - scanIndex: 0, + dirtyPanelBars: [], + dirtyPanelBarIndex: 0, }; QUEUE_BY_STORE.set(store, queue); } @@ -379,16 +378,11 @@ const flushVisualQueue = (queue) => { }; const flushDirtyPanelBars = (queue, startedAt) => { - if (!queue.scanPanelBars) return; - if (!queue.scanItems) { - queue.scanItems = [...(queue.store.sceneIndex?.byType?.get('item') ?? [])]; - queue.scanIndex = 0; - } + if (queue.dirtyPanelBars.length === 0) return; - while (queue.scanIndex < queue.scanItems.length) { - const item = queue.scanItems[queue.scanIndex]; - queue.scanIndex += 1; - const bar = item?._panelBarComponent; + while (queue.dirtyPanelBarIndex < queue.dirtyPanelBars.length) { + const bar = queue.dirtyPanelBars[queue.dirtyPanelBarIndex]; + queue.dirtyPanelBarIndex += 1; if (bar?._patchmapPanelBarDirty) { const change = bar._patchmapQueuedVisualChange; const options = bar._patchmapQueuedVisualOptions; @@ -410,7 +404,7 @@ const flushDirtyPanelBars = (queue, startedAt) => { } } if ( - queue.scanIndex < queue.scanItems.length && + queue.dirtyPanelBarIndex < queue.dirtyPanelBars.length && now() - startedAt >= FRAME_BUDGET_MS ) { scheduleFlush(queue); @@ -418,9 +412,8 @@ const flushDirtyPanelBars = (queue, startedAt) => { } } - queue.scanPanelBars = false; - queue.scanItems = null; - queue.scanIndex = 0; + queue.dirtyPanelBars = []; + queue.dirtyPanelBarIndex = 0; }; const applyDeferredVisualChange = (component, change, options) => { From 6b3c48e8c34fbd4a0bf3b473c5c0649c0f23ac0b Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 30 Apr 2026 00:22:20 +0900 Subject: [PATCH 05/51] perf(patchmap): enable world render group --- src/patchmap.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/patchmap.js b/src/patchmap.js index f3c6ea2b..7165eeb8 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -139,6 +139,7 @@ class Patchmap extends WildcardEventEmitter { const store = this._createStoreContext(); this._viewport = initViewport(this.app, viewportOptions, store); this._world = new World({ store }); + this._world.enableRenderGroup?.(); store.world = this._world; this.viewport.addChild(this._world); this._viewTransform.attach({ viewport: this.viewport, world: this._world }); From c20507b9ad08c25fe65d6bb9a1cf5e055976451a Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 30 Apr 2026 09:50:40 +0900 Subject: [PATCH 06/51] perf(patchmap): skip redundant panel bar lookups --- src/display/renderers/panelComponentRenderer.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/display/renderers/panelComponentRenderer.js b/src/display/renderers/panelComponentRenderer.js index 31dddc84..1f665dca 100644 --- a/src/display/renderers/panelComponentRenderer.js +++ b/src/display/renderers/panelComponentRenderer.js @@ -63,8 +63,8 @@ const tryApplyPanelBarStateChange = (item, componentChanges, options) => { if (!bar) return false; applyBarProps(bar, barChange); - hidePanelComponent(getPanelComponentByType(item, 'icon')); - hidePanelComponent(getPanelComponentByType(item, 'text')); + hidePanelComponent(item._panelIconComponent); + hidePanelComponent(item._panelTextComponent); markPanelBarVisualDirty(bar, barChange, options); return true; }; From ec5659aa23b32ed35a169c1c03b0b27d3ed90eab Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 30 Apr 2026 10:47:39 +0900 Subject: [PATCH 07/51] perf(patchmap): reuse aggregate bar eligibility --- src/display/renderers/panelComponentRenderer.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/display/renderers/panelComponentRenderer.js b/src/display/renderers/panelComponentRenderer.js index 1f665dca..51191220 100644 --- a/src/display/renderers/panelComponentRenderer.js +++ b/src/display/renderers/panelComponentRenderer.js @@ -309,8 +309,16 @@ const markPanelBarVisualDirty = (component, change, options) => { return; } - const layer = ensurePanelBarLayer(component.store); - component._patchmapUseAggregateBar = Boolean(layer?.canRender(component)); + const sourceChanged = Object.hasOwn(change, 'source'); + let layer = component.store?.panelBarLayer; + if ( + sourceChanged || + !component._patchmapUseAggregateBar || + layer?.destroyed + ) { + layer = ensurePanelBarLayer(component.store); + component._patchmapUseAggregateBar = Boolean(layer?.canRender(component)); + } if (component._patchmapPanelBarDirty) { mergeQueuedChange(component._patchmapQueuedVisualChange, change); } else { From 3ddd06aff47ecff7a87c3ae30b071c1f76d7a633 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 13 May 2026 17:39:52 +0900 Subject: [PATCH 08/51] perf: optimize aggregate panel bar updates --- src/display/renderers/PanelBarLayer.js | 232 +++++++++++++++--- .../renderers/panelComponentRenderer.js | 146 ++++++++++- .../render/patch-service-contract.test.js | 154 ++++++++++++ 3 files changed, 488 insertions(+), 44 deletions(-) diff --git a/src/display/renderers/PanelBarLayer.js b/src/display/renderers/PanelBarLayer.js index 9859c1ba..73967cbd 100644 --- a/src/display/renderers/PanelBarLayer.js +++ b/src/display/renderers/PanelBarLayer.js @@ -36,6 +36,7 @@ export class PanelBarLayer extends ParticleContainer { this._entries = new WeakMap(); this._activeAnimations = new Set(); this._animationFrame = null; + this._needsParticleChildrenUpdate = false; } canRender(bar) { @@ -135,7 +136,7 @@ export class PanelBarLayer extends ParticleContainer { entry.layout = layout; entry.particles = particles; entry.particle = particles[0] ?? null; - this.update(); + this._needsParticleChildrenUpdate = true; } _removeEntryParticles(entry) { @@ -147,6 +148,13 @@ export class PanelBarLayer extends ParticleContainer { this.particleChildren.splice(index, 1); } } + this._needsParticleChildrenUpdate = true; + } + + flushParticleChildrenUpdate() { + if (!this._needsParticleChildrenUpdate) return; + this._needsParticleChildrenUpdate = false; + this.update(); } _applyAppearance(entry, { alpha, tint }) { @@ -168,7 +176,7 @@ export class PanelBarLayer extends ParticleContainer { entry.animation = { texture, - from: fromState, + from: cloneState(fromState), to: nextState, durationMs, startedAt: now(), @@ -185,11 +193,42 @@ export class PanelBarLayer extends ParticleContainer { } _applyState(entry, texture, state, rotation = state.rotation) { - const normalizedState = normalizeState(state, rotation); + this._applyStateValues( + entry, + texture, + state.x, + state.y, + state.width ?? state.w, + state.height ?? state.h, + rotation ?? 0, + ); + } + + _applyInterpolatedState(entry, animation, progress) { + const from = animation.from; + const to = animation.to; + this._applyStateValues( + entry, + animation.texture, + lerp(from.x, to.x, progress), + lerp(from.y, to.y, progress), + lerp(from.width, to.width, progress), + lerp(from.height, to.height, progress), + to.rotation, + ); + } + + _applyStateValues(entry, texture, x, y, width, height, rotation) { const layout = entry.layout ?? getNineSliceLayout(texture); - const targetSlices = resolveTargetSlices(layout, normalizedState); - const cos = Math.cos(normalizedState.rotation); - const sin = Math.sin(normalizedState.rotation); + if (layout.borderless) { + this._applyBorderlessState(entry, layout, x, y, width, height, rotation); + return; + } + + const state = updateEntryState(entry, x, y, width, height, rotation); + const targetSlices = resolveTargetSlices(layout, state); + const cos = Math.cos(rotation); + const sin = Math.sin(rotation); for (let index = 0; index < entry.particles.length; index += 1) { const particle = entry.particles[index]; @@ -206,16 +245,116 @@ export class PanelBarLayer extends ParticleContainer { continue; } - particle.x = normalizedState.x + target.x * cos - target.y * sin; - particle.y = normalizedState.y + target.x * sin + target.y * cos; + particle.x = x + target.x * cos - target.y * sin; + particle.y = y + target.x * sin + target.y * cos; particle.scaleX = target.width / piece.width; particle.scaleY = target.height / piece.height; - particle.rotation = normalizedState.rotation; + particle.rotation = rotation; if (entry.alpha !== undefined && particle.alpha !== entry.alpha) { particle.alpha = entry.alpha; } } - entry.state = normalizedState; + } + + _applyBorderlessState(entry, layout, x, y, width, height, rotation) { + updateEntryState(entry, x, y, width, height, rotation); + const borderScale = resolveBorderScaleForSize(layout, width, height); + const left = layout.slice.leftWidth * borderScale; + const right = layout.slice.rightWidth * borderScale; + const top = layout.slice.topHeight * borderScale; + const bottom = layout.slice.bottomHeight * borderScale; + const centerWidth = Math.max(0, width - left - right); + const centerHeight = Math.max(0, height - top - bottom); + const cos = Math.cos(rotation); + const sin = Math.sin(rotation); + const alpha = entry.alpha; + const particles = entry.particles; + const pieces = layout.pieces; + + applyParticleState( + particles[0], + pieces[0], + 0, + 0, + left, + top, + x, + y, + cos, + sin, + rotation, + alpha, + ); + applyParticleState( + particles[1], + pieces[1], + width - right, + 0, + right, + top, + x, + y, + cos, + sin, + rotation, + alpha, + ); + applyParticleState( + particles[2], + pieces[2], + 0, + height - bottom, + left, + bottom, + x, + y, + cos, + sin, + rotation, + alpha, + ); + applyParticleState( + particles[3], + pieces[3], + width - right, + height - bottom, + right, + bottom, + x, + y, + cos, + sin, + rotation, + alpha, + ); + applyParticleState( + particles[4], + pieces[4], + 0, + top, + width, + centerHeight, + x, + y, + cos, + sin, + rotation, + alpha, + ); + applyParticleState( + particles[5], + pieces[5], + left, + 0, + centerWidth, + height, + x, + y, + cos, + sin, + rotation, + alpha, + ); } _resolveState(bar) { @@ -276,15 +415,7 @@ export class PanelBarLayer extends ParticleContainer { const progress = clamp01( (time - animation.startedAt) / animation.durationMs, ); - this._applyState( - entry, - animation.texture, - interpolateState( - animation.from, - animation.to, - easePower2InOut(progress), - ), - ); + this._applyInterpolatedState(entry, animation, easePower2InOut(progress)); if (progress >= 1) { entry.animation = null; this._activeAnimations.delete(entry); @@ -603,10 +734,13 @@ const resolveBorderlessTargetSlices = (layout, state) => { }; const resolveBorderScale = (layout, state) => + resolveBorderScaleForSize(layout, state.width, state.height); + +const resolveBorderScaleForSize = (layout, width, height) => Math.min( 1, - safeScale(state.width, layout.slice.leftWidth + layout.slice.rightWidth), - safeScale(state.height, layout.slice.topHeight + layout.slice.bottomHeight), + safeScale(width, layout.slice.leftWidth + layout.slice.rightWidth), + safeScale(height, layout.slice.topHeight + layout.slice.bottomHeight), ); const safeScale = (size, borderSize) => { @@ -614,14 +748,54 @@ const safeScale = (size, borderSize) => { return Math.max(0, size / borderSize); }; -const normalizeState = (state, rotation) => ({ +const updateEntryState = (entry, x, y, width, height, rotation) => { + const state = entry.state ?? {}; + state.x = x; + state.y = y; + state.width = width; + state.height = height; + state.rotation = rotation; + entry.state = state; + return state; +}; + +const cloneState = (state) => ({ x: state.x, y: state.y, - width: state.width ?? state.w, - height: state.height ?? state.h, - rotation: rotation ?? 0, + width: state.width, + height: state.height, + rotation: state.rotation, }); +const applyParticleState = ( + particle, + piece, + localX, + localY, + width, + height, + x, + y, + cos, + sin, + rotation, + alpha, +) => { + if (!particle || !piece || width <= 0 || height <= 0) { + if (particle) particle.alpha = 0; + return; + } + + particle.x = x + localX * cos - localY * sin; + particle.y = y + localX * sin + localY * cos; + particle.scaleX = width / piece.width; + particle.scaleY = height / piece.height; + particle.rotation = rotation; + if (alpha !== undefined && particle.alpha !== alpha) { + particle.alpha = alpha; + } +}; + const requestFrame = (callback) => { if (typeof requestAnimationFrame === 'function') { return requestAnimationFrame(callback); @@ -642,14 +816,6 @@ const clamp01 = (value) => (value < 0 ? 0 : value > 1 ? 1 : value); const easePower2InOut = (progress) => progress < 0.5 ? 2 * progress * progress : 1 - (-2 * progress + 2) ** 2 / 2; -const interpolateState = (from, to, progress) => ({ - x: lerp(from.x, to.x, progress), - y: lerp(from.y, to.y, progress), - width: lerp(from.width, to.width, progress), - height: lerp(from.height, to.height, progress), - rotation: to.rotation, -}); - const lerp = (from, to, progress) => from + (to - from) * progress; const getWorldRotation = (node) => { diff --git a/src/display/renderers/panelComponentRenderer.js b/src/display/renderers/panelComponentRenderer.js index 51191220..c130cbfb 100644 --- a/src/display/renderers/panelComponentRenderer.js +++ b/src/display/renderers/panelComponentRenderer.js @@ -20,15 +20,22 @@ export const tryApplyPanelComponentChanges = ( ) => { if (!canUsePanelRenderer(item, componentChanges, options)) return false; if (tryApplyPanelBarStateChange(item, componentChanges, options)) return true; - if (hasDuplicateUnkeyedTypes(componentChanges)) return false; + if (hasDuplicateUnkeyedTypes(componentChanges)) { + restorePanelBarFallback(item); + return false; + } const jobs = []; for (const change of componentChanges) { - if (!SUPPORTED_TYPES.has(change?.type)) return false; + if (!SUPPORTED_TYPES.has(change?.type)) { + restorePanelBarFallback(item); + return false; + } const component = findPanelComponent(item, change); if (!component) { if (change.show === false) continue; + restorePanelBarFallback(item); return false; } @@ -37,8 +44,22 @@ export const tryApplyPanelComponentChanges = ( } } + let barChange = null; + let shouldReconcileBar = false; for (const { component, change } of jobs) { - applyPanelComponentChange(item, component, change, options); + const isBar = component.type === 'bar'; + applyPanelComponentChange(item, component, change, options, { + deferBarVisual: isBar, + }); + if (isBar) { + barChange = mergeQueuedChange(barChange ?? {}, change); + } + shouldReconcileBar ||= + isBar || component.type === 'icon' || component.type === 'text'; + } + + if (shouldReconcileBar) { + reconcilePanelBarVisual(item, barChange, options); } return true; }; @@ -229,16 +250,32 @@ const findPanelComponent = (item, change) => { return getPanelComponentByType(item, change.type); }; +const getSinglePanelBarComponent = (item) => { + let bar = null; + for (const child of item.children ?? []) { + if (child?.type !== 'bar' || child.destroyed) continue; + if (bar) return null; + bar = child; + } + return bar; +}; + const canUsePanelRenderer = (item, componentChanges, options) => item?.type === 'item' && options.validateSchema === false && options.mergeStrategy !== 'replace' && Array.isArray(componentChanges); -const applyPanelComponentChange = (item, component, change, options) => { +const applyPanelComponentChange = ( + item, + component, + change, + options, + { deferBarVisual = false } = {}, +) => { if (isNoopHiddenChange(component, change)) return; - if (component.type === 'bar') { + if (component.type === 'bar' && !deferBarVisual) { hideAggregatedBar(component); } @@ -252,6 +289,10 @@ const applyPanelComponentChange = (item, component, change, options) => { component.tint = getColor(component.store.theme, component.props.tint); } + if (component.type === 'bar' && deferBarVisual) { + return; + } + if (needsDeferredVisualWork(component, change)) { enqueueVisualChange(component, change, options); return; @@ -276,6 +317,47 @@ const applyPanelComponentChange = (item, component, change, options) => { } }; +const reconcilePanelBarVisual = (item, change, options) => { + const bar = getSinglePanelBarComponent(item); + if (!bar) return; + + if (canUseAggregateBar(item, bar)) { + markPanelBarVisualDirty(bar, change ?? {}, options); + return; + } + + hideAggregatedBar(bar); + if (change && Object.keys(change).length > 0) { + applyDeferredVisualChange(bar, change, options); + } else { + bar.renderable = bar.props?.show !== false; + } +}; + +const canUseAggregateBar = (item, bar) => { + if (!bar || bar.props?.show === false) return false; + if (isVisiblePanelComponent(item._panelIconComponent)) return false; + if (isVisiblePanelComponent(item._panelTextComponent)) return false; + if (hasUnsafeAggregateEffects(item) || hasUnsafeAggregateEffects(bar)) { + return false; + } + + const layer = ensurePanelBarLayer(bar.store); + return Boolean(layer?.canRender(bar)); +}; + +const isVisiblePanelComponent = (component) => + Boolean(component && !component.destroyed && component.props?.show !== false); + +const hasUnsafeAggregateEffects = (component) => + Boolean( + component?.mask || + component?.filters?.length || + (component?.blendMode && + component.blendMode !== 'normal' && + component.blendMode !== 'inherit'), + ); + const enqueueVisualChange = ( component, change, @@ -319,15 +401,28 @@ const markPanelBarVisualDirty = (component, change, options) => { layer = ensurePanelBarLayer(component.store); component._patchmapUseAggregateBar = Boolean(layer?.canRender(component)); } + enqueueDirtyPanelBar(queue, component, change); + component._patchmapQueuedVisualOptions = options; + scheduleFlush(queue); +}; + +const enqueueDirtyPanelBar = (queue, component, change) => { if (component._patchmapPanelBarDirty) { mergeQueuedChange(component._patchmapQueuedVisualChange, change); - } else { - component._patchmapPanelBarDirty = true; - component._patchmapQueuedVisualChange = change; - queue.dirtyPanelBars.push(component); + return; } - component._patchmapQueuedVisualOptions = options; - scheduleFlush(queue); + + component._patchmapPanelBarDirty = true; + component._patchmapQueuedVisualChange = change; + if (queue.flushingDirtyPanelBars) { + if (!queue.nextDirtyPanelBarSet.has(component)) { + queue.nextDirtyPanelBarSet.add(component); + queue.nextDirtyPanelBars.push(component); + } + return; + } + + queue.dirtyPanelBars.push(component); }; const ensureVisualQueue = (store) => { @@ -341,6 +436,9 @@ const ensureVisualQueue = (store) => { scheduled: false, dirtyPanelBars: [], dirtyPanelBarIndex: 0, + flushingDirtyPanelBars: false, + nextDirtyPanelBars: [], + nextDirtyPanelBarSet: new Set(), }; QUEUE_BY_STORE.set(store, queue); } @@ -388,6 +486,8 @@ const flushVisualQueue = (queue) => { const flushDirtyPanelBars = (queue, startedAt) => { if (queue.dirtyPanelBars.length === 0) return; + const dirtyParticleLayers = new Set(); + queue.flushingDirtyPanelBars = true; while (queue.dirtyPanelBarIndex < queue.dirtyPanelBars.length) { const bar = queue.dirtyPanelBars[queue.dirtyPanelBarIndex]; queue.dirtyPanelBarIndex += 1; @@ -404,6 +504,7 @@ const flushDirtyPanelBars = (queue, startedAt) => { ? ensurePanelBarLayer(bar.store) : null; if (layer?.syncBar(bar)) { + dirtyParticleLayers.add(layer); bar.renderable = false; bar._patchmapNeedsInitialSource = false; } else { @@ -415,13 +516,29 @@ const flushDirtyPanelBars = (queue, startedAt) => { queue.dirtyPanelBarIndex < queue.dirtyPanelBars.length && now() - startedAt >= FRAME_BUDGET_MS ) { + flushPanelBarLayerParticleUpdates(dirtyParticleLayers); scheduleFlush(queue); return; } } + flushPanelBarLayerParticleUpdates(dirtyParticleLayers); + queue.flushingDirtyPanelBars = false; queue.dirtyPanelBars = []; queue.dirtyPanelBarIndex = 0; + + if (queue.nextDirtyPanelBars.length > 0) { + queue.dirtyPanelBars = queue.nextDirtyPanelBars; + queue.nextDirtyPanelBars = []; + queue.nextDirtyPanelBarSet.clear(); + scheduleFlush(queue); + } +}; + +const flushPanelBarLayerParticleUpdates = (layers) => { + for (const layer of layers) { + layer.flushParticleChildrenUpdate?.(); + } }; const applyDeferredVisualChange = (component, change, options) => { @@ -503,6 +620,13 @@ const hideAggregatedBar = (bar) => { if (bar) bar._patchmapUseAggregateBar = false; }; +const restorePanelBarFallback = (item) => { + const bar = getSinglePanelBarComponent(item) ?? item?._panelBarComponent; + if (!bar) return; + hideAggregatedBar(bar); + bar.renderable = bar.props?.show !== false; +}; + const syncParentComponentProps = (item, component, change, mergeStrategy) => { const parentComponents = item.props?.components; if (!Array.isArray(parentComponents)) return; diff --git a/src/tests/render/patch-service-contract.test.js b/src/tests/render/patch-service-contract.test.js index 57accf7b..1a9f6fab 100644 --- a/src/tests/render/patch-service-contract.test.js +++ b/src/tests/render/patch-service-contract.test.js @@ -105,6 +105,11 @@ const emitPointer = (viewport, type, position, extras = {}) => { const getComponent = (item, type) => item.children.find((child) => child.type === type); +const getAggregateEntry = (item) => { + const bar = getComponent(item, 'bar'); + return bar ? item.store?.panelBarLayer?._entries?.get(bar) : null; +}; + describe('patch-service plant map contract', () => { const { getPatchmap } = setupPatchmapTests(); @@ -214,11 +219,160 @@ describe('patch-service plant map contract', () => { unit: '%', }); expect(bar.props.animation).toBe(true); + expect(bar.renderable).toBe(false); + expect(getAggregateEntry(item)?.particle.alpha).toBeGreaterThan(0); expect(icon?.renderable ?? false).toBe(false); expect(text?.renderable ?? false).toBe(false); } }); + it('uses aggregate bars when the final panel state only shows a rect bar', async () => { + const patchmap = getPatchmap(); + patchmap.draw(plantMapData); + await waitForScene(); + + const [item] = patchmap.selector(PANEL_ITEM_PATH); + patchmap.update({ + elements: item, + changes: { + components: [ + { + type: 'bar', + show: true, + size: { height: '64%' }, + tint: '#2563eb', + animation: false, + }, + ], + }, + validateSchema: false, + emit: false, + }); + await waitForScene(); + + const bar = getComponent(item, 'bar'); + expect(bar.renderable).toBe(false); + expect(bar.props.size.height).toMatchObject({ value: 64, unit: '%' }); + expect(getAggregateEntry(item)?.particle.alpha).toBeGreaterThan(0); + }); + + it('batches aggregate particle updates while materializing panel bars', async () => { + const patchmap = getPatchmap(); + patchmap.draw(plantMapData); + await waitForScene(); + + const panelItems = patchmap.selector(PANEL_ITEM_PATH); + for (const item of panelItems) { + patchmap.update({ + elements: item, + changes: { + components: [ + { + type: 'bar', + show: true, + size: { height: '64%' }, + tint: '#2563eb', + animation: false, + }, + ], + }, + validateSchema: false, + emit: false, + }); + } + + const layer = panelItems[0]._panelBarComponent.store.panelBarLayer; + const originalUpdate = layer.update.bind(layer); + let updateCount = 0; + layer.update = (...args) => { + updateCount += 1; + return originalUpdate(...args); + }; + + await waitForScene(); + + expect(updateCount).toBeLessThan(panelItems.length); + expect(panelItems.every((item) => getAggregateEntry(item))).toBe(true); + }); + + it('keeps aggregate bars when background and bar update together in a bar-only state', async () => { + const patchmap = getPatchmap(); + patchmap.draw(plantMapData); + await waitForScene(); + + const [item] = patchmap.selector(PANEL_ITEM_PATH); + patchmap.update({ + elements: item, + changes: { + components: [ + { + type: 'background', + size: '100%', + source: { type: 'rect', fill: '#f1f5f9', radius: 4 }, + }, + { + type: 'bar', + show: true, + size: { height: '72%' }, + tint: '#0C73BF', + animation: false, + }, + ], + }, + validateSchema: false, + emit: false, + }); + await waitForScene(); + + const background = getComponent(item, 'background'); + const bar = getComponent(item, 'bar'); + expect(background.props.source.fill).toBe('#f1f5f9'); + expect(bar.renderable).toBe(false); + expect(getAggregateEntry(item)?.particle.alpha).toBeGreaterThan(0); + }); + + it('falls back to the normal bar when an icon becomes visible', async () => { + const patchmap = getPatchmap(); + patchmap.draw(plantMapData); + await waitForScene(); + + const [item] = patchmap.selector(PANEL_ITEM_PATH); + patchmap.update({ + elements: item, + changes: { + components: [ + { type: 'bar', show: true, size: '100%', animation: false }, + { type: 'icon', show: false }, + { type: 'text', show: false }, + ], + }, + validateSchema: false, + emit: false, + }); + await waitForScene(); + expect(getComponent(item, 'bar').renderable).toBe(false); + + patchmap.update({ + elements: item, + changes: { + components: [ + { type: 'bar', show: true, size: '100%', animation: false }, + { type: 'icon', show: true, source: 'warning', tint: 'white' }, + { type: 'text', show: false }, + ], + }, + validateSchema: false, + emit: false, + }); + await waitForScene(); + + const bar = getComponent(item, 'bar'); + const icon = getComponent(item, 'icon'); + expect(bar.renderable).toBe(true); + expect(icon.renderable).toBe(true); + expect(getAggregateEntry(item)?.particle.alpha).toBe(0); + }); + it('supports report-style panel background and relations path updates', async () => { const patchmap = getPatchmap(); patchmap.draw(plantMapData); From 1bb7baaa63acc27ec76eeca8bf3abeb72498d0f9 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:06:03 +0900 Subject: [PATCH 09/51] perf: record flat aggregate bar particle experiment --- src/display/renderers/PanelBarLayer.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/display/renderers/PanelBarLayer.js b/src/display/renderers/PanelBarLayer.js index 73967cbd..aa50672c 100644 --- a/src/display/renderers/PanelBarLayer.js +++ b/src/display/renderers/PanelBarLayer.js @@ -46,9 +46,9 @@ export class PanelBarLayer extends ParticleContainer { syncBar(bar) { if (!bar?.parent || bar.destroyed) return false; - const texture = getBarTexture(bar); - if (!texture) return false; + if (!getBarTexture(bar)) return false; + const texture = Texture.WHITE; let entry = this._entries.get(bar); if (!entry) { entry = this._createEntry(bar, texture); @@ -57,7 +57,7 @@ export class PanelBarLayer extends ParticleContainer { } const alpha = this._resolveAlpha(bar); - const tint = getColor(bar.store.theme, bar.props?.tint ?? 0xffffff); + const tint = resolveFlatBarTint(bar); this._applyAppearance(entry, { alpha, tint }); if (alpha === 0) { @@ -460,6 +460,13 @@ const placePanelBarLayer = (world, layer) => { world.setChildIndex(layer, relationIndex); }; +const resolveFlatBarTint = (bar) => { + if (bar.props?.tint !== undefined) { + return getColor(bar.store.theme, bar.props.tint); + } + return getColor(bar.store.theme, bar.props?.source?.fill ?? 0xffffff); +}; + const getBarTexture = (bar) => { const source = bar?.props?.source; if (!source || source.type !== 'rect') return null; From ae1c2b34fa23afc89f08d98a7958490979c30dd1 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:06:07 +0900 Subject: [PATCH 10/51] chore: revert flat aggregate bar particle experiment --- src/display/renderers/PanelBarLayer.js | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/display/renderers/PanelBarLayer.js b/src/display/renderers/PanelBarLayer.js index aa50672c..73967cbd 100644 --- a/src/display/renderers/PanelBarLayer.js +++ b/src/display/renderers/PanelBarLayer.js @@ -46,9 +46,9 @@ export class PanelBarLayer extends ParticleContainer { syncBar(bar) { if (!bar?.parent || bar.destroyed) return false; - if (!getBarTexture(bar)) return false; + const texture = getBarTexture(bar); + if (!texture) return false; - const texture = Texture.WHITE; let entry = this._entries.get(bar); if (!entry) { entry = this._createEntry(bar, texture); @@ -57,7 +57,7 @@ export class PanelBarLayer extends ParticleContainer { } const alpha = this._resolveAlpha(bar); - const tint = resolveFlatBarTint(bar); + const tint = getColor(bar.store.theme, bar.props?.tint ?? 0xffffff); this._applyAppearance(entry, { alpha, tint }); if (alpha === 0) { @@ -460,13 +460,6 @@ const placePanelBarLayer = (world, layer) => { world.setChildIndex(layer, relationIndex); }; -const resolveFlatBarTint = (bar) => { - if (bar.props?.tint !== undefined) { - return getColor(bar.store.theme, bar.props.tint); - } - return getColor(bar.store.theme, bar.props?.source?.fill ?? 0xffffff); -}; - const getBarTexture = (bar) => { const source = bar?.props?.source; if (!source || source.type !== 'rect') return null; From bf2f6f105f710828eff7bc4b948cd56750720fb1 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:06:51 +0900 Subject: [PATCH 11/51] perf: record aggregate bar viewport culling experiment --- src/display/renderers/PanelBarLayer.js | 102 ++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 2 deletions(-) diff --git a/src/display/renderers/PanelBarLayer.js b/src/display/renderers/PanelBarLayer.js index 73967cbd..0067d69c 100644 --- a/src/display/renderers/PanelBarLayer.js +++ b/src/display/renderers/PanelBarLayer.js @@ -16,6 +16,7 @@ const DEFAULT_BOUNDS = new Rectangle( 2_000_000, ); const ZERO_POINT = new Point(); +const CULLING_MARGIN = 512; export class PanelBarLayer extends ParticleContainer { constructor(store) { @@ -34,15 +35,27 @@ export class PanelBarLayer extends ParticleContainer { this.store = store; this.zIndex = 0; this._entries = new WeakMap(); + this._entrySet = new Set(); this._activeAnimations = new Set(); this._animationFrame = null; this._needsParticleChildrenUpdate = false; + this._needsParticleChildrenRefresh = false; + this._cullingFrame = null; + this._boundScheduleCullingRefresh = () => this._scheduleCullingRefresh(); + this._bindViewportCulling(); } canRender(bar) { return Boolean(getBarTexture(bar)); } + destroy(options) { + const viewport = this.store?.viewport; + viewport?.off?.('moved', this._boundScheduleCullingRefresh); + viewport?.off?.('zoomed', this._boundScheduleCullingRefresh); + super.destroy(options); + } + syncBar(bar) { if (!bar?.parent || bar.destroyed) return false; @@ -106,6 +119,7 @@ export class PanelBarLayer extends ParticleContainer { _createEntry(bar, texture) { const entry = { + bar, texture: null, layout: null, particles: [], @@ -115,6 +129,7 @@ export class PanelBarLayer extends ParticleContainer { }; this._setEntryTexture(entry, texture); this._entries.set(bar, entry); + this._entrySet.add(entry); return entry; } @@ -131,12 +146,12 @@ export class PanelBarLayer extends ParticleContainer { ); this._removeEntryParticles(entry); - this.particleChildren.push(...particles); entry.texture = texture; entry.layout = layout; entry.particles = particles; entry.particle = particles[0] ?? null; this._needsParticleChildrenUpdate = true; + this._needsParticleChildrenRefresh = true; } _removeEntryParticles(entry) { @@ -152,9 +167,12 @@ export class PanelBarLayer extends ParticleContainer { } flushParticleChildrenUpdate() { + if (this._needsParticleChildrenRefresh) { + this._refreshParticleChildrenForViewport(); + } if (!this._needsParticleChildrenUpdate) return; - this._needsParticleChildrenUpdate = false; this.update(); + this._needsParticleChildrenUpdate = false; } _applyAppearance(entry, { alpha, tint }) { @@ -225,7 +243,11 @@ export class PanelBarLayer extends ParticleContainer { return; } + const previousState = entry.state; const state = updateEntryState(entry, x, y, width, height, rotation); + if (!previousState) { + this._needsParticleChildrenRefresh = true; + } const targetSlices = resolveTargetSlices(layout, state); const cos = Math.cos(rotation); const sin = Math.sin(rotation); @@ -257,7 +279,11 @@ export class PanelBarLayer extends ParticleContainer { } _applyBorderlessState(entry, layout, x, y, width, height, rotation) { + const previousState = entry.state; updateEntryState(entry, x, y, width, height, rotation); + if (!previousState) { + this._needsParticleChildrenRefresh = true; + } const borderScale = resolveBorderScaleForSize(layout, width, height); const left = layout.slice.leftWidth * borderScale; const right = layout.slice.rightWidth * borderScale; @@ -426,6 +452,59 @@ export class PanelBarLayer extends ParticleContainer { this._scheduleAnimationFrame(); } } + + _bindViewportCulling() { + const viewport = this.store?.viewport; + if (!viewport?.on) return; + viewport.on('moved', this._boundScheduleCullingRefresh); + viewport.on('zoomed', this._boundScheduleCullingRefresh); + } + + _scheduleCullingRefresh() { + if (this.destroyed || this._cullingFrame !== null) return; + this._cullingFrame = requestFrame(() => { + this._cullingFrame = null; + this._needsParticleChildrenRefresh = true; + this.flushParticleChildrenUpdate(); + }); + } + + _refreshParticleChildrenForViewport() { + this._needsParticleChildrenRefresh = false; + const viewportBounds = this._getViewportBounds(); + if (!viewportBounds) { + this.particleChildren = getAllEntryParticles(this._entrySet); + this._needsParticleChildrenUpdate = true; + return; + } + + const visibleParticles = []; + for (const entry of this._entrySet) { + if (!entry.state || entry.alpha === 0) continue; + if (!intersectsState(viewportBounds, entry.state)) continue; + visibleParticles.push(...entry.particles); + } + + this.particleChildren = visibleParticles; + this._needsParticleChildrenUpdate = true; + } + + _getViewportBounds() { + const viewport = this.store?.viewport; + if (!viewport?.toWorld) return null; + + const width = viewport.screenWidth ?? viewport.screen?.width; + const height = viewport.screenHeight ?? viewport.screen?.height; + if (!Number.isFinite(width) || !Number.isFinite(height)) return null; + + const topLeft = viewport.toWorld(0, 0); + const bottomRight = viewport.toWorld(width, height); + const minX = Math.min(topLeft.x, bottomRight.x) - CULLING_MARGIN; + const minY = Math.min(topLeft.y, bottomRight.y) - CULLING_MARGIN; + const maxX = Math.max(topLeft.x, bottomRight.x) + CULLING_MARGIN; + const maxY = Math.max(topLeft.y, bottomRight.y) + CULLING_MARGIN; + return { minX, minY, maxX, maxY }; + } } export const ensurePanelBarLayer = (store) => { @@ -811,6 +890,25 @@ const now = () => const normalizeDuration = (durationMs) => Math.max(0, Number(durationMs ?? 200) || 0); +const getAllEntryParticles = (entries) => { + const particles = []; + for (const entry of entries) { + if (entry.alpha !== 0) particles.push(...entry.particles); + } + return particles; +}; + +const intersectsState = (bounds, state) => { + const width = Math.abs(state.width ?? state.w ?? 0); + const height = Math.abs(state.height ?? state.h ?? 0); + return ( + state.x + width >= bounds.minX && + state.x <= bounds.maxX && + state.y + height >= bounds.minY && + state.y <= bounds.maxY + ); +}; + const clamp01 = (value) => (value < 0 ? 0 : value > 1 ? 1 : value); const easePower2InOut = (progress) => From 27b4c366cdaf0f864900388fa832e50cf060ed13 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:06:54 +0900 Subject: [PATCH 12/51] chore: revert aggregate bar viewport culling experiment --- src/display/renderers/PanelBarLayer.js | 102 +------------------------ 1 file changed, 2 insertions(+), 100 deletions(-) diff --git a/src/display/renderers/PanelBarLayer.js b/src/display/renderers/PanelBarLayer.js index 0067d69c..73967cbd 100644 --- a/src/display/renderers/PanelBarLayer.js +++ b/src/display/renderers/PanelBarLayer.js @@ -16,7 +16,6 @@ const DEFAULT_BOUNDS = new Rectangle( 2_000_000, ); const ZERO_POINT = new Point(); -const CULLING_MARGIN = 512; export class PanelBarLayer extends ParticleContainer { constructor(store) { @@ -35,27 +34,15 @@ export class PanelBarLayer extends ParticleContainer { this.store = store; this.zIndex = 0; this._entries = new WeakMap(); - this._entrySet = new Set(); this._activeAnimations = new Set(); this._animationFrame = null; this._needsParticleChildrenUpdate = false; - this._needsParticleChildrenRefresh = false; - this._cullingFrame = null; - this._boundScheduleCullingRefresh = () => this._scheduleCullingRefresh(); - this._bindViewportCulling(); } canRender(bar) { return Boolean(getBarTexture(bar)); } - destroy(options) { - const viewport = this.store?.viewport; - viewport?.off?.('moved', this._boundScheduleCullingRefresh); - viewport?.off?.('zoomed', this._boundScheduleCullingRefresh); - super.destroy(options); - } - syncBar(bar) { if (!bar?.parent || bar.destroyed) return false; @@ -119,7 +106,6 @@ export class PanelBarLayer extends ParticleContainer { _createEntry(bar, texture) { const entry = { - bar, texture: null, layout: null, particles: [], @@ -129,7 +115,6 @@ export class PanelBarLayer extends ParticleContainer { }; this._setEntryTexture(entry, texture); this._entries.set(bar, entry); - this._entrySet.add(entry); return entry; } @@ -146,12 +131,12 @@ export class PanelBarLayer extends ParticleContainer { ); this._removeEntryParticles(entry); + this.particleChildren.push(...particles); entry.texture = texture; entry.layout = layout; entry.particles = particles; entry.particle = particles[0] ?? null; this._needsParticleChildrenUpdate = true; - this._needsParticleChildrenRefresh = true; } _removeEntryParticles(entry) { @@ -167,12 +152,9 @@ export class PanelBarLayer extends ParticleContainer { } flushParticleChildrenUpdate() { - if (this._needsParticleChildrenRefresh) { - this._refreshParticleChildrenForViewport(); - } if (!this._needsParticleChildrenUpdate) return; - this.update(); this._needsParticleChildrenUpdate = false; + this.update(); } _applyAppearance(entry, { alpha, tint }) { @@ -243,11 +225,7 @@ export class PanelBarLayer extends ParticleContainer { return; } - const previousState = entry.state; const state = updateEntryState(entry, x, y, width, height, rotation); - if (!previousState) { - this._needsParticleChildrenRefresh = true; - } const targetSlices = resolveTargetSlices(layout, state); const cos = Math.cos(rotation); const sin = Math.sin(rotation); @@ -279,11 +257,7 @@ export class PanelBarLayer extends ParticleContainer { } _applyBorderlessState(entry, layout, x, y, width, height, rotation) { - const previousState = entry.state; updateEntryState(entry, x, y, width, height, rotation); - if (!previousState) { - this._needsParticleChildrenRefresh = true; - } const borderScale = resolveBorderScaleForSize(layout, width, height); const left = layout.slice.leftWidth * borderScale; const right = layout.slice.rightWidth * borderScale; @@ -452,59 +426,6 @@ export class PanelBarLayer extends ParticleContainer { this._scheduleAnimationFrame(); } } - - _bindViewportCulling() { - const viewport = this.store?.viewport; - if (!viewport?.on) return; - viewport.on('moved', this._boundScheduleCullingRefresh); - viewport.on('zoomed', this._boundScheduleCullingRefresh); - } - - _scheduleCullingRefresh() { - if (this.destroyed || this._cullingFrame !== null) return; - this._cullingFrame = requestFrame(() => { - this._cullingFrame = null; - this._needsParticleChildrenRefresh = true; - this.flushParticleChildrenUpdate(); - }); - } - - _refreshParticleChildrenForViewport() { - this._needsParticleChildrenRefresh = false; - const viewportBounds = this._getViewportBounds(); - if (!viewportBounds) { - this.particleChildren = getAllEntryParticles(this._entrySet); - this._needsParticleChildrenUpdate = true; - return; - } - - const visibleParticles = []; - for (const entry of this._entrySet) { - if (!entry.state || entry.alpha === 0) continue; - if (!intersectsState(viewportBounds, entry.state)) continue; - visibleParticles.push(...entry.particles); - } - - this.particleChildren = visibleParticles; - this._needsParticleChildrenUpdate = true; - } - - _getViewportBounds() { - const viewport = this.store?.viewport; - if (!viewport?.toWorld) return null; - - const width = viewport.screenWidth ?? viewport.screen?.width; - const height = viewport.screenHeight ?? viewport.screen?.height; - if (!Number.isFinite(width) || !Number.isFinite(height)) return null; - - const topLeft = viewport.toWorld(0, 0); - const bottomRight = viewport.toWorld(width, height); - const minX = Math.min(topLeft.x, bottomRight.x) - CULLING_MARGIN; - const minY = Math.min(topLeft.y, bottomRight.y) - CULLING_MARGIN; - const maxX = Math.max(topLeft.x, bottomRight.x) + CULLING_MARGIN; - const maxY = Math.max(topLeft.y, bottomRight.y) + CULLING_MARGIN; - return { minX, minY, maxX, maxY }; - } } export const ensurePanelBarLayer = (store) => { @@ -890,25 +811,6 @@ const now = () => const normalizeDuration = (durationMs) => Math.max(0, Number(durationMs ?? 200) || 0); -const getAllEntryParticles = (entries) => { - const particles = []; - for (const entry of entries) { - if (entry.alpha !== 0) particles.push(...entry.particles); - } - return particles; -}; - -const intersectsState = (bounds, state) => { - const width = Math.abs(state.width ?? state.w ?? 0); - const height = Math.abs(state.height ?? state.h ?? 0); - return ( - state.x + width >= bounds.minX && - state.x <= bounds.maxX && - state.y + height >= bounds.minY && - state.y <= bounds.maxY - ); -}; - const clamp01 = (value) => (value < 0 ? 0 : value > 1 ? 1 : value); const easePower2InOut = (progress) => From a76cfcf57c32409642ee73a3bbfd9c72cf88fbb6 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:07:10 +0900 Subject: [PATCH 13/51] perf: record aggregate bar alpha fast path experiment --- src/display/renderers/PanelBarLayer.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/display/renderers/PanelBarLayer.js b/src/display/renderers/PanelBarLayer.js index 73967cbd..a61d7874 100644 --- a/src/display/renderers/PanelBarLayer.js +++ b/src/display/renderers/PanelBarLayer.js @@ -87,6 +87,13 @@ export class PanelBarLayer extends ParticleContainer { syncAlphaForSubtree(root) { if (!root || root.destroyed) return; + const rootBar = root._panelBarComponent; + const rootEntry = rootBar ? this._entries.get(rootBar) : null; + if (rootEntry && isPanelItemWithCachedBar(root)) { + this._applyAppearance(rootEntry, { alpha: this._resolveAlpha(rootBar) }); + return; + } + const stack = [root]; while (stack.length > 0) { const node = stack.pop(); @@ -460,6 +467,11 @@ const placePanelBarLayer = (world, layer) => { world.setChildIndex(layer, relationIndex); }; +const isPanelItemWithCachedBar = (node) => + node?.type === 'item' && + node._panelBarComponent && + !node.children?.some((child) => child?.type === 'item'); + const getBarTexture = (bar) => { const source = bar?.props?.source; if (!source || source.type !== 'rect') return null; From ee1e448fa359ef82216caa44d5efb95fbf2d5930 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:07:14 +0900 Subject: [PATCH 14/51] chore: revert aggregate bar alpha fast path experiment --- src/display/renderers/PanelBarLayer.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/display/renderers/PanelBarLayer.js b/src/display/renderers/PanelBarLayer.js index a61d7874..73967cbd 100644 --- a/src/display/renderers/PanelBarLayer.js +++ b/src/display/renderers/PanelBarLayer.js @@ -87,13 +87,6 @@ export class PanelBarLayer extends ParticleContainer { syncAlphaForSubtree(root) { if (!root || root.destroyed) return; - const rootBar = root._panelBarComponent; - const rootEntry = rootBar ? this._entries.get(rootBar) : null; - if (rootEntry && isPanelItemWithCachedBar(root)) { - this._applyAppearance(rootEntry, { alpha: this._resolveAlpha(rootBar) }); - return; - } - const stack = [root]; while (stack.length > 0) { const node = stack.pop(); @@ -467,11 +460,6 @@ const placePanelBarLayer = (world, layer) => { world.setChildIndex(layer, relationIndex); }; -const isPanelItemWithCachedBar = (node) => - node?.type === 'item' && - node._panelBarComponent && - !node.children?.some((child) => child?.type === 'item'); - const getBarTexture = (bar) => { const source = bar?.props?.source; if (!source || source.type !== 'rect') return null; From 32f8dd0253b78c117053330b4612377d2ccddb46 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:07:57 +0900 Subject: [PATCH 15/51] docs: record custom mesh no slice experiment --- .../patchmap-custom-mesh-no-slice.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/experiments/patchmap-custom-mesh-no-slice.md diff --git a/docs/experiments/patchmap-custom-mesh-no-slice.md b/docs/experiments/patchmap-custom-mesh-no-slice.md new file mode 100644 index 00000000..dec91f03 --- /dev/null +++ b/docs/experiments/patchmap-custom-mesh-no-slice.md @@ -0,0 +1,34 @@ +# Patchmap Custom Mesh No-Slice Experiment + +Rejected experiment record. + +## Change + +- Replaced aggregate panel bar rendering with a custom CPU-updated Pixi Mesh. +- Removed nine-slice/radius rendering for aggregate bars and rendered bars as flat, no-radius quads. +- Kept patch-service aggregate bar API shape compatible during the experiment. + +## Benchmark + +Report: + +- `.gstack/benchmark-reports/2026-05-13T10-14-52-837Z-patchmap-frame-benchmark.json` + +Baseline: + +- `.gstack/benchmark-reports/2026-05-13T08-29-31-964Z-patchmap-frame-benchmark.json` + +## Result + +| Scenario | Baseline FPS | Experiment FPS | Delta | +|---|---:|---:|---:| +| draw+update animated bars | 52.43 | 49.22 | -6.1% | +| all bars every 1s x10 | 38.42 | 44.43 | +15.6% | +| wheel pan | 26.69 | 31.99 | +19.9% | +| ctrl wheel zoom | 36.00 | 38.88 | +8.0% | +| panel chart stream | 12.46 | 8.91 | -28.5% | +| highlight alpha burst | 25.75 | 13.57 | -47.3% | + +## Decision + +Rejected. The experiment improved some bulk update and interaction cases, but regressed patch-service chart stream and highlight alpha scenarios too much for production use. From f2a5cf10f12f065ad3285780183536b562cc336e Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:08:01 +0900 Subject: [PATCH 16/51] chore: revert custom mesh no slice experiment record --- .../patchmap-custom-mesh-no-slice.md | 34 ------------------- 1 file changed, 34 deletions(-) delete mode 100644 docs/experiments/patchmap-custom-mesh-no-slice.md diff --git a/docs/experiments/patchmap-custom-mesh-no-slice.md b/docs/experiments/patchmap-custom-mesh-no-slice.md deleted file mode 100644 index dec91f03..00000000 --- a/docs/experiments/patchmap-custom-mesh-no-slice.md +++ /dev/null @@ -1,34 +0,0 @@ -# Patchmap Custom Mesh No-Slice Experiment - -Rejected experiment record. - -## Change - -- Replaced aggregate panel bar rendering with a custom CPU-updated Pixi Mesh. -- Removed nine-slice/radius rendering for aggregate bars and rendered bars as flat, no-radius quads. -- Kept patch-service aggregate bar API shape compatible during the experiment. - -## Benchmark - -Report: - -- `.gstack/benchmark-reports/2026-05-13T10-14-52-837Z-patchmap-frame-benchmark.json` - -Baseline: - -- `.gstack/benchmark-reports/2026-05-13T08-29-31-964Z-patchmap-frame-benchmark.json` - -## Result - -| Scenario | Baseline FPS | Experiment FPS | Delta | -|---|---:|---:|---:| -| draw+update animated bars | 52.43 | 49.22 | -6.1% | -| all bars every 1s x10 | 38.42 | 44.43 | +15.6% | -| wheel pan | 26.69 | 31.99 | +19.9% | -| ctrl wheel zoom | 36.00 | 38.88 | +8.0% | -| panel chart stream | 12.46 | 8.91 | -28.5% | -| highlight alpha burst | 25.75 | 13.57 | -47.3% | - -## Decision - -Rejected. The experiment improved some bulk update and interaction cases, but regressed patch-service chart stream and highlight alpha scenarios too much for production use. From 7a22e9ee72487692ff5ed3290318761666e5593d Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:08:28 +0900 Subject: [PATCH 17/51] docs: record cpu mesh aggregate bar experiment --- .../patchmap-cpu-mesh-aggregate-bars.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/experiments/patchmap-cpu-mesh-aggregate-bars.md diff --git a/docs/experiments/patchmap-cpu-mesh-aggregate-bars.md b/docs/experiments/patchmap-cpu-mesh-aggregate-bars.md new file mode 100644 index 00000000..bd8b180d --- /dev/null +++ b/docs/experiments/patchmap-cpu-mesh-aggregate-bars.md @@ -0,0 +1,38 @@ +# Patchmap CPU Mesh Aggregate Bars Experiment + +Rejected experiment record. + +## Change + +- Replaced aggregate panel bar particles with a CPU-updated custom Pixi Mesh. +- Kept rounded/nine-slice rendering in the mesh path. +- Updated mesh vertex buffers from JavaScript on bar state changes and animation ticks. + +## Benchmark + +Report: + +- `.gstack/benchmark-reports/2026-05-13T09-19-39-148Z-patchmap-frame-benchmark.json` + +Baseline: + +- `.gstack/benchmark-reports/2026-05-13T08-29-31-964Z-patchmap-frame-benchmark.json` + +## Result + +| Scenario | Baseline FPS | Experiment FPS | Delta | +|---|---:|---:|---:| +| draw+update animated bars | 52.43 | 58.14 | +10.9% | +| all bars every 1s x10 | 38.42 | 59.01 | +53.6% | +| wheel pan | 26.69 | 49.30 | +84.7% | +| ctrl wheel zoom | 36.00 | 46.63 | +29.5% | +| transformer select | 52.91 | 59.97 | +13.3% | +| shift drag multi select | 46.54 | 58.22 | +25.1% | +| panel mixed state burst | 26.57 | 28.53 | +7.4% | +| panel chart stream | 12.46 | 10.39 | -16.6% | +| highlight alpha burst | 25.75 | 17.32 | -32.7% | +| report backgrounds burst | 28.96 | 30.62 | +5.7% | + +## Decision + +Rejected as a full replacement. It improved bulk animation and interaction scenarios, but regressed patch-service chart streaming and highlight alpha enough to make the behavior unsuitable as the default renderer. From c87c4f020f027b584bf41eaeecdb01daf0c54dcf Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:08:28 +0900 Subject: [PATCH 18/51] chore: revert cpu mesh aggregate bar experiment record --- .../patchmap-cpu-mesh-aggregate-bars.md | 38 ------------------- 1 file changed, 38 deletions(-) delete mode 100644 docs/experiments/patchmap-cpu-mesh-aggregate-bars.md diff --git a/docs/experiments/patchmap-cpu-mesh-aggregate-bars.md b/docs/experiments/patchmap-cpu-mesh-aggregate-bars.md deleted file mode 100644 index bd8b180d..00000000 --- a/docs/experiments/patchmap-cpu-mesh-aggregate-bars.md +++ /dev/null @@ -1,38 +0,0 @@ -# Patchmap CPU Mesh Aggregate Bars Experiment - -Rejected experiment record. - -## Change - -- Replaced aggregate panel bar particles with a CPU-updated custom Pixi Mesh. -- Kept rounded/nine-slice rendering in the mesh path. -- Updated mesh vertex buffers from JavaScript on bar state changes and animation ticks. - -## Benchmark - -Report: - -- `.gstack/benchmark-reports/2026-05-13T09-19-39-148Z-patchmap-frame-benchmark.json` - -Baseline: - -- `.gstack/benchmark-reports/2026-05-13T08-29-31-964Z-patchmap-frame-benchmark.json` - -## Result - -| Scenario | Baseline FPS | Experiment FPS | Delta | -|---|---:|---:|---:| -| draw+update animated bars | 52.43 | 58.14 | +10.9% | -| all bars every 1s x10 | 38.42 | 59.01 | +53.6% | -| wheel pan | 26.69 | 49.30 | +84.7% | -| ctrl wheel zoom | 36.00 | 46.63 | +29.5% | -| transformer select | 52.91 | 59.97 | +13.3% | -| shift drag multi select | 46.54 | 58.22 | +25.1% | -| panel mixed state burst | 26.57 | 28.53 | +7.4% | -| panel chart stream | 12.46 | 10.39 | -16.6% | -| highlight alpha burst | 25.75 | 17.32 | -32.7% | -| report backgrounds burst | 28.96 | 30.62 | +5.7% | - -## Decision - -Rejected as a full replacement. It improved bulk animation and interaction scenarios, but regressed patch-service chart streaming and highlight alpha enough to make the behavior unsuitable as the default renderer. From e1d9f0e9a3e005d14fb1594221061743a9dc0e22 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:08:42 +0900 Subject: [PATCH 19/51] docs: record gpu animation mesh bar experiment --- .../patchmap-gpu-animation-mesh-bars.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/experiments/patchmap-gpu-animation-mesh-bars.md diff --git a/docs/experiments/patchmap-gpu-animation-mesh-bars.md b/docs/experiments/patchmap-gpu-animation-mesh-bars.md new file mode 100644 index 00000000..fbaf7d0c --- /dev/null +++ b/docs/experiments/patchmap-gpu-animation-mesh-bars.md @@ -0,0 +1,34 @@ +# Patchmap GPU Animation Mesh Bars Experiment + +Rejected experiment record. + +## Change + +- Replaced aggregate panel bar rendering with a custom Pixi Mesh. +- Moved bar animation interpolation into shader attributes/uniform time. +- Used per-bar from/to/timing attributes to reduce JavaScript animation work. + +## Benchmark + +Report: + +- `.gstack/benchmark-reports/2026-05-13T09-36-50-452Z-patchmap-frame-benchmark.json` + +Baseline: + +- `.gstack/benchmark-reports/2026-05-13T08-29-31-964Z-patchmap-frame-benchmark.json` + +## Result + +| Scenario | Baseline FPS | Experiment FPS | Delta | +|---|---:|---:|---:| +| draw+update animated bars | 52.43 | 42.67 | -18.6% | +| all bars every 1s x10 | 38.42 | 41.15 | +7.1% | +| wheel pan | 26.69 | 17.28 | -35.3% | +| ctrl wheel zoom | 36.00 | 29.77 | -17.3% | +| panel chart stream | 12.46 | 9.80 | -21.3% | +| highlight alpha burst | 25.75 | 6.84 | -73.4% | + +## Decision + +Rejected. GPU-side interpolation reduced some JavaScript animation work, but added enough shader and attribute overhead to regress most 4x CPU frame scenarios. From 7bf0c86f8501cf0f7b32a1583233738af2ae1e3b Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:08:43 +0900 Subject: [PATCH 20/51] chore: revert gpu animation mesh bar experiment record --- .../patchmap-gpu-animation-mesh-bars.md | 34 ------------------- 1 file changed, 34 deletions(-) delete mode 100644 docs/experiments/patchmap-gpu-animation-mesh-bars.md diff --git a/docs/experiments/patchmap-gpu-animation-mesh-bars.md b/docs/experiments/patchmap-gpu-animation-mesh-bars.md deleted file mode 100644 index fbaf7d0c..00000000 --- a/docs/experiments/patchmap-gpu-animation-mesh-bars.md +++ /dev/null @@ -1,34 +0,0 @@ -# Patchmap GPU Animation Mesh Bars Experiment - -Rejected experiment record. - -## Change - -- Replaced aggregate panel bar rendering with a custom Pixi Mesh. -- Moved bar animation interpolation into shader attributes/uniform time. -- Used per-bar from/to/timing attributes to reduce JavaScript animation work. - -## Benchmark - -Report: - -- `.gstack/benchmark-reports/2026-05-13T09-36-50-452Z-patchmap-frame-benchmark.json` - -Baseline: - -- `.gstack/benchmark-reports/2026-05-13T08-29-31-964Z-patchmap-frame-benchmark.json` - -## Result - -| Scenario | Baseline FPS | Experiment FPS | Delta | -|---|---:|---:|---:| -| draw+update animated bars | 52.43 | 42.67 | -18.6% | -| all bars every 1s x10 | 38.42 | 41.15 | +7.1% | -| wheel pan | 26.69 | 17.28 | -35.3% | -| ctrl wheel zoom | 36.00 | 29.77 | -17.3% | -| panel chart stream | 12.46 | 9.80 | -21.3% | -| highlight alpha burst | 25.75 | 6.84 | -73.4% | - -## Decision - -Rejected. GPU-side interpolation reduced some JavaScript animation work, but added enough shader and attribute overhead to regress most 4x CPU frame scenarios. From 908f871ce7432971f8a01b1c5e2d4e4042782b0c Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:08:59 +0900 Subject: [PATCH 21/51] docs: record packed color cpu mesh experiment --- .../patchmap-packed-color-cpu-mesh-bars.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/experiments/patchmap-packed-color-cpu-mesh-bars.md diff --git a/docs/experiments/patchmap-packed-color-cpu-mesh-bars.md b/docs/experiments/patchmap-packed-color-cpu-mesh-bars.md new file mode 100644 index 00000000..eef3ac02 --- /dev/null +++ b/docs/experiments/patchmap-packed-color-cpu-mesh-bars.md @@ -0,0 +1,38 @@ +# Patchmap Packed-Color CPU Mesh Bars Experiment + +Rejected experiment record. + +## Change + +- Replaced aggregate panel bar rendering with a CPU-updated Pixi Mesh. +- Used a 6-piece borderless layout for rounded bars. +- Packed color into an `unorm8x4` vertex attribute to reduce color buffer size. + +## Benchmark + +Report: + +- `.gstack/benchmark-reports/2026-05-13T10-05-18-563Z-patchmap-frame-benchmark.json` + +Baseline: + +- `.gstack/benchmark-reports/2026-05-13T08-29-31-964Z-patchmap-frame-benchmark.json` + +## Result + +| Scenario | Baseline FPS | Experiment FPS | Delta | +|---|---:|---:|---:| +| draw+update animated bars | 52.43 | 38.43 | -26.7% | +| all bars every 1s x10 | 38.42 | 41.70 | +8.5% | +| wheel pan | 26.69 | 19.30 | -27.7% | +| ctrl wheel zoom | 36.00 | 17.80 | -50.6% | +| transformer select | 52.91 | 49.33 | -6.8% | +| shift drag multi select | 46.54 | 50.58 | +8.7% | +| panel mixed state burst | 26.57 | 18.42 | -30.7% | +| panel chart stream | 12.46 | 10.08 | -19.1% | +| highlight alpha burst | 25.75 | 19.14 | -25.7% | +| report backgrounds burst | 28.96 | 26.22 | -9.5% | + +## Decision + +Rejected. Packed color and the reduced piece count did not offset mesh update overhead. Most interaction and streaming scenarios regressed. From 198c5ad596d05dba45859c00fc786162056fc635 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:09:00 +0900 Subject: [PATCH 22/51] chore: revert packed color cpu mesh experiment record --- .../patchmap-packed-color-cpu-mesh-bars.md | 38 ------------------- 1 file changed, 38 deletions(-) delete mode 100644 docs/experiments/patchmap-packed-color-cpu-mesh-bars.md diff --git a/docs/experiments/patchmap-packed-color-cpu-mesh-bars.md b/docs/experiments/patchmap-packed-color-cpu-mesh-bars.md deleted file mode 100644 index eef3ac02..00000000 --- a/docs/experiments/patchmap-packed-color-cpu-mesh-bars.md +++ /dev/null @@ -1,38 +0,0 @@ -# Patchmap Packed-Color CPU Mesh Bars Experiment - -Rejected experiment record. - -## Change - -- Replaced aggregate panel bar rendering with a CPU-updated Pixi Mesh. -- Used a 6-piece borderless layout for rounded bars. -- Packed color into an `unorm8x4` vertex attribute to reduce color buffer size. - -## Benchmark - -Report: - -- `.gstack/benchmark-reports/2026-05-13T10-05-18-563Z-patchmap-frame-benchmark.json` - -Baseline: - -- `.gstack/benchmark-reports/2026-05-13T08-29-31-964Z-patchmap-frame-benchmark.json` - -## Result - -| Scenario | Baseline FPS | Experiment FPS | Delta | -|---|---:|---:|---:| -| draw+update animated bars | 52.43 | 38.43 | -26.7% | -| all bars every 1s x10 | 38.42 | 41.70 | +8.5% | -| wheel pan | 26.69 | 19.30 | -27.7% | -| ctrl wheel zoom | 36.00 | 17.80 | -50.6% | -| transformer select | 52.91 | 49.33 | -6.8% | -| shift drag multi select | 46.54 | 50.58 | +8.7% | -| panel mixed state burst | 26.57 | 18.42 | -30.7% | -| panel chart stream | 12.46 | 10.08 | -19.1% | -| highlight alpha burst | 25.75 | 19.14 | -25.7% | -| report backgrounds burst | 28.96 | 26.22 | -9.5% | - -## Decision - -Rejected. Packed color and the reduced piece count did not offset mesh update overhead. Most interaction and streaming scenarios regressed. From 833ab7ab0c9d89ab582a134dbcc9a95e692bc3ca Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:22:50 +0900 Subject: [PATCH 23/51] docs: define patchmap cleanroom feature contracts --- docs/architecture/cleanroom-renderer.md | 158 +++++++++++++++ docs/features/README.md | 26 +++ docs/features/data-schema.md | 187 ++++++++++++++++++ docs/features/draw-update-selector.md | 102 ++++++++++ .../features/interaction-state-transformer.md | 133 +++++++++++++ docs/features/non-goals.md | 35 ++++ docs/features/patch-service-compatibility.md | 80 ++++++++ docs/features/performance-contracts.md | 65 ++++++ docs/features/public-api.md | 96 +++++++++ docs/features/rendering-semantics.md | 92 +++++++++ 10 files changed, 974 insertions(+) create mode 100644 docs/architecture/cleanroom-renderer.md create mode 100644 docs/features/README.md create mode 100644 docs/features/data-schema.md create mode 100644 docs/features/draw-update-selector.md create mode 100644 docs/features/interaction-state-transformer.md create mode 100644 docs/features/non-goals.md create mode 100644 docs/features/patch-service-compatibility.md create mode 100644 docs/features/performance-contracts.md create mode 100644 docs/features/public-api.md create mode 100644 docs/features/rendering-semantics.md diff --git a/docs/architecture/cleanroom-renderer.md b/docs/architecture/cleanroom-renderer.md new file mode 100644 index 00000000..0944d526 --- /dev/null +++ b/docs/architecture/cleanroom-renderer.md @@ -0,0 +1,158 @@ +# Clean-room Renderer Architecture + +This is the target architecture for the rewrite. + +## Goal + +Replace the current DisplayObject-first implementation with a data/model-first +engine: + +```text +public API / patch-service update + -> normalized model + -> indexes + -> render IR diff + -> feature renderers + -> scheduled GPU/scene updates +``` + +PixiJS remains the rendering backend. Patch-map model state becomes the source of +truth. + +## Layers + +### 1. Compatibility API Layer + +Responsibilities: + +- Keep `Patchmap`, `Transformer`, `StateManager`, `UndoRedoManager`, events, + `draw`, `update`, `selector`, `focus`, and `fit` compatible. +- Convert selector result wrappers and legacy DisplayObject-like objects into + model ids. +- Keep existing callback target shape. + +### 2. Model Layer + +Responsibilities: + +- Normalize draw data into stable node records. +- Maintain parent/children relationships independent of Pixi children. +- Store element/component props, layout state, transform state, visibility, + locked flags, generated grid cell ids, and relation endpoints. +- Support update merge/replace/refresh semantics. + +Indexes: + +- id -> node +- type -> nodes +- display -> nodes +- parent -> children +- selectable nodes +- relation endpoint dependencies +- dirty render features + +### 3. Layout Layer + +Responsibilities: + +- Resolve group/grid/item geometry. +- Resolve component size, margin, placement, padding, and orientation. +- Produce render-space rectangles and oriented bounds. +- Produce hit-test bounds and focus/fit bounds. + +### 4. Render IR + +Render IR is a compact backend-neutral description of visible output. + +Feature groups: + +- item backgrounds +- bars +- icons +- item text +- standalone rects +- standalone text +- images +- relations +- transformer overlays +- selection overlays + +Each IR record carries: + +- logical node id +- feature type +- z/layer order +- geometry +- material/style key +- alpha/visibility +- update generation + +### 5. Renderer Policy Layer + +Renderer policy selects the cheapest correct renderer by workload: + +- Static or mostly static rounded rect groups can use cached textures or + aggregate particle/mesh renderers. +- Bulk bar animation can use a renderer optimized for contiguous batch updates. +- Sparse chart stream updates must avoid uploading or rewriting full buffers. +- Highlight/dim alpha changes should use group alpha/material indirection when + possible instead of per-piece traversal. +- Text/icon/effects can fall back to Pixi objects until a specialized renderer is + justified. + +Rejected global replacement strategies must not be reintroduced without passing +all benchmark scenarios. + +### 6. Scheduler + +Responsibilities: + +- Coalesce update bursts. +- Separate synchronous model changes from render work. +- Maintain frame budget for render uploads. +- Cancel superseded animations. +- Merge repeated updates for the same node before render flush. +- Support immediate correctness for reads/callbacks after `update()`. + +### 7. Hit Test and Interaction Layer + +Responsibilities: + +- Use model/layout spatial indexes for selection and drag selection. +- Avoid full scene graph traversal for common pointer queries. +- Preserve selection units: entity, closestGroup, highestGroup, grid. +- Preserve drill-down and deep-select behavior. + +### 8. Pixi Backend + +Responsibilities: + +- Own minimal Pixi containers/layers. +- Maintain viewport, world compatibility root, transformer/selection overlays. +- Upload render IR changes to Pixi objects, ParticleContainers, Meshes, or custom + renderers. + +## Migration Strategy + +1. Freeze official feature docs and contract tests. +2. Add model/index layer behind existing draw/update while still rendering with + current DisplayObjects. +3. Route selector/focus/fit through indexes where possible. +4. Introduce render IR for panel bars first, with renderer policy split for bulk + vs sparse updates. +5. Move backgrounds and relations to feature renderers. +6. Move selection hit testing to spatial index. +7. Replace remaining DisplayObject-first paths once tests and benchmarks pass. + +## Design Decisions + +Confirmed for the clean-room rewrite: + +- Arbitrary Pixi DisplayObject mutation is not an official compatibility goal. +- Selector results and callback targets may become compatibility wrappers rather + than Pixi subclasses, as long as official fields and methods keep working. +- Patch-service optimized paths are first-class API contracts. + +Still requires a future product/design decision before exposing publicly: + +- Whether no-radius bar mode should become an opt-in performance style. diff --git a/docs/features/README.md b/docs/features/README.md new file mode 100644 index 00000000..561c52e1 --- /dev/null +++ b/docs/features/README.md @@ -0,0 +1,26 @@ +# Patch-map Official Feature Contract + +This directory fixes the official behavior that the clean-room rewrite must preserve. + +The rewrite may replace the internal PixiJS scene graph, renderer layout, display object +classes, animation scheduler, indexing strategy, and patch-service optimization paths. +It must keep the public library behavior described here. + +## Feature Documents + +- [Public API](./public-api.md) +- [Data Schema](./data-schema.md) +- [Draw, Update, Selector](./draw-update-selector.md) +- [Rendering Semantics](./rendering-semantics.md) +- [Interaction, State, Transformer](./interaction-state-transformer.md) +- [Patch-service Compatibility](./patch-service-compatibility.md) +- [Performance Contracts](./performance-contracts.md) +- [Non-goals and Pixi Native Compatibility](./non-goals.md) + +## Compatibility Principle + +Patch-map is a map-rendering library built on PixiJS, not a general PixiJS +DisplayObject framework. Existing official APIs stay compatible. Direct user +mutation of internal Pixi DisplayObjects is not a design constraint for the new +engine unless the mutation is part of an official API listed in this directory. + diff --git a/docs/features/data-schema.md b/docs/features/data-schema.md new file mode 100644 index 00000000..471d20e8 --- /dev/null +++ b/docs/features/data-schema.md @@ -0,0 +1,187 @@ +# Data Schema + +The clean-room rewrite must accept the same public map data shape and update +shape. + +## Top-level Data + +`draw(data)` accepts an array of elements. + +Supported top-level element types: + +- `group` +- `grid` +- `item` +- `relations` +- `image` +- `text` +- `rect` + +Every element supports: + +- `type` +- `id`, default generated id +- `label` +- `show`, default `true` +- `locked`, default `false` +- `attrs`, arbitrary object applied to transform and custom searchable fields + +Duplicate ids are invalid after schema validation. + +## Element Types + +### `group` + +Fields: + +- `children: Element[]` + +Behavior: + +- Groups child elements and applies group transform/visibility. +- Selector paths traverse through `children`. + +### `grid` + +Fields: + +- `cells: (0 | 1 | string)[][]` +- `inactiveCellStrategy: 'destroy' | 'hide'`, default `destroy` +- `gap: number | { x?: number, y?: number }` +- `item.size` +- `item.padding` +- `item.components` +- `item.contentOrientation: 'follow-item' | 'upright'`, default `upright` + +Behavior: + +- Creates addressable item cells for active cells. +- Generated cell ids follow the existing `..` pattern. +- String cell values are accepted by the schema and must remain searchable. + +### `item` + +Fields: + +- `size` +- `padding` +- `components` +- `contentOrientation: 'follow-item' | 'upright'`, default `upright` + +Behavior: + +- Renders a component stack inside the item bounds. +- Item components are addressable by type/id/label where supported. + +### `relations` + +Fields: + +- `links: { source: string, target: string }[]` +- `style` + +Behavior: + +- Draws links between elements by id. +- Refreshing relations recalculates link endpoints. +- Relations are excluded from default `focus()` and `fit()` target sets. + +### `image` + +Fields: + +- `source: string` +- `size?` + +### `text` + +Fields: + +- `text`, default empty string +- `style` +- `size?` + +### `rect` + +Fields: + +- `size` +- `fill` +- `stroke` +- `radius` + +## Components + +Supported item component types: + +- `background` +- `bar` +- `icon` +- `text` + +All components support: + +- `type` +- `id` +- `label` +- `show`, default `true` +- `attrs` + +### `background` + +Fields: + +- `source: TextureStyle | string` +- `size`, normalized to 100% item size +- `tint` + +### `bar` + +Fields: + +- `source: TextureStyle` +- `size` +- `placement`, default `bottom` +- `margin`, default `0` +- `tint` +- `animation`, default `true` +- `animationDuration`, default `200` + +### `icon` + +Fields: + +- `source: string` +- `size` +- `placement`, default `center` +- `margin`, default `0` +- `tint` + +### `text` + +Fields: + +- `text`, default empty string +- `placement`, default `center` +- `margin`, default `0` +- `tint` +- `style` +- `split`, default `0` + +## Primitive Normalization + +The rewrite must preserve: + +- `Size`: number or `{ width, height }` +- `PxOrPercent`: number, percentage string, or `{ value, unit }` +- `PxOrPercentSize`: number/string or `{ width, height }` +- `Margin`: number, `{ x, y }`, `{ top, right, bottom, left }`, or mixed axis + and edge keys +- `Gap`: number or `{ x, y }` +- `Placement`: `left`, `left-top`, `left-bottom`, `top`, `right`, + `right-top`, `right-bottom`, `bottom`, `center`, `none` +- Rect texture style: `{ type: 'rect', fill, borderWidth, borderColor, radius }` +- Theme token colors such as `primary.default` and direct CSS/hex colors + +Spacing normalization applies in both `draw(data)` and `update({ changes })`. + diff --git a/docs/features/draw-update-selector.md b/docs/features/draw-update-selector.md new file mode 100644 index 00000000..f7f283d5 --- /dev/null +++ b/docs/features/draw-update-selector.md @@ -0,0 +1,102 @@ +# Draw, Update, Selector + +## `draw(data)` + +Required behavior: + +- Accepts current map data schema. +- Converts legacy data when needed. +- Validates schema unless reusing already processed data. +- Clears previous managed scene when new data differs. +- Reuses the current scene when drawing identical data and all existing world + children are managed patch-map children. +- Clears undo/redo history. +- Reverts animation context for the previous scene. +- Removes registered canvas events. +- Creates scene indexes used by selector fast paths. +- Schedules a relations refresh after initial draw. +- Warms find/hit-test bounds cache. +- Emits `patchmap:draw` asynchronously after the scene is visible. +- Returns validated/processed data. + +## `update(options)` + +Supported options: + +- `path?: string` +- `elements?: object | object[]` +- `changes?: object` +- `history?: boolean | string` +- `relativeTransform?: boolean` +- `rotateOrigin?: 'center'` +- `mergeStrategy?: 'merge' | 'replace'` +- `refresh?: boolean` +- `validateSchema?: boolean` +- `normalize?: boolean` +- `emit?: boolean` + +Required behavior: + +- Updates direct `elements`, selector `path` results, or both. +- Returns the updated element list. +- Emits `patchmap:updated` unless `emit === false`. +- `history: true` records undo/redo history with a generated id. +- `history: string` merges updates with the same history id. +- `relativeTransform` adds numeric `attrs.x`, `attrs.y`, `attrs.rotation`, and + `attrs.angle` to current values. +- `rotateOrigin: 'center'` preserves the visible center when applying angle or + rotation changes. +- `mergeStrategy: 'merge'` deep-merges objects. +- `mergeStrategy: 'replace'` replaces top-level changed properties. +- `refresh: true` reruns property handlers even if values are unchanged. +- `validateSchema: false` supports fast patch-service update paths. + +## Patch-service Panel Update Fast Path + +The rewrite must preserve behavior for item component updates shaped like: + +```js +{ + components: [ + { type: 'bar', ... }, + { type: 'icon', show: false }, + { type: 'text', show: false }, + ] +} +``` + +and for bar-only final states where icon/text are absent or hidden. + +Required behavior: + +- Bar state updates may be rendered by an aggregate renderer. +- The logical component object must keep updated `props`. +- `bar.renderable` may be false while an aggregate renderer draws it. +- Showing icon or text must restore normal component rendering. +- Background + bar updates must keep aggregate bars when final state only shows + the bar. +- Alpha changes on items must propagate to aggregate bars. + +## `selector(path, options)` + +Required behavior: + +- Root `$` points to patch-map `world`. +- JSONPath-style traversal works for `children`. +- Exact id paths return stable results. +- Exact type/display paths use scene indexes where possible. +- Selector result objects expose stable official fields: + - `type` + - `id` + - `label` + - `props` + - `children` + - transform/display fields used by public callbacks + +Official selector compatibility includes existing patch-service paths: + +- `$..children[?(@.display=="panelGroup")].children` +- `$..children[?(@.display=="inverter")]` +- `$..children[?(@.type==="relations")]` +- `$..[?(@.id=="...")]` + diff --git a/docs/features/interaction-state-transformer.md b/docs/features/interaction-state-transformer.md new file mode 100644 index 00000000..242cfa9d --- /dev/null +++ b/docs/features/interaction-state-transformer.md @@ -0,0 +1,133 @@ +# Interaction, State, Transformer + +## Canvas Events + +`patchmap.event` must support: + +- `add(options)` +- `remove(id)` +- `removeAll()` +- `on(idOrIds)` +- `off(idOrIds)` +- `get(id)` +- `getAll()` + +Event options: + +- `id?: string` +- `path: string` +- `action: string` +- `fn: function` + +Required behavior: + +- `path: '$'` targets the viewport/canvas surface, including empty-canvas + pointer events. +- Other paths resolve from `world`. +- Multiple action names can be registered in one string. +- Multiple ids can be activated/deactivated/removed in one string. + +## StateManager + +Required behavior: + +- `register(name, StateClassOrObject, isSingleton = true)` +- `setState(name, ...args)` +- `resetState()` +- `pushState(name, ...args)` +- `popState(payload?)` +- `getCurrentState()` +- `activateModifier(name, ...args)` +- `deactivateModifier()` +- `destroy()` + +Events: + +- `state:pushed` +- `state:popped` +- `state:set` +- `state:reset` +- `state:destroyed` +- `modifier:activated` +- `modifier:deactivated` +- wildcard namespace events + +## State Base Class + +Required behavior: + +- Static `handledEvents`. +- Lifecycle methods: `enter`, `exit`, `pause`, `resume`, `destroy`. +- `PROPAGATE_EVENT` allows state event propagation. +- `abortController` resets on enter and aborts on exit. + +## SelectionState + +Default state registered as `selection`. + +Supported options: + +- `draggable` +- `paintSelection` +- `selectUnit`: `entity`, `closestGroup`, `highestGroup`, `grid` +- `drillDown` +- `deepSelect` +- `filter` +- `selectionBoxStyle.fill` +- `selectionBoxStyle.stroke` + +Callbacks: + +- `onDown` +- `onUp` +- `onClick` +- `onDoubleClick` +- `onRightClick` +- `onDragStart` +- `onDrag` +- `onDragEnd` +- `onOver` + +Required behavior: + +- Click and tap route to click handling. +- Double-click fires `onDoubleClick` and does not also fire `onClick`. +- Right-click prevents the browser canvas context menu. +- Drag selection supports rectangle selection. +- Paint selection supports freeform path/segment selection. +- Shift-drag multi-select behavior remains supported in benchmark scenarios. +- Ctrl/Meta deep selection can select deeper entities regardless of configured + select unit. +- Selection hit testing honors `filter`. + +## Transformer + +Supported constructor options: + +- `elements` +- `wireframeStyle.thickness` +- `wireframeStyle.color` +- `boundsDisplayMode`: `all`, `groupOnly`, `elementOnly`, `none` +- `resizeHandles` +- `rotateHandles` +- `transformHistory` +- `resizeKeepRatio` +- `getResizeKeepRatio` + +Required behavior: + +- Assigning `patchmap.transformer` activates the transformer. +- `transformer.elements` sets selected elements. +- `transformer.selection.add/remove/set` updates selection. +- Emits `update_elements` with `{ current, added, removed }`. +- Single rotated object uses oriented frame. +- Multi-object selection uses axis-aligned group frame. +- Resize handles can resize supported selections. +- Rotate handles rotate supported element types: grid, item, rect, image, text. +- Relations and groups are not mutated by transformer rotation. +- Locked elements remain selected but are not mutated. +- Shift rotation snaps delta to 15 degrees. +- Existing `attrs.rotation` users keep writing `rotation`; otherwise rotation + gestures write `angle`. +- `transformHistory: true` groups one gesture into one undo/redo entry. + diff --git a/docs/features/non-goals.md b/docs/features/non-goals.md new file mode 100644 index 00000000..3ef94029 --- /dev/null +++ b/docs/features/non-goals.md @@ -0,0 +1,35 @@ +# Non-goals and Pixi Native Compatibility + +## Non-goals + +The clean-room rewrite does not need to preserve arbitrary user mutation of +internal Pixi DisplayObjects. + +Not official compatibility requirements: + +- Users adding/removing arbitrary children under internal element containers. +- Users depending on specific Pixi subclasses for item/background/bar/icon/text. +- Users depending on exact internal layer child counts. +- Users mutating internal `Particle`, `Graphics`, `Sprite`, `Text`, or + `NineSliceSprite` instances directly. +- Users depending on private fields such as aggregate renderer internals, except + where tests temporarily inspect them for benchmark/contract validation. + +## Compatibility Still Required + +The following remain official: + +- `patchmap.app` exists after init. +- `patchmap.viewport` exists and supports documented viewport plugin operations. +- `patchmap.world` exists as selector root and event/focus/fit compatibility + root. +- Objects returned from `selector()` and callbacks expose documented fields and + can be passed back to `update({ elements })`, transformer selection, focus/fit + where supported. +- Public events and callbacks receive compatible target objects. + +## Design Consequence + +The rewrite may introduce logical node wrappers and render IR records. PixiJS can +be treated as an output backend, not the source of truth for library state. + diff --git a/docs/features/patch-service-compatibility.md b/docs/features/patch-service-compatibility.md new file mode 100644 index 00000000..2d7bdf88 --- /dev/null +++ b/docs/features/patch-service-compatibility.md @@ -0,0 +1,80 @@ +# Patch-service Compatibility + +Patch-service usage is a first-class contract for the rewrite. + +## Stable Selector Paths + +Required paths include: + +- `$..children[?(@.display=="panelGroup")].children` +- `$..children[?(@.display=="inverter")]` +- `$..children[?(@.type==="relations")]` +- `$..children[?(@.display=="...")]` +- `$..children[?(@.type=="...")]` +- `$..[?(@.id=="...")]` + +The rewrite must preserve: + +- Generated grid item ids. +- `attrs.display` indexing and updates. +- Type indexing after updates. +- Relations path stability. + +## Panel Bar Workflows + +Patch-service frequently updates all or many panel items with: + +```js +patchmap.update({ + elements: item, + changes: { + components: [ + { + type: 'bar', + show: true, + size: { height: '64%' }, + tint: '#2563eb', + animation: true, + }, + { type: 'icon', show: false }, + { type: 'text', show: false }, + ], + }, + validateSchema: false, + emit: false, +}); +``` + +Required behavior: + +- Final bar-only state can use aggregate rendering. +- Hidden icon/text remain hidden and do not draw over bars. +- If icon/text later become visible, normal component rendering is restored. +- Background + bar updates keep aggregate rendering when final visible state is + compatible. +- Bar alpha follows item alpha in bulk highlight/dim workflows. +- Bar animation is smooth and visible. +- Aggregate layer ordering remains between item backgrounds and relations. + +## Background and Relations Updates + +Required behavior: + +- Report-style background color changes must update visible backgrounds. +- Relations visibility/path refresh updates must not break selector indexes. +- Relations remain above backgrounds and bars. + +## Compatibility Objects + +Objects returned from `selector()` and callbacks must expose: + +- `id` +- `label` +- `type` +- `props` +- `children` +- transform/display properties used by documented APIs + +They do not need to be stable Pixi native DisplayObject subclasses in the new +engine. Compatibility wrappers are acceptable. + diff --git a/docs/features/performance-contracts.md b/docs/features/performance-contracts.md new file mode 100644 index 00000000..241cace9 --- /dev/null +++ b/docs/features/performance-contracts.md @@ -0,0 +1,65 @@ +# Performance Contracts + +The clean-room rewrite is performance-driven. It must preserve behavior and +improve frame smoothness on lower-end machines. + +## Benchmark Harness + +Use: + +```sh +PATCHMAP_BENCH_CPU_THROTTLE=4 node .gstack/benchmark-harness/run-patchmap-frame-benchmark.mjs +``` + +Reports are local `.gstack` artifacts and remain ignored by git. + +## Required Scenarios + +The rewrite must be measured against: + +- idle baseline after draw +- redraw same data with fit +- draw + update all panel animated bar heights +- update all panel animated bar heights every 1s x10 +- wheel canvas pan +- ctrl + wheel zoom +- transformer single object select +- shift drag multi select +- panel mixed state burst +- panel chart stream +- highlight bulk alpha burst +- relations visibility by path +- report panel backgrounds burst + +## Target + +Primary target: + +- Reduce p95 frame time and long frames under 4x CPU throttle. +- Keep interaction and animation visually smooth near 60Hz where the dataset and + browser allow. +- Avoid improving one bulk scenario by regressing chart stream or alpha burst. + +## Lessons From Rejected Experiments + +Rejected experiments are preserved in git history as record/revert commit pairs. + +Observed constraints: + +- CPU mesh can improve bulk animation, pan, and zoom, but naive full replacement + regresses chart stream and alpha burst. +- GPU-side animation alone regresses most scenarios due to shader/attribute + overhead. +- Packed color CPU mesh did not offset mesh update cost. +- Removing radius/slice helps some bulk and pan cases but regresses chart stream + and highlight alpha. +- Rebuilding `particleChildren` for naive viewport culling costs more than it + saves. + +Implication: + +- The final design must separate model diffing, renderer policy, update + scheduling, sparse updates, bulk updates, alpha groups, and culling data + structures. +- A single renderer strategy for every workload is not sufficient. + diff --git a/docs/features/public-api.md b/docs/features/public-api.md new file mode 100644 index 00000000..4e7484e3 --- /dev/null +++ b/docs/features/public-api.md @@ -0,0 +1,96 @@ +# Public API + +The clean-room rewrite must preserve these public exports and Patchmap instance +behaviors. + +## Module Exports + +- `Patchmap` +- `Transformer` +- `Command` +- `UndoRedoManager` +- `State` +- `PROPAGATE_EVENT` +- `selector` +- `convertLegacyData` +- Existing exports from `src/utils/index.js` + +## Patchmap Lifecycle + +### `new Patchmap()` + +Creates an uninitialized patch-map instance. + +### `init(element, options)` + +Initializes the instance once. + +Supported option groups: + +- `app`: Pixi Application options accepted by the current implementation. +- `viewport`: viewport options and `plugins`. +- `theme`: theme token overrides. +- `assets`: asset bundle/asset definitions. +- `transformer`: optional `Transformer` instance. + +Required behavior: + +- Creates an application, viewport, world, state manager, undo/redo manager, and + default selection state. +- Emits `patchmap:initialized` after successful initialization. +- Calling `init()` after initialization is a no-op. +- Default viewport plugins include clamp zoom, drag, wheel, pinch, and decelerate + unless overridden. + +### `destroy()` + +Required behavior: + +- Removes canvas events. +- Destroys state manager and undo/redo manager. +- Destroys viewport/application resources. +- Disconnects resize observer. +- Resets instance fields so a later `init()` can create a fresh instance. +- Emits `patchmap:destroyed`. + +## Patchmap Properties + +- `app`: current Pixi Application object. Public for compatibility, but the + rewrite does not guarantee stable internal children. +- `viewport`: viewport object. Official support is limited to documented + viewport plugin methods and coordinate/event behavior. +- `world`: root map object for selector compatibility. Direct mutation is not an + official feature. +- `theme`: resolved theme object. +- `isInit`: initialization state. +- `undoRedoManager`: history manager. +- `transformer`: assignable `Transformer` instance or `null`. +- `stateManager`: state manager instance. +- `animationContext`: GSAP context compatibility surface. The rewrite may remove + GSAP internally if this remains behavior-compatible for public usage. +- `event`: event management facade. +- `rotation`: world view rotation controller. +- `flip`: world view flip controller. + +## Patchmap Methods + +- `draw(data)`: validates and renders map data. +- `update(options)`: applies changes to selected elements. +- `focus(ids, options)`: moves viewport center to target bounds. +- `fit(ids, options)`: moves viewport and fits target bounds. +- `selector(path, options)`: JSONPath-style object lookup rooted at `world`. + +## Events + +Patchmap emits: + +- `patchmap:initialized` +- `patchmap:draw` +- `patchmap:updated` +- `patchmap:destroyed` +- `patchmap:rotated` +- `patchmap:flipped` + +The event emitter supports exact event names and wildcard namespaces through the +existing `WildcardEventEmitter` behavior. + diff --git a/docs/features/rendering-semantics.md b/docs/features/rendering-semantics.md new file mode 100644 index 00000000..444db9f0 --- /dev/null +++ b/docs/features/rendering-semantics.md @@ -0,0 +1,92 @@ +# Rendering Semantics + +The rewrite may replace the internal Pixi display tree. It must preserve visual +and logical behavior. + +## Layer Order + +Required relative order: + +- User map groups/items/backgrounds render first. +- Aggregate panel bar layer, when used, renders above item backgrounds. +- Relations render above backgrounds and aggregate bars. +- Transformer and transient interaction overlays render above map content. + +The previously validated contract is: + +`group/item content < aggregate panel bar layer < relations < transformer` + +## Element Rendering + +### Groups + +- Apply transform/visibility to descendants. +- Contribute descendants to focus/fit bounds. + +### Grids + +- Layout active cells by row/column, item size, padding, and gap. +- Generated item ids and selector paths remain stable. +- Inactive cells obey `inactiveCellStrategy`. + +### Items + +- Render components according to component order. +- Component placement/margin/size are resolved relative to item bounds. +- `contentOrientation: 'upright'` keeps inner text/icon/bar readable under item + angle and world rotation/flip. +- `contentOrientation: 'follow-item'` follows item angle. + +### Backgrounds + +- Support rect texture style and string image/asset sources. +- Rect background supports fill, border, and radius. + +### Bars + +- Support rect texture style, tint, placement, margin, percent/px size, and + animation. +- Rounded bars must render without visibly distorted corners when radius is + enabled. +- Bar animation changes height/size smoothly over `animationDuration`. +- `animation: false` applies changes immediately. + +### Icons + +- Support asset/source keys, tint, placement, margin, and size. + +### Text + +- Item text supports style, split, tint, placement, margin, auto font behavior, + word wrap width, and overflow modes. +- Standalone text supports text, style, size, and transforms. + +### Relations + +- Resolve source/target ids. +- Recalculate endpoints on refresh and linked object transform changes. +- Support style fields compatible with existing stroke behavior. + +## View Transform + +World rotation and flip must preserve: + +- `patchmap.rotation.value` +- `patchmap.rotation.rotateBy()` +- `patchmap.rotation.reset()` +- `patchmap.flip.x/y` +- `patchmap.flip.set()` +- `patchmap.flip.toggleX()` +- `patchmap.flip.toggleY()` +- `patchmap.flip.reset()` + +## Rendering Optimization Freedom + +Allowed internal replacements: + +- DisplayObject-per-component can be replaced by render IR nodes. +- Background/bar/relation/text/icon renderers can be feature-specific. +- Aggregate GPU/mesh/particle layers can replace individual Pixi objects. +- Scheduler can batch and reorder internal GPU uploads as long as public update + order and callbacks remain compatible. + From 8cf49305f1d12cb54142e60ce6ac6c5bcac52328 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:23:52 +0900 Subject: [PATCH 24/51] test: cover patch-service aggregate bar stream contracts --- .../render/patch-service-contract.test.js | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/src/tests/render/patch-service-contract.test.js b/src/tests/render/patch-service-contract.test.js index 1a9f6fab..94a38983 100644 --- a/src/tests/render/patch-service-contract.test.js +++ b/src/tests/render/patch-service-contract.test.js @@ -417,6 +417,99 @@ describe('patch-service plant map contract', () => { expect(patchmap.selector(RELATIONS_PATH)[0].renderable).toBe(false); }); + it('keeps aggregate bar alpha in sync during bulk highlight updates', async () => { + const patchmap = getPatchmap(); + patchmap.draw(plantMapData); + await waitForScene(); + + const panelItems = patchmap.selector(PANEL_ITEM_PATH); + for (const item of panelItems) { + patchmap.update({ + elements: item, + changes: { + components: [ + { + type: 'bar', + show: true, + size: { height: '64%' }, + tint: '#2563eb', + animation: false, + }, + { type: 'icon', show: false }, + { type: 'text', show: false }, + ], + }, + validateSchema: false, + emit: false, + }); + } + await waitForScene(); + + const highlighted = panelItems.filter((_, index) => index % 2 === 0); + const dimmed = panelItems.filter((_, index) => index % 2 === 1); + patchmap.update({ + elements: highlighted, + changes: { attrs: { alpha: 1 } }, + validateSchema: false, + emit: false, + }); + patchmap.update({ + elements: dimmed, + changes: { attrs: { alpha: 0.5 } }, + validateSchema: false, + emit: false, + }); + await waitForScene(); + + for (const item of highlighted) { + expect(item.alpha).toBe(1); + expect(getAggregateEntry(item)?.particle.alpha).toBe(1); + } + for (const item of dimmed) { + expect(item.alpha).toBe(0.5); + expect(getAggregateEntry(item)?.particle.alpha).toBe(0.5); + } + }); + + it('keeps sparse panel chart stream updates addressable and aggregated', async () => { + const patchmap = getPatchmap(); + patchmap.draw(plantMapData); + await waitForScene(); + + const panelItems = patchmap.selector(PANEL_ITEM_PATH); + for (let frame = 0; frame < 8; frame += 1) { + for (let index = frame % 2; index < panelItems.length; index += 2) { + const percent = 12 + ((frame + index) % 7) * 11; + patchmap.update({ + elements: panelItems[index], + changes: { + components: [ + { + type: 'bar', + show: true, + size: { height: `${percent}%` }, + tint: percent > 50 ? '#0C73BF' : '#1099FF', + animation: true, + }, + { type: 'icon', show: false }, + { type: 'text', show: false }, + ], + }, + validateSchema: false, + emit: false, + }); + } + } + await waitForScene(260); + + for (const item of panelItems) { + const bar = getComponent(item, 'bar'); + expect(bar.renderable).toBe(false); + expect(getAggregateEntry(item)?.particle.alpha).toBeGreaterThan(0); + expect(bar.props.size.height.unit).toBe('%'); + } + }); + it('keeps transformer selection callbacks compatible with panel items', async () => { const patchmap = getPatchmap(); patchmap.transformer = new Transformer(); From 63be7a6a3a811c4c83ab5a2b2f0335511c7292a1 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:30:36 +0900 Subject: [PATCH 25/51] refactor: introduce logical scene index --- src/display/draw.js | 2 + src/display/mixins/Base.js | 3 + src/display/model/LogicalSceneIndex.js | 153 ++++++++++++++++++++ src/display/model/LogicalSceneIndex.test.js | 108 ++++++++++++++ 4 files changed, 266 insertions(+) create mode 100644 src/display/model/LogicalSceneIndex.js create mode 100644 src/display/model/LogicalSceneIndex.test.js diff --git a/src/display/draw.js b/src/display/draw.js index c530d169..6eef7763 100644 --- a/src/display/draw.js +++ b/src/display/draw.js @@ -1,3 +1,4 @@ +import { LogicalSceneIndex } from './model/LogicalSceneIndex'; import { SceneIndex } from './model/SceneIndex'; import { primePanelComponentCache } from './renderers/panelComponentRenderer'; @@ -22,6 +23,7 @@ const destroyChildren = (parent) => { const resetElementIndex = (store) => { const targetStore = store.world?.store ?? store; targetStore.sceneIndex = new SceneIndex(); + targetStore.modelIndex = new LogicalSceneIndex(); targetStore.elementById = targetStore.sceneIndex.elementById; }; diff --git a/src/display/mixins/Base.js b/src/display/mixins/Base.js index 0f81ac11..130c68e8 100644 --- a/src/display/mixins/Base.js +++ b/src/display/mixins/Base.js @@ -207,6 +207,7 @@ export const Base = (superClass) => { keysToProcess.some((key) => BOUNDS_SYNC_KEYS.has(key)) ) { this.store?.sceneIndex?.touch(); + this.store?.modelIndex?.touch(); } if (this.parent?._onChildUpdate) { @@ -344,6 +345,7 @@ export const Base = (superClass) => { elementById.set(this.id, this); } sceneIndex?.update(this, previousIndexKeys); + this.store?.modelIndex?.updateFromNode(this); } _removeFromStoreElementIndex() { @@ -352,6 +354,7 @@ export const Base = (superClass) => { elementById.delete(this.id); } this.store?.sceneIndex?.remove(this); + this.store?.modelIndex?.removeFromNode(this); } }; diff --git a/src/display/model/LogicalSceneIndex.js b/src/display/model/LogicalSceneIndex.js new file mode 100644 index 00000000..d5602df5 --- /dev/null +++ b/src/display/model/LogicalSceneIndex.js @@ -0,0 +1,153 @@ +export class LogicalSceneIndex { + constructor() { + this.recordsById = new Map(); + this.recordsByNode = new WeakMap(); + this.byType = new Map(); + this.byDisplay = new Map(); + this.childrenById = new Map(); + this.selectableIds = new Set(); + this.version = 0; + } + + addFromNode(node) { + const record = createLogicalNodeRecord(node); + if (!record) return; + + this.recordsByNode.set(node, record); + if (record.id) { + this.recordsById.set(record.id, record); + } + addToBucket(this.byType, record.type, record); + addToBucket(this.byDisplay, record.display, record); + if (record.selectable && record.id) { + this.selectableIds.add(record.id); + } + this.#addParentChild(record); + this.version++; + } + + updateFromNode(node) { + this.removeFromNode(node); + this.addFromNode(node); + } + + removeFromNode(node) { + const record = this.recordsByNode.get(node); + if (!record) return; + + if (record.id && this.recordsById.get(record.id)?.ref === node) { + this.recordsById.delete(record.id); + } + removeFromBucket(this.byType, record.type, record); + removeFromBucket(this.byDisplay, record.display, record); + if (record.id) { + this.selectableIds.delete(record.id); + } + this.#removeParentChild(record); + this.recordsByNode.delete(node); + this.version++; + } + + touch() { + this.version++; + } + + getById(id) { + return this.recordsById.get(id) ?? null; + } + + getRefById(id) { + return this.getById(id)?.ref ?? null; + } + + getByType(type) { + return [...(this.byType.get(type) ?? [])]; + } + + getRefsByType(type) { + return this.getByType(type).map((record) => record.ref); + } + + getByDisplay(display) { + return [...(this.byDisplay.get(display) ?? [])]; + } + + getRefsByDisplay(display) { + return this.getByDisplay(display).map((record) => record.ref); + } + + getChildren(parentId) { + const ids = this.childrenById.get(parentId); + if (!ids) return []; + return [...ids].map((id) => this.getById(id)).filter(Boolean); + } + + #addParentChild(record) { + if (!record.parentId || !record.id) return; + let childIds = this.childrenById.get(record.parentId); + if (!childIds) { + childIds = new Set(); + this.childrenById.set(record.parentId, childIds); + } + childIds.add(record.id); + } + + #removeParentChild(record) { + if (!record.parentId || !record.id) return; + const childIds = this.childrenById.get(record.parentId); + if (!childIds) return; + childIds.delete(record.id); + if (childIds.size === 0) { + this.childrenById.delete(record.parentId); + } + } +} + +export const createLogicalNodeRecord = (node) => { + if (!node?.type) return null; + const id = node.id; + const attrs = node.props?.attrs ?? {}; + return { + id, + type: node.type, + display: node.display ?? attrs.display, + label: node.label, + parentId: node.parent?.id, + childIds: getChildIds(node), + selectable: Boolean(node.constructor?.isSelectable), + attrs, + props: node.props, + ref: node, + }; +}; + +const getChildIds = (node) => { + if (!Array.isArray(node.children)) return []; + const ids = []; + for (const child of node.children) { + if (child?.id) { + ids.push(child.id); + } + } + return ids; +}; + +const addToBucket = (buckets, key, record) => { + if (key === undefined || key === null) return; + let bucket = buckets.get(key); + if (!bucket) { + bucket = new Set(); + buckets.set(key, bucket); + } + bucket.add(record); +}; + +const removeFromBucket = (buckets, key, record) => { + if (key === undefined || key === null) return; + const bucket = buckets.get(key); + if (!bucket) return; + bucket.delete(record); + if (bucket.size === 0) { + buckets.delete(key); + } +}; diff --git a/src/display/model/LogicalSceneIndex.test.js b/src/display/model/LogicalSceneIndex.test.js new file mode 100644 index 00000000..23989d04 --- /dev/null +++ b/src/display/model/LogicalSceneIndex.test.js @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { LogicalSceneIndex } from './LogicalSceneIndex'; + +const selectableType = { isSelectable: true }; +const inertType = { isSelectable: false }; + +const createNode = ({ + id, + type, + display, + parent = null, + selectable = false, + children = [], +}) => { + const node = { + id, + type, + display, + parent, + props: { + attrs: display ? { display } : {}, + }, + children, + constructor: selectable ? selectableType : inertType, + }; + for (const child of children) { + child.parent = node; + } + return node; +}; + +describe('LogicalSceneIndex', () => { + it('indexes node records by id, type, display, selectable id, and parent child contract', () => { + const index = new LogicalSceneIndex(); + const child = createNode({ + id: 'child-1', + type: 'item', + display: 'panelItem', + selectable: true, + }); + const parent = createNode({ + id: 'parent-1', + type: 'grid', + display: 'panelGroup', + children: [child], + }); + + index.addFromNode(parent); + index.addFromNode(child); + + expect(index.getById('child-1')).toMatchObject({ + id: 'child-1', + type: 'item', + display: 'panelItem', + parentId: 'parent-1', + selectable: true, + }); + expect(index.getRefsByType('item')).toEqual([child]); + expect(index.getRefsByDisplay('panelGroup')).toEqual([parent]); + expect(index.getChildren('parent-1').map((record) => record.id)).toEqual([ + 'child-1', + ]); + expect(index.selectableIds.has('child-1')).toBe(true); + }); + + it('moves updated records between buckets without leaving stale ids', () => { + const index = new LogicalSceneIndex(); + const node = createNode({ + id: 'item-1', + type: 'item', + display: 'panelItem', + selectable: true, + }); + + index.addFromNode(node); + node.id = 'item-2'; + node.display = 'panelItemActive'; + node.props.attrs.display = 'panelItemActive'; + index.updateFromNode(node); + + expect(index.getById('item-1')).toBe(null); + expect(index.getById('item-2')?.ref).toBe(node); + expect(index.getRefsByDisplay('panelItem')).toEqual([]); + expect(index.getRefsByDisplay('panelItemActive')).toEqual([node]); + expect(index.selectableIds.has('item-1')).toBe(false); + expect(index.selectableIds.has('item-2')).toBe(true); + }); + + it('removes records and parent child links', () => { + const index = new LogicalSceneIndex(); + const parent = createNode({ id: 'parent-1', type: 'grid' }); + const child = createNode({ + id: 'child-1', + type: 'item', + parent, + selectable: true, + }); + + index.addFromNode(parent); + index.addFromNode(child); + index.removeFromNode(child); + + expect(index.getById('child-1')).toBe(null); + expect(index.getRefsByType('item')).toEqual([]); + expect(index.getChildren('parent-1')).toEqual([]); + expect(index.selectableIds.has('child-1')).toBe(false); + }); +}); From a5953127e1cf58b4134286354f8083fafc65427f Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:32:55 +0900 Subject: [PATCH 26/51] refactor: route selectors through logical index --- src/utils/selector/selector.js | 29 +++++++++++--- src/utils/selector/selector.test.js | 61 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 src/utils/selector/selector.test.js diff --git a/src/utils/selector/selector.js b/src/utils/selector/selector.js index c64df49a..82ee1a00 100644 --- a/src/utils/selector/selector.js +++ b/src/utils/selector/selector.js @@ -16,21 +16,25 @@ export const selector = (json, path, options = {}) => { const selectFromSceneIndex = (json, path, options) => { if (options?.resultType || json?.type !== 'canvas') return null; + const modelIndex = json?.store?.modelIndex; const sceneIndex = json?.store?.sceneIndex; - if (!sceneIndex || typeof path !== 'string') return null; + if ((!modelIndex && !sceneIndex) || typeof path !== 'string') return null; const id = matchExactIdPath(path); if (id) { - const element = sceneIndex.getById(id); + const element = + modelIndex?.getRefById?.(id) ?? sceneIndex?.getById?.(id) ?? null; return element ? [element] : []; } const childrenPath = matchExactChildrenPath(path); if (childrenPath) { - const elements = - childrenPath.key === 'display' - ? sceneIndex.getByDisplay(childrenPath.value) - : sceneIndex.getByType(childrenPath.value); + const elements = getIndexedElements( + modelIndex, + sceneIndex, + childrenPath.key, + childrenPath.value, + ); if (childrenPath.children) { return elements.flatMap((element) => element.children ?? []); } @@ -40,6 +44,19 @@ const selectFromSceneIndex = (json, path, options) => { return null; }; +const getIndexedElements = (modelIndex, sceneIndex, key, value) => { + if (key === 'display') { + return ( + modelIndex?.getRefsByDisplay?.(value) ?? + sceneIndex?.getByDisplay?.(value) ?? + [] + ); + } + return ( + modelIndex?.getRefsByType?.(value) ?? sceneIndex?.getByType?.(value) ?? [] + ); +}; + const matchExactIdPath = (path) => { const match = path.match(/^\$..\[\?\(@\.id\s*={2,3}\s*(["'])([^"']+)\1\)\]$/); return match?.[2] ?? null; diff --git a/src/utils/selector/selector.test.js b/src/utils/selector/selector.test.js new file mode 100644 index 00000000..3dfcf0d1 --- /dev/null +++ b/src/utils/selector/selector.test.js @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; +import { selector } from './selector'; + +describe('selector indexed canvas paths', () => { + it('uses logical model index before scene index for exact id paths', () => { + const modelElement = { id: 'model-item' }; + const sceneElement = { id: 'scene-item' }; + const canvas = { + type: 'canvas', + store: { + modelIndex: { + getRefById: vi.fn(() => modelElement), + }, + sceneIndex: { + getById: vi.fn(() => sceneElement), + }, + }, + }; + + expect(selector(canvas, '$..[?(@.id=="item-1")]')).toEqual([modelElement]); + expect(canvas.store.modelIndex.getRefById).toHaveBeenCalledWith('item-1'); + expect(canvas.store.sceneIndex.getById).not.toHaveBeenCalled(); + }); + + it('uses logical model index for exact display children paths', () => { + const child = { id: 'child-1' }; + const panelGroup = { id: 'group-1', children: [child] }; + const canvas = { + type: 'canvas', + store: { + modelIndex: { + getRefsByDisplay: vi.fn(() => [panelGroup]), + }, + }, + }; + + expect( + selector(canvas, '$..children[?(@.display=="panelGroup")].children'), + ).toEqual([child]); + expect(canvas.store.modelIndex.getRefsByDisplay).toHaveBeenCalledWith( + 'panelGroup', + ); + }); + + it('falls back to scene index when the logical model index is unavailable', () => { + const element = { id: 'scene-item' }; + const canvas = { + type: 'canvas', + store: { + sceneIndex: { + getByType: vi.fn(() => [element]), + }, + }, + }; + + expect(selector(canvas, '$..children[?(@.type=="item")]')).toEqual([ + element, + ]); + expect(canvas.store.sceneIndex.getByType).toHaveBeenCalledWith('item'); + }); +}); From 75136da2227c76792256eeccaf0124b0f60d0782 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 10:58:54 +0900 Subject: [PATCH 27/51] feat: scaffold patchmap v2 engine core --- src/v2/PatchMapV2Engine.js | 55 +++++ src/v2/PatchMapV2Engine.test.js | 167 ++++++++++++++ src/v2/index.js | 6 + src/v2/layout/createV2Layout.js | 161 +++++++++++++ src/v2/model/V2ModelStore.js | 251 +++++++++++++++++++++ src/v2/model/createV2Model.js | 119 ++++++++++ src/v2/model/updateV2Model.js | 144 ++++++++++++ src/v2/render-ir/createV2RenderIR.js | 152 +++++++++++++ src/v2/render-policy/createV2RenderPlan.js | 90 ++++++++ 9 files changed, 1145 insertions(+) create mode 100644 src/v2/PatchMapV2Engine.js create mode 100644 src/v2/PatchMapV2Engine.test.js create mode 100644 src/v2/index.js create mode 100644 src/v2/layout/createV2Layout.js create mode 100644 src/v2/model/V2ModelStore.js create mode 100644 src/v2/model/createV2Model.js create mode 100644 src/v2/model/updateV2Model.js create mode 100644 src/v2/render-ir/createV2RenderIR.js create mode 100644 src/v2/render-policy/createV2RenderPlan.js diff --git a/src/v2/PatchMapV2Engine.js b/src/v2/PatchMapV2Engine.js new file mode 100644 index 00000000..e5be2593 --- /dev/null +++ b/src/v2/PatchMapV2Engine.js @@ -0,0 +1,55 @@ +import { createV2Layout } from './layout/createV2Layout'; +import { createV2Model } from './model/createV2Model'; +import { updateV2Model } from './model/updateV2Model'; +import { createV2RenderIR } from './render-ir/createV2RenderIR'; +import { createV2RenderPlan } from './render-policy/createV2RenderPlan'; + +export class PatchMapV2Engine { + constructor({ theme = {} } = {}) { + this.theme = theme; + this.model = null; + this.layout = null; + this.renderIR = null; + this.renderPlan = null; + this.revision = 0; + } + + draw(data) { + this.model = createV2Model(data); + this.#refreshDerivedState(); + return this.snapshot(); + } + + update(opts = {}) { + if (!this.model) return []; + const updated = updateV2Model(this.model, opts); + if (updated.length > 0) { + this.#refreshDerivedState(); + } + return updated.map((record) => record.ref); + } + + selector(path) { + if (!this.model) return []; + return this.model.selector(path).map((record) => record.ref); + } + + snapshot() { + return { + model: this.model, + layout: this.layout, + renderIR: this.renderIR, + renderPlan: this.renderPlan, + revision: this.revision, + }; + } + + #refreshDerivedState() { + this.layout = createV2Layout(this.model); + this.renderIR = createV2RenderIR(this.model, this.layout, { + theme: this.theme, + }); + this.renderPlan = createV2RenderPlan(this.model, this.renderIR); + this.revision += 1; + } +} diff --git a/src/v2/PatchMapV2Engine.test.js b/src/v2/PatchMapV2Engine.test.js new file mode 100644 index 00000000..40d0816b --- /dev/null +++ b/src/v2/PatchMapV2Engine.test.js @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; +import { PatchMapV2Engine } from './PatchMapV2Engine'; + +const createPanelData = () => [ + { + type: 'grid', + id: 'panel-grid', + attrs: { display: 'panelGroup', x: 10, y: 20 }, + cells: [ + ['A', 'B'], + [0, 'C'], + ], + gap: { x: 5, y: 7 }, + item: { + size: { width: 100, height: 40 }, + padding: 2, + components: [ + { + type: 'background', + source: { type: 'rect', fill: '#f8fafc', radius: 4 }, + }, + { + type: 'bar', + source: { type: 'rect', fill: '#38bdf8', radius: 3 }, + size: { width: '100%', height: '50%' }, + placement: 'bottom', + animation: true, + }, + { type: 'icon', source: 'wifi', show: false, size: 16 }, + { type: 'text', text: '', show: false, size: '100%' }, + ], + }, + }, +]; + +describe('PatchMapV2Engine', () => { + it('creates a normalized model with generated grid items and indexes', () => { + const engine = new PatchMapV2Engine(); + const { model } = engine.draw(createPanelData()); + + expect(model.get('panel-grid')).toMatchObject({ + id: 'panel-grid', + type: 'grid', + display: 'panelGroup', + }); + expect(model.get('panel-grid.0.0')).toMatchObject({ + id: 'panel-grid.0.0', + type: 'item', + parentId: 'panel-grid', + generated: true, + }); + expect(model.get('panel-grid.1.0')).toBe(null); + expect(model.getByDisplay('panelGroup').map((record) => record.id)).toEqual( + ['panel-grid'], + ); + expect( + model + .selector('$..children[?(@.display=="panelGroup")].children') + .map((record) => record.id), + ).toEqual(['panel-grid.0.0', 'panel-grid.0.1', 'panel-grid.1.1']); + }); + + it('builds render IR for panel background and aggregateable bars', () => { + const engine = new PatchMapV2Engine(); + const { renderIR, renderPlan } = engine.draw(createPanelData()); + + const bars = renderIR.byFeature.get('bar'); + const backgrounds = renderIR.byFeature.get('background'); + expect(bars).toHaveLength(3); + expect(backgrounds).toHaveLength(3); + expect(bars[0]).toMatchObject({ + feature: 'bar', + layer: 'bar', + ownerId: 'panel-grid.0.0', + material: { + animation: true, + animationDuration: 200, + }, + }); + expect(bars[0].frame).toMatchObject({ + x: 12, + y: 40, + width: 96, + height: 18, + }); + expect(renderPlan.aggregateBars).toHaveLength(3); + expect(renderPlan.aggregateBackgrounds).toHaveLength(3); + expect(renderPlan.stats.estimatedDisplayObjects).toBe(2); + }); + + it('applies patch-service style component updates to the model and IR', () => { + const engine = new PatchMapV2Engine(); + engine.draw(createPanelData()); + + const updated = engine.update({ + path: '$..[?(@.id=="panel-grid.0.0")]', + changes: { + components: [ + { + type: 'bar', + size: { height: '75%' }, + animation: true, + }, + { type: 'icon', show: false }, + { type: 'text', show: false }, + ], + }, + validateSchema: false, + }); + + expect(updated.map((element) => element.id)).toEqual(['panel-grid.0.0']); + const bar = engine.renderIR.byFeature + .get('bar') + .find((node) => node.ownerId === 'panel-grid.0.0'); + expect(bar.frame).toMatchObject({ + x: 12, + y: 31, + width: 96, + height: 27, + }); + }); + + it('supports patch-service type selector paths for relation refreshes', () => { + const engine = new PatchMapV2Engine(); + engine.draw([ + ...createPanelData(), + { + type: 'relations', + id: 'relations-1', + links: [{ source: 'panel-grid.0.0', target: 'panel-grid.0.1' }], + }, + ]); + + expect(engine.selector('$..[?(@.type=="relations")]')).toMatchObject([ + { id: 'relations-1', type: 'relations' }, + ]); + }); + + it('moves a panel owner back to Pixi fallback policy when icon becomes visible', () => { + const engine = new PatchMapV2Engine(); + engine.draw(createPanelData()); + + engine.update({ + path: '$..[?(@.id=="panel-grid.0.0")]', + changes: { + components: [ + { type: 'bar', size: { height: '75%' } }, + { type: 'icon', show: true }, + { type: 'text', show: false }, + ], + }, + validateSchema: false, + }); + + const fallbackNodeIds = engine.renderPlan.pixiNodes.map((node) => node.id); + const bar = engine.renderIR.byFeature + .get('bar') + .find((node) => node.ownerId === 'panel-grid.0.0'); + const icon = engine.renderIR.byFeature + .get('icon') + .find((node) => node.ownerId === 'panel-grid.0.0'); + + expect(engine.renderPlan.aggregateBars).toHaveLength(2); + expect(fallbackNodeIds).toContain(bar.id); + expect(fallbackNodeIds).toContain(icon.id); + }); +}); diff --git a/src/v2/index.js b/src/v2/index.js new file mode 100644 index 00000000..d5c1faea --- /dev/null +++ b/src/v2/index.js @@ -0,0 +1,6 @@ +export { createV2Layout } from './layout/createV2Layout'; +export { createV2Model } from './model/createV2Model'; +export { updateV2Model } from './model/updateV2Model'; +export { PatchMapV2Engine } from './PatchMapV2Engine'; +export { createV2RenderIR } from './render-ir/createV2RenderIR'; +export { createV2RenderPlan } from './render-policy/createV2RenderPlan'; diff --git a/src/v2/layout/createV2Layout.js b/src/v2/layout/createV2Layout.js new file mode 100644 index 00000000..eb45b576 --- /dev/null +++ b/src/v2/layout/createV2Layout.js @@ -0,0 +1,161 @@ +import { normalizeBoxSpacing } from '../../utils/spacing'; + +export const createV2Layout = (model) => { + const frames = new Map(); + frames.set(model.root.id, { + id: model.root.id, + x: 0, + y: 0, + width: 0, + height: 0, + rotation: 0, + alpha: 1, + visible: true, + }); + + for (const child of model.getChildren(model.root.id)) { + layoutNode(model, frames, child, frames.get(model.root.id)); + } + + return { + frames, + getFrame(id) { + return frames.get(id) ?? null; + }, + }; +}; + +const layoutNode = (model, frames, record, parentFrame) => { + const attrs = record.props.attrs ?? {}; + const size = resolveElementSize(record); + const frame = { + id: record.id, + parentId: record.parentId, + x: parentFrame.x + (attrs.x ?? 0), + y: parentFrame.y + (attrs.y ?? 0), + width: size.width, + height: size.height, + rotation: parentFrame.rotation + resolveRotation(attrs), + alpha: parentFrame.alpha * (attrs.alpha ?? 1), + visible: parentFrame.visible && record.show, + }; + frames.set(record.id, frame); + + if (record.type === 'item') { + for (const component of model.getComponents(record.id)) { + frames.set(component.id, layoutComponent(component, frame, record)); + } + } + + for (const child of model.getChildren(record.id)) { + if (child.kind === 'component') continue; + layoutNode(model, frames, child, frame); + } +}; + +const resolveElementSize = (record) => { + const size = record.props.size ?? record.props.item?.size; + return { + width: resolveNumber(size?.width), + height: resolveNumber(size?.height), + }; +}; + +const layoutComponent = (component, itemFrame, itemRecord) => { + const contentFrame = getItemContentFrame(itemFrame, itemRecord.props.padding); + const size = resolveComponentSize(component, contentFrame); + const placement = + component.props.placement ?? defaultPlacement(component.type); + const margin = normalizeBoxSpacing(component.props.margin ?? 0); + const point = resolvePlacement(placement, contentFrame, size, margin); + return { + id: component.id, + parentId: itemRecord.id, + x: point.x, + y: point.y, + width: size.width, + height: size.height, + rotation: itemFrame.rotation, + alpha: itemFrame.alpha, + visible: itemFrame.visible && component.show, + }; +}; + +const getItemContentFrame = (itemFrame, padding = 0) => { + const normalized = normalizeBoxSpacing(padding); + return { + x: itemFrame.x + normalized.left, + y: itemFrame.y + normalized.top, + width: Math.max(0, itemFrame.width - normalized.left - normalized.right), + height: Math.max(0, itemFrame.height - normalized.top - normalized.bottom), + }; +}; + +const resolveComponentSize = (component, contentFrame) => { + const size = component.props.size ?? { + width: { value: 100, unit: '%' }, + height: { value: 100, unit: '%' }, + }; + return { + width: resolvePxOrPercent(size.width ?? size, contentFrame.width), + height: resolvePxOrPercent(size.height ?? size, contentFrame.height), + }; +}; + +const resolvePlacement = (placement, contentFrame, size, margin) => { + const left = contentFrame.x + margin.left; + const right = contentFrame.x + contentFrame.width - size.width - margin.right; + const top = contentFrame.y + margin.top; + const bottom = + contentFrame.y + contentFrame.height - size.height - margin.bottom; + const centerX = contentFrame.x + (contentFrame.width - size.width) / 2; + const centerY = contentFrame.y + (contentFrame.height - size.height) / 2; + + switch (placement) { + case 'top': + return { x: centerX, y: top }; + case 'bottom': + return { x: centerX, y: bottom }; + case 'left': + return { x: left, y: centerY }; + case 'right': + return { x: right, y: centerY }; + case 'top-left': + return { x: left, y: top }; + case 'top-right': + return { x: right, y: top }; + case 'bottom-left': + return { x: left, y: bottom }; + case 'bottom-right': + return { x: right, y: bottom }; + default: + return { x: centerX, y: centerY }; + } +}; + +const resolveNumber = (value) => { + if (typeof value === 'number') return value; + if (value?.unit === 'px') return value.value; + if (typeof value === 'string') return Number.parseFloat(value) || 0; + return 0; +}; + +const resolvePxOrPercent = (value, parentSize) => { + if (typeof value === 'number') return value; + if (typeof value === 'string') { + return value.endsWith('%') + ? parentSize * (Number.parseFloat(value) / 100) + : Number.parseFloat(value) || 0; + } + if (value?.unit === '%') return parentSize * (value.value / 100); + if (value?.unit === 'px') return value.value; + return 0; +}; + +const resolveRotation = (attrs) => { + if (typeof attrs.rotation === 'number') return attrs.rotation; + if (typeof attrs.angle === 'number') return (attrs.angle * Math.PI) / 180; + return 0; +}; + +const defaultPlacement = (type) => (type === 'bar' ? 'bottom' : 'center'); diff --git a/src/v2/model/V2ModelStore.js b/src/v2/model/V2ModelStore.js new file mode 100644 index 00000000..ee4b5daa --- /dev/null +++ b/src/v2/model/V2ModelStore.js @@ -0,0 +1,251 @@ +export class V2ModelStore { + constructor() { + this.root = createRecord({ + id: '$root', + type: 'canvas', + kind: 'root', + parentId: null, + props: { type: 'canvas', id: '$root', children: [] }, + depth: 0, + }); + this.records = new Map([[this.root.id, this.root]]); + this.byType = new Map([['canvas', new Set([this.root.id])]]); + this.byDisplay = new Map(); + this.childrenById = new Map([[this.root.id, []]]); + this.componentsByParentId = new Map(); + this.selectableIds = new Set(); + this.revision = 0; + } + + add(recordInput) { + const record = createRecord(recordInput); + if (!record?.id) { + throw new Error(`v2 model record requires an id: ${record?.type}`); + } + if (this.records.has(record.id)) { + throw new Error(`Duplicate v2 model id: ${record.id}`); + } + + this.records.set(record.id, record); + addToBucket(this.byType, record.type, record.id); + addToBucket(this.byDisplay, record.display, record.id); + addChildId(this.childrenById, record.parentId, record.id); + if (record.kind === 'component') { + addChildId(this.componentsByParentId, record.parentId, record.id); + } + if (record.selectable) { + this.selectableIds.add(record.id); + } + this.revision += 1; + return record; + } + + replaceRecordProps(id, props) { + const record = this.records.get(id); + if (!record) return null; + + removeFromBucket(this.byType, record.type, id); + removeFromBucket(this.byDisplay, record.display, id); + const nextRecord = createRecord({ + ...record, + type: props.type ?? record.type, + props, + }); + this.records.set(id, nextRecord); + addToBucket(this.byType, nextRecord.type, id); + addToBucket(this.byDisplay, nextRecord.display, id); + this.revision += 1; + return nextRecord; + } + + remove(id) { + const record = this.records.get(id); + if (!record || record.id === this.root.id) return; + + for (const childId of this.childrenById.get(id) ?? []) { + this.remove(childId); + } + removeFromBucket(this.byType, record.type, id); + removeFromBucket(this.byDisplay, record.display, id); + removeChildId(this.childrenById, record.parentId, id); + removeChildId(this.componentsByParentId, record.parentId, id); + this.childrenById.delete(id); + this.componentsByParentId.delete(id); + this.selectableIds.delete(id); + this.records.delete(id); + this.revision += 1; + } + + get(id) { + return this.records.get(id) ?? null; + } + + getByType(type) { + return idsToRecords(this, this.byType.get(type)); + } + + getByDisplay(display) { + return idsToRecords(this, this.byDisplay.get(display)); + } + + getChildren(id) { + return idsToRecords(this, this.childrenById.get(id)); + } + + getComponents(id) { + return idsToRecords(this, this.componentsByParentId.get(id)); + } + + selector(path) { + if (typeof path !== 'string') return []; + + const id = matchExactIdPath(path); + if (id) { + const record = this.get(id); + return record ? [record] : []; + } + + const anyPath = matchExactAnyPath(path); + if (anyPath) { + return anyPath.key === 'display' + ? this.getByDisplay(anyPath.value) + : this.getByType(anyPath.value); + } + + const childrenPath = matchExactChildrenPath(path); + if (childrenPath) { + const records = + childrenPath.key === 'display' + ? this.getByDisplay(childrenPath.value) + : this.getByType(childrenPath.value); + if (childrenPath.children) { + return records.flatMap((record) => this.getChildren(record.id)); + } + return records; + } + + return []; + } +} + +export const createRecord = ({ + id, + type, + kind = 'element', + parentId = null, + props = {}, + depth = 0, + generated = false, + ref = null, +}) => { + const attrs = props.attrs ?? {}; + const record = { + id, + type, + kind, + parentId, + depth, + generated, + props, + attrs, + display: props.display ?? attrs.display, + label: props.label, + show: props.show !== false, + locked: props.locked === true, + selectable: isSelectableType(type, kind), + ref: null, + }; + record.ref = ref ?? createCompatibilityRef(record); + return record; +}; + +const createCompatibilityRef = (record) => ({ + id: record.id, + type: record.type, + label: record.label, + display: record.display, + props: record.props, + attrs: record.attrs, + _v2Record: record, +}); + +const isSelectableType = (type, kind) => + kind === 'element' && + (type === 'group' || + type === 'grid' || + type === 'item' || + type === 'rect' || + type === 'image' || + type === 'text' || + type === 'relations'); + +const addToBucket = (buckets, key, id) => { + if (key === undefined || key === null) return; + let bucket = buckets.get(key); + if (!bucket) { + bucket = new Set(); + buckets.set(key, bucket); + } + bucket.add(id); +}; + +const removeFromBucket = (buckets, key, id) => { + if (key === undefined || key === null) return; + const bucket = buckets.get(key); + if (!bucket) return; + bucket.delete(id); + if (bucket.size === 0) { + buckets.delete(key); + } +}; + +const addChildId = (childrenById, parentId, childId) => { + if (!parentId) return; + let children = childrenById.get(parentId); + if (!children) { + children = []; + childrenById.set(parentId, children); + } + children.push(childId); +}; + +const removeChildId = (childrenById, parentId, childId) => { + if (!parentId) return; + const children = childrenById.get(parentId); + if (!children) return; + const index = children.indexOf(childId); + if (index !== -1) { + children.splice(index, 1); + } +}; + +const idsToRecords = (store, ids) => + [...(ids ?? [])].map((id) => store.get(id)).filter(Boolean); + +const matchExactIdPath = (path) => { + const match = path.match(/^\$..\[\?\(@\.id\s*={2,3}\s*(["'])([^"']+)\1\)\]$/); + return match?.[2] ?? null; +}; + +const matchExactAnyPath = (path) => { + const match = path.match( + /^\$..\[\?\(@\.(display|type)\s*={2,3}\s*(["'])([^"']+)\2\)\]$/, + ); + if (!match) return null; + return { + key: match[1], + value: match[3], + }; +}; + +const matchExactChildrenPath = (path) => { + const match = path.match( + /^\$..children\[\?\(@\.(display|type)\s*={2,3}\s*(["'])([^"']+)\2\)\](\.children)?$/, + ); + if (!match) return null; + return { + key: match[1], + value: match[3], + children: Boolean(match[4]), + }; +}; diff --git a/src/v2/model/createV2Model.js b/src/v2/model/createV2Model.js new file mode 100644 index 00000000..97a9392e --- /dev/null +++ b/src/v2/model/createV2Model.js @@ -0,0 +1,119 @@ +import { + applyComponentDefaults, + applyElementDefaults, +} from '../../display/default-props'; +import { V2ModelStore } from './V2ModelStore'; + +export const createV2Model = (data) => { + const store = new V2ModelStore(); + const elements = Array.isArray(data) ? data : []; + const normalized = elements.map((element) => applyElementDefaults(element)); + + store.root.props.children = normalized; + for (const element of normalized) { + addElement(store, element, { + parentId: store.root.id, + depth: 1, + generated: false, + }); + } + return store; +}; + +const addElement = (store, props, context) => { + const record = store.add({ + id: props.id, + type: props.type, + kind: 'element', + parentId: context.parentId, + props, + depth: context.depth, + generated: context.generated, + }); + + if (props.type === 'group') { + for (const child of props.children ?? []) { + addElement(store, applyElementDefaults(child), { + parentId: props.id, + depth: context.depth + 1, + generated: false, + }); + } + return record; + } + + if (props.type === 'grid') { + addGridItems(store, props, context.depth + 1); + return record; + } + + if (props.type === 'item') { + addComponents(store, props, context.depth + 1); + } + + return record; +}; + +const addGridItems = (store, grid, depth) => { + const cells = grid.cells ?? []; + const gap = grid.gap ?? { x: 0, y: 0 }; + const itemTemplate = grid.item ?? {}; + const itemWidth = itemTemplate.size?.width ?? 0; + const itemHeight = itemTemplate.size?.height ?? 0; + + for (let rowIndex = 0; rowIndex < cells.length; rowIndex += 1) { + const row = cells[rowIndex] ?? []; + for (let colIndex = 0; colIndex < row.length; colIndex += 1) { + const cellValue = row[colIndex]; + const inactive = !cellValue; + if (inactive && grid.inactiveCellStrategy !== 'hide') continue; + + const id = `${grid.id}.${rowIndex}.${colIndex}`; + const itemProps = applyElementDefaults({ + type: 'item', + id, + ...cloneGridItemTemplate(itemTemplate), + label: String(cellValue), + attrs: { + ...(itemTemplate.attrs ?? {}), + gridIndex: { row: rowIndex, col: colIndex }, + x: colIndex * (itemWidth + gap.x), + y: rowIndex * (itemHeight + gap.y), + }, + show: !inactive, + }); + store.add({ + id, + type: 'item', + kind: 'element', + parentId: grid.id, + props: itemProps, + depth, + generated: true, + }); + addComponents(store, itemProps, depth + 1); + } + } +}; + +const addComponents = (store, itemProps, depth) => { + for (const component of itemProps.components ?? []) { + const componentProps = applyComponentDefaults(component); + store.add({ + id: componentProps.id, + type: componentProps.type, + kind: 'component', + parentId: itemProps.id, + props: componentProps, + depth, + generated: false, + }); + } +}; + +const cloneGridItemTemplate = (itemTemplate) => { + const next = structuredClone(itemTemplate); + if (!Array.isArray(next.components)) return next; + next.components = next.components.map(({ id, ...component }) => component); + return next; +}; diff --git a/src/v2/model/updateV2Model.js b/src/v2/model/updateV2Model.js new file mode 100644 index 00000000..2346b64f --- /dev/null +++ b/src/v2/model/updateV2Model.js @@ -0,0 +1,144 @@ +import { applyComponentDefaults } from '../../display/default-props'; +import { deepMerge } from '../../utils/deepmerge/deepmerge'; + +export const updateV2Model = (model, opts = {}) => { + const targets = resolveTargets(model, opts); + const changes = opts.changes ?? null; + if (!changes || targets.length === 0) return []; + + const updated = []; + for (const target of targets) { + const nextProps = + opts.mergeStrategy === 'replace' + ? { type: target.type, ...changes } + : deepMerge(structuredClone(target.props), changes, { + mergeStrategy: 'merge', + }); + const nextRecord = model.replaceRecordProps(target.id, nextProps); + if (!nextRecord) continue; + if (Array.isArray(changes.components) && nextRecord.type === 'item') { + updateComponents(model, nextRecord, changes.components, opts); + } + updated.push(nextRecord); + } + return updated; +}; + +const resolveTargets = (model, opts) => { + const directElements = normalizeElements(opts.elements) + .map((element) => resolveElementRecord(model, element)) + .filter(Boolean); + if (opts.path) { + directElements.push(...model.selector(opts.path)); + } + return uniqueRecords(directElements); +}; + +const normalizeElements = (elements) => { + if (!elements) return []; + return Array.isArray(elements) ? elements : [elements]; +}; + +const resolveElementRecord = (model, element) => { + if (!element) return null; + if (typeof element === 'string') return model.get(element); + if (element._v2Record) return model.get(element._v2Record.id); + if (element.id) return model.get(element.id); + return null; +}; + +const updateComponents = (model, itemRecord, componentChanges, opts) => { + const current = model.getComponents(itemRecord.id); + const used = new Set(); + const nextComponentProps = []; + + for (const change of componentChanges) { + const match = findComponentRecord(current, change, used); + const nextProps = match + ? mergeComponentProps(match.props, change, opts) + : applyComponentDefaults(change); + if (match) { + used.add(match.id); + model.replaceRecordProps(match.id, nextProps); + } else if (nextProps.show !== false) { + model.add({ + id: nextProps.id, + type: nextProps.type, + kind: 'component', + parentId: itemRecord.id, + props: nextProps, + depth: itemRecord.depth + 1, + }); + } + nextComponentProps.push(nextProps); + } + + if (opts.mergeStrategy === 'replace') { + for (const component of current) { + if (!used.has(component.id)) { + model.remove(component.id); + } + } + } + + const mergedItemProps = { + ...itemRecord.props, + components: + opts.mergeStrategy === 'replace' + ? nextComponentProps + : mergeParentComponents( + itemRecord.props.components, + nextComponentProps, + ), + }; + model.replaceRecordProps(itemRecord.id, mergedItemProps); +}; + +const findComponentRecord = (records, change, used) => { + const match = records.find( + (record) => + !used.has(record.id) && + ((change.id && record.id === change.id) || + (change.label && record.label === change.label)), + ); + if (match) return match; + if (!change.id && !change.label) { + return records.find( + (record) => !used.has(record.id) && record.type === change.type, + ); + } + return null; +}; + +const mergeComponentProps = (props, change, opts) => + opts.mergeStrategy === 'replace' + ? applyComponentDefaults({ type: props.type, ...change }) + : deepMerge(structuredClone(props), change, { mergeStrategy: 'merge' }); + +const mergeParentComponents = (current = [], next = []) => { + const byIdOrType = new Map(); + for (const component of current) { + byIdOrType.set( + component.id ?? component.label ?? component.type, + component, + ); + } + for (const component of next) { + byIdOrType.set( + component.id ?? component.label ?? component.type, + component, + ); + } + return [...byIdOrType.values()]; +}; + +const uniqueRecords = (records) => { + const seen = new Set(); + const unique = []; + for (const record of records) { + if (!record || seen.has(record.id)) continue; + seen.add(record.id); + unique.push(record); + } + return unique; +}; diff --git a/src/v2/render-ir/createV2RenderIR.js b/src/v2/render-ir/createV2RenderIR.js new file mode 100644 index 00000000..904b0b9e --- /dev/null +++ b/src/v2/render-ir/createV2RenderIR.js @@ -0,0 +1,152 @@ +export const createV2RenderIR = (model, layout) => { + const nodes = []; + + for (const record of model.records.values()) { + if (record.kind === 'root') continue; + const frame = layout.getFrame(record.id); + if (!frame?.visible) continue; + + if (record.kind === 'component') { + const node = createComponentNode(record, frame); + if (node) nodes.push(node); + continue; + } + + const node = createElementNode(record, frame); + if (node) nodes.push(node); + } + + return { + nodes, + byFeature: groupByFeature(nodes), + }; +}; + +const createElementNode = (record, frame) => { + if (record.type === 'rect') { + return { + id: record.id, + ownerId: record.id, + feature: 'rect', + layer: 'shape', + frame, + material: { + fill: record.props.fill, + stroke: record.props.stroke, + radius: record.props.radius ?? 0, + }, + }; + } + if (record.type === 'text') { + return { + id: record.id, + ownerId: record.id, + feature: 'text', + layer: 'text', + frame, + material: { + text: record.props.text ?? '', + style: record.props.style, + }, + }; + } + if (record.type === 'image') { + return { + id: record.id, + ownerId: record.id, + feature: 'image', + layer: 'image', + frame, + material: { + source: record.props.source, + }, + }; + } + if (record.type === 'relations') { + return { + id: record.id, + ownerId: record.id, + feature: 'relations', + layer: 'relations', + frame, + material: { + links: record.props.links ?? [], + style: record.props.style, + }, + }; + } + return null; +}; + +const createComponentNode = (record, frame) => { + if (record.type === 'background') { + return { + id: record.id, + ownerId: record.parentId, + feature: 'background', + layer: 'background', + frame, + material: { + source: record.props.source, + tint: record.props.tint, + }, + }; + } + if (record.type === 'bar') { + return { + id: record.id, + ownerId: record.parentId, + feature: 'bar', + layer: 'bar', + frame, + material: { + source: record.props.source, + tint: record.props.tint, + animation: record.props.animation !== false, + animationDuration: record.props.animationDuration ?? 200, + }, + }; + } + if (record.type === 'icon') { + return { + id: record.id, + ownerId: record.parentId, + feature: 'icon', + layer: 'icon', + frame, + material: { + source: record.props.source, + tint: record.props.tint, + }, + }; + } + if (record.type === 'text') { + return { + id: record.id, + ownerId: record.parentId, + feature: 'label', + layer: 'text', + frame, + material: { + text: record.props.text ?? '', + style: record.props.style, + tint: record.props.tint, + split: record.props.split ?? 0, + }, + }; + } + return null; +}; + +const groupByFeature = (nodes) => { + const groups = new Map(); + for (const node of nodes) { + let group = groups.get(node.feature); + if (!group) { + group = []; + groups.set(node.feature, group); + } + group.push(node); + } + return groups; +}; diff --git a/src/v2/render-policy/createV2RenderPlan.js b/src/v2/render-policy/createV2RenderPlan.js new file mode 100644 index 00000000..8f77a968 --- /dev/null +++ b/src/v2/render-policy/createV2RenderPlan.js @@ -0,0 +1,90 @@ +export const createV2RenderPlan = (model, renderIR) => { + const visibleByOwner = groupVisibleNodesByOwner(renderIR.nodes); + const aggregateBars = []; + const aggregateBackgrounds = []; + const pixiNodes = []; + const relations = []; + + for (const node of renderIR.nodes) { + if (node.feature === 'relations') { + relations.push(node); + continue; + } + + if (node.feature === 'bar' && canUseAggregateBar(node, visibleByOwner)) { + aggregateBars.push(node); + continue; + } + + if ( + node.feature === 'background' && + canUseAggregateBackground(node, visibleByOwner) + ) { + aggregateBackgrounds.push(node); + continue; + } + + pixiNodes.push(node); + } + + return { + aggregateBars, + aggregateBackgrounds, + pixiNodes, + relations, + stats: { + nodes: renderIR.nodes.length, + aggregateBars: aggregateBars.length, + aggregateBackgrounds: aggregateBackgrounds.length, + pixiNodes: pixiNodes.length, + relations: relations.length, + estimatedDisplayObjects: + pixiNodes.length + + relations.length + + countAggregateLayers({ + aggregateBars, + aggregateBackgrounds, + }), + }, + }; +}; + +const canUseAggregateBar = (node, visibleByOwner) => { + if (!isRectSource(node.material?.source)) return false; + + const visible = visibleByOwner.get(node.ownerId) ?? []; + return !visible.some( + (ownerNode) => + ownerNode.id !== node.id && + (ownerNode.feature === 'icon' || ownerNode.feature === 'label'), + ); +}; + +const canUseAggregateBackground = (node, visibleByOwner) => { + if (!isRectSource(node.material?.source)) return false; + + const visible = visibleByOwner.get(node.ownerId) ?? []; + return visible.some((ownerNode) => ownerNode.feature === 'bar'); +}; + +const groupVisibleNodesByOwner = (nodes) => { + const byOwner = new Map(); + for (const node of nodes) { + let group = byOwner.get(node.ownerId); + if (!group) { + group = []; + byOwner.set(node.ownerId, group); + } + group.push(node); + } + return byOwner; +}; + +const isRectSource = (source) => source?.type === 'rect'; + +const countAggregateLayers = ({ aggregateBars, aggregateBackgrounds }) => { + let count = 0; + if (aggregateBars.length > 0) count += 1; + if (aggregateBackgrounds.length > 0) count += 1; + return count; +}; From 2fa839a75d29523e96f5ec7630884b2460086679 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 11:00:28 +0900 Subject: [PATCH 28/51] feat: add patchmap v2 render scheduler --- src/v2/PatchMapV2Engine.js | 15 +++++ src/v2/PatchMapV2Engine.test.js | 27 +++++++++ src/v2/index.js | 2 + src/v2/render-ir/diffV2RenderIR.js | 61 +++++++++++++++++++ src/v2/scheduler/V2RenderScheduler.js | 87 +++++++++++++++++++++++++++ 5 files changed, 192 insertions(+) create mode 100644 src/v2/render-ir/diffV2RenderIR.js create mode 100644 src/v2/scheduler/V2RenderScheduler.js diff --git a/src/v2/PatchMapV2Engine.js b/src/v2/PatchMapV2Engine.js index e5be2593..9d08ee4b 100644 --- a/src/v2/PatchMapV2Engine.js +++ b/src/v2/PatchMapV2Engine.js @@ -2,7 +2,9 @@ import { createV2Layout } from './layout/createV2Layout'; import { createV2Model } from './model/createV2Model'; import { updateV2Model } from './model/updateV2Model'; import { createV2RenderIR } from './render-ir/createV2RenderIR'; +import { diffV2RenderIR } from './render-ir/diffV2RenderIR'; import { createV2RenderPlan } from './render-policy/createV2RenderPlan'; +import { V2RenderScheduler } from './scheduler/V2RenderScheduler'; export class PatchMapV2Engine { constructor({ theme = {} } = {}) { @@ -10,7 +12,9 @@ export class PatchMapV2Engine { this.model = null; this.layout = null; this.renderIR = null; + this.renderDiff = null; this.renderPlan = null; + this.scheduler = new V2RenderScheduler(); this.revision = 0; } @@ -39,17 +43,28 @@ export class PatchMapV2Engine { model: this.model, layout: this.layout, renderIR: this.renderIR, + renderDiff: this.renderDiff, renderPlan: this.renderPlan, revision: this.revision, }; } #refreshDerivedState() { + const previousIR = this.renderIR; this.layout = createV2Layout(this.model); this.renderIR = createV2RenderIR(this.model, this.layout, { theme: this.theme, }); + this.renderDiff = diffV2RenderIR(previousIR, this.renderIR); this.renderPlan = createV2RenderPlan(this.model, this.renderIR); + this.scheduler.enqueue({ + revision: this.revision + 1, + model: this.model, + layout: this.layout, + renderIR: this.renderIR, + renderDiff: this.renderDiff, + renderPlan: this.renderPlan, + }); this.revision += 1; } } diff --git a/src/v2/PatchMapV2Engine.test.js b/src/v2/PatchMapV2Engine.test.js index 40d0816b..851f9a1e 100644 --- a/src/v2/PatchMapV2Engine.test.js +++ b/src/v2/PatchMapV2Engine.test.js @@ -118,6 +118,7 @@ describe('PatchMapV2Engine', () => { width: 96, height: 27, }); + expect(engine.renderDiff.updated.map((node) => node.id)).toContain(bar.id); }); it('supports patch-service type selector paths for relation refreshes', () => { @@ -164,4 +165,30 @@ describe('PatchMapV2Engine', () => { expect(fallbackNodeIds).toContain(bar.id); expect(fallbackNodeIds).toContain(icon.id); }); + + it('coalesces repeated render work while keeping the latest revision available', () => { + const scheduled = []; + const engine = new PatchMapV2Engine(); + engine.scheduler.schedule = (callback) => scheduled.push(callback); + engine.draw(createPanelData()); + + engine.update({ + path: '$..[?(@.id=="panel-grid.0.0")]', + changes: { components: [{ type: 'bar', size: { height: '20%' } }] }, + validateSchema: false, + }); + engine.update({ + path: '$..[?(@.id=="panel-grid.0.0")]', + changes: { components: [{ type: 'bar', size: { height: '80%' } }] }, + validateSchema: false, + }); + + expect(scheduled).toHaveLength(1); + const flushed = engine.scheduler.flush(); + const latestBar = flushed.renderIR.byFeature + .get('bar') + .find((node) => node.ownerId === 'panel-grid.0.0'); + expect(flushed.revision).toBe(engine.revision); + expect(latestBar.frame.height).toBeCloseTo(28.8); + }); }); diff --git a/src/v2/index.js b/src/v2/index.js index d5c1faea..692a03d3 100644 --- a/src/v2/index.js +++ b/src/v2/index.js @@ -3,4 +3,6 @@ export { createV2Model } from './model/createV2Model'; export { updateV2Model } from './model/updateV2Model'; export { PatchMapV2Engine } from './PatchMapV2Engine'; export { createV2RenderIR } from './render-ir/createV2RenderIR'; +export { diffV2RenderIR } from './render-ir/diffV2RenderIR'; export { createV2RenderPlan } from './render-policy/createV2RenderPlan'; +export { V2RenderScheduler } from './scheduler/V2RenderScheduler'; diff --git a/src/v2/render-ir/diffV2RenderIR.js b/src/v2/render-ir/diffV2RenderIR.js new file mode 100644 index 00000000..0b45cc07 --- /dev/null +++ b/src/v2/render-ir/diffV2RenderIR.js @@ -0,0 +1,61 @@ +export const diffV2RenderIR = (previousIR, nextIR) => { + const previous = indexNodes(previousIR?.nodes ?? []); + const next = indexNodes(nextIR?.nodes ?? []); + const added = []; + const updated = []; + const removed = []; + + for (const [id, nextNode] of next) { + const previousNode = previous.get(id); + if (!previousNode) { + added.push(nextNode); + continue; + } + if (nodeSignature(previousNode) !== nodeSignature(nextNode)) { + updated.push(nextNode); + } + } + + for (const [id, previousNode] of previous) { + if (!next.has(id)) { + removed.push(previousNode); + } + } + + return { + added, + updated, + removed, + changed: added.length + updated.length + removed.length, + }; +}; + +const indexNodes = (nodes) => { + const index = new Map(); + for (const node of nodes) { + index.set(node.id, node); + } + return index; +}; + +const nodeSignature = (node) => + JSON.stringify({ + feature: node.feature, + layer: node.layer, + ownerId: node.ownerId, + frame: normalizeFrame(node.frame), + material: node.material, + }); + +const normalizeFrame = (frame) => ({ + x: round(frame.x), + y: round(frame.y), + width: round(frame.width), + height: round(frame.height), + rotation: round(frame.rotation), + alpha: round(frame.alpha), + visible: frame.visible, +}); + +const round = (value) => + typeof value === 'number' ? Math.round(value * 1000) / 1000 : value; diff --git a/src/v2/scheduler/V2RenderScheduler.js b/src/v2/scheduler/V2RenderScheduler.js new file mode 100644 index 00000000..e92cfe5b --- /dev/null +++ b/src/v2/scheduler/V2RenderScheduler.js @@ -0,0 +1,87 @@ +export class V2RenderScheduler { + constructor({ + schedule = defaultSchedule, + frameBudgetMs = 4, + now = defaultNow, + } = {}) { + this.schedule = schedule; + this.frameBudgetMs = frameBudgetMs; + this.now = now; + this.pending = null; + this.scheduled = false; + this.appliedRevision = 0; + } + + enqueue(work) { + this.pending = mergeWork(this.pending, work); + if (this.scheduled) return; + this.scheduled = true; + this.schedule(() => this.flush()); + } + + flush(apply = null) { + this.scheduled = false; + const work = this.pending; + this.pending = null; + if (!work) return null; + + const startedAt = this.now(); + const result = { + ...work, + overBudget: false, + elapsedMs: 0, + }; + if (apply) { + apply(work, { + startedAt, + frameBudgetMs: this.frameBudgetMs, + shouldYield: () => this.now() - startedAt >= this.frameBudgetMs, + }); + } + result.elapsedMs = this.now() - startedAt; + result.overBudget = result.elapsedMs >= this.frameBudgetMs; + this.appliedRevision = work.revision; + return result; + } +} + +const mergeWork = (previous, next) => { + if (!previous) return next; + if (!next) return previous; + return { + ...previous, + ...next, + renderDiff: mergeDiff(previous.renderDiff, next.renderDiff), + }; +}; + +const mergeDiff = (previous, next) => { + if (!previous) return next; + if (!next) return previous; + return { + added: mergeNodes(previous.added, next.added), + updated: mergeNodes(previous.updated, next.updated), + removed: mergeNodes(previous.removed, next.removed), + changed: (previous.changed ?? 0) + (next.changed ?? 0), + }; +}; + +const mergeNodes = (left = [], right = []) => { + const byId = new Map(); + for (const node of left) byId.set(node.id, node); + for (const node of right) byId.set(node.id, node); + return [...byId.values()]; +}; + +const defaultSchedule = (callback) => { + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(callback); + return; + } + setTimeout(callback, 0); +}; + +const defaultNow = () => + typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); From 25a2742935521b7e134ce99ccf2f8348e785011e Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 11:01:06 +0900 Subject: [PATCH 29/51] fix: keep patchmap v2 selector refs stable --- src/v2/PatchMapV2Engine.test.js | 19 +++++++++++++++++++ src/v2/model/V2ModelStore.js | 24 ++++++++++++++---------- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/v2/PatchMapV2Engine.test.js b/src/v2/PatchMapV2Engine.test.js index 851f9a1e..86e33147 100644 --- a/src/v2/PatchMapV2Engine.test.js +++ b/src/v2/PatchMapV2Engine.test.js @@ -121,6 +121,25 @@ describe('PatchMapV2Engine', () => { expect(engine.renderDiff.updated.map((node) => node.id)).toContain(bar.id); }); + it('keeps selector compatibility refs stable across direct element updates', () => { + const engine = new PatchMapV2Engine(); + engine.draw(createPanelData()); + const [item] = engine.selector('$..[?(@.id=="panel-grid.0.0")]'); + + const updated = engine.update({ + elements: item, + changes: { attrs: { display: 'selected-panel-item' } }, + validateSchema: false, + }); + + expect(updated).toEqual([item]); + expect(item.display).toBe('selected-panel-item'); + expect(item._v2Record.display).toBe('selected-panel-item'); + expect(engine.selector('$..[?(@.display=="selected-panel-item")]')).toEqual( + [item], + ); + }); + it('supports patch-service type selector paths for relation refreshes', () => { const engine = new PatchMapV2Engine(); engine.draw([ diff --git a/src/v2/model/V2ModelStore.js b/src/v2/model/V2ModelStore.js index ee4b5daa..fb97a4bc 100644 --- a/src/v2/model/V2ModelStore.js +++ b/src/v2/model/V2ModelStore.js @@ -50,6 +50,7 @@ export class V2ModelStore { ...record, type: props.type ?? record.type, props, + ref: record.ref, }); this.records.set(id, nextRecord); addToBucket(this.byType, nextRecord.type, id); @@ -155,19 +156,22 @@ export const createRecord = ({ selectable: isSelectableType(type, kind), ref: null, }; - record.ref = ref ?? createCompatibilityRef(record); + record.ref = syncCompatibilityRef(ref ?? createCompatibilityRef(), record); return record; }; -const createCompatibilityRef = (record) => ({ - id: record.id, - type: record.type, - label: record.label, - display: record.display, - props: record.props, - attrs: record.attrs, - _v2Record: record, -}); +const createCompatibilityRef = () => ({}); + +const syncCompatibilityRef = (ref, record) => { + ref.id = record.id; + ref.type = record.type; + ref.label = record.label; + ref.display = record.display; + ref.props = record.props; + ref.attrs = record.attrs; + ref._v2Record = record; + return ref; +}; const isSelectableType = (type, kind) => kind === 'element' && From e1efa8740e1fc8c1950a537f294fe23b547e9442 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 11:10:19 +0900 Subject: [PATCH 30/51] feat: add patchmap v2 pixi renderer --- src/v2/backend/V2PixiRenderer.js | 142 ++++++++++++++++++++++++++ src/v2/backend/V2PixiRenderer.test.js | 79 ++++++++++++++ src/v2/index.js | 1 + src/v2/model/createV2Model.js | 71 ++++++++++--- 4 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 src/v2/backend/V2PixiRenderer.js create mode 100644 src/v2/backend/V2PixiRenderer.test.js diff --git a/src/v2/backend/V2PixiRenderer.js b/src/v2/backend/V2PixiRenderer.js new file mode 100644 index 00000000..0b0aa9d7 --- /dev/null +++ b/src/v2/backend/V2PixiRenderer.js @@ -0,0 +1,142 @@ +import { Container, Sprite, Texture } from 'pixi.js'; +import { getColor } from '../../utils/get'; + +export class V2PixiRenderer { + constructor({ store, target }) { + this.store = store; + this.target = target ?? store?.world; + this.layers = createLayers(); + this.objectsById = new Map(); + this.attached = false; + } + + attach() { + if (this.attached || !this.target) return; + this.target.addChild(this.layers.background); + this.target.addChild(this.layers.bar); + this.target.addChild(this.layers.fallback); + this.target.addChild(this.layers.relations); + this.attached = true; + } + + render(snapshot) { + this.attach(); + if (!snapshot?.renderIR) return; + + const diff = snapshot.renderDiff ?? { + added: snapshot.renderIR.nodes, + updated: [], + removed: [], + }; + for (const node of diff.removed ?? []) { + this.#removeNode(node.id); + } + for (const node of diff.added ?? []) { + this.#upsertNode(node); + } + for (const node of diff.updated ?? []) { + this.#upsertNode(node); + } + } + + destroy() { + for (const object of this.objectsById.values()) { + object.destroy(); + } + this.objectsById.clear(); + for (const layer of Object.values(this.layers)) { + layer.destroy({ children: true }); + } + this.attached = false; + } + + #upsertNode(node) { + const layer = this.#getLayer(node); + if (!layer) return; + + let object = this.objectsById.get(node.id); + if (!object) { + object = this.#createDisplayObject(node); + this.objectsById.set(node.id, object); + layer.addChild(object); + } else if (object.parent !== layer) { + object.parent?.removeChild(object); + layer.addChild(object); + } + + applyNodeToObject(object, node, this.store); + } + + #removeNode(id) { + const object = this.objectsById.get(id); + if (!object) return; + object.parent?.removeChild(object); + object.destroy(); + this.objectsById.delete(id); + } + + #getLayer(node) { + if (node.layer === 'background') return this.layers.background; + if (node.layer === 'bar') return this.layers.bar; + if (node.layer === 'relations') return this.layers.relations; + return this.layers.fallback; + } + + #createDisplayObject(node) { + const texture = resolveNodeTexture(node, this.store) ?? Texture.WHITE; + const object = new Sprite(texture); + object.label = `patchmap-v2-${node.id}`; + object._patchmapV2NodeId = node.id; + applySlice(object, texture); + return object; + } +} + +const createLayers = () => ({ + background: createLayer('patchmap-v2-background-layer'), + bar: createLayer('patchmap-v2-bar-layer'), + fallback: createLayer('patchmap-v2-fallback-layer'), + relations: createLayer('patchmap-v2-relations-layer'), +}); + +const createLayer = (label) => { + const layer = new Container({ label }); + layer._patchmapInternal = true; + layer._patchmapV2Layer = true; + return layer; +}; + +const applyNodeToObject = (object, node, store) => { + const texture = resolveNodeTexture(node, store); + if (texture && object.texture !== texture) { + object.texture = texture; + applySlice(object, texture); + } + + object.visible = node.frame.visible !== false; + object.renderable = object.visible; + object.x = node.frame.x; + object.y = node.frame.y; + object.width = node.frame.width; + object.height = node.frame.height; + object.rotation = node.frame.rotation ?? 0; + object.alpha = node.frame.alpha ?? 1; + if (node.material?.tint !== undefined) { + object.tint = getColor(store.theme, node.material.tint); + } +}; + +const resolveNodeTexture = (node, store) => { + const source = node.material?.source; + if (!source) return Texture.WHITE; + return store.textureResolver?.(source, store, node) ?? Texture.WHITE; +}; + +const applySlice = (object, texture) => { + const slice = texture.metadata?.slice; + if (!slice) return; + object.leftWidth = slice.leftWidth ?? 0; + object.rightWidth = slice.rightWidth ?? 0; + object.topHeight = slice.topHeight ?? 0; + object.bottomHeight = slice.bottomHeight ?? 0; +}; diff --git a/src/v2/backend/V2PixiRenderer.test.js b/src/v2/backend/V2PixiRenderer.test.js new file mode 100644 index 00000000..97ad1edb --- /dev/null +++ b/src/v2/backend/V2PixiRenderer.test.js @@ -0,0 +1,79 @@ +import { Container, Texture } from 'pixi.js'; +import { describe, expect, it } from 'vitest'; +import { PatchMapV2Engine } from '../PatchMapV2Engine'; +import { V2PixiRenderer } from './V2PixiRenderer'; + +describe('V2PixiRenderer', () => { + it('attaches stable v2 layers in render order and reuses display objects across updates', () => { + const world = new Container(); + const renderer = new V2PixiRenderer({ + store: { + world, + theme: {}, + textureResolver: () => Texture.WHITE, + }, + target: world, + }); + const engine = new PatchMapV2Engine(); + const snapshot = engine.draw([ + { + type: 'item', + id: 'item-1', + size: { width: 100, height: 50 }, + components: [ + { + type: 'background', + source: { type: 'rect', fill: '#ffffff', radius: 4 }, + }, + { + type: 'bar', + source: { type: 'rect', fill: '#0ea5e9', radius: 3 }, + size: { width: '100%', height: '50%' }, + placement: 'bottom', + }, + ], + }, + ]); + + renderer.render(snapshot); + + expect(world.children.map((child) => child.label)).toEqual([ + 'patchmap-v2-background-layer', + 'patchmap-v2-bar-layer', + 'patchmap-v2-fallback-layer', + 'patchmap-v2-relations-layer', + ]); + const barNode = snapshot.renderIR.byFeature.get('bar')[0]; + const barObject = renderer.objectsById.get(barNode.id); + expect(barObject.parent.label).toBe('patchmap-v2-bar-layer'); + expect(barObject.width).toBe(100); + expect(barObject.height).toBe(25); + expect(barObject.y).toBe(25); + + const nextSnapshot = engine.draw([ + { + type: 'item', + id: 'item-1', + size: { width: 100, height: 50 }, + components: [ + { + type: 'background', + source: { type: 'rect', fill: '#ffffff', radius: 4 }, + }, + { + type: 'bar', + source: { type: 'rect', fill: '#0ea5e9', radius: 3 }, + size: { width: '100%', height: '80%' }, + placement: 'bottom', + }, + ], + }, + ]); + renderer.render(nextSnapshot); + + const nextBarNode = nextSnapshot.renderIR.byFeature.get('bar')[0]; + expect(renderer.objectsById.get(nextBarNode.id)).toBe(barObject); + expect(barObject.height).toBe(40); + expect(barObject.y).toBe(10); + }); +}); diff --git a/src/v2/index.js b/src/v2/index.js index 692a03d3..4b45db6a 100644 --- a/src/v2/index.js +++ b/src/v2/index.js @@ -1,3 +1,4 @@ +export { V2PixiRenderer } from './backend/V2PixiRenderer'; export { createV2Layout } from './layout/createV2Layout'; export { createV2Model } from './model/createV2Model'; export { updateV2Model } from './model/updateV2Model'; diff --git a/src/v2/model/createV2Model.js b/src/v2/model/createV2Model.js index 97a9392e..a048ab13 100644 --- a/src/v2/model/createV2Model.js +++ b/src/v2/model/createV2Model.js @@ -7,7 +7,9 @@ import { V2ModelStore } from './V2ModelStore'; export const createV2Model = (data) => { const store = new V2ModelStore(); const elements = Array.isArray(data) ? data : []; - const normalized = elements.map((element) => applyElementDefaults(element)); + const normalized = elements.map((element) => + applyElementDefaults(seedStableComponentIds(element)), + ); store.root.props.children = normalized; for (const element of normalized) { @@ -33,7 +35,7 @@ const addElement = (store, props, context) => { if (props.type === 'group') { for (const child of props.children ?? []) { - addElement(store, applyElementDefaults(child), { + addElement(store, applyElementDefaults(seedStableComponentIds(child)), { parentId: props.id, depth: context.depth + 1, generated: false, @@ -69,19 +71,21 @@ const addGridItems = (store, grid, depth) => { if (inactive && grid.inactiveCellStrategy !== 'hide') continue; const id = `${grid.id}.${rowIndex}.${colIndex}`; - const itemProps = applyElementDefaults({ - type: 'item', - id, - ...cloneGridItemTemplate(itemTemplate), - label: String(cellValue), - attrs: { - ...(itemTemplate.attrs ?? {}), - gridIndex: { row: rowIndex, col: colIndex }, - x: colIndex * (itemWidth + gap.x), - y: rowIndex * (itemHeight + gap.y), - }, - show: !inactive, - }); + const itemProps = applyElementDefaults( + seedStableComponentIds({ + type: 'item', + id, + ...cloneGridItemTemplate(itemTemplate), + label: String(cellValue), + attrs: { + ...(itemTemplate.attrs ?? {}), + gridIndex: { row: rowIndex, col: colIndex }, + x: colIndex * (itemWidth + gap.x), + y: rowIndex * (itemHeight + gap.y), + }, + show: !inactive, + }), + ); store.add({ id, type: 'item', @@ -97,8 +101,13 @@ const addGridItems = (store, grid, depth) => { }; const addComponents = (store, itemProps, depth) => { - for (const component of itemProps.components ?? []) { - const componentProps = applyComponentDefaults(component); + const components = itemProps.components ?? []; + for (let index = 0; index < components.length; index += 1) { + const component = components[index]; + const componentProps = applyComponentDefaults({ + ...component, + id: component.id ?? `${itemProps.id}.${component.type}.${index}`, + }); store.add({ id: componentProps.id, type: componentProps.type, @@ -111,6 +120,34 @@ const addComponents = (store, itemProps, depth) => { } }; +const seedStableComponentIds = (element) => { + if (!element || typeof element !== 'object') return element; + + if (element.type === 'group' && Array.isArray(element.children)) { + return { + ...element, + children: element.children.map(seedStableComponentIds), + }; + } + + if (element.type !== 'item' || !Array.isArray(element.components)) { + return element; + } + + return { + ...element, + components: element.components.map((component, index) => { + if (!component || typeof component !== 'object' || component.id) { + return component; + } + return { + ...component, + id: `${element.id}.${component.type}.${index}`, + }; + }), + }; +}; + const cloneGridItemTemplate = (itemTemplate) => { const next = structuredClone(itemTemplate); if (!Array.isArray(next.components)) return next; From 281d20305e11ecddabec3cf6aee502658c618fd4 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 11:12:25 +0900 Subject: [PATCH 31/51] feat: add patchmap v2 opt-in mode --- src/patchmap.js | 41 ++++++++++++- src/tests/render/patchmap-v2-mode.test.js | 70 +++++++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 src/tests/render/patchmap-v2-mode.test.js diff --git a/src/patchmap.js b/src/patchmap.js index 7165eeb8..949032ef 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -26,6 +26,7 @@ import { WildcardEventEmitter } from './utils/event/WildcardEventEmitter'; import { selector } from './utils/selector/selector'; import { themeStore } from './utils/theme'; import { validateMapData } from './utils/validator'; +import { PatchMapV2Engine, V2PixiRenderer } from './v2'; class Patchmap extends WildcardEventEmitter { _app = null; @@ -42,6 +43,9 @@ class Patchmap extends WildcardEventEmitter { _drawToken = 0; _drawCacheKey = null; _drawCacheData = null; + _engineMode = 'legacy'; + _v2Engine = null; + _v2Renderer = null; get app() { return this._app; @@ -129,10 +133,12 @@ class Patchmap extends WildcardEventEmitter { theme: themeOptions = {}, assets: assetsOptions = [], transformer, + engine = 'legacy', } = opts; this.undoRedoManager._setHotkeys(); this._theme.set(themeOptions); + this._engineMode = engine === 'v2' ? 'v2' : 'legacy'; this._app = new Application(); await initApp(this.app, { resizeTo: element, ...appOptions }); @@ -142,6 +148,10 @@ class Patchmap extends WildcardEventEmitter { this._world.enableRenderGroup?.(); store.world = this._world; this.viewport.addChild(this._world); + if (this._engineMode === 'v2') { + this._v2Engine = new PatchMapV2Engine({ theme: this.theme }); + this._v2Renderer = new V2PixiRenderer({ store, target: this._world }); + } this._viewTransform.attach({ viewport: this.viewport, world: this._world }); await initAsset(assetsOptions); @@ -170,6 +180,7 @@ class Patchmap extends WildcardEventEmitter { this.stateManager.resetState(); this.stateManager.destroy(); event.removeAllEvent(this.viewport); + this._v2Renderer?.destroy(); this.viewport.destroy({ children: true, context: true, style: true }); const parentElement = this.app.canvas.parentElement; this.app.destroy(true); @@ -190,6 +201,9 @@ class Patchmap extends WildcardEventEmitter { this._drawToken = 0; this._drawCacheKey = null; this._drawCacheData = null; + this._engineMode = 'legacy'; + this._v2Engine = null; + this._v2Renderer = null; this.emit('patchmap:destroyed', { target: this }); this.removeAllListeners(); } @@ -219,13 +233,19 @@ class Patchmap extends WildcardEventEmitter { this.undoRedoManager.clear(); this.animationContext.revert(); event.removeAllEvent(this.viewport); - if (!canReuseCurrentScene) { + if (this._engineMode === 'v2') { + const snapshot = this._v2Engine.draw(validatedData); + this._v2Renderer.render(snapshot); + this._v2Engine.scheduler.flush(); + this._drawCacheKey = drawCacheKey; + this._drawCacheData = validatedData; + } else if (!canReuseCurrentScene) { draw(store, validatedData); this._drawCacheKey = drawCacheKey; this._drawCacheData = validatedData; } - if (!canReuseCurrentScene) { + if (this._engineMode !== 'v2' && !canReuseCurrentScene) { // Force a refresh of all relation elements after the initial draw. This ensures // that all link targets exist in the scene graph before the relations // attempt to draw their links. @@ -261,6 +281,20 @@ class Patchmap extends WildcardEventEmitter { } update(opts = {}) { + if (this._engineMode === 'v2') { + const updatedElements = this._v2Engine.update(opts); + const snapshot = + this._v2Engine.scheduler.flush() ?? this._v2Engine.snapshot(); + this._v2Renderer.render(snapshot); + if (opts.emit !== false) { + this.emit('patchmap:updated', { + elements: updatedElements, + target: this, + }); + } + return updatedElements; + } + const updatedElements = update(this.world, opts); if (opts.emit !== false) { this.emit('patchmap:updated', { @@ -298,6 +332,9 @@ class Patchmap extends WildcardEventEmitter { } selector(path, opts) { + if (this._engineMode === 'v2') { + return this._v2Engine.selector(path, opts); + } return selector(this.world, path, opts); } diff --git a/src/tests/render/patchmap-v2-mode.test.js b/src/tests/render/patchmap-v2-mode.test.js new file mode 100644 index 00000000..14228167 --- /dev/null +++ b/src/tests/render/patchmap-v2-mode.test.js @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Patchmap } from '../../patchmap'; + +const createPanelData = () => [ + { + type: 'item', + id: 'panel-1', + size: { width: 100, height: 50 }, + attrs: { display: 'panelItem' }, + components: [ + { + type: 'background', + source: { type: 'rect', fill: '#ffffff', radius: 4 }, + }, + { + type: 'bar', + source: { type: 'rect', fill: '#0ea5e9', radius: 3 }, + size: { width: '100%', height: '50%' }, + placement: 'bottom', + }, + ], + }, +]; + +describe('Patchmap v2 opt-in mode', () => { + let patchmap; + let element; + + beforeEach(async () => { + document.body.innerHTML = ''; + element = document.createElement('div'); + element.style.height = '100svh'; + document.body.appendChild(element); + + patchmap = new Patchmap(); + await patchmap.init(element, { engine: 'v2' }); + }); + + afterEach(() => { + patchmap?.destroy(); + element?.parentElement?.removeChild(element); + }); + + it('keeps draw, selector, update, and renderer object reuse behind the v2 option', () => { + patchmap.draw(createPanelData()); + + const [item] = patchmap.selector('$..[?(@.id=="panel-1")]'); + expect(item).toMatchObject({ id: 'panel-1', display: 'panelItem' }); + + const barLayer = patchmap.world.children.find( + (child) => child.label === 'patchmap-v2-bar-layer', + ); + const barObject = barLayer.children[0]; + expect(barObject.height).toBe(25); + expect(barObject.y).toBe(25); + + const updated = patchmap.update({ + elements: item, + changes: { + components: [{ type: 'bar', size: { height: '80%' } }], + }, + validateSchema: false, + }); + + expect(updated).toEqual([item]); + expect(barLayer.children[0]).toBe(barObject); + expect(barObject.height).toBe(40); + expect(barObject.y).toBe(10); + }); +}); From 9c298c4fe2101662a3301e49d4aa8458d6c026bc Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 11:14:11 +0900 Subject: [PATCH 32/51] perf: aggregate patchmap v2 panel layers --- src/tests/render/patchmap-v2-mode.test.js | 18 +-- src/v2/backend/V2PixiRenderer.js | 131 +++++++++++++++++++--- src/v2/backend/V2PixiRenderer.test.js | 52 +++++++++ 3 files changed, 177 insertions(+), 24 deletions(-) diff --git a/src/tests/render/patchmap-v2-mode.test.js b/src/tests/render/patchmap-v2-mode.test.js index 14228167..0cfaa9cc 100644 --- a/src/tests/render/patchmap-v2-mode.test.js +++ b/src/tests/render/patchmap-v2-mode.test.js @@ -47,12 +47,10 @@ describe('Patchmap v2 opt-in mode', () => { const [item] = patchmap.selector('$..[?(@.id=="panel-1")]'); expect(item).toMatchObject({ id: 'panel-1', display: 'panelItem' }); - const barLayer = patchmap.world.children.find( - (child) => child.label === 'patchmap-v2-bar-layer', - ); - const barObject = barLayer.children[0]; - expect(barObject.height).toBe(25); - expect(barObject.y).toBe(25); + const barParticle = + patchmap._v2Renderer.aggregateLayers.bar.particleChildren[0]; + expect(barParticle.scaleY).toBe(25); + expect(barParticle.y).toBe(25); const updated = patchmap.update({ elements: item, @@ -63,8 +61,10 @@ describe('Patchmap v2 opt-in mode', () => { }); expect(updated).toEqual([item]); - expect(barLayer.children[0]).toBe(barObject); - expect(barObject.height).toBe(40); - expect(barObject.y).toBe(10); + expect(patchmap._v2Renderer.aggregateLayers.bar.particleChildren[0]).toBe( + barParticle, + ); + expect(barParticle.scaleY).toBe(40); + expect(barParticle.y).toBe(10); }); }); diff --git a/src/v2/backend/V2PixiRenderer.js b/src/v2/backend/V2PixiRenderer.js index 0b0aa9d7..6f55b68c 100644 --- a/src/v2/backend/V2PixiRenderer.js +++ b/src/v2/backend/V2PixiRenderer.js @@ -1,4 +1,11 @@ -import { Container, Sprite, Texture } from 'pixi.js'; +import { + Container, + Particle, + ParticleContainer, + Rectangle, + Sprite, + Texture, +} from 'pixi.js'; import { getColor } from '../../utils/get'; export class V2PixiRenderer { @@ -6,12 +13,16 @@ export class V2PixiRenderer { this.store = store; this.target = target ?? store?.world; this.layers = createLayers(); + this.aggregateLayers = createAggregateLayers(); this.objectsById = new Map(); + this.particlesById = new Map(); this.attached = false; } attach() { if (this.attached || !this.target) return; + this.layers.background.addChild(this.aggregateLayers.background); + this.layers.bar.addChild(this.aggregateLayers.bar); this.target.addChild(this.layers.background); this.target.addChild(this.layers.bar); this.target.addChild(this.layers.fallback); @@ -23,18 +34,20 @@ export class V2PixiRenderer { this.attach(); if (!snapshot?.renderIR) return; - const diff = snapshot.renderDiff ?? { - added: snapshot.renderIR.nodes, - updated: [], - removed: [], - }; - for (const node of diff.removed ?? []) { - this.#removeNode(node.id); - } - for (const node of diff.added ?? []) { - this.#upsertNode(node); - } - for (const node of diff.updated ?? []) { + const plan = snapshot.renderPlan; + const aggregateNodes = [ + ...(plan?.aggregateBackgrounds ?? []), + ...(plan?.aggregateBars ?? []), + ]; + const normalNodes = plan + ? [...plan.pixiNodes, ...plan.relations] + : snapshot.renderIR.nodes; + + this.#removeStaleNormalNodes(normalNodes, aggregateNodes); + this.#syncAggregateLayer('background', plan?.aggregateBackgrounds ?? []); + this.#syncAggregateLayer('bar', plan?.aggregateBars ?? []); + + for (const node of normalNodes) { this.#upsertNode(node); } } @@ -44,6 +57,10 @@ export class V2PixiRenderer { object.destroy(); } this.objectsById.clear(); + this.particlesById.clear(); + for (const layer of Object.values(this.aggregateLayers)) { + layer.destroy(); + } for (const layer of Object.values(this.layers)) { layer.destroy({ children: true }); } @@ -67,6 +84,49 @@ export class V2PixiRenderer { applyNodeToObject(object, node, this.store); } + #removeStaleNormalNodes(normalNodes, aggregateNodes) { + const retainedIds = new Set(normalNodes.map((node) => node.id)); + for (const node of aggregateNodes) { + retainedIds.delete(node.id); + } + for (const id of this.objectsById.keys()) { + if (!retainedIds.has(id)) { + this.#removeNode(id); + } + } + } + + #syncAggregateLayer(kind, nodes) { + const layer = this.aggregateLayers[kind]; + const wantedIds = new Set(nodes.map((node) => node.id)); + + for (const id of this.particlesById.keys()) { + if (this.#getParticleKind(id) === kind && !wantedIds.has(id)) { + this.particlesById.delete(id); + } + } + + const particles = nodes.map((node) => { + let particle = this.particlesById.get(node.id); + if (!particle) { + particle = new Particle({ texture: Texture.WHITE }); + particle._patchmapV2NodeId = node.id; + particle._patchmapV2Kind = kind; + this.particlesById.set(node.id, particle); + } + applyNodeToParticle(particle, node, this.store); + return particle; + }); + + layer.particleChildren.length = 0; + layer.particleChildren.push(...particles); + layer.update(); + } + + #getParticleKind(id) { + return this.particlesById.get(id)?._patchmapV2Kind; + } + #removeNode(id) { const object = this.objectsById.get(id); if (!object) return; @@ -106,6 +166,28 @@ const createLayer = (label) => { return layer; }; +const createAggregateLayers = () => ({ + background: createAggregateLayer('patchmap-v2-aggregate-background-layer'), + bar: createAggregateLayer('patchmap-v2-aggregate-bar-layer'), +}); + +const createAggregateLayer = (label) => { + const layer = new ParticleContainer({ + label, + texture: Texture.WHITE, + boundsArea: new Rectangle(-1_000_000, -1_000_000, 2_000_000, 2_000_000), + dynamicProperties: { + vertex: true, + position: true, + rotation: true, + color: true, + }, + }); + layer._patchmapInternal = true; + layer._patchmapV2AggregateLayer = true; + return layer; +}; + const applyNodeToObject = (object, node, store) => { const texture = resolveNodeTexture(node, store); if (texture && object.texture !== texture) { @@ -121,11 +203,30 @@ const applyNodeToObject = (object, node, store) => { object.height = node.frame.height; object.rotation = node.frame.rotation ?? 0; object.alpha = node.frame.alpha ?? 1; - if (node.material?.tint !== undefined) { - object.tint = getColor(store.theme, node.material.tint); + const tint = getNodeTint(node, store); + if (tint !== undefined) { + object.tint = tint; + } +}; + +const applyNodeToParticle = (particle, node, store) => { + particle.x = node.frame.x; + particle.y = node.frame.y; + particle.scaleX = node.frame.width; + particle.scaleY = node.frame.height; + particle.rotation = node.frame.rotation ?? 0; + particle.anchorX = 0; + particle.anchorY = 0; + particle.alpha = node.frame.alpha ?? 1; + const tint = getNodeTint(node, store); + if (tint !== undefined) { + particle.tint = tint; } }; +const getNodeTint = (node, store) => + getColor(store.theme, node.material?.tint ?? node.material?.source?.fill); + const resolveNodeTexture = (node, store) => { const source = node.material?.source; if (!source) return Texture.WHITE; diff --git a/src/v2/backend/V2PixiRenderer.test.js b/src/v2/backend/V2PixiRenderer.test.js index 97ad1edb..ec4acdfa 100644 --- a/src/v2/backend/V2PixiRenderer.test.js +++ b/src/v2/backend/V2PixiRenderer.test.js @@ -31,6 +31,7 @@ describe('V2PixiRenderer', () => { size: { width: '100%', height: '50%' }, placement: 'bottom', }, + { type: 'icon', source: 'alert', show: true, size: 10 }, ], }, ]); @@ -66,6 +67,7 @@ describe('V2PixiRenderer', () => { size: { width: '100%', height: '80%' }, placement: 'bottom', }, + { type: 'icon', source: 'alert', show: true, size: 10 }, ], }, ]); @@ -76,4 +78,54 @@ describe('V2PixiRenderer', () => { expect(barObject.height).toBe(40); expect(barObject.y).toBe(10); }); + + it('renders aggregateable backgrounds and bars through particle layers', () => { + const world = new Container(); + const renderer = new V2PixiRenderer({ + store: { + world, + theme: {}, + textureResolver: () => Texture.WHITE, + }, + target: world, + }); + const engine = new PatchMapV2Engine(); + const snapshot = engine.draw([ + { + type: 'grid', + id: 'panel-grid', + cells: [['A', 'B']], + gap: { x: 4, y: 0 }, + item: { + size: { width: 100, height: 50 }, + components: [ + { + type: 'background', + source: { type: 'rect', fill: '#ffffff', radius: 4 }, + }, + { + type: 'bar', + source: { type: 'rect', fill: '#0ea5e9', radius: 3 }, + size: { width: '100%', height: '50%' }, + placement: 'bottom', + }, + ], + }, + }, + ]); + + renderer.render(snapshot); + + expect(renderer.objectsById.size).toBe(0); + expect(renderer.aggregateLayers.background.particleChildren).toHaveLength( + 2, + ); + expect(renderer.aggregateLayers.bar.particleChildren).toHaveLength(2); + expect(renderer.particlesById.size).toBe(4); + const firstBar = renderer.aggregateLayers.bar.particleChildren[0]; + expect(firstBar.x).toBe(0); + expect(firstBar.y).toBe(25); + expect(firstBar.scaleX).toBe(100); + expect(firstBar.scaleY).toBe(25); + }); }); From b63c79fbaa5ed542aa3aa289c316cc0e771d5029 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 11:16:44 +0900 Subject: [PATCH 33/51] feat: add patchmap v2 compatibility refs --- src/patchmap.js | 13 ++- src/tests/render/patchmap-v2-mode.test.js | 29 +++++- src/v2/PatchMapV2Engine.js | 4 +- src/v2/compat/V2CompatibilityRef.js | 121 ++++++++++++++++++++++ src/v2/model/V2ModelStore.js | 16 ++- 5 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 src/v2/compat/V2CompatibilityRef.js diff --git a/src/patchmap.js b/src/patchmap.js index 949032ef..fc43d769 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -149,7 +149,7 @@ class Patchmap extends WildcardEventEmitter { store.world = this._world; this.viewport.addChild(this._world); if (this._engineMode === 'v2') { - this._v2Engine = new PatchMapV2Engine({ theme: this.theme }); + this._v2Engine = new PatchMapV2Engine({ theme: this.theme, store }); this._v2Renderer = new V2PixiRenderer({ store, target: this._world }); } this._viewTransform.attach({ viewport: this.viewport, world: this._world }); @@ -311,6 +311,9 @@ class Patchmap extends WildcardEventEmitter { * @returns {void|null} */ focus(ids, opts) { + if (this._engineMode === 'v2') { + return focus(this.viewport, this._v2Engine.model?.root?.ref, ids, opts); + } return focus(this.viewport, this.world, ids, opts); } @@ -320,6 +323,14 @@ class Patchmap extends WildcardEventEmitter { * @returns {void|null} */ fit(ids, opts) { + if (this._engineMode === 'v2') { + return fitViewport( + this.viewport, + this._v2Engine.model?.root?.ref, + ids, + opts, + ); + } return fitViewport(this.viewport, this.world, ids, opts); } diff --git a/src/tests/render/patchmap-v2-mode.test.js b/src/tests/render/patchmap-v2-mode.test.js index 0cfaa9cc..cb4c510d 100644 --- a/src/tests/render/patchmap-v2-mode.test.js +++ b/src/tests/render/patchmap-v2-mode.test.js @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Patchmap } from '../../patchmap'; +import Transformer from '../../transformer/Transformer'; const createPanelData = () => [ { @@ -33,7 +34,14 @@ describe('Patchmap v2 opt-in mode', () => { document.body.appendChild(element); patchmap = new Patchmap(); - await patchmap.init(element, { engine: 'v2' }); + await patchmap.init(element, { + engine: 'v2', + transformer: new Transformer({ + resizeHandles: true, + rotateHandles: true, + boundsDisplayMode: 'all', + }), + }); }); afterEach(() => { @@ -67,4 +75,23 @@ describe('Patchmap v2 opt-in mode', () => { expect(barParticle.scaleY).toBe(40); expect(barParticle.y).toBe(10); }); + + it('exposes lightweight bounds refs for fit and transformer drawing', () => { + patchmap.draw(createPanelData()); + + const [item] = patchmap.selector('$..[?(@.id=="panel-1")]'); + expect(item.getBounds()).toMatchObject({ + x: 0, + y: 0, + width: 100, + height: 50, + }); + + patchmap.fit(null, { padding: 0 }); + patchmap.transformer.elements = [item]; + patchmap.transformer.draw(); + + expect(patchmap.transformer.elements).toEqual([item]); + expect(patchmap.transformer.children.length).toBeGreaterThan(0); + }); }); diff --git a/src/v2/PatchMapV2Engine.js b/src/v2/PatchMapV2Engine.js index 9d08ee4b..1bf9d1b0 100644 --- a/src/v2/PatchMapV2Engine.js +++ b/src/v2/PatchMapV2Engine.js @@ -7,8 +7,9 @@ import { createV2RenderPlan } from './render-policy/createV2RenderPlan'; import { V2RenderScheduler } from './scheduler/V2RenderScheduler'; export class PatchMapV2Engine { - constructor({ theme = {} } = {}) { + constructor({ theme = {}, store = null } = {}) { this.theme = theme; + this.store = store; this.model = null; this.layout = null; this.renderIR = null; @@ -52,6 +53,7 @@ export class PatchMapV2Engine { #refreshDerivedState() { const previousIR = this.renderIR; this.layout = createV2Layout(this.model); + this.model.syncCompatibilityRefs(this.layout, this.store); this.renderIR = createV2RenderIR(this.model, this.layout, { theme: this.theme, }); diff --git a/src/v2/compat/V2CompatibilityRef.js b/src/v2/compat/V2CompatibilityRef.js new file mode 100644 index 00000000..8265ab14 --- /dev/null +++ b/src/v2/compat/V2CompatibilityRef.js @@ -0,0 +1,121 @@ +import { Matrix, Point, Rectangle } from 'pixi.js'; +import { getBoundsFromPoints } from '../../utils/transform'; + +export class V2CompatibilityRef { + static isElement = true; + static isSelectable = true; + static isResizable = true; + static isRotatable = true; + + id = null; + type = null; + parent = null; + children = []; + destroyed = false; + store = null; + _v2Layout = null; + _v2Record = null; + + get x() { + return this.#frame.x; + } + + get y() { + return this.#frame.y; + } + + get width() { + return this.#frame.width; + } + + get height() { + return this.#frame.height; + } + + get rotation() { + return this.#frame.rotation ?? 0; + } + + get angle() { + return (this.rotation * 180) / Math.PI; + } + + get alpha() { + return this.#frame.alpha ?? 1; + } + + get visible() { + return this.#frame.visible !== false; + } + + get renderable() { + return this.visible; + } + + get worldTransform() { + return this.getGlobalTransform(new Matrix(), false); + } + + get #frame() { + return ( + this._v2Layout?.getFrame(this.id) ?? { + x: this.attrs?.x ?? 0, + y: this.attrs?.y ?? 0, + width: this.props?.size?.width ?? 0, + height: this.props?.size?.height ?? 0, + rotation: this.attrs?.rotation ?? 0, + alpha: this.attrs?.alpha ?? 1, + visible: this.show !== false, + } + ); + } + + getLocalBounds() { + const frame = this.#frame; + return new Rectangle(0, 0, frame.width, frame.height); + } + + getBounds() { + return getBoundsFromPoints(this.#worldCorners()); + } + + getGlobalPosition(point = new Point()) { + const frame = this.#frame; + point.set(frame.x, frame.y); + return point; + } + + getGlobalTransform(matrix = new Matrix()) { + const frame = this.#frame; + const rotation = frame.rotation ?? 0; + const cos = Math.cos(rotation); + const sin = Math.sin(rotation); + matrix.a = cos; + matrix.b = sin; + matrix.c = -sin; + matrix.d = cos; + matrix.tx = frame.x; + matrix.ty = frame.y; + return matrix; + } + + toGlobal(point, out = new Point()) { + return this.getGlobalTransform(new Matrix(), false).apply(point, out); + } + + toLocal(point, _from = null, out = new Point()) { + const matrix = this.getGlobalTransform(new Matrix(), false).invert(); + return matrix.apply(point, out); + } + + #worldCorners() { + const frame = this.#frame; + const transform = this.getGlobalTransform(new Matrix(), false); + return [ + new Point(0, 0), + new Point(frame.width, 0), + new Point(frame.width, frame.height), + new Point(0, frame.height), + ].map((point) => transform.apply(point)); + } +} diff --git a/src/v2/model/V2ModelStore.js b/src/v2/model/V2ModelStore.js index fb97a4bc..e0b580e6 100644 --- a/src/v2/model/V2ModelStore.js +++ b/src/v2/model/V2ModelStore.js @@ -1,3 +1,5 @@ +import { V2CompatibilityRef } from '../compat/V2CompatibilityRef'; + export class V2ModelStore { constructor() { this.root = createRecord({ @@ -127,6 +129,18 @@ export class V2ModelStore { return []; } + + syncCompatibilityRefs(layout, store = null) { + for (const record of this.records.values()) { + const ref = record.ref; + ref._v2Layout = layout; + ref.store = store; + ref.parent = this.get(record.parentId)?.ref ?? null; + ref.children = this.getChildren(record.id) + .filter((child) => child.kind !== 'component') + .map((child) => child.ref); + } + } } export const createRecord = ({ @@ -160,7 +174,7 @@ export const createRecord = ({ return record; }; -const createCompatibilityRef = () => ({}); +const createCompatibilityRef = () => new V2CompatibilityRef(); const syncCompatibilityRef = (ref, record) => { ref.id = record.id; From 74cf71c799e256b4964ef0ca185c0ffc59ecbba9 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 11:18:25 +0900 Subject: [PATCH 34/51] feat: expose patchmap v2 interaction index --- src/tests/render/patchmap-v2-mode.test.js | 16 ++++++ src/v2/compat/V2CompatibilityRef.js | 4 ++ src/v2/model/V2ModelStore.js | 59 ++++++++++++++++++++++- 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/tests/render/patchmap-v2-mode.test.js b/src/tests/render/patchmap-v2-mode.test.js index cb4c510d..5dfd6a5b 100644 --- a/src/tests/render/patchmap-v2-mode.test.js +++ b/src/tests/render/patchmap-v2-mode.test.js @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { findIntersectObject } from '../../events/find'; import { Patchmap } from '../../patchmap'; import Transformer from '../../transformer/Transformer'; @@ -94,4 +95,19 @@ describe('Patchmap v2 opt-in mode', () => { expect(patchmap.transformer.elements).toEqual([item]); expect(patchmap.transformer.children.length).toBeGreaterThan(0); }); + + it('exposes v2 refs through the scene index for hit testing', () => { + patchmap.draw(createPanelData()); + + const hit = findIntersectObject( + patchmap.viewport, + { x: 50, y: 25 }, + { + selectUnit: 'entity', + filter: (target) => target.type === 'item', + }, + ); + + expect(hit).toMatchObject({ id: 'panel-1', type: 'item' }); + }); }); diff --git a/src/v2/compat/V2CompatibilityRef.js b/src/v2/compat/V2CompatibilityRef.js index 8265ab14..1ff9e928 100644 --- a/src/v2/compat/V2CompatibilityRef.js +++ b/src/v2/compat/V2CompatibilityRef.js @@ -108,6 +108,10 @@ export class V2CompatibilityRef { return matrix.apply(point, out); } + getChildIndex(child) { + return this.children.indexOf(child); + } + #worldCorners() { const frame = this.#frame; const transform = this.getGlobalTransform(new Matrix(), false); diff --git a/src/v2/model/V2ModelStore.js b/src/v2/model/V2ModelStore.js index e0b580e6..4fa580e8 100644 --- a/src/v2/model/V2ModelStore.js +++ b/src/v2/model/V2ModelStore.js @@ -16,6 +16,8 @@ export class V2ModelStore { this.childrenById = new Map([[this.root.id, []]]); this.componentsByParentId = new Map(); this.selectableIds = new Set(); + this.sceneIndexAdapter = createSceneIndexAdapter(this); + this.modelIndexAdapter = createModelIndexAdapter(this); this.revision = 0; } @@ -135,11 +137,19 @@ export class V2ModelStore { const ref = record.ref; ref._v2Layout = layout; ref.store = store; - ref.parent = this.get(record.parentId)?.ref ?? null; + ref.parent = + record.kind === 'root' + ? (store?.world ?? null) + : (this.get(record.parentId)?.ref ?? null); ref.children = this.getChildren(record.id) .filter((child) => child.kind !== 'component') .map((child) => child.ref); } + if (store) { + store.sceneIndex = this.sceneIndexAdapter; + store.modelIndex = this.modelIndexAdapter; + store.elementById = this.sceneIndexAdapter.elementById; + } } } @@ -240,6 +250,53 @@ const removeChildId = (childrenById, parentId, childId) => { const idsToRecords = (store, ids) => [...(ids ?? [])].map((id) => store.get(id)).filter(Boolean); +const recordsToRefs = (records) => records.map((record) => record.ref); + +const createSceneIndexAdapter = (store) => ({ + get version() { + return store.revision; + }, + get selectable() { + return recordsToRefs(idsToRecords(store, store.selectableIds)); + }, + get elementById() { + return new Map( + [...store.records.values()].map((record) => [record.id, record.ref]), + ); + }, + getById(id) { + return store.get(id)?.ref ?? null; + }, + getByType(type) { + return recordsToRefs(store.getByType(type)); + }, + getByDisplay(display) { + return recordsToRefs(store.getByDisplay(display)); + }, + add() {}, + update() {}, + remove() {}, + touch() {}, +}); + +const createModelIndexAdapter = (store) => ({ + get version() { + return store.revision; + }, + getRefById(id) { + return store.get(id)?.ref ?? null; + }, + getRefsByType(type) { + return recordsToRefs(store.getByType(type)); + }, + getRefsByDisplay(display) { + return recordsToRefs(store.getByDisplay(display)); + }, + updateFromNode() {}, + removeFromNode() {}, + touch() {}, +}); + const matchExactIdPath = (path) => { const match = path.match(/^\$..\[\?\(@\.id\s*={2,3}\s*(["'])([^"']+)\1\)\]$/); return match?.[2] ?? null; From 41a247e847b2fef57479c084f14b46be65db372f Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 11:21:24 +0900 Subject: [PATCH 35/51] fix: namespace patchmap v2 component records --- src/v2/PatchMapV2Engine.test.js | 35 +++++++++++++++++++++++++++++++++ src/v2/model/createV2Model.js | 15 +++++++++++++- src/v2/model/updateV2Model.js | 7 ++++++- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/v2/PatchMapV2Engine.test.js b/src/v2/PatchMapV2Engine.test.js index 86e33147..da9eb952 100644 --- a/src/v2/PatchMapV2Engine.test.js +++ b/src/v2/PatchMapV2Engine.test.js @@ -156,6 +156,41 @@ describe('PatchMapV2Engine', () => { ]); }); + it('namespaces duplicated component ids by owner while preserving update matching', () => { + const engine = new PatchMapV2Engine(); + engine.draw([ + { + type: 'item', + id: 'panel-a', + size: { width: 10, height: 10 }, + components: [{ type: 'bar', id: 'shared-bar', size: '50%' }], + }, + { + type: 'item', + id: 'panel-b', + size: { width: 10, height: 10 }, + components: [{ type: 'bar', id: 'shared-bar', size: '50%' }], + }, + ]); + + expect(engine.model.get('panel-a.shared-bar')).toBeTruthy(); + expect(engine.model.get('panel-b.shared-bar')).toBeTruthy(); + + engine.update({ + elements: engine.selector('$..[?(@.id=="panel-a")]')[0], + changes: { + components: [{ type: 'bar', id: 'shared-bar', size: '80%' }], + }, + validateSchema: false, + }); + + expect(engine.model.get('panel-a.shared-bar').props.size).toBe('80%'); + expect(engine.model.get('panel-b.shared-bar').props.size.height).toEqual({ + value: 50, + unit: '%', + }); + }); + it('moves a panel owner back to Pixi fallback policy when icon becomes visible', () => { const engine = new PatchMapV2Engine(); engine.draw(createPanelData()); diff --git a/src/v2/model/createV2Model.js b/src/v2/model/createV2Model.js index a048ab13..b36998fc 100644 --- a/src/v2/model/createV2Model.js +++ b/src/v2/model/createV2Model.js @@ -108,8 +108,13 @@ const addComponents = (store, itemProps, depth) => { ...component, id: component.id ?? `${itemProps.id}.${component.type}.${index}`, }); + const componentId = createComponentRecordId( + itemProps.id, + componentProps, + index, + ); store.add({ - id: componentProps.id, + id: componentId, type: componentProps.type, kind: 'component', parentId: itemProps.id, @@ -120,6 +125,14 @@ const addComponents = (store, itemProps, depth) => { } }; +const createComponentRecordId = (ownerId, componentProps, index) => { + const componentId = componentProps.id ?? `${componentProps.type}.${index}`; + if (componentId.startsWith(`${ownerId}.`)) { + return componentId; + } + return `${ownerId}.${componentId}`; +}; + const seedStableComponentIds = (element) => { if (!element || typeof element !== 'object') return element; diff --git a/src/v2/model/updateV2Model.js b/src/v2/model/updateV2Model.js index 2346b64f..4a65d6ba 100644 --- a/src/v2/model/updateV2Model.js +++ b/src/v2/model/updateV2Model.js @@ -98,7 +98,7 @@ const findComponentRecord = (records, change, used) => { const match = records.find( (record) => !used.has(record.id) && - ((change.id && record.id === change.id) || + ((change.id && isComponentIdMatch(record, change.id)) || (change.label && record.label === change.label)), ); if (match) return match; @@ -110,6 +110,11 @@ const findComponentRecord = (records, change, used) => { return null; }; +const isComponentIdMatch = (record, id) => + record.id === id || + record.props?.id === id || + record.id === `${record.parentId}.${id}`; + const mergeComponentProps = (props, change, opts) => opts.mergeStrategy === 'replace' ? applyComponentDefaults({ type: props.type, ...change }) From f510609e177855940095f95318e8872b43879a7a Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 11:31:38 +0900 Subject: [PATCH 36/51] perf: batch patchmap v2 silent updates --- src/patchmap.js | 40 ++++++++++++++++++++--- src/tests/render/patchmap-v2-mode.test.js | 24 ++++++++++++++ src/v2/PatchMapV2Engine.js | 17 +++++++++- src/v2/PatchMapV2Engine.test.js | 31 ++++++++++++++++++ 4 files changed, 107 insertions(+), 5 deletions(-) diff --git a/src/patchmap.js b/src/patchmap.js index fc43d769..cbe1ce1a 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -46,6 +46,7 @@ class Patchmap extends WildcardEventEmitter { _engineMode = 'legacy'; _v2Engine = null; _v2Renderer = null; + _v2RenderScheduled = false; get app() { return this._app; @@ -204,6 +205,7 @@ class Patchmap extends WildcardEventEmitter { this._engineMode = 'legacy'; this._v2Engine = null; this._v2Renderer = null; + this._v2RenderScheduled = false; this.emit('patchmap:destroyed', { target: this }); this.removeAllListeners(); } @@ -282,10 +284,16 @@ class Patchmap extends WildcardEventEmitter { update(opts = {}) { if (this._engineMode === 'v2') { - const updatedElements = this._v2Engine.update(opts); - const snapshot = - this._v2Engine.scheduler.flush() ?? this._v2Engine.snapshot(); - this._v2Renderer.render(snapshot); + const deferRender = opts.emit === false && opts.flush !== true; + const updatedElements = this._v2Engine.update({ + ...opts, + deferRender, + }); + if (deferRender) { + this._scheduleV2Render(); + } else { + this._flushV2Render(); + } if (opts.emit !== false) { this.emit('patchmap:updated', { elements: updatedElements, @@ -312,6 +320,7 @@ class Patchmap extends WildcardEventEmitter { */ focus(ids, opts) { if (this._engineMode === 'v2') { + this._flushV2Render(); return focus(this.viewport, this._v2Engine.model?.root?.ref, ids, opts); } return focus(this.viewport, this.world, ids, opts); @@ -324,6 +333,7 @@ class Patchmap extends WildcardEventEmitter { */ fit(ids, opts) { if (this._engineMode === 'v2') { + this._flushV2Render(); return fitViewport( this.viewport, this._v2Engine.model?.root?.ref, @@ -344,6 +354,7 @@ class Patchmap extends WildcardEventEmitter { selector(path, opts) { if (this._engineMode === 'v2') { + this._flushV2Render(); return this._v2Engine.selector(path, opts); } return selector(this.world, path, opts); @@ -369,6 +380,27 @@ class Patchmap extends WildcardEventEmitter { animationContext: this.animationContext, }; } + + _scheduleV2Render() { + if (this._v2RenderScheduled) return; + this._v2RenderScheduled = true; + const schedule = + typeof requestAnimationFrame === 'function' + ? requestAnimationFrame + : (callback) => setTimeout(callback, 0); + schedule(() => { + this._v2RenderScheduled = false; + if (this.isInit && this._engineMode === 'v2') { + this._flushV2Render(); + } + }); + } + + _flushV2Render() { + if (!this._v2Engine || !this._v2Renderer) return; + const snapshot = this._v2Engine.flush(); + this._v2Renderer.render(snapshot); + } } function createDrawCacheKey(data) { diff --git a/src/tests/render/patchmap-v2-mode.test.js b/src/tests/render/patchmap-v2-mode.test.js index 5dfd6a5b..bee60670 100644 --- a/src/tests/render/patchmap-v2-mode.test.js +++ b/src/tests/render/patchmap-v2-mode.test.js @@ -110,4 +110,28 @@ describe('Patchmap v2 opt-in mode', () => { expect(hit).toMatchObject({ id: 'panel-1', type: 'item' }); }); + + it('batches emit:false v2 updates into the next frame', async () => { + patchmap.draw(createPanelData()); + const [item] = patchmap.selector('$..[?(@.id=="panel-1")]'); + const barParticle = + patchmap._v2Renderer.aggregateLayers.bar.particleChildren[0]; + + patchmap.update({ + elements: item, + changes: { + components: [{ type: 'bar', size: { height: '80%' } }], + }, + validateSchema: false, + emit: false, + }); + + expect(patchmap._v2Engine.dirty).toBe(true); + expect(barParticle.scaleY).toBe(25); + + await new Promise((resolve) => requestAnimationFrame(resolve)); + + expect(patchmap._v2Engine.dirty).toBe(false); + expect(barParticle.scaleY).toBe(40); + }); }); diff --git a/src/v2/PatchMapV2Engine.js b/src/v2/PatchMapV2Engine.js index 1bf9d1b0..60d93083 100644 --- a/src/v2/PatchMapV2Engine.js +++ b/src/v2/PatchMapV2Engine.js @@ -17,10 +17,12 @@ export class PatchMapV2Engine { this.renderPlan = null; this.scheduler = new V2RenderScheduler(); this.revision = 0; + this.dirty = false; } draw(data) { this.model = createV2Model(data); + this.dirty = false; this.#refreshDerivedState(); return this.snapshot(); } @@ -29,7 +31,12 @@ export class PatchMapV2Engine { if (!this.model) return []; const updated = updateV2Model(this.model, opts); if (updated.length > 0) { - this.#refreshDerivedState(); + if (opts.deferRender) { + this.dirty = true; + } else { + this.dirty = false; + this.#refreshDerivedState(); + } } return updated.map((record) => record.ref); } @@ -50,6 +57,14 @@ export class PatchMapV2Engine { }; } + flush() { + if (this.dirty) { + this.dirty = false; + this.#refreshDerivedState(); + } + return this.scheduler.flush() ?? this.snapshot(); + } + #refreshDerivedState() { const previousIR = this.renderIR; this.layout = createV2Layout(this.model); diff --git a/src/v2/PatchMapV2Engine.test.js b/src/v2/PatchMapV2Engine.test.js index da9eb952..a4649dde 100644 --- a/src/v2/PatchMapV2Engine.test.js +++ b/src/v2/PatchMapV2Engine.test.js @@ -245,4 +245,35 @@ describe('PatchMapV2Engine', () => { expect(flushed.revision).toBe(engine.revision); expect(latestBar.frame.height).toBeCloseTo(28.8); }); + + it('defers repeated model updates until an explicit v2 flush', () => { + const engine = new PatchMapV2Engine(); + engine.draw(createPanelData()); + const initialRevision = engine.revision; + const [firstPanel] = engine.selector('$..[?(@.id=="panel-grid.0.0")]'); + + engine.update({ + elements: firstPanel, + changes: { components: [{ type: 'bar', size: { height: '20%' } }] }, + validateSchema: false, + deferRender: true, + }); + engine.update({ + elements: firstPanel, + changes: { components: [{ type: 'bar', size: { height: '80%' } }] }, + validateSchema: false, + deferRender: true, + }); + + expect(engine.revision).toBe(initialRevision); + expect(engine.dirty).toBe(true); + + const snapshot = engine.flush(); + const latestBar = snapshot.renderIR.byFeature + .get('bar') + .find((node) => node.ownerId === 'panel-grid.0.0'); + + expect(engine.revision).toBe(initialRevision + 1); + expect(latestBar.frame.height).toBeCloseTo(28.8); + }); }); From 529c10d7d6c7ca48092515b05be9885ad32d331f Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 11:36:15 +0900 Subject: [PATCH 37/51] perf: fast path patchmap v2 component updates --- src/v2/model/updateV2Model.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/v2/model/updateV2Model.js b/src/v2/model/updateV2Model.js index 4a65d6ba..3d42d4fd 100644 --- a/src/v2/model/updateV2Model.js +++ b/src/v2/model/updateV2Model.js @@ -8,6 +8,12 @@ export const updateV2Model = (model, opts = {}) => { const updated = []; for (const target of targets) { + if (canUseComponentOnlyUpdate(target, changes, opts)) { + updateComponents(model, target, changes.components, opts); + updated.push(model.get(target.id) ?? target); + continue; + } + const nextProps = opts.mergeStrategy === 'replace' ? { type: target.type, ...changes } @@ -24,6 +30,12 @@ export const updateV2Model = (model, opts = {}) => { return updated; }; +const canUseComponentOnlyUpdate = (target, changes, opts) => + target.type === 'item' && + Array.isArray(changes.components) && + opts.mergeStrategy !== 'replace' && + Object.keys(changes).every((key) => key === 'components'); + const resolveTargets = (model, opts) => { const directElements = normalizeElements(opts.elements) .map((element) => resolveElementRecord(model, element)) From e98e208bed91d50d596687fa98303272307c5cbf Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 11:40:57 +0900 Subject: [PATCH 38/51] perf: incrementally flush patchmap v2 dirty owners --- src/v2/PatchMapV2Engine.js | 87 +++++++++++++++++++++++++++- src/v2/backend/V2PixiRenderer.js | 59 +++++++++++++++++++ src/v2/layout/createV2Layout.js | 16 ++++- src/v2/render-ir/createV2RenderIR.js | 19 +++--- 4 files changed, 168 insertions(+), 13 deletions(-) diff --git a/src/v2/PatchMapV2Engine.js b/src/v2/PatchMapV2Engine.js index 60d93083..8ace0a4e 100644 --- a/src/v2/PatchMapV2Engine.js +++ b/src/v2/PatchMapV2Engine.js @@ -1,7 +1,11 @@ -import { createV2Layout } from './layout/createV2Layout'; +import { createV2Layout, layoutV2Node } from './layout/createV2Layout'; import { createV2Model } from './model/createV2Model'; import { updateV2Model } from './model/updateV2Model'; -import { createV2RenderIR } from './render-ir/createV2RenderIR'; +import { + createV2RenderIR, + createV2RenderNode, + groupV2NodesByFeature, +} from './render-ir/createV2RenderIR'; import { diffV2RenderIR } from './render-ir/diffV2RenderIR'; import { createV2RenderPlan } from './render-policy/createV2RenderPlan'; import { V2RenderScheduler } from './scheduler/V2RenderScheduler'; @@ -18,11 +22,13 @@ export class PatchMapV2Engine { this.scheduler = new V2RenderScheduler(); this.revision = 0; this.dirty = false; + this.dirtyOwnerIds = new Set(); } draw(data) { this.model = createV2Model(data); this.dirty = false; + this.dirtyOwnerIds.clear(); this.#refreshDerivedState(); return this.snapshot(); } @@ -33,8 +39,12 @@ export class PatchMapV2Engine { if (updated.length > 0) { if (opts.deferRender) { this.dirty = true; + for (const record of updated) { + this.dirtyOwnerIds.add(record.id); + } } else { this.dirty = false; + this.dirtyOwnerIds.clear(); this.#refreshDerivedState(); } } @@ -60,11 +70,82 @@ export class PatchMapV2Engine { flush() { if (this.dirty) { this.dirty = false; - this.#refreshDerivedState(); + if (!this.#refreshDirtyOwners()) { + this.#refreshDerivedState(); + } + this.dirtyOwnerIds.clear(); } return this.scheduler.flush() ?? this.snapshot(); } + #refreshDirtyOwners() { + if (!this.layout || !this.renderIR || this.dirtyOwnerIds.size === 0) { + return false; + } + if ( + this.dirtyOwnerIds.size > 1000 || + this.dirtyOwnerIds.size > this.model.records.size / 2 + ) { + return false; + } + + const previousNodes = this.renderIR.nodes.filter((node) => + this.dirtyOwnerIds.has(node.ownerId), + ); + const nextNodes = []; + + for (const ownerId of this.dirtyOwnerIds) { + const owner = this.model.get(ownerId); + if (!owner) continue; + layoutV2Node(this.model, this.layout.frames, owner); + const ownerNode = createV2RenderNode( + owner, + this.layout.getFrame(owner.id), + ); + if (ownerNode) nextNodes.push(ownerNode); + for (const component of this.model.getComponents(owner.id)) { + const node = createV2RenderNode( + component, + this.layout.getFrame(component.id), + ); + if (node) nextNodes.push(node); + } + } + + const dirtyOwnerIds = new Set(this.dirtyOwnerIds); + this.renderIR = { + nodes: [ + ...this.renderIR.nodes.filter( + (node) => !dirtyOwnerIds.has(node.ownerId), + ), + ...nextNodes, + ], + byFeature: groupV2NodesByFeature([ + ...this.renderIR.nodes.filter( + (node) => !dirtyOwnerIds.has(node.ownerId), + ), + ...nextNodes, + ]), + }; + this.renderDiff = diffV2RenderIR( + { nodes: previousNodes }, + { nodes: nextNodes }, + ); + this.renderPlan = createV2RenderPlan(this.model, { nodes: nextNodes }); + this.scheduler.enqueue({ + revision: this.revision + 1, + model: this.model, + layout: this.layout, + renderIR: this.renderIR, + renderDiff: this.renderDiff, + renderPlan: this.renderPlan, + incremental: true, + }); + this.revision += 1; + this.model.syncCompatibilityRefs(this.layout, this.store); + return true; + } + #refreshDerivedState() { const previousIR = this.renderIR; this.layout = createV2Layout(this.model); diff --git a/src/v2/backend/V2PixiRenderer.js b/src/v2/backend/V2PixiRenderer.js index 6f55b68c..65d4fd8f 100644 --- a/src/v2/backend/V2PixiRenderer.js +++ b/src/v2/backend/V2PixiRenderer.js @@ -34,6 +34,11 @@ export class V2PixiRenderer { this.attach(); if (!snapshot?.renderIR) return; + if (snapshot.incremental) { + this.#renderIncremental(snapshot); + return; + } + const plan = snapshot.renderPlan; const aggregateNodes = [ ...(plan?.aggregateBackgrounds ?? []), @@ -96,6 +101,35 @@ export class V2PixiRenderer { } } + #renderIncremental(snapshot) { + const plan = snapshot.renderPlan; + const aggregateNodes = [ + ...(plan?.aggregateBackgrounds ?? []), + ...(plan?.aggregateBars ?? []), + ]; + const normalNodes = plan + ? [...plan.pixiNodes, ...plan.relations] + : [ + ...(snapshot.renderDiff?.added ?? []), + ...(snapshot.renderDiff?.updated ?? []), + ]; + + for (const node of snapshot.renderDiff?.removed ?? []) { + this.#removeNode(node.id); + this.#removeParticle(node.id); + } + for (const node of aggregateNodes) { + this.#removeNode(node.id); + this.#upsertParticle(node); + } + for (const node of normalNodes) { + this.#removeParticle(node.id); + this.#upsertNode(node); + } + this.aggregateLayers.background.update(); + this.aggregateLayers.bar.update(); + } + #syncAggregateLayer(kind, nodes) { const layer = this.aggregateLayers[kind]; const wantedIds = new Set(nodes.map((node) => node.id)); @@ -123,6 +157,31 @@ export class V2PixiRenderer { layer.update(); } + #upsertParticle(node) { + const kind = node.layer === 'background' ? 'background' : 'bar'; + const layer = this.aggregateLayers[kind]; + let particle = this.particlesById.get(node.id); + if (!particle) { + particle = new Particle({ texture: Texture.WHITE }); + particle._patchmapV2NodeId = node.id; + particle._patchmapV2Kind = kind; + this.particlesById.set(node.id, particle); + layer.particleChildren.push(particle); + } + applyNodeToParticle(particle, node, this.store); + } + + #removeParticle(id) { + const particle = this.particlesById.get(id); + if (!particle) return; + const layer = this.aggregateLayers[particle._patchmapV2Kind]; + const index = layer.particleChildren.indexOf(particle); + if (index !== -1) { + layer.particleChildren.splice(index, 1); + } + this.particlesById.delete(id); + } + #getParticleKind(id) { return this.particlesById.get(id)?._patchmapV2Kind; } diff --git a/src/v2/layout/createV2Layout.js b/src/v2/layout/createV2Layout.js index eb45b576..13fe33a9 100644 --- a/src/v2/layout/createV2Layout.js +++ b/src/v2/layout/createV2Layout.js @@ -25,7 +25,19 @@ export const createV2Layout = (model) => { }; }; -const layoutNode = (model, frames, record, parentFrame) => { +export const layoutV2Node = (model, frames, record) => { + const parentFrame = frames.get(record.parentId) ?? frames.get(model.root.id); + if (!parentFrame) return; + layoutNode(model, frames, record, parentFrame, { recursive: false }); +}; + +const layoutNode = ( + model, + frames, + record, + parentFrame, + { recursive = true } = {}, +) => { const attrs = record.props.attrs ?? {}; const size = resolveElementSize(record); const frame = { @@ -47,6 +59,8 @@ const layoutNode = (model, frames, record, parentFrame) => { } } + if (!recursive) return; + for (const child of model.getChildren(record.id)) { if (child.kind === 'component') continue; layoutNode(model, frames, child, frame); diff --git a/src/v2/render-ir/createV2RenderIR.js b/src/v2/render-ir/createV2RenderIR.js index 904b0b9e..7a5d1166 100644 --- a/src/v2/render-ir/createV2RenderIR.js +++ b/src/v2/render-ir/createV2RenderIR.js @@ -6,22 +6,23 @@ export const createV2RenderIR = (model, layout) => { const frame = layout.getFrame(record.id); if (!frame?.visible) continue; - if (record.kind === 'component') { - const node = createComponentNode(record, frame); - if (node) nodes.push(node); - continue; - } - - const node = createElementNode(record, frame); + const node = createV2RenderNode(record, frame); if (node) nodes.push(node); } return { nodes, - byFeature: groupByFeature(nodes), + byFeature: groupV2NodesByFeature(nodes), }; }; +export const createV2RenderNode = (record, frame) => { + if (record.kind === 'component') { + return createComponentNode(record, frame); + } + return createElementNode(record, frame); +}; + const createElementNode = (record, frame) => { if (record.type === 'rect') { return { @@ -138,7 +139,7 @@ const createComponentNode = (record, frame) => { return null; }; -const groupByFeature = (nodes) => { +export const groupV2NodesByFeature = (nodes) => { const groups = new Map(); for (const node of nodes) { let group = groups.get(node.feature); From 254472d1ced8d8bdebaa5177beb4e36a93cbfd01 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 11:43:33 +0900 Subject: [PATCH 39/51] perf: reduce patchmap v2 update merge overhead --- src/v2/model/updateV2Model.js | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/v2/model/updateV2Model.js b/src/v2/model/updateV2Model.js index 3d42d4fd..bb7554ce 100644 --- a/src/v2/model/updateV2Model.js +++ b/src/v2/model/updateV2Model.js @@ -1,5 +1,4 @@ import { applyComponentDefaults } from '../../display/default-props'; -import { deepMerge } from '../../utils/deepmerge/deepmerge'; export const updateV2Model = (model, opts = {}) => { const targets = resolveTargets(model, opts); @@ -17,9 +16,7 @@ export const updateV2Model = (model, opts = {}) => { const nextProps = opts.mergeStrategy === 'replace' ? { type: target.type, ...changes } - : deepMerge(structuredClone(target.props), changes, { - mergeStrategy: 'merge', - }); + : mergePatch(target.props, changes); const nextRecord = model.replaceRecordProps(target.id, nextProps); if (!nextRecord) continue; if (Array.isArray(changes.components) && nextRecord.type === 'item') { @@ -130,7 +127,26 @@ const isComponentIdMatch = (record, id) => const mergeComponentProps = (props, change, opts) => opts.mergeStrategy === 'replace' ? applyComponentDefaults({ type: props.type, ...change }) - : deepMerge(structuredClone(props), change, { mergeStrategy: 'merge' }); + : mergePatch(props, change); + +const mergePatch = (target, source) => { + if (source === undefined) return target; + if (!isMergeableObject(target) || !isMergeableObject(source)) { + return source; + } + + const out = { ...target }; + for (const key of Object.keys(source)) { + out[key] = mergePatch(out[key], source[key]); + } + return out; +}; + +const isMergeableObject = (value) => + value && + typeof value === 'object' && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype; const mergeParentComponents = (current = [], next = []) => { const byIdOrType = new Map(); From f3145561f155ce44f009886de5ceff4239509178 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 11:46:34 +0900 Subject: [PATCH 40/51] perf: queue patchmap v2 silent updates by frame budget --- src/patchmap.js | 56 ++++++++++++++++++++--- src/tests/render/patchmap-v2-mode.test.js | 4 +- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/patchmap.js b/src/patchmap.js index cbe1ce1a..3343b99d 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -47,6 +47,7 @@ class Patchmap extends WildcardEventEmitter { _v2Engine = null; _v2Renderer = null; _v2RenderScheduled = false; + _v2UpdateQueue = []; get app() { return this._app; @@ -206,6 +207,7 @@ class Patchmap extends WildcardEventEmitter { this._v2Engine = null; this._v2Renderer = null; this._v2RenderScheduled = false; + this._v2UpdateQueue = []; this.emit('patchmap:destroyed', { target: this }); this.removeAllListeners(); } @@ -285,15 +287,18 @@ class Patchmap extends WildcardEventEmitter { update(opts = {}) { if (this._engineMode === 'v2') { const deferRender = opts.emit === false && opts.flush !== true; - const updatedElements = this._v2Engine.update({ - ...opts, - deferRender, - }); if (deferRender) { + const targets = this._resolveV2UpdateTargets(opts); + this._v2UpdateQueue.push(opts); this._scheduleV2Render(); - } else { - this._flushV2Render(); + return targets; } + this._flushV2UpdateQueue(); + const updatedElements = this._v2Engine.update({ + ...opts, + deferRender: false, + }); + this._flushV2Render(); if (opts.emit !== false) { this.emit('patchmap:updated', { elements: updatedElements, @@ -320,6 +325,7 @@ class Patchmap extends WildcardEventEmitter { */ focus(ids, opts) { if (this._engineMode === 'v2') { + this._flushV2UpdateQueue(); this._flushV2Render(); return focus(this.viewport, this._v2Engine.model?.root?.ref, ids, opts); } @@ -333,6 +339,7 @@ class Patchmap extends WildcardEventEmitter { */ fit(ids, opts) { if (this._engineMode === 'v2') { + this._flushV2UpdateQueue(); this._flushV2Render(); return fitViewport( this.viewport, @@ -354,6 +361,7 @@ class Patchmap extends WildcardEventEmitter { selector(path, opts) { if (this._engineMode === 'v2') { + this._flushV2UpdateQueue(); this._flushV2Render(); return this._v2Engine.selector(path, opts); } @@ -391,16 +399,50 @@ class Patchmap extends WildcardEventEmitter { schedule(() => { this._v2RenderScheduled = false; if (this.isInit && this._engineMode === 'v2') { - this._flushV2Render(); + this._processV2UpdateQueue(); } }); } + _processV2UpdateQueue() { + const frameBudgetMs = 4; + const startedAt = performance.now(); + while (this._v2UpdateQueue.length > 0) { + const opts = this._v2UpdateQueue.shift(); + this._v2Engine.update({ ...opts, deferRender: true }); + if (performance.now() - startedAt >= frameBudgetMs) { + this._scheduleV2Render(); + return; + } + } + this._flushV2Render(); + } + + _flushV2UpdateQueue() { + while (this._v2UpdateQueue.length > 0) { + const opts = this._v2UpdateQueue.shift(); + this._v2Engine.update({ ...opts, deferRender: true }); + } + } + _flushV2Render() { if (!this._v2Engine || !this._v2Renderer) return; const snapshot = this._v2Engine.flush(); this._v2Renderer.render(snapshot); } + + _resolveV2UpdateTargets(opts) { + const elements = []; + if (opts.elements) { + elements.push( + ...(Array.isArray(opts.elements) ? opts.elements : [opts.elements]), + ); + } + if (opts.path) { + elements.push(...this._v2Engine.selector(opts.path)); + } + return [...new Set(elements.filter(Boolean))]; + } } function createDrawCacheKey(data) { diff --git a/src/tests/render/patchmap-v2-mode.test.js b/src/tests/render/patchmap-v2-mode.test.js index bee60670..6cdcd685 100644 --- a/src/tests/render/patchmap-v2-mode.test.js +++ b/src/tests/render/patchmap-v2-mode.test.js @@ -126,11 +126,13 @@ describe('Patchmap v2 opt-in mode', () => { emit: false, }); - expect(patchmap._v2Engine.dirty).toBe(true); + expect(patchmap._v2UpdateQueue).toHaveLength(1); expect(barParticle.scaleY).toBe(25); + await new Promise((resolve) => requestAnimationFrame(resolve)); await new Promise((resolve) => requestAnimationFrame(resolve)); + expect(patchmap._v2UpdateQueue).toHaveLength(0); expect(patchmap._v2Engine.dirty).toBe(false); expect(barParticle.scaleY).toBe(40); }); From d8b9e4ae50fe34a92740edabd12e5c9a723eef19 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 11:51:35 +0900 Subject: [PATCH 41/51] perf: query patchmap v2 hit targets by frame --- src/events/find.js | 13 +++++++ src/v2/model/V2ModelStore.js | 72 ++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/events/find.js b/src/events/find.js index 4411b56c..46e193d0 100644 --- a/src/events/find.js +++ b/src/events/find.js @@ -242,6 +242,13 @@ const getIndexedPointCandidates = (parent, point, config = {}) => { return null; } + if (sceneIndex.queryPoint) { + return sceneIndex + .queryPoint(point, config, parent) + .filter((candidate) => canResolveCandidate(candidate, config)) + .sort((a, b) => compareCandidatesByDisplayOrder(parent, a, b)); + } + const entries = getBoundsCandidateEntries(parent, sceneIndex); const candidates = []; for (const entry of entries) { @@ -270,6 +277,12 @@ const getIndexedPolygonCandidates = (parent, selectionBounds, config = {}) => { return null; } + if (sceneIndex.queryBounds) { + return sceneIndex + .queryBounds(selectionBounds, config, parent) + .filter((candidate) => canResolveCandidate(candidate, config)); + } + const entries = getBoundsCandidateEntries(parent, sceneIndex); const candidates = []; for (const entry of entries) { diff --git a/src/v2/model/V2ModelStore.js b/src/v2/model/V2ModelStore.js index 4fa580e8..ddcc6702 100644 --- a/src/v2/model/V2ModelStore.js +++ b/src/v2/model/V2ModelStore.js @@ -273,6 +273,26 @@ const createSceneIndexAdapter = (store) => ({ getByDisplay(display) { return recordsToRefs(store.getByDisplay(display)); }, + queryPoint(point, _config, parent) { + const localPoint = parent?.toLocal ? parent.toLocal(point) : null; + return querySelectableFrames( + store, + (bounds) => + boundsContainPoint(bounds, point) || + (localPoint && boundsContainPoint(bounds, localPoint)), + ); + }, + queryBounds(bounds, _config, parent) { + const localBounds = parent?.toLocal + ? convertBoundsToLocal(bounds, parent) + : null; + return querySelectableFrames( + store, + (candidateBounds) => + boundsIntersect(bounds, candidateBounds) || + (localBounds && boundsIntersect(localBounds, candidateBounds)), + ); + }, add() {}, update() {}, remove() {}, @@ -297,6 +317,58 @@ const createModelIndexAdapter = (store) => ({ touch() {}, }); +const querySelectableFrames = (store, predicate) => { + const refs = []; + for (const id of store.selectableIds) { + const record = store.get(id); + if (!record || !record.ref.visible || !predicate(getRecordBounds(record))) { + continue; + } + refs.push(record.ref); + } + return refs; +}; + +const getRecordBounds = (record) => { + const frame = record.ref._v2Layout?.getFrame(record.id); + const x = frame?.x ?? record.attrs?.x ?? 0; + const y = frame?.y ?? record.attrs?.y ?? 0; + const width = frame?.width ?? record.props?.size?.width ?? 0; + const height = frame?.height ?? record.props?.size?.height ?? 0; + return { + minX: x, + minY: y, + maxX: x + width, + maxY: y + height, + }; +}; + +const boundsContainPoint = (bounds, point) => + point.x >= bounds.minX && + point.x <= bounds.maxX && + point.y >= bounds.minY && + point.y <= bounds.maxY; + +const boundsIntersect = (left, right) => + !left || + !right || + (left.minX <= right.maxX && + left.maxX >= right.minX && + left.minY <= right.maxY && + left.maxY >= right.minY); + +const convertBoundsToLocal = (bounds, parent) => { + if (!bounds) return null; + const topLeft = parent.toLocal({ x: bounds.minX, y: bounds.minY }); + const bottomRight = parent.toLocal({ x: bounds.maxX, y: bounds.maxY }); + return { + minX: Math.min(topLeft.x, bottomRight.x), + minY: Math.min(topLeft.y, bottomRight.y), + maxX: Math.max(topLeft.x, bottomRight.x), + maxY: Math.max(topLeft.y, bottomRight.y), + }; +}; + const matchExactIdPath = (path) => { const match = path.match(/^\$..\[\?\(@\.id\s*={2,3}\s*(["'])([^"']+)\1\)\]$/); return match?.[2] ?? null; From a4d9b10d0f1b5f47acec40e1c2c35a8e1b791035 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 11:53:12 +0900 Subject: [PATCH 42/51] fix: fall back when v2 frame query misses --- src/events/find.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/events/find.js b/src/events/find.js index 46e193d0..944f00f1 100644 --- a/src/events/find.js +++ b/src/events/find.js @@ -243,10 +243,11 @@ const getIndexedPointCandidates = (parent, point, config = {}) => { } if (sceneIndex.queryPoint) { - return sceneIndex + const queried = sceneIndex .queryPoint(point, config, parent) .filter((candidate) => canResolveCandidate(candidate, config)) .sort((a, b) => compareCandidatesByDisplayOrder(parent, a, b)); + if (queried.length > 0) return queried; } const entries = getBoundsCandidateEntries(parent, sceneIndex); @@ -278,9 +279,10 @@ const getIndexedPolygonCandidates = (parent, selectionBounds, config = {}) => { } if (sceneIndex.queryBounds) { - return sceneIndex + const queried = sceneIndex .queryBounds(selectionBounds, config, parent) .filter((candidate) => canResolveCandidate(candidate, config)); + if (queried.length > 0) return queried; } const entries = getBoundsCandidateEntries(parent, sceneIndex); From 485dbc8f984021c8c8303e858630ea7864692274 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 12:11:44 +0900 Subject: [PATCH 43/51] refactor: promote patchmap engine implementation --- .../PatchMapEngine.js} | 51 +++-- .../PatchMapEngine.test.js} | 26 +-- .../backend/PixiRenderer.js} | 34 +-- .../backend/PixiRenderer.test.js} | 26 +-- .../compat/CompatibilityRef.js} | 42 ++-- src/engine/index.js | 9 + .../layout/createLayout.js} | 14 +- .../model/ModelStore.js} | 16 +- .../model/createModel.js} | 6 +- .../model/updateModel.js} | 4 +- .../render-ir/createRenderIR.js} | 10 +- .../render-ir/diffRenderIR.js} | 2 +- .../render-policy/createRenderPlan.js} | 2 +- .../scheduler/RenderScheduler.js} | 2 +- src/patchmap.js | 201 +++++++----------- ...2-mode.test.js => patchmap-engine.test.js} | 21 +- src/v2/index.js | 9 - 17 files changed, 217 insertions(+), 258 deletions(-) rename src/{v2/PatchMapV2Engine.js => engine/PatchMapEngine.js} (73%) rename src/{v2/PatchMapV2Engine.test.js => engine/PatchMapEngine.test.js} (92%) rename src/{v2/backend/V2PixiRenderer.js => engine/backend/PixiRenderer.js} (90%) rename src/{v2/backend/V2PixiRenderer.test.js => engine/backend/PixiRenderer.test.js} (84%) rename src/{v2/compat/V2CompatibilityRef.js => engine/compat/CompatibilityRef.js} (71%) create mode 100644 src/engine/index.js rename src/{v2/layout/createV2Layout.js => engine/layout/createLayout.js} (93%) rename src/{v2/model/V2ModelStore.js => engine/model/ModelStore.js} (96%) rename src/{v2/model/createV2Model.js => engine/model/createModel.js} (97%) rename src/{v2/model/updateV2Model.js => engine/model/updateModel.js} (97%) rename src/{v2/render-ir/createV2RenderIR.js => engine/render-ir/createRenderIR.js} (92%) rename src/{v2/render-ir/diffV2RenderIR.js => engine/render-ir/diffRenderIR.js} (96%) rename src/{v2/render-policy/createV2RenderPlan.js => engine/render-policy/createRenderPlan.js} (97%) rename src/{v2/scheduler/V2RenderScheduler.js => engine/scheduler/RenderScheduler.js} (98%) rename src/tests/render/{patchmap-v2-mode.test.js => patchmap-engine.test.js} (82%) delete mode 100644 src/v2/index.js diff --git a/src/v2/PatchMapV2Engine.js b/src/engine/PatchMapEngine.js similarity index 73% rename from src/v2/PatchMapV2Engine.js rename to src/engine/PatchMapEngine.js index 8ace0a4e..92f63703 100644 --- a/src/v2/PatchMapV2Engine.js +++ b/src/engine/PatchMapEngine.js @@ -1,16 +1,16 @@ -import { createV2Layout, layoutV2Node } from './layout/createV2Layout'; -import { createV2Model } from './model/createV2Model'; -import { updateV2Model } from './model/updateV2Model'; +import { createLayout, layoutNode } from './layout/createLayout'; +import { createModel } from './model/createModel'; +import { updateModel } from './model/updateModel'; import { - createV2RenderIR, - createV2RenderNode, - groupV2NodesByFeature, -} from './render-ir/createV2RenderIR'; -import { diffV2RenderIR } from './render-ir/diffV2RenderIR'; -import { createV2RenderPlan } from './render-policy/createV2RenderPlan'; -import { V2RenderScheduler } from './scheduler/V2RenderScheduler'; + createRenderIR, + createRenderNode, + groupNodesByFeature, +} from './render-ir/createRenderIR'; +import { diffRenderIR } from './render-ir/diffRenderIR'; +import { createRenderPlan } from './render-policy/createRenderPlan'; +import { RenderScheduler } from './scheduler/RenderScheduler'; -export class PatchMapV2Engine { +export class PatchMapEngine { constructor({ theme = {}, store = null } = {}) { this.theme = theme; this.store = store; @@ -19,14 +19,14 @@ export class PatchMapV2Engine { this.renderIR = null; this.renderDiff = null; this.renderPlan = null; - this.scheduler = new V2RenderScheduler(); + this.scheduler = new RenderScheduler(); this.revision = 0; this.dirty = false; this.dirtyOwnerIds = new Set(); } draw(data) { - this.model = createV2Model(data); + this.model = createModel(data); this.dirty = false; this.dirtyOwnerIds.clear(); this.#refreshDerivedState(); @@ -35,7 +35,7 @@ export class PatchMapV2Engine { update(opts = {}) { if (!this.model) return []; - const updated = updateV2Model(this.model, opts); + const updated = updateModel(this.model, opts); if (updated.length > 0) { if (opts.deferRender) { this.dirty = true; @@ -97,14 +97,11 @@ export class PatchMapV2Engine { for (const ownerId of this.dirtyOwnerIds) { const owner = this.model.get(ownerId); if (!owner) continue; - layoutV2Node(this.model, this.layout.frames, owner); - const ownerNode = createV2RenderNode( - owner, - this.layout.getFrame(owner.id), - ); + layoutNode(this.model, this.layout.frames, owner); + const ownerNode = createRenderNode(owner, this.layout.getFrame(owner.id)); if (ownerNode) nextNodes.push(ownerNode); for (const component of this.model.getComponents(owner.id)) { - const node = createV2RenderNode( + const node = createRenderNode( component, this.layout.getFrame(component.id), ); @@ -120,18 +117,18 @@ export class PatchMapV2Engine { ), ...nextNodes, ], - byFeature: groupV2NodesByFeature([ + byFeature: groupNodesByFeature([ ...this.renderIR.nodes.filter( (node) => !dirtyOwnerIds.has(node.ownerId), ), ...nextNodes, ]), }; - this.renderDiff = diffV2RenderIR( + this.renderDiff = diffRenderIR( { nodes: previousNodes }, { nodes: nextNodes }, ); - this.renderPlan = createV2RenderPlan(this.model, { nodes: nextNodes }); + this.renderPlan = createRenderPlan(this.model, { nodes: nextNodes }); this.scheduler.enqueue({ revision: this.revision + 1, model: this.model, @@ -148,13 +145,13 @@ export class PatchMapV2Engine { #refreshDerivedState() { const previousIR = this.renderIR; - this.layout = createV2Layout(this.model); + this.layout = createLayout(this.model); this.model.syncCompatibilityRefs(this.layout, this.store); - this.renderIR = createV2RenderIR(this.model, this.layout, { + this.renderIR = createRenderIR(this.model, this.layout, { theme: this.theme, }); - this.renderDiff = diffV2RenderIR(previousIR, this.renderIR); - this.renderPlan = createV2RenderPlan(this.model, this.renderIR); + this.renderDiff = diffRenderIR(previousIR, this.renderIR); + this.renderPlan = createRenderPlan(this.model, this.renderIR); this.scheduler.enqueue({ revision: this.revision + 1, model: this.model, diff --git a/src/v2/PatchMapV2Engine.test.js b/src/engine/PatchMapEngine.test.js similarity index 92% rename from src/v2/PatchMapV2Engine.test.js rename to src/engine/PatchMapEngine.test.js index a4649dde..4080417c 100644 --- a/src/v2/PatchMapV2Engine.test.js +++ b/src/engine/PatchMapEngine.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { PatchMapV2Engine } from './PatchMapV2Engine'; +import { PatchMapEngine } from './PatchMapEngine'; const createPanelData = () => [ { @@ -33,9 +33,9 @@ const createPanelData = () => [ }, ]; -describe('PatchMapV2Engine', () => { +describe('PatchMapEngine', () => { it('creates a normalized model with generated grid items and indexes', () => { - const engine = new PatchMapV2Engine(); + const engine = new PatchMapEngine(); const { model } = engine.draw(createPanelData()); expect(model.get('panel-grid')).toMatchObject({ @@ -61,7 +61,7 @@ describe('PatchMapV2Engine', () => { }); it('builds render IR for panel background and aggregateable bars', () => { - const engine = new PatchMapV2Engine(); + const engine = new PatchMapEngine(); const { renderIR, renderPlan } = engine.draw(createPanelData()); const bars = renderIR.byFeature.get('bar'); @@ -89,7 +89,7 @@ describe('PatchMapV2Engine', () => { }); it('applies patch-service style component updates to the model and IR', () => { - const engine = new PatchMapV2Engine(); + const engine = new PatchMapEngine(); engine.draw(createPanelData()); const updated = engine.update({ @@ -122,7 +122,7 @@ describe('PatchMapV2Engine', () => { }); it('keeps selector compatibility refs stable across direct element updates', () => { - const engine = new PatchMapV2Engine(); + const engine = new PatchMapEngine(); engine.draw(createPanelData()); const [item] = engine.selector('$..[?(@.id=="panel-grid.0.0")]'); @@ -134,14 +134,14 @@ describe('PatchMapV2Engine', () => { expect(updated).toEqual([item]); expect(item.display).toBe('selected-panel-item'); - expect(item._v2Record.display).toBe('selected-panel-item'); + expect(item._record.display).toBe('selected-panel-item'); expect(engine.selector('$..[?(@.display=="selected-panel-item")]')).toEqual( [item], ); }); it('supports patch-service type selector paths for relation refreshes', () => { - const engine = new PatchMapV2Engine(); + const engine = new PatchMapEngine(); engine.draw([ ...createPanelData(), { @@ -157,7 +157,7 @@ describe('PatchMapV2Engine', () => { }); it('namespaces duplicated component ids by owner while preserving update matching', () => { - const engine = new PatchMapV2Engine(); + const engine = new PatchMapEngine(); engine.draw([ { type: 'item', @@ -192,7 +192,7 @@ describe('PatchMapV2Engine', () => { }); it('moves a panel owner back to Pixi fallback policy when icon becomes visible', () => { - const engine = new PatchMapV2Engine(); + const engine = new PatchMapEngine(); engine.draw(createPanelData()); engine.update({ @@ -222,7 +222,7 @@ describe('PatchMapV2Engine', () => { it('coalesces repeated render work while keeping the latest revision available', () => { const scheduled = []; - const engine = new PatchMapV2Engine(); + const engine = new PatchMapEngine(); engine.scheduler.schedule = (callback) => scheduled.push(callback); engine.draw(createPanelData()); @@ -246,8 +246,8 @@ describe('PatchMapV2Engine', () => { expect(latestBar.frame.height).toBeCloseTo(28.8); }); - it('defers repeated model updates until an explicit v2 flush', () => { - const engine = new PatchMapV2Engine(); + it('defers repeated model updates until an explicit flush', () => { + const engine = new PatchMapEngine(); engine.draw(createPanelData()); const initialRevision = engine.revision; const [firstPanel] = engine.selector('$..[?(@.id=="panel-grid.0.0")]'); diff --git a/src/v2/backend/V2PixiRenderer.js b/src/engine/backend/PixiRenderer.js similarity index 90% rename from src/v2/backend/V2PixiRenderer.js rename to src/engine/backend/PixiRenderer.js index 65d4fd8f..fb7f5ae9 100644 --- a/src/v2/backend/V2PixiRenderer.js +++ b/src/engine/backend/PixiRenderer.js @@ -8,7 +8,7 @@ import { } from 'pixi.js'; import { getColor } from '../../utils/get'; -export class V2PixiRenderer { +export class PixiRenderer { constructor({ store, target }) { this.store = store; this.target = target ?? store?.world; @@ -144,8 +144,8 @@ export class V2PixiRenderer { let particle = this.particlesById.get(node.id); if (!particle) { particle = new Particle({ texture: Texture.WHITE }); - particle._patchmapV2NodeId = node.id; - particle._patchmapV2Kind = kind; + particle._patchmapNodeId = node.id; + particle._patchmapKind = kind; this.particlesById.set(node.id, particle); } applyNodeToParticle(particle, node, this.store); @@ -163,8 +163,8 @@ export class V2PixiRenderer { let particle = this.particlesById.get(node.id); if (!particle) { particle = new Particle({ texture: Texture.WHITE }); - particle._patchmapV2NodeId = node.id; - particle._patchmapV2Kind = kind; + particle._patchmapNodeId = node.id; + particle._patchmapKind = kind; this.particlesById.set(node.id, particle); layer.particleChildren.push(particle); } @@ -174,7 +174,7 @@ export class V2PixiRenderer { #removeParticle(id) { const particle = this.particlesById.get(id); if (!particle) return; - const layer = this.aggregateLayers[particle._patchmapV2Kind]; + const layer = this.aggregateLayers[particle._patchmapKind]; const index = layer.particleChildren.indexOf(particle); if (index !== -1) { layer.particleChildren.splice(index, 1); @@ -183,7 +183,7 @@ export class V2PixiRenderer { } #getParticleKind(id) { - return this.particlesById.get(id)?._patchmapV2Kind; + return this.particlesById.get(id)?._patchmapKind; } #removeNode(id) { @@ -204,30 +204,30 @@ export class V2PixiRenderer { #createDisplayObject(node) { const texture = resolveNodeTexture(node, this.store) ?? Texture.WHITE; const object = new Sprite(texture); - object.label = `patchmap-v2-${node.id}`; - object._patchmapV2NodeId = node.id; + object.label = `patchmap-${node.id}`; + object._patchmapNodeId = node.id; applySlice(object, texture); return object; } } const createLayers = () => ({ - background: createLayer('patchmap-v2-background-layer'), - bar: createLayer('patchmap-v2-bar-layer'), - fallback: createLayer('patchmap-v2-fallback-layer'), - relations: createLayer('patchmap-v2-relations-layer'), + background: createLayer('patchmap-background-layer'), + bar: createLayer('patchmap-bar-layer'), + fallback: createLayer('patchmap-fallback-layer'), + relations: createLayer('patchmap-relations-layer'), }); const createLayer = (label) => { const layer = new Container({ label }); layer._patchmapInternal = true; - layer._patchmapV2Layer = true; + layer._patchmapLayer = true; return layer; }; const createAggregateLayers = () => ({ - background: createAggregateLayer('patchmap-v2-aggregate-background-layer'), - bar: createAggregateLayer('patchmap-v2-aggregate-bar-layer'), + background: createAggregateLayer('patchmap-aggregate-background-layer'), + bar: createAggregateLayer('patchmap-aggregate-bar-layer'), }); const createAggregateLayer = (label) => { @@ -243,7 +243,7 @@ const createAggregateLayer = (label) => { }, }); layer._patchmapInternal = true; - layer._patchmapV2AggregateLayer = true; + layer._patchmapAggregateLayer = true; return layer; }; diff --git a/src/v2/backend/V2PixiRenderer.test.js b/src/engine/backend/PixiRenderer.test.js similarity index 84% rename from src/v2/backend/V2PixiRenderer.test.js rename to src/engine/backend/PixiRenderer.test.js index ec4acdfa..120e4a25 100644 --- a/src/v2/backend/V2PixiRenderer.test.js +++ b/src/engine/backend/PixiRenderer.test.js @@ -1,12 +1,12 @@ import { Container, Texture } from 'pixi.js'; import { describe, expect, it } from 'vitest'; -import { PatchMapV2Engine } from '../PatchMapV2Engine'; -import { V2PixiRenderer } from './V2PixiRenderer'; +import { PatchMapEngine } from '../PatchMapEngine'; +import { PixiRenderer } from './PixiRenderer'; -describe('V2PixiRenderer', () => { - it('attaches stable v2 layers in render order and reuses display objects across updates', () => { +describe('PixiRenderer', () => { + it('attaches stable layers in render order and reuses display objects across updates', () => { const world = new Container(); - const renderer = new V2PixiRenderer({ + const renderer = new PixiRenderer({ store: { world, theme: {}, @@ -14,7 +14,7 @@ describe('V2PixiRenderer', () => { }, target: world, }); - const engine = new PatchMapV2Engine(); + const engine = new PatchMapEngine(); const snapshot = engine.draw([ { type: 'item', @@ -39,14 +39,14 @@ describe('V2PixiRenderer', () => { renderer.render(snapshot); expect(world.children.map((child) => child.label)).toEqual([ - 'patchmap-v2-background-layer', - 'patchmap-v2-bar-layer', - 'patchmap-v2-fallback-layer', - 'patchmap-v2-relations-layer', + 'patchmap-background-layer', + 'patchmap-bar-layer', + 'patchmap-fallback-layer', + 'patchmap-relations-layer', ]); const barNode = snapshot.renderIR.byFeature.get('bar')[0]; const barObject = renderer.objectsById.get(barNode.id); - expect(barObject.parent.label).toBe('patchmap-v2-bar-layer'); + expect(barObject.parent.label).toBe('patchmap-bar-layer'); expect(barObject.width).toBe(100); expect(barObject.height).toBe(25); expect(barObject.y).toBe(25); @@ -81,7 +81,7 @@ describe('V2PixiRenderer', () => { it('renders aggregateable backgrounds and bars through particle layers', () => { const world = new Container(); - const renderer = new V2PixiRenderer({ + const renderer = new PixiRenderer({ store: { world, theme: {}, @@ -89,7 +89,7 @@ describe('V2PixiRenderer', () => { }, target: world, }); - const engine = new PatchMapV2Engine(); + const engine = new PatchMapEngine(); const snapshot = engine.draw([ { type: 'grid', diff --git a/src/v2/compat/V2CompatibilityRef.js b/src/engine/compat/CompatibilityRef.js similarity index 71% rename from src/v2/compat/V2CompatibilityRef.js rename to src/engine/compat/CompatibilityRef.js index 1ff9e928..9a070213 100644 --- a/src/v2/compat/V2CompatibilityRef.js +++ b/src/engine/compat/CompatibilityRef.js @@ -1,7 +1,7 @@ import { Matrix, Point, Rectangle } from 'pixi.js'; import { getBoundsFromPoints } from '../../utils/transform'; -export class V2CompatibilityRef { +export class CompatibilityRef { static isElement = true; static isSelectable = true; static isResizable = true; @@ -13,8 +13,8 @@ export class V2CompatibilityRef { children = []; destroyed = false; store = null; - _v2Layout = null; - _v2Record = null; + _layout = null; + _record = null; get x() { return this.#frame.x; @@ -58,7 +58,7 @@ export class V2CompatibilityRef { get #frame() { return ( - this._v2Layout?.getFrame(this.id) ?? { + this._layout?.getFrame(this.id) ?? { x: this.attrs?.x ?? 0, y: this.attrs?.y ?? 0, width: this.props?.size?.width ?? 0, @@ -80,9 +80,7 @@ export class V2CompatibilityRef { } getGlobalPosition(point = new Point()) { - const frame = this.#frame; - point.set(frame.x, frame.y); - return point; + return this.getGlobalTransform(new Matrix()).apply(new Point(0, 0), point); } getGlobalTransform(matrix = new Matrix()) { @@ -90,12 +88,30 @@ export class V2CompatibilityRef { const rotation = frame.rotation ?? 0; const cos = Math.cos(rotation); const sin = Math.sin(rotation); - matrix.a = cos; - matrix.b = sin; - matrix.c = -sin; - matrix.d = cos; - matrix.tx = frame.x; - matrix.ty = frame.y; + const local = { + a: cos, + b: sin, + c: -sin, + d: cos, + tx: frame.x, + ty: frame.y, + }; + const world = this.store?.world?.getGlobalTransform?.(new Matrix(), false); + if (!world) { + matrix.a = local.a; + matrix.b = local.b; + matrix.c = local.c; + matrix.d = local.d; + matrix.tx = local.tx; + matrix.ty = local.ty; + return matrix; + } + matrix.a = world.a * local.a + world.c * local.b; + matrix.b = world.b * local.a + world.d * local.b; + matrix.c = world.a * local.c + world.c * local.d; + matrix.d = world.b * local.c + world.d * local.d; + matrix.tx = world.a * local.tx + world.c * local.ty + world.tx; + matrix.ty = world.b * local.tx + world.d * local.ty + world.ty; return matrix; } diff --git a/src/engine/index.js b/src/engine/index.js new file mode 100644 index 00000000..bc7ec610 --- /dev/null +++ b/src/engine/index.js @@ -0,0 +1,9 @@ +export { PixiRenderer } from './backend/PixiRenderer'; +export { createLayout } from './layout/createLayout'; +export { createModel } from './model/createModel'; +export { updateModel } from './model/updateModel'; +export { PatchMapEngine } from './PatchMapEngine'; +export { createRenderIR } from './render-ir/createRenderIR'; +export { diffRenderIR } from './render-ir/diffRenderIR'; +export { createRenderPlan } from './render-policy/createRenderPlan'; +export { RenderScheduler } from './scheduler/RenderScheduler'; diff --git a/src/v2/layout/createV2Layout.js b/src/engine/layout/createLayout.js similarity index 93% rename from src/v2/layout/createV2Layout.js rename to src/engine/layout/createLayout.js index 13fe33a9..464a68a5 100644 --- a/src/v2/layout/createV2Layout.js +++ b/src/engine/layout/createLayout.js @@ -1,6 +1,6 @@ import { normalizeBoxSpacing } from '../../utils/spacing'; -export const createV2Layout = (model) => { +export const createLayout = (model) => { const frames = new Map(); frames.set(model.root.id, { id: model.root.id, @@ -14,7 +14,7 @@ export const createV2Layout = (model) => { }); for (const child of model.getChildren(model.root.id)) { - layoutNode(model, frames, child, frames.get(model.root.id)); + layoutNodeRecursive(model, frames, child, frames.get(model.root.id)); } return { @@ -25,13 +25,15 @@ export const createV2Layout = (model) => { }; }; -export const layoutV2Node = (model, frames, record) => { +export const layoutNode = (model, frames, record) => { const parentFrame = frames.get(record.parentId) ?? frames.get(model.root.id); if (!parentFrame) return; - layoutNode(model, frames, record, parentFrame, { recursive: false }); + layoutNodeRecursive(model, frames, record, parentFrame, { + recursive: false, + }); }; -const layoutNode = ( +const layoutNodeRecursive = ( model, frames, record, @@ -63,7 +65,7 @@ const layoutNode = ( for (const child of model.getChildren(record.id)) { if (child.kind === 'component') continue; - layoutNode(model, frames, child, frame); + layoutNodeRecursive(model, frames, child, frame); } }; diff --git a/src/v2/model/V2ModelStore.js b/src/engine/model/ModelStore.js similarity index 96% rename from src/v2/model/V2ModelStore.js rename to src/engine/model/ModelStore.js index ddcc6702..4697f06c 100644 --- a/src/v2/model/V2ModelStore.js +++ b/src/engine/model/ModelStore.js @@ -1,6 +1,6 @@ -import { V2CompatibilityRef } from '../compat/V2CompatibilityRef'; +import { CompatibilityRef } from '../compat/CompatibilityRef'; -export class V2ModelStore { +export class ModelStore { constructor() { this.root = createRecord({ id: '$root', @@ -24,10 +24,10 @@ export class V2ModelStore { add(recordInput) { const record = createRecord(recordInput); if (!record?.id) { - throw new Error(`v2 model record requires an id: ${record?.type}`); + throw new Error(`PatchMap model record requires an id: ${record?.type}`); } if (this.records.has(record.id)) { - throw new Error(`Duplicate v2 model id: ${record.id}`); + throw new Error(`Duplicate PatchMap model id: ${record.id}`); } this.records.set(record.id, record); @@ -135,7 +135,7 @@ export class V2ModelStore { syncCompatibilityRefs(layout, store = null) { for (const record of this.records.values()) { const ref = record.ref; - ref._v2Layout = layout; + ref._layout = layout; ref.store = store; ref.parent = record.kind === 'root' @@ -184,7 +184,7 @@ export const createRecord = ({ return record; }; -const createCompatibilityRef = () => new V2CompatibilityRef(); +const createCompatibilityRef = () => new CompatibilityRef(); const syncCompatibilityRef = (ref, record) => { ref.id = record.id; @@ -193,7 +193,7 @@ const syncCompatibilityRef = (ref, record) => { ref.display = record.display; ref.props = record.props; ref.attrs = record.attrs; - ref._v2Record = record; + ref._record = record; return ref; }; @@ -330,7 +330,7 @@ const querySelectableFrames = (store, predicate) => { }; const getRecordBounds = (record) => { - const frame = record.ref._v2Layout?.getFrame(record.id); + const frame = record.ref._layout?.getFrame(record.id); const x = frame?.x ?? record.attrs?.x ?? 0; const y = frame?.y ?? record.attrs?.y ?? 0; const width = frame?.width ?? record.props?.size?.width ?? 0; diff --git a/src/v2/model/createV2Model.js b/src/engine/model/createModel.js similarity index 97% rename from src/v2/model/createV2Model.js rename to src/engine/model/createModel.js index b36998fc..a868017a 100644 --- a/src/v2/model/createV2Model.js +++ b/src/engine/model/createModel.js @@ -2,10 +2,10 @@ import { applyComponentDefaults, applyElementDefaults, } from '../../display/default-props'; -import { V2ModelStore } from './V2ModelStore'; +import { ModelStore } from './ModelStore'; -export const createV2Model = (data) => { - const store = new V2ModelStore(); +export const createModel = (data) => { + const store = new ModelStore(); const elements = Array.isArray(data) ? data : []; const normalized = elements.map((element) => applyElementDefaults(seedStableComponentIds(element)), diff --git a/src/v2/model/updateV2Model.js b/src/engine/model/updateModel.js similarity index 97% rename from src/v2/model/updateV2Model.js rename to src/engine/model/updateModel.js index bb7554ce..71a29c31 100644 --- a/src/v2/model/updateV2Model.js +++ b/src/engine/model/updateModel.js @@ -1,6 +1,6 @@ import { applyComponentDefaults } from '../../display/default-props'; -export const updateV2Model = (model, opts = {}) => { +export const updateModel = (model, opts = {}) => { const targets = resolveTargets(model, opts); const changes = opts.changes ?? null; if (!changes || targets.length === 0) return []; @@ -51,7 +51,7 @@ const normalizeElements = (elements) => { const resolveElementRecord = (model, element) => { if (!element) return null; if (typeof element === 'string') return model.get(element); - if (element._v2Record) return model.get(element._v2Record.id); + if (element._record) return model.get(element._record.id); if (element.id) return model.get(element.id); return null; }; diff --git a/src/v2/render-ir/createV2RenderIR.js b/src/engine/render-ir/createRenderIR.js similarity index 92% rename from src/v2/render-ir/createV2RenderIR.js rename to src/engine/render-ir/createRenderIR.js index 7a5d1166..718fbe1e 100644 --- a/src/v2/render-ir/createV2RenderIR.js +++ b/src/engine/render-ir/createRenderIR.js @@ -1,4 +1,4 @@ -export const createV2RenderIR = (model, layout) => { +export const createRenderIR = (model, layout) => { const nodes = []; for (const record of model.records.values()) { @@ -6,17 +6,17 @@ export const createV2RenderIR = (model, layout) => { const frame = layout.getFrame(record.id); if (!frame?.visible) continue; - const node = createV2RenderNode(record, frame); + const node = createRenderNode(record, frame); if (node) nodes.push(node); } return { nodes, - byFeature: groupV2NodesByFeature(nodes), + byFeature: groupNodesByFeature(nodes), }; }; -export const createV2RenderNode = (record, frame) => { +export const createRenderNode = (record, frame) => { if (record.kind === 'component') { return createComponentNode(record, frame); } @@ -139,7 +139,7 @@ const createComponentNode = (record, frame) => { return null; }; -export const groupV2NodesByFeature = (nodes) => { +export const groupNodesByFeature = (nodes) => { const groups = new Map(); for (const node of nodes) { let group = groups.get(node.feature); diff --git a/src/v2/render-ir/diffV2RenderIR.js b/src/engine/render-ir/diffRenderIR.js similarity index 96% rename from src/v2/render-ir/diffV2RenderIR.js rename to src/engine/render-ir/diffRenderIR.js index 0b45cc07..5280bd5a 100644 --- a/src/v2/render-ir/diffV2RenderIR.js +++ b/src/engine/render-ir/diffRenderIR.js @@ -1,4 +1,4 @@ -export const diffV2RenderIR = (previousIR, nextIR) => { +export const diffRenderIR = (previousIR, nextIR) => { const previous = indexNodes(previousIR?.nodes ?? []); const next = indexNodes(nextIR?.nodes ?? []); const added = []; diff --git a/src/v2/render-policy/createV2RenderPlan.js b/src/engine/render-policy/createRenderPlan.js similarity index 97% rename from src/v2/render-policy/createV2RenderPlan.js rename to src/engine/render-policy/createRenderPlan.js index 8f77a968..deb8dbaf 100644 --- a/src/v2/render-policy/createV2RenderPlan.js +++ b/src/engine/render-policy/createRenderPlan.js @@ -1,4 +1,4 @@ -export const createV2RenderPlan = (model, renderIR) => { +export const createRenderPlan = (_model, renderIR) => { const visibleByOwner = groupVisibleNodesByOwner(renderIR.nodes); const aggregateBars = []; const aggregateBackgrounds = []; diff --git a/src/v2/scheduler/V2RenderScheduler.js b/src/engine/scheduler/RenderScheduler.js similarity index 98% rename from src/v2/scheduler/V2RenderScheduler.js rename to src/engine/scheduler/RenderScheduler.js index e92cfe5b..bfe95513 100644 --- a/src/v2/scheduler/V2RenderScheduler.js +++ b/src/engine/scheduler/RenderScheduler.js @@ -1,4 +1,4 @@ -export class V2RenderScheduler { +export class RenderScheduler { constructor({ schedule = defaultSchedule, frameBudgetMs = 4, diff --git a/src/patchmap.js b/src/patchmap.js index 3343b99d..4cd91d00 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -1,13 +1,10 @@ import gsap from 'gsap'; -import { Application, UPDATE_PRIORITY } from 'pixi.js'; +import { Application } from 'pixi.js'; import { isValidationError } from 'zod-validation-error'; import { UndoRedoManager } from './command/UndoRedoManager'; -import './display/components/registry'; -import { draw } from './display/draw'; -import './display/elements/registry'; -import { update } from './display/update'; import ViewTransform from './display/view-transform/ViewTransform'; import World from './display/World'; +import { PatchMapEngine, PixiRenderer } from './engine'; import { warmFindBoundsCache } from './events/find'; import { fit as fitViewport, focus } from './events/focus-fit'; import StateManager from './events/StateManager'; @@ -23,10 +20,8 @@ import Transformer from './transformer/Transformer'; import { convertLegacyData } from './utils/convert'; import { event } from './utils/event/canvas'; import { WildcardEventEmitter } from './utils/event/WildcardEventEmitter'; -import { selector } from './utils/selector/selector'; import { themeStore } from './utils/theme'; import { validateMapData } from './utils/validator'; -import { PatchMapV2Engine, V2PixiRenderer } from './v2'; class Patchmap extends WildcardEventEmitter { _app = null; @@ -41,13 +36,13 @@ class Patchmap extends WildcardEventEmitter { _world = null; _viewTransform = this._createViewTransform(); _drawToken = 0; + _drawCacheSource = null; _drawCacheKey = null; _drawCacheData = null; - _engineMode = 'legacy'; - _v2Engine = null; - _v2Renderer = null; - _v2RenderScheduled = false; - _v2UpdateQueue = []; + _engine = null; + _renderer = null; + _renderScheduled = false; + _updateQueue = []; get app() { return this._app; @@ -135,12 +130,10 @@ class Patchmap extends WildcardEventEmitter { theme: themeOptions = {}, assets: assetsOptions = [], transformer, - engine = 'legacy', } = opts; this.undoRedoManager._setHotkeys(); this._theme.set(themeOptions); - this._engineMode = engine === 'v2' ? 'v2' : 'legacy'; this._app = new Application(); await initApp(this.app, { resizeTo: element, ...appOptions }); @@ -150,10 +143,8 @@ class Patchmap extends WildcardEventEmitter { this._world.enableRenderGroup?.(); store.world = this._world; this.viewport.addChild(this._world); - if (this._engineMode === 'v2') { - this._v2Engine = new PatchMapV2Engine({ theme: this.theme, store }); - this._v2Renderer = new V2PixiRenderer({ store, target: this._world }); - } + this._engine = new PatchMapEngine({ theme: this.theme, store }); + this._renderer = new PixiRenderer({ store, target: this._world }); this._viewTransform.attach({ viewport: this.viewport, world: this._world }); await initAsset(assetsOptions); @@ -182,7 +173,7 @@ class Patchmap extends WildcardEventEmitter { this.stateManager.resetState(); this.stateManager.destroy(); event.removeAllEvent(this.viewport); - this._v2Renderer?.destroy(); + this._renderer?.destroy(); this.viewport.destroy({ children: true, context: true, style: true }); const parentElement = this.app.canvas.parentElement; this.app.destroy(true); @@ -201,13 +192,13 @@ class Patchmap extends WildcardEventEmitter { this._world = null; this._viewTransform = this._createViewTransform(); this._drawToken = 0; + this._drawCacheSource = null; this._drawCacheKey = null; this._drawCacheData = null; - this._engineMode = 'legacy'; - this._v2Engine = null; - this._v2Renderer = null; - this._v2RenderScheduled = false; - this._v2UpdateQueue = []; + this._engine = null; + this._renderer = null; + this._renderScheduled = false; + this._updateQueue = []; this.emit('patchmap:destroyed', { target: this }); this.removeAllListeners(); } @@ -215,11 +206,14 @@ class Patchmap extends WildcardEventEmitter { draw(data) { if (!this.isInit) return; - const drawCacheKey = createDrawCacheKey(data); + const canReuseCurrentSource = + this._drawCacheSource === data && this._engine?.model; + const drawCacheKey = canReuseCurrentSource + ? this._drawCacheKey + : createDrawCacheKey(data); const canReuseCurrentScene = - this._drawCacheKey === drawCacheKey && - this.world?.children?.length > 0 && - hasOnlyManagedWorldChildren(this.world); + canReuseCurrentSource || + (this._drawCacheKey === drawCacheKey && this._engine?.model); const processedData = canReuseCurrentScene ? this._drawCacheData : processData(JSON.parse(JSON.stringify(data))); @@ -231,39 +225,20 @@ class Patchmap extends WildcardEventEmitter { if (isValidationError(validatedData)) throw validatedData; const drawToken = ++this._drawToken; - const store = this._createStoreContext(); - this.app.stop(); this.undoRedoManager.clear(); this.animationContext.revert(); event.removeAllEvent(this.viewport); - if (this._engineMode === 'v2') { - const snapshot = this._v2Engine.draw(validatedData); - this._v2Renderer.render(snapshot); - this._v2Engine.scheduler.flush(); - this._drawCacheKey = drawCacheKey; - this._drawCacheData = validatedData; - } else if (!canReuseCurrentScene) { - draw(store, validatedData); + this._flushUpdateQueue(); + if (!canReuseCurrentScene) { + const snapshot = this._engine.draw(validatedData); + this._renderer.render(snapshot); + this._engine.scheduler.flush(); + this._drawCacheSource = data; this._drawCacheKey = drawCacheKey; this._drawCacheData = validatedData; - } - - if (this._engineMode !== 'v2' && !canReuseCurrentScene) { - // Force a refresh of all relation elements after the initial draw. This ensures - // that all link targets exist in the scene graph before the relations - // attempt to draw their links. - this.app.ticker.addOnce( - () => { - this.update({ - path: '$..[?(@.type=="relations")]', - refresh: true, - emit: false, - }); - }, - undefined, - UPDATE_PRIORITY.UTILITY, - ); + } else { + this._flushRender(); } this.app.start(); warmFindBoundsCache(this.viewport); @@ -285,30 +260,20 @@ class Patchmap extends WildcardEventEmitter { } update(opts = {}) { - if (this._engineMode === 'v2') { - const deferRender = opts.emit === false && opts.flush !== true; - if (deferRender) { - const targets = this._resolveV2UpdateTargets(opts); - this._v2UpdateQueue.push(opts); - this._scheduleV2Render(); - return targets; - } - this._flushV2UpdateQueue(); - const updatedElements = this._v2Engine.update({ - ...opts, - deferRender: false, - }); - this._flushV2Render(); - if (opts.emit !== false) { - this.emit('patchmap:updated', { - elements: updatedElements, - target: this, - }); - } - return updatedElements; + const deferRender = opts.emit === false && opts.flush !== true; + if (deferRender) { + const targets = this._resolveUpdateTargets(opts); + this._updateQueue.push(opts); + this._scheduleRender(); + return targets; } - const updatedElements = update(this.world, opts); + this._flushUpdateQueue(); + const updatedElements = this._engine.update({ + ...opts, + deferRender: false, + }); + this._flushRender(); if (opts.emit !== false) { this.emit('patchmap:updated', { elements: updatedElements, @@ -324,12 +289,9 @@ class Patchmap extends WildcardEventEmitter { * @returns {void|null} */ focus(ids, opts) { - if (this._engineMode === 'v2') { - this._flushV2UpdateQueue(); - this._flushV2Render(); - return focus(this.viewport, this._v2Engine.model?.root?.ref, ids, opts); - } - return focus(this.viewport, this.world, ids, opts); + this._flushUpdateQueue(); + this._flushRender(); + return focus(this.viewport, this._engine.model?.root?.ref, ids, opts); } /** @@ -338,17 +300,9 @@ class Patchmap extends WildcardEventEmitter { * @returns {void|null} */ fit(ids, opts) { - if (this._engineMode === 'v2') { - this._flushV2UpdateQueue(); - this._flushV2Render(); - return fitViewport( - this.viewport, - this._v2Engine.model?.root?.ref, - ids, - opts, - ); - } - return fitViewport(this.viewport, this.world, ids, opts); + this._flushUpdateQueue(); + this._flushRender(); + return fitViewport(this.viewport, this._engine.model?.root?.ref, ids, opts); } get rotation() { @@ -360,12 +314,9 @@ class Patchmap extends WildcardEventEmitter { } selector(path, opts) { - if (this._engineMode === 'v2') { - this._flushV2UpdateQueue(); - this._flushV2Render(); - return this._v2Engine.selector(path, opts); - } - return selector(this.world, path, opts); + this._flushUpdateQueue(); + this._flushRender(); + return this._engine.selector(path, opts); } _createViewTransform() { @@ -389,49 +340,49 @@ class Patchmap extends WildcardEventEmitter { }; } - _scheduleV2Render() { - if (this._v2RenderScheduled) return; - this._v2RenderScheduled = true; + _scheduleRender() { + if (this._renderScheduled) return; + this._renderScheduled = true; const schedule = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (callback) => setTimeout(callback, 0); schedule(() => { - this._v2RenderScheduled = false; - if (this.isInit && this._engineMode === 'v2') { - this._processV2UpdateQueue(); + this._renderScheduled = false; + if (this.isInit) { + this._processUpdateQueue(); } }); } - _processV2UpdateQueue() { + _processUpdateQueue() { const frameBudgetMs = 4; const startedAt = performance.now(); - while (this._v2UpdateQueue.length > 0) { - const opts = this._v2UpdateQueue.shift(); - this._v2Engine.update({ ...opts, deferRender: true }); + while (this._updateQueue.length > 0) { + const opts = this._updateQueue.shift(); + this._engine.update({ ...opts, deferRender: true }); if (performance.now() - startedAt >= frameBudgetMs) { - this._scheduleV2Render(); + this._scheduleRender(); return; } } - this._flushV2Render(); + this._flushRender(); } - _flushV2UpdateQueue() { - while (this._v2UpdateQueue.length > 0) { - const opts = this._v2UpdateQueue.shift(); - this._v2Engine.update({ ...opts, deferRender: true }); + _flushUpdateQueue() { + while (this._updateQueue.length > 0) { + const opts = this._updateQueue.shift(); + this._engine.update({ ...opts, deferRender: true }); } } - _flushV2Render() { - if (!this._v2Engine || !this._v2Renderer) return; - const snapshot = this._v2Engine.flush(); - this._v2Renderer.render(snapshot); + _flushRender() { + if (!this._engine || !this._renderer) return; + const snapshot = this._engine.flush(); + this._renderer.render(snapshot); } - _resolveV2UpdateTargets(opts) { + _resolveUpdateTargets(opts) { const elements = []; if (opts.elements) { elements.push( @@ -439,7 +390,7 @@ class Patchmap extends WildcardEventEmitter { ); } if (opts.path) { - elements.push(...this._v2Engine.selector(opts.path)); + elements.push(...this._engine.selector(opts.path)); } return [...new Set(elements.filter(Boolean))]; } @@ -449,12 +400,6 @@ function createDrawCacheKey(data) { return JSON.stringify(data); } -function hasOnlyManagedWorldChildren(world) { - return (world?.children ?? []).every( - (child) => child?.type || child?._patchmapInternal, - ); -} - function scheduleUserVisibleTask(task) { const scheduler = globalThis.scheduler; if (scheduler?.postTask) { diff --git a/src/tests/render/patchmap-v2-mode.test.js b/src/tests/render/patchmap-engine.test.js similarity index 82% rename from src/tests/render/patchmap-v2-mode.test.js rename to src/tests/render/patchmap-engine.test.js index 6cdcd685..27a053c3 100644 --- a/src/tests/render/patchmap-v2-mode.test.js +++ b/src/tests/render/patchmap-engine.test.js @@ -24,7 +24,7 @@ const createPanelData = () => [ }, ]; -describe('Patchmap v2 opt-in mode', () => { +describe('Patchmap engine mode', () => { let patchmap; let element; @@ -36,7 +36,6 @@ describe('Patchmap v2 opt-in mode', () => { patchmap = new Patchmap(); await patchmap.init(element, { - engine: 'v2', transformer: new Transformer({ resizeHandles: true, rotateHandles: true, @@ -50,14 +49,14 @@ describe('Patchmap v2 opt-in mode', () => { element?.parentElement?.removeChild(element); }); - it('keeps draw, selector, update, and renderer object reuse behind the v2 option', () => { + it('keeps draw, selector, update, and renderer object reuse on the default engine', () => { patchmap.draw(createPanelData()); const [item] = patchmap.selector('$..[?(@.id=="panel-1")]'); expect(item).toMatchObject({ id: 'panel-1', display: 'panelItem' }); const barParticle = - patchmap._v2Renderer.aggregateLayers.bar.particleChildren[0]; + patchmap._renderer.aggregateLayers.bar.particleChildren[0]; expect(barParticle.scaleY).toBe(25); expect(barParticle.y).toBe(25); @@ -70,7 +69,7 @@ describe('Patchmap v2 opt-in mode', () => { }); expect(updated).toEqual([item]); - expect(patchmap._v2Renderer.aggregateLayers.bar.particleChildren[0]).toBe( + expect(patchmap._renderer.aggregateLayers.bar.particleChildren[0]).toBe( barParticle, ); expect(barParticle.scaleY).toBe(40); @@ -96,7 +95,7 @@ describe('Patchmap v2 opt-in mode', () => { expect(patchmap.transformer.children.length).toBeGreaterThan(0); }); - it('exposes v2 refs through the scene index for hit testing', () => { + it('exposes engine refs through the scene index for hit testing', () => { patchmap.draw(createPanelData()); const hit = findIntersectObject( @@ -111,11 +110,11 @@ describe('Patchmap v2 opt-in mode', () => { expect(hit).toMatchObject({ id: 'panel-1', type: 'item' }); }); - it('batches emit:false v2 updates into the next frame', async () => { + it('batches emit:false updates into the next frame', async () => { patchmap.draw(createPanelData()); const [item] = patchmap.selector('$..[?(@.id=="panel-1")]'); const barParticle = - patchmap._v2Renderer.aggregateLayers.bar.particleChildren[0]; + patchmap._renderer.aggregateLayers.bar.particleChildren[0]; patchmap.update({ elements: item, @@ -126,14 +125,14 @@ describe('Patchmap v2 opt-in mode', () => { emit: false, }); - expect(patchmap._v2UpdateQueue).toHaveLength(1); + expect(patchmap._updateQueue).toHaveLength(1); expect(barParticle.scaleY).toBe(25); await new Promise((resolve) => requestAnimationFrame(resolve)); await new Promise((resolve) => requestAnimationFrame(resolve)); - expect(patchmap._v2UpdateQueue).toHaveLength(0); - expect(patchmap._v2Engine.dirty).toBe(false); + expect(patchmap._updateQueue).toHaveLength(0); + expect(patchmap._engine.dirty).toBe(false); expect(barParticle.scaleY).toBe(40); }); }); diff --git a/src/v2/index.js b/src/v2/index.js deleted file mode 100644 index 4b45db6a..00000000 --- a/src/v2/index.js +++ /dev/null @@ -1,9 +0,0 @@ -export { V2PixiRenderer } from './backend/V2PixiRenderer'; -export { createV2Layout } from './layout/createV2Layout'; -export { createV2Model } from './model/createV2Model'; -export { updateV2Model } from './model/updateV2Model'; -export { PatchMapV2Engine } from './PatchMapV2Engine'; -export { createV2RenderIR } from './render-ir/createV2RenderIR'; -export { diffV2RenderIR } from './render-ir/diffV2RenderIR'; -export { createV2RenderPlan } from './render-policy/createV2RenderPlan'; -export { V2RenderScheduler } from './scheduler/V2RenderScheduler'; From 8aec629270d95db2a5cbe172dba0f96f5468ea96 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 12:13:04 +0900 Subject: [PATCH 44/51] perf: skip unchanged patchmap redraw render --- src/patchmap.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/patchmap.js b/src/patchmap.js index 4cd91d00..44ed16ec 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -229,6 +229,7 @@ class Patchmap extends WildcardEventEmitter { this.undoRedoManager.clear(); this.animationContext.revert(); event.removeAllEvent(this.viewport); + const hadQueuedUpdates = this._updateQueue.length > 0 || this._engine.dirty; this._flushUpdateQueue(); if (!canReuseCurrentScene) { const snapshot = this._engine.draw(validatedData); @@ -237,7 +238,7 @@ class Patchmap extends WildcardEventEmitter { this._drawCacheSource = data; this._drawCacheKey = drawCacheKey; this._drawCacheData = validatedData; - } else { + } else if (hadQueuedUpdates) { this._flushRender(); } this.app.start(); From 82f0e7e481490e73482567ab3bd4874cc946fda1 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 12:59:20 +0900 Subject: [PATCH 45/51] fix: restore patchmap visual rendering --- src/engine/backend/PixiRenderer.js | 304 ++++++++++++++++++++++++- src/engine/compat/CompatibilityRef.js | 29 +++ src/engine/layout/createLayout.js | 8 + src/engine/model/ModelStore.js | 13 +- src/engine/model/updateModel.js | 26 +++ src/engine/render-ir/createRenderIR.js | 1 + src/events/find.js | 10 +- 7 files changed, 375 insertions(+), 16 deletions(-) diff --git a/src/engine/backend/PixiRenderer.js b/src/engine/backend/PixiRenderer.js index fb7f5ae9..7aac9b6d 100644 --- a/src/engine/backend/PixiRenderer.js +++ b/src/engine/backend/PixiRenderer.js @@ -1,12 +1,18 @@ import { + BitmapText, Container, + NineSliceSprite, Particle, ParticleContainer, + Point, Rectangle, Sprite, Texture, } from 'pixi.js'; +import { getTexture } from '../../assets/textures/texture'; +import { splitText } from '../../display/mixins/utils'; import { getColor } from '../../utils/get'; +import { getCentroid, getObjectWorldCorners } from '../../utils/transform'; export class PixiRenderer { constructor({ store, target }) { @@ -59,7 +65,7 @@ export class PixiRenderer { destroy() { for (const object of this.objectsById.values()) { - object.destroy(); + object.destroy({ children: true }); } this.objectsById.clear(); this.particlesById.clear(); @@ -77,6 +83,11 @@ export class PixiRenderer { if (!layer) return; let object = this.objectsById.get(node.id); + const objectKind = getDisplayObjectKind(node); + if (object && object._patchmapObjectKind !== objectKind) { + this.#removeNode(node.id); + object = null; + } if (!object) { object = this.#createDisplayObject(node); this.objectsById.set(node.id, object); @@ -87,6 +98,7 @@ export class PixiRenderer { } applyNodeToObject(object, node, this.store); + syncRenderedRef(this.store, node, object); } #removeStaleNormalNodes(normalNodes, aggregateNodes) { @@ -190,7 +202,7 @@ export class PixiRenderer { const object = this.objectsById.get(id); if (!object) return; object.parent?.removeChild(object); - object.destroy(); + object.destroy({ children: true }); this.objectsById.delete(id); } @@ -202,15 +214,44 @@ export class PixiRenderer { } #createDisplayObject(node) { - const texture = resolveNodeTexture(node, this.store) ?? Texture.WHITE; - const object = new Sprite(texture); + const object = createDisplayObject(node, this.store); object.label = `patchmap-${node.id}`; object._patchmapNodeId = node.id; - applySlice(object, texture); return object; } } +const createDisplayObject = (node, store) => { + const kind = getDisplayObjectKind(node); + let object; + if (kind === 'relations') { + object = new Container(); + object.linkPoints = []; + object._patchmapLineSprites = []; + } else if (kind === 'text') { + object = new BitmapText({ text: '', style: {} }); + } else { + const texture = resolveNodeTexture(node, store) ?? Texture.WHITE; + object = + kind === 'nine-slice' + ? new NineSliceSprite({ texture }) + : new Sprite(texture); + applySlice(object, texture); + } + object._patchmapObjectKind = kind; + return object; +}; + +const getDisplayObjectKind = (node) => { + if (node.feature === 'relations') return 'relations'; + if (node.feature === 'label' || node.feature === 'text') return 'text'; + if (node.feature === 'background' || node.feature === 'bar') { + return 'nine-slice'; + } + if (node.feature === 'rect' && node.material?.radius) return 'nine-slice'; + return 'sprite'; +}; + const createLayers = () => ({ background: createLayer('patchmap-background-layer'), bar: createLayer('patchmap-bar-layer'), @@ -248,6 +289,18 @@ const createAggregateLayer = (label) => { }; const applyNodeToObject = (object, node, store) => { + if (object._patchmapObjectKind === 'relations') { + applyNodeTransform(object, node); + applyRelationNodeToGraphics(object, node, store); + return; + } + + if (object._patchmapObjectKind === 'text') { + applyNodeTransform(object, node); + applyTextNodeToBitmapText(object, node, store); + return; + } + const texture = resolveNodeTexture(node, store); if (texture && object.texture !== texture) { object.texture = texture; @@ -256,16 +309,121 @@ const applyNodeToObject = (object, node, store) => { object.visible = node.frame.visible !== false; object.renderable = object.visible; - object.x = node.frame.x; - object.y = node.frame.y; + applyNodeTransform(object, node); object.width = node.frame.width; object.height = node.frame.height; + const tint = getNodeTint(node, store); + if (tint !== undefined) { + object.tint = normalizeColor(tint); + } +}; + +const applyNodeTransform = (object, node) => { + object.visible = node.frame.visible !== false; + object.renderable = object.visible; + object.x = node.frame.x; + object.y = node.frame.y; object.rotation = node.frame.rotation ?? 0; object.alpha = node.frame.alpha ?? 1; +}; + +const applyTextNodeToBitmapText = (object, node, store) => { + const layout = resolveTextLayout(node, store); + object.text = layout.text; + object.style = layout.style; + object._patchmapTextStyle = layout.style; const tint = getNodeTint(node, store); - if (tint !== undefined) { - object.tint = tint; + if (tint !== undefined) object.tint = normalizeColor(tint); +}; + +const applyRelationNodeToGraphics = (object, node, store) => { + object.linkPoints.length = 0; + + const links = node.material?.links ?? []; + if (links.length === 0) return; + const style = node.material?.style ?? {}; + const strokeStyle = { + color: normalizeColor(getColor(store?.theme, style.color ?? '#1A1A1A')), + width: style.width ?? 1, + alpha: style.alpha ?? 1, + alignment: style.alignment, + cap: style.cap, + join: style.join, + }; + object.strokeStyle = strokeStyle; + + let renderedLinks = 0; + for (const link of links) { + const source = store?.elementById?.get(link.source); + const target = store?.elementById?.get(link.target); + if (!source || !target || source.destroyed || target.destroyed) continue; + + const sourcePoint = resolveLinkedLocalAnchor(object, source); + const targetPoint = resolveLinkedLocalAnchor(object, target); + if (!sourcePoint || !targetPoint) continue; + + const line = getRelationLineSprite(object, renderedLinks); + applyLineSprite(line, sourcePoint, targetPoint, strokeStyle); + object.linkPoints.push({ + sourcePoint: [sourcePoint.x, sourcePoint.y], + targetPoint: [targetPoint.x, targetPoint.y], + }); + renderedLinks += 1; + } + + for ( + let index = renderedLinks; + index < object._patchmapLineSprites.length; + index += 1 + ) { + object._patchmapLineSprites[index].visible = false; + } +}; + +const getRelationLineSprite = (object, index) => { + let line = object._patchmapLineSprites[index]; + if (!line) { + line = new Sprite(Texture.WHITE); + line.anchor.set(0, 0.5); + line.label = `patchmap-relation-line-${index}`; + object._patchmapLineSprites[index] = line; + object.addChild(line); + } + line.visible = true; + line.renderable = true; + return line; +}; + +const applyLineSprite = (line, sourcePoint, targetPoint, strokeStyle) => { + const dx = targetPoint.x - sourcePoint.x; + const dy = targetPoint.y - sourcePoint.y; + line.x = sourcePoint.x; + line.y = sourcePoint.y; + line.width = Math.hypot(dx, dy); + line.height = strokeStyle.width ?? 1; + line.rotation = Math.atan2(dy, dx); + line.tint = strokeStyle.color; + line.alpha = strokeStyle.alpha ?? 1; +}; + +const resolveLinkedLocalAnchor = (object, ref) => { + const globalPoint = getCentroid(getObjectWorldCorners(ref)); + return object.toLocal(globalPoint, undefined, new Point()); +}; + +const getLinkBounds = (linkPoints) => { + const xs = []; + const ys = []; + for (const point of linkPoints) { + xs.push(point.sourcePoint[0], point.targetPoint[0]); + ys.push(point.sourcePoint[1], point.targetPoint[1]); } + if (xs.length === 0) return new Rectangle(0, 0, 0, 0); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + return new Rectangle(minX, minY, maxX - minX, maxY - minY); }; const applyNodeToParticle = (particle, node, store) => { @@ -279,17 +437,27 @@ const applyNodeToParticle = (particle, node, store) => { particle.alpha = node.frame.alpha ?? 1; const tint = getNodeTint(node, store); if (tint !== undefined) { - particle.tint = tint; + particle.tint = normalizeColor(tint); } }; const getNodeTint = (node, store) => - getColor(store.theme, node.material?.tint ?? node.material?.source?.fill); + getColor(store?.theme, node.material?.tint ?? node.material?.source?.fill); const resolveNodeTexture = (node, store) => { const source = node.material?.source; if (!source) return Texture.WHITE; - return store.textureResolver?.(source, store, node) ?? Texture.WHITE; + const resolved = store.textureResolver?.(source, store, node); + if (resolved) return resolved; + try { + const renderer = store?.viewport?.app?.renderer ?? store?.app?.renderer; + if (renderer) { + return getTexture(renderer, store.theme, source) ?? Texture.EMPTY; + } + } catch { + return Texture.EMPTY; + } + return typeof source === 'string' ? Texture.EMPTY : Texture.WHITE; }; const applySlice = (object, texture) => { @@ -300,3 +468,115 @@ const applySlice = (object, texture) => { object.topHeight = slice.topHeight ?? 0; object.bottomHeight = slice.bottomHeight ?? 0; }; + +const normalizeTextStyle = (style = {}, node, store) => { + const next = { ...style }; + if (next.fill !== undefined) { + next.fill = normalizeColor(getColor(store?.theme, next.fill)); + } else { + const tint = getNodeTint(node, store); + if (tint !== undefined) next.fill = normalizeColor(tint); + } + return next; +}; + +const normalizeColor = (color) => { + if (typeof color === 'string' && color.startsWith('#')) { + return Number.parseInt(color.slice(1), 16); + } + return color; +}; + +const resolveTextLayout = (node, store) => { + const baseStyle = normalizeTextStyle(node.material?.style, node, store); + const margin = normalizeMargin(node.material?.margin); + const bounds = { + width: Math.max(0, node.frame.width - margin.left - margin.right), + height: Math.max(0, node.frame.height - margin.top - margin.bottom), + }; + const style = { ...baseStyle }; + let text = splitText(node.material?.text ?? '', node.material?.split ?? 0); + + if (style.fontSize === 'auto') { + const range = style.autoFont ?? { min: 8, max: 48 }; + style.fontSize = fitFontSize(text, bounds, range); + } + + if (style.overflow && style.overflow !== 'visible') { + text = truncateText(text, bounds, style, style.overflow); + } + + return { text, style }; +}; + +const fitFontSize = (text, bounds, range) => { + const min = Number(range.min ?? 8); + const max = Number(range.max ?? 48); + if (!text) return max; + const byWidth = bounds.width / Math.max(1, longestLineLength(text) * 0.55); + const byHeight = bounds.height / Math.max(1, lineCount(text)); + return Math.max(min, Math.min(max, Math.floor(Math.min(byWidth, byHeight)))); +}; + +const truncateText = (text, bounds, style, overflow) => { + const fontSize = Number(style.fontSize) || 16; + const maxChars = Math.max( + 0, + Math.floor(bounds.width / Math.max(1, fontSize * 0.55)), + ); + if (measureTextWidth(text, fontSize) <= bounds.width) return text; + if (overflow === 'ellipsis') { + if (maxChars <= 1) return '…'; + return `${text.slice(0, Math.max(0, maxChars - 1))}…`; + } + return text.slice(0, maxChars); +}; + +const measureTextWidth = (text, fontSize) => + longestLineLength(text) * fontSize * 0.55; + +const longestLineLength = (text) => + Math.max( + 0, + ...String(text) + .split('\n') + .map((line) => line.length), + ); + +const lineCount = (text) => String(text).split('\n').length; + +const normalizeMargin = (margin = 0) => { + if (typeof margin === 'number') { + return { top: margin, right: margin, bottom: margin, left: margin }; + } + return { + top: margin.top ?? margin.y ?? 0, + right: margin.right ?? margin.x ?? 0, + bottom: margin.bottom ?? margin.y ?? 0, + left: margin.left ?? margin.x ?? 0, + }; +}; + +const syncRenderedRef = (store, node, object) => { + const ref = store?.elementById?.get(node.id); + if (!ref) return; + ref._renderObject = object; + if (node.feature === 'relations') { + ref.children = [getRelationPathProxy(object)]; + ref._linkPoints = object.linkPoints; + } +}; + +const getRelationPathProxy = (object) => { + if (!object._patchmapPathProxy) { + object._patchmapPathProxy = { + type: 'path', + allowContainsPoint: true, + get strokeStyle() { + return object.strokeStyle; + }, + getBounds: () => getLinkBounds(object.linkPoints), + }; + } + return object._patchmapPathProxy; +}; diff --git a/src/engine/compat/CompatibilityRef.js b/src/engine/compat/CompatibilityRef.js index 9a070213..eefec61b 100644 --- a/src/engine/compat/CompatibilityRef.js +++ b/src/engine/compat/CompatibilityRef.js @@ -15,6 +15,28 @@ export class CompatibilityRef { store = null; _layout = null; _record = null; + _renderObject = null; + _linkPoints = []; + + get text() { + return this._renderObject?.text ?? this.props?.text ?? ''; + } + + get style() { + return this._renderObject?._patchmapTextStyle ?? this.props?.style; + } + + get tint() { + return this._renderObject?.tint ?? this.props?.tint; + } + + get texture() { + return this._renderObject?.texture; + } + + get linkPoints() { + return this._renderObject?.linkPoints ?? this._linkPoints; + } get x() { return this.#frame.x; @@ -72,6 +94,13 @@ export class CompatibilityRef { getLocalBounds() { const frame = this.#frame; + if ( + this.type === 'item' && + (!Array.isArray(this.props?.components) || + this.props.components.length === 0) + ) { + return new Rectangle(0, 0, 0, 0); + } return new Rectangle(0, 0, frame.width, frame.height); } diff --git a/src/engine/layout/createLayout.js b/src/engine/layout/createLayout.js index 464a68a5..c79ad435 100644 --- a/src/engine/layout/createLayout.js +++ b/src/engine/layout/createLayout.js @@ -71,6 +71,10 @@ const layoutNodeRecursive = ( const resolveElementSize = (record) => { const size = record.props.size ?? record.props.item?.size; + if (typeof size === 'number' || typeof size === 'string') { + const value = resolveNumber(size); + return { width: value, height: value }; + } return { width: resolveNumber(size?.width), height: resolveNumber(size?.height), @@ -137,12 +141,16 @@ const resolvePlacement = (placement, contentFrame, size, margin) => { case 'right': return { x: right, y: centerY }; case 'top-left': + case 'left-top': return { x: left, y: top }; case 'top-right': + case 'right-top': return { x: right, y: top }; case 'bottom-left': + case 'left-bottom': return { x: left, y: bottom }; case 'bottom-right': + case 'right-bottom': return { x: right, y: bottom }; default: return { x: centerX, y: centerY }; diff --git a/src/engine/model/ModelStore.js b/src/engine/model/ModelStore.js index 4697f06c..099914fb 100644 --- a/src/engine/model/ModelStore.js +++ b/src/engine/model/ModelStore.js @@ -82,7 +82,14 @@ export class ModelStore { } get(id) { - return this.records.get(id) ?? null; + return this.records.get(id) ?? this.findByPublicId(id) ?? null; + } + + findByPublicId(id) { + for (const record of this.records.values()) { + if (record.props?.id === id) return record; + } + return null; } getByType(type) { @@ -140,7 +147,9 @@ export class ModelStore { ref.parent = record.kind === 'root' ? (store?.world ?? null) - : (this.get(record.parentId)?.ref ?? null); + : record.parentId === this.root.id + ? (store?.world ?? null) + : (this.get(record.parentId)?.ref ?? null); ref.children = this.getChildren(record.id) .filter((child) => child.kind !== 'component') .map((child) => child.ref); diff --git a/src/engine/model/updateModel.js b/src/engine/model/updateModel.js index 71a29c31..0a76d2ef 100644 --- a/src/engine/model/updateModel.js +++ b/src/engine/model/updateModel.js @@ -131,6 +131,9 @@ const mergeComponentProps = (props, change, opts) => const mergePatch = (target, source) => { if (source === undefined) return target; + if (Array.isArray(target) && Array.isArray(source)) { + return mergeArrayPatch(target, source); + } if (!isMergeableObject(target) || !isMergeableObject(source)) { return source; } @@ -142,6 +145,29 @@ const mergePatch = (target, source) => { return out; }; +const mergeArrayPatch = (target, source) => { + if (!source.every(isRelationLink) && !target.every(isRelationLink)) { + return source; + } + const merged = [...target]; + for (const item of source) { + if (isRelationLink(item)) { + const key = `${item.source}|${item.target}`; + const exists = merged.some( + (current) => + isRelationLink(current) && + `${current.source}|${current.target}` === key, + ); + if (exists) continue; + } + merged.push(item); + } + return merged; +}; + +const isRelationLink = (value) => + isMergeableObject(value) && 'source' in value && 'target' in value; + const isMergeableObject = (value) => value && typeof value === 'object' && diff --git a/src/engine/render-ir/createRenderIR.js b/src/engine/render-ir/createRenderIR.js index 718fbe1e..2ab06f8e 100644 --- a/src/engine/render-ir/createRenderIR.js +++ b/src/engine/render-ir/createRenderIR.js @@ -133,6 +133,7 @@ const createComponentNode = (record, frame) => { style: record.props.style, tint: record.props.tint, split: record.props.split ?? 0, + margin: record.props.margin, }, }; } diff --git a/src/events/find.js b/src/events/find.js index 944f00f1..fc081739 100644 --- a/src/events/find.js +++ b/src/events/find.js @@ -114,8 +114,8 @@ const compareCandidatesByDisplayOrder = (parent, a, b) => { if (pathA[i] !== pathB[i]) { const commonParent = pathA[i].parent; return ( - commonParent.getChildIndex(pathB[i]) - - commonParent.getChildIndex(pathA[i]) + getDisplayOrderIndex(commonParent, pathB[i]) - + getDisplayOrderIndex(commonParent, pathA[i]) ); } } @@ -123,6 +123,12 @@ const compareCandidatesByDisplayOrder = (parent, a, b) => { return pathB.length - pathA.length; }; +const getDisplayOrderIndex = (parent, child) => { + const childIndex = parent?.children?.indexOf?.(child); + if (childIndex >= 0) return childIndex; + return child?._record?.depth ?? 0; +}; + export const findIntersectObject = ( parent, point, From d5114e5b006057879e2e659433b29fde9b1bfd14 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 17:16:34 +0900 Subject: [PATCH 46/51] perf: coalesce deferred patchmap updates --- src/patchmap.js | 133 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 124 insertions(+), 9 deletions(-) diff --git a/src/patchmap.js b/src/patchmap.js index 44ed16ec..e33845b3 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -43,6 +43,7 @@ class Patchmap extends WildcardEventEmitter { _renderer = null; _renderScheduled = false; _updateQueue = []; + _coalescedUpdateQueue = new Map(); get app() { return this._app; @@ -199,6 +200,7 @@ class Patchmap extends WildcardEventEmitter { this._renderer = null; this._renderScheduled = false; this._updateQueue = []; + this._coalescedUpdateQueue = new Map(); this.emit('patchmap:destroyed', { target: this }); this.removeAllListeners(); } @@ -264,7 +266,7 @@ class Patchmap extends WildcardEventEmitter { const deferRender = opts.emit === false && opts.flush !== true; if (deferRender) { const targets = this._resolveUpdateTargets(opts); - this._updateQueue.push(opts); + this._enqueueUpdate({ ...opts, _resolvedTargets: targets }); this._scheduleRender(); return targets; } @@ -358,21 +360,31 @@ class Patchmap extends WildcardEventEmitter { _processUpdateQueue() { const frameBudgetMs = 4; + const maxUpdatesPerFrame = 750; const startedAt = performance.now(); - while (this._updateQueue.length > 0) { - const opts = this._updateQueue.shift(); + let processed = 0; + while ( + this._updateQueue.length > 0 && + processed < maxUpdatesPerFrame && + performance.now() - startedAt < frameBudgetMs + ) { + const opts = this._dequeueUpdate(); this._engine.update({ ...opts, deferRender: true }); - if (performance.now() - startedAt >= frameBudgetMs) { - this._scheduleRender(); - return; - } + processed += 1; + } + + if (processed > 0) { + this._flushRender(); + } + + if (this._updateQueue.length > 0) { + this._scheduleRender(); } - this._flushRender(); } _flushUpdateQueue() { while (this._updateQueue.length > 0) { - const opts = this._updateQueue.shift(); + const opts = this._dequeueUpdate(); this._engine.update({ ...opts, deferRender: true }); } } @@ -395,6 +407,33 @@ class Patchmap extends WildcardEventEmitter { } return [...new Set(elements.filter(Boolean))]; } + + _enqueueUpdate(opts) { + const key = getCoalescedUpdateKey(opts); + if (!key) { + this._updateQueue.push({ opts, key: null }); + return; + } + + const existing = this._coalescedUpdateQueue.get(key); + if (existing) { + existing.opts = mergeQueuedUpdate(existing.opts, opts); + return; + } + + const entry = { opts, key }; + this._coalescedUpdateQueue.set(key, entry); + this._updateQueue.push(entry); + } + + _dequeueUpdate() { + const entry = this._updateQueue.shift(); + if (!entry) return {}; + if (entry.key && this._coalescedUpdateQueue.get(entry.key) === entry) { + this._coalescedUpdateQueue.delete(entry.key); + } + return entry.opts; + } } function createDrawCacheKey(data) { @@ -410,4 +449,80 @@ function scheduleUserVisibleTask(task) { setTimeout(task, 0); } +function getCoalescedUpdateKey(opts) { + if (!canCoalesceQueuedUpdate(opts)) return null; + const target = opts._resolvedTargets[0]; + return `item:${target.id}:components`; +} + +function canCoalesceQueuedUpdate(opts) { + return ( + opts.emit === false && + opts.flush !== true && + opts.mergeStrategy !== 'replace' && + !opts.path && + Array.isArray(opts._resolvedTargets) && + opts._resolvedTargets.length === 1 && + opts._resolvedTargets[0]?.type === 'item' && + isPlainObject(opts.changes) && + Array.isArray(opts.changes.components) && + Object.keys(opts.changes).every((key) => key === 'components') + ); +} + +function mergeQueuedUpdate(previous, next) { + return { + ...previous, + ...next, + changes: { + components: mergeComponentChanges( + previous.changes.components, + next.changes.components, + ), + }, + _resolvedTargets: next._resolvedTargets ?? previous._resolvedTargets, + }; +} + +function mergeComponentChanges(previousComponents = [], nextComponents = []) { + const merged = previousComponents.map((component) => ({ ...component })); + for (const component of nextComponents) { + const index = merged.findIndex((current) => + isSameComponentChange(current, component), + ); + if (index === -1) { + merged.push({ ...component }); + } else { + merged[index] = mergePatch(merged[index], component); + } + } + return merged; +} + +function isSameComponentChange(a, b) { + if (a.id || b.id) return a.id === b.id; + if (a.label || b.label) return a.label === b.label; + return a.type === b.type; +} + +function mergePatch(target, source) { + if (source === undefined) return target; + if (!isPlainObject(target) || !isPlainObject(source)) return source; + + const out = { ...target }; + for (const key of Object.keys(source)) { + out[key] = mergePatch(out[key], source[key]); + } + return out; +} + +function isPlainObject(value) { + return ( + value && + typeof value === 'object' && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} + export { Patchmap }; From ca99285bdce61a7a3f087c59bda2497828b68596 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 17:16:47 +0900 Subject: [PATCH 47/51] fix: refresh engine render plan for aggregate policy --- src/engine/PatchMapEngine.js | 13 +++++++++++-- src/engine/PatchMapEngine.test.js | 13 +++++++++++-- src/engine/layout/createLayout.js | 5 ++++- src/engine/render-policy/createRenderPlan.js | 17 +++++------------ 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/engine/PatchMapEngine.js b/src/engine/PatchMapEngine.js index 92f63703..90bae402 100644 --- a/src/engine/PatchMapEngine.js +++ b/src/engine/PatchMapEngine.js @@ -19,6 +19,7 @@ export class PatchMapEngine { this.renderIR = null; this.renderDiff = null; this.renderPlan = null; + this.renderPlanNeedsRefresh = false; this.scheduler = new RenderScheduler(); this.revision = 0; this.dirty = false; @@ -57,6 +58,10 @@ export class PatchMapEngine { } snapshot() { + if (this.renderPlanNeedsRefresh && this.renderIR) { + this.renderPlan = createRenderPlan(this.model, this.renderIR); + this.renderPlanNeedsRefresh = false; + } return { model: this.model, layout: this.layout, @@ -128,14 +133,17 @@ export class PatchMapEngine { { nodes: previousNodes }, { nodes: nextNodes }, ); - this.renderPlan = createRenderPlan(this.model, { nodes: nextNodes }); + const incrementalRenderPlan = createRenderPlan(this.model, { + nodes: nextNodes, + }); + this.renderPlanNeedsRefresh = true; this.scheduler.enqueue({ revision: this.revision + 1, model: this.model, layout: this.layout, renderIR: this.renderIR, renderDiff: this.renderDiff, - renderPlan: this.renderPlan, + renderPlan: incrementalRenderPlan, incremental: true, }); this.revision += 1; @@ -152,6 +160,7 @@ export class PatchMapEngine { }); this.renderDiff = diffRenderIR(previousIR, this.renderIR); this.renderPlan = createRenderPlan(this.model, this.renderIR); + this.renderPlanNeedsRefresh = false; this.scheduler.enqueue({ revision: this.revision + 1, model: this.model, diff --git a/src/engine/PatchMapEngine.test.js b/src/engine/PatchMapEngine.test.js index 4080417c..ee92f77a 100644 --- a/src/engine/PatchMapEngine.test.js +++ b/src/engine/PatchMapEngine.test.js @@ -60,7 +60,7 @@ describe('PatchMapEngine', () => { ).toEqual(['panel-grid.0.0', 'panel-grid.0.1', 'panel-grid.1.1']); }); - it('builds render IR for panel background and aggregateable bars', () => { + it('builds render IR for styled panel background and bars', () => { const engine = new PatchMapEngine(); const { renderIR, renderPlan } = engine.draw(createPanelData()); @@ -68,6 +68,12 @@ describe('PatchMapEngine', () => { const backgrounds = renderIR.byFeature.get('background'); expect(bars).toHaveLength(3); expect(backgrounds).toHaveLength(3); + expect(backgrounds[0].frame).toMatchObject({ + x: 10, + y: 20, + width: 100, + height: 40, + }); expect(bars[0]).toMatchObject({ feature: 'bar', layer: 'bar', @@ -191,7 +197,7 @@ describe('PatchMapEngine', () => { }); }); - it('moves a panel owner back to Pixi fallback policy when icon becomes visible', () => { + it('keeps a styled panel owner in Pixi fallback policy when icon becomes visible', () => { const engine = new PatchMapEngine(); engine.draw(createPanelData()); @@ -216,6 +222,9 @@ describe('PatchMapEngine', () => { .find((node) => node.ownerId === 'panel-grid.0.0'); expect(engine.renderPlan.aggregateBars).toHaveLength(2); + expect( + engine.renderPlan.aggregateBars.map((node) => node.id), + ).not.toContain(bar.id); expect(fallbackNodeIds).toContain(bar.id); expect(fallbackNodeIds).toContain(icon.id); }); diff --git a/src/engine/layout/createLayout.js b/src/engine/layout/createLayout.js index c79ad435..b298e74b 100644 --- a/src/engine/layout/createLayout.js +++ b/src/engine/layout/createLayout.js @@ -82,7 +82,10 @@ const resolveElementSize = (record) => { }; const layoutComponent = (component, itemFrame, itemRecord) => { - const contentFrame = getItemContentFrame(itemFrame, itemRecord.props.padding); + const contentFrame = + component.type === 'background' + ? itemFrame + : getItemContentFrame(itemFrame, itemRecord.props.padding); const size = resolveComponentSize(component, contentFrame); const placement = component.props.placement ?? defaultPlacement(component.type); diff --git a/src/engine/render-policy/createRenderPlan.js b/src/engine/render-policy/createRenderPlan.js index deb8dbaf..645ab7c4 100644 --- a/src/engine/render-policy/createRenderPlan.js +++ b/src/engine/render-policy/createRenderPlan.js @@ -16,10 +16,7 @@ export const createRenderPlan = (_model, renderIR) => { continue; } - if ( - node.feature === 'background' && - canUseAggregateBackground(node, visibleByOwner) - ) { + if (node.feature === 'background' && canUseAggregateBackground(node)) { aggregateBackgrounds.push(node); continue; } @@ -50,7 +47,7 @@ export const createRenderPlan = (_model, renderIR) => { }; const canUseAggregateBar = (node, visibleByOwner) => { - if (!isRectSource(node.material?.source)) return false; + if (!isAggregateRectSource(node.material?.source)) return false; const visible = visibleByOwner.get(node.ownerId) ?? []; return !visible.some( @@ -60,12 +57,8 @@ const canUseAggregateBar = (node, visibleByOwner) => { ); }; -const canUseAggregateBackground = (node, visibleByOwner) => { - if (!isRectSource(node.material?.source)) return false; - - const visible = visibleByOwner.get(node.ownerId) ?? []; - return visible.some((ownerNode) => ownerNode.feature === 'bar'); -}; +const canUseAggregateBackground = (node) => + isAggregateRectSource(node.material?.source); const groupVisibleNodesByOwner = (nodes) => { const byOwner = new Map(); @@ -80,7 +73,7 @@ const groupVisibleNodesByOwner = (nodes) => { return byOwner; }; -const isRectSource = (source) => source?.type === 'rect'; +const isAggregateRectSource = (source) => source?.type === 'rect'; const countAggregateLayers = ({ aggregateBars, aggregateBackgrounds }) => { let count = 0; From 17da35cb698179408566fa995243279ba78ae103 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 17:16:51 +0900 Subject: [PATCH 48/51] perf: render aggregate rect layers with atlas particles --- src/engine/backend/PixiRenderer.js | 739 ++++++++++++++++++++++- src/engine/backend/PixiRenderer.test.js | 6 +- src/tests/render/patchmap-engine.test.js | 52 +- 3 files changed, 765 insertions(+), 32 deletions(-) diff --git a/src/engine/backend/PixiRenderer.js b/src/engine/backend/PixiRenderer.js index 7aac9b6d..7d38f384 100644 --- a/src/engine/backend/PixiRenderer.js +++ b/src/engine/backend/PixiRenderer.js @@ -20,8 +20,15 @@ export class PixiRenderer { this.target = target ?? store?.world; this.layers = createLayers(); this.aggregateLayers = createAggregateLayers(); + this.aggregateTextureAtlases = { + background: new RectTextureAtlas(), + bar: new RectTextureAtlas({ tintable: true, animatedHeights: true }), + }; this.objectsById = new Map(); this.particlesById = new Map(); + this.aggregateNodesById = new Map(); + this.particleAnimations = new Map(); + this.particleAnimationFrame = null; this.attached = false; } @@ -65,10 +72,16 @@ export class PixiRenderer { destroy() { for (const object of this.objectsById.values()) { + cancelObjectAnimation(object); object.destroy({ children: true }); } this.objectsById.clear(); this.particlesById.clear(); + this.aggregateNodesById.clear(); + this.#cancelAllParticleAnimations(); + for (const atlas of Object.values(this.aggregateTextureAtlases)) { + atlas?.destroy(); + } for (const layer of Object.values(this.aggregateLayers)) { layer.destroy(); } @@ -145,6 +158,20 @@ export class PixiRenderer { #syncAggregateLayer(kind, nodes) { const layer = this.aggregateLayers[kind]; const wantedIds = new Set(nodes.map((node) => node.id)); + const atlas = this.aggregateTextureAtlases[kind]; + + for (const [id, node] of this.aggregateNodesById) { + if (node.layer === kind && !wantedIds.has(id)) { + this.aggregateNodesById.delete(id); + } + } + for (const node of nodes) { + this.aggregateNodesById.set(node.id, node); + } + atlas?.sync(nodes, this.store); + if (atlas?.baseTexture) { + layer.texture = atlas.baseTexture; + } for (const id of this.particlesById.keys()) { if (this.#getParticleKind(id) === kind && !wantedIds.has(id)) { @@ -155,12 +182,17 @@ export class PixiRenderer { const particles = nodes.map((node) => { let particle = this.particlesById.get(node.id); if (!particle) { - particle = new Particle({ texture: Texture.WHITE }); + particle = new Particle({ + texture: this.#resolveAggregateTexture(kind, node) ?? Texture.WHITE, + }); particle._patchmapNodeId = node.id; particle._patchmapKind = kind; this.particlesById.set(node.id, particle); } - applyNodeToParticle(particle, node, this.store); + applyNodeToParticle(particle, node, this.store, { + texture: this.#resolveAggregateTexture(kind, node), + tint: this.#resolveAggregateTint(kind, node), + }); return particle; }); @@ -172,26 +204,48 @@ export class PixiRenderer { #upsertParticle(node) { const kind = node.layer === 'background' ? 'background' : 'bar'; const layer = this.aggregateLayers[kind]; + this.aggregateNodesById.set(node.id, node); + this.#ensureAggregateTexture(kind, node); + if (this.aggregateTextureAtlases[kind]?.baseTexture) { + layer.texture = this.aggregateTextureAtlases[kind].baseTexture; + } let particle = this.particlesById.get(node.id); if (!particle) { - particle = new Particle({ texture: Texture.WHITE }); + particle = new Particle({ + texture: this.#resolveAggregateTexture(kind, node) ?? Texture.WHITE, + }); particle._patchmapNodeId = node.id; particle._patchmapKind = kind; this.particlesById.set(node.id, particle); layer.particleChildren.push(particle); } - applyNodeToParticle(particle, node, this.store); + applyNodeToParticle(particle, node, this.store, { + texture: this.#resolveAggregateTexture(kind, node), + tint: this.#resolveAggregateTint(kind, node), + animate: kind === 'bar', + onAnimate: (fromFrame, toFrame, durationMs) => + this.#animateParticleFrame( + kind, + particle, + node, + fromFrame, + toFrame, + durationMs, + ), + }); } #removeParticle(id) { const particle = this.particlesById.get(id); if (!particle) return; + this.#cancelParticleAnimation(particle); const layer = this.aggregateLayers[particle._patchmapKind]; const index = layer.particleChildren.indexOf(particle); if (index !== -1) { layer.particleChildren.splice(index, 1); } this.particlesById.delete(id); + this.aggregateNodesById.delete(id); } #getParticleKind(id) { @@ -201,11 +255,125 @@ export class PixiRenderer { #removeNode(id) { const object = this.objectsById.get(id); if (!object) return; + cancelObjectAnimation(object); object.parent?.removeChild(object); object.destroy({ children: true }); this.objectsById.delete(id); } + #resolveAggregateTexture(kind, node) { + return this.aggregateTextureAtlases[kind]?.get(node, this.store) ?? null; + } + + #resolveAggregateTextureForFrame(kind, node, frame) { + return ( + this.aggregateTextureAtlases[kind]?.getForFrame( + node, + frame, + this.store, + ) ?? null + ); + } + + #resolveAggregateTint(kind, node) { + if (kind !== 'bar') return undefined; + return getNodeTint(node, this.store); + } + + #ensureAggregateTexture(kind, node) { + const atlas = this.aggregateTextureAtlases[kind]; + if (!atlas || atlas.has(node, this.store)) return; + const nodes = [...this.aggregateNodesById.values()].filter( + (current) => current.layer === kind, + ); + atlas.sync(nodes, this.store); + for (const [id, particle] of this.particlesById) { + if (particle._patchmapKind !== kind) continue; + const currentNode = this.aggregateNodesById.get(id); + if (!currentNode) continue; + const texture = atlas.get(currentNode, this.store); + if (texture) particle.texture = texture; + } + } + + #animateParticleFrame(kind, particle, node, fromFrame, toFrame, durationMs) { + this.#cancelParticleAnimation(particle); + const duration = normalizeDuration(durationMs); + if (duration === 0) { + applyParticleFrame(particle, toFrame, { + texture: this.#resolveAggregateTextureForFrame(kind, node, toFrame), + tint: this.#resolveAggregateTint(kind, node), + }); + return; + } + + this.particleAnimations.set(particle, { + kind, + node, + fromFrame, + toFrame, + duration, + startedAt: now(), + }); + this.#scheduleParticleAnimationFrame(); + } + + #scheduleParticleAnimationFrame() { + if (this.particleAnimationFrame !== null) return; + this.particleAnimationFrame = requestFrame((time) => { + this.particleAnimationFrame = null; + this.#tickParticleAnimations(time); + }); + } + + #tickParticleAnimations(time = now()) { + for (const [particle, animation] of this.particleAnimations) { + if (!this.particlesById.has(particle._patchmapNodeId)) { + this.particleAnimations.delete(particle); + continue; + } + const progress = clamp01( + (time - animation.startedAt) / animation.duration, + ); + const eased = easePower2InOut(progress); + const frame = interpolateFrame( + animation.fromFrame, + animation.toFrame, + eased, + ); + applyParticleFrame(particle, frame, { + texture: this.#resolveAggregateTextureForFrame( + animation.kind, + animation.node, + frame, + ), + tint: this.#resolveAggregateTint(animation.kind, animation.node), + }); + if (progress >= 1) { + this.particleAnimations.delete(particle); + } + } + + for (const layer of Object.values(this.aggregateLayers)) { + layer.update(); + } + if (this.particleAnimations.size > 0) { + this.#scheduleParticleAnimationFrame(); + } + } + + #cancelParticleAnimation(particle) { + this.particleAnimations.delete(particle); + } + + #cancelAllParticleAnimations() { + this.particleAnimations.clear(); + if (this.particleAnimationFrame !== null) { + cancelFrame(this.particleAnimationFrame); + this.particleAnimationFrame = null; + } + } + #getLayer(node) { if (node.layer === 'background') return this.layers.background; if (node.layer === 'bar') return this.layers.bar; @@ -280,6 +448,7 @@ const createAggregateLayer = (label) => { vertex: true, position: true, rotation: true, + uvs: true, color: true, }, }); @@ -309,9 +478,7 @@ const applyNodeToObject = (object, node, store) => { object.visible = node.frame.visible !== false; object.renderable = object.visible; - applyNodeTransform(object, node); - object.width = node.frame.width; - object.height = node.frame.height; + applyNodeFrame(object, node); const tint = getNodeTint(node, store); if (tint !== undefined) { object.tint = normalizeColor(tint); @@ -327,6 +494,158 @@ const applyNodeTransform = (object, node) => { object.alpha = node.frame.alpha ?? 1; }; +const applyNodeFrame = (object, node) => { + const nextFrame = normalizeFrame(node.frame); + const previousFrame = object._patchmapFrame; + const shouldAnimate = + node.feature === 'bar' && + node.material?.animation && + previousFrame && + previousFrame.visible !== false && + nextFrame.visible !== false && + hasGeometryChange(previousFrame, nextFrame); + + object.visible = nextFrame.visible !== false; + object.renderable = object.visible; + object._patchmapFrame = nextFrame; + + if (shouldAnimate) { + animateObjectFrame( + object, + readObjectFrame(object, previousFrame), + nextFrame, + node.material?.animationDuration, + ); + return; + } + + cancelObjectAnimation(object); + applyFrameToObject(object, nextFrame); +}; + +const normalizeFrame = (frame) => ({ + x: frame.x, + y: frame.y, + width: frame.width, + height: frame.height, + rotation: frame.rotation ?? 0, + alpha: frame.alpha ?? 1, + visible: frame.visible !== false, +}); + +const readObjectFrame = (object, fallback) => ({ + x: Number.isFinite(object.x) ? object.x : fallback.x, + y: Number.isFinite(object.y) ? object.y : fallback.y, + width: Number.isFinite(object.width) ? object.width : fallback.width, + height: Number.isFinite(object.height) ? object.height : fallback.height, + rotation: Number.isFinite(object.rotation) + ? object.rotation + : fallback.rotation, + alpha: Number.isFinite(object.alpha) ? object.alpha : fallback.alpha, + visible: object.visible !== false, +}); + +const applyFrameToObject = (object, frame) => { + object.visible = frame.visible !== false; + object.renderable = object.visible; + object.x = frame.x; + object.y = frame.y; + object.rotation = frame.rotation ?? 0; + object.alpha = frame.alpha ?? 1; + object.width = Math.max(0, frame.width); + object.height = Math.max(0, frame.height); +}; + +const animateObjectFrame = (object, fromFrame, toFrame, durationMs) => { + cancelObjectAnimation(object); + + const duration = normalizeDuration(durationMs); + if (duration === 0) { + applyFrameToObject(object, toFrame); + return; + } + + const startedAt = now(); + const animation = { + frameId: null, + cancelled: false, + }; + const tick = (time = now()) => { + if (animation.cancelled || object.destroyed) return; + + const progress = clamp01((time - startedAt) / duration); + const eased = easePower2InOut(progress); + applyFrameToObject(object, interpolateFrame(fromFrame, toFrame, eased)); + + if (progress < 1) { + animation.frameId = requestFrame(tick); + } else { + object._patchmapAnimation = null; + applyFrameToObject(object, toFrame); + } + }; + + object._patchmapAnimation = animation; + animation.frameId = requestFrame(tick); +}; + +const cancelObjectAnimation = (object) => { + const animation = object?._patchmapAnimation; + if (!animation) return; + animation.cancelled = true; + if (animation.frameId !== null) cancelFrame(animation.frameId); + object._patchmapAnimation = null; +}; + +const interpolateFrame = (fromFrame, toFrame, progress) => ({ + x: lerp(fromFrame.x, toFrame.x, progress), + y: lerp(fromFrame.y, toFrame.y, progress), + width: lerp(fromFrame.width, toFrame.width, progress), + height: lerp(fromFrame.height, toFrame.height, progress), + rotation: lerp(fromFrame.rotation, toFrame.rotation, progress), + alpha: lerp(fromFrame.alpha, toFrame.alpha, progress), + visible: toFrame.visible !== false, +}); + +const hasGeometryChange = (fromFrame, toFrame) => + Math.abs(fromFrame.x - toFrame.x) > 0.001 || + Math.abs(fromFrame.y - toFrame.y) > 0.001 || + Math.abs(fromFrame.width - toFrame.width) > 0.001 || + Math.abs(fromFrame.height - toFrame.height) > 0.001 || + Math.abs(fromFrame.rotation - toFrame.rotation) > 0.001; + +const normalizeDuration = (durationMs) => + Math.max(0, Number.isFinite(durationMs) ? durationMs : 200); + +const easePower2InOut = (value) => + value < 0.5 ? 2 * value * value : 1 - (-2 * value + 2) ** 2 / 2; + +const lerp = (from, to, progress) => from + (to - from) * progress; + +const clamp01 = (value) => Math.min(1, Math.max(0, value)); + +const now = () => { + if (typeof performance !== 'undefined' && performance.now) { + return performance.now(); + } + return Date.now(); +}; + +const requestFrame = (callback) => { + if (typeof requestAnimationFrame === 'function') { + return requestAnimationFrame(callback); + } + return setTimeout(() => callback(now()), 16); +}; + +const cancelFrame = (frameId) => { + if (typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(frameId); + return; + } + clearTimeout(frameId); +}; + const applyTextNodeToBitmapText = (object, node, store) => { const layout = resolveTextLayout(node, store); object.text = layout.text; @@ -426,18 +745,72 @@ const getLinkBounds = (linkPoints) => { return new Rectangle(minX, minY, maxX - minX, maxY - minY); }; -const applyNodeToParticle = (particle, node, store) => { - particle.x = node.frame.x; - particle.y = node.frame.y; - particle.scaleX = node.frame.width; - particle.scaleY = node.frame.height; - particle.rotation = node.frame.rotation ?? 0; +const applyNodeToParticle = (particle, node, store, options = {}) => { + const nextFrame = normalizeFrame(node.frame); + const previousFrame = particle._patchmapFrame; + const shouldAnimate = + options.animate && + node.material?.animation && + previousFrame && + previousFrame.visible !== false && + nextFrame.visible !== false && + hasGeometryChange(previousFrame, nextFrame); + + particle._patchmapFrame = nextFrame; + if (shouldAnimate) { + options.onAnimate?.( + readParticleFrame(particle, previousFrame), + nextFrame, + node.material?.animationDuration, + ); + return; + } + + applyParticleFrame(particle, nextFrame, { + texture: options.texture, + tint: + options.tint ?? (options.texture ? undefined : getNodeTint(node, store)), + }); +}; + +const readParticleFrame = (particle, fallback) => ({ + x: Number.isFinite(particle.x) ? particle.x : fallback.x, + y: Number.isFinite(particle.y) ? particle.y : fallback.y, + width: particle.texture + ? particle.texture.width * particle.scaleX + : fallback.width, + height: particle.texture + ? particle.texture.height * particle.scaleY + : fallback.height, + rotation: Number.isFinite(particle.rotation) + ? particle.rotation + : fallback.rotation, + alpha: Number.isFinite(particle.alpha) ? particle.alpha : fallback.alpha, + visible: fallback.visible !== false, +}); + +const applyParticleFrame = (particle, frame, options = {}) => { + if (options.texture) { + particle.texture = options.texture; + } + const texture = options.texture ? particle.texture : null; + particle.x = frame.x; + particle.y = frame.y; + particle.scaleX = texture + ? frame.width / Math.max(1, texture.width) + : frame.width; + particle.scaleY = texture + ? frame.height / Math.max(1, texture.height) + : frame.height; + particle.rotation = frame.rotation ?? 0; particle.anchorX = 0; particle.anchorY = 0; - particle.alpha = node.frame.alpha ?? 1; - const tint = getNodeTint(node, store); + particle.alpha = frame.alpha ?? 1; + const tint = options.tint; if (tint !== undefined) { particle.tint = normalizeColor(tint); + } else { + particle.tint = 0xffffff; } }; @@ -557,6 +930,342 @@ const normalizeMargin = (margin = 0) => { }; }; +class RectTextureAtlas { + constructor({ tintable = false, animatedHeights = false } = {}) { + this.tintable = tintable; + this.animatedHeights = animatedHeights; + this.animatedHeightMaxByBaseKey = new Map(); + this.signature = ''; + this.baseTexture = null; + this.texturesByKey = new Map(); + } + + sync(nodes, store) { + const entries = createAtlasEntries(nodes, store, { + tintable: this.tintable, + animatedHeights: this.animatedHeights, + animatedHeightMaxByBaseKey: this.animatedHeightMaxByBaseKey, + }); + const signature = entries.map((entry) => entry.key).join('|'); + if (signature === this.signature) return; + + this.destroy(); + this.signature = signature; + if (entries.length === 0 || !canCreateCanvas()) return; + + const packed = packAtlasEntries(entries); + const canvas = document.createElement('canvas'); + canvas.width = packed.width; + canvas.height = packed.height; + const context = canvas.getContext('2d'); + if (!context) return; + + for (const entry of packed.entries) { + drawRectAtlasEntry(context, entry); + } + + this.baseTexture = Texture.from(canvas); + this.baseTexture.source.update?.(); + for (const entry of packed.entries) { + this.texturesByKey.set( + entry.key, + new Texture({ + source: this.baseTexture.source, + frame: new Rectangle( + entry.x, + entry.y, + entry.pixelWidth, + entry.pixelHeight, + ), + orig: new Rectangle(0, 0, entry.width, entry.height), + }), + ); + } + } + + has(node, store) { + return this.texturesByKey.has( + createAtlasKey(node, store, { tintable: this.tintable }), + ); + } + + get(node, store) { + return ( + this.texturesByKey.get( + createAtlasKey(node, store, { tintable: this.tintable }), + ) ?? null + ); + } + + getForFrame(node, frame, store) { + return ( + this.texturesByKey.get( + createAtlasKey(node, store, { + frame, + tintable: this.tintable, + }), + ) ?? null + ); + } + + destroy() { + for (const texture of this.texturesByKey.values()) { + texture.destroy(false); + } + this.texturesByKey.clear(); + this.baseTexture?.destroy(true); + this.baseTexture = null; + this.signature = ''; + } +} + +const canCreateCanvas = () => + typeof document !== 'undefined' && + typeof document.createElement === 'function'; + +const ATLAS_RESOLUTION = 5; + +const createAtlasEntries = (nodes, store, options = {}) => { + const byKey = new Map(); + for (const node of nodes) { + if (node.material?.source?.type !== 'rect') continue; + const entries = createAtlasNodeEntries(node, store, options); + for (const entry of entries) { + if (!entry || byKey.has(entry.key)) continue; + byKey.set(entry.key, entry); + } + } + return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key)); +}; + +const createAtlasNodeEntries = (node, store, options) => { + const height = Math.max(1, Math.ceil(node.frame.height)); + if (!options.animatedHeights) { + return [createAtlasEntry(node, store, options, node.frame)]; + } + + const baseKey = createAtlasBaseKey(node, store, options); + const reservedHeight = Math.min( + 256, + Math.max(height, Math.ceil(Math.max(1, node.frame.width) * 3)), + ); + const maxHeight = Math.max( + reservedHeight, + options.animatedHeightMaxByBaseKey?.get(baseKey) ?? 0, + ); + options.animatedHeightMaxByBaseKey?.set(baseKey, maxHeight); + + const entries = []; + for (let currentHeight = 1; currentHeight <= maxHeight; currentHeight += 1) { + entries.push( + createAtlasEntry(node, store, options, { + ...node.frame, + height: currentHeight, + }), + ); + } + return entries; +}; + +const createAtlasEntry = (node, store, options, frame) => { + const width = Math.max(1, Math.ceil(frame.width)); + const height = Math.max(1, Math.ceil(frame.height)); + const source = node.material.source; + const fill = resolveAtlasFill(node, store, options); + return { + key: createAtlasKey(node, store, { ...options, frame }), + width, + height, + fill, + borderColor: resolveCanvasColor( + store, + source.borderColor ?? + source.stroke?.color ?? + source.fill ?? + 'transparent', + ), + borderWidth: Math.max( + 0, + Number(source.borderWidth ?? source.stroke?.width ?? 0), + ), + radius: normalizeRadius(source.radius ?? 0), + }; +}; + +const createAtlasKey = (node, store, options = {}) => { + const source = node.material?.source ?? {}; + const frame = options.frame ?? node.frame; + return JSON.stringify({ + width: Math.max(1, Math.ceil(frame.width)), + height: Math.max(1, Math.ceil(frame.height)), + fill: resolveAtlasFill(node, store, options), + borderColor: resolveCanvasColor( + store, + source.borderColor ?? + source.stroke?.color ?? + source.fill ?? + 'transparent', + ), + borderWidth: Math.max( + 0, + Number(source.borderWidth ?? source.stroke?.width ?? 0), + ), + radius: normalizeRadius(source.radius ?? 0), + }); +}; + +const createAtlasBaseKey = (node, store, options = {}) => { + const source = node.material?.source ?? {}; + return JSON.stringify({ + width: Math.max(1, Math.ceil(node.frame.width)), + fill: resolveAtlasFill(node, store, options), + borderColor: resolveCanvasColor( + store, + source.borderColor ?? + source.stroke?.color ?? + source.fill ?? + 'transparent', + ), + borderWidth: Math.max( + 0, + Number(source.borderWidth ?? source.stroke?.width ?? 0), + ), + radius: normalizeRadius(source.radius ?? 0), + }); +}; + +const resolveAtlasFill = (node, store, options = {}) => { + if ( + options.tintable && + node.material?.tint && + Number(node.material?.source?.borderWidth ?? 0) === 0 + ) { + return 'white'; + } + return resolveCanvasColor( + store, + node.material?.source?.fill ?? node.material?.tint ?? 'white', + ); +}; + +const packAtlasEntries = (entries) => { + const padding = 2; + const maxWidth = 2048; + let cursorX = padding; + let cursorY = padding; + let rowHeight = 0; + let width = 0; + + const packedEntries = entries.map((entry) => { + const pixelWidth = entry.width * ATLAS_RESOLUTION; + const pixelHeight = entry.height * ATLAS_RESOLUTION; + if (cursorX + pixelWidth + padding > maxWidth) { + cursorX = padding; + cursorY += rowHeight + padding; + rowHeight = 0; + } + const packed = { + ...entry, + x: cursorX, + y: cursorY, + pixelWidth, + pixelHeight, + }; + cursorX += pixelWidth + padding; + rowHeight = Math.max(rowHeight, pixelHeight); + width = Math.max(width, cursorX); + return packed; + }); + + return { + width: nextPowerOfTwo(Math.max(1, Math.min(maxWidth, width + padding))), + height: nextPowerOfTwo(Math.max(1, cursorY + rowHeight + padding)), + entries: packedEntries, + }; +}; + +const drawRectAtlasEntry = (context, entry) => { + context.save(); + context.translate(entry.x, entry.y); + context.scale(ATLAS_RESOLUTION, ATLAS_RESOLUTION); + + const borderInset = entry.borderWidth / 2; + const x = borderInset; + const y = borderInset; + const width = Math.max(0, entry.width - entry.borderWidth); + const height = Math.max(0, entry.height - entry.borderWidth); + const radius = clampRadius(entry.radius, width, height); + + context.beginPath(); + roundedRectPath(context, x, y, width, height, radius); + context.fillStyle = entry.fill; + context.fill(); + if (entry.borderWidth > 0) { + context.lineWidth = entry.borderWidth; + context.strokeStyle = entry.borderColor; + context.stroke(); + } + context.restore(); +}; + +const roundedRectPath = (context, x, y, width, height, radius) => { + context.moveTo(x + radius.topLeft, y); + context.lineTo(x + width - radius.topRight, y); + context.quadraticCurveTo(x + width, y, x + width, y + radius.topRight); + context.lineTo(x + width, y + height - radius.bottomRight); + context.quadraticCurveTo( + x + width, + y + height, + x + width - radius.bottomRight, + y + height, + ); + context.lineTo(x + radius.bottomLeft, y + height); + context.quadraticCurveTo(x, y + height, x, y + height - radius.bottomLeft); + context.lineTo(x, y + radius.topLeft); + context.quadraticCurveTo(x, y, x + radius.topLeft, y); +}; + +const normalizeRadius = (radius) => { + if (typeof radius === 'number') { + return { + topLeft: radius, + topRight: radius, + bottomRight: radius, + bottomLeft: radius, + }; + } + return { + topLeft: Number(radius?.topLeft ?? radius?.top ?? radius?.left ?? 0), + topRight: Number(radius?.topRight ?? radius?.top ?? radius?.right ?? 0), + bottomRight: Number( + radius?.bottomRight ?? radius?.bottom ?? radius?.right ?? 0, + ), + bottomLeft: Number( + radius?.bottomLeft ?? radius?.bottom ?? radius?.left ?? 0, + ), + }; +}; + +const clampRadius = (radius, width, height) => { + const limit = Math.max(0, Math.min(width, height) / 2); + return { + topLeft: Math.min(limit, radius.topLeft), + topRight: Math.min(limit, radius.topRight), + bottomRight: Math.min(limit, radius.bottomRight), + bottomLeft: Math.min(limit, radius.bottomLeft), + }; +}; + +const resolveCanvasColor = (store, color) => { + const resolved = getColor(store?.theme, color); + if (typeof resolved === 'number') { + return `#${resolved.toString(16).padStart(6, '0')}`; + } + return resolved ?? 'transparent'; +}; + +const nextPowerOfTwo = (value) => 2 ** Math.ceil(Math.log2(value)); + const syncRenderedRef = (store, node, object) => { const ref = store?.elementById?.get(node.id); if (!ref) return; diff --git a/src/engine/backend/PixiRenderer.test.js b/src/engine/backend/PixiRenderer.test.js index 120e4a25..6d1f077e 100644 --- a/src/engine/backend/PixiRenderer.test.js +++ b/src/engine/backend/PixiRenderer.test.js @@ -30,6 +30,7 @@ describe('PixiRenderer', () => { source: { type: 'rect', fill: '#0ea5e9', radius: 3 }, size: { width: '100%', height: '50%' }, placement: 'bottom', + animation: false, }, { type: 'icon', source: 'alert', show: true, size: 10 }, ], @@ -66,6 +67,7 @@ describe('PixiRenderer', () => { source: { type: 'rect', fill: '#0ea5e9', radius: 3 }, size: { width: '100%', height: '80%' }, placement: 'bottom', + animation: false, }, { type: 'icon', source: 'alert', show: true, size: 10 }, ], @@ -101,11 +103,11 @@ describe('PixiRenderer', () => { components: [ { type: 'background', - source: { type: 'rect', fill: '#ffffff', radius: 4 }, + source: { type: 'rect', fill: '#ffffff' }, }, { type: 'bar', - source: { type: 'rect', fill: '#0ea5e9', radius: 3 }, + source: { type: 'rect', fill: '#0ea5e9' }, size: { width: '100%', height: '50%' }, placement: 'bottom', }, diff --git a/src/tests/render/patchmap-engine.test.js b/src/tests/render/patchmap-engine.test.js index 27a053c3..24d8b32f 100644 --- a/src/tests/render/patchmap-engine.test.js +++ b/src/tests/render/patchmap-engine.test.js @@ -55,25 +55,30 @@ describe('Patchmap engine mode', () => { const [item] = patchmap.selector('$..[?(@.id=="panel-1")]'); expect(item).toMatchObject({ id: 'panel-1', display: 'panelItem' }); - const barParticle = - patchmap._renderer.aggregateLayers.bar.particleChildren[0]; - expect(barParticle.scaleY).toBe(25); - expect(barParticle.y).toBe(25); + const getBarObject = () => { + const barNode = patchmap._engine.renderIR.byFeature + .get('bar') + .find((node) => node.ownerId === item.id); + return patchmap._renderer.objectsById.get(barNode.id); + }; + const barObject = getBarObject(); + expect(barObject.height).toBe(25); + expect(barObject.y).toBe(25); const updated = patchmap.update({ elements: item, changes: { - components: [{ type: 'bar', size: { height: '80%' } }], + components: [ + { type: 'bar', size: { height: '80%' }, animation: false }, + ], }, validateSchema: false, }); expect(updated).toEqual([item]); - expect(patchmap._renderer.aggregateLayers.bar.particleChildren[0]).toBe( - barParticle, - ); - expect(barParticle.scaleY).toBe(40); - expect(barParticle.y).toBe(10); + expect(getBarObject()).toBe(barObject); + expect(barObject.height).toBe(40); + expect(barObject.y).toBe(10); }); it('exposes lightweight bounds refs for fit and transformer drawing', () => { @@ -113,26 +118,43 @@ describe('Patchmap engine mode', () => { it('batches emit:false updates into the next frame', async () => { patchmap.draw(createPanelData()); const [item] = patchmap.selector('$..[?(@.id=="panel-1")]'); - const barParticle = - patchmap._renderer.aggregateLayers.bar.particleChildren[0]; + const getBarObject = () => { + const barNode = patchmap._engine.renderIR.byFeature + .get('bar') + .find((node) => node.ownerId === item.id); + return patchmap._renderer.objectsById.get(barNode.id); + }; + const barObject = getBarObject(); patchmap.update({ elements: item, changes: { - components: [{ type: 'bar', size: { height: '80%' } }], + components: [ + { type: 'bar', size: { height: '20%' }, animation: false }, + ], + }, + validateSchema: false, + emit: false, + }); + patchmap.update({ + elements: item, + changes: { + components: [ + { type: 'bar', size: { height: '80%' }, animation: false }, + ], }, validateSchema: false, emit: false, }); expect(patchmap._updateQueue).toHaveLength(1); - expect(barParticle.scaleY).toBe(25); + expect(barObject.height).toBe(25); await new Promise((resolve) => requestAnimationFrame(resolve)); await new Promise((resolve) => requestAnimationFrame(resolve)); expect(patchmap._updateQueue).toHaveLength(0); expect(patchmap._engine.dirty).toBe(false); - expect(barParticle.scaleY).toBe(40); + expect(getBarObject().height).toBe(40); }); }); From 6f65fb3a43dc8380c8ea54fbb26e0288ec4b7656 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 17:29:17 +0900 Subject: [PATCH 49/51] perf: reduce aggregate particle animation writes --- src/engine/backend/PixiRenderer.js | 45 +++++++++++++++++++----------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/src/engine/backend/PixiRenderer.js b/src/engine/backend/PixiRenderer.js index 7d38f384..a2dacbbb 100644 --- a/src/engine/backend/PixiRenderer.js +++ b/src/engine/backend/PixiRenderer.js @@ -435,22 +435,28 @@ const createLayer = (label) => { }; const createAggregateLayers = () => ({ - background: createAggregateLayer('patchmap-aggregate-background-layer'), - bar: createAggregateLayer('patchmap-aggregate-bar-layer'), + background: createAggregateLayer('patchmap-aggregate-background-layer', { + vertex: false, + position: false, + rotation: false, + uvs: false, + color: false, + }), + bar: createAggregateLayer('patchmap-aggregate-bar-layer', { + vertex: true, + position: true, + rotation: false, + uvs: true, + color: false, + }), }); -const createAggregateLayer = (label) => { +const createAggregateLayer = (label, dynamicProperties) => { const layer = new ParticleContainer({ label, texture: Texture.WHITE, boundsArea: new Rectangle(-1_000_000, -1_000_000, 2_000_000, 2_000_000), - dynamicProperties: { - vertex: true, - position: true, - rotation: true, - uvs: true, - color: true, - }, + dynamicProperties, }); layer._patchmapInternal = true; layer._patchmapAggregateLayer = true; @@ -802,15 +808,22 @@ const applyParticleFrame = (particle, frame, options = {}) => { particle.scaleY = texture ? frame.height / Math.max(1, texture.height) : frame.height; - particle.rotation = frame.rotation ?? 0; - particle.anchorX = 0; - particle.anchorY = 0; - particle.alpha = frame.alpha ?? 1; + const rotation = frame.rotation ?? 0; + if (particle.rotation !== rotation) particle.rotation = rotation; + if (particle.anchorX !== 0) particle.anchorX = 0; + if (particle.anchorY !== 0) particle.anchorY = 0; + const alpha = frame.alpha ?? 1; + if (particle.alpha !== alpha) particle.alpha = alpha; const tint = options.tint; if (tint !== undefined) { - particle.tint = normalizeColor(tint); - } else { + const color = normalizeColor(tint); + if (particle._patchmapTint !== color) { + particle.tint = color; + particle._patchmapTint = color; + } + } else if (particle._patchmapTint !== 0xffffff) { particle.tint = 0xffffff; + particle._patchmapTint = 0xffffff; } }; From 1caba65ddb46724a2bfe1213f43012b1e0893183 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Thu, 14 May 2026 17:54:08 +0900 Subject: [PATCH 50/51] fix: align rotated component layout --- src/engine/PatchMapEngine.test.js | 45 +++++++++++++++++++++++++++++++ src/engine/layout/createLayout.js | 32 +++++++++++++++++----- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/engine/PatchMapEngine.test.js b/src/engine/PatchMapEngine.test.js index ee92f77a..571916ff 100644 --- a/src/engine/PatchMapEngine.test.js +++ b/src/engine/PatchMapEngine.test.js @@ -127,6 +127,51 @@ describe('PatchMapEngine', () => { expect(engine.renderDiff.updated.map((node) => node.id)).toContain(bar.id); }); + it('places item components in the rotated owner coordinate space', () => { + const engine = new PatchMapEngine(); + const { renderIR } = engine.draw([ + { + type: 'item', + id: 'rotated-panel', + size: { width: 120, height: 80 }, + padding: 10, + attrs: { x: 250, y: 160, angle: 90 }, + components: [ + { + type: 'background', + id: 'panel-bg', + source: { type: 'rect', fill: '#ffffff' }, + }, + { + type: 'bar', + id: 'panel-bar', + source: { type: 'rect', fill: '#0ea5e9' }, + size: { width: '100%', height: '50%' }, + placement: 'bottom', + }, + ], + }, + ]); + + const background = renderIR.byFeature.get('background')[0]; + const bar = renderIR.byFeature.get('bar')[0]; + + expect(background.frame).toMatchObject({ + x: 250, + y: 160, + width: 120, + height: 80, + }); + expect(background.frame.rotation).toBeCloseTo(Math.PI / 2); + expect(bar.frame).toMatchObject({ + x: 210, + y: 170, + width: 100, + height: 30, + }); + expect(bar.frame.rotation).toBeCloseTo(Math.PI / 2); + }); + it('keeps selector compatibility refs stable across direct element updates', () => { const engine = new PatchMapEngine(); engine.draw(createPanelData()); diff --git a/src/engine/layout/createLayout.js b/src/engine/layout/createLayout.js index b298e74b..9cb50bb4 100644 --- a/src/engine/layout/createLayout.js +++ b/src/engine/layout/createLayout.js @@ -42,11 +42,12 @@ const layoutNodeRecursive = ( ) => { const attrs = record.props.attrs ?? {}; const size = resolveElementSize(record); + const origin = transformLocalPoint(parentFrame, attrs.x ?? 0, attrs.y ?? 0); const frame = { id: record.id, parentId: record.parentId, - x: parentFrame.x + (attrs.x ?? 0), - y: parentFrame.y + (attrs.y ?? 0), + x: origin.x, + y: origin.y, width: size.width, height: size.height, rotation: parentFrame.rotation + resolveRotation(attrs), @@ -84,18 +85,19 @@ const resolveElementSize = (record) => { const layoutComponent = (component, itemFrame, itemRecord) => { const contentFrame = component.type === 'background' - ? itemFrame + ? { x: 0, y: 0, width: itemFrame.width, height: itemFrame.height } : getItemContentFrame(itemFrame, itemRecord.props.padding); const size = resolveComponentSize(component, contentFrame); const placement = component.props.placement ?? defaultPlacement(component.type); const margin = normalizeBoxSpacing(component.props.margin ?? 0); const point = resolvePlacement(placement, contentFrame, size, margin); + const origin = transformLocalPoint(itemFrame, point.x, point.y); return { id: component.id, parentId: itemRecord.id, - x: point.x, - y: point.y, + x: origin.x, + y: origin.y, width: size.width, height: size.height, rotation: itemFrame.rotation, @@ -107,13 +109,29 @@ const layoutComponent = (component, itemFrame, itemRecord) => { const getItemContentFrame = (itemFrame, padding = 0) => { const normalized = normalizeBoxSpacing(padding); return { - x: itemFrame.x + normalized.left, - y: itemFrame.y + normalized.top, + x: normalized.left, + y: normalized.top, width: Math.max(0, itemFrame.width - normalized.left - normalized.right), height: Math.max(0, itemFrame.height - normalized.top - normalized.bottom), }; }; +const transformLocalPoint = (parentFrame, localX, localY) => { + const rotation = parentFrame.rotation ?? 0; + if (rotation === 0) { + return { + x: parentFrame.x + localX, + y: parentFrame.y + localY, + }; + } + const cos = Math.cos(rotation); + const sin = Math.sin(rotation); + return { + x: parentFrame.x + localX * cos - localY * sin, + y: parentFrame.y + localX * sin + localY * cos, + }; +}; + const resolveComponentSize = (component, contentFrame) => { const size = component.props.size ?? { width: { value: 100, unit: '%' }, From a3909e5633abdec8a08543e828320b54554485ed Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 26 May 2026 11:27:18 +0900 Subject: [PATCH 51/51] docs: add performance experiments documentation for Patchmap V2 --- .../patchmap-main-performance-final-report.md | 332 ++++++++++ ...map-perf-branch-performance-experiments.md | 610 ++++++++++++++++++ .../patchmap-v2-performance-experiments.md | 364 +++++++++++ 3 files changed, 1306 insertions(+) create mode 100644 docs/experiments/patchmap-main-performance-final-report.md create mode 100644 docs/experiments/patchmap-perf-branch-performance-experiments.md create mode 100644 docs/experiments/patchmap-v2-performance-experiments.md diff --git a/docs/experiments/patchmap-main-performance-final-report.md b/docs/experiments/patchmap-main-performance-final-report.md new file mode 100644 index 00000000..ae4ffc42 --- /dev/null +++ b/docs/experiments/patchmap-main-performance-final-report.md @@ -0,0 +1,332 @@ +# Patchmap Main Performance Final Report + +이 보고서는 `main` 브랜치의 성능 병목을 기준으로 `perf/patchmap-render-optimization`와 `rewrite/patchmap-v2-engine`에서 진행한 실험을 종합한 최종 판단이다. 목표는 실제 `main`에 어떤 성능 개선을 가져갈지 결정하는 것이다. + +참고 문서: + +- `docs/experiments/patchmap-perf-branch-performance-experiments.md` +- `docs/experiments/patchmap-v2-performance-experiments.md` + +## Documentation Review Result + +두 실험 문서를 다시 검토한 결과, 새 세션에서 시작해도 다음 정보를 파악할 수 있다. + +- 어떤 브랜치에서 어떤 커밋이 채택됐는지 +- 어떤 실험이 revert됐는지 +- 어떤 benchmark report를 기준으로 판단했는지 +- 각 실험이 어떤 시나리오를 개선하거나 악화했는지 +- 렌더 parity, animation, padding, alpha, relation layer, rotation 같은 기능/시각 리스크가 무엇이었는지 +- main에 가져갈 후보와 가져가지 말아야 할 후보가 무엇인지 + +보강한 내용: + +- 두 문서에 `Documentation Review` 섹션을 추가했다. +- 커밋 없이 benchmark report만 남은 PoC는 영속 diff가 없다는 점을 명시했다. +- perf 브랜치에 잠시 올라갔다가 v2로 옮겨진 커밋을 perf 최종 결과와 분리했다. + +남은 한계: + +- 일부 실험은 대화 중 즉시 revert되어 정확한 diff가 남아 있지 않다. +- benchmark는 대부분 단일 로컬 실행이다. 절대 FPS보다 같은 리포트 안 상대 비교가 더 신뢰할 만하다. +- current `main`은 `v0.9.2`이며, `v0.9.1` 이후 성능 구조 변경이 거의 없어서 0.9.1 benchmark를 current main 병목의 대표값으로 사용한다. + +## Current Main Baseline + +현재 `main`: + +- HEAD: `351e037 0.9.2` +- `v0.9.1` 이후 주요 변경: normalized text placement fix +- perf/v2의 SceneIndex, aggregate bar layer, panel component renderer, update coalescing, render scheduler는 포함되어 있지 않다. + +main 코드 기준 병목: + +| 영역 | 현재 main 동작 | 병목 | +| --- | --- | --- | +| draw | 매번 `JSON.parse(JSON.stringify(data))`, schema validation, full draw | 같은 데이터 redraw에서도 clone/validation/render 비용 반복 | +| selector | 모든 path를 `JSONSearch`로 children traversal | id/type/display 조회도 전체 scene 순회 | +| update | path selector 후 각 element에 일반 `apply` | patch-service의 대량 panel component update도 일반 update pipeline 사용 | +| components | component change마다 children copy, schema/default materializer, priority lookup | 9,336 panel item 전체 update에서 반복 비용 큼 | +| selection | 매 hit-test마다 `collectCandidates`로 selectable 후보 탐색 | shift-drag, transformer select에서 후보 탐색과 bounds 계산 반복 | +| bar rendering | 각 bar가 일반 DisplayObject/Graphics/Sprite path로 존재 | bar-only panel에서도 객체 수와 animation update가 많음 | +| bulk update | latest-state queue/coalescing/frame budget 없음 | 대량 update가 한 번에 몰리면 main thread spike | +| culling | scene 전체 Pixi object 유지 | viewport 밖 object도 update 대상이 되기 쉬움 | + +0.9.1 proxy benchmark: + +리포트: `.gstack/benchmark-reports/2026-05-14T06-39-39-163Z-patchmap-3way-frame-benchmark-isolated.json` + +| 시나리오 | 0.9.1 FPS | 의미 | +| --- | ---: | --- | +| idle baseline | 4.75 | 대용량 scene 유지 비용 | +| draw redraw+fit | 4.61 | redraw/full validation/full render 비용 | +| draw+update all bars | 3.31 | 전체 panel bar update 병목 | +| all bars x10 | 3.43 | 반복 bulk update 병목 | +| wheel pan | 4.46 | 많은 DisplayObject와 transform/update 부담 | +| ctrl+wheel zoom | 5.89 | zoom 자체는 상대적으로 덜 나쁨 | +| transformer select | 4.73 | selection candidate 탐색 비용 | +| shift drag multi select | 4.92 | multi-selection bounds/hit-test 비용 | +| mixed state burst | 3.05 | show/alpha/size 혼합 update 병목 | +| chart stream | 3.46 | 작은 panel update가 계속 들어오는 stream 병목 | +| highlight alpha burst | 5.15 | alpha propagation/update 병목 | +| relations visibility | 6.18 | relation path update 비용 | +| report backgrounds burst | 5.60 | background component update 비용 | + +이 isolated 리포트는 절대 FPS가 낮게 나오는 모드다. 중요한 점은 같은 리포트 안에서 perf/v2와 비교했을 때 main 계열이 bulk update, draw, pan, mixed burst에서 꾸준히 뒤처진다는 것이다. + +## Perf Branch Result + +perf 브랜치는 main의 구조를 크게 유지하면서 부분 최적화를 얹었다. + +핵심 해결 방식: + +- `SceneIndex`로 selector/find 후보 탐색 비용 감소 +- selection bounds cache와 `warmFindBoundsCache` +- direct element update fast path +- patch-service panel component renderer +- aggregate `PanelBarLayer` +- dirty panel bar direct queue +- aggregate eligibility cache +- `ParticleContainer.update()` batching + +0.9.1 대비 3-way isolated 개선: + +| 시나리오 | 0.9.1 FPS | perf FPS | 변화 | +| --- | ---: | ---: | ---: | +| draw redraw+fit | 4.61 | 5.15 | +11.7% | +| draw+update all bars | 3.31 | 4.35 | +31.4% | +| all bars x10 | 3.43 | 4.32 | +25.9% | +| wheel pan | 4.46 | 5.39 | +20.9% | +| mixed state burst | 3.05 | 3.65 | +19.7% | +| chart stream | 3.46 | 3.66 | +5.8% | +| highlight alpha burst | 5.15 | 5.34 | +3.7% | + +CPU 1x 안정 대표 리포트에서는 대부분 60fps 근처까지 올라갔다. + +리포트: `.gstack/benchmark-reports/2026-04-30T04-58-58-969Z-patchmap-frame-benchmark.json` + +| 시나리오 | FPS | p95 ms | long tasks | +| --- | ---: | ---: | ---: | +| draw+update all bars | 60.00 | 16.9 | 0 | +| all bars x10 | 60.00 | 17.2 | 0 | +| wheel pan | 60.03 | 17.2 | 0 | +| ctrl+wheel zoom | 60.00 | 17.1 | 0 | +| transformer select | 60.00 | 16.8 | 0 | +| chart stream | 56.89 | 32.6 | 0 | +| highlight alpha burst | 57.06 | 17.5 | 2 | +| report backgrounds burst | 59.39 | 17.2 | 0 | + +perf 브랜치의 장점: + +- main 구조와 가까워서 적용 리스크가 낮다. +- patch-service 계약 테스트와 함께 가져갈 수 있다. +- main의 명확한 병목인 selector/find/update/component/bar rendering 비용을 직접 줄인다. + +perf 브랜치의 한계: + +- CPU 4x에서는 chart stream, highlight alpha, pan/zoom이 여전히 불안정했다. +- aggregate bar 조건과 fallback을 정확히 관리해야 한다. +- render architecture 자체는 legacy scene graph 중심이라 장기적으로 복잡도가 누적된다. + +## V2 Result + +v2는 main 구조를 유지하지 않고 cleanroom renderer 쪽으로 간 실험이다. + +핵심 해결 방식: + +- 공식 기능 계약 문서화 +- Pixi native 무제한 호환보다 patch-map 공식 기능 우선 +- model/index/render IR/render policy/scheduler 분리 +- compatibility refs 유지 +- aggregate atlas particle layer +- latest-state queue + coalescing + per-frame budget +- render plan refresh와 fallback +- rotated component layout 등 렌더 parity 보정 + +0.9.1 대비 3-way isolated 개선: + +| 시나리오 | 0.9.1 FPS | v2 FPS | 변화 | +| --- | ---: | ---: | ---: | +| draw redraw+fit | 4.61 | 6.77 | +46.9% | +| draw+update all bars | 3.31 | 4.71 | +42.3% | +| all bars x10 | 3.43 | 4.00 | +16.6% | +| wheel pan | 4.46 | 5.05 | +13.2% | +| transformer select | 4.73 | 5.32 | +12.5% | +| mixed state burst | 3.05 | 3.75 | +23.0% | +| chart stream | 3.46 | 3.60 | +4.0% | +| highlight alpha burst | 5.15 | 5.91 | +14.8% | + +최신 v2 단일 리포트: + +리포트: `.gstack/benchmark-reports/2026-05-14T08-55-53-441Z-patchmap-frame-benchmark.json` + +| 시나리오 | FPS | p95 ms | long tasks | +| --- | ---: | ---: | ---: | +| draw+update all bars | 55.54 | 17.6 | 2 | +| all bars x10 | 58.10 | 17.5 | 0 | +| wheel pan | 60.00 | 17.0 | 0 | +| ctrl+wheel zoom | 59.99 | 17.1 | 0 | +| transformer select | 60.03 | 17.3 | 0 | +| shift drag multi select | 59.98 | 17.3 | 0 | +| chart stream | 46.35 | 33.4 | 0 | +| highlight alpha burst | 37.31 | 17.6 | 2 | +| relations visibility | 60.00 | 17.5 | 0 | +| report backgrounds burst | 53.40 | 33.4 | 1 | + +v2의 장점: + +- 구조적으로 가장 장기성이 좋다. +- main의 draw/update/render coupling을 끊는 방향이다. +- 공식 기능을 좁히면 Pixi scene graph 전체 호환 부담을 줄일 수 있다. +- 0.9.1 대비 대부분 시나리오에서 가장 큰 상대 개선을 보였다. + +v2의 한계: + +- 렌더 parity 이슈가 실제로 많이 발생했다. +- chart stream, highlight alpha, mixed/background burst 병목은 아직 남아 있다. +- main에 바로 적용하기에는 변경량이 크다. +- 기존 tests 중 Pixi native object assumption을 가진 부분은 재정의가 필요하다. + +## Rejected Strategy Summary + +| 전략 | 결과 | 결론 | +| --- | --- | --- | +| CPU custom mesh full replacement | bulk/x10/pan은 크게 개선, chart/alpha 악화 | default renderer로 부적합 | +| GPU-side animation mesh | x10 소폭 개선, 대부분 회귀 | 부적합 | +| packed-color CPU mesh | 대부분 회귀 | 부적합 | +| radius/no-slice 제거 | 일부 bulk 개선, chart/alpha/visual contract 악화 | 기본값 부적합 | +| flat `Texture.WHITE` aggregate bar | rounded/nine-slice contract 약화 | 부적합 | +| naive viewport culling | `particleChildren` 재구성 비용이 큼 | 부적합 | +| leaf-only alpha fast path | 적용 범위가 좁고 alpha contract 리스크 | 단독 적용 부적합 | +| OffscreenCanvas/WebWorker | DOM/viewport/selection/main-thread state coupling 때문에 근거 부족 | 현재 우선순위 낮음 | +| bar spritesheet quantization | animation fidelity/texture churn 리스크, 유지 커밋 없음 | 현재 우선순위 낮음 | + +## Which Approach Is Better? + +단기 main 적용에는 perf 브랜치 방식이 더 낫다. + +이유: + +- current main의 병목을 직접 겨냥한다. +- 변경 범위가 v2보다 작다. +- patch-service 계약 테스트와 함께 단계적으로 넣을 수 있다. +- 성능 개선 근거가 있고 렌더 parity 리스크가 v2보다 낮다. + +장기 최종 구조에는 v2 방향이 더 낫다. + +이유: + +- main의 근본 문제는 draw/update/render/interaction이 Pixi scene graph에 강하게 엮인 구조다. +- v2는 model/index/render policy/scheduler를 분리해 이 문제를 구조적으로 해결한다. +- Pixi native 무제한 호환을 공식 목표에서 낮추면 더 큰 최적화 여지가 생긴다. + +하지만 v2를 main에 바로 적용하기에는 아직 이르다. + +- render parity를 여러 차례 고쳤고, 아직 chart/alpha/mixed/background 병목이 남았다. +- 서비스 적용 전 `patch-service` demo/실사용 시각 검증이 더 필요하다. +- main에 들어가는 첫 개선은 안정적인 perf 브랜치 후보가 맞다. + +## Recommended Main Plan + +### Phase 1. Test and Benchmark Guardrails + +가장 먼저 적용: + +- patch-service contract tests +- benchmark harness/data/report ignore +- scenario list 고정 + +목적: + +- 이후 성능 개선이 selector path/update shape/render parity를 깨지 않는지 보호한다. + +### Phase 2. Index and Selection + +적용 후보: + +- `SceneIndex` +- selector exact id/type/display fast path +- selection candidate index +- bounds cache +- `warmFindBoundsCache` + +해결하는 main 병목: + +- selector full traversal +- transformer select/shift drag candidate traversal +- relation/path update lookup + +### Phase 3. Patch-service Panel Fast Path + +적용 후보: + +- `panelComponentRenderer` +- panel component cache +- direct element update fast path +- dirty panel bar direct queue +- aggregate eligibility cache + +해결하는 main 병목: + +- 전체 panel component update +- chart stream +- hidden icon/text + bar-only update +- repeated child lookup/default/schema work + +### Phase 4. Aggregate Bar Layer + +적용 후보: + +- `PanelBarLayer` particle aggregate +- nine-slice particle layout +- `flushParticleChildrenUpdate` +- animation state clone / numeric `_applyStateValues` + +조건: + +- rounded corner/padding/alpha/rotation/relation layer visual parity 확인 +- icon/text show, mask/filter/blendMode에서는 fallback + +해결하는 main 병목: + +- 일반 bar DisplayObject 수 +- bulk bar height animation +- repeated panel bar updates + +### Phase 5. Draw Cache and Redraw Skip + +적용 후보: + +- same-data draw cache +- validated data reuse +- managed world children check +- unchanged redraw skip + +해결하는 main 병목: + +- redraw+fit +- repeated full clone/validation/full draw + +### Phase 6. V2 Re-evaluation + +조건: + +- Phase 1-5 이후에도 Windows laptop/CPU throttle에서 목표 미달일 때 +- patch-service real demo 시각 parity가 고정됐을 때 +- chart stream/highlight alpha 병목에 대한 dirty bit/index 설계가 준비됐을 때 + +적용 방식: + +- v2 전체 merge가 아니라, main에서 이미 검증한 public contract를 기반으로 별도 major refactor로 진행한다. + +## Final Conclusion + +main의 핵심 병목은 "너무 많은 Pixi scene graph object를 일반 경로로 계속 찾고, 업데이트하고, 렌더링하는 것"이다. perf 브랜치는 이 문제를 기존 구조 안에서 index/cache/aggregate layer로 줄였고, v2는 아예 model/render/scheduler를 분리해 장기 구조를 제시했다. + +실제 main 적용 선택은 다음이 가장 합리적이다. + +1. perf 브랜치의 안전한 최적화와 테스트를 먼저 main에 단계적으로 적용한다. +2. custom mesh, GPU animation, radius 제거, naive culling은 가져가지 않는다. +3. v2는 장기 방향으로 유지하되, 지금 바로 main replacement로 넣지 않는다. +4. 이후에도 병목이 남으면 v2에서 검증된 공식 기능 축소, render policy, latest-state queue, aggregate atlas layer를 선별적으로 다시 평가한다. + +따라서 최종 추천은 "perf branch 기반의 점진적 main 적용 + v2 cleanroom 방향의 장기 재설계 유지"다. diff --git a/docs/experiments/patchmap-perf-branch-performance-experiments.md b/docs/experiments/patchmap-perf-branch-performance-experiments.md new file mode 100644 index 00000000..9de0bbd2 --- /dev/null +++ b/docs/experiments/patchmap-perf-branch-performance-experiments.md @@ -0,0 +1,610 @@ +# Patchmap Perf Branch Performance Experiments + +이 문서는 `perf/patchmap-render-optimization` 브랜치에서 진행한 patch-map 성능 실험과 결과를 정리한 것이다. 목적은 `rewrite/patchmap-v2-engine` 실험 로그와 함께 실제 `main` 브랜치에 가져갈 개선 후보를 고르기 위한 근거를 남기는 것이다. + +대상 브랜치는 `v0.9.1`에서 분기했다. 이후 v2 cleanroom 관련 문서/논리 인덱스 커밋이 잠시 이 브랜치에 올라갔지만, `198c5ad`로 reset되어 perf 브랜치 최종 상태에서는 제외됐다. v2로 옮겨진 항목은 `docs/experiments/patchmap-v2-performance-experiments.md`에서 다룬다. + +## Documentation Review + +이 문서는 새 세션에서 perf 브랜치 실험 흐름을 다시 파악할 수 있도록 다음 출처를 함께 묶었다. + +- `perf/patchmap-render-optimization` 최종 커밋 히스토리 +- reflog에 남은 reset/분기 흐름 +- reverted experiment commit diff +- reverted docs experiment commit 내용 +- `.gstack/benchmark-reports`의 frame benchmark JSON/MD 리포트 +- 현재 `main`과 perf/v2 branch diff 구조 + +검토 결과, perf 브랜치 최종 상태에 남은 채택 변경과 revert된 실험은 모두 이 문서 안에서 추적 가능하다. 커밋 없이 benchmark 리포트만 남은 PoC는 "추가 benchmark-only PoC 흔적"으로 따로 분리했다. 해당 항목은 정확한 코드 diff를 복구할 수 없으므로 main 적용 후보로 보지 않고, 반려 방향의 근거로만 사용한다. + +## 범위와 기준 + +- 브랜치: `perf/patchmap-render-optimization` +- 분기 기준: `ae9333c` / `v0.9.1` +- 최종 브랜치 HEAD: `198c5ad chore: revert packed color cpu mesh experiment record` +- 주요 데이터: `.gstack/benchmark-harness/patch-service-plant-map-data.json` +- 데이터 크기: + - top-level nodes: 458 + - raw JSON objects: 12,184 + - raw JSON arrays: 579 + - panel items: 9,336 + - inverter items: 26 + - total runtime items: 9,365 + - JSON size: 약 795KB +- 주요 benchmark: + - `.gstack/benchmark-harness/run-patchmap-frame-benchmark.mjs` + - `PATCHMAP_BENCH_CPU_THROTTLE=4 node .gstack/benchmark-harness/run-patchmap-frame-benchmark.mjs` + +## 고정한 시나리오 + +| 시나리오 | 목적 | +| --- | --- | +| `idle baseline after draw` | 최초 draw 이후 안정 프레임 | +| `draw: redraw same data with fit` | 같은 데이터 redraw와 fit | +| `draw+update: all panel animated bar heights` | 전체 panel bar 높이 animated update | +| `update: all panel animated bar heights every 1s x10` | 전체 bar 높이 변경 10회 반복 | +| `interaction: wheel canvas pan for 120 frames` | 휠 pan 체감 성능 | +| `interaction: ctrl wheel zoom in/out for 120 frames` | ctrl+wheel zoom 체감 성능 | +| `interaction: transformer single object select` | transformer 단일 선택 | +| `interaction: shift drag multi select` | shift+drag 멀티 선택 | +| `update: panel mixed state burst` | panel state 혼합 burst | +| `update: panel chart stream for 120 frames` | patch-service chart stream | +| `update: highlight bulk alpha burst` | 대량 alpha/highlight update | +| `update: relations visibility by path` | relation path update | +| `update: report panel backgrounds burst` | report background burst | + +## 최종 커밋 흐름 + +| 커밋 | 성격 | 요약 | +| --- | --- | --- | +| `5ccab05` | 채택 | panel rendering/selection 최적화의 큰 묶음 | +| `38c4cf0` | 채택 테스트 | patch-service 계약 테스트 추가 | +| `8307b22` | 채택 환경 | `.gstack` benchmark report ignore | +| `320043a` | 채택 | dirty panel bar를 전체 scan 대신 직접 queue로 flush | +| `6b3c48e` | 채택 | Pixi world render group 활성화 | +| `c20507b` | 채택 | panel bar lookup 중복 제거 | +| `ec5659a` | 채택 | aggregate bar eligibility 재사용 | +| `3ddd06a` | 채택 | aggregate panel bar update 추가 최적화 | +| `1bb7baa` -> `ae1c2b3` | 반려 | flat aggregate bar particle 실험 후 revert | +| `bf2f6f1` -> `27b4c36` | 반려 | aggregate viewport culling 실험 후 revert | +| `a76cfcf` -> `ee1e448` | 반려 | aggregate alpha fast path 실험 후 revert | +| `32f8dd0` -> `f2a5cf1` | 반려 기록 | custom mesh no-slice 실험 문서 기록 후 revert | +| `7a22e9e` -> `c87c4f0` | 반려 기록 | CPU mesh aggregate bars 실험 문서 기록 후 revert | +| `e1d9f0e` -> `7bf0c86` | 반려 기록 | GPU animation mesh bars 실험 문서 기록 후 revert | +| `908f871` -> `198c5ad` | 반려 기록 | packed-color CPU mesh bars 실험 문서 기록 후 revert | + +## 채택된 실험 + +### 1. Panel rendering/selection 최적화 + +커밋: `5ccab05 perf(patchmap): optimize panel rendering and selection` + +변경: + +- `SceneIndex` 추가 + - `elementById` + - `byType` + - `byDisplay` + - `selectable` + - `version` +- selector 일부를 `jsonpath-plus` 전체 순회 대신 index로 처리 + - exact id path + - `children[?(@.type == "...")]` + - `children[?(@.display == "...")]` +- selection hit-test 후보 수를 줄임 + - selectable index 사용 + - point/polygon candidate bounds cache + - `warmFindBoundsCache`로 draw 이후 bounds cache를 frame budget 안에서 예열 +- `patchmap.draw` 최적화 + - 같은 data redraw 시 validated data/cache 재사용 + - managed world children만 있으면 scene reuse 가능 + - draw event를 `scheduler.postTask` 또는 `setTimeout`으로 user-visible task 처리 +- direct element update fast path + - `validateSchema: false` + - path/history/rotateOrigin 없는 단일 element update + - panel component change는 별도 renderer로 빠르게 적용 +- `PanelBarLayer` 추가 + - Pixi `ParticleContainer` 기반 aggregate bar layer + - bar를 일반 child DisplayObject로 모두 그리지 않고 aggregate particle로 렌더 + - nine-slice texture를 piece로 쪼개 rounded bar를 보존 + - bar animation을 layer 내부 requestAnimationFrame으로 처리 + - alpha/tint/placement/rotation 반영 +- `panelComponentRenderer` 추가 + - patch-service panel update shape를 빠르게 적용 + - `[{ type: 'bar' }, { type: 'icon', show: false }, { type: 'text', show: false }]` 형태를 aggregate bar path로 처리 + - item별 background/bar/icon/text component cache + - deferred visual queue와 per-frame budget 적용 + +결과: + +- CPU 1x 기준 대다수 interaction/draw/update는 60fps 근처로 도달했다. +- 첫 대표 리포트 `.gstack/benchmark-reports/2026-04-29T14-50-47-471Z-patchmap-frame-benchmark.json`: + +| 시나리오 | FPS | p95 ms | long tasks | +| --- | ---: | ---: | ---: | +| idle baseline | 60.00 | 16.9 | 0 | +| draw redraw+fit | 60.00 | 17.2 | 0 | +| draw+update all bars | 60.00 | 16.9 | 0 | +| all bars x10 | 60.00 | 17.4 | 0 | +| wheel pan | 60.00 | 17.0 | 0 | +| ctrl+wheel zoom | 60.00 | 17.1 | 0 | +| transformer select | 60.00 | 17.3 | 0 | +| shift drag multi select | 59.39 | 17.1 | 0 | +| mixed state burst | 48.91 | 17.7 | 2 | +| chart stream | 39.69 | 50.0 | 9 | +| highlight alpha burst | 53.89 | 17.7 | 2 | +| relations visibility | 60.00 | 17.6 | 0 | +| report backgrounds burst | 54.92 | 33.3 | 2 | + +판단: + +- panel bulk update, pan/zoom, transformer/select 계열에는 즉시 효과가 있었다. +- chart stream, mixed burst, alpha/background burst에는 여전히 long task와 spike가 남았다. + +### 2. Patch-service 계약 테스트 + +커밋: `38c4cf0 test(patchmap): cover patch service contracts` + +변경: + +- `src/tests/render/patch-service-contract.test.js` 추가 +- patch-service가 실제로 쓰는 panel update shape를 테스트로 고정 +- aggregate bar stream, hidden icon/text, selector/update path 호환을 보호 + +판단: + +- 성능 개선 자체는 아니지만 main 적용 후보를 고를 때 필수 안전장치다. +- perf 브랜치에서 가장 가져갈 가치가 높은 비기능 변경이다. + +### 3. Benchmark report ignore + +커밋: `8307b22 chore(gitignore): ignore local benchmark artifacts` + +변경: + +- `.gstack` benchmark report가 계속 쌓이므로 gitignore 추가 + +판단: + +- report는 로컬 비교 근거로 남기되 main에는 올리지 않는 운영 정책으로 적절하다. + +### 4. Dirty panel bars direct flush + +커밋: `320043a perf(patchmap): flush dirty panel bars directly` + +변경: + +- 기존에는 dirty panel bar가 생기면 `sceneIndex.byType('item')` 전체를 다시 훑는 방식이었다. +- 변경 후에는 dirty bar component를 `queue.dirtyPanelBars`에 직접 넣고 해당 bar만 flush한다. +- frame budget은 유지한다. + +결과: + +- 전체 item scan을 피하므로 전체 panel update에서 불필요한 순회가 줄었다. +- 같은 시간대 CPU 1x 리포트는 계속 60fps 근처를 유지했다. + +판단: + +- main에 가져가기 좋은 좁은 변경이다. +- v2에서도 유사한 "dirty owner 직접 관리" 방향으로 이어졌다. + +### 5. World render group + +커밋: `6b3c48e perf(patchmap): enable world render group` + +변경: + +- `this._world.enableRenderGroup?.()` 호출 추가 + +결과: + +- 단독 효과는 큰 폭으로 분리 측정하기 어렵다. +- CPU 1x 리포트는 대체로 60fps를 유지했다. + +판단: + +- 변경 폭은 매우 작다. +- 다만 Pixi render group은 scene 구조/레이어와 상호작용하므로 main 적용 시 visual layer ordering, viewport transform, relation layer를 같이 확인해야 한다. + +### 6. Redundant panel bar lookup 제거 + +커밋: `c20507b perf(patchmap): skip redundant panel bar lookups` + +변경: + +- aggregate bar state update path에서 이미 cache된 `item._panelIconComponent`, `item._panelTextComponent`를 사용 +- `getPanelComponentByType` 반복 호출을 줄임 + +결과: + +- 미세 최적화지만 risk가 낮다. +- CPU 1x에서는 chart/highlight가 다소 흔들렸지만 반복 리포트에서 안정화됐다. + +판단: + +- main 적용 후보. + +### 7. Aggregate bar eligibility 재사용 + +커밋: `ec5659a perf(patchmap): reuse aggregate bar eligibility` + +변경: + +- `markPanelBarVisualDirty`에서 매번 `ensurePanelBarLayer(...).canRender(...)`를 계산하지 않음 +- source가 바뀌었거나 aggregate 사용 상태가 없거나 layer가 destroyed일 때만 eligibility 재계산 + +결과: + +- source가 그대로인 height/tint/alpha stream에서는 불필요한 texture/layer 검사 감소 +- 대표 안정 리포트 `.gstack/benchmark-reports/2026-04-30T01-53-36-160Z-patchmap-frame-benchmark.json`: + +| 시나리오 | FPS | p95 ms | long tasks | +| --- | ---: | ---: | ---: | +| draw+update all bars | 60.00 | 17.1 | 0 | +| all bars x10 | 59.99 | 17.9 | 0 | +| wheel pan | 60.00 | 17.8 | 0 | +| ctrl+wheel zoom | 60.05 | 17.3 | 0 | +| chart stream | 56.49 | 33.3 | 0 | +| highlight alpha burst | 55.41 | 18.4 | 2 | +| report backgrounds burst | 59.39 | 18.0 | 0 | + +판단: + +- source 변경이 드문 patch-service bar stream에 적합하다. +- main 적용 후보. + +### 8. Aggregate panel bar update 최적화 + +커밋: `3ddd06a perf: optimize aggregate panel bar updates` + +변경: + +- `PanelBarLayer`의 `particleChildren` 변경 시 즉시 `update()`하지 않고 `flushParticleChildrenUpdate()`로 모아서 처리 +- animation 시작 state를 clone해 in-place state mutation과 충돌하지 않도록 수정 +- `_applyStateValues`로 상태 적용 경로를 숫자 인자로 단순화 +- borderless/nine-slice fast path 추가 +- dirty bar flush 중 새 dirty bar가 들어오는 경우 `nextDirtyPanelBars`로 다음 frame에 넘김 +- dirty particle layer set을 모아 한 번에 flush +- patch-service aggregate stream 테스트 추가 + +결과: + +- 나중의 mesh 실험 기준 baseline으로 사용됐다. +- `.gstack/benchmark-reports/2026-05-13T08-29-31-964Z-patchmap-frame-benchmark.json` 기준 CPU 4x: + +| 시나리오 | FPS | p95 ms | long tasks | +| --- | ---: | ---: | ---: | +| draw+update all bars | 52.43 | 33.4 | 0 | +| all bars x10 | 38.42 | 34.2 | 0 | +| wheel pan | 26.69 | 50.8 | 9 | +| ctrl+wheel zoom | 36.00 | 34.2 | 0 | +| transformer select | 52.91 | 33.4 | 0 | +| shift drag multi select | 46.54 | 33.9 | 1 | +| chart stream | 12.46 | 117.0 | 117 | +| highlight alpha burst | 25.75 | 50.0 | 2 | +| report backgrounds burst | 28.96 | 50.1 | 4 | + +판단: + +- CPU 4x에서는 여전히 실패가 많지만, particle/nine-slice 기반 aggregate를 유지하면서 bulk update를 다룰 수 있는 기준 구현이 됐다. +- 이 구현을 기준으로 mesh 계열 PoC가 비교됐다. + +## 0.9.1 / perf branch / v2 3-way 비교 + +리포트: `.gstack/benchmark-reports/2026-05-14T06-39-39-163Z-patchmap-3way-frame-benchmark-isolated.json` + +이 리포트는 isolated mode라 절대 FPS가 낮다. 같은 리포트 안 상대 비교만 본다. + +| 시나리오 | perf FPS | 0.9.1 FPS | perf vs 0.9.1 | +| --- | ---: | ---: | ---: | +| idle baseline | 5.36 | 4.75 | +12.8% | +| draw redraw+fit | 5.15 | 4.61 | +11.7% | +| draw+update all bars | 4.35 | 3.31 | +31.4% | +| all bars x10 | 4.32 | 3.43 | +25.9% | +| wheel pan | 5.39 | 4.46 | +20.9% | +| ctrl+wheel zoom | 5.81 | 5.89 | -1.4% | +| transformer select | 4.21 | 4.73 | -11.0% | +| shift drag multi select | 5.11 | 4.92 | +3.9% | +| mixed state burst | 3.65 | 3.05 | +19.7% | +| chart stream | 3.66 | 3.46 | +5.8% | +| highlight alpha burst | 5.34 | 5.15 | +3.7% | +| relations visibility | 5.61 | 6.18 | -9.2% | +| report backgrounds burst | 5.41 | 5.60 | -3.4% | + +해석: + +- perf branch는 0.9.1 대비 bulk draw/update, x10, wheel pan에서 개선됐다. +- ctrl zoom, transformer, relation/background 일부는 0.9.1보다 나쁘거나 비슷하다. +- v2와 비교하면 perf branch는 isolated mode에서 `all bars x10`, `wheel pan`, `chart stream`만 근소하게 앞섰고, 대부분은 v2가 앞섰다. + +## 대표 리포트 요약 + +| 리포트 | 조건 | 의미 | +| --- | --- | --- | +| `2026-04-29T14-50-47-471Z` | CPU 1x, headless | 초기 큰 perf patch 직후 대표값 | +| `2026-04-30T04-58-58-969Z` | CPU 1x, headless | Apr 30까지 채택된 perf 변경의 안정 대표값 | +| `2026-04-30T04-55-28-711Z` | CPU 4x, headed, FPS overlay on | 시각 확인용에 가까움. overlay/headed라 순수 비교값으로는 약함 | +| `2026-05-13T08-29-31-964Z` | CPU 4x, headless | mesh PoC들의 baseline으로 사용 | +| `2026-05-14T03-22-21-649Z` | CPU 4x, headless, perf branch 재측정 | 최종 perf branch 재측정. 다른 4x 리포트보다 낮게 나와 환경/측정 노이즈 가능성이 큼 | + +CPU 1x 안정 대표값: + +리포트: `.gstack/benchmark-reports/2026-04-30T04-58-58-969Z-patchmap-frame-benchmark.json` + +| 시나리오 | FPS | p95 ms | long tasks | +| --- | ---: | ---: | ---: | +| idle baseline | 59.99 | 17.5 | 0 | +| draw redraw+fit | 60.00 | 17.1 | 0 | +| draw+update all bars | 60.00 | 16.9 | 0 | +| all bars x10 | 60.00 | 17.2 | 0 | +| wheel pan | 60.03 | 17.2 | 0 | +| ctrl+wheel zoom | 60.00 | 17.1 | 0 | +| transformer select | 60.00 | 16.8 | 0 | +| shift drag multi select | 60.00 | 17.5 | 0 | +| mixed state burst | 53.39 | 17.6 | 2 | +| chart stream | 56.89 | 32.6 | 0 | +| highlight alpha burst | 57.06 | 17.5 | 2 | +| relations visibility | 59.98 | 17.2 | 0 | +| report backgrounds burst | 59.39 | 17.2 | 0 | + +해석: + +- CPU 1x에서는 대부분 체감상 매우 부드럽다. +- strict pass 기준으로는 mixed/highlight의 long task가 남는다. + +## 반려한 실험 + +### 1. Flat aggregate bar particle + +커밋: `1bb7baa perf: record flat aggregate bar particle experiment` -> `ae1c2b3 chore: revert flat aggregate bar particle experiment` + +변경: + +- aggregate bar가 실제 `getBarTexture` 결과를 사용하지 않고 `Texture.WHITE` 하나로 렌더 +- tint는 `bar.props.tint` 또는 `bar.props.source.fill`에서 계산 +- 사실상 rounded/nine-slice를 버리고 flat rect particle로 단순화 + +결과: + +- 커밋에 안정 benchmark 문서가 붙지는 않았다. +- 이후 `custom mesh no-slice` 실험에서 radius 제거/flat path가 일부 bulk에는 도움되지만 chart/alpha가 크게 나빠지는 패턴을 확인했다. + +판단: + +- rounded bar 시각 계약과 patch-service 렌더 parity를 깨기 쉬워 반려. + +### 2. Aggregate bar viewport culling + +커밋: `bf2f6f1 perf: record aggregate bar viewport culling experiment` -> `27b4c36 chore: revert aggregate bar viewport culling experiment` + +변경: + +- `CULLING_MARGIN = 512` +- aggregate entry set을 따로 관리 +- viewport `moved`, `zoomed` 이벤트에 반응해 visible entry만 `particleChildren`에 남김 +- viewport bounds와 bar state intersect로 particleChildren 재구성 + +결과: + +- particleChildren 재구성 비용이 pan/zoom 중 절감 효과보다 컸다. +- culling이 이득을 보려면 단순 children rebuild가 아니라 spatial index와 안정적인 visible set diff가 필요하다. + +판단: + +- naive viewport culling은 반려. + +### 3. Aggregate bar alpha fast path + +커밋: `a76cfcf perf: record aggregate bar alpha fast path experiment` -> `ee1e448 chore: revert aggregate bar alpha fast path experiment` + +변경: + +- `syncAlphaForSubtree(root)`에서 root가 cached bar를 가진 leaf panel이면 subtree stack 순회를 생략 +- root bar entry alpha만 바로 갱신 + +결과: + +- 커밋에 안정 benchmark 문서가 붙지는 않았다. +- leaf panel에는 도움이 될 수 있지만, nested item/child 관계와 alpha propagation 계약을 일반화하기 어렵다. + +판단: + +- 제한적인 fast path라 반려. +- main에 가져가려면 alpha-only dirty index를 별도 설계해야 한다. + +### 4. CPU mesh aggregate bars + +커밋 기록: `7a22e9e docs: record cpu mesh aggregate bar experiment` -> `c87c4f0 chore: revert cpu mesh aggregate bar experiment record` + +변경: + +- aggregate panel bar particles를 CPU-updated custom Pixi Mesh로 대체 +- rounded/nine-slice 렌더 유지 +- bar state change와 animation tick에서 JS로 vertex buffer 갱신 + +리포트: + +- baseline: `.gstack/benchmark-reports/2026-05-13T08-29-31-964Z-patchmap-frame-benchmark.json` +- experiment: `.gstack/benchmark-reports/2026-05-13T09-19-39-148Z-patchmap-frame-benchmark.json` + +| 시나리오 | baseline FPS | experiment FPS | 변화 | +| --- | ---: | ---: | ---: | +| draw+update animated bars | 52.43 | 58.14 | +10.9% | +| all bars x10 | 38.42 | 59.01 | +53.6% | +| wheel pan | 26.69 | 49.30 | +84.7% | +| ctrl wheel zoom | 36.00 | 46.63 | +29.5% | +| transformer select | 52.91 | 59.97 | +13.3% | +| shift drag multi select | 46.54 | 58.22 | +25.1% | +| mixed state burst | 26.57 | 28.53 | +7.4% | +| chart stream | 12.46 | 10.39 | -16.6% | +| highlight alpha burst | 25.75 | 17.32 | -32.7% | +| report backgrounds burst | 28.96 | 30.62 | +5.7% | + +판단: + +- bulk animation, pan/zoom에는 가장 강한 개선 가능성을 보였다. +- 그러나 patch-service chart stream과 alpha burst가 크게 나빠져 default renderer로는 반려. +- main 후보로는 "전면 대체"가 아니라 특정 bulk-only opt-in 또는 더 세밀한 dirty path가 필요하다. + +### 5. GPU animation mesh bars + +커밋 기록: `e1d9f0e docs: record gpu animation mesh bar experiment` -> `7bf0c86 chore: revert gpu animation mesh bar experiment record` + +변경: + +- custom Pixi Mesh 사용 +- bar animation interpolation을 shader/uniform time과 per-bar from/to/timing attribute로 이동 + +리포트: + +- experiment: `.gstack/benchmark-reports/2026-05-13T09-36-50-452Z-patchmap-frame-benchmark.json` + +| 시나리오 | baseline FPS | experiment FPS | 변화 | +| --- | ---: | ---: | ---: | +| draw+update animated bars | 52.43 | 42.67 | -18.6% | +| all bars x10 | 38.42 | 41.15 | +7.1% | +| wheel pan | 26.69 | 17.28 | -35.3% | +| ctrl wheel zoom | 36.00 | 29.77 | -17.3% | +| chart stream | 12.46 | 9.80 | -21.3% | +| highlight alpha burst | 25.75 | 6.84 | -73.4% | + +판단: + +- CPU animation work 감소보다 shader/attribute overhead가 더 컸다. +- 반려. + +### 6. Packed-color CPU mesh bars + +커밋 기록: `908f871 docs: record packed color cpu mesh experiment` -> `198c5ad chore: revert packed color cpu mesh experiment record` + +변경: + +- CPU mesh 사용 +- rounded bar를 6-piece borderless layout으로 단순화 +- color를 `unorm8x4` vertex attribute로 packing + +리포트: + +- experiment: `.gstack/benchmark-reports/2026-05-13T10-05-18-563Z-patchmap-frame-benchmark.json` + +| 시나리오 | baseline FPS | experiment FPS | 변화 | +| --- | ---: | ---: | ---: | +| draw+update animated bars | 52.43 | 38.43 | -26.7% | +| all bars x10 | 38.42 | 41.70 | +8.5% | +| wheel pan | 26.69 | 19.30 | -27.7% | +| ctrl wheel zoom | 36.00 | 17.80 | -50.6% | +| transformer select | 52.91 | 49.33 | -6.8% | +| shift drag multi select | 46.54 | 50.58 | +8.7% | +| mixed state burst | 26.57 | 18.42 | -30.7% | +| chart stream | 12.46 | 10.08 | -19.1% | +| highlight alpha burst | 25.75 | 19.14 | -25.7% | +| report backgrounds burst | 28.96 | 26.22 | -9.5% | + +판단: + +- color packing과 piece 수 감소가 mesh update overhead를 상쇄하지 못했다. +- 반려. + +### 7. Custom mesh no-slice / radius 제거 + +커밋 기록: `32f8dd0 docs: record custom mesh no slice experiment` -> `f2a5cf1 chore: revert custom mesh no slice experiment record` + +변경: + +- aggregate bar를 custom CPU-updated Pixi Mesh로 렌더 +- nine-slice/radius 제거 +- flat no-radius quad로 단순화 + +리포트: + +- experiment: `.gstack/benchmark-reports/2026-05-13T10-14-52-837Z-patchmap-frame-benchmark.json` + +| 시나리오 | baseline FPS | experiment FPS | 변화 | +| --- | ---: | ---: | ---: | +| draw+update animated bars | 52.43 | 49.22 | -6.1% | +| all bars x10 | 38.42 | 44.43 | +15.6% | +| wheel pan | 26.69 | 31.99 | +19.9% | +| ctrl wheel zoom | 36.00 | 38.88 | +8.0% | +| chart stream | 12.46 | 8.91 | -28.5% | +| highlight alpha burst | 25.75 | 13.57 | -47.3% | + +판단: + +- radius 제거는 일부 bulk/interaction에는 도움된다. +- 하지만 chart/alpha를 크게 악화시키고 visual contract도 약화한다. +- main 적용 기본값으로는 반려. + +## 추가 benchmark-only PoC 흔적 + +다음 리포트들은 커밋으로 남은 구현명이 없거나 실험 문서가 revert되어 정확한 코드 diff는 남지 않았다. 다만 모두 CPU 4x에서 baseline보다 chart/alpha 또는 pan/update가 나빠져 최종 브랜치에 남지 않았다. + +| 리포트 | draw+update FPS | x10 FPS | wheel FPS | chart FPS | highlight FPS | 판단 | +| --- | ---: | ---: | ---: | ---: | ---: | --- | +| `2026-05-13T10-17-29-409Z` | 50.99 | 46.95 | 37.21 | 9.47 | 20.71 | 일부 bulk 개선, chart/alpha 회귀 | +| `2026-05-13T10-21-07-863Z` | 47.35 | 38.48 | 30.35 | 8.37 | 11.28 | 전반 회귀 | +| `2026-05-13T10-23-08-712Z` | 42.35 | 26.59 | 21.68 | 7.97 | 10.17 | 전반 회귀 | +| `2026-05-14T01-27-02-132Z` | 49.88 | 36.00 | 26.87 | 10.43 | 23.85 | baseline 근처, 채택 근거 부족 | +| `2026-05-14T01-28-53-516Z` | 39.22 | 32.86 | 30.47 | 8.26 | 18.65 | 전반 회귀 | +| `2026-05-14T01-31-33-749Z` | 49.01 | 37.34 | 32.65 | 10.73 | 24.24 | baseline 근처, 채택 근거 부족 | +| `2026-05-14T01-34-34-044Z` | 43.15 | 28.17 | 26.24 | 9.54 | 15.16 | 전반 회귀 | +| `2026-05-14T01-37-56-966Z` | 44.70 | 30.21 | 29.32 | 8.11 | 4.76 | alpha 심각 회귀 | +| `2026-05-14T01-43-02-763Z` | 48.17 | 33.39 | 29.66 | 10.01 | 20.00 | 채택 근거 부족 | + +## v2로 옮겨진 임시 커밋 + +reflog 기준 다음 커밋들은 잠시 perf 브랜치 위에 만들어졌다가 `rewrite/patchmap-v2-engine` 브랜치로 분기된 뒤, perf 브랜치는 `198c5ad`로 reset됐다. + +| 커밋 | 현재 위치 | 내용 | +| --- | --- | --- | +| `833ab7a` | v2 branch | cleanroom feature contracts | +| `8cf4930` | v2 branch | patch-service aggregate bar stream contracts | +| `63be7a6` | v2 branch | logical scene index | +| `a595312` | v2 branch | selector route through logical index | + +이 항목들은 perf branch 최종 결과가 아니므로 main 적용 판단에서는 v2 실험 로그와 함께 봐야 한다. + +## main 적용 후보 판단 + +가져가기 좋은 후보: + +- patch-service 계약 테스트 +- `.gstack` benchmark report ignore +- SceneIndex 기반 selector/find 후보 축소 +- selection bounds cache와 `warmFindBoundsCache` +- direct element update fast path 중 `validateSchema: false` 단일 element + component update에 한정된 부분 +- panel component cache +- dirty panel bars direct queue +- aggregate bar eligibility cache +- `PanelBarLayer`의 particleChildren flush batching + +주의해서 가져갈 후보: + +- world render group + - 레이어 순서, relation, viewport transform 확인 필요 +- aggregate bar layer + - patch-service bar-only panel에는 효과가 큼 + - icon/text가 보이거나 mask/filter/blendMode가 있으면 fallback 필요 +- per-frame visual queue + - UI freeze를 줄이지만 "적용이 늦게 보이는" 체감이 생길 수 있음 + +가져가지 말아야 할 후보: + +- flat `Texture.WHITE` aggregate bar +- naive viewport culling +- leaf-only alpha fast path +- custom mesh full replacement +- GPU-side animation mesh +- packed-color CPU mesh +- no-slice/radius 제거 기본 적용 + +## 결론 + +perf 브랜치에서 가장 의미 있었던 개선은 custom mesh가 아니라 기존 Pixi displayObject 구조 안에서 다음을 줄인 것이다. + +- selector/find의 전체 scene traversal +- panel component lookup +- 전체 item scan +- 일반 bar DisplayObject 렌더/animation work +- `ParticleContainer.update()` 즉시 호출 횟수 + +반대로 custom mesh 계열은 bulk bar update에서는 눈에 띄는 잠재력이 있었지만, patch-service에서 중요한 chart stream과 highlight alpha burst를 반복적으로 악화시켰다. main에 적용할 때는 perf 브랜치의 작은 안전한 최적화와 테스트를 우선하고, renderer 전면 교체는 v2 문서의 cleanroom 설계/렌더 parity 검증과 함께 별도 판단해야 한다. diff --git a/docs/experiments/patchmap-v2-performance-experiments.md b/docs/experiments/patchmap-v2-performance-experiments.md new file mode 100644 index 00000000..53754b2e --- /dev/null +++ b/docs/experiments/patchmap-v2-performance-experiments.md @@ -0,0 +1,364 @@ +# Patchmap V2 Performance Experiments + +이 문서는 `rewrite/patchmap-v2-engine` 브랜치에서 진행한 patch-map 성능/렌더링 실험을 세션 대화, 커밋 히스토리, 로컬 `.gstack/benchmark-reports` 리포트, 현재 문서 기준으로 정리한 것이다. + +숫자는 대부분 로컬 단일 실행 결과다. 절대 FPS는 실행 모드, 브라우저 프로세스 상태, CPU throttle, headed/headless 여부에 따라 흔들리므로 같은 리포트 안의 상대 비교를 우선한다. 렌더링이 깨지거나 patch-service 사용 계약을 깨는 변경은 성능 수치가 좋아도 채택하지 않았다. + +## Documentation Review + +이 문서는 새 세션에서 v2 실험 흐름을 다시 파악할 수 있도록 다음 출처를 함께 묶었다. + +- `rewrite/patchmap-v2-engine` 커밋 히스토리 +- perf 브랜치에서 v2로 분기된 reflog 흐름 +- `.gstack/benchmark-reports`의 frame benchmark JSON/MD 리포트 +- `docs/features/*` 공식 기능 계약 문서 +- `docs/architecture/cleanroom-renderer.md` +- 데모/시각 확인 과정에서 발견한 렌더 parity 이슈 + +검토 결과, v2 쪽에서 실제로 채택된 구조 변경, 되돌린 PoC, benchmark 숫자, 남은 병목은 모두 이 문서 안에서 추적 가능하다. 다만 일부 대화 중 PoC는 커밋 없이 즉시 revert되어 정확한 diff가 남지 않았다. 그런 항목은 "benchmark-backed production patch 없음", "유지된 커밋 없음", "즉시 revert"로 표시했다. 이 표기는 누락이 아니라 확인 가능한 영속 기록이 없다는 뜻이다. + +## 목표 + +- `draw`, `update`, selector, fit/focus, transformer, stateManager, patch-service selector path/update shape를 유지한다. +- 기존 Pixi displayObject를 임의로 만지는 비공식 사용보다 라이브러리의 공식 기능 계약을 우선한다. +- 대용량 patch-service 데이터에서 화면이 60fps에 가깝게 유지되는지 headless/headed 브라우저로 측정한다. +- 전체 패널 bar 높이 변경, 휠 pan, ctrl+wheel zoom, transformer 선택, shift+drag 멀티 선택 같은 실제 사용자 인터랙션을 벤치 시나리오로 고정한다. +- 성능 개선은 렌더 일치, 애니메이션, padding, rounded background/bar, alpha, 관계선 레이어 순서, 회전 객체 배치를 깨지 않아야 한다. + +## 기준 데이터와 벤치 환경 + +- 데이터: `.gstack/benchmark-harness/patch-service-plant-map-data.json` +- 최신 리포트 기준 데이터 크기: + - top-level items: 458 + - raw JSON objects: 12,184 + - raw JSON arrays: 579 + - panel items: 9,336 + - all items: 9,365 + - JSON size: 795,005 bytes +- 주요 실행 명령: + - `node .gstack/benchmark-harness/run-patchmap-frame-benchmark.mjs` + - `PATCHMAP_BENCH_CPU_THROTTLE=4 node .gstack/benchmark-harness/run-patchmap-frame-benchmark.mjs` + - headed 확인은 benchmark harness/demo HTML을 dev server로 열어 확인했다. +- 측정 지표: + - average FPS + - p95 frame ms + - max frame ms + - long task count + - action duration / update sync time +- headed 시각 확인용 FPS overlay는 추가했지만, 계측 오염을 피하기 위해 benchmark 측정에서는 기본적으로 꺼둔다. + +## 고정한 시나리오 + +| 시나리오 | 목적 | +| --- | --- | +| `idle baseline after draw` | 최초 draw 이후 정지 상태 프레임 안정성 | +| `draw: redraw same data with fit` | 같은 대용량 데이터를 다시 draw하고 fit 수행 | +| `draw+update: all panel animated bar heights` | draw 후 전체 panel bar 높이 animated update | +| `update: all panel animated bar heights every 1s x10` | 전체 panel bar 높이 변경을 반복 입력했을 때 누적 부하 | +| `interaction: wheel canvas pan for 120 frames` | 휠 기반 캔버스 이동 | +| `interaction: ctrl wheel zoom in/out for 120 frames` | ctrl+wheel 기반 zoom in/out | +| `interaction: transformer single object select` | transformer가 켜진 객체 단일 선택 | +| `interaction: shift drag multi select` | shift+drag 멀티 선택 | +| `update: panel mixed state burst` | panel show/alpha/size가 섞인 burst update | +| `update: panel chart stream for 120 frames` | patch-service 쪽 chart 값이 계속 들어오는 stream update | +| `update: highlight bulk alpha burst` | highlight/alpha가 대량으로 바뀌는 update | +| `update: relations visibility by path` | 관계선 visibility/path update | +| `update: report panel backgrounds burst` | report panel background 변경 burst | + +별도 데모도 만들었다. + +- `.gstack/benchmark-harness/patchmap-panel-height-demo.html` +- 버튼: + - 전체 패널 bar height 1회 변경 + - 전체 패널 bar height 10회 변경 +- 10회 입력 간격: 200ms +- bar animation 기본값: 200ms +- 데모에서 padding 적용 여부, background 내부 bar 위치, rounded corner, 애니메이션 유무를 눈으로 확인했다. + +## 공식 기능 고정 + +클린룸 재설계 전에 공식 기능을 문서로 고정했다. + +| 문서 | 내용 | +| --- | --- | +| `docs/features/public-api.md` | 공개 API 범위 | +| `docs/features/data-schema.md` | data/model schema | +| `docs/features/draw-update-selector.md` | draw/update/selector 계약 | +| `docs/features/rendering-semantics.md` | background/bar/relation/text/icon 렌더 의미 | +| `docs/features/interaction-state-transformer.md` | interaction, selection, transformer, state 계약 | +| `docs/features/patch-service-compatibility.md` | patch-service 사용 형태 호환 | +| `docs/features/performance-contracts.md` | 벤치 시나리오와 성능 목표 | +| `docs/features/non-goals.md` | Pixi native 무제한 노출 등 비목표 | +| `docs/architecture/cleanroom-renderer.md` | cleanroom renderer 구조 | + +핵심 설계 방향은 `public API -> normalized model -> indexes -> render IR diff -> feature renderers -> scheduled GPU/scene updates`다. Pixi scene graph를 사용자가 직접 만질 수 있다는 가능성은 공식 목표에서 낮추고, patch-map이 제공하는 기능 계약과 patch-service adapter behavior를 우선했다. + +## 채택된 구조 변경 + +| 커밋 | 변경 | 결과 | +| --- | --- | --- | +| `833ab7a` | cleanroom feature contracts 문서화 | 전면 개편 전 기능 고정 기준 생성 | +| `8cf4930` | patch-service aggregate bar stream 계약 테스트 | patch-service 쪽 update shape와 aggregate stream을 테스트로 고정 | +| `63be7a6`, `a595312` | logical scene index, selector routing | Pixi scene graph 탐색 의존을 줄이고 logical id/path 기반으로 조회 | +| `75136da`, `2fa839a` | engine core, render scheduler scaffold | model/render diff/scheduler 기반 v2 구조 시작 | +| `e1efa87`, `281d203` | Pixi renderer와 opt-in v2 mode | 기존 구현과 비교 가능한 v2 경로 확보 | +| `9c298c4` | v2 panel layer aggregate | panel background/bar 계열의 object 수 절감 | +| `b63c79f`, `74cf71c` | compatibility refs, interaction index | selector/transformer/focus 등 기존 API 호환 유지 | +| `f510609`, `529c10d`, `e98e208`, `254472d`, `f314556`, `d5114e5` | silent update batch, fast path, incremental flush, merge overhead 절감, frame-budget queue, latest-state coalescing | 전체 update를 한 프레임에 몰아 UI가 멈추는 문제를 줄임 | +| `485dbc8` | v2 engine implementation 승격 | opt-in 성격을 제거하고 새 엔진을 주 구현으로 승격 | +| `8aec629` | unchanged redraw render skip | 같은 데이터 redraw에서 불필요한 render 감소 | +| `82f0e7e` | visual rendering 복구 | 클린룸 전환 후 깨진 rounded/background/bar 시각 복구 | +| `ca99285` | aggregate policy 반영 render plan refresh | icon/text show 상태 변화 시 aggregate fallback/refresh가 맞게 동작하도록 보정 | +| `17da35c` | aggregate rect layers with atlas particles | background/bar aggregate layer를 atlas particle 기반으로 확장 | +| `6f65fb3` | aggregate particle animation writes 감소 | aggregate animation 중 particle write 부하 감소 | +| `1caba65` | rotated component layout 정렬 | 회전된 item 안의 bar/component가 parent-local 좌표계에 맞게 배치되도록 수정 | + +## Aggregate bar 정책 + +초기 조건은 다음 형태처럼 icon/text가 명시적으로 hidden인 patch-service panel에서만 aggregate bar를 썼다. + +```js +[ + { type: 'bar', ... }, + { type: 'icon', show: false }, + { type: 'text', show: false }, +] +``` + +이후 조건을 넓혀 "실제로 보이는 구조가 background와 bar만 있는 경우" aggregate bar를 사용하도록 바꿨다. 이유는 icon/text 객체가 데이터에 존재하더라도 `show: false`이면 렌더 결과는 bar-only와 같기 때문이다. + +중요한 fallback: + +- icon/text가 다시 show되면 aggregate path만 유지하면 안 된다. +- render plan refresh로 일반 component render path로 돌아가야 한다. +- aggregate로 렌더할 때 일반 bar displayObject는 Pixi 객체 호환을 위해 남아 있을 수 있지만, 실제 렌더는 `renderable = false`로 숨기고 aggregate layer가 담당한다. + +## Queue / batching 설계 + +대량 `emit: false` update를 즉시 모두 적용하면 화면이 멈춘다. 그래서 현재는 다음 조합을 채택했다. + +- latest-state queue +- per-frame budget +- coalescing +- explicit flush가 필요한 API는 queue를 비우고 최신 렌더를 보장 + +현재 코드 기준 update queue는 frame budget 4ms, 최대 750 update/frame으로 처리한다. 같은 target components update는 queue 안에서 최신 상태로 병합한다. 이 때문에 "전체 패널 한번 변경"이 데모에서 수백 ms 걸리는 것처럼 보일 수 있는데, 의도는 한 프레임에 모든 일을 밀어 넣지 않고 유저 입력/렌더링 여유를 남기는 것이다. + +실험상 batching을 제거하거나 무작정 크게 풀면 UI가 멈추는 현상이 있었다. 따라서 최신 상태 coalescing + per-frame budget은 현재 설계에서 유지하는 쪽이 낫다. + +## 최신 단일 리포트 + +리포트: `.gstack/benchmark-reports/2026-05-14T08-55-53-441Z-patchmap-frame-benchmark.json` + +- CPU throttle: 1 +- Headless Chrome +- Viewport: benchmark default +- 기준 frame budget: 16.667ms +- Head: `1caba65` + +| 시나리오 | FPS | p95 ms | max ms | long tasks | +| --- | ---: | ---: | ---: | ---: | +| idle baseline | 59.99 | 17.1 | 17.7 | 1 | +| draw redraw+fit | 56.51 | 17.1 | 100.0 | 1 | +| draw+update all bars | 55.54 | 17.6 | 166.7 | 2 | +| all bars x10 | 58.10 | 17.5 | 34.1 | 0 | +| wheel pan | 60.00 | 17.0 | 17.7 | 0 | +| ctrl+wheel zoom | 59.99 | 17.1 | 17.7 | 0 | +| transformer select | 60.03 | 17.3 | 17.7 | 0 | +| shift drag multi select | 59.98 | 17.3 | 17.6 | 0 | +| mixed state burst | 48.90 | 33.5 | 49.9 | 1 | +| chart stream | 46.35 | 33.4 | 34.2 | 0 | +| highlight alpha burst | 37.31 | 17.6 | 516.7 | 2 | +| relations visibility | 60.00 | 17.5 | 17.7 | 0 | +| report backgrounds burst | 53.40 | 33.4 | 66.7 | 1 | + +해석: + +- pan, zoom, transformer, shift-drag, relation update는 현재 60fps 근처다. +- all bars x10은 latest-state/coalescing 적용 후 최신 리포트에서 long task 0으로 안정적이다. +- chart stream, highlight alpha, mixed burst, background burst는 아직 병목이 남아 있다. +- highlight alpha는 평균 FPS보다 max frame spike가 문제다. + +## 3-way isolated 비교 + +리포트: `.gstack/benchmark-reports/2026-05-14T06-39-39-163Z-patchmap-3way-frame-benchmark-isolated.json` + +- mode: `scenario-isolated` +- CPU throttle: 1 +- viewport: 1440x900 +- 같은 리포트 안에서 `rewrite/v2`, `perf branch`, `npm 0.9.1`을 비교했다. +- 이 모드는 절대 FPS가 낮게 나왔으므로 상대 비교만 본다. + +| 시나리오 | v2 FPS | perf branch FPS | 0.9.1 FPS | v2 vs perf | v2 vs 0.9.1 | +| --- | ---: | ---: | ---: | ---: | ---: | +| idle baseline | 6.17 | 5.36 | 4.75 | +15.1% | +29.9% | +| draw redraw+fit | 6.77 | 5.15 | 4.61 | +31.5% | +46.9% | +| draw+update all bars | 4.71 | 4.35 | 3.31 | +8.3% | +42.3% | +| all bars x10 | 4.00 | 4.32 | 3.43 | -7.4% | +16.6% | +| wheel pan | 5.05 | 5.39 | 4.46 | -6.3% | +13.2% | +| ctrl+wheel zoom | 5.99 | 5.81 | 5.89 | +3.1% | +1.7% | +| transformer select | 5.32 | 4.21 | 4.73 | +26.4% | +12.5% | +| shift drag multi select | 5.55 | 5.11 | 4.92 | +8.6% | +12.8% | +| mixed state burst | 3.75 | 3.65 | 3.05 | +2.7% | +23.0% | +| chart stream | 3.60 | 3.66 | 3.46 | -1.6% | +4.0% | +| highlight alpha burst | 5.91 | 5.34 | 5.15 | +10.7% | +14.8% | +| relations visibility | 6.49 | 5.61 | 6.18 | +15.7% | +5.0% | +| report backgrounds burst | 5.80 | 5.41 | 5.60 | +7.2% | +3.6% | + +해석: + +- v2는 0.9.1 대비 모든 항목에서 개선됐다. +- perf branch 대비로는 대부분 개선됐지만 `all bars x10`, `wheel pan`, `chart stream`은 perf branch가 조금 나았다. +- 이후 최신 단일 리포트에서는 `all bars x10`이 58.10fps/long task 0까지 개선됐다. + +## Atlas layer 단계별 리포트 + +Background atlas 적용 후: + +리포트: `.gstack/benchmark-reports/2026-05-14T07-15-12-810Z-patchmap-v2-after-bg-atlas-isolated.json` + +| 시나리오 | FPS | p95 ms | +| --- | ---: | ---: | +| idle baseline | 14.34 | 83.4 | +| draw redraw+fit | 11.85 | 116.7 | +| draw+update all bars | 6.02 | 249.9 | +| all bars x10 | 4.66 | 267.2 | +| wheel pan | 9.02 | 133.4 | +| ctrl+wheel zoom | 9.94 | 116.7 | +| transformer select | 8.91 | 133.4 | +| shift drag multi select | 8.22 | 134.4 | +| mixed state burst | 4.88 | 283.3 | +| chart stream | 5.46 | 217.3 | +| highlight alpha burst | 11.15 | 116.7 | +| relations visibility | 14.06 | 83.7 | +| report backgrounds burst | 11.19 | 100.3 | + +Background + bar atlas 적용 후: + +리포트: `.gstack/benchmark-reports/2026-05-14T07-23-26-054Z-patchmap-v2-after-bg-bar-atlas-isolated.json` + +| 시나리오 | FPS | p95 ms | +| --- | ---: | ---: | +| idle baseline | 10.14 | 116.6 | +| draw redraw+fit | 9.88 | 116.8 | +| draw+update all bars | 7.22 | 166.8 | +| all bars x10 | 7.10 | 183.3 | +| wheel pan | 11.78 | 100.1 | +| ctrl+wheel zoom | 12.63 | 100.0 | +| transformer select | 11.39 | 133.3 | +| shift drag multi select | 10.39 | 215.7 | +| mixed state burst | 7.01 | 182.9 | +| chart stream | 9.01 | 134.3 | +| highlight alpha burst | 12.73 | 100.0 | +| relations visibility | 15.48 | 83.4 | +| report backgrounds burst | 9.78 | 117.0 | + +해석: + +- bar atlas까지 추가한 뒤 draw/update, pan/zoom, chart stream, highlight alpha는 개선됐다. +- idle/draw 일부는 background-only atlas 리포트보다 낮아졌지만, 핵심 update/interaction 병목에는 도움이 됐다. +- 이후 일반 frame benchmark에서는 60fps에 가까운 항목이 늘어났다. + +## 반려한 실험 + +성능이 일부 좋아도 렌더 parity나 다른 시나리오가 깨지면 되돌렸다. 아래 실험들은 기록용 커밋 또는 로컬 PoC 후 revert됐다. + +| 실험 | 변경 | 측정/결과 | 결정 | +| --- | --- | --- | --- | +| flat aggregate bar particle | aggregate bar texture를 실제 rounded/nine-slice texture 대신 `Texture.WHITE`와 tint로 대체 | 커밋 `1bb7baa` 후 `ae1c2b3`로 revert. 안정 리포트는 커밋에 남지 않음 | rounded bar 시각 계약과 이후 시나리오 안정성 리스크로 유지하지 않음 | +| aggregate bar viewport culling | viewport bounds와 margin을 기준으로 aggregate particle children을 다시 구성 | 커밋 `bf2f6f1` 후 `27b4c36`로 revert. `performance-contracts.md`에 "particleChildren 재구성 비용이 절감보다 큼"으로 기록 | pan/zoom 중 culling 재구성 비용이 커서 반려 | +| aggregate bar alpha fast path | root cached bar만 빠르게 alpha sync하고 subtree 탐색을 줄임 | 커밋 `a76cfcf` 후 `ee1e448`로 revert | 제한적 개선 또는 계약 리스크 대비 이득 부족 | +| CPU mesh aggregate bars | aggregate panel bar particles를 CPU-updated Pixi Mesh로 대체, rounded/nine-slice 유지 | `draw+update` +10.9%, `all bars x10` +53.6%, `wheel` +84.7%, `ctrl zoom` +29.5%. 반면 `chart stream` -16.6%, `highlight alpha` -32.7% | bulk/pan은 좋았지만 chart/alpha 회귀가 커서 반려 | +| custom mesh no slice | radius/nine-slice 없이 flat quad mesh로 단순화 | `all bars x10` +15.6%, `wheel` +19.9%, `ctrl zoom` +8.0%. 반면 `draw+update` -6.1%, `chart stream` -28.5%, `highlight alpha` -47.3% | radius 제거만으로는 전체 개선이 아니고 시각 계약도 약화되어 반려 | +| GPU animation mesh bars | shader/uniform time과 per-bar from/to/timing attribute로 GPU-side animation | `all bars x10` +7.1%. 반면 `draw+update` -18.6%, `wheel` -35.3%, `highlight alpha` -73.4% | attribute/shader overhead가 커서 반려 | +| packed-color CPU mesh bars | CPU mesh에서 color를 packed `unorm8x4`로 넣고 6-piece borderless layout 사용 | `all bars x10` +8.5%, `shift drag` +8.7%. 반면 `draw+update` -26.7%, `ctrl zoom` -50.6%, `mixed` -30.7% | 대다수 시나리오 회귀로 반려 | +| conditional bulk bar mesh overlay | 현재 v2 위에서 bulk bar animation 전용 Mesh overlay를 조건부로 적용 | 기준 리포트 대비 `draw+update` -17.4%, `all bars x10` -36.1%, `chart stream` -70.7%, `highlight alpha` -59.8% | 즉시 revert | +| queue head pointer | `_updateQueue.shift()`를 head pointer/compact 방식으로 바꿔 shift 비용 제거 시도 | 기준 리포트 대비 `all bars x10` -30.6%, `chart stream` -49.2%, `highlight` -66.7%, `backgrounds` -61.9% | 미세 자료구조 변경보다 scheduling side effect가 커서 revert | +| single element update target fast path | 단일 `opts.elements` update에서 target resolve를 더 빠르게 하는 경로 추가 | `all bars x10` 약 -44.8%, `chart stream` 약 -75.2%, `highlight` 약 -72.8%, `backgrounds` 약 -60.9% | revert | +| OffscreenCanvas/WebWorker | Pixi environment 후보로 검토 | patch-map update/interaction/viewport/selection이 DOM/Pixi scene graph/main-thread state와 강하게 묶여 있고, 현재 병목은 renderer policy/update coalescing 쪽이었다 | benchmark-backed production patch 없음 | +| bar spritesheet quantization | 5%/1% 단위 bar height texture/spritesheet 후보 검토 | aggregate atlas particle path가 더 직접적인 대안이었고, quantization은 animation fidelity와 texture churn 리스크가 있음 | 유지된 커밋 없음 | + +## Mesh 실험에서 얻은 결론 + +Custom mesh는 객체 수를 줄일 수 있어서 대량 bulk update 일부에는 효과가 있었다. 하지만 현재 기능 범위 전체를 놓고 보면 다음 문제가 반복됐다. + +- chart stream, alpha burst처럼 작은 update가 자주 들어오는 시나리오에서 vertex/attribute 갱신 비용이 커졌다. +- rounded/nine-slice를 정확히 유지하려면 mesh geometry가 복잡해져 단순 quad 장점이 줄었다. +- GPU-side animation은 CPU tween 제거 효과보다 attribute upload/shader path 비용이 더 컸다. +- radius를 없애면 일부 bulk 시나리오는 좋아지지만, 렌더 계약과 chart/alpha 시나리오가 나빠졌다. + +따라서 현재 branch에서는 mesh 전면 대체보다 atlas particle aggregate + render plan/coalescing/scheduler 개선을 채택했다. + +## 렌더 parity 이슈와 수정 + +| 이슈 | 증상 | 처리 | +| --- | --- | --- | +| v2 초기 render 깨짐 | demo에서 background/bar가 이미지처럼 나오지 않고 padding/rounded가 맞지 않음 | visual renderer 복구, padding 적용, bar가 background 내부에 들어가도록 수정 | +| animation 사라짐 | 전체 panel height 변경 시 bar가 즉시 바뀌거나 움직임이 보이지 않음 | aggregate bar animation 복구, 기본 animationDuration 200ms | +| rounded corner 찌그러짐 | bar/background radius가 특정 크기에서 찌그러짐 | nine-slice particle/atlas layer로 corner 보존 | +| alpha 미적용처럼 보임 | aggregate path에서 alpha 변화가 일반 renderer와 달라 보임 | alpha sync 경로 확인 및 aggregate appearance 적용 | +| relation layer 순서 | relation이 background보다 위, bar보다 아래에 끼는 상태 확인 | render order/layer 정책 점검 | +| repeated 10x update | queue가 끝날 때까지 마지막에 한 번만 적용되는 것처럼 보임 | latest-state coalescing 의도와 UI freeze tradeoff 검토, per-frame budget 유지 | +| height 변경 중 bar 떨림 | bar 위치가 흔들리는 것처럼 보임 | animation/placement 계산 점검 | +| rotated object | 회전된 item 안 component가 parent-local 회전을 따르지 않음 | `1caba65`에서 수정. 90도 회전 item의 bar 위치 smoke test 통과 | + +## 회전 수정 전후 확인 + +비교 리포트: + +- 이전: `.gstack/benchmark-reports/2026-05-14T08-57-03-020Z-patchmap-frame-benchmark.json` +- 이후: `.gstack/benchmark-reports/2026-05-14T08-55-53-441Z-patchmap-frame-benchmark.json` + +| 시나리오 | 이전 FPS | 이후 FPS | 이전 p95 | 이후 p95 | 판단 | +| --- | ---: | ---: | ---: | ---: | --- | +| draw redraw+fit | 57.64 | 56.51 | 17.4 | 17.1 | 노이즈 수준 | +| draw+update all bars | 57.83 | 55.54 | 17.2 | 17.6 | 소폭 하락이나 허용 범위 | +| all bars x10 | 57.28 | 58.10 | 17.7 | 17.5 | 개선 | +| chart stream | 32.12 | 46.35 | 50.2 | 33.4 | 개선 | +| highlight alpha burst | 31.63 | 37.31 | 33.3 | 17.6 | 개선 | +| report backgrounds burst | 44.43 | 53.40 | 50.1 | 33.4 | 개선 | + +회전 대응으로 인한 의미 있는 성능 저하는 확인되지 않았다. + +## 현재 남은 병목 + +최신 리포트 기준 완전히 해결되지 않은 항목은 다음이다. + +- `update: panel chart stream for 120 frames` + - 46.35fps, p95 33.4ms + - 작은 update가 매 프레임 들어오는 stream에서 render/update overhead가 남아 있다. +- `update: highlight bulk alpha burst` + - 37.31fps, max 516.7ms + - 평균보다 spike가 문제다. +- `update: panel mixed state burst` + - 48.90fps, p95 33.5ms + - show/alpha/size 혼합 변경에서 render plan refresh 비용이 남아 있다. +- `update: report panel backgrounds burst` + - 53.40fps, p95 33.4ms + - background aggregate layer는 개선됐지만 burst 시 완전 60fps는 아니다. + +## 최종 판단 + +이 브랜치에서 효과가 있어 남긴 전략은 다음이다. + +- 공식 기능 계약을 먼저 문서화하고, Pixi native 무제한 호환보다 patch-map 공식 기능과 patch-service adapter behavior를 우선한 것 +- logical model/index/render IR 중심으로 scene graph 탐색을 줄인 것 +- background/bar 계열을 aggregate atlas particle layer로 렌더해 top-level object 수와 일반 displayObject update를 줄인 것 +- latest-state queue, coalescing, per-frame budget으로 대량 update가 UI를 멈추지 않게 한 것 +- 일반 bar displayObject는 compatibility ref로 유지하되 실제 bar-only 렌더는 aggregate layer가 담당하게 한 것 +- 렌더 parity가 깨진 변경은 수치가 좋아도 반려한 것 + +효과가 없거나 유지하지 않은 전략은 다음이다. + +- custom mesh 전면 대체 +- GPU-side animation mesh +- radius 없는 flat bar +- naive viewport culling +- OffscreenCanvas/WebWorker 전환 +- bar height spritesheet quantization +- queue 자료구조 미세 최적화 +- 단일 update target fast path + +현재 구조가 이론적으로 가능한 최종 최적해라고 보기는 어렵다. 다만 이번 실험 범위에서는 "mesh/shader로 더 낮은 레이어를 직접 짜는 방식"보다 "공식 기능을 좁히고, model/index/render policy/scheduler/aggregate atlas layer를 정리하는 방식"이 더 안정적으로 성능과 렌더 일치를 동시에 만족했다. + +다음 후보가 있다면 chart stream과 highlight alpha spike를 따로 잡아야 한다. 이미 좋은 bulk update 수치를 더 밀기보다, 작은 반복 update와 alpha-only update가 render plan 전체를 건드리지 않도록 더 세밀한 dirty bit/index를 도입하는 쪽이 우선순위가 높다.