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