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
43 changes: 43 additions & 0 deletions src/components/ComparisonPlots.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,49 @@ describe("ComparisonPlots", () => {
});
});

it("plots no year after 2050, and scales the y-axis to what it plots", async () => {
// Years 2020..2060: the 2060 peak must neither be drawn nor stretch the
// shared axis. The download keeps it; the plots do not.
const entries = [
makeMetricEntry("p1", "absoluteEmissions", [100, 200, 300, 400, 900]),
makeMetricEntry("p2", "absoluteEmissions", [50, 150]),
];
render(<ComparisonPlots entries={entries} />);
await userEvent
.setup()
.selectOptions(screen.getAllByRole("combobox")[0], "Absolute Emissions");

expect(mockedMultiLineChart.mock.calls.length).toBeGreaterThan(0);
mockedMultiLineChart.mock.calls.forEach(([props]) => {
expect(props.yMax).toBe(400);
const years = props.data.data.map((d) => Number(d.year));
expect(Math.max(...years)).toBeLessThanOrEqual(2050);
});
});

it("leaves a column the cap reduced to one year out of the shared y-axis", async () => {
// p2 runs 2050..2060, so only 2050 survives the cap: its panel is hidden,
// and its 10,000 must not flatten p1's 100..200 series.
const p2 = makeMetricEntry("p2", "absoluteEmissions", [10000, 20000]);
p2.timeseriesdata?.data.forEach((d, i) => {
d.year = String(2050 + i * 10);
});
const entries = [
makeMetricEntry("p1", "absoluteEmissions", [100, 200]),
p2,
];
render(<ComparisonPlots entries={entries} />);
await userEvent
.setup()
.selectOptions(screen.getAllByRole("combobox")[0], "Absolute Emissions");

expect(mockedMultiLineChart.mock.calls.length).toBeGreaterThan(0);
mockedMultiLineChart.mock.calls.forEach(([props]) => {
expect(props.yMin).toBe(100);
expect(props.yMax).toBe(200);
});
});

describe("sector segment badge", () => {
it("captions each column with the part of the sector the metric covers", async () => {
// Matches the detail page's small multiples, which caption each panel
Expand Down
19 changes: 17 additions & 2 deletions src/components/ComparisonPlots.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "../utils/geographyFallback";
import { getSectorDefinition } from "../utils/timeseriesTaxonomy";
import { plottedSegments } from "../utils/timeseriesSegments";
import { capToPlotYears } from "../utils/plotYears";
import { getSectorSegmentTooltip } from "../utils/tooltipUtils";
import { useElementWidth } from "../hooks/useElementWidth";

Expand Down Expand Up @@ -84,10 +85,21 @@ interface ComparisonPlotsProps {
}

const ComparisonPlots: React.FC<ComparisonPlotsProps> = ({
entries,
entries: fullEntries,
requestedGeographies = NO_REQUESTS,
requestedSector = null,
}) => {
// Capped once, here, so everything below agrees with what is drawn — the
// shared y-axis above all, which would otherwise stretch to values past
// 2050 that no chart shows.
const entries = useMemo(
() =>
fullEntries.map((e) => ({
...e,
timeseriesdata: capToPlotYears(e.timeseriesdata),
Comment thread
Copilot marked this conversation as resolved.
})),
[fullEntries],
);
const n = entries.length;

/*
Expand Down Expand Up @@ -159,7 +171,9 @@ const ComparisonPlots: React.FC<ComparisonPlotsProps> = ({
//
// Each entry contributes the rows for ITS OWN resolved geography: filtering
// everything on one token would compute the axis from rows no chart shows,
// and leave columns that fell back scaled to someone else's data.
// and leave columns that fell back scaled to someone else's data. A column
// PlotPanel hides (fewer than two years, e.g. once capped at 2050) adds
// nothing either, or it would stretch the axis of the charts that are shown.
const sharedYBounds = useMemo(() => {
const isLineChart =
selectedPlot === "capacity" ||
Expand All @@ -173,6 +187,7 @@ const ComparisonPlots: React.FC<ComparisonPlotsProps> = ({
entries.forEach((e, idx) => {
const used = resolutions[idx]?.used;
if (!used) return;
if (!hasDataForMetricAndGeo(e.timeseriesdata, selectedPlot, used)) return;
e.timeseriesdata?.data
?.filter(
(d) =>
Expand Down
23 changes: 23 additions & 0 deletions src/components/DownloadDataset.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,27 @@ describe("<DownloadDataset />", () => {
screen.getByText(/Minor deviations from the source data may occur/),
).toBeInTheDocument();
});

it("says the download is complete only when it runs past the plots", () => {
const { rerender } = render(
<DownloadDataset
label="Dataset"
href="/data/ds.csv"
/>,
);
expect(screen.queryByText(/show years up to 2050/)).toBeNull();

rerender(
<DownloadDataset
label="Dataset"
href="/data/ds.csv"
extendsPastPlots
/>,
);
expect(
screen.getByText(
"The plots on this page show years up to 2050. This download contains every year.",
),
).toBeInTheDocument();
});
});
13 changes: 13 additions & 0 deletions src/components/DownloadDataset.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
// src/components/DownloadDataset.tsx
import React from "react";
import AdditionalInfoBox from "./AdditionalInfoBox";
import { MAX_PLOT_YEAR } from "../utils/plotYears";

type Props = {
label: string;
href: string;
summary?: string;
/**
* The dataset runs past the last year the plots show, so the summary's year
* range and the plots disagree; say that the download is complete.
*/
extendsPastPlots?: boolean;
className?: string;
};

export default function DownloadDataset({
label,
href,
summary,
extendsPastPlots = false,
className,
}: Props) {
return (
Expand All @@ -34,6 +41,12 @@ export default function DownloadDataset({
{summary && (
<p className="text-xs text-neutral-500 mt-1 truncate">{summary}</p>
)}
{extendsPastPlots && (
<p className="text-xs text-rmigray-700 mt-1">
The plots on this page show years up to {MAX_PLOT_YEAR}. This download
contains every year.
</p>
)}
<p className="text-xs text-rmigray-700 mt-1">
Note: Minor deviations from the source data may occur due to light
processing required for standardizing pathways to a common format.
Expand Down
47 changes: 45 additions & 2 deletions src/components/PlotGrid.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import PlotGrid from "./PlotGrid";
import MultiLineChart from "./MultiLineChart";
import type { Geography } from "../types";

vi.mock("./MultiLineChart", () => ({
default: () => <div data-testid="multi-line-chart" />,
default: vi.fn(() => <div data-testid="multi-line-chart" />),
}));
vi.mock("./NormalizedStackedAreaChart", () => ({
default: () => <div data-testid="stacked-area-chart" />,
default: vi.fn(() => <div data-testid="stacked-area-chart" />),
}));
const mockedMultiLineChart = vi.mocked(MultiLineChart);

const SEA: Geography = {
global: false,
Expand Down Expand Up @@ -59,6 +61,47 @@ describe("PlotGrid", () => {
expect(screen.getAllByTestId("multi-line-chart")).toHaveLength(2);
});

describe("years after 2050", () => {
const row = (metric: string, year: number) => ({
...rows(metric, "Global")[0],
year: String(year),
});

it("hands the charts no year after 2050", () => {
mockedMultiLineChart.mockClear();
render(
<PlotGrid
timeseriesdata={{
data: [2030, 2050, 2070, 2100].map((y) => row("capacity", y)),
}}
pathwayGeography={SEA}
/>,
);

expect(mockedMultiLineChart).toHaveBeenCalled();
mockedMultiLineChart.mock.calls.forEach(([props]) => {
expect(props.data.data.map((d) => d.year)).toEqual(["2030", "2050"]);
});
});

it("drops a panel left with a single year once later years are cut", () => {
render(
<PlotGrid
timeseriesdata={{
data: [
...[2050, 2060, 2070].map((y) => row("capacity", y)),
...[2030, 2040].map((y) => row("generation", y)),
],
}}
pathwayGeography={SEA}
/>,
);

expect(screen.queryByText("Capacity")).toBeNull();
expect(screen.getByText("Generation")).toBeInTheDocument();
});
});

it("skips a plot type that has no data at any geography", () => {
render(
<PlotGrid
Expand Down
9 changes: 8 additions & 1 deletion src/components/PlotGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "../utils/geographyFallback";
import { getSectorDefinition } from "../utils/timeseriesTaxonomy";
import { plottedSegments } from "../utils/timeseriesSegments";
import { capToPlotYears } from "../utils/plotYears";
import { getSectorSegmentTooltip } from "../utils/tooltipUtils";

/** Panel size for the small multiples — two columns at desktop width. */
Expand Down Expand Up @@ -69,7 +70,7 @@ interface PlotGridProps {
* shows PlotPanel's empty state, because that absence is informative.
*/
export const PlotGrid: React.FC<PlotGridProps> = ({
timeseriesdata,
timeseriesdata: fullTimeseriesdata,
datasetId,
pathwayGeography,
requestedGeography = null,
Expand All @@ -78,6 +79,12 @@ export const PlotGrid: React.FC<PlotGridProps> = ({
title,
className = "",
}) => {
// Capped once, here, so everything below agrees with what is drawn.
const timeseriesdata = useMemo(
() => capToPlotYears(fullTimeseriesdata),
[fullTimeseriesdata],
);

const availableGeographies = useMemo(() => {
if (!timeseriesdata?.data) return [];
return Array.from(
Expand Down
8 changes: 7 additions & 1 deletion src/pages/PathwayDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type { TimeSeries } from "../components/PlotSelector";
import PlotGrid from "../components/PlotGrid";
import { PLOT_ORDER } from "../components/PlotPanel";
import getTemperatureColor from "../utils/getTemperatureColor";
import { extendsPastPlotYears } from "../utils/plotYears";
import TextWithTooltip from "../components/TextWithTooltip";
import RegionMembersTooltip from "../components/RegionMembersTooltip";
import {
Expand Down Expand Up @@ -417,7 +418,7 @@ const PathwayDetailPage: React.FC = () => {
<PublicationBlock publication={pathway.publication} />

{tsIndexLoaded && datasets.length > 0
? datasets.map((d) => {
? datasets.map((d, i) => {
const label = d.label ?? d.datasetId;
const summary = summarizeSummary(d.summary);
return (
Expand All @@ -426,6 +427,11 @@ const PathwayDetailPage: React.FC = () => {
label={label}
href={d.path}
summary={summary}
// The plots draw the first dataset, so only it is known to
// run past them.
extendsPastPlots={
i === 0 && extendsPastPlotYears(timeseriesdata)
}
/>
);
})
Expand Down
40 changes: 40 additions & 0 deletions src/utils/plotYears.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import {
MAX_PLOT_YEAR,
capToPlotYears,
extendsPastPlotYears,
} from "./plotYears";

const rows = (...years: (string | number)[]) => ({
data: years.map((year) => ({ year, value: 1 })),
});

describe("capToPlotYears", () => {
it("keeps 2050 and drops every later year", () => {
expect(MAX_PLOT_YEAR).toBe(2050);
const capped = capToPlotYears(rows(2040, 2050, 2051, 2100));
expect(capped?.data.map((d) => d.year)).toEqual([2040, 2050]);
});

it("reads string years as numbers", () => {
const capped = capToPlotYears(rows("2030", "2050", "2060"));
expect(capped?.data.map((d) => d.year)).toEqual(["2030", "2050"]);
});

it("keeps the other fields of the timeseries", () => {
const capped = capToPlotYears({ ...rows(2060), datasetId: "x" });
expect(capped).toEqual({ data: [], datasetId: "x" });
});

it("passes null through", () => {
expect(capToPlotYears(null)).toBeNull();
});
});

describe("extendsPastPlotYears", () => {
it("is true only when some row is after 2050", () => {
expect(extendsPastPlotYears(rows(2020, 2050))).toBe(false);
expect(extendsPastPlotYears(rows(2020, "2055"))).toBe(true);
expect(extendsPastPlotYears(null)).toBe(false);
});
});
35 changes: 35 additions & 0 deletions src/utils/plotYears.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* The last year the benchmark plots show (#1010). Pathways end anywhere from
* 2030 to 2100, and plots with wildly different x-axes are hard to compare,
* so every plot stops here. Downloads still carry the full data.
*/
export const MAX_PLOT_YEAR = 2050;

interface YearRow {
year: string | number;
}

/**
* The timeseries without its rows after {@link MAX_PLOT_YEAR}. Apply it where
* rows enter the plots, before anything is derived from them, so the shared
* y-axis, which panels show and the segment badges all agree with what is
* drawn.
*
* `Number()` because the type says string but the JSON carries numbers.
*/
export function capToPlotYears<T extends { data: readonly YearRow[] }>(
timeseries: T | null,
): T | null {
if (!timeseries?.data) return timeseries;
return {
...timeseries,
data: timeseries.data.filter((d) => Number(d.year) <= MAX_PLOT_YEAR),
};
}

/** Whether a timeseries has rows the plots leave out. */
export function extendsPastPlotYears(
timeseries: { data: readonly YearRow[] } | null,
): boolean {
return timeseries?.data?.some((d) => Number(d.year) > MAX_PLOT_YEAR) ?? false;
}
Loading