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/ 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/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/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..73967cbd --- /dev/null +++ b/src/display/renderers/PanelBarLayer.js @@ -0,0 +1,829 @@ +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; + this._needsParticleChildrenUpdate = false; + } + + 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._needsParticleChildrenUpdate = true; + } + + _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); + } + } + this._needsParticleChildrenUpdate = true; + } + + flushParticleChildrenUpdate() { + if (!this._needsParticleChildrenUpdate) return; + this._needsParticleChildrenUpdate = false; + this.update(); + } + + _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: cloneState(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) { + 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); + 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]; + 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 = 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 = rotation; + if (entry.alpha !== undefined && particle.alpha !== entry.alpha) { + particle.alpha = entry.alpha; + } + } + } + + _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) { + 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._applyInterpolatedState(entry, animation, 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) => + resolveBorderScaleForSize(layout, state.width, state.height); + +const resolveBorderScaleForSize = (layout, width, height) => + Math.min( + 1, + safeScale(width, layout.slice.leftWidth + layout.slice.rightWidth), + safeScale(height, layout.slice.topHeight + layout.slice.bottomHeight), + ); + +const safeScale = (size, borderSize) => { + if (borderSize <= 0) return 1; + return Math.max(0, size / borderSize); +}; + +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, + 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); + } + 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 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..c130cbfb --- /dev/null +++ b/src/display/renderers/panelComponentRenderer.js @@ -0,0 +1,791 @@ +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)) { + restorePanelBarFallback(item); + return false; + } + + const jobs = []; + for (const change of componentChanges) { + 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; + } + + if (!isNoopHiddenChange(component, change)) { + jobs.push({ component, change }); + } + } + + let barChange = null; + let shouldReconcileBar = false; + for (const { component, change } of jobs) { + 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; +}; + +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(item._panelIconComponent); + hidePanelComponent(item._panelTextComponent); + 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 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, + { deferBarVisual = false } = {}, +) => { + if (isNoopHiddenChange(component, change)) return; + + if (component.type === 'bar' && !deferBarVisual) { + 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 (component.type === 'bar' && deferBarVisual) { + return; + } + + 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 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, + 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 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)); + } + enqueueDirtyPanelBar(queue, component, change); + component._patchmapQueuedVisualOptions = options; + scheduleFlush(queue); +}; + +const enqueueDirtyPanelBar = (queue, component, change) => { + if (component._patchmapPanelBarDirty) { + mergeQueuedChange(component._patchmapQueuedVisualChange, change); + return; + } + + 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) => { + if (!store) return null; + let queue = QUEUE_BY_STORE.get(store); + if (!queue) { + queue = { + store, + jobs: [], + index: 0, + scheduled: false, + dirtyPanelBars: [], + dirtyPanelBarIndex: 0, + flushingDirtyPanelBars: false, + nextDirtyPanelBars: [], + nextDirtyPanelBarSet: new Set(), + }; + 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.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; + 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)) { + dirtyParticleLayers.add(layer); + bar.renderable = false; + bar._patchmapNeedsInitialSource = false; + } else { + hideAggregatedBar(bar); + applyDeferredVisualChange(bar, change, options); + } + } + if ( + 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) => { + 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 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; + + 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..7165eeb8 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; @@ -136,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 }); @@ -184,6 +188,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 +197,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 +219,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 +323,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/tests/render/patch-service-contract.test.js b/src/tests/render/patch-service-contract.test.js new file mode 100644 index 00000000..1a9f6fab --- /dev/null +++ b/src/tests/render/patch-service-contract.test.js @@ -0,0 +1,451 @@ +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); + +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(); + + 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(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); + 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', + ]); + }); +}); 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]), + }; +};