Data visualization
Charts in a mod panel with utils.charts, the bundled Recharts module.
The task: a panel with a mode-share pie, a station ridership bar chart and a ridership line over time. The chain is the Panels chain plus `utils.charts` and the ridership getters on `gameState`.
Getting Started#
utils.charts is the whole Recharts module.
(function () {
const api = window.SubwayBuilderAPI;
const { React, charts, components } = api.utils;
const { PieChart, Pie, Cell, BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip } = charts;
const { Card, CardHeader, CardTitle, CardContent } = components;
const h = React.createElement;Pie Chart: Mode Share#
`gameState.getModeChoiceStats` returns { walking, driving, transit, unknown }. An optional 'homeToWork' or 'workToHome' argument picks the direction.
function ModeShareChart({ width }) {
const modes = api.gameState.getModeChoiceStats();
const data = [
{ name: 'Transit', value: modes.transit, color: '#22c55e' },
{ name: 'Driving', value: modes.driving, color: '#ef4444' },
{ name: 'Walking', value: modes.walking, color: '#3b82f6' }
];
return h(PieChart, { width, height: 200 },
h(Pie, { data, dataKey: 'value', nameKey: 'name', cx: '50%', cy: '50%', outerRadius: 70,
label: ({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%` },
data.map((entry, i) => h(Cell, { key: i, fill: entry.color }))
)
);
}Bar Chart: Station Ridership#
`gameState.getStationRidership`(stationId) returns { total, transfers, byRoute } from the 15-minute completed-commute snapshot; in a fresh game total and transfers are 0 and byRoute is empty.
function StationRidershipChart({ width }) {
const data = api.gameState.getStations().slice(0, 10).map((station) => ({
name: station.name.slice(0, 15),
riders: api.gameState.getStationRidership(station.id).total
}));
return h(BarChart, { width, height: 260, data }, [
h(CartesianGrid, { key: 'grid', strokeDasharray: '3 3' }),
h(XAxis, { key: 'x', dataKey: 'name', tick: { fontSize: 10 }, angle: -45, textAnchor: 'end', height: 60 }),
h(YAxis, { key: 'y' }),
h(Tooltip, { key: 'tip' }),
h(Bar, { key: 'bar', dataKey: 'riders', fill: '#8b5cf6' })
]);
}Line Chart: Ridership Over Time#
`gameState.getRidershipStats` returns { totalRidersPerHour, totalRiders, timeWindowHours } from the commutes completed in the last 15 game minutes. The code below samples it into a series: a `hooks.onDayChange` callback records one point per day change and keeps the last 30.
const history = [];
api.hooks.onDayChange((day) => {
history.push({ day, riders: api.gameState.getRidershipStats().totalRidersPerHour });
if (history.length > 30) history.shift();
});
function RidershipTrendChart({ width }) {
return h(LineChart, { width, height: 220, data: history.slice() }, [
h(CartesianGrid, { key: 'grid', strokeDasharray: '3 3' }),
h(XAxis, { key: 'x', dataKey: 'day' }),
h(YAxis, { key: 'y' }),
h(Tooltip, { key: 'tip' }),
h(Line, { key: 'line', type: 'monotone', dataKey: 'riders', stroke: '#22c55e', strokeWidth: 2 })
]);
}The onDayChange registration sits at the top level of the script, so it runs once per script execution and survives panel open and close.
Complete Dashboard#
The dashboard stacks the three charts in cards and sizes them from the panel width.
Real-Time Updates#
To redraw the charts on a game event, subscribe inside useEffect and return the hook's unsubscribe function as the cleanup:
function Dashboard({ width, height }) {
const [, tick] = React.useState(0);
React.useEffect(() => api.hooks.onHourChange(() => tick((n) => n + 1)), []);
const inner = width - 32;
return h('div', { style: { width, height, overflow: 'auto', padding: 16 }, className: 'space-y-4' }, [
h(Card, { key: 'mode' }, [
h(CardHeader, { key: 'h' }, h(CardTitle, null, 'Mode share')),
h(CardContent, { key: 'c' }, h(ModeShareChart, { width: inner }))
]),
h(Card, { key: 'stations' }, [
h(CardHeader, { key: 'h' }, h(CardTitle, null, 'Top stations')),
h(CardContent, { key: 'c' }, h(StationRidershipChart, { width: inner }))
]),
h(Card, { key: 'trend' }, [
h(CardHeader, { key: 'h' }, h(CardTitle, null, 'Riders per hour')),
h(CardContent, { key: 'c' }, h(RidershipTrendChart, { width: inner }))
])
]);
}onHourChange fires once per crossed game hour (at most 24 per simulation update).
Register the Panel#
api.hooks.onMapReady(() => {
api.ui.addFloatingPanel({
id: 'my-mod-analytics',
icon: 'BarChart3',
title: 'Network Analytics',
defaultWidth: 640,
defaultHeight: 720,
render: Dashboard
});
});
})();`ui.addFloatingPanel` passes { width, height } to render, so the charts take explicit pixel sizes from those props instead of Recharts' ResponsiveContainer. Registration inside `hooks.onMapReady` is replaced by id on every fire, which the Panels guide covers.
Available Chart Types#
utils.charts is the full Recharts 2 module, so every Recharts export is there. The common ones:
| Export | Use |
|---|---|
LineChart, Line | Trends over time |
BarChart, Bar | Comparisons |
PieChart, Pie, Cell | Proportions; Cell colors one slice |
AreaChart, Area | Volume over time |
RadarChart, Radar, PolarGrid, PolarAngleAxis | Several measures per item |
ComposedChart | Lines, bars and areas on one axis |
XAxis, YAxis, CartesianGrid | Axes and background grid |
Tooltip, Legend | Hover values and the series key |
ResponsiveContainer | Sizes a chart to its parent; needs a parent with a set height |
Rules of Thumb#
- Give each chart a pixel
widthandheight, or aResponsiveContainerinside a sized parent; a chart with no size renders nothing. - Cap the points per series (the trend above keeps 30).
- Redraw on a game hook (
onHourChange,onDayChange), not on a timer per frame. - Read the map's colors with `gameState.getMapColors` when the chart should match the theme.