diff --git a/v1/examples/bar-chart/README.md b/v1/examples/bar-chart/README.md index 0f24ac7..2e0c02b 100644 --- a/v1/examples/bar-chart/README.md +++ b/v1/examples/bar-chart/README.md @@ -1,11 +1,10 @@ # Reference: Bar Chart This full-screen example displays a title and six configurable bars. Each bar -has a numeric value and a text label. +has a numeric value and a text label. The chart advances one point per +`playAction` step; the points and the background image asset are declared in +`bar-chart.ograf.json`. -The Graphic is designed for a 1920 x 1080, 50 fps output. It supports real-time -and non-real-time rendering and exposes one play step. - -The package was exported with [StreamShapers](https://streamshapers.com) OGraf -Export. All runtime assets, including the local Lottie player, are included in -this directory. +The Graphic is designed for a 1920 x 1080 output and supports real-time +rendering. The bundled SVG background can be replaced through the +`backgroundImage` input. diff --git a/v1/examples/bar-chart/bar-chart.ograf.json b/v1/examples/bar-chart/bar-chart.ograf.json index 3ae5b73..d3cb44c 100644 --- a/v1/examples/bar-chart/bar-chart.ograf.json +++ b/v1/examples/bar-chart/bar-chart.ograf.json @@ -1,111 +1,87 @@ { "$schema": "https://ograf.ebu.io/v1/specification/json-schemas/graphics/schema.json", - "id": "804a74b8-0076-48b1-a804-4119ba888640", - "version": "1.0.1", - "name": "OGraf BarChart", + "id": "ograf.bar-chart", + "version": "2.0.0", + "name": "Animated Bar Chart", + "description": "A full-screen bar chart that reveals one point on each play step.", "main": "graphic.mjs", "supportsRealTime": true, - "supportsNonRealTime": true, + "supportsNonRealTime": false, + "stepCount": 6, "schema": { "type": "object", + "additionalProperties": false, "properties": { - "_Title": { + "title": { "type": "string", "label": "Title", "default": "Bar Chart Graphic", "gddType": "single-line", "order": 0 }, - "_Value 1": { - "type": "number", - "label": "Value 1", - "default": 205, - "order": 1 + "points": { + "type": "array", + "label": "Chart points", + "description": "Points are revealed in array order, one per play step.", + "order": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { + "type": "string", + "label": "Label", + "gddType": "single-line" + }, + "value": { + "type": "number", + "label": "Value", + "minimum": 0 + } + }, + "required": [ + "label", + "value" + ] + }, + "default": [ + { "label": "2006", "value": 205 }, + { "label": "2007", "value": 600 }, + { "label": "2008", "value": 807 }, + { "label": "2009", "value": 1200 }, + { "label": "2010", "value": 290 }, + { "label": "2011", "value": 102 } + ] }, - "_Text 1": { + "backgroundImage": { "type": "string", - "label": "Text 1", - "default": "2006", - "gddType": "single-line", + "label": "Background image", + "description": "Image asset used for the full-screen blue background.", + "default": "./lib/background.svg", + "gddType": "file-path/image-path", + "gddOptions": { + "extensions": [ "svg", "png", "jpg", "jpeg", "webp", "gif" ] + }, "order": 2 - }, - "_Value 2": { - "type": "number", - "label": "Value 2", - "default": 600, - "order": 3 - }, - "_Text 2": { - "type": "string", - "label": "Text 2", - "default": "2007", - "gddType": "single-line", - "order": 4 - }, - "_Value 3": { - "type": "number", - "label": "Value 3", - "default": 807, - "order": 5 - }, - "_Text 3": { - "type": "string", - "label": "Text 3", - "default": "2008", - "gddType": "single-line", - "order": 6 - }, - "_Value 4": { - "type": "number", - "label": "Value 4", - "default": 1200, - "order": 7 - }, - "_Text 4": { - "type": "string", - "label": "Text 4", - "default": "2009", - "gddType": "single-line", - "order": 8 - }, - "_Value 5": { - "type": "number", - "label": "Value 5", - "default": 290, - "order": 9 - }, - "_Text 5": { - "type": "string", - "label": "Text 5", - "default": "2010", - "gddType": "single-line", - "order": 10 - }, - "_Value 6": { - "type": "number", - "label": "Value 6", - "default": 102, - "order": 11 - }, - "_Text 6": { - "type": "string", - "label": "Text 6", - "default": "2011", - "gddType": "single-line", - "order": 12 } } }, - "description": "A fullscreen graphic template with a barchart featuring six bars and a title.\nDesigned by Mike and technically adapted by Nico.", "author": { - "name": "Mike and Nico", - "url": "streamshapers.com" + "name": "European Broadcasting Union", + "url": "https://ebu.io/" }, - "stepCount": 1, "actionDurations": [ { "type": "playAction", - "duration": 4040 + "duration": 500, + "steps": [ + { "step": 0, "duration": 500 }, + { "step": 1, "duration": 450 }, + { "step": 2, "duration": 450 }, + { "step": 3, "duration": 450 }, + { "step": 4, "duration": 450 }, + { "step": 5, "duration": 450 } + ] }, { "type": "updateAction", @@ -116,21 +92,6 @@ "duration": 400 } ], - "renderRequirements": [ - { - "resolution": { - "width": { - "exact": 1920 - }, - "height": { - "exact": 1080 - } - }, - "frameRate": { - "exact": 50 - } - } - ], "thumbnails": [ { "file": "thumbnail.png", diff --git a/v1/examples/bar-chart/graphic.mjs b/v1/examples/bar-chart/graphic.mjs index 644dfc2..1d334bf 100644 --- a/v1/examples/bar-chart/graphic.mjs +++ b/v1/examples/bar-chart/graphic.mjs @@ -1,2852 +1,234 @@ -/** - * ┌─────────────────────────────────────────────┐ - * │ This OGraf template was created using │ - * │ StreamShapers Ferryman v3.0.52-beta │ - * │ https://streamshapers.com │ - * └─────────────────────────────────────────────┘ - */ - -/** - * The bundled lottie-web 5.12.2 module is distributed under the MIT License: - * - * MIT License - * - * Copyright (c) 2015 Bodymovin - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -/** - * @typedef { import("https://ograf.ebu.io/v1/specification/javascript-types/index.d.ts").GraphicsAPI.Graphic } Graphic - */ - - - const replacements = { - version: "v3.0.52-beta", - fontFaceStyles: "", - spx: " ", - googleTableData: [], - ferrymanJSON: "", - fetchInterval: 0, - clocks: "{}", - timers: "[]", - stopwatches: "[]", - stepCount: 1, - }; - - -const FALLBACK_NON_REALTIME_FRAME_RATE = 30; -const OGRAF_IMAGES_PATH_PREFIX = "lib/images/"; -const OGRAF_IMAGES_BASE_SPECIFIER = "./lib/images/"; -const OGRAF_LOTTIE_PLAYER_SPECIFIER = "./lib/lottie-web.esm.mjs"; -const OGRAF_LOTTIE_ANIMATION_SPECIFIER = "./lib/animation.json"; -const IMAGES_BASE_URL = new URL(OGRAF_IMAGES_BASE_SPECIFIER, import.meta.url).toString(); - -function applySvgTrackMatteBounds(animationItem) { - const renderer = animationItem && animationItem.renderer; - if (!renderer || renderer.rendererType !== 'svg') return; - - const updatedMasks = new Set(); - - function getReferencedMask(ownerRenderer, element) { - const matteElement = element && element.matteElement; - if (!matteElement || typeof matteElement.getAttribute !== 'function') return null; - - const maskReference = matteElement.getAttribute('mask'); - const hashIndex = maskReference ? maskReference.lastIndexOf('#') : -1; - if (hashIndex < 0) return null; - - const closingIndex = maskReference.indexOf(')', hashIndex); - const maskId = maskReference - .slice(hashIndex + 1, closingIndex < 0 ? undefined : closingIndex) - .replace(/^["']|["']$/g, '') - .trim(); - const defs = ownerRenderer && ownerRenderer.globalData && ownerRenderer.globalData.defs; - if (!maskId || !defs || typeof defs.querySelectorAll !== 'function') return null; - - const masks = defs.querySelectorAll('mask'); - for (let index = 0; index < masks.length; index += 1) { - if (masks[index].getAttribute('id') === maskId) return masks[index]; - } - return null; - } - - function setAttributeIfChanged(element, name, value) { - if (element.getAttribute(name) !== value) element.setAttribute(name, value); - } - - function walkRenderer(ownerRenderer, fallbackData) { - if (!ownerRenderer || !Array.isArray(ownerRenderer.elements)) return; - - const ownerData = ownerRenderer.data || fallbackData || {}; - const width = Number(ownerData.w); - const height = Number(ownerData.h); - const hasValidBounds = - Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0; - - for (let index = 0; index < ownerRenderer.elements.length; index += 1) { - const element = ownerRenderer.elements[index]; - if (!element || element === true) continue; - - if (hasValidBounds && element.data && element.data.tt) { - const mask = getReferencedMask(ownerRenderer, element); - if (mask && !updatedMasks.has(mask)) { - updatedMasks.add(mask); - setAttributeIfChanged(mask, 'maskUnits', 'userSpaceOnUse'); - setAttributeIfChanged(mask, 'maskContentUnits', 'userSpaceOnUse'); - setAttributeIfChanged(mask, 'x', '0'); - setAttributeIfChanged(mask, 'y', '0'); - setAttributeIfChanged(mask, 'width', String(width)); - setAttributeIfChanged(mask, 'height', String(height)); - } - } - - if (Array.isArray(element.elements)) walkRenderer(element, element.data || ownerData); - } - } - - walkRenderer(renderer, animationItem.animationData); +const ENTER_DURATION = 500; +const STEP_DURATION = 450; +const EXIT_DURATION = 400; + +const CSS = ` + :host { + display: block; + width: 100%; + height: 100%; + overflow: hidden; + font-family: Arial, Helvetica, sans-serif; + color: #050505; + background: #3974c8; + } + + .background-image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + + .chart { + box-sizing: border-box; + position: absolute; + inset: 6.5% 5.7% auto; + height: 64.8%; + padding: 3.3% 3.1% 3.5%; + border-radius: 20px; + background: linear-gradient(135deg, #cadbff, #9ab8f4); + opacity: 0; + transform: translateY(18px); + transition: opacity ${ENTER_DURATION}ms ease, transform ${ENTER_DURATION}ms ease; + } + + .chart.is-visible { + opacity: 1; + transform: translateY(0); + } + + .chart.is-exiting { + opacity: 0; + transform: translateY(-18px); + transition-duration: ${EXIT_DURATION}ms; + } + + h1 { + margin: 0; + font-size: clamp(28px, 4.1vw, 72px); + line-height: 1; + } + + .plot { + position: absolute; + inset: 20% 12% 13%; + display: flex; + align-items: stretch; + border-bottom: 2px solid #1c2838; + } + + .point { + position: relative; + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + } + + .bar { + width: 48%; + min-height: 0; + background: #0ca5d8; + transform-origin: bottom; + transform: scaleY(0); + transition: transform ${STEP_DURATION}ms cubic-bezier(.22, 1, .36, 1); + } + + .point.is-revealed .bar { + transform: scaleY(1); + } + + .value, + .label { + position: absolute; + white-space: nowrap; + font-size: clamp(16px, 1.8vw, 34px); + } + + .value { + bottom: calc(var(--bar-height) + 1.2%); + transform: translateY(100%); + } + + .label { + top: calc(100% + 2.1%); + } +`; + +function resolveTargetStep(currentStep, { delta = 1, goto } = {}, stepCount) { + if (Number.isInteger(goto) && goto >= 0) return goto; + return (currentStep ?? -1) + (Number.isInteger(delta) ? delta : 1); } -/** @implements {Graphic} */ -class FerrymanGraphic extends HTMLElement { - constructor() { - super(); - this.shadow = this.attachShadow({ mode: 'open' }); - this.state = { - lottieTemplate: null, - lottieTemplateUpdate: undefined, - lottieTemplateNew: null, - playStatus: '', - nextCount: 1, - nextTotal: 0, - stepMarkers: [], - stepCount: null, - showInfo: false, - currentSegment: 'start', - startPlayed: false, - animReady: this._createDeferredPromise(), - animLoaded: this._createDeferredPromise(), - animation: null, - newAnimation: null, - updateAnimation: null, - stopExist: false, - showLogs: false, - imagesPathPrefix: IMAGES_BASE_URL, - updateCounter: 1, - currentFrame: null, - currentTimestamp: 0, - updateExist: false, - lottieState: 'idle', - markers: [], - pendingLoopMarker: null, - boundaryTimerId: null, - playbackEpoch: 0, - activePlaybackAnimation: null, - segmentedAnimations: new WeakSet(), - _lastSegmentResolve: null, - breakFrame: undefined, - fetchSheet: true, - initialData: {}, - inspectorData: {}, - data: {}, - renderType: 'realtime', - clockIntervalIds: [], - clocks: {}, - timers: [], - stopwatches: [], - timerIntervalIds: [], - googleTableCells: null, - fetchInterval: 0, - lastRawUpdate: null, - lastGoogleTableData: {}, - googleDataRevision: 0, - googlePollTimerId: null, - googlePollRunning: false, - _googleTimers: [], - fontStyleNodes: [], - actionsSchedule: [], - scheduleCursor: 0, - nonRealtimePlaybackState: null, - actionQueue: [], - actionQueueRunning: false, - activeActionEntry: null, - disposeStarted: false, - disposeResult: null, - }; - } - - async load(loadParams) { - const { data = false, renderType = 'realtime', renderCharacteristics = {} } = loadParams; - const incomingData = loadParams.data || {}; - this.state.renderType = renderType; - this.state.nonRealtimePlaybackState = this._createNonRealtimePlaybackState(); - this.state.markers = []; - this.state.nextTotal = 0; - this.state.stepMarkers = []; - this.state.stepCount = null; - this.state.stopExist = false; - this.state.updateExist = false; - this.state.initialData = { ...incomingData }; - this.state.inspectorData = {}; - this.state.data = { ...incomingData, ...(this.state.inspectorData || {}) }; - const temp = document.createElement('div'); - temp.innerHTML = this._getReplacement('fontFaceStyles'); - const styleNode = temp.querySelector('style'); - if (styleNode) this.shadow.appendChild(styleNode); - - // Load the packaged ESM player; the exported graphic never fetches its runtime from a CDN. - const lottieModule = await import(import.meta.resolve(OGRAF_LOTTIE_PLAYER_SPECIFIER)); - this.lottie = lottieModule.default; - if (this.state.showLogs) console.log('Load params: ', loadParams); - - const url = import.meta.resolve(OGRAF_LOTTIE_ANIMATION_SPECIFIER); - const response = await fetch(url); - this.state.lottieTemplate = await response.json(); - - for (const asset of this.state.lottieTemplate.assets || []) { - if (asset.u && !/^https?:|^data:|^\//.test(asset.u)) { - asset.u = IMAGES_BASE_URL; - } - } - this._prepareOgrafLottieTemplate(this._getReplacement('stepCount')); - - // Read Google Sheets polling configuration before merging the initial data. - this.state.googleTableCells = this._getReplacement('googleTableData'); - const fiEarly = this._getReplacement('fetchInterval'); - this.state.fetchInterval = Number.isFinite(+fiEarly) ? +fiEarly : 0; - - let initData = { ...incomingData }; - - const initialGoogleTable = await this._parseGoogleTable(); - if ( - this.state.renderType !== 'non-realtime' && - this.state.fetchSheet && - Array.isArray(initialGoogleTable) - ) { - initData = await this._updateGoogleTableData(initData); - } - this.state.lastGoogleTableData = this._getGoogleTableDataSnapshot( - initData, - initialGoogleTable - ); - - this.state.data = - this.state.renderType === 'non-realtime' - ? { ...initData, ...(this.state.inspectorData || {}) } - : initData; - // Load data may disable subsequent external polling after the initial data merge. - this._applyDataControlFlags(this.state.data); - this._updateLottieData(this.state.data, this.state.lottieTemplateNew); - - // Parse realtime clock, timer, and stopwatch configuration before creating the player. - this.state.clocks = (await this._parseClocks()) || {}; - this.state.timers = this._parseTimers() || []; - this.state.stopwatches = this._parseStopwatches() || []; - - const animDiv = document.createElement('div'); - animDiv.id = 'animation1'; - this.shadow.appendChild(animDiv); - this.state.animation = this.lottie.loadAnimation({ - autoplay: false, - loop: false, - container: animDiv, - renderer: 'svg', - animationData: this.state.lottieTemplateNew, - }); - if ( - !this.state.animation.renderer.data.fonts || - !this.state.animation.renderer.data.fonts.list || - this.state.animation.renderer.data.fonts.list.length === 0 - ) { - if (this.state.showLogs) console.log('No fonts found'); - } else { - const fonts = this.state.animation.renderer.data.fonts.list; - if (fonts) { - for (const font in fonts) { - let family = fonts[font].fFamily; - let fontPath = fonts[font].fPath; - if (fontPath !== '') { - this._addFont(family, fontPath); - } - } - } - } - - this.state.animation.addEventListener('DOMLoaded', () => { - applySvgTrackMatteBounds(this.state.animation); - if (this.state.showLogs) console.log('Template - Ready'); - this.state.animReady.resolve('ready'); - }); - - this._addEvents(this.state.animation); - - await this.state.animReady.promise; - this._seekAbsoluteFrame( - this.state.animation, - Number.isFinite(this.state.lottieTemplateNew?.ip) ? this.state.lottieTemplateNew.ip : 0 - ); - this.state.playStatus = 'loaded'; - this.state.currentSegment = 'start'; - this.state.animLoaded.resolve('Template - Animation is Ready'); - - // Keep a short realtime-only settle delay for compatibility with Lottie SVG rendering. - if (this.state.renderType !== 'non-realtime') { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - // Re-read polling configuration after animation initialization for legacy replacements. - this.state.googleTableCells = this._getReplacement('googleTableData'); - this.state.fetchInterval = this._getReplacement('fetchInterval'); - const fi = this._getReplacement('fetchInterval'); - this.state.fetchInterval = Number.isFinite(+fi) ? +fi : 0; - - // Keep the historic four-second settle period, then poll serially at the configured rate. - // The next timeout is only armed after the current fetch and queued update have completed. - this._scheduleGoogleTablePoll(4000 + this.state.fetchInterval); - - await this.state.animLoaded.promise; - await this.state.animReady.promise; - await this._loadFonts(); - - // Resolve load only after the animation and fonts are ready to receive actions. - return { - statusCode: 200, - statusMessage: 'Ograf ready to recieve an action', - }; - } - - playAction(args = {}) { - return this._enqueueAction('playAction', () => this._playAction(args)); - } - - async _playAction(args = {}) { - const { skipAnimation = false, delta = 1, goto: goToStep } = args || {}; - - if (this.state.renderType === 'non-realtime') { - return this._nonRealtimePlayAction({ skipAnimation, delta, goto: goToStep }); - } - - // Ferryman treats the ended state as terminal; load() is required before another play. - if (this.state.playStatus === 'stop') { - return { statusCode: 200, currentStep: undefined, result: { ended: true } }; - } - - if (!this.state.startPlayed) { - await this.state.animLoaded.promise; - if (this.state.showLogs) console.log('AnimLoaded'); - await this.state.animReady.promise; - if (this.state.showLogs) console.log('animReady'); - await this._loadFonts(); - if ( - this.state.renderType !== 'non-realtime' && - this.state.clocks && - Object.keys(this.state.clocks).length !== 0 - ) { - this._startClocks(); - } - if ( - this.state.renderType !== 'non-realtime' && - ((this.state.timers && this.state.timers.length > 0) || - (this.state.stopwatches && this.state.stopwatches.length > 0)) - ) { - this._startTimers(); - } - if (this.state.showLogs) console.log('fonts loaded'); - } - - // Internally, -1 represents the start state before any step has played. - const currentStep = this.state.startPlayed ? this.state.nextCount - 1 : -1; - const stepCount = this._getOgrafStepCount(); - const lastStep = Math.max(0, stepCount - 1); - - const isExplicitGoto = - typeof goToStep === 'number' && Number.isFinite(goToStep) && goToStep >= 0; - let rawTarget; - if (isExplicitGoto) { - rawTarget = goToStep; - } else if (typeof delta === 'number') { - rawTarget = currentStep + delta; - } else { - rawTarget = this.state.startPlayed ? currentStep + 1 : 0; - } - - // OGraf requires a target at or beyond stepCount to transition to the end state. - if (rawTarget >= stepCount) { - await this._transitionRealtimeToEnd({ skipAnimation }); - return { statusCode: 200, currentStep: undefined }; - } - - let targetStep = this._clamp(rawTarget, 0, lastStep); - - if (this.state.startPlayed && targetStep === currentStep && !isExplicitGoto) { - return Promise.resolve({ statusCode: 200, currentStep: currentStep }); - } - - const deferred = this._createDeferredPromise(); - const marker = targetStep === 0 ? 'start' : this._stepToMarker(targetStep); - if (marker === 'stop') { - await this._stopAction({ skipAnimation }); - return { statusCode: 200, currentStep: undefined }; - } - - // Public realtime actions only enter this method at an idle scheduler boundary. Commit - // the selected OGraf step before playback so a first goto above zero establishes the same - // state as a sequence of relative plays. - const previousActionState = this._captureRealtimeActionState(); - try { - this.state._lastSegmentResolve = deferred.resolve; - this.state.currentSegment = marker; - this.state.playStatus = 'play'; - this.state.startPlayed = true; - this.state.nextCount = targetStep + 1; - this._playMarker(marker, { skip: !!skipAnimation }); - if (skipAnimation) this._resolveAndClear({ segment: marker, currentStep: targetStep }); - } catch (error) { - this._abortPlaybackStart(this.state.animation); - this._restoreRealtimeActionState(previousActionState); - throw error; - } - - const returnStep = targetStep; - return deferred.promise.then((payload) => { - const result = - payload && typeof payload === 'object' && !Array.isArray(payload) - ? { ...payload } - : payload; - if (result && typeof result === 'object') delete result.currentStep; - const hasResult = - result !== undefined && - (typeof result !== 'object' || result === null || Object.keys(result).length > 0); - const cancelled = result && typeof result === 'object' && result.cancelled === true; - - return { - statusCode: cancelled ? 409 : 200, - ...(cancelled ? { statusMessage: 'Play action was cancelled.' } : {}), - currentStep: returnStep, - ...(hasResult ? { result } : {}), - }; - }); - } - - stopAction(args = {}) { - return this._enqueueAction('stopAction', () => this._stopAction(args)); - } - - async _stopAction(args = {}) { - const { skipAnimation = false } = args || {}; - - if (this.state.renderType === 'non-realtime') { - return this._nonRealtimeStopAction({ skipAnimation }); - } - - // Repeated stops and stops before the first play are idempotent. - if (this.state.playStatus === 'stop' || !this.state.startPlayed) { - return { statusCode: 200, statusMessage: 'Already stopped' }; - } - - return this._transitionRealtimeToEnd({ skipAnimation }); - } - - async _transitionRealtimeToEnd({ skipAnimation = false } = {}) { - // This may run before the first step when playAction targets the OGraf end node directly; - // public stopAction keeps the pre-start no-op for step-based graphics. - const previousActionState = this._captureRealtimeActionState(); - const deferred = this._createDeferredPromise(); - - try { - if (!this.state.animation) { - deferred.resolve({ segment: 'stop' }); - } else if (this.state.stopExist) { - this.state._lastSegmentResolve = deferred.resolve; - this.state.currentSegment = 'stop'; - if (skipAnimation) { - const marker = this._getMarker('stop'); - if (marker) { - const startFrame = Number.isFinite(marker.tm) ? marker.tm : 0; - const duration = - Number.isFinite(marker.dr) && marker.dr > 0 ? marker.dr : 1; - this._seekAbsoluteFrame(this.state.animation, startFrame + duration - 1, { - resetSegments: true, - }); - } else { - this.state.animation?.destroy?.(); - } - this._resolveAndClear({ segment: 'stop' }); - } else { - this._beginPlayback(this.state.animation); - this.state.animation?.goToAndPlay('stop', true); - } - } else { - deferred.resolve({ segment: 'stop' }); - this.state.animation?.destroy?.(); - } - } catch (error) { - this._abortPlaybackStart(this.state.animation); - this._restoreRealtimeActionState(previousActionState); - throw error; - } - - this.state.nextCount = 1; - this.state.startPlayed = false; - this.state.playStatus = 'stop'; - this._stopClocks(); - this._stopTimers(); - return deferred.promise.then((payload) => ({ - statusCode: 200, - statusMessage: 'Stop Animation played', - })); - } - - updateAction(params = {}) { - return this._enqueueAction('updateAction', () => this._updateAction(params)); - } - - async _updateAction(params = {}) { - const { - data = false, - renderType = 'realtime', - skipAnimation = false, - _skipGoogleFetch = false, - _periodicGoogleUpdate = false, - _googleDataRevision = null, - } = params; - const readyResponse = { - statusCode: 200, - statusMessage: 'Ograf ready for playAction', - }; - - if (_periodicGoogleUpdate) { - if ( - this.state.renderType === 'non-realtime' || - this.state.disposeStarted || - this.state.playStatus === 'disposed' || - !this.state.fetchSheet - ) { - return readyResponse; - } - - const currentGoogleDataRevision = Number.isFinite(this.state.googleDataRevision) - ? this.state.googleDataRevision - : 0; - if ( - Number.isFinite(_googleDataRevision) && - _googleDataRevision !== currentGoogleDataRevision - ) { - return readyResponse; - } - - // The fetch may have waited behind another action. Recompare at the scheduler boundary - // so a manual update that already applied the same values cannot replay the marker. - const periodicData = this._getGoogleTableDataSnapshot(data); - const currentGoogleData = this._getGoogleTableDataSnapshot(this.state.data); - if (!this._hasGoogleTableDataChanged(periodicData, currentGoogleData)) { - this.state.lastGoogleTableData = periodicData; - return readyResponse; - } - } - - const updateStartedBeforeFirstPlay = - this.state.playStatus === 'loaded' && !this.state.startPlayed; - if ( - updateStartedBeforeFirstPlay && - (!this.state.animation?.renderer || - !Array.isArray(this.state.animation.renderer.elements)) - ) { - return { - statusCode: 500, - statusMessage: 'Unable to apply update before playAction.', - }; - } - - if (this.state.lottieState !== 'idle') - console.log('Updating is not possible while Playing '); - if (this.state.showLogs) console.log('Update triggered:', data); - - // Preserve the currently rendered data while preparing the next update animation. - this.state.lottieTemplateUpdate = JSON.parse(JSON.stringify(this.state.lottieTemplateNew)); - this.state.lottieTemplateNew = JSON.parse(JSON.stringify(this.state.lottieTemplate)); - let parsed; - - if (data) { - parsed = data; - } - - const googleTable = - !_skipGoogleFetch && this.state.fetchSheet ? await this._parseGoogleTable() : null; - if (Array.isArray(googleTable)) { - const payloadObj = parsed && typeof parsed === 'object' ? { ...parsed } : {}; - parsed = await this._updateGoogleTableData(payloadObj); - } - - if (parsed) { - for (let key of Object.keys(parsed)) { - this.state.data[key] = parsed[key]; - } - } - - if (!_periodicGoogleUpdate && Array.isArray(googleTable) && googleTable.length > 0) { - const currentRevision = Number.isFinite(this.state.googleDataRevision) - ? this.state.googleDataRevision - : 0; - this.state.googleDataRevision = currentRevision + 1; - } - - if (!_periodicGoogleUpdate) this.state.lastRawUpdate = parsed; - if (_periodicGoogleUpdate || Array.isArray(googleTable)) { - this.state.lastGoogleTableData = this._getGoogleTableDataSnapshot( - this.state.data, - _periodicGoogleUpdate ? this.state.googleTableCells : googleTable - ); - } - - if (this.state.data) { - for (let key of Object.keys(this.state.data)) { - if (key === '_templateInfo') this.state.showInfo = true; - if (key === '_debug') this.state.showLogs = true; - if (key === 'epochID') { - this.state.imagesQuery = `?epoch=${encodeURIComponent(this.state.data[key])}`; - } - if (key === '_stopFetch') { - this.state.fetchSheet = false; - this._stopGoogleTablePolling(); - } - } - this._updateLottieData(this.state.data, this.state.lottieTemplateNew); - } - - // Non-realtime updates mutate deterministic state and render the current timestamp directly. - if (this.state.renderType === 'non-realtime' || renderType === 'non-realtime') { - const parsed = data && typeof data === 'object' ? data : {}; - this.state.inspectorData = { ...(this.state.inspectorData || {}), ...parsed }; - this.state.lastRawUpdate = parsed; - this._renderNonRealtimeState(this.state.currentTimestamp || 0); - return { statusCode: 200 }; - } - - if (updateStartedBeforeFirstPlay) { - this._applyDataToRenderedAnimation(this.state.data); - return readyResponse; - } - - // Rebuild the active animation when an update arrives between marker segments. - if (this.state.playStatus === 'play' && this.state.lottieState === 'idle') { - let updateData = JSON.parse(JSON.stringify(this.state.data)); - this.state.updateCounter++; - - // Play the optional update marker before restoring the main animation. - if (this.state.updateExist && updateData) { - const updateStartFrame = this.state.currentFrame; - if (this.state.showLogs) - console.log(`Update triggered at frame: ${updateStartFrame}`); - - for (let key of Object.keys(updateData)) { - if ( - this._hasLayerInAllComps(this.state.lottieTemplateUpdate, `${key}_update`) - ) { - updateData[`${key}_update`] = updateData[key]; - delete updateData[key]; - } - } - this._updateLottieData(updateData, this.state.lottieTemplateUpdate); - const newDiv = document.createElement('div'); - newDiv.id = 'animation' + this.state.updateCounter; - if (skipAnimation) newDiv.style.display = 'none'; - this.shadow.appendChild(newDiv); - - const updateAnimation = this.lottie.loadAnimation({ - container: this.shadow.getElementById('animation' + this.state.updateCounter), - renderer: 'svg', - loop: false, - autoplay: false, - animationData: this.state.lottieTemplateUpdate, - }); - this.state.updateAnimation = updateAnimation; - - return new Promise((resolve) => { - updateAnimation.addEventListener('DOMLoaded', () => { - if ( - this.state.playStatus === 'disposed' || - this.state.updateAnimation !== updateAnimation - ) { - return; - } - applySvgTrackMatteBounds(updateAnimation); - if (this.state.clocks && Object.keys(this.state.clocks).length) { - this._updateClocksOn(updateAnimation); - } - this._updateTimersOn(updateAnimation); - if (this.state.showInfo) console.log('Template - updated Animation Ready'); - const updateMarker = this.state.lottieTemplateUpdate.markers.find( - (marker) => marker.cm === 'update' - ); - const shouldSkipUpdate = skipAnimation && updateMarker; - - if (shouldSkipUpdate) { - const updateEndFrame = updateMarker.tm + updateMarker.dr - 1; - this._seekAbsoluteFrame(updateAnimation, updateEndFrame); - const updateDiv = this.shadow.getElementById( - 'animation' + this.state.updateCounter - ); - updateAnimation.destroy(); - if (updateDiv) updateDiv.remove(); - this.state.updateAnimation = null; - this.state.lottieState = 'idle'; - this._restoreUpdatedAnimationAtFrame(updateStartFrame, resolve, { - incrementCounter: true, - oldDivId: 'animation' + (this.state.updateCounter - 1), - readyResponse, - syncTimers: true, - }); - return; - } else { - this._addEvents(updateAnimation); - updateAnimation.goToAndPlay('update', true); - } - - this.state.animation.destroy(); - const oldDiv = this.shadow.getElementById( - 'animation' + (this.state.updateCounter - 1) - ); - if (oldDiv) oldDiv.remove(); - this.state.animation = updateAnimation; - this.state.updateAnimation = null; - - let restoreStarted = false; - const restoreUpdatedAnimation = () => { - if ( - restoreStarted || - this.state.playStatus === 'disposed' || - this.state.animation !== updateAnimation - ) { - return; - } - restoreStarted = true; - this.state.lottieState = 'idle'; - if (this.state.showInfo) - console.log(`LottieState: "${this.state.lottieState}`); - this._restoreUpdatedAnimationAtFrame(updateStartFrame, resolve, { - incrementCounter: true, - readyResponse, - syncTimers: true, - }); - }; - - this.state.animation.addEventListener('_idle', restoreUpdatedAnimation); - }); - }); - } - - // Without an update marker, reload the data and restore the current frame directly. - if (!this.state.updateExist) { - return new Promise((resolve) => { - this._restoreUpdatedAnimationAtFrame(this.state.currentFrame, resolve, { - incrementCounter: false, - readyResponse, - syncTimers: false, - }); - }); - } - } - return readyResponse; - } - - _restoreUpdatedAnimationAtFrame( - frame, - resolve, - { - incrementCounter = true, - readyResponse = { - statusCode: 200, - statusMessage: 'Ograf ready for playAction', - }, - syncTimers = true, - oldDivId = null, - } = {} - ) { - if (incrementCounter) this.state.updateCounter++; - const newDiv = document.createElement('div'); - newDiv.id = 'animation' + this.state.updateCounter; - this.shadow.appendChild(newDiv); - - const newAnimation = this.lottie.loadAnimation({ - container: this.shadow.getElementById('animation' + this.state.updateCounter), - renderer: 'svg', - loop: false, - autoplay: false, - animationData: this.state.lottieTemplateNew, - }); - this.state.newAnimation = newAnimation; - - newAnimation.addEventListener('DOMLoaded', () => { - if (this.state.playStatus === 'disposed' || this.state.newAnimation !== newAnimation) { - return; - } - applySvgTrackMatteBounds(newAnimation); - if (this.state.clocks && Object.keys(this.state.clocks).length) { - this._updateClocksOn(newAnimation); - } - if (syncTimers) this._updateTimersOn(newAnimation); - if (this.state.showInfo) console.log('Template - updated Animation Ready'); - const restoredFrame = this._seekAbsoluteFrame(newAnimation, frame); - - const oldAnimation = this.state.animation; - const oldDiv = this.shadow.getElementById( - oldDivId || 'animation' + (this.state.updateCounter - 1) - ); - if (oldDiv) oldDiv.remove(); - - oldAnimation?.destroy?.(); - this.state.animation = newAnimation; - this.state.currentFrame = restoredFrame; - - this._addEvents(this.state.animation); - this.state.newAnimation = null; - this.state.lottieState = 'idle'; - resolve(readyResponse); - }); - } - - // Cleanup is best effort: settle pending actions, run every cleanup step, and report failures - // in one OGraf ReturnPayload. - async dispose(_params) { - if (this.state.disposeStarted) { - return this.state.disposeResult || { statusCode: 200 }; - } - this.state.disposeStarted = true; - this.state.playStatus = 'disposed'; - this.state.playbackEpoch++; - - const cleanupErrors = []; - const cleanup = (label, callback) => { - try { - callback(); - } catch (error) { - cleanupErrors.push( - `${label}: ${error instanceof Error ? error.message : String(error)}` - ); - } - }; - - // Settle pending action promises before destroying resources they may still reference. - cleanup('pending actions', () => { - const cancellation = { - segment: this.state.currentSegment, - cancelled: true, - reason: 'dispose', - }; - this.state._lastSegmentResolve?.(cancellation); - this.state._lastSegmentResolve = null; - this.state.pendingLoopMarker = null; - this.state.activePlaybackAnimation = null; - this.state.segmentedAnimations = new WeakSet(); - const disposedPayload = (actionName) => - this._createActionErrorPayload(actionName, 410, 'Graphic has been disposed.'); - if (this.state.activeActionEntry) { - this.state.activeActionEntry.resolve( - disposedPayload(this.state.activeActionEntry.actionName) - ); - this.state.activeActionEntry = null; - } - for (const queuedAction of this.state.actionQueue || []) { - queuedAction.resolve(disposedPayload(queuedAction.actionName)); - } - this.state.actionQueue = []; - this.state.actionQueueRunning = false; - }); - cleanup('playback boundary timer', () => { - if (this.state.boundaryTimerId != null) { - clearTimeout(this.state.boundaryTimerId); - this.state.boundaryTimerId = null; - } - }); - cleanup('timers', () => this._stopTimers()); - cleanup('clocks', () => this._stopClocks()); - cleanup('external data polling', () => this._stopGoogleTablePolling()); - cleanup('animations', () => { - const animations = new Set([ - this.state.updateAnimation, - this.state.newAnimation, - this.state.animation, - ]); - for (const animation of animations) animation?.destroy?.(); - this.state.updateAnimation = null; - this.state.newAnimation = null; - this.state.animation = null; - }); - cleanup('font styles', () => { - for (const styleNode of this.state.fontStyleNodes || []) styleNode.remove?.(); - this.state.fontStyleNodes = []; - }); - cleanup('shadow DOM', () => { - this.shadow.innerHTML = ''; - }); - - this.state.actionsSchedule = []; - this.state.scheduleCursor = 0; - this.state.nonRealtimePlaybackState = null; - this.state.lottieState = 'idle'; - - this.state.disposeResult = - cleanupErrors.length > 0 - ? { - statusCode: 500, - statusMessage: `Dispose cleanup failed: ${cleanupErrors.join('; ')}`, - } - : { statusCode: 200 }; - return this.state.disposeResult; - } - - // Ferryman diagnostic helper; not part of the OGraf Graphic interface. - async getStatus(_params) { - return { - playStatus: this.state.playStatus, // '' before load; then loaded | play | stop | disposed - currentSegment: this.state.currentSegment || null, - currentStep: this.state.startPlayed ? this.state.nextCount - 1 : -1, - lottieState: this.state.lottieState, // idle | play | pause - }; - } - - /** - * Required OGraf action entry point for manifest-defined custom actions. - * Ferryman does not export custom actions, so every invocation returns 400. - * - * @param {{ id: string, payload: unknown, skipAnimation?: boolean }} params - * @returns {Promise<{ statusCode: number, statusMessage: string }>} - */ - customAction(params) { - return this._enqueueAction('customAction', () => this._customAction(params)); - } - - async _customAction(_params) { - return { - statusCode: 400, - statusMessage: 'No custom actions defined for this graphic.', - }; - } - - /** - * Seeks a non-realtime graphic to a timestamp and resolves after rendering that frame. - * - * @param {{ timestamp: number }} payload Position on the timeline in milliseconds. - * @returns {Promise<{ statusCode: number, statusMessage?: string }>} - * - * Rendering behavior: - * 1. Scheduled updates at or before the timestamp are merged with load data, followed by - * current Inspector overrides; the stored schedule is not consumed or mutated. - * 2. Playback state comes from scheduled play, stop, and update actions when a schedule - * exists, otherwise from direct non-realtime actions. - * 3. Marker-local time selects the frame; goToAndStop renders it without playback. - */ - async goToTime(payload) { - if ( - !payload || - typeof payload.timestamp !== 'number' || - !Number.isFinite(payload.timestamp) - ) - return { statusCode: 400, statusMessage: 'timestamp must be a finite number' }; - const ts = payload.timestamp; - this._renderNonRealtimeState(ts); - return { statusCode: 200 }; - } - - /** - * Stores a non-realtime OGraf action schedule, replacing any previous schedule. - * The Promise resolves as soon as the schedule is stored, not when its actions execute. - * - * OGraf callers use `{ schedule }`. Direct arrays and `{ actionsSchedule }` remain accepted - * for compatibility with older Ferryman callers. - * - * @param {object | Array} payload OGraf schedule payload or a supported legacy form. - * @returns {Promise} An empty payload after the schedule has been stored. - * - * goToTime() replays relevant sorted entries at or before the requested timestamp without - * consuming the schedule. - */ - async setActionsSchedule(payload) { - const list = Array.isArray(payload) - ? payload - : Array.isArray(payload?.schedule) - ? payload.schedule - : Array.isArray(payload?.actionsSchedule) - ? payload.actionsSchedule - : []; - this.state.actionsSchedule = [...list].sort( - (a, b) => (a.timestamp || 0) - (b.timestamp || 0) - ); - this.state.scheduleCursor = 0; - this.state.nonRealtimePlaybackState = this._createNonRealtimePlaybackState(); - return {}; - } - - startTimer(name) { - if (this.state.renderType === 'non-realtime') return; - if (this.state.showLogs) console.log('[graphic.mjs] startTimer', name); - for (let timer of this.state.timers) { - if (timer.name === name) { - if (timer.id === null) { - timer.id = setInterval(() => { - timer.time--; - if (timer.time === timer.stopTime) { - clearInterval(timer.id); - timer.id = null; - } - }, 1000); - } - } - } - } - - stopTimer(name) { - if (this.state.renderType === 'non-realtime') return; - if (this.state.showLogs) console.log('[graphic.mjs] stopTimer', name); - for (let timer of this.state.timers) { - if (timer.name === name) { - if (timer.id !== null) { - clearInterval(timer.id); - timer.id = null; - } - } - } - } - - setTimer(name, time) { - if (this.state.renderType === 'non-realtime') return; - if (this.state.showLogs) console.log('[graphic.mjs] setTimer', name, time); - for (let timer of this.state.timers) { - if (timer.name === name) { - timer.time = time; - } - } - } - - setTimerStop(name, time) { - if (this.state.renderType === 'non-realtime') return; - if (this.state.showLogs) console.log('[graphic.mjs] setTimerStop', name, time); - for (let timer of this.state.timers) { - if (timer.name === name) { - timer.stopTime = time; - } - } - } - - startStopwatch(name) { - if (this.state.renderType === 'non-realtime') return; - if (this.state.showLogs) console.log('[graphic.mjs] startStopwatch', name); - for (let stopwatch of this.state.stopwatches) { - if (stopwatch.name === name) { - if (stopwatch.id === null) { - stopwatch.id = setInterval(() => { - stopwatch.time++; - if (stopwatch.time === stopwatch.stopTime) { - clearInterval(stopwatch.id); - stopwatch.id = null; - } - }, 1000); - } - } - } - } - - stopStopwatch(name) { - if (this.state.renderType === 'non-realtime') return; - if (this.state.showLogs) console.log('[graphic.mjs] stopStopwatch', name); - for (let stopwatch of this.state.stopwatches) { - if (stopwatch.name === name) { - if (stopwatch.id !== null) { - clearInterval(stopwatch.id); - stopwatch.id = null; - } - } - } - } - - setStopwatch(name, time) { - if (this.state.renderType === 'non-realtime') return; - if (this.state.showLogs) console.log('[graphic.mjs] setStopwatch', name, time); - for (let stopwatch of this.state.stopwatches) { - if (stopwatch.name === name) { - stopwatch.time = time; - } - } - } - - setStopwatchStop(name, time) { - if (this.state.renderType === 'non-realtime') return; - if (this.state.showLogs) console.log('[graphic.mjs] setStopwatchStop', name, time); - for (let stopwatch of this.state.stopwatches) { - if (stopwatch.name === name) { - stopwatch.stopTime = time; - } - } - } - - _nonRealtimePlayAction({ skipAnimation = false, delta = 1, goto: goToStep } = {}) { - const timestamp = this.state.currentTimestamp || 0; - const playbackState = this._getDirectNonRealtimePlaybackState(); - this._reduceScheduledPlayAction(playbackState, timestamp, { - skipAnimation, - delta, - goto: goToStep, - }); - this._syncNonRealtimePlaybackState(playbackState); - this._renderNonRealtimeState(timestamp, playbackState); - - // Keep the zero-based currentStep at the top level; the end node uses undefined. - return { - statusCode: 200, - currentStep: playbackState.startPlayed ? playbackState.nextCount - 1 : undefined, - }; - } - - _nonRealtimeStopAction({ skipAnimation = false } = {}) { - const timestamp = this.state.currentTimestamp || 0; - const playbackState = this._getDirectNonRealtimePlaybackState(); - this._reduceScheduledStopAction(playbackState, timestamp, { skipAnimation }); - this._syncNonRealtimePlaybackState(playbackState); - this._renderNonRealtimeState(timestamp, playbackState); - return { statusCode: 200, statusMessage: 'Stop Animation played' }; - } - - _renderNonRealtimeState(timestamp, playbackState = null) { - // Use one deterministic render path for scheduled seeks, direct NRT actions, and edits. - this.state.currentTimestamp = timestamp; - const dataAtTime = this._getDataAtTime(timestamp); - this._applyDataState(dataAtTime); - - const resolvedPlaybackState = - playbackState || - ((this.state.actionsSchedule || []).length > 0 - ? this._getScheduledNonRealtimePlaybackState(timestamp) - : this._getDirectNonRealtimePlaybackState()); - const frame = this._getTimelineFrameForPlaybackState(resolvedPlaybackState, timestamp); - if (this.state.animation) { - this._seekAbsoluteFrame(this.state.animation, frame); - this._renderNonRealtimeFrame(frame); - } - } - - _renderNonRealtimeFrame(frame) { - // Use Lottie's seek path for the redraw bump so expressions reset correctly. - const animation = this.state.animation; - if (!animation || typeof animation.goToAndStop !== 'function') return; - - try { - const endFrame = this._getEndFrame(); - const bounds = this._getAnimationBounds(animation); - const bumpFrame = frame < endFrame ? frame + 1 : Math.max(frame - 1, bounds.startFrame); - if (bumpFrame !== frame) this._seekAbsoluteFrame(animation, bumpFrame); - this._seekAbsoluteFrame(animation, frame); - } catch (err) { - if (this.state.showLogs) console.warn('[NonRealtime] redraw failed:', err); - } - } - - _getSortedActionsSchedule() { - // Sort on read so helper calls never depend on caller or host ordering. - return [...(this.state.actionsSchedule || [])].sort( - (a, b) => (a.timestamp || 0) - (b.timestamp || 0) - ); - } - - _getDataAtTime(timestamp) { - // Derive data from load data, scheduled updates, then current Inspector overrides. - const data = { ...(this.state.initialData || {}) }; - const schedule = this._getSortedActionsSchedule(); - - for (const item of schedule) { - if (typeof item?.timestamp !== 'number' || item.timestamp > timestamp) continue; - if (item.action?.type !== 'updateAction') continue; - - const actionData = item.action.params?.data; - if (actionData && typeof actionData === 'object') { - Object.assign(data, actionData); - } - } - - return { ...data, ...(this.state.inspectorData || {}) }; - } - - _getTimelineFrameAtTime(timestamp) { - return this._getTimelineFrameForPlaybackState( - this._getScheduledNonRealtimePlaybackState(timestamp), - timestamp - ); - } - - _createNonRealtimePlaybackState() { - return { - startPlayed: false, - nextCount: 1, - activeSegment: null, - activeTimestamp: 0, - skipAnimation: false, - hiddenFrame: null, - }; - } - - _getDirectNonRealtimePlaybackState() { - if (!this.state.nonRealtimePlaybackState) { - this.state.nonRealtimePlaybackState = this._createNonRealtimePlaybackState(); - } - return this.state.nonRealtimePlaybackState; - } - - _getScheduledNonRealtimePlaybackState(timestamp) { - const schedule = this._getSortedActionsSchedule(); - const state = this._createNonRealtimePlaybackState(); - - for (const item of schedule) { - if (typeof item?.timestamp !== 'number' || item.timestamp > timestamp) continue; - - const action = item.action || {}; - const params = action.params || {}; - - if (action.type === 'playAction') { - this._reduceScheduledPlayAction(state, item.timestamp, params); - } else if (action.type === 'stopAction') { - this._reduceScheduledStopAction(state, item.timestamp, params); - } else if (action.type === 'updateAction') { - this._reduceScheduledUpdateAction(state, item.timestamp, params); - } - } - - return state; - } - - _syncNonRealtimePlaybackState(playbackState) { - this.state.startPlayed = playbackState.startPlayed; - this.state.nextCount = playbackState.nextCount; - this.state.playStatus = playbackState.startPlayed ? 'play' : 'stop'; - this.state.lottieState = 'idle'; - if (playbackState.activeSegment) this.state.currentSegment = playbackState.activeSegment; - this.state.pendingLoopMarker = null; - this.state.activePlaybackAnimation = null; - this.state._lastSegmentResolve = null; - } - - _getTimelineFrameForPlaybackState(state, timestamp) { - // A stop without a stop marker freezes the graphic at its final renderable frame. - if (state.hiddenFrame !== null) return state.hiddenFrame; - if (!state.activeSegment) return this._getDefaultMarkerTimelineFrameAtTime(timestamp); - - const marker = this._getMarker(state.activeSegment); - if (!marker) return this._getDefaultMarkerTimelineFrameAtTime(timestamp); - return this._getMarkerFrameAtTime(marker, timestamp, state.activeTimestamp, { - skipAnimation: state.skipAnimation, - }); - } - - _reduceScheduledPlayAction(state, timestamp, params = {}) { - // Apply OGraf playAction step selection to the supplied NRT state without realtime playback. - const currentStep = state.startPlayed ? state.nextCount - 1 : -1; - const stepCount = this._getOgrafStepCount(); - const lastStep = Math.max(0, stepCount - 1); - const goToStep = params.goto; - const delta = params.delta; - const isExplicitGoto = - typeof goToStep === 'number' && Number.isFinite(goToStep) && goToStep >= 0; - let rawTarget; - - if (isExplicitGoto) { - rawTarget = goToStep; - } else if (typeof delta === 'number' && Number.isFinite(delta)) { - rawTarget = currentStep + delta; - } else { - rawTarget = state.startPlayed ? currentStep + 1 : 0; - } - - if (rawTarget >= stepCount) { - this._reduceScheduledStopAction(state, timestamp, params); - return; - } - - const targetStep = this._clamp(rawTarget, 0, lastStep); - if (state.startPlayed && targetStep === currentStep && !isExplicitGoto) return; - - const marker = this._stepToMarker(targetStep); - if (marker === 'stop') { - this._reduceScheduledStopAction(state, timestamp, params); - return; - } - - state.activeSegment = marker; - state.activeTimestamp = timestamp; - state.skipAnimation = !!params.skipAnimation; - state.hiddenFrame = null; - state.startPlayed = true; - state.nextCount = targetStep + 1; - } - - _reduceScheduledStopAction(state, timestamp, params = {}) { - // Stop either plays the stop marker or freezes at a stable ended frame. - state.startPlayed = false; - state.nextCount = 1; - state.activeTimestamp = timestamp; - state.skipAnimation = !!params.skipAnimation; - - if (this.state.stopExist && this._getMarker('stop')) { - state.activeSegment = 'stop'; - state.hiddenFrame = null; - } else { - state.activeSegment = null; - state.hiddenFrame = this._getEndFrame(); - } - } - - _reduceScheduledUpdateAction(state, timestamp, params = {}) { - // Data changes are handled by _getDataAtTime; this only renders the optional update marker. - if (!this.state.updateExist || !this._getMarker('update')) return; - - state.activeSegment = 'update'; - state.activeTimestamp = timestamp; - state.skipAnimation = !!params.skipAnimation; - state.hiddenFrame = null; - } - - _getMarkerFrameAtTime(marker, timestamp, markerStartTimestamp, { skipAnimation = false } = {}) { - // Translate outer host time into local time inside the active marker segment. - const startFrame = Number.isFinite(marker.tm) ? marker.tm : 0; - const duration = Number.isFinite(marker.dr) && marker.dr > 0 ? marker.dr : 1; - const endFrame = startFrame + duration - 1; - - // skipAnimation is deterministic: jump straight to the marker's final frame. - if (skipAnimation) return this._clamp(endFrame, startFrame, endFrame); - - const fr = this._getFrameRate(); - const localMs = Math.max(0, timestamp - markerStartTimestamp); - const localFrame = Math.round((localMs / 1000) * fr); - // Clamp instead of looping; realtime loop markers remain realtime-only. - return this._clamp(startFrame + localFrame, startFrame, endFrame); - } - - _getDefaultMarkerTimelineFrameAtTime(timestamp) { - const frame = this._getFerrymanOgrafMarkerTimelineFrame( - this.state.lottieTemplate?.markers || [], - timestamp, - this._getFrameRate() - ); - - return frame ?? this._getAbsoluteTimelineFrame(timestamp); - } - - _getFerrymanOgrafMarkerTimelineFrame( - markers = [], - timestamp = 0, - frameRate = FALLBACK_NON_REALTIME_FRAME_RATE - ) { - const timeline = this._buildFerrymanOgrafMarkerTimeline(markers, frameRate); - if (timeline.length === 0) return null; - - const safeFrameRate = this._getSafeFrameRate(frameRate); - const safeTimestamp = Number.isFinite(Number(timestamp)) - ? Math.max(0, Number(timestamp)) - : 0; - const activeEntry = - timeline.find((entry) => safeTimestamp < entry.endMs) || timeline[timeline.length - 1]; - const localMs = Math.max(0, safeTimestamp - activeEntry.startMs); - const localFrame = Math.round((localMs / 1000) * safeFrameRate); - - return this._clampTimelineFrame( - activeEntry.startFrame + localFrame, - activeEntry.startFrame, - activeEntry.endFrame - ); - } - - // Deterministic NRT order is start, numerically sorted nextN markers, then stop. - // Update and *_loop markers are handled separately and excluded here. - _buildFerrymanOgrafMarkerTimeline(markers = [], frameRate = FALLBACK_NON_REALTIME_FRAME_RATE) { - const sourceMarkers = Array.isArray(markers) ? markers : []; - const normalizedMarkers = sourceMarkers.map((marker, index) => ({ - marker, - index, - name: this._normalizeFerrymanTimelineMarkerName(marker?.cm), - })); - - const startEntry = normalizedMarkers.find(({ name }) => name === 'start'); - if (!startEntry) return []; - - const nextEntries = this._analyzeOgrafStepMarkers(sourceMarkers).orderedSteps; - - const stopEntry = normalizedMarkers.find(({ name }) => name === 'stop'); - const orderedEntries = [ - { name: 'start', marker: startEntry.marker }, - ...nextEntries.map(({ marker, name }) => ({ name, marker })), - ...(stopEntry ? [{ name: 'stop', marker: stopEntry.marker }] : []), - ]; - - const safeFrameRate = this._getSafeFrameRate(frameRate); - let startMs = 0; - - return orderedEntries.map(({ name, marker }) => { - const startFrame = this._getTimelineMarkerStartFrame(marker); - const durationFrames = this._getTimelineMarkerDurationFrames(marker); - const durationMs = (durationFrames / safeFrameRate) * 1000; - const entry = { - name, - marker, - startMs, - endMs: startMs + durationMs, - durationMs, - startFrame, - endFrame: startFrame + durationFrames - 1, - }; - - startMs += durationMs; - return entry; - }); - } - - _normalizeFerrymanTimelineMarkerName(value) { - return String(value ?? '') - .trim() - .toLowerCase(); - } - - _prepareOgrafLottieTemplate(configuredStepCount) { - this.state.markers = []; - this.state.stopExist = false; - this.state.updateExist = false; - - // Normalize Ferryman control-marker names before deriving the step model. - const markers = Array.isArray(this.state.lottieTemplate?.markers) - ? this.state.lottieTemplate.markers - : []; - for (const marker of markers) { - const name = String(marker.cm); - const normalizedName = this._normalizeFerrymanTimelineMarkerName(name); - const finalName = /^(?:start|stop|update|next\d+)(?:_loop)?$/.test(normalizedName) - ? normalizedName - : name; - - marker.cm = finalName; - this.state.markers.push(finalName); - if (finalName === 'stop') this.state.stopExist = true; - if (finalName === 'update') this.state.updateExist = true; - } - - const stepCount = this._configureOgrafStepModel(markers, configuredStepCount); - // This exact clone is passed to Lottie as animationData. It must be made - // after marker canonicalisation so string-based playback can resolve it. - this.state.lottieTemplateNew = JSON.parse(JSON.stringify(this.state.lottieTemplate)); - return stepCount; - } - - _getTimelineMarkerStartFrame(marker) { - const startFrame = Number(marker?.tm); - return Number.isFinite(startFrame) ? startFrame : 0; - } - - _getTimelineMarkerDurationFrames(marker) { - const durationFrames = Number(marker?.dr); - return Number.isFinite(durationFrames) && durationFrames > 0 ? durationFrames : 1; - } - - _clampTimelineFrame(frame, min, max) { - return Math.min(Math.max(frame, min), max); - } - - _getAbsoluteTimelineFrame(timestamp) { - // Fall back to absolute Lottie time when no Ferryman marker timeline is available. - const frame = Math.round((timestamp / 1000) * this._getFrameRate()); - return this._clamp(frame, 0, this._getEndFrame()); - } - - _getFrameRate() { - // Prefer the source Lottie frame rate; fall back to the loaded player. - return this._getSafeFrameRate( - this.state.lottieTemplate?.fr ?? this.state.animation?.frameRate - ); - } - - _getSafeFrameRate(frameRate) { - const normalized = Number(frameRate); - return Number.isFinite(normalized) && normalized > 0 - ? normalized - : FALLBACK_NON_REALTIME_FRAME_RATE; - } - - _getEndFrame() { - // Lottie op is exclusive, so the last renderable frame is op - 1. - const op = this.state.lottieTemplate?.op ?? this.state.animation?.getDuration?.(true) ?? 1; - return Math.max(0, op - 1); - } - - _getOgrafStepCount() { - return Number.isInteger(this.state.stepCount) && this.state.stepCount > 0 - ? this.state.stepCount - : this.state.nextTotal + 1; - } - - _analyzeOgrafStepMarkers(markers = []) { - // Positive nextN numbers define playback order. OGraf step IDs remain dense and - // zero-based even when the authored marker numbers contain gaps. - const markersByStep = new Map(); - const invalidNames = []; - const duplicateSteps = []; - - for (const marker of Array.isArray(markers) ? markers : []) { - const name = this._normalizeFerrymanTimelineMarkerName(marker?.cm); - if (!/^next\d+$/.test(name)) continue; - - const match = /^next([1-9]\d*)$/.exec(name); - if (!match) { - invalidNames.push(name); - continue; - } - - const step = Number(match[1]); - if (!Number.isSafeInteger(step)) { - invalidNames.push(name); - continue; - } - if (markersByStep.has(step)) { - duplicateSteps.push(step); - continue; - } - markersByStep.set(step, marker); - } - - const orderedSteps = [...markersByStep.entries()] - .sort(([a], [b]) => a - b) - .map(([markerNumber, marker]) => ({ - markerNumber, - marker, - name: this._normalizeFerrymanTimelineMarkerName(marker?.cm), - })); - - return { - orderedSteps, - invalidNames: [...new Set(invalidNames)].sort(), - duplicateSteps: [...new Set(duplicateSteps)].sort((a, b) => a - b), - }; - } - - _configureOgrafStepModel(markers = [], configuredStepCount) { - // The injected manifest count must match the marker-derived positive step model. - const analysis = this._analyzeOgrafStepMarkers(markers); - const problems = [ - analysis.invalidNames.length - ? `invalid markers: ${analysis.invalidNames.join(', ')}` - : null, - analysis.duplicateSteps.length - ? `duplicate markers: ${analysis.duplicateSteps.map((step) => `next${step}`).join(', ')}` - : null, - ].filter(Boolean); - if (problems.length > 0) { - throw new Error(`Invalid OGraf step marker sequence (${problems.join('; ')})`); - } - - const derivedStepCount = analysis.orderedSteps.length + 1; - const parsedStepCount = Number(configuredStepCount); - const hasConfiguredStepCount = - configuredStepCount !== null && - configuredStepCount !== undefined && - configuredStepCount !== '' && - Number.isInteger(parsedStepCount) && - parsedStepCount > 0; - const stepCount = hasConfiguredStepCount ? parsedStepCount : derivedStepCount; - - if (stepCount !== derivedStepCount) { - throw new Error( - `Manifest stepCount ${stepCount} does not match marker-derived stepCount ${derivedStepCount}.` - ); - } - - this.state.stepMarkers = analysis.orderedSteps.map(({ name }) => name); - this.state.nextTotal = this.state.stepMarkers.length; - this.state.stepCount = stepCount; - return stepCount; - } - - _stepToMarker(step) { - if (step <= 0) return 'start'; - if (this.state.stopExist && step > this.state.nextTotal) return 'stop'; - return this.state.stepMarkers?.[step - 1] ?? 'next' + step; - } - - _applyDataState(data = {}) { - // Rebuild the editable data snapshot without starting playback or async updates. - const nextData = data && typeof data === 'object' ? { ...data } : {}; - this.state.data = nextData; - this._applyDataControlFlags(this.state.data); - this.state.lottieTemplateNew = JSON.parse(JSON.stringify(this.state.lottieTemplate)); - this._updateLottieData(this.state.data, this.state.lottieTemplateNew); - this._applyDataToRenderedAnimation(this.state.data); - } - - _applyDataControlFlags(data = {}) { - // Preserve Ferryman control flags while keeping data application synchronous. - Object.keys(data || {}).forEach((key) => { - if (key === '_templateInfo') this.state.showInfo = true; - if (key === '_debug') this.state.showLogs = true; - if (key === 'epochID') { - this.state.imagesQuery = `?epoch=${encodeURIComponent(data[key])}`; - } - if (key === '_stopFetch') { - this.state.fetchSheet = false; - this._stopGoogleTablePolling(); - } - }); - } - - _applyDataToRenderedAnimation(data = {}) { - // Patch the currently rendered Lottie tree so goToTime can seek immediately. - const animation = this.state.animation; - const renderer = animation?.renderer; - if (!animation || !renderer || !data) return; - - animation.animationData = this.state.lottieTemplateNew; - renderer.data = this.state.lottieTemplateNew; - - Object.keys(data).forEach((key) => { - const imageRefIds = this._getImageRefIdsForKey(key); - this._updateRenderedLayer(renderer.elements, key, data[key], imageRefIds); - }); - } - - _updateRenderedLayer(elements, key, value, imageRefIds = new Set()) { - // Walk rendered layers recursively; Lottie precomps can nest editable fields. - if (!Array.isArray(elements)) return; - elements.forEach((el) => { - if (!el) return; - - const isNamedTarget = el.data?.nm === key; - const isImageTarget = el.data?.refId && imageRefIds.has(el.data.refId); - - if (isNamedTarget || isImageTarget) { - if (typeof el.updateDocumentData === 'function') { - this._updateRenderedLayerData(el.data, value); - el.updateDocumentData({ t: this._decodeHtmlEntities(value) }); - } else if (el.data.refId) { - this._updateRenderedImage(el, value); - } - } - - if (el.elements) this._updateRenderedLayer(el.elements, key, value, imageRefIds); - }); - } - - _updateRenderedLayerData(layer, value) { - // Keep Lottie's live layer data in sync so a same-frame render cannot revert text. - if (layer?.t?.d?.k?.[0]?.s) { - layer.t.d.k[0].s.t = this._decodeHtmlEntities(value); - } - } - - _updateRenderedImage(element, value) { - // Image layers need DOM href updates because Lottie text APIs do not cover assets. - const href = this._resolveImageHref(value); - if (!href) return; - - const candidates = [element.innerElem, element.layerElement, element.baseElement].filter( - Boolean - ); - - this._updateRenderedImageAssetData(element, value); - - candidates.forEach((node) => { - const imageNode = - node.tagName?.toLowerCase?.() === 'image' ? node : node.querySelector?.('image'); - if (imageNode) { - imageNode.setAttribute('href', href); - imageNode.setAttributeNS('http://www.w3.org/1999/xlink', 'href', href); - } - }); - } - - _updateRenderedImageAssetData(element, value) { - // Keep Lottie's live asset data aligned so the next seek cannot restore the old image. - if (!element?.assetData) return; - this._writeImageAssetData(element.assetData, value); - } - - _getImageRefIdsForKey(key) { - // Image fields are rendered through assets; collect every asset ref driven by this key. - const lottie = this.state.lottieTemplateNew || this.state.lottieTemplate; - const assets = Array.isArray(lottie?.assets) ? lottie.assets : []; - const refIds = new Set(); - const visitedAssetIds = Object.create(null); - const isImageAsset = (asset) => asset && !Array.isArray(asset.layers); - const walkLayers = (layers) => { - if (!Array.isArray(layers)) return; - - layers.forEach((layer) => { - if (!layer) return; - if (layer.nm === key && layer.refId) { - const refAsset = assets.find((asset) => asset?.id === layer.refId); - if (isImageAsset(refAsset)) refIds.add(layer.refId); - } - - if (layer.refId && !visitedAssetIds[layer.refId]) { - const asset = assets.find((candidate) => candidate?.id === layer.refId); - if (asset && Array.isArray(asset.layers)) { - visitedAssetIds[layer.refId] = true; - walkLayers(asset.layers); - } - } - }); - }; - - walkLayers(lottie?.layers); - return refIds; - } - - _resolveImageHref(value) { - const raw = String(value ?? '').trim(); - if (!raw) return ''; - if (/^(data:|blob:|https?:|file:)/i.test(raw)) return raw; - - const isPureFileName = !raw.includes('/') && !raw.includes('\\'); - const localImagePath = this._getLocalImagePath(raw); - const href = - isPureFileName || localImagePath - ? this._getImagesBaseUrl() + (localImagePath || raw) - : raw; - return this._appendImagesQuery(href); - } - - _getImagesBaseUrl() { - return this.state.imagesPathPrefix || IMAGES_BASE_URL; - } - - _getLocalImagePath(value) { - const normalized = String(value ?? '') - .trim() - .replace(/\\/g, '/') - .replace(/^\.\//, ''); - return normalized.startsWith(OGRAF_IMAGES_PATH_PREFIX) - ? normalized.slice(OGRAF_IMAGES_PATH_PREFIX.length) - : ''; - } - - _appendImagesQuery(href) { - const query = this.state.imagesQuery || ''; - if (!query) return href; - - const hashIndex = href.indexOf('#'); - const beforeHash = hashIndex === -1 ? href : href.slice(0, hashIndex); - const hash = hashIndex === -1 ? '' : href.slice(hashIndex); - const separator = beforeHash.includes('?') ? '&' : '?'; - return `${beforeHash}${separator}${query.replace(/^\?/, '')}${hash}`; - } - - _decodeHtmlEntities(encodedString) { - const textArea = document.createElement('textarea'); - textArea.innerHTML = encodedString; - return textArea.value; - } - - _updateLayerText(layer, newText) { - // Decode HTML entities before normalizing every supported line-break representation. - const decoded = this._decodeHtmlEntities(String(newText)); - const withLineBreaks = decoded - .replace(//gi, '\r') // HTML
tags - .replace(/\\n/g, '\r') // Literal \n strings - .replace(/\r\n|\n/g, '\r'); // Other newline formats - layer.t.d.k[0].s.t = withLineBreaks; - } - _updateAssetsByRefid(assets, id, value) { - const a = assets.find((x) => x.id === id); - if (!a) return; - - this._writeImageAssetData(a, value); - } - - _writeImageAssetData(asset, value) { - const raw = String(value ?? '').trim(); - if (!asset || !raw) return; - - if (/^(data:|blob:|https?:|file:)/i.test(raw)) { - asset.u = ''; - asset.p = raw; - asset.e = 1; - return; - } - - const isPureFileName = !raw.includes('/') && !raw.includes('\\'); - const localImagePath = this._getLocalImagePath(raw); - if (localImagePath) { - asset.u = this._getImagesBaseUrl(); - asset.p = this._appendImagesQuery(localImagePath); - asset.e = 0; - return; - } - - if (isPureFileName) { - asset.u = this._getImagesBaseUrl(); - asset.p = this._appendImagesQuery(raw); - asset.e = 0; - return; - } - - const lastSlash = Math.max(raw.lastIndexOf('/'), raw.lastIndexOf('\\')); - asset.u = lastSlash >= 0 ? raw.slice(0, lastSlash + 1) : ''; - asset.p = this._appendImagesQuery(lastSlash >= 0 ? raw.slice(lastSlash + 1) : raw); - asset.e = 0; - } - - _updateLottieData(data, lottie) { - if (!lottie || !data) return; - for (let key of Object.keys(data)) { - this._updateLayerInAllComps(lottie, key, data[key]); - } - } - - _hasLayerInAllComps(lottie, key) { - if (!lottie || !lottie.layers) return false; - const assets = Array.isArray(lottie.assets) ? lottie.assets : []; - const visitedAssetIds = Object.create(null); - const walkLayers = (layers) => { - if (!Array.isArray(layers)) return false; - for (let i = 0; i < layers.length; i += 1) { - const layer = layers[i]; - if (!layer) continue; - if (layer.nm === key) return true; - if (layer.refId && !visitedAssetIds[layer.refId]) { - const asset = assets.find((a) => a && a.id === layer.refId); - if (asset && Array.isArray(asset.layers)) { - visitedAssetIds[layer.refId] = true; - if (walkLayers(asset.layers)) return true; - } - } - } - return false; - }; - return walkLayers(lottie.layers); - } - - _updateLayerInAllComps(lottie, key, value) { - if (!lottie || !lottie.layers) return; - const assets = Array.isArray(lottie.assets) ? lottie.assets : []; - const visitedAssetIds = Object.create(null); - const isImageAsset = (asset) => asset && !Array.isArray(asset.layers); - const walkLayers = (layers) => { - if (!Array.isArray(layers)) return; - for (let i = 0; i < layers.length; i += 1) { - const layer = layers[i]; - if (!layer) continue; - if (layer.nm === key) { - if ('refId' in layer) { - const refAsset = assets.find((a) => a && a.id === layer.refId); - if (isImageAsset(refAsset)) { - this._updateAssetsByRefid(assets, layer.refId, value); - } - // Precomp ref: nothing to write at the layer level — content lives in - // the asset's layers, walked below if not already visited. - } else { - this._updateLayerText(layer, value); - } - } - if (layer.refId && !visitedAssetIds[layer.refId]) { - const asset = assets.find((a) => a && a.id === layer.refId); - if (asset && Array.isArray(asset.layers)) { - visitedAssetIds[layer.refId] = true; - walkLayers(asset.layers); - } - } - } - }; - walkLayers(lottie.layers); - } - - _loadFonts() { - return new Promise((resolve, reject) => { - const fontPromises = []; - - if ( - !this.state.lottieTemplate.fonts || - !this.state.lottieTemplate.fonts.list || - this.state.lottieTemplate.fonts.list.length === 0 - ) { - if (this.state.showLogs) console.log('No fonts found'); - resolve('No fonts found'); - } else { - const fonts = this.state.lottieTemplate.fonts.list; - for (const font of fonts) { - if (font.fPath !== '') { - fontPromises.push(new FontFace(font.fFamily, `url(${font.fPath})`).load()); - } - } +function normalizePoints(points) { + if (!Array.isArray(points) || points.length === 0) { + throw new Error('The Bar Chart requires at least one point.'); + } - Promise.all(fontPromises) - .then(() => resolve('Fonts loaded')) - .catch((err) => reject(err)); - } - }); + return points.map((point, index) => { + const value = Number(point?.value); + if (!Number.isFinite(value) || value < 0) { + throw new Error(`Point ${index + 1} must have a non-negative numeric value.`); } - - _addFont = (fam, path) => { - const newFont = document.createElement('style'); - newFont.appendChild( - document.createTextNode(`\ - @font-face {\ - font-family: ${fam};\ - src: url('${path}');\ - }\ - `) - ); - document.head.appendChild(newFont); - this.state.fontStyleNodes.push(newFont); + return { + label: String(point?.label ?? ''), + value }; - - _getMarker(markerName) { - const normalizedMarkerName = this._normalizeFerrymanTimelineMarkerName(markerName); - for (let marker of this.state.lottieTemplate.markers) { - if ( - marker.cm === markerName || - this._normalizeFerrymanTimelineMarkerName(marker.cm) === normalizedMarkerName - ) { - return marker; - } - } - } - - _getAnimationBounds(animation) { - const animationDataCandidates = [ - animation?.animationData, - animation?.renderer?.data, - this.state.lottieTemplateNew, - this.state.lottieTemplate, - ]; - const animationData = - animationDataCandidates.find( - (candidate) => - candidate && (Number.isFinite(candidate.ip) || Number.isFinite(candidate.op)) - ) || {}; - const startFrame = Number.isFinite(animationData.ip) ? Number(animationData.ip) : 0; - const outFrame = Number.isFinite(animationData.op) - ? Number(animationData.op) - : startFrame + 1; - return { - startFrame, - endFrame: Math.max(startFrame, outFrame - 1), - }; - } - - _getAbsoluteAnimationFrame(animation) { - if (!animation) return null; - const bounds = this._getAnimationBounds(animation); - const firstFrame = Number.isFinite(animation.firstFrame) - ? Number(animation.firstFrame) - : bounds.startFrame; - const localFrame = Number.isFinite(animation.currentFrame) - ? Number(animation.currentFrame) - : 0; - return this._clamp(Math.round(localFrame + firstFrame), bounds.startFrame, bounds.endFrame); - } - - _captureRealtimeActionState() { - return { - currentSegment: this.state.currentSegment, - currentFrame: this.state.currentFrame, - nextCount: this.state.nextCount, - startPlayed: this.state.startPlayed, - playStatus: this.state.playStatus, - pendingLoopMarker: this.state.pendingLoopMarker, - }; - } - - _restoreRealtimeActionState(actionState) { - if (!actionState) return; - this.state.currentSegment = actionState.currentSegment; - this.state.currentFrame = actionState.currentFrame; - this.state.nextCount = actionState.nextCount; - this.state.startPlayed = actionState.startPlayed; - this.state.playStatus = actionState.playStatus; - this.state.pendingLoopMarker = actionState.pendingLoopMarker; - } - - _abortPlaybackStart(animation) { - this.state.playbackEpoch++; - this.state.activePlaybackAnimation = null; - this.state.lottieState = 'idle'; - this.state._lastSegmentResolve = null; - this.state.segmentedAnimations?.delete?.(animation); - if (this.state.boundaryTimerId != null) { - clearTimeout(this.state.boundaryTimerId); - this.state.boundaryTimerId = null; - } - } - - _seekAbsoluteFrame(animation, targetFrame, { resetSegments = false } = {}) { - if (!animation || typeof animation.goToAndStop !== 'function') return null; - - let bounds = this._getAnimationBounds(animation); - const fullDuration = bounds.endFrame - bounds.startFrame + 1; - const firstFrame = Number.isFinite(animation.firstFrame) - ? Number(animation.firstFrame) - : bounds.startFrame; - const hasSegmentedDuration = - Number.isFinite(animation.totalFrames) && - Math.round(Number(animation.totalFrames)) !== Math.round(fullDuration); - const knownSegmentedAnimation = this.state.segmentedAnimations?.has?.(animation) === true; - const shouldResetSegments = - resetSegments && - typeof animation.resetSegments === 'function' && - (knownSegmentedAnimation || firstFrame !== bounds.startFrame || hasSegmentedDuration); - - if (shouldResetSegments) { - animation.resetSegments(true); - this.state.segmentedAnimations?.delete?.(animation); - bounds = this._getAnimationBounds(animation); - } - - const fallbackFrame = Number.isFinite(this.state.currentFrame) - ? Number(this.state.currentFrame) - : bounds.startFrame; - const absoluteFrame = this._clamp( - Number.isFinite(Number(targetFrame)) ? Number(targetFrame) : fallbackFrame, - bounds.startFrame, - bounds.endFrame - ); - const activeFirstFrame = Number.isFinite(animation.firstFrame) - ? Number(animation.firstFrame) - : bounds.startFrame; - animation.goToAndStop(absoluteFrame - activeFirstFrame, true); - if (animation === this.state.animation) this.state.currentFrame = absoluteFrame; - return absoluteFrame; - } - - _beginPlayback(animation) { - if (!animation) return this.state.playbackEpoch; - if (!this.state.segmentedAnimations) this.state.segmentedAnimations = new WeakSet(); - this.state.segmentedAnimations.add(animation); - this.state.playbackEpoch++; - this.state.activePlaybackAnimation = animation; - this.state.lottieState = 'play'; - return this.state.playbackEpoch; - } - - _schedulePlaybackBoundary(animation, epoch = this.state.playbackEpoch) { - if ( - this.state.renderType === 'non-realtime' || - this.state.playStatus === 'disposed' || - animation !== this.state.activePlaybackAnimation || - epoch !== this.state.playbackEpoch || - this.state.boundaryTimerId != null - ) { - return; - } - - this.state.boundaryTimerId = setTimeout(() => { - this.state.boundaryTimerId = null; - if ( - this.state.playStatus === 'disposed' || - animation !== this.state.activePlaybackAnimation || - epoch !== this.state.playbackEpoch - ) { - return; - } - this._completePlaybackBoundary(animation, epoch); - }, 0); - } - - _completePlaybackBoundary(animation, epoch) { - if ( - this.state.playStatus === 'disposed' || - animation !== this.state.activePlaybackAnimation || - epoch !== this.state.playbackEpoch - ) { - return; - } - - this.state.activePlaybackAnimation = null; - this.state.lottieState = 'idle'; - const segmentResolve = this.state._lastSegmentResolve; - this.state._lastSegmentResolve = null; - segmentResolve?.({ segment: this.state.currentSegment }); - Promise.resolve().then(() => this._advancePlaybackScheduler()); - } - - _advancePlaybackScheduler() { - if ( - this.state.renderType === 'non-realtime' || - this.state.playStatus === 'disposed' || - this.state.actionQueueRunning || - this.state.boundaryTimerId != null || - this.state.lottieState !== 'idle' - ) { - return; - } - - if ((this.state.actionQueue?.length || 0) > 0) { - this._drainActionQueue(); - return; - } - this._startPendingLoop(); - } - - _startPendingLoop() { - const marker = this.state.pendingLoopMarker; - if ( - !marker || - !this.state.animation || - this.state.playStatus === 'disposed' || - this.state.lottieState !== 'idle' || - this.state.actionQueueRunning || - (this.state.actionQueue?.length || 0) > 0 || - this.state.boundaryTimerId != null - ) { - return; - } - - const previousSegment = this.state.currentSegment; - try { - this.state.currentSegment = marker; - this._beginPlayback(this.state.animation); - this.state.animation.goToAndPlay(marker, true); - } catch (error) { - this._abortPlaybackStart(this.state.animation); - this.state.currentSegment = previousSegment; - this.state.pendingLoopMarker = null; - if (this.state.showLogs) console.error(`Loop marker ${marker} failed:`, error); - } - } - - _addEvents(animation) { - animation.addEventListener('enterFrame', () => { - if (animation === this.state.animation) { - this.state.currentFrame = this._getAbsoluteAnimationFrame(animation); - if (this.state.showLogs) console.log(`Current Frame: ${this.state.currentFrame}`); - } - }); - animation.addEventListener('_play', () => { - if ( - animation === this.state.animation || - animation === this.state.updateAnimation || - animation === this.state.newAnimation - ) { - this.state.lottieState = 'play'; - if (this.state.showLogs) console.log(`LottieState: ${this.state.lottieState}`); - } - }); - animation.addEventListener('_pause', () => { - if (animation !== this.state.activePlaybackAnimation) return; - this.state.lottieState = 'pause'; - this._schedulePlaybackBoundary(animation, this.state.playbackEpoch); - }); - animation.addEventListener('_idle', () => { - if ( - animation === this.state.animation || - animation === this.state.updateAnimation || - animation === this.state.newAnimation - ) { - this.state.lottieState = 'idle'; - } - }); - } - - _playMarker(markerName, { skip = false } = {}) { - if (!markerName || !this.state.animation) return; - - this.state.currentSegment = markerName; - const loopMarker = `${markerName}_loop`; - if (!markerName.includes('_loop') && this.state.markers.includes(loopMarker)) { - this.state.pendingLoopMarker = loopMarker; - } - - if (!skip) { - this._beginPlayback(this.state.animation); - this.state.animation.goToAndPlay(markerName, true); - return; - } - - const marker = this._getMarker(markerName); - if (!marker) return; - const startFrame = Number.isFinite(marker.tm) ? marker.tm : 0; - const duration = Number.isFinite(marker.dr) && marker.dr > 0 ? marker.dr : 1; - this.state.playbackEpoch++; - this.state.activePlaybackAnimation = null; - this._seekAbsoluteFrame(this.state.animation, startFrame + duration - 1, { - resetSegments: true, - }); - this.state.lottieState = 'idle'; - } - - _isGoogleTablePollingEnabled() { - const configuredInterval = Number(this.state.fetchInterval); - const cfg = this.state.googleTableCells; - const hasConfiguration = - (Array.isArray(cfg) && cfg.length > 0) || - (typeof cfg === 'string' && - cfg.trim() !== '' && - cfg.trim() !== 'null' && - cfg.trim() !== '[]'); - - return ( - this.state.renderType !== 'non-realtime' && - !this.state.disposeStarted && - this.state.playStatus !== 'disposed' && - this.state.fetchSheet && - Number.isFinite(configuredInterval) && - configuredInterval > 0 && - hasConfiguration - ); - } - - _scheduleGoogleTablePoll(delay = this.state.fetchInterval) { - if ( - !this._isGoogleTablePollingEnabled() || - this.state.googlePollRunning || - this.state.googlePollTimerId != null - ) { - return this.state.googlePollTimerId; - } - - const parsedDelay = Number(delay); - const delayMs = Number.isFinite(parsedDelay) - ? Math.max(0, parsedDelay) - : Math.max(0, Number(this.state.fetchInterval) || 0); - const timerId = setTimeout(() => { - if (this.state.googlePollTimerId !== timerId) return; - this.state.googlePollTimerId = null; - this.state._googleTimers = []; - void this._pollGoogleTableData(); - }, delayMs); - - this.state.googlePollTimerId = timerId; - // Retain this legacy collection for diagnostic consumers while owning only one timer. - this.state._googleTimers = [timerId]; - return timerId; - } - - _stopGoogleTablePolling() { - if (this.state.googlePollTimerId != null) { - clearTimeout(this.state.googlePollTimerId); - this.state.googlePollTimerId = null; - } - for (const id of this.state._googleTimers || []) { - clearTimeout(id); - clearInterval(id); - } - this.state._googleTimers = []; - } - - async _pollGoogleTableData() { - if (this.state.googlePollRunning || !this._isGoogleTablePollingEnabled()) { - return { statusCode: 200 }; - } - - this.state.googlePollRunning = true; - try { - const googleDataRevision = Number.isFinite(this.state.googleDataRevision) - ? this.state.googleDataRevision - : 0; - const tableCfg = await this._parseGoogleTable(); - if (!this._isGoogleTablePollingEnabled() || !Array.isArray(tableCfg)) { - return { statusCode: 200 }; - } - - const currentGoogleData = this._getGoogleTableDataSnapshot(this.state.data, tableCfg); - const fetchedData = await this._updateGoogleTableData(currentGoogleData); - - // A stop/dispose may have happened while the network request was pending. - if (!this._isGoogleTablePollingEnabled()) return { statusCode: 200 }; - const fetchedAtCurrentRevision = - (Number.isFinite(this.state.googleDataRevision) - ? this.state.googleDataRevision - : 0) === googleDataRevision; - if (!fetchedAtCurrentRevision) { - return { statusCode: 200 }; - } - - const fetchedGoogleData = this._getGoogleTableDataSnapshot(fetchedData, tableCfg); - const liveGoogleData = this._getGoogleTableDataSnapshot(this.state.data, tableCfg); - if (!this._hasGoogleTableDataChanged(fetchedGoogleData, liveGoogleData, tableCfg)) { - this.state.lastGoogleTableData = fetchedGoogleData; - return { statusCode: 200 }; - } - - const result = await this.updateAction({ - data: fetchedGoogleData, - _skipGoogleFetch: true, - _periodicGoogleUpdate: true, - _googleDataRevision: googleDataRevision, - }); - if ( - !this.state.disposeStarted && - this.state.playStatus !== 'disposed' && - result?.statusCode === 200 - ) { - this.state.lastGoogleTableData = this._getGoogleTableDataSnapshot( - this.state.data, - tableCfg - ); - if ( - (Number.isFinite(this.state.googleDataRevision) - ? this.state.googleDataRevision - : 0) === googleDataRevision && - this.state.showInfo - ) { - console.log('Periodically update triggered'); - } - } - return result; - } catch (error) { - if (this.state.showLogs) console.error('Periodic Google Sheets update failed:', error); - return { - statusCode: 500, - statusMessage: 'Periodic Google Sheets update failed.', - }; - } finally { - this.state.googlePollRunning = false; - this._scheduleGoogleTablePoll(); - } - } - - _getGoogleTableKeys(tableCfg = this.state.googleTableCells) { - let parsedConfig = tableCfg; - if (typeof parsedConfig === 'string') { - try { - parsedConfig = JSON.parse(parsedConfig); - } catch { - parsedConfig = []; - } - } - if (!Array.isArray(parsedConfig)) return []; - - return [ - ...new Set( - parsedConfig - .map((entry) => (typeof entry?.key === 'string' ? entry.key : '')) - .filter(Boolean) - ), - ]; - } - - _getGoogleTableDataSnapshot(dataObj = {}, tableCfg = this.state.googleTableCells) { - const source = dataObj && typeof dataObj === 'object' ? dataObj : {}; - return this._getGoogleTableKeys(tableCfg).reduce((snapshot, key) => { - snapshot[key] = source[key]; - return snapshot; - }, {}); - } - - _hasGoogleTableDataChanged(nextData = {}, currentData = {}, tableCfg) { - const configuredKeys = this._getGoogleTableKeys(tableCfg); - const keys = - configuredKeys.length > 0 - ? configuredKeys - : [...new Set([...Object.keys(nextData || {}), ...Object.keys(currentData || {})])]; - return keys.some((key) => !Object.is(nextData?.[key], currentData?.[key])); - } - - async _parseGoogleTable() { - const cfg = this.state.googleTableCells; - if (!cfg) return null; - - if (Array.isArray(cfg)) return cfg; - if (typeof cfg === 'object') return cfg; - - if (typeof cfg === 'string') { - const trimmed = cfg.trim(); - if (!trimmed || trimmed === 'null' || trimmed === '[]') return null; - try { - return JSON.parse(trimmed); - } catch { - return null; - } - } - return null; - } - - async _fetchDataFromGoogle(url) { - try { - const resp = await fetch(url, { - cache: 'no-store', - headers: { - 'Cache-Control': 'no-cache', - Pragma: 'no-cache', - }, - }); - if (!resp.ok) { - console.warn(`Error on fetching googlesheets data. Status: ${resp.status}`); - return null; - } - const tsv = await resp.text(); - return tsv.split('\n').map((row) => row.split('\t')); - } catch (err) { - console.error('Error collecting data from Google:', err); - return null; - } - } - - _getCellData(a1, data) { - try { - const ref = String(a1 || '') - .trim() - .replace(/\$/g, '') - .toUpperCase(); - const colMatch = ref.match(/[A-Z]+/g); - const rowMatch = ref.match(/[0-9]+/g); - if (!colMatch || !rowMatch) return undefined; - const colLetters = colMatch[0]; - const base = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - let rowIx = parseInt(rowMatch[0], 10) - 1; - let colIx = 0; - for (let i = 0; i < colLetters.length; i++) { - colIx = colIx * 26 + base.indexOf(colLetters[i]) + 1; - } - colIx -= 1; - - let cellValue = data[rowIx] ? data[rowIx][colIx] : undefined; - if (cellValue && cellValue.startsWith('"') && cellValue.endsWith('"')) { - cellValue = cellValue.slice(1, -1); - } - if (cellValue) { - cellValue = cellValue.replace(/\r/g, '').replace(/\n/g, ''); - } - return cellValue; - } catch (e) { - console.log('Error getCellData from GoogleSheet:', e); - } - } - - async _updateGoogleTableData(dataObj) { - try { - let data = dataObj && typeof dataObj === 'object' ? { ...dataObj } : {}; - const tableCfg = await this._parseGoogleTable(); - if (!Array.isArray(tableCfg) || tableCfg.length === 0) return data; - - const sources = {}; - const cacheBust = Date.now(); - for (const element of tableCfg) { - const sheetId = encodeURIComponent(String(element.id || '').trim()); - const gid = encodeURIComponent(String(element.sheet ?? '0').trim() || '0'); - const sheetURL = `https://docs.google.com/spreadsheets/d/${sheetId}/export?format=tsv&gid=${gid}&_=${cacheBust}`; - const sourceKey = `${sheetId}:${gid}`; - let tsvArray = sources[sourceKey]; - if (!Object.prototype.hasOwnProperty.call(sources, sourceKey)) { - tsvArray = await this._fetchDataFromGoogle(sheetURL); - sources[sourceKey] = tsvArray; - } - if (tsvArray && tsvArray.length > 0) { - const cellRef = String(element.cell || '').trim(); - const value = this._getCellData(cellRef, tsvArray); - if (typeof value !== 'undefined') { - data[element.key] = value; - if (this.state.showLogs) { - console.log( - '[GoogleFetch] set', - element.key, - 'from', - cellRef, - '=', - value - ); - } - } else if (this.state.showLogs) { - console.warn('[GoogleFetch] no value for', element.key, 'cell', cellRef); - } - } - } - return data; - } catch (e) { - console.log(e); - return dataObj || {}; - } - } - - async _parseClocks() { - const cfg = this._getReplacement('clocks'); - if (!cfg) return null; - - if (typeof cfg === 'object') return cfg; - if (typeof cfg === 'string') { - const t = cfg.trim(); - if (!t || t === 'null' || t === '{}') return null; - try { - return JSON.parse(t); - } catch { - return null; - } - } - return null; - } - - _getTime(type, addSeconds = 0) { - const formats = { - clock1: { hour: '2-digit', minute: '2-digit', hour12: false }, - clock2: { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }, - clock3: { hour: '2-digit', minute: '2-digit', hour12: true }, - clock4: { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true }, - clock5: { hour: '2-digit', minute: '2-digit', hour12: true }, - clock6: { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true }, - }; - const now = new Date(); - now.setSeconds(now.getSeconds() + (addSeconds || 0)); - let out = new Intl.DateTimeFormat('en-GB', formats[type] || formats.clock1).format(now); - if (type === 'clock5' || type === 'clock6') out = out.replace(/\s?(AM|PM)$/i, ''); - return out; - } - - _updateClocksOn(animation) { - if (!animation || !animation.isLoaded || !this.state.clocks) return; - const clocks = this.state.clocks; - Object.keys(clocks).forEach((clk) => { - const time = this._getTime(clk); - const layers = clocks[clk] || []; - const renderer = animation.renderer; - if (!renderer || !renderer.elements) return; - renderer.elements.forEach((el) => { - if (el && el.data && el.data.nm && typeof el.updateDocumentData === 'function') { - if (layers.includes(el.data.nm)) el.updateDocumentData({ t: time }); - } - }); - }); - - if (this.state.lottieState === 'idle') { - const saveFrame = this._getAbsoluteAnimationFrame(animation); - if (animation.renderer && typeof animation.renderer.renderFrame === 'function') { - try { - const bounds = this._getAnimationBounds(animation); - const bumpFrame = - saveFrame < bounds.endFrame - ? saveFrame + 1 - : Math.max(saveFrame - 1, bounds.startFrame); - if (bumpFrame !== saveFrame) animation.renderer.renderFrame(bumpFrame); - animation.renderer.renderFrame(saveFrame); - } catch (e) { - console.warn('Clock re-render error:', e); - } - } - } - } - - _startClocks() { - if (this.state.renderType === 'non-realtime') return; - if (!this.state.clocks || Object.keys(this.state.clocks).length === 0) return; - - (this.state.clockIntervalIds || []).forEach((id) => clearInterval(id)); - this.state.clockIntervalIds = []; - - const id = setInterval(() => { - this._updateClocksOn(this.state.animation); - this._updateClocksOn(this.state.newAnimation); - this._updateClocksOn(this.state.updateAnimation); - }, 100); - this.state.clockIntervalIds.push(id); - - this._updateClocksOn(this.state.animation); - this._updateClocksOn(this.state.newAnimation); - this._updateClocksOn(this.state.updateAnimation); - } - - _stopClocks() { - (this.state.clockIntervalIds || []).forEach((id) => clearInterval(id)); - this.state.clockIntervalIds = []; - } - - _parseTimers() { - const cfg = this._getReplacement('timers'); - if (!cfg) return []; - if (Array.isArray(cfg)) return cfg; - if (typeof cfg === 'string') { - const t = cfg.trim(); - if (!t || t === 'null' || t === '[]') return []; - try { - return JSON.parse(t); - } catch { - return []; - } - } - return []; - } - - _parseStopwatches() { - const cfg = this._getReplacement('stopwatches'); - if (!cfg) return []; - if (Array.isArray(cfg)) return cfg; - if (typeof cfg === 'string') { - const t = cfg.trim(); - if (!t || t === 'null' || t === '[]') return []; - try { - return JSON.parse(t); - } catch { - return []; - } - } - return []; - } - - _formatTimer(timer, format) { - let negative = timer < 0; - timer = Math.abs(timer); - - let hours = Math.floor(timer / 3600); - let minutes = Math.floor((timer % 3600) / 60); - let seconds = timer % 60; - - function pad(num) { - return (num < 10 ? '0' : '') + num; - } - - let result; - switch (format) { - case 's': - result = String(seconds); - break; - case 'ss': - result = pad(seconds); - break; - case 'm:ss': - result = minutes + ':' + pad(seconds); - break; - case 'mm:ss': - result = pad(minutes) + ':' + pad(seconds); - break; - case 'h:mm:ss': - result = hours + ':' + pad(minutes) + ':' + pad(seconds); - break; - case 'hh:mm:ss': - result = pad(hours) + ':' + pad(minutes) + ':' + pad(seconds); - break; - default: - result = pad(minutes) + ':' + pad(seconds); - break; - } - - return negative ? '-' + result : result; - } - - _updateTimersOn(animation) { - if (!animation || !animation.isLoaded) return; - const renderer = animation.renderer; - if (!renderer || !renderer.elements) return; - - const allItems = [...(this.state.timers || []), ...(this.state.stopwatches || [])]; - if (allItems.length === 0) return; - - allItems.forEach((item) => { - const time = this._formatTimer(item.time, item.format); - const layers = item.layers || []; - renderer.elements.forEach((el) => { - if (el && el.data && el.data.nm && typeof el.updateDocumentData === 'function') { - if (layers.includes(el.data.nm)) el.updateDocumentData({ t: time }); - } - }); - }); - - if (this.state.lottieState === 'idle') { - const saveFrame = this._getAbsoluteAnimationFrame(animation); - if (animation.renderer && typeof animation.renderer.renderFrame === 'function') { - try { - const bounds = this._getAnimationBounds(animation); - const bumpFrame = - saveFrame < bounds.endFrame - ? saveFrame + 1 - : Math.max(saveFrame - 1, bounds.startFrame); - if (bumpFrame !== saveFrame) animation.renderer.renderFrame(bumpFrame); - animation.renderer.renderFrame(saveFrame); - } catch (e) { - console.warn('Timer re-render error:', e); - } - } - } - } - - _startTimers() { - if (this.state.renderType === 'non-realtime') return; - this._clearTimerDisplayIntervals(); - - // Refresh timer text every 100 ms without changing timer state. - const displayId = setInterval(() => { - this._updateTimersOn(this.state.animation); - this._updateTimersOn(this.state.newAnimation); - this._updateTimersOn(this.state.updateAnimation); - }, 100); - this.state.timerIntervalIds.push(displayId); - - // Render once immediately instead of waiting for the first interval tick. - this._updateTimersOn(this.state.animation); - this._updateTimersOn(this.state.newAnimation); - this._updateTimersOn(this.state.updateAnimation); - } - - _clearTimerDisplayIntervals() { - (this.state.timerIntervalIds || []).forEach((id) => clearInterval(id)); - this.state.timerIntervalIds = []; - } - - _stopTimers() { - this._clearTimerDisplayIntervals(); - - // Clear individual timer and stopwatch countdown intervals. - (this.state.timers || []).forEach((timer) => { - if (timer.id !== null) { - clearInterval(timer.id); - timer.id = null; - } - }); - (this.state.stopwatches || []).forEach((sw) => { - if (sw.id !== null) { - clearInterval(sw.id); - sw.id = null; - } - }); - } - - _getReplacement(attr) { - return replacements[attr]; - } - - _resolveAndClear(payload) { - this.state.lottieState = 'idle'; - if (this.state._lastSegmentResolve) { - this.state._lastSegmentResolve(payload); - this.state._lastSegmentResolve = null; - } - } - - _enqueueAction(actionName, action) { - if (this.state.playStatus === 'disposed') { - return Promise.resolve( - this._createActionErrorPayload(actionName, 410, 'Graphic has been disposed.') - ); - } - const deferred = this._createDeferredPromise(); - if (!Array.isArray(this.state.actionQueue)) this.state.actionQueue = []; - this.state.actionQueue.push({ actionName, action, resolve: deferred.resolve }); - if (this.state.renderType === 'non-realtime') { - this._drainActionQueue(); - } else { - this._advancePlaybackScheduler(); - } - return deferred.promise; - } - - _drainActionQueue() { - if (this.state.actionQueueRunning) return; - const queuedAction = this.state.actionQueue?.[0]; - if (!queuedAction) return; - if ( - this.state.renderType !== 'non-realtime' && - (this.state.lottieState !== 'idle' || this.state.boundaryTimerId != null) - ) { - return; - } - this.state.actionQueue.shift(); - - if (queuedAction.actionName === 'playAction' || queuedAction.actionName === 'stopAction') { - this.state.pendingLoopMarker = null; - } - - this.state.actionQueueRunning = true; - this.state.activeActionEntry = queuedAction; - const finish = (payload) => { - if (this.state.activeActionEntry !== queuedAction) return; - this.state.activeActionEntry = null; - this.state.actionQueueRunning = false; - queuedAction.resolve(this._normalizeActionPayload(queuedAction.actionName, payload)); - Promise.resolve().then(() => { - if (this.state.renderType === 'non-realtime') { - this._drainActionQueue(); - } else { - this._advancePlaybackScheduler(); - } - }); - }; - - if (this.state.playStatus === 'disposed') { - finish( - this._createActionErrorPayload( - queuedAction.actionName, - 410, - 'Graphic has been disposed.' - ) - ); - return; - } - - let result; - try { - result = queuedAction.action(); - } catch (error) { - if (this.state.showLogs) console.error(`${queuedAction.actionName} failed:`, error); - finish( - this._createActionErrorPayload( - queuedAction.actionName, - 500, - `${queuedAction.actionName} failed.` - ) - ); - return; - } - - Promise.resolve(result).then(finish, (error) => { - if (this.state.showLogs) console.error(`${queuedAction.actionName} failed:`, error); - finish( - this._createActionErrorPayload( - queuedAction.actionName, - 500, - `${queuedAction.actionName} failed.` - ) - ); - }); - } - - _normalizeActionPayload(actionName, payload) { - if (payload === undefined) { - const successPayload = { statusCode: 200 }; - if (actionName === 'playAction') { - successPayload.currentStep = this.state.startPlayed - ? this.state.nextCount - 1 - : undefined; - } - return successPayload; - } - if (payload && typeof payload === 'object' && Number.isFinite(payload.statusCode)) { - return payload; - } - return this._createActionErrorPayload( - actionName, - 500, - `${actionName} returned an invalid payload.` - ); - } - - _createActionErrorPayload(actionName, statusCode, statusMessage) { - const payload = { statusCode, statusMessage }; - if (actionName === 'playAction') { - payload.currentStep = this.state.startPlayed ? this.state.nextCount - 1 : undefined; - } - return payload; - } - - _createDeferredPromise() { - let resolver, rejecter; - const promise = new Promise((resolve, reject) => { - resolver = resolve; - rejecter = reject; - }); - return { - promise, - resolve: resolver, - reject: rejecter, - }; - } - - _clamp(n, min, max) { - return Math.min(Math.max(n, min), max); - } + }); } -export default FerrymanGraphic; +export default class BarChartGraphic extends HTMLElement { + constructor() { + super(); + this._shadow = this.attachShadow({ mode: 'open' }); + this._currentStep = undefined; + this._points = []; + this._backgroundImage = ''; + } + + async load({ data = {} } = {}) { + this._points = normalizePoints(data.points); + this._backgroundImage = String(data.backgroundImage ?? './lib/background.svg'); + this._currentStep = undefined; + this._buildDOM(data.title ?? 'Bar Chart Graphic'); + return { statusCode: 200 }; + } + + async updateAction({ data = {}, skipAnimation = false } = {}) { + if (data.points !== undefined) this._points = normalizePoints(data.points); + if (data.backgroundImage !== undefined) { + this._backgroundImage = String(data.backgroundImage); + const image = this._shadow.querySelector('.background-image'); + if (image) image.src = this._backgroundImage; + } + if (data.title !== undefined) { + const title = this._shadow.querySelector('h1'); + if (title) title.textContent = String(data.title); + } + this._renderPoints(this._currentStep, skipAnimation); + return { statusCode: 200 }; + } + + async playAction(params = {}) { + const rawTarget = resolveTargetStep(this._currentStep, params, this._points.length); + if (rawTarget >= this._points.length) { + await this.stopAction(params); + return { statusCode: 200, currentStep: undefined }; + } + + const targetStep = Math.max(0, rawTarget); + const chart = this._shadow.querySelector('.chart'); + const wasVisible = chart?.classList.contains('is-visible'); + this._currentStep = targetStep; + this._renderPoints(targetStep, params.skipAnimation); + + if (!wasVisible && chart) { + chart.classList.add('is-visible'); + if (!params.skipAnimation) await this._wait(ENTER_DURATION); + } else if (!params.skipAnimation) { + await this._wait(STEP_DURATION); + } + + return { statusCode: 200, currentStep: targetStep }; + } + + async stopAction({ skipAnimation = false } = {}) { + const chart = this._shadow.querySelector('.chart'); + if (chart && !skipAnimation && chart.classList.contains('is-visible')) { + chart.classList.add('is-exiting'); + await this._wait(EXIT_DURATION); + } + chart?.classList.remove('is-visible', 'is-exiting'); + this._currentStep = undefined; + return { statusCode: 200 }; + } + + async dispose() { + this._shadow.innerHTML = ''; + this._points = []; + this._currentStep = undefined; + return { statusCode: 200 }; + } + + _buildDOM(title) { + this._shadow.innerHTML = ` + +
+

+
+
`; + this._shadow.querySelector('h1').textContent = title; + this._shadow.querySelector('.background-image').src = this._backgroundImage; + this._renderPoints(undefined, true); + } + + _renderPoints(step, skipAnimation) { + const plot = this._shadow.querySelector('.plot'); + if (!plot) return; + + const maxValue = Math.max(...this._points.map(point => point.value), 1); + plot.innerHTML = this._points.map((point, index) => { + const height = `${(point.value / maxValue) * 82}%`; + const revealed = Number.isInteger(step) && index <= step; + return `
+ ${point.value} +
+ ${point.label} +
`; + }).join(''); + + if (skipAnimation) { + plot.querySelectorAll('.bar').forEach(bar => { + bar.style.transition = 'none'; + }); + } + } + + _wait(duration) { + return new Promise(resolve => window.setTimeout(resolve, duration)); + } +} diff --git a/v1/examples/bar-chart/lib/background.svg b/v1/examples/bar-chart/lib/background.svg new file mode 100644 index 0000000..3637176 --- /dev/null +++ b/v1/examples/bar-chart/lib/background.svg @@ -0,0 +1,6 @@ + + + + + +