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.

js
(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.

js
    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.

js
    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.

js
    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:

js
    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#

js
    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:

ExportUse
LineChart, LineTrends over time
BarChart, BarComparisons
PieChart, Pie, CellProportions; Cell colors one slice
AreaChart, AreaVolume over time
RadarChart, Radar, PolarGrid, PolarAngleAxisSeveral measures per item
ComposedChartLines, bars and areas on one axis
XAxis, YAxis, CartesianGridAxes and background grid
Tooltip, LegendHover values and the series key
ResponsiveContainerSizes a chart to its parent; needs a parent with a set height

Rules of Thumb#

  1. Give each chart a pixel width and height, or a ResponsiveContainer inside a sized parent; a chart with no size renders nothing.
  2. Cap the points per series (the trend above keeps 30).
  3. Redraw on a game hook (onHourChange, onDayChange), not on a timer per frame.
  4. Read the map's colors with `gameState.getMapColors` when the chart should match the theme.