Data

A scene can read rows from a file through a query and wait for them before the first frame. You build a bar chart race from a JSON file, and captions from a voice track's transcript and beat file.

A chart from data

The next clip is a bar chart race, whose bars change length and swap places as the years pass.

It loads its rows from a file with wildflower.query, which only the full tier of WildflowerJS has, so change the first script to wildflower.full.min.js.

The chart reads data/sales.json, in a data folder next to the page, with one row for each product line. A row has an id, a name, a color and a values array with one number for each year.

{
    "rows": [
        { "id": "meadow",  "name": "Meadow",  "color": "#7c8cff", "values": [820000, 910000, 1040000, 1180000, 1250000, 1310000] },
        { "id": "orchard", "name": "Orchard", "color": "#ff9f5a", "values": [640000, 700000, 690000, 720000, 980000, 1420000] },
        { "id": "harbor",  "name": "Harbor",  "color": "#5fc8d8", "values": [1100000, 1050000, 980000, 900000, 870000, 820000] }
    ]
}

The chart clip starts at 6 seconds, after the title card's clip, so the root's data-duration becomes 14. Add the clip after the title card, inside the root.

<div class="chart" data-component="sales-chart" data-start="6" data-duration="8">
    <div class="rows" data-list="bars" data-key="id">
        <template>
            <div class="row" data-bind-style="style">
                <span class="name" data-bind="name"></span>
                <div class="bar" data-bind-style="bar"></div>
                <b class="val" data-bind="shown"></b>
            </div>
        </template>
    </div>
</div>
.chart { position: absolute; inset: 0; }
.rows { position: absolute; left: 80px; right: 80px; top: 120px; }
.row { position: absolute; left: 0; right: 0; top: 0; height: 56px; display: flex; align-items: center; }
.name { width: 180px; padding-right: 20px; text-align: right; font-size: 22px; }
.bar { height: 36px; border-radius: 6px; }
.val { margin-left: 14px; font-size: 20px; }

Add the scene to the end of scenes.js.

const { interpolate, rank, ticks, format, styleAt } = wildflower.kinetic;

wildflower.query('sales', {
    from: 'data/sales.json',
    select: (data) => data.rows
});

const money = (v) => format(v, { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 });

scene('sales-chart', {
    waitFor: ['sales'],
    computed: {
        rows() { return wildflower.getQuery('sales').rows; },
        scaleMax() {
            const max = Math.max(0, ...this.rows.map((r) => Math.max(...r.values)));
            const axis = ticks(0, max || 1, 5);
            return axis[axis.length - 1];
        },
        year() { return interpolate(this.local, [1, 7], [0, 5]); },
        bars() {
            return rank(this.year, this.rows, (r, k) => r.values[k]).map(({ row, value, position }) => ({
                id: row.id,
                name: row.name,
                shown: money(value),
                style: styleAt(this.local, { translateY: position * 56 }),
                bar: { width: (700 * value / this.scaleMax).toFixed(2) + 'px', background: row.color }
            }));
        }
    }
});

waitFor: ['sales'] holds frame 0 until the query has its rows, and Kinetic also waits for fonts to load and for the images under the scenes to decode. The computeds still run once when the scene is set up, before the rows arrive, so each one has to cope with no rows: these do (no rows, no bars), while one that reads a single row, such as this.rows[0].values, needs a guard for the empty case.

HelperWhat it does
ticks(min, max, count)Returns about count round values from at or below min to at or above max. The last one is the top of the scale, so the bars keep one scale through the race. A small count can pick a coarse step and a top well above max, leaving part of the chart empty; 4 to 6 usually fits. To draw an axis, place a line for each tick at 700 * v / scaleMax, the bars' own scale, labeled with format(v, { notation: 'compact' }) for values such as 2M.
format(n, options)Intl.NumberFormat in the en-US locale unless locale says otherwise, so a number reads the same on every render machine.
interpolate(t, at, to)Maps a time to a value as a track does, and holds the first and last value outside the range. year goes from 0 to 5 as local goes from 1 to 7 seconds.
rank(t, rows, by)Orders the rows for a race. by(row, k) gives a row's value at keyframe k, here one year.

At a whole number of t each bar sits at its rank for that year. Between two years its value and position blend the two years' values and ranks, so a bar that overtakes another moves through the crossing.

Two errors stop the page from becoming ready.

  • A query that ends in an error prints a console line that starts [KN-131].
  • A waitFor name that matches no query prints [KN-133].

In both cases check() reports readiness failed, and a renderer that waits on readiness stops at its timeout instead of capturing frames that have no data.

<div data-composition-id="main" data-start="0" data-duration="14"
     data-width="1280" data-height="720" data-fps="30">
    <div class="backdrop" data-component="backdrop" data-start="0" data-duration="6"></div>
    <div class="card" data-component="title-card" data-start="0.4" data-duration="5">
        <h1 data-list="letters" data-key="key">
            <template><span class="ch" data-bind="text" data-bind-style="style"></span></template>
        </h1>
        <p class="sub" data-bind="typed"></p>
    </div>
    <div class="chart" data-component="sales-chart" data-start="6" data-duration="8">
        <div class="rows" data-list="bars" data-key="id">
            <template>
                <div class="row" data-bind-style="style">
                    <span class="name" data-bind="name"></span>
                    <div class="bar" data-bind-style="bar"></div>
                    <b class="val" data-bind="shown"></b>
                </div>
            </template>
        </div>
    </div>
</div>
const { scene, split, styleAt, stagger, typewriter } = wildflower.kinetic;

const RISE = {
    opacity:    { at: [0, 0.35], to: [0, 1] },
    translateY: { at: [0, 0.6], to: [48, 0], ease: 'spring' },
    scale:      { at: [0, 0.6], to: [0.9, 1], ease: 'power2.out' }
};

scene('title-card', {
    computed: {
        letters() {
            const rows = split('WILDFLOWER');
            return rows.map((r) => ({
                key: r.key,
                text: r.text,
                style: r.space ? {} : styleAt(this.local - 0.2 - stagger(r.i, rows.length, { each: 0.06 }), RISE)
            }));
        },
        typed() { return typewriter(this.local - 1, 'Every frame is a function of time.', 28); }
    }
});

scene('backdrop', {
    keyframes: {
        bg: { at: [0, 2.6, 5.6], to: ['#0b0f1a', '#181030', '#0b0f1a'] }
    },
    root: { style: { background: 'bg' } }
});

const { interpolate, rank, ticks, format } = wildflower.kinetic;

wildflower.query('sales', {
    from: 'data/sales.json',
    select: (data) => data.rows
});

const money = (v) => format(v, { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 });

scene('sales-chart', {
    waitFor: ['sales'],
    computed: {
        rows() { return wildflower.getQuery('sales').rows; },
        scaleMax() {
            const max = Math.max(0, ...this.rows.map((r) => Math.max(...r.values)));
            const axis = ticks(0, max || 1, 5);
            return axis[axis.length - 1];
        },
        year() { return interpolate(this.local, [1, 7], [0, 5]); },
        bars() {
            return rank(this.year, this.rows, (r, k) => r.values[k]).map(({ row, value, position }) => ({
                id: row.id,
                name: row.name,
                shown: money(value),
                style: styleAt(this.local, { translateY: position * 56 }),
                bar: { width: (700 * value / this.scaleMax).toFixed(2) + 'px', background: row.color }
            }));
        }
    }
});
html, body { margin: 0; }
[data-composition-id] {
    position: relative;
    overflow: hidden;
    background: #0b0f1a;
    color: #fff;
    font-family: system-ui, sans-serif;
}
[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
.card {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
}
.card h1 { margin: 0; font-size: 136px; }
.sub { margin: 28px 0 0; font-size: 34px; min-height: 1.3em; }
.ch { display: inline-block; white-space: pre; }
.backdrop { position: absolute; inset: 0; }
.chart { position: absolute; inset: 0; }
.rows { position: absolute; left: 80px; right: 80px; top: 120px; }
.row { position: absolute; left: 0; right: 0; top: 0; height: 56px; display: flex; align-items: center; }
.name { width: 180px; padding-right: 20px; text-align: right; font-size: 22px; }
.bar { height: 36px; border-radius: 6px; }
.val { margin-left: 14px; font-size: 20px; }
The chart

Captions from a voice track

A voice track gives two files, and both load through queries.

  • A word-timed transcript, a list of words with text, start and end in seconds, which transcription tools write.
  • A beat file, an object whose beats array holds { time, strength } entries.

The captions clip starts at 14 seconds, where the chart ends, so the root's data-duration becomes 27. Add the clip after the chart, inside the root.

<div class="captions" data-component="captions" data-start="14" data-duration="13">
    <div class="disc" data-bind-style="disc"></div>
    <p class="caption" data-list="lineWords" data-key="key">
        <template><span class="word" data-bind="text" data-bind-style="style"></span></template>
    </p>
</div>
.captions { position: absolute; inset: 0; }
.disc {
    position: absolute;
    left: 50%;
    top: 40%;
    width: 180px;
    height: 180px;
    margin: -90px 0 0 -90px;
    border-radius: 50%;
    background: #9d7bff;
}
.caption {
    position: absolute;
    left: 80px;
    right: 80px;
    bottom: 96px;
    margin: 0;
    min-height: 96px;
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 0 18px;
}
.word { font-size: 62px; font-weight: 700; line-height: 1.2; }

Add the scene to the end of scenes.js, and put the two files in data/. The scene reads local, so the transcript's times count from the start of the clip.

const { captions, beat, styleAt } = wildflower.kinetic;

wildflower.query('words', { from: 'data/transcript.json' });
wildflower.query('beats', { from: 'data/beats.json', select: (file) => file.beats });

scene('captions', {
    waitFor: ['words', 'beats'],
    computed: {
        line() { return captions(this.local, wildflower.getQuery('words').rows, { maxWords: 4, hold: 0.5 }); },
        pulse() { return beat(this.local, wildflower.getQuery('beats').rows); },
        lineWords() {
            return this.line.words.map((w) => ({
                key: w.key,
                text: w.text,
                style: {
                    opacity: String(w.state === 'future' ? 0.3 : 1),
                    color: w.state === 'active' ? '#c9b8ff' : '#fff'
                }
            }));
        },
        disc() { return styleAt(this.local, { scale: 1 + 0.14 * this.pulse }); }
    }
});

captions(t, words, options) groups the words into lines and returns the line current at t. A line stays until the next one starts, and for at most hold seconds after its last word.

  • A line ends at the end of a sentence, at a silence longer than maxGap seconds (1 by default), or after maxWords words (5 by default).
  • Each word of the line has a state of past, active or future, and p, its own progress from 0 to 1.
  • Between lines words is empty, so a list over it draws nothing.

beat(t, beats) returns a pulse from 0 to 1, the largest of the beats' strengths, each decayed by e^(-elapsed / decay) with decay at 0.2 seconds by default. The disc grows by up to 14 percent on each beat.

The clock alone drives the captions and the pulse, and the page plays no audio.

<div data-composition-id="main" data-start="0" data-duration="27"
     data-width="1280" data-height="720" data-fps="30">
    <div class="backdrop" data-component="backdrop" data-start="0" data-duration="6"></div>
    <div class="card" data-component="title-card" data-start="0.4" data-duration="5">
        <h1 data-list="letters" data-key="key">
            <template><span class="ch" data-bind="text" data-bind-style="style"></span></template>
        </h1>
        <p class="sub" data-bind="typed"></p>
    </div>
    <div class="chart" data-component="sales-chart" data-start="6" data-duration="8">
        <div class="rows" data-list="bars" data-key="id">
            <template>
                <div class="row" data-bind-style="style">
                    <span class="name" data-bind="name"></span>
                    <div class="bar" data-bind-style="bar"></div>
                    <b class="val" data-bind="shown"></b>
                </div>
            </template>
        </div>
    </div>
    <div class="captions" data-component="captions" data-start="14" data-duration="13">
        <div class="disc" data-bind-style="disc"></div>
        <p class="caption" data-list="lineWords" data-key="key">
            <template><span class="word" data-bind="text" data-bind-style="style"></span></template>
        </p>
    </div>
</div>
const { scene, split, styleAt, stagger, typewriter } = wildflower.kinetic;

const RISE = {
    opacity:    { at: [0, 0.35], to: [0, 1] },
    translateY: { at: [0, 0.6], to: [48, 0], ease: 'spring' },
    scale:      { at: [0, 0.6], to: [0.9, 1], ease: 'power2.out' }
};

scene('title-card', {
    computed: {
        letters() {
            const rows = split('WILDFLOWER');
            return rows.map((r) => ({
                key: r.key,
                text: r.text,
                style: r.space ? {} : styleAt(this.local - 0.2 - stagger(r.i, rows.length, { each: 0.06 }), RISE)
            }));
        },
        typed() { return typewriter(this.local - 1, 'Every frame is a function of time.', 28); }
    }
});

scene('backdrop', {
    keyframes: {
        bg: { at: [0, 2.6, 5.6], to: ['#0b0f1a', '#181030', '#0b0f1a'] }
    },
    root: { style: { background: 'bg' } }
});

const { interpolate, rank, ticks, format } = wildflower.kinetic;

wildflower.query('sales', {
    from: 'data/sales.json',
    select: (data) => data.rows
});

const money = (v) => format(v, { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 });

scene('sales-chart', {
    waitFor: ['sales'],
    computed: {
        rows() { return wildflower.getQuery('sales').rows; },
        scaleMax() {
            const max = Math.max(0, ...this.rows.map((r) => Math.max(...r.values)));
            const axis = ticks(0, max || 1, 5);
            return axis[axis.length - 1];
        },
        year() { return interpolate(this.local, [1, 7], [0, 5]); },
        bars() {
            return rank(this.year, this.rows, (r, k) => r.values[k]).map(({ row, value, position }) => ({
                id: row.id,
                name: row.name,
                shown: money(value),
                style: styleAt(this.local, { translateY: position * 56 }),
                bar: { width: (700 * value / this.scaleMax).toFixed(2) + 'px', background: row.color }
            }));
        }
    }
});

const { captions, beat } = wildflower.kinetic;

wildflower.query('words', { from: 'data/transcript.json' });
wildflower.query('beats', { from: 'data/beats.json', select: (file) => file.beats });

scene('captions', {
    waitFor: ['words', 'beats'],
    computed: {
        line() { return captions(this.local, wildflower.getQuery('words').rows, { maxWords: 4, hold: 0.5 }); },
        pulse() { return beat(this.local, wildflower.getQuery('beats').rows); },
        lineWords() {
            return this.line.words.map((w) => ({
                key: w.key,
                text: w.text,
                style: {
                    opacity: String(w.state === 'future' ? 0.3 : 1),
                    color: w.state === 'active' ? '#c9b8ff' : '#fff'
                }
            }));
        },
        disc() { return styleAt(this.local, { scale: 1 + 0.14 * this.pulse }); }
    }
});
html, body { margin: 0; }
[data-composition-id] {
    position: relative;
    overflow: hidden;
    background: #0b0f1a;
    color: #fff;
    font-family: system-ui, sans-serif;
}
[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
.card {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
}
.card h1 { margin: 0; font-size: 136px; }
.sub { margin: 28px 0 0; font-size: 34px; min-height: 1.3em; }
.ch { display: inline-block; white-space: pre; }
.backdrop { position: absolute; inset: 0; }
.chart { position: absolute; inset: 0; }
.rows { position: absolute; left: 80px; right: 80px; top: 120px; }
.row { position: absolute; left: 0; right: 0; top: 0; height: 56px; display: flex; align-items: center; }
.name { width: 180px; padding-right: 20px; text-align: right; font-size: 22px; }
.bar { height: 36px; border-radius: 6px; }
.val { margin-left: 14px; font-size: 20px; }
.captions { position: absolute; inset: 0; }
.disc {
    position: absolute;
    left: 50%;
    top: 40%;
    width: 180px;
    height: 180px;
    margin: -90px 0 0 -90px;
    border-radius: 50%;
    background: #9d7bff;
}
.caption {
    position: absolute;
    left: 80px;
    right: 80px;
    bottom: 96px;
    margin: 0;
    min-height: 96px;
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 0 18px;
}
.word { font-size: 62px; font-weight: 700; line-height: 1.2; }
The captions