The Box-Office Line
Sixteen years of US box-office studio standings routed as a Beck-style Underground map, built Muze-native.
What you’re looking at
The chart opens on Paramount, the 2010 winner. Its pipe stays at full strength while the other seven recede, and the readout summarizes its years led, best finish, and cumulative US gross. Hover any other pipe to follow that studio instead. Your selection stays in focus as you move around the chart; leave the chart to return to Paramount.
This is a bump chart dressed like a tube map. Bump charts show how rankings change over time, and this one borrows its look from Harry Beck’s London Underground map. Each studio is a colored route, each year is a station, and each horizontal track is a rank. The 45-degree bends show studios moving up or down the table, with rank one running along the top.

The opening board on the left shows where every studio entered service; the arrivals board on the right shows the six studios still in the race in 2010. The eleven roundels on the top track mark a change in box-office leader.
Six studios lead at least once. Warner holds the top spot most often, leading five of the sixteen years, followed by Sony with four and Paramount with three. Disney leads twice; Universal and Fox each get one year at number one. Paramount finishes first in 2010, while the shorter New Line and DreamWorks routes end in 2008 and 2005.
How it was built
The visualization builds a derived internal DataModel from the four configured Worksheet fields. The CSV carries the published annual ranks, while the leaders, lead-change years, opening and final service records, and hover summaries are derived at runtime.
While Muze does not natively support line marks with 45 degree bends, what it does support is extending its visual language with custom logic. A custom TransitLine layer expands each rank change into horizontal, diagonal, and vertical path points, then reports series-level hover ids so Muze’s own highlighter can dim every other route to 0.08 without an overlay SVG.
The two standing boards on either side are Muze Y axes, configured independently with per-field tick values and formatters. A custom side effect implementation writes the readout instead of opening a tooltip and keeps the active studio in focus until the pointer leaves the chart, when Paramount returns as the default. The axis font ships through the Muze theme after document.fonts.ready, so Muze can accurately measure and reserve space for the text labels.
Take it with you
Paste these complete artifacts into Muze Studio, then load the CSV into a Worksheet. Change only the four FIELD values when your Answer uses different column names; keep the annual rank as a categorical field.
JavaScript
Self-contained Muze Studio code: Answer DataModel adaptation, octolinear route geometry, transit line layer, dual-axis boards, derived lead changes, and hover readout.
Preview
const { muze, getDataFromSearchQuery } = viz;
const data = getDataFromSearchQuery();
// Field names — these are the only data bindings a Studio user normally edits.
const FIELD = {
series: "Studio",
step: "Year",
value: "Total USGrossM",
rank: "Rank",
};
// Key order is paint order: long-serving pipes first, shorter services on top.
const INK = {
Warner: "#B23A2E",
Disney: "#C99420",
Sony: "#24457A",
Paramount: "#57869E",
Universal: "#2F6B4F",
Fox: "#7A5230",
"New Line": "#7C2F44",
DreamWorks: "#5B4E8E",
};
const NOTES = [
{ text: "DreamWorks · sold to Paramount ’06", series: "DreamWorks" },
{ text: "New Line · folded into Warner ’08", series: "New Line" },
];
const formatRecord = (record) =>
`${record.name} · led ${record.led} of ${record.steps} years · ` +
`best #${record.bestRank} (${new Date(record.bestStep).getFullYear()}) · ` +
`$${(record.total / 1000).toFixed(1)}B total US gross`;
const MAP_FONT_FAMILY = '"Jost", "Futura", "Century Gothic", sans-serif';
const SERIES = Object.keys(INK);
const DEFAULT_SERIES = "Paramount";
const DIMMED_OPACITY = 0.08;
const CHAMFER = 13 / Math.SQRT2;
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) {
const schema = dataModel.getData().schema;
const available = schema.map((field) => field.name);
const required = Object.values(FIELD);
const missing = required.filter((field) => !available.includes(field));
if (missing.length) {
throw new Error(
`The Box-Office Line requires configured fields ${required
.map((field) => `"${field}"`)
.join(
", ",
)}. Missing: ${missing.join(", ")}. Available: ${available.join(", ")}`,
);
}
const column = (field) => dataModel.getField(field).data();
const names = column(FIELD.series);
const steps = column(FIELD.step);
const values = column(FIELD.value);
const ranks = column(FIELD.rank);
const rows = names
.map((name, index) => ({
name: String(name),
step: normalizeStep(steps[index]),
value: Number(values[index]),
rank: Number(ranks[index]),
}))
.filter(
(row) =>
row.name &&
Number.isFinite(row.step) &&
Number.isFinite(row.value) &&
Number.isFinite(row.rank),
);
if (!rows.length) {
throw new Error("The Box-Office Line found no valid rank-race rows.");
}
return rows;
}
const serviceOf = (rows, name) =>
rows.filter((row) => row.name === name).sort((a, b) => a.step - b.step);
function computeStandings(rows) {
const steps = [...new Set(rows.map((row) => row.step))].sort((a, b) => a - b);
const leaders = steps.map((step) => {
const leader = rows.find((row) => row.step === step && row.rank === 1);
if (!leader) {
throw new Error(
`The Box-Office Line found no rank 1 row for ${new Date(step).getFullYear()}.`,
);
}
return leader.name;
});
const leadChangeSteps = steps.filter(
(_step, index) => index > 0 && leaders[index] !== leaders[index - 1],
);
return { steps, leaders, leadChangeSteps };
}
function buildServiceRecords(rows, { steps, leaders }) {
return Object.fromEntries(
SERIES.map((name) => {
const service = serviceOf(rows, name);
if (!service.length) {
throw new Error(
`The Box-Office Line has no rows for configured series "${name}".`,
);
}
const best = service.reduce((a, b) => (b.rank < a.rank ? b : a));
return [
name,
formatRecord({
name,
led: leaders.filter((leader) => leader === name).length,
steps: steps.length,
bestRank: best.rank,
bestStep: best.step,
total: service.reduce((sum, row) => sum + row.value, 0),
}),
];
}),
);
}
const makeTransitLineLayer = (LineLayer) =>
class TransitLineLayer extends LineLayer {
static formalName() {
return "transitLine";
}
getPathPoints(points) {
const pathPoints = [];
let previous = null;
points.forEach((point) => {
const { x, y } = point.update;
const defined = x !== null && y !== null;
if (defined && previous && Math.abs(y - previous.y) >= 0.5) {
const chamfer = Math.min(
CHAMFER,
Math.abs(y - previous.y) / 2,
Math.abs(x - previous.x) / 2,
);
const centerX = (previous.x + x) / 2;
const direction = Math.sign(y - previous.y);
[
[centerX - chamfer, previous.y],
[centerX, previous.y + direction * chamfer],
[centerX, y - direction * chamfer],
[centerX + chamfer, y],
].forEach(([bendX, bendY]) =>
pathPoints.push({
...point,
update: { ...point.update, x: bendX, y: bendY },
}),
);
}
pathPoints.push(point);
previous = defined ? { x, y } : null;
});
return pathPoints;
}
getNearestPoint(x, y, config) {
const point = super.getNearestPoint(x, y, config);
const name = point?.id?.[1][point.id[0].indexOf("Series")];
if (name !== undefined) {
point.id = [["Series"], [name]];
}
return point;
}
};
async function buildViz(muze, data, mountId, options = {}) {
if (options.signal?.aborted) return null;
const mount = document.getElementById(mountId);
if (!mount) {
throw new Error(`The Box-Office Line mount #${mountId} was not found.`);
}
// Muze measures label space before paint; request and wait for the measured webfont.
if (document.fonts) {
try {
await document.fonts.load('600 13px "Jost"');
} catch (_) {
// Continue with the configured fallback stack.
}
await document.fonts.ready;
}
if (options.signal?.aborted) return null;
const { DataModel, ActionModel } = muze;
const { GenericSideEffect } = muze.SideEffects.standards;
const rows = rowsFromDataModel(data);
const standings = computeStandings(rows);
const { steps, leadChangeSteps } = standings;
const records = buildServiceRecords(rows, standings);
const lastStep = steps[steps.length - 1];
const canvasId = `${mountId}-canvas`;
mount.innerHTML = `
<main class="box-office-line" aria-label="US box-office studio rank race from 1995 to 2010">
<p class="box-office-line__readout" aria-live="polite"></p>
<div id="${canvasId}" class="box-office-line__canvas"></div>
<p class="box-office-line__footnote">${NOTES.map(
({ text, series }) =>
`<span style="color:${INK[series]}">${text}</span>`,
).join("")}</p>
</main>
`;
const readout = mount.querySelector(".box-office-line__readout");
let activeSeries = DEFAULT_SERIES;
const setActiveSeries = (name) => {
activeSeries = name;
readout.textContent = records[name];
};
setActiveSeries(DEFAULT_SERIES);
const dmSchema = [
{ name: "Series", type: "dimension" },
{ name: "Step", type: "dimension", subtype: "temporal" },
{ name: "Track", type: "measure", defAggFn: "avg" },
{ name: "Track2", type: "measure", defAggFn: "avg" },
];
const dmData = rows.map((row) => ({
Series: row.name,
Step: row.step,
Track: -row.rank,
Track2: -row.rank,
}));
const dm = new DataModel(DataModel.loadDataSync(dmData, dmSchema));
const board = (list) =>
Object.fromEntries(
list.map((row) => [-row.rank, `${row.rank} · ${row.name.toUpperCase()}`]),
);
const leftBoard = board(SERIES.map((name) => serviceOf(rows, name)[0]));
const rightBoard = board(rows.filter((row) => row.step === lastStep));
const boardConfig = (labels) => ({
tickValues: Object.keys(labels).map(Number),
tickFormat: ({ rawValue }) => labels[rawValue] ?? "",
});
const tickInk = (extra = {}) => ({
labels: { style: { fill: "#262019", ...extra } },
});
const maxRank = Math.max(...rows.map((row) => row.rank));
const yDomain = [-(maxRank + 0.85), -0.42];
// Explicit domains keep both board axes aligned and temporal ticks pinned
// to stations after Muze negotiates mark-overflow space.
const stepMs = (lastStep - steps[0]) / (steps.length - 1);
const xDomain = [steps[0] - stepMs / 4, lastStep + stepMs / 4];
const theme = {
name: "box-office-line",
className: "bol-theme",
font: {
fontSize: "12px",
fontFamily: MAP_FONT_FAMILY,
fontWeight: "600",
fontStyle: "normal",
components: {
axis: {
fields: { Step: { ticks: { fontSize: "11px" } } },
},
},
},
loadCSS: () => {},
};
const pipeHighlight = {
highlight: {
sideEffects: {
"plot-highlighter": {
setTransform: (selectionSet) => selectionSet,
rules: [
{
target: "exitSet",
style: {
"fill-opacity": DIMMED_OPACITY,
"stroke-opacity": DIMMED_OPACITY,
},
},
],
},
"line-anchors": { enabled: false },
},
},
};
const env = typeof muze === "function" ? muze() : muze;
const canvas = env.canvas();
const layerRegistry = canvas.registry().componentSubRegistry.layers;
layerRegistry.register(makeTransitLineLayer(layerRegistry.get().line));
canvas
.data(dm)
.rows([["Track"], ["Track2"]])
.columns(["Step"])
.color("Series")
.transform({
leadChanges: (model) =>
model.select({
operator: "and",
conditions: [
{ field: "Track", value: -1, operator: "eq" },
{
// Temporal `in` does not match in this Muze build.
operator: "or",
conditions: leadChangeSteps.map((step) => ({
field: "Step",
value: step,
operator: "eq",
})),
},
],
}),
})
.layers([
{
mark: "transitLine",
className: "bol-pipes",
nearestPointThreshold: 28,
transition: { disabled: true },
interaction: pipeHighlight,
encoding: { y: "Track" },
},
{
mark: "point",
source: "leadChanges",
className: "bol-roundel",
interactive: false,
encoding: { y: "Track2" },
},
])
.config({
theme,
axes: {
x: {
showAxisName: false,
transition: { disabled: true },
ticks: tickInk({ "letter-spacing": "2.2px" }),
fields: { Step: { domain: xDomain } },
tickFormat: ({ rawValue }) => {
const year = new Date(rawValue).getFullYear();
return year % 5 ? "" : String(year);
},
},
y: {
showAxisName: false,
showInnerTicks: false,
showOuterTicks: false,
transition: { disabled: true },
ticks: tickInk(),
fields: {
Track: {
domain: yDomain,
...boardConfig(leftBoard),
},
Track2: {
domain: yDomain,
...boardConfig(rightBoard),
},
},
},
},
gridLines: {
x: { show: true },
y: { show: false },
color: "#c6bdac",
transition: { disabled: true },
},
legend: {
color: {
show: false,
domainRangeMap: INK,
ordering: { type: "custom", values: SERIES },
},
},
interaction: {
highlight: {
sideEffects: {
tooltip: { enabled: false },
"transit-readout": {},
},
},
},
columns: { headers: { show: false } },
})
.mount(`#${canvasId}`);
const restoreActiveHighlight = () =>
canvas.firebolt().dispatchBehaviour("highlight", {
criteria: { dimensions: [["Series"], [activeSeries]] },
});
const restoreDefaultHighlight = () => {
setActiveSeries(DEFAULT_SERIES);
restoreActiveHighlight();
};
const chartShell = mount.querySelector(".box-office-line");
chartShell.addEventListener("mouseleave", restoreDefaultHighlight);
class TransitReadout extends GenericSideEffect {
static formalName() {
return "transit-readout";
}
static target() {
return "all";
}
apply(_selectionSet, payload) {
const dimensions = payload?.criteria?.dimensions;
const name = dimensions && dimensions[1][dimensions[0].indexOf("Series")];
if (name) setActiveSeries(name);
else restoreActiveHighlight();
return this;
}
}
ActionModel.for(canvas)
.registerSideEffects(TransitReadout)
.dissociateBehaviour(
["select", "click"],
["select", "longtouch"],
["brush", "drag"],
["brush", "touchdrag"],
);
canvas.once("afterRendered", restoreDefaultHighlight);
return {
dispose() {
try {
canvas.dispose();
} catch (_) {}
try {
dm.dispose();
} catch (_) {}
chartShell.removeEventListener("mouseleave", restoreDefaultHighlight);
mount.replaceChildren();
},
};
}
buildViz(muze, data, "chart").catch((error) =>
console.error("The Box-Office Line failed to render.", error),
);
CSS
Complete #chart-scoped cream stock, pipe, roundel, readout, board, and footnote styling using Studio-safe system fonts.
Preview
@import url("https://fonts.googleapis.com/css2?family=Jost:wght@400;500;600;700&display=swap");
html,
body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: auto;
}
#chart,
#chart * {
font-family: "Jost", "Futura", "Century Gothic", sans-serif !important;
}
#chart {
box-sizing: border-box;
width: 100%;
height: 100%;
min-width: 1200px;
min-height: 520px;
color: #262019;
--bol-stock: #f4efe3;
--bol-ink: #262019;
--bol-muted: #8a8073;
}
#chart .box-office-line {
box-sizing: border-box;
display: grid;
grid-template-rows: 40px minmax(0, 1fr) 30px;
width: 100%;
height: 100%;
min-width: 1200px;
min-height: 520px;
overflow: visible;
}
#chart .box-office-line__readout {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
margin: 0;
padding: 0;
border-top: 1px solid var(--bol-ink);
border-bottom: 1px solid var(--bol-ink);
color: var(--bol-ink);
font-size: 13px;
font-weight: 600 !important;
letter-spacing: 0.14em;
line-height: 18px;
text-align: center;
text-transform: uppercase !important;
}
#chart .box-office-line__canvas {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: visible;
}
#chart .box-office-line__footnote {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
margin: 0;
padding: 0;
color: var(--bol-ink);
font-size: 10px;
font-weight: 600 !important;
letter-spacing: 1.4px;
line-height: 15px;
text-align: center;
text-transform: uppercase !important;
}
#chart .box-office-line__footnote span + span::before {
content: "—";
margin: 0 14px;
color: var(--bol-muted);
}
#chart .bol-pipes path {
fill: none;
stroke-width: 5px;
stroke-linecap: round;
stroke-linejoin: round;
cursor: pointer;
transition:
fill-opacity 140ms ease,
stroke-opacity 140ms ease;
}
@media (prefers-reduced-motion: reduce) {
#chart .bol-pipes path {
transition: none;
}
}
#chart .bol-roundel g {
opacity: 1 !important;
}
#chart .bol-roundel path {
fill: var(--bol-stock) !important;
stroke: var(--bol-ink) !important;
stroke-width: 2.5px !important;
}
#chart .muze-axis-container-bottom .muze-axis path {
stroke: var(--bol-ink);
stroke-width: 1.5px;
}
#chart .muze-axis-container-bottom .muze-ticks line {
stroke: var(--bol-ink);
stroke-width: 1px;
}
#chart .muze-axis-container-bottom .muze-ticks text {
font-weight: 600 !important;
}
#chart .muze-axis-container-left .muze-axis text,
#chart .muze-axis-container-right .muze-axis text {
font-weight: 600 !important;
}
#chart .muze-axis-container-left .muze-axis path,
#chart .muze-axis-container-right .muze-axis path {
display: none;
}
#chart table,
#chart td,
#chart th {
border: 0 !important;
}
HTML
The Muze Studio mount element; JavaScript creates the readout, canvas, and footnote children.
Preview
<div id="chart"></div>
Dataset (CSV)
119 studio-year standings with Studio, Year, USGrossM, and categorical Rank fields. Load it into a ThoughtSpot Worksheet for the Studio example.