Sales Chart
The code of the Sales Chart demo, 3 files.
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] }
]
}