From 5ccab05c0639805fc61ec4de0437ebe373efea5a Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 29 Apr 2026 23:52:43 +0900 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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.