The Superstore KPI Board
Twelve small-multiple KPI cards under one shared legend — hover a point in any spark line and all twelve headlines retarget to that month.
What you’re looking at
Twelve KPI cards, three colour-banded rows of four: one row per Superstore category, and inside it the four sub-categories that sold most in the most recent year. Each card is a headline figure with a spark line under it, and every card on the board is drawn on the same two years — 2012 in near-black, 2011 in grey.
Each card carries four things, in reading order:
- Title — the sub-category, ellipsised rather than wrapped, so the row of cards keeps its rhythm.
- Headline — the most-recent-year total. It is written once and never follows the pointer; it is the card’s fixed identity.
- Delta line —
▲16.2% from previous year ($164K vs $141K). The arrow and colour carry the sign, so no figure ever prints a minus. - Spark line — twelve months against twelve months, both years overlaid on the same discrete month axis rather than run end to end.
Hovering is the point of the board. Put the pointer on any month in any one of the twelve spark lines and all twelve delta lines retarget to that month at once, while the crossband lines up across the whole board — so you read one month across twelve sub-categories in a single glance, then leave the chart and every card falls back to its full-year comparison. There is no tooltip: the headlines are the tooltip.
Read at the year grain, 2012 was a good year almost everywhere. Office Machines is the largest panel at $563K and up 32.2%; Binders and Binder Accessories grows fastest at +62.2%; the losses are all in Furniture, led by Chairs & Chairmats at −13.7%. Drop to the month grain and the picture gets much noisier — Storage & Organization’s January is up 1605% on a January 2011 that barely happened ($51K against $3K), which is exactly why the headline stays pinned to the year while only the bracket moves.
Three months on the board have no prior-year sales at all (Bookcases in March, Copiers and Fax in August and December). Those render as a grey em dash rather than an infinite percentage, because “no base to compare against” is a different answer from “no growth”.
How it was built
One search query feeds the whole board. Four configured fields — category, sub-category, month, and the measure — are folded in a single pass into two shapes: a twelve-slot monthly series per sub-category, which is what the headlines and delta lines read, and flat rows at month grain, which is what the spark lines are drawn from. The two comparison years are simply the two most recent years present in the result; the rest of the extract is ignored, and fewer than two is an error rather than an empty second series.
All twelve canvases share one internal DataModel. Each card takes its slice as a select(...) off that model rather than building its own, so the board stays on one lineage and one teardown. Months are emitted as Jan…Dec strings, which makes the x axis discrete and overlays the two years on the same twelve slots — with an explicit ordering: { type: "custom", values: MONTHS } so the axis reads in calendar order instead of the alphabetical order a discrete axis otherwise falls into.
The cross-card hover is a custom Muze side effect, not mouse maths. A SurrogateSideEffect called kpiSync is mapped onto the built-in highlight behaviour through ActionModel.mapSideEffects, so the hovered month arrives inside Muze’s own interaction payload. It mutates nothing in the chart it fires from — it only reports the month outward, where the board rewrites the twelve delta lines and echoes the samehighlight into the other eleven canvases so their crossbands agree. That echo needs a re-entry guard, since each dispatch fires kpiSync again, and the rewrite is coalesced through one requestAnimationFrame, because a fast sweep along a line emits pointer events far faster than twelve headlines can be repainted.
The legend is Muze’s standalone muze.legend builder mounted once above the board, with the per-canvas legends switched off — so the two swatches come off the same colour-axis machinery as the lines rather than being hand-drawn boxes. The retinal range lives under legend.color.fields.Year, not on the color() setter, which only carries a field name.
The rest is layout discipline. Every slot on the delta line is a fixed-width track — the arrow pinned left, the percentage flushed right so the % signs form a column, and the two figures aligned outwards so both brackets always hug a number — which is what lets hover swap digits in place instead of pushing the sentence around. The build waits on document.fonts.ready and paints the headlines before any canvas is created, because Muze measures label space before paint and the headline heights decide how much room each spark line gets.
Take it with you
Paste these complete artifacts into Muze Studio, then load the CSV into a Worksheet and search for the measure by category, sub-category, and month. Change only the four FIELD values when your Answer names its columns differently.
JavaScript
Self-contained Muze Studio code: Answer DataModel adaptation, panel folding, twelve spark-line canvases off one shared model, the kpiSync side effect that fans hover out to every card, and the shared legend.
Preview
/**
* The Superstore KPI Board
* =======================================================================
* Twelve small-multiple cards — the top four sub-categories of each
* category — each pairing a KPI headline with a Muze spark line that reads
* the most recent year (black) against the prior year (grey).
*
* Hovering a point in ANY card retargets the headline of ALL TWELVE to that
* month. That is done with a custom Muze side effect (`kpiSync`) mapped onto
* the built-in `highlight` behaviour, so the month arrives in Muze's own
* interaction payload rather than from hand-rolled mouse maths.
*/
/* global viz */
const { muze, getDataFromSearchQuery } = viz;
const data = getDataFromSearchQuery();
// Field names — these are the only data bindings a Studio user normally edits.
const FIELD = {
category: "Category",
subCategory: "Sub-Category",
step: "Month(Order Date)",
value: "Total Sales",
};
// Key order is row order: category bands paint top to bottom.
const INK = {
Furniture: "#4caf6e",
"Office Supplies": "#eab350",
Technology: "#a074e8",
};
// The two series read as a value pair: the most recent year in black, the
// prior year stepped back to grey. Order matters — it is the colour domain.
const SERIES_INK = { previous: "#bebebe", recent: "#111111" };
const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const CATEGORIES = Object.keys(INK);
const FALLBACK_INK = "#7a8a99";
const PANELS_PER_CATEGORY = 4;
const AXIS_INK = "#8f8f8f";
const BOARD_FONT_FAMILY =
'"Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif';
// Beyond four figures the label would outgrow its fixed track on the delta
// line and collide with the phrase beside it, so it is clamped instead.
const PERCENT_CEILING = 9999;
/* ------------------------------------------------------------------ *
* Formatting
* ------------------------------------------------------------------ */
/** 30120 -> "$30K", 1_240_000 -> "$1.2M". */
const formatMoney = (value) => {
const number = Number(value);
if (!Number.isFinite(number)) return "—";
const abs = Math.abs(number);
const sign = number < 0 ? "-" : "";
if (abs >= 1e9) return `${sign}$${(abs / 1e9).toFixed(1)}B`;
if (abs >= 1e6) return `${sign}$${(abs / 1e6).toFixed(1)}M`;
if (abs >= 1e3) return `${sign}$${Math.round(abs / 1e3)}K`;
return `${sign}$${Math.round(abs)}`;
};
/**
* Magnitude of a percent change, sign excluded — the ▲/▼ glyph carries that.
* The decimal is dropped from three figures up: a sparse month against a
* sparse prior month can swing past 1000%, and "1605%" reads no worse than
* "1604.7%" while staying inside its track.
*/
const formatPercent = (change) => {
const abs = Math.abs(change);
if (abs > PERCENT_CEILING) return `>${PERCENT_CEILING}%`;
return `${abs >= 100 ? Math.round(abs) : abs.toFixed(1)}%`;
};
/** Null when the change is undefined — no prior-year base to divide by. */
const percentChange = (recent, previous) => {
if (!Number.isFinite(recent) || !Number.isFinite(previous) || previous === 0) {
return null;
}
return ((recent - previous) / Math.abs(previous)) * 100;
};
/* ------------------------------------------------------------------ *
* Reading the search query
* ------------------------------------------------------------------ */
function normalizeStep(value) {
if (value instanceof Date) return value.getTime();
const number = Number(value);
if (Number.isFinite(number)) {
return Math.abs(number) < 10000 ? new Date(number, 0, 1).getTime() : number;
}
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : NaN;
}
function rowsFromDataModel(dataModel) {
// Read through getData() rather than getField(): getData() returns
// { schema, data } as positional rows and is the accessor available in
// every Muze build, whereas getField() is not, and returns undefined
// instead of throwing when it cannot resolve a name.
const { schema, data: records } = dataModel.getData();
// Studio surfaces a query column under its name or its display name
// depending on how the search was written, so accept either.
const columnIndex = (wanted) =>
schema.findIndex(
(field) => field.name === wanted || field.displayName === wanted,
);
const required = Object.values(FIELD);
const missing = required.filter((field) => columnIndex(field) === -1);
if (missing.length) {
const available = schema.map((field) => field.displayName || field.name);
throw new Error(
`The Superstore KPI Board requires configured fields ${required
.map((field) => `"${field}"`)
.join(
", ",
)}. Missing: ${missing.join(", ")}. Available: ${available.join(", ")}`,
);
}
const at = {
category: columnIndex(FIELD.category),
subCategory: columnIndex(FIELD.subCategory),
step: columnIndex(FIELD.step),
value: columnIndex(FIELD.value),
};
const rows = records
.map((record) => ({
category: String(record[at.category]),
subCategory: String(record[at.subCategory]),
step: normalizeStep(record[at.step]),
value: Number(record[at.value]),
}))
.filter(
(row) =>
row.category &&
row.subCategory &&
Number.isFinite(row.step) &&
Number.isFinite(row.value),
);
if (!rows.length) {
throw new Error(
`The Superstore KPI Board found no valid rows in ${records.length} returned by the search query.`,
);
}
return rows;
}
function comparisonYears(rows) {
const years = [
...new Set(rows.map((row) => new Date(row.step).getFullYear())),
].sort((a, b) => b - a);
if (years.length < 2) {
throw new Error(
`The Superstore KPI Board compares two years and found ${years.length}: ${years.join(", ")}.`,
);
}
return { recentYear: years[0], previousYear: years[1] };
}
/**
* Folds the raw rows into the panel grain once, producing both things the
* board needs: the twelve-slot monthly series behind every KPI headline, and
* the flat rows the spark lines are drawn from.
*/
function foldToPanels(rows, { recentYear, previousYear }) {
const stats = new Map();
const panelRows = [];
rows.forEach((row) => {
const date = new Date(row.step);
const year = date.getFullYear();
if (year !== recentYear && year !== previousYear) return;
const monthIndex = date.getMonth();
let stat = stats.get(row.subCategory);
if (!stat) {
stat = {
category: row.category,
subCategory: row.subCategory,
recent: new Array(12).fill(null),
previous: new Array(12).fill(null),
recentTotal: 0,
previousTotal: 0,
};
stats.set(row.subCategory, stat);
}
const isRecent = year === recentYear;
const series = isRecent ? stat.recent : stat.previous;
series[monthIndex] = (series[monthIndex] || 0) + row.value;
if (isRecent) stat.recentTotal += row.value;
else stat.previousTotal += row.value;
});
stats.forEach((stat) => {
[
[recentYear, stat.recent],
[previousYear, stat.previous],
].forEach(([year, series]) =>
series.forEach((value, monthIndex) => {
if (value === null) return;
panelRows.push({
Category: stat.category,
SubCategory: stat.subCategory,
Year: String(year),
Month: MONTHS[monthIndex],
"Total Sales": value,
});
}),
);
});
if (!panelRows.length) {
throw new Error(
`The Superstore KPI Board found no rows in ${previousYear} or ${recentYear}.`,
);
}
return { stats, panelRows };
}
/**
* Top `PANELS_PER_CATEGORY` sub-categories per category by most-recent-year
* sales, listed alphabetically inside the category.
*/
function pickPanels(stats) {
const byCategory = new Map();
stats.forEach((stat) => {
if (!byCategory.has(stat.category)) byCategory.set(stat.category, []);
byCategory.get(stat.category).push(stat);
});
const ordered = [...byCategory.keys()].sort((a, b) => {
const ai = CATEGORIES.indexOf(a);
const bi = CATEGORIES.indexOf(b);
if (ai === -1 && bi === -1) return a.localeCompare(b);
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
return ordered.map((category) => ({
category,
ink: INK[category] || FALLBACK_INK,
stats: byCategory
.get(category)
.slice()
.sort((a, b) => b.recentTotal - a.recentTotal)
.slice(0, PANELS_PER_CATEGORY)
.sort((a, b) => a.subCategory.localeCompare(b.subCategory)),
}));
}
/* ------------------------------------------------------------------ *
* Card markup
* ------------------------------------------------------------------ */
const el = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
};
/**
* One KPI card.
*
* The headline is the most-recent-year total and is written once here — it is
* the card's fixed identity and does not follow the pointer. Only the delta
* line is dynamic, so its elements come back as refs.
*
* Every slot on that line is a fixed-width track (see the CSS): the arrow is
* pinned left and the percentage flushed right so the "%" signs form a column;
* the two figures are aligned outwards so both brackets always hug a number.
* Hovering therefore swaps digits in place instead of reflowing the sentence.
*/
function createCard(stat, chartId, ink) {
const card = el("article", "kpi-card");
card.style.setProperty("--accent", ink);
const head = el("header", "kpi-card__head");
head.appendChild(el("h3", "kpi-card__title", stat.subCategory));
head.appendChild(el("div", "kpi-card__value", formatMoney(stat.recentTotal)));
const delta = el("div", "kpi-card__delta");
const deltaPct = el("span", "kpi-card__delta-pct");
const deltaArrow = el("span", "kpi-card__delta-arrow");
const deltaNum = el("span", "kpi-card__delta-num");
deltaPct.append(deltaArrow, deltaNum);
const deltaCur = el("span", "kpi-card__delta-cur");
const deltaPrev = el("span", "kpi-card__delta-prev");
const pair = el("span", "kpi-card__delta-pair");
pair.append(
document.createTextNode("("),
deltaCur,
el("span", "kpi-card__delta-vs", "vs"),
deltaPrev,
document.createTextNode(")"),
);
delta.append(
deltaPct,
el("span", "kpi-card__delta-text", "from previous year"),
pair,
);
head.appendChild(delta);
card.appendChild(head);
const chart = el("div", "kpi-card__chart");
chart.id = chartId;
card.appendChild(chart);
return {
card,
chart,
refs: { deltaPct, deltaArrow, deltaNum, deltaCur, deltaPrev },
};
}
/** Writes one card's delta line for a hovered month, or for the whole year. */
function renderDelta(refs, stat, monthIndex) {
const isMonth = monthIndex !== null;
const recent = isMonth ? stat.recent[monthIndex] : stat.recentTotal;
const previous = isMonth ? stat.previous[monthIndex] : stat.previousTotal;
const change = percentChange(recent, previous);
if (change === null) {
refs.deltaArrow.textContent = "";
refs.deltaNum.textContent = "—";
refs.deltaPct.className = "kpi-card__delta-pct is-flat";
} else {
const up = change >= 0;
refs.deltaArrow.textContent = up ? "▲" : "▼";
refs.deltaNum.textContent = formatPercent(change);
refs.deltaPct.className = `kpi-card__delta-pct ${up ? "is-up" : "is-down"}`;
}
refs.deltaCur.textContent = Number.isFinite(recent)
? formatMoney(recent)
: "—";
refs.deltaPrev.textContent = Number.isFinite(previous)
? formatMoney(previous)
: "—";
}
/* ------------------------------------------------------------------ *
* Interaction plumbing
* ------------------------------------------------------------------ */
/**
* Pulls the hovered month out of a Muze interaction payload, which arrives as
* `{ criteria: { dimensions: [[...fieldNames], [...values]] } }`. A null
* criteria means the pointer left the plot.
*/
function monthFromPayload(payload) {
const dimensions = payload?.criteria?.dimensions;
if (!Array.isArray(dimensions) || dimensions.length < 2) return null;
const [fields, ...values] = dimensions;
const index = Array.isArray(fields) ? fields.indexOf("Month") : -1;
if (index === -1) return null;
const month = String(values[0][index]);
return MONTHS.includes(month) ? month : null;
}
/* ------------------------------------------------------------------ *
* Shared legend
* ------------------------------------------------------------------ */
const legendLabel = (year, isRecent) =>
`${year} (${isRecent ? "most recent" : "previous"})`;
/**
* `muze.legend` is Muze's standalone legend builder — the same component the
* canvases would each render, so the swatches come off the same colour-axis
* machinery as the lines rather than being hand-drawn boxes. One of these
* replaces twelve per-canvas legends.
*/
function mountMuzeLegend(muze, legendId, dm, years) {
return muze.legend
.data(dm)
.config({
position: "top",
// Muze gives the legend a fixed-width box; `center` centres the items
// inside it so that centring the box also centres what you see.
align: "center",
title: { show: false },
color: {
field: "Year",
ordering: {
type: "custom",
values: [String(years.recentYear), String(years.previousYear)],
},
range: [SERIES_INK.recent, SERIES_INK.previous],
item: {
text: {
// Muze hands the formatter { formattedValue, rawValue }.
formatter: ({ formattedValue, rawValue }) => {
const year = String(rawValue ?? formattedValue);
return legendLabel(year, year === String(years.recentYear));
},
},
},
},
})
.mount(`#${legendId}`)
.onMount(() => centreLegendInk(document.getElementById(legendId)));
}
/**
* The same two entries as flat markup, for Muze builds without the standalone
* legend builder (it is newer than the release this docs site ships). The
* swatches read their colours from SERIES_INK — the same constant that feeds
* the canvases' colour range — so the legend cannot drift from the lines, and
* a flex row centres exactly, which is why this path needs no ink-centring.
*/
function paintSwatchLegend(host, years) {
if (!host) return null;
const list = el("ul", "kpi-board__legend-items");
[
[years.recentYear, SERIES_INK.recent, true],
[years.previousYear, SERIES_INK.previous, false],
].forEach(([year, ink, isRecent]) => {
const item = el("li", "kpi-board__legend-item");
const swatch = el("span", "kpi-board__legend-swatch");
swatch.style.background = ink;
item.append(swatch, el("span", undefined, legendLabel(year, isRecent)));
list.appendChild(item);
});
host.replaceChildren(list);
return null;
}
/**
* Nudges the legend so its *painted* content is centred, not just its box.
* Muze sizes each item cell to its label plus a fixed buffer, and the buffer
* on the last cell is trailing whitespace — so a perfectly centred box still
* renders the swatches a few pixels left of centre. Measuring the real ink
* keeps working if the labels change width.
*/
function centreLegendInk(host) {
const bar = host?.closest(".kpi-board__legendbar");
if (!bar) return;
const apply = () => {
host.style.transform = "";
const marks = [...host.querySelectorAll("svg")];
const labels = [...host.querySelectorAll("*")].filter(
(node) => !node.children.length && node.textContent.trim(),
);
const boxes = [...marks, ...labels].map((node) =>
node.getBoundingClientRect(),
);
if (!boxes.length) return;
const inkCentre =
(Math.min(...boxes.map((box) => box.left)) +
Math.max(...boxes.map((box) => box.right))) /
2;
// Target the bar's centre, not the host's own: translating the host moves
// the ink with it, so an offset measured against the host never closes.
const barBox = bar.getBoundingClientRect();
const drift = inkCentre - (barBox.left + barBox.right) / 2;
if (Math.abs(drift) > 0.5) {
host.style.transform = `translateX(${-drift}px)`;
}
};
// Once on the next frame, because Muze sizes the legend's wrappers after
// mount() returns — and again when fonts settle, because the legend renders
// in its own webfont. Requesting that font starts a *second* loading round
// after mount, and the label widths (so the ink's centre) move with it.
requestAnimationFrame(apply);
if (document.fonts) {
document.fonts.ready.then(apply);
}
}
/* ------------------------------------------------------------------ *
* Build
* ------------------------------------------------------------------ */
async function buildViz(muze, data, mountId, options = {}) {
if (options.signal?.aborted) return null;
const mount = document.getElementById(mountId);
if (!mount) {
throw new Error(`The Superstore KPI Board mount #${mountId} was not found.`);
}
// Muze measures label space before paint, and the KPI headline heights set
// how much room is left for the charts, so settle text metrics up front.
if (document.fonts) {
await document.fonts.ready;
}
if (options.signal?.aborted) return null;
const { DataModel, ActionModel } = muze;
const { SurrogateSideEffect } = muze.SideEffects.standards;
const rows = rowsFromDataModel(data);
const years = comparisonYears(rows);
const { stats, panelRows } = foldToPanels(rows, years);
const groups = pickPanels(stats);
const legendId = `${mountId}-legend`;
mount.innerHTML = `
<main class="kpi-board" aria-label="Monthly sales by sub-category, ${years.recentYear} against ${years.previousYear}">
<header class="kpi-board__legendbar">
<div class="kpi-board__legend" id="${legendId}"></div>
</header>
<div class="kpi-board__rows"></div>
</main>
`;
const dmSchema = [
{ name: "Category", type: "dimension" },
{ name: "SubCategory", type: "dimension" },
{ name: "Year", type: "dimension" },
{ name: "Month", type: "dimension" },
{ name: "Total Sales", type: "measure", defAggFn: "sum" },
];
const dm = new DataModel(DataModel.loadDataSync(panelRows, dmSchema));
/* ---- cards ---- */
const board = mount.querySelector(".kpi-board__rows");
const panels = [];
groups.forEach((group, rowIndex) => {
const row = el("section", "kpi-board__row");
const band = el("div", "kpi-board__band", group.category.toUpperCase());
band.style.background = group.ink;
row.appendChild(band);
const cards = el("div", "kpi-board__cards");
group.stats.forEach((stat, colIndex) => {
const chartId = `${mountId}-chart-${rowIndex}-${colIndex}`;
const { card, chart, refs } = createCard(stat, chartId, group.ink);
cards.appendChild(card);
panels.push({ stat, refs, chart, chartId, canvas: null });
});
row.appendChild(cards);
board.appendChild(row);
});
/* ---- hover fan-out ---- */
let hoveredMonth = null;
let pendingMonth;
let frame = 0;
let echoing = false;
const paintDeltas = () => {
const monthIndex =
hoveredMonth === null ? null : MONTHS.indexOf(hoveredMonth);
panels.forEach((panel) => renderDelta(panel.refs, panel.stat, monthIndex));
};
// Mirrors the hovered month into the other eleven canvases so the crossband
// lines up across the whole board, not just the card under the pointer.
const echoHighlight = (month) => {
if (echoing) return;
echoing = true;
const criteria =
month === null ? null : { dimensions: [["Month"], [month]] };
panels.forEach((panel) => {
try {
panel.canvas?.firebolt().dispatchBehaviour("highlight", { criteria });
} catch {
// A canvas still mid-render has nothing to highlight; the next hover
// picks it up.
}
});
echoing = false;
};
// Coalesced through one animation frame so a fast sweep along a line does
// not rewrite twelve headlines per pointer event.
const setHoveredMonth = (month) => {
if (month === hoveredMonth && pendingMonth === undefined) return;
pendingMonth = month;
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
const next = pendingMonth;
pendingMonth = undefined;
if (next === hoveredMonth) return;
hoveredMonth = next;
paintDeltas();
echoHighlight(next);
});
};
// Headlines are painted before the canvases are sized, so each chart is
// measured against the space actually left below its header.
paintDeltas();
/* ---- spark lines ---- */
const theme = {
name: "kpi-board",
className: "kpi-board-theme",
font: {
fontSize: "10px",
fontFamily: BOARD_FONT_FAMILY,
fontWeight: "400",
fontStyle: "normal",
},
loadCSS: () => {},
};
const tickInk = { labels: { style: { fill: AXIS_INK } } };
const chartSize = (panel) => {
const rect = panel.chart.getBoundingClientRect();
return {
width: Math.max(160, Math.round(rect.width)),
height: Math.max(120, Math.round(rect.height)),
};
};
const env = typeof muze === "function" ? muze() : muze;
/**
* Retargets every KPI headline on hover. It changes nothing inside the chart
* it fires from, and closes over `setHoveredMonth` so no module-level state
* is needed.
*/
class KpiSync extends SurrogateSideEffect {
static formalName() {
return "kpiSync";
}
static target() {
return "visual-unit";
}
static mutates() {
return false;
}
apply(_selectionSet, payload) {
setHoveredMonth(payload?.criteria ? monthFromPayload(payload) : null);
return this;
}
}
panels.forEach((panel) => {
const size = chartSize(panel);
// One card's slice of the board model. Kept as a select() off the shared
// model rather than twelve separate DataModels, so they stay on one
// lineage.
const panelData = dm.select({
field: "SubCategory",
operator: "eq",
value: panel.stat.subCategory,
});
panel.canvas = env
.canvas()
.width(size.width)
.height(size.height)
.data(panelData)
.rows(["Total Sales"])
.columns(["Month"])
.color("Year")
.layers([{ mark: "line" }])
.config({
theme,
// The card already names the measure and the dimension, so drop the
// headers Muze would otherwise stack on top of the plot.
columns: { headers: { show: false } },
rows: { headers: { show: false } },
gridLines: { show: false },
gridBands: { x: { show: false }, y: { show: false } },
legend: {
// One shared legend replaces twelve per-canvas ones.
show: false,
color: {
fields: {
Year: {
// Retinal ranges are read from `legend.<channel>.fields`, not
// from the `color()` setter, which only carries a field name.
// Ordering is stated with them so range[i] lands on values[i].
range: [SERIES_INK.previous, SERIES_INK.recent],
ordering: {
type: "custom",
values: [String(years.previousYear), String(years.recentYear)],
},
},
},
},
},
axes: {
x: {
showAxisName: false,
tickSize: 0,
padding: 0.05,
compact: true,
ticks: tickInk,
// Calendar order, not the alphabetical order a discrete axis
// would otherwise fall into.
fields: { Month: { ordering: { type: "custom", values: MONTHS } } },
tickFormat: ({ formattedValue }) => String(formattedValue).charAt(0),
},
y: {
showAxisName: false,
showAxisLine: false,
tickSize: 0,
numberOfTicks: 4,
ticks: tickInk,
tickFormat: ({ rawValue }) => formatMoney(rawValue),
},
},
interaction: {
highlight: {
sideEffects: {
// The KPI headlines are the tooltip, so only the crossband and
// our sync run.
tooltip: { enabled: false },
crossline: {},
kpiSync: {},
},
},
},
})
.mount(`#${panel.chartId}`);
// Registered on the group firebolt before the first paint so every visual
// unit inherits the definition as it is created.
panel.canvas.firebolt().registerSideEffects([KpiSync]);
});
ActionModel.for(...panels.map((panel) => panel.canvas))
.registerSideEffects(KpiSync)
.mapSideEffects({ highlight: ["kpiSync"] });
/* ---- shared legend ---- */
// One legend for the whole board, built by Muze itself where the build
// offers it and painted from the same colour constant where it does not.
const usingMuzeLegend = Boolean(muze.legend);
const legend = usingMuzeLegend
? mountMuzeLegend(muze, legendId, dm, years)
: paintSwatchLegend(document.getElementById(legendId), years);
/* ---- lifecycle ---- */
// Leaving the board resets the headlines: Muze fires a null-criteria
// highlight on mouseout of a plot, but not when the pointer jumps straight
// from a line to the page chrome.
const shell = mount.querySelector(".kpi-board");
const resetHover = () => setHoveredMonth(null);
shell.addEventListener("mouseleave", resetHover);
// Muze canvases are sized in pixels, so re-measure once the window settles.
let resizeTimer;
const onResize = () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
panels.forEach((panel) => {
const size = chartSize(panel);
panel.canvas.width(size.width).height(size.height);
});
// The swatch fallback is flex-centred and needs no correction.
if (usingMuzeLegend) centreLegendInk(document.getElementById(legendId));
}, 200);
};
window.addEventListener("resize", onResize);
return {
dispose() {
clearTimeout(resizeTimer);
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
shell.removeEventListener("mouseleave", resetHover);
// Each teardown is guarded on its own: a canvas that never finished
// rendering must not stop the rest of the board being released.
panels.forEach((panel) => {
try {
panel.canvas?.dispose();
} catch {
/* already gone */
}
});
try {
legend?.dispose?.();
} catch {
/* already gone */
}
try {
dm.dispose();
} catch {
/* already gone */
}
mount.replaceChildren();
},
};
}
buildViz(muze, data, "chart").catch((error) =>
console.error("The Superstore KPI Board failed to render.", error),
);
CSS
The board shell, category bands, card chrome, and the fixed-width delta-line tracks that keep hover from reflowing the sentence. Drops to two columns below 1400px.
Preview
/* =====================================================================
* The Superstore KPI Board
* Three colour-banded category rows of four KPI cards, filling the
* viewport under one shared legend. Each card pairs an HTML headline with
* a Muze spark line.
*
* Class names are namespaced under kpi-board / kpi-card because the board
* mounts into a host page whose own stylesheet it must not collide with.
* ===================================================================== */
:root {
--kpi-card-bg: #ffffff;
--kpi-ink: #1c1c1c;
--kpi-ink-soft: #3d3d3d;
--kpi-ink-faint: #8c8c8c;
--kpi-up: #2563eb;
--kpi-down: #e23d2d;
--kpi-font: "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
* {
box-sizing: border-box;
}
html,
body,
#chart {
height: 100%;
}
/* No background is painted: the board sits on whatever the host page uses,
* which is what Studio expects a chart to do. */
body {
margin: 0;
font-family: var(--kpi-font);
color: var(--kpi-ink);
-webkit-font-smoothing: antialiased;
}
/* ---------------------------------------------------------------- *
* Shell
* ---------------------------------------------------------------- */
.kpi-board {
display: flex;
flex-direction: column;
height: 100%;
}
.kpi-board__legendbar {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
padding: 7px 12px 0;
}
/* Muze renders the legend into this box; keep it from stretching the bar. */
.kpi-board__legend {
display: flex;
align-items: center;
min-height: 22px;
}
.kpi-board__rows {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: 10px;
}
.kpi-board__row {
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
flex: 1 1 0;
gap: 8px;
min-height: 0;
}
.kpi-board__band {
display: grid;
place-items: center;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
color: #ffffff;
writing-mode: vertical-rl;
transform: rotate(180deg);
}
.kpi-board__cards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
min-width: 0;
min-height: 0;
}
/* ---------------------------------------------------------------- *
* Card
* ---------------------------------------------------------------- */
.kpi-card {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
padding: 10px 12px 6px;
background: var(--kpi-card-bg);
border: 2px solid var(--accent, #cccccc);
}
.kpi-card__head {
flex: 0 0 auto;
}
.kpi-card__title {
margin: 0;
overflow: hidden;
font-size: 14px;
font-weight: 400;
color: var(--kpi-ink-soft);
text-overflow: ellipsis;
white-space: nowrap;
}
/* The most-recent-year total. Written once and never touched by hover — it is
* the card's fixed identity; the hovered month's figures live in the bracket
* on the delta line below. */
.kpi-card__value {
margin: 2px 0 1px;
font-size: 30px;
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.01em;
color: #000000;
font-variant-numeric: tabular-nums;
}
/* ---------------------------------------------------------------- *
* Delta line — fixed tracks so hover swaps digits without reflowing
* ---------------------------------------------------------------- */
.kpi-card__delta {
display: flex;
align-items: baseline;
gap: 5px;
font-size: 13px;
line-height: 1.35;
color: var(--kpi-ink-soft);
white-space: nowrap;
}
/* Two tracks: the arrow pinned left, the number flushed right against a fixed
* edge. That keeps the arrows in a column AND the "%" signs in a column, so
* the gap before the phrase is identical on every card — a single left-aligned
* slot would leave that gap ragged as the digit count changes. Sized for
* "▼1605%", the widest label this data produces; stated as a min-width so a
* freak label from another dataset nudges the phrase across rather than
* printing on top of it. */
.kpi-card__delta-pct {
display: inline-flex;
align-items: baseline;
flex: 0 0 auto;
min-width: 4.3em;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.kpi-card__delta-arrow {
flex: 0 0 1em;
}
.kpi-card__delta-num {
flex: 1 1 auto;
text-align: right;
}
.kpi-card__delta-pct.is-up {
color: var(--kpi-up);
}
.kpi-card__delta-pct.is-down {
color: var(--kpi-down);
}
.kpi-card__delta-pct.is-flat {
color: var(--kpi-ink-faint);
}
.kpi-card__delta-text,
.kpi-card__delta-pair {
flex: 0 0 auto;
}
/* Fixed tracks inside the brackets, so "(", "vs" and ")" all stay put and only
* the digits change. The two figures are aligned outwards — the current year
* against the opening bracket, the prior year against the closing one — so
* both brackets always hug a number and whatever slack the shorter values
* leave pools symmetrically around the "vs". 3em is the width of "$563K", the
* widest figure this data produces, so the track is as tight as it can be
* while still holding every value. The colours mirror the lines in the chart
* below: black is the most recent year, grey the prior one. */
.kpi-card__delta-cur,
.kpi-card__delta-prev {
display: inline-block;
min-width: 3em;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.kpi-card__delta-cur {
text-align: left;
color: #000000;
}
.kpi-card__delta-prev {
text-align: right;
color: var(--kpi-ink-faint);
}
.kpi-card__delta-vs {
padding: 0 2px;
font-weight: 400;
color: var(--kpi-ink-faint);
}
/* ---------------------------------------------------------------- *
* Chart
* ---------------------------------------------------------------- */
.kpi-card__chart {
flex: 1 1 auto;
min-height: 90px;
margin-top: 6px;
}
/* ---------------------------------------------------------------- *
* Responsive
* ---------------------------------------------------------------- */
/* The delta line needs ~276px of card width. Below the breakpoint four
* columns can no longer give it that, so drop to two rather than let the
* sentence spill past the card border. */
@media (max-width: 1400px) {
html,
body,
#chart,
.kpi-board {
height: auto;
}
.kpi-board__rows {
overflow: visible;
}
.kpi-board__row {
flex: 0 0 auto;
}
.kpi-board__cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.kpi-card__chart {
min-height: 150px;
}
}
HTML
The Muze Studio mount element; JavaScript builds the legend bar, the three category rows, and all twelve cards inside it.
Preview
<div id="chart"></div>
Dataset (CSV)
803 rows of Superstore sales at category / sub-category / month grain, 2009–2012 — the order extract folded to the grain a monthly search query returns. Load it into a ThoughtSpot Worksheet for the Studio example.