Sales Chart

The code of the Sales Chart demo, 3 files.

Run the demo All demos

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Sales Chart | WildflowerJS Demo</title>
    <script defer src="/js/analytics.js"></script>
    <!-- The framework's full tier (the one with wildflower.query), then Kinetic
         with its preview bar, then the scene. Kinetic runs the clock and the
         render itself. -->
    <script defer src="/js/dist/wildflower.full.min.js"></script>
    <script defer src="/js/extensions/kinetic.wf.min.js" data-preview></script>
    <script defer src="app.js"></script>
    <style>
        * { box-sizing: border-box; }
        html, body { margin: 0; background: #0a0b10; color: #e6e6f0; font-family: system-ui, sans-serif; }
        .demo-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 8px 20px; background: #12141c; border-bottom: 1px solid rgba(255,255,255,0.08); }
        .demo-toolbar a { display: flex; align-items: center; gap: 10px; text-decoration: none; color: #5fa6a6; font-size: 1rem; }
        .demo-toolbar .logo { height: 32px; }
        .demo-toolbar .brand-wf { font-weight: 520; }
        .demo-toolbar .brand-js { font-weight: 700; }
        .demo-toolbar .demo-info { font-size: 11px; color: rgba(255,255,255,0.6); text-align: right; }
        .demo-toolbar .demo-info strong { color: rgba(255,255,255,0.9); }

        /* Hidden until Kinetic marks the root ready: before that, the stage
           holds unbound markup. With data-preview, Kinetic scales the stage
           down to the window. */
        #stage:not([data-kinetic-ready]) { visibility: hidden; }
        .frame { padding: 24px; }
        #stage { position: relative; width: 1280px; height: 720px; overflow: hidden; background: #0f1118; }
        .layer { position: absolute; inset: 0; }

        .chart { position: absolute; inset: 0; padding: 56px 80px 64px 80px; }
        .head { display: flex; justify-content: space-between; align-items: baseline; height: 72px; }
        .head h1 { margin: 0; font-size: 40px; font-weight: 700; letter-spacing: -0.01em; color: #fff; }
        .head .year { font-size: 64px; font-weight: 800; color: #3a3f55; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
        .plot { position: relative; margin-top: 24px; height: 448px; }
        .grid { position: absolute; left: 180px; top: 0; bottom: 0; width: 900px; }
        .tick { position: absolute; top: 0; bottom: 0; border-left: 1px solid rgba(255,255,255,0.08); }
        .tick span { position: absolute; bottom: -28px; left: 0; transform: translateX(-50%); font-size: 15px; color: #6c7390; font-variant-numeric: tabular-nums; white-space: nowrap; }
        .rows { position: absolute; left: 0; top: 0; right: 0; }
        .row { position: absolute; left: 0; right: 0; top: 0; height: 56px; display: flex; align-items: center; will-change: transform; }
        .row .name { width: 180px; padding-right: 20px; text-align: right; font-size: 22px; font-weight: 600; color: #c9cde0; }
        .row .bar { height: 36px; border-radius: 6px; min-width: 0; }
        .row .val { margin-left: 14px; font-size: 20px; font-weight: 600; color: #fff; font-variant-numeric: tabular-nums; white-space: nowrap; }
        .foot { position: absolute; left: 80px; right: 80px; bottom: 28px; font-size: 16px; color: #6c7390; }

        .about { max-width: 1280px; margin: 0 auto; padding: 0 24px 96px; color: #a9adc2; font-size: 14px; line-height: 1.55; }
        .about p { margin: 0 0 10px; }
        .about b { color: #e6e6f0; font-weight: 600; }
        .about code { color: #e6e6f0; font-size: 13px; }
    </style>
</head>
<body>
    <div class="demo-toolbar">
        <a href="/demos/">
            <img class="logo" src="/assets/wildflower_96.png" alt="WildflowerJS">
            <span><span class="brand-wf">Wildflower</span><span class="brand-js">JS</span> Demos</span>
        </a>
        <div class="demo-info"><strong>Sales Chart</strong>: a bar chart race</div>
    </div>

    <div class="frame">
        <div id="stage" data-composition-id="sales" data-start="0" data-duration="12" data-width="1280" data-height="720" data-fps="30"
             data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"Revenue by product line"}]'>
            <div class="layer" data-component="sales-chart" data-start="0" data-duration="12">
                <div class="chart" data-bind-style="card">
                    <div class="head">
                        <h1 data-bind="inputs.title"></h1>
                        <div class="year" data-bind="yearLabel"></div>
                    </div>
                    <div class="plot">
                        <div class="grid" data-list="axis" data-key="key">
                            <template><div class="tick" data-bind-style="style"><span data-bind="label"></span></div></template>
                        </div>
                        <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="foot" data-bind="foot"></div>
                </div>
            </div>
        </div>
    </div>

    <div class="about">
        <p>The rows come from a JSON file through <code>wildflower.query</code>, and the first frame waits for it to load.</p>
    </div>
</body>
</html>

app.js

// Sales chart: rows from a query, grown in, then raced across six years.
// This is one scene, and every animated value is a function of local. The
// query needs the full tier. In a render, or with ?embed, Kinetic shows the
// stage alone.

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

// ---- The data --------------------------------------------------------------

// The file holds { years, rows }; the query's rows are the product lines.
wildflower.query('sales', {
    from: 'data/sales.json',
    select: (data) => data.rows,
});

const YEARS = [2019, 2020, 2021, 2022, 2023, 2024];
const ROW = 56;          // px per row
const PLOT = 900;        // px, the bars' full width
const RACE = [4.5, 10.5];   // seconds: the years roll from the first to the last

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

// ---- The scene ---------------------------------------------------------------

scene('sales-chart', {
    waitFor: ['sales'],
    inputs: { title: { type: String, default: 'Revenue by product line' } },
    edges: { enter: 0.4, exit: 0.6 },
    computed: {
        rows() { return wildflower.getQuery('sales').rows; },
        // The axis tops out at the largest value in any year, rounded up to
        // a tick, so the bars keep one scale through the race.
        axisTicks() {
            const max = Math.max(0, ...this.rows.map((r) => Math.max(...r.values)));
            return ticks(0, max || 1, 5);
        },
        scaleMax() { const t = this.axisTicks; return t[t.length - 1] || 1; },
        // The position along the years, where 0 is 2019, 5 is 2024, and
        // fractions fall between. Each year holds for its first 45%, then
        // the rows move to the next ordering with an ease, so a crossing
        // reads as one.
        year() {
            const raw = interpolate(this.local, RACE, [0, YEARS.length - 1]);
            const k = Math.floor(raw);
            return k + interpolate(raw - k, [0.45, 1], [0, 1], { ease: 'power2.inOut' });
        },
        yearLabel() { return String(YEARS[Math.round(this.year)]); },
        axis() {
            return this.axisTicks.map((v) => ({
                key: v,
                label: money(v),
                style: { left: (PLOT * v / this.scaleMax) + 'px' },
            }));
        },
        bars() {
            const t = this.local;
            const scaleMax = this.scaleMax;
            return rank(this.year, this.rows, (r, k) => r.values[k]).map(({ row, value, position }) => {
                // Each bar grows in over 1.2 s, top row first, the next
                // starting 0.12 s later.
                const delay = stagger(Math.round(position), this.rows.length, { each: 0.12 });
                const grown = interpolate(t - delay, [0.4, 1.6], [0, 1], { ease: 'power3.out' });
                return {
                    id: row.id,
                    name: row.name,
                    style: styleAt(t, { translateY: position * ROW }),
                    bar: { width: (PLOT * value / scaleMax * grown).toFixed(2) + 'px', background: row.color },
                    shown: money(value * grown),
                };
            });
        },
        foot() {
            return this.local < RACE[0] - 0.5 ? 'Revenue, ' + YEARS[0] : 'Revenue by year, ' + YEARS[0] + ' to ' + YEARS[YEARS.length - 1];
        },
        card() { return { opacity: String(this.presence) }; },
    },
});

data/sales.json

{
  "years": [2019, 2020, 2021, 2022, 2023, 2024],
  "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] },
    { "id": "summit",  "name": "Summit",  "color": "#9d7bff", "values": [300000, 480000, 760000, 1010000, 1330000, 1680000] },
    { "id": "canyon",  "name": "Canyon",  "color": "#e06c7a", "values": [560000, 540000, 610000, 650000, 700000, 720000] },
    { "id": "delta",   "name": "Delta",   "color": "#79d28b", "values": [420000, 470000, 520000, 590000, 640000, 690000] },
    { "id": "prairie", "name": "Prairie", "color": "#e3c35a", "values": [910000, 880000, 850000, 830000, 790000, 760000] },
    { "id": "lagoon",  "name": "Lagoon",  "color": "#5fa6a6", "values": [150000, 260000, 410000, 620000, 900000, 1150000] }
  ]
}