Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/components/ActiveChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,30 @@ interface ActiveChartProps {
range: ChartRange | ResidentRange;
/** As-of snapshot for the residents stock views; null = latest. */
period: string | null;
/** Compare mode's shared count-axis max; see ImmigrationChartData.yMax. */
yMax?: number;
/** Compare mode: the shell lists policy events once; see ImmigrationChartData. */
hidePolicyList?: boolean;
}

/** Memoized so unrelated shell state doesn't re-render the chart. */
export const ActiveChart = memo<ActiveChartProps>(
({ chart, processingData, residentsData, filters, residentFilters, range, period }) => {
({ chart, processingData, residentsData, filters, residentFilters, range, period, yMax, hidePolicyList }) => {
if (chart.dataset === 'residents') {
const Chart = chart.component;
return <Chart data={residentsData} filters={residentFilters} range={range as ResidentRange} period={period} />;
}
const Chart = chart.component;
return <Chart data={processingData} filters={filters} range={range as ChartRange} />;
return <Chart data={processingData} filters={filters} range={range as ChartRange} yMax={yMax} hidePolicyList={hidePolicyList} />;
},
(prev, next) =>
prev.chart === next.chart &&
prev.processingData === next.processingData &&
prev.residentsData === next.residentsData &&
prev.range === next.range &&
prev.period === next.period &&
prev.yMax === next.yMax &&
prev.hidePolicyList === next.hidePolicyList &&
prev.filters.bureau === next.filters.bureau &&
prev.filters.type === next.filters.type &&
prev.residentFilters.region === next.residentFilters.region &&
Expand Down
9 changes: 8 additions & 1 deletion src/components/ChartDataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,17 @@ interface ChartDataTableProps {
data: ImmigrationData[];
filters: { bureau: string; type: string };
range: ChartRange;
/**
* Compare mode renders one table per bureau; this names whose, so the two
* identical "View data table" controls stay distinguishable to a screen reader.
*/
label?: string;
}

/** Room for the row-label column plus each data column. */
const minWidthFor = (columns: number): number => Math.max(560, 140 + columns * 84);

export const ChartDataTable: React.FC<ChartDataTableProps> = ({ table, chartKey, data, filters, range }) => {
export const ChartDataTable: React.FC<ChartDataTableProps> = ({ table, chartKey, data, filters, range, label }) => {
const [open, setOpen] = useState(false);
const { t, formatters } = useLocale();

Expand Down Expand Up @@ -65,6 +70,7 @@ export const ChartDataTable: React.FC<ChartDataTableProps> = ({ table, chartKey,
<button
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-label={label ? `${t(open ? 'table.hide' : 'table.view')} (${label})` : undefined}
className="flex items-center gap-1 text-xs text-primary hover:opacity-80"
>
{open ? <ChevronUp className="size-3.5" /> : <ChevronDown className="size-3.5" />}
Expand All @@ -73,6 +79,7 @@ export const ChartDataTable: React.FC<ChartDataTableProps> = ({ table, chartKey,
{open && (
<button
onClick={downloadCsv}
aria-label={label ? `${t('table.downloadCsv')} (${label})` : undefined}
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs text-secondary-foreground hover:bg-muted"
>
<Download className="size-3.5" aria-hidden="true" />
Expand Down
70 changes: 61 additions & 9 deletions src/components/DashboardShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ import buildInfo from '../buildInfo';
import { applicationOptions } from '../constants/applicationOptions';
import { bureauOptions } from '../constants/bureauOptions';
import { nationalities, NATIONALITY_REGIONS, nationalityByCode } from '../constants/nationalities';
import { POLICY_EVENTS } from '../constants/policyEvents';
import { useTheme } from '../contexts/ThemeContext';
import type { DashboardMeta, ImmigrationData } from '../hooks/useImmigrationData';
import { useMediaQuery } from '../hooks/useMediaQuery';
import { useLocale } from '../i18n/LocaleContext';
import { T } from '../i18n/T';
import {
Expand All @@ -39,13 +41,14 @@ import type { ResidentRecord } from '../utils/residentsData';
import type { ResidentRange } from '../utils/residentsSelectors';
import { getAllPeriods } from '../utils/residentsSelectors';
import { parsePeriodParam, parseStatusParam } from '../utils/residentUrlParams';
import type { ChartRange } from '../utils/selectors';
import { type ChartRange, getAllMonths, monthsForRange } from '../utils/selectors';
import type { ApplicationDetails } from '../utils/urlApplicationDetails';
import { getApplicationDetailsFromParams, isEstimatorPermalink } from '../utils/urlApplicationDetails';
import type { Dataset } from './common/ChartComponents';
import { CHART_KEYS, CHARTS_BY_DATASET, datasetForChart,DATASETS } from './common/ChartComponents';
import { LanguageSwitcher } from './common/LanguageSwitcher';
import { PeriodSelector } from './common/PeriodSelector';
import { PolicyEventList } from './common/PolicyEventList';
import { SnapshotPeriodSelector } from './common/SnapshotPeriodSelector';
import { GitHubIcon } from './icons/GitHubIcon';
import { JapanFlagIcon } from './icons/JapanFlagIcon';
Expand Down Expand Up @@ -222,6 +225,27 @@ export const DashboardShell: React.FC<DashboardShellProps> = ({ data, meta, resi
? compare
: null;

// Both panes plot to one count axis so bar heights and line positions can be
// read across. The pane pair is only on screen from md up (Tailwind's 768px);
// below that the primary chart is alone and keeps its own scale.
const sideBySide = useMediaQuery('(min-width: 768px)');
const sharedYMax = useMemo(() => {
if (!compareBureau || !sideBySide || activeChart.dataset !== 'processing' || !activeChart.axisMax) return undefined;
const { axisMax } = activeChart;
const rangeArg = range as ChartRange;
const max = Math.max(
axisMax(chartData, effectiveFilters, rangeArg),
axisMax(chartData, { bureau: compareBureau, type: effectiveFilters.type }, rangeArg)
);
return max > 0 ? max : undefined;
}, [compareBureau, sideBySide, activeChart, chartData, effectiveFilters, range]);

// The events on the plot for the shared list; same rule the chart applies.
const compareEvents = useMemo(() => {
const periods = new Set(monthsForRange(getAllMonths(chartData), range as ChartRange));
return POLICY_EVENTS.filter((event) => periods.has(event.period));
}, [chartData, range]);

// --- Estimator state, lifted so the sidebar and the mobile sheet share it ---
const [estimatorDetails, setEstimatorDetails] = useState<ApplicationDetails>(() =>
getApplicationDetailsFromParams(searchParams)
Expand Down Expand Up @@ -595,6 +619,8 @@ export const DashboardShell: React.FC<DashboardShellProps> = ({ data, meta, resi
residentFilters={effectiveResidentFilters}
range={range}
period={effectivePeriod}
yMax={sharedYMax}
hidePolicyList={Boolean(compareBureau)}
/>
</div>
{/* The comparison pane follows its control: both are
Expand All @@ -615,21 +641,47 @@ export const DashboardShell: React.FC<DashboardShellProps> = ({ data, meta, resi
residentFilters={effectiveResidentFilters}
range={range}
period={effectivePeriod}
yMax={sharedYMax}
hidePolicyList
/>
</div>
)}
</div>
{/* Policy events are national — the same for every bureau —
so compare mode lists them once, full width, rather
than once per pane. */}
{compareBureau && activeChart.dataset === 'processing' && activeChart.policyEvents && (
<PolicyEventList events={compareEvents} />
)}
{/* Narrowed on activeChart, not `dataset`, so TypeScript
can reach `table` on the processing half of the
registry union. */}
registry union. One table per bureau when comparing,
so each keeps its own CSV download. */}
{activeChart.dataset === 'processing' && (
<ChartDataTable
table={activeChart.table}
chartKey={activeChart.key}
data={chartData}
filters={effectiveFilters}
range={range as ChartRange}
/>
<div className={compareBureau ? 'grid gap-x-4 md:grid-cols-2' : undefined}>
<div className="min-w-0">
<ChartDataTable
table={activeChart.table}
chartKey={activeChart.key}
data={chartData}
filters={effectiveFilters}
range={range as ChartRange}
label={compareBureau ? bureauLabel(effectiveFilters.bureau) : undefined}
/>
</div>
{compareBureau && (
<div className="hidden min-w-0 md:block">
<ChartDataTable
table={activeChart.table}
chartKey={activeChart.key}
data={chartData}
filters={{ bureau: compareBureau, type: effectiveFilters.type }}
range={range as ChartRange}
label={bureauLabel(compareBureau)}
/>
</div>
)}
</div>
)}
</>
)}
Expand Down
11 changes: 11 additions & 0 deletions src/components/bklit/charts/line-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ export interface LineChartProps {
onPhaseChange?: (phase: ChartPhase) => void;
/** LOCAL MODIFICATION: x tick/ticker label format override — see time-series-chart-shell.tsx. */
formatDateLabel?: (date: Date) => string;
/**
* LOCAL MODIFICATION: axes held at a fixed domain, keyed by `yAxisId` — see
* composed-chart.tsx. Lets two charts share one scale. (Re-apply after a
* re-vendor.)
*/
yAxisDomains?: Record<string, [number, number]>;
/** Child components (Line, Grid, ChartTooltip, etc.) */
children: ReactNode;
}
Expand Down Expand Up @@ -158,6 +164,7 @@ interface ChartInnerProps {
xDomainSlotCount?: number;
tweenYDomainOnXDomainChange?: boolean;
formatDateLabel?: (date: Date) => string;
yAxisDomains?: Record<string, [number, number]>;
children: ReactNode;
containerRef: React.RefObject<HTMLDivElement | null>;
onPhaseChange: (phase: ChartPhase) => void;
Expand All @@ -181,6 +188,7 @@ function ChartInner({
xDomainSlotCount,
tweenYDomainOnXDomainChange,
formatDateLabel,
yAxisDomains,
children,
containerRef,
onPhaseChange,
Expand Down Expand Up @@ -208,6 +216,7 @@ function ChartInner({
xDataKey={xDataKey}
xDomain={xDomain}
xDomainSlotCount={xDomainSlotCount}
yAxisDomains={yAxisDomains}
yDomainTween={yDomainTween}
yDomainTweenDuration={yDomainTweenDuration}
>
Expand Down Expand Up @@ -236,6 +245,7 @@ export function LineChart({
style,
onPhaseChange,
formatDateLabel,
yAxisDomains,
children,
}: LineChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -292,6 +302,7 @@ export function LineChart({
xDataKey={xDataKey}
xDomain={xDomain}
xDomainSlotCount={xDomainSlotCount}
yAxisDomains={yAxisDomains}
yDomainTween={yDomainTween}
yDomainTweenDuration={yDomainTweenDuration}
>
Expand Down
65 changes: 48 additions & 17 deletions src/components/charts/CategorySubmissionsLineChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Line, LineChart } from '../bklit/charts/line-chart';
import { ChartTooltip } from '../bklit/charts/tooltip';
import { XAxis } from '../bklit/charts/x-axis';
import { YAxis } from '../bklit/charts/y-axis';
import { niceYDomain } from '../bklit/charts/y-domain-utils';
import type { ImmigrationChartData } from '../common/ChartComponents';
import { SeriesLegend } from '../common/SeriesLegend';

Expand All @@ -31,7 +32,42 @@ const SERIES = [
{ id: 'permanent', labelKey: 'chart.types.series.permanent', type: '60', color: 'var(--chart-6)' },
] as const;

export const CategorySubmissionsLineChart: React.FC<ImmigrationChartData> = ({ data, filters, range }) => {
const buildTypeRows = (
data: ImmigrationChartData['data'],
filters: ImmigrationChartData['filters'],
range: ImmigrationChartData['range']
): Record<string, unknown>[] =>
monthsForRange(getAllMonths(data), range).map((month) => {
// 'all' bureau = the official nationwide aggregate row
const monthData = selectData(data, {
month,
scope: bureauScopeFromFilter(filters.bureau),
status: STATUS_CODES.NEW_APPLICATIONS,
});
const row: Record<string, unknown> = { date: new Date(`${month}-01T00:00:00`) };
for (const series of SERIES) {
row[series.id] = monthData.reduce((sum, entry) => (entry.type === series.type ? sum + entry.value : sum), 0);
}
return row;
});

/**
* The highest monthly value of any series. Compare mode shares the greater of
* the two bureaus' values so both panes draw to one scale. Ignores the legend's
* hidden series — that toggle is per pane, and a scale that moved when one pane
* hid a line would stop the two matching.
*/
export const typesAxisMax = (
data: ImmigrationChartData['data'],
filters: ImmigrationChartData['filters'],
range: ImmigrationChartData['range']
): number =>
buildTypeRows(data, filters, range).reduce(
(max, row) => Math.max(max, ...SERIES.map((series) => Number(row[series.id]))),
0
);

export const CategorySubmissionsLineChart: React.FC<ImmigrationChartData> = ({ data, filters, range, yMax }) => {
const { t, formatters } = useLocale();
const series = useMemo(() => SERIES.map((entry) => ({ ...entry, label: t(entry.labelKey) })), [t]);
const [hiddenSeries, setHiddenSeries] = useState<ReadonlySet<string>>(() => new Set());
Expand All @@ -43,22 +79,16 @@ export const CategorySubmissionsLineChart: React.FC<ImmigrationChartData> = ({ d
});
const visibleSeries = series.filter((entry) => !hiddenSeries.has(entry.id));

const chartData = useMemo(() => {
const months = monthsForRange(getAllMonths(data), range);
return months.map((month) => {
// 'all' bureau = the official nationwide aggregate row
const monthData = selectData(data, {
month,
scope: bureauScopeFromFilter(filters.bureau),
status: STATUS_CODES.NEW_APPLICATIONS,
});
const row: Record<string, unknown> = { date: new Date(`${month}-01T00:00:00`) };
for (const series of SERIES) {
row[series.id] = monthData.reduce((sum, entry) => (entry.type === series.type ? sum + entry.value : sum), 0);
}
return row;
});
}, [data, filters.bureau, range]);
const chartData = useMemo(
() => buildTypeRows(data, { bureau: filters.bureau, type: filters.type }, range),
[data, filters.bureau, filters.type, range]
);
// Only when compare mode supplies a shared max; otherwise the domain keeps
// tweening to whichever series are visible.
const yAxisDomains = useMemo<Record<string, [number, number]> | undefined>(
() => (yMax && yMax > 0 ? { left: niceYDomain([0, yMax]) } : undefined),
[yMax]
);

return (
<div className="chart-card-content">
Expand Down Expand Up @@ -87,6 +117,7 @@ export const CategorySubmissionsLineChart: React.FC<ImmigrationChartData> = ({ d
<LineChart
data={chartData}
aspectRatio="16 / 8"
yAxisDomains={yAxisDomains}
// Monthly points are all on the 1st — the default month+day labels
// drop the year, which is ambiguous across multi-year ranges.
formatDateLabel={(date) => formatters.monthYear(date)}
Expand Down
Loading
Loading