diff --git a/src/components/ComparisonPlots.test.tsx b/src/components/ComparisonPlots.test.tsx index 30fd69ea..47472556 100644 --- a/src/components/ComparisonPlots.test.tsx +++ b/src/components/ComparisonPlots.test.tsx @@ -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(); + 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(); + 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 diff --git a/src/components/ComparisonPlots.tsx b/src/components/ComparisonPlots.tsx index 42471848..10129a37 100644 --- a/src/components/ComparisonPlots.tsx +++ b/src/components/ComparisonPlots.tsx @@ -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"; @@ -84,10 +85,21 @@ interface ComparisonPlotsProps { } const ComparisonPlots: React.FC = ({ - 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), + })), + [fullEntries], + ); const n = entries.length; /* @@ -159,7 +171,9 @@ const ComparisonPlots: React.FC = ({ // // 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" || @@ -173,6 +187,7 @@ const ComparisonPlots: React.FC = ({ entries.forEach((e, idx) => { const used = resolutions[idx]?.used; if (!used) return; + if (!hasDataForMetricAndGeo(e.timeseriesdata, selectedPlot, used)) return; e.timeseriesdata?.data ?.filter( (d) => diff --git a/src/components/DownloadDataset.test.tsx b/src/components/DownloadDataset.test.tsx index 4fba5b43..395bf941 100644 --- a/src/components/DownloadDataset.test.tsx +++ b/src/components/DownloadDataset.test.tsx @@ -41,4 +41,27 @@ describe("", () => { 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( + , + ); + expect(screen.queryByText(/show years up to 2050/)).toBeNull(); + + rerender( + , + ); + expect( + screen.getByText( + "The plots on this page show years up to 2050. This download contains every year.", + ), + ).toBeInTheDocument(); + }); }); diff --git a/src/components/DownloadDataset.tsx b/src/components/DownloadDataset.tsx index e99d981c..da8faf42 100644 --- a/src/components/DownloadDataset.tsx +++ b/src/components/DownloadDataset.tsx @@ -1,11 +1,17 @@ // 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; }; @@ -13,6 +19,7 @@ export default function DownloadDataset({ label, href, summary, + extendsPastPlots = false, className, }: Props) { return ( @@ -34,6 +41,12 @@ export default function DownloadDataset({ {summary && (

{summary}

)} + {extendsPastPlots && ( +

+ The plots on this page show years up to {MAX_PLOT_YEAR}. This download + contains every year. +

+ )}

Note: Minor deviations from the source data may occur due to light processing required for standardizing pathways to a common format. diff --git a/src/components/PlotGrid.test.tsx b/src/components/PlotGrid.test.tsx index 74cfc8ac..ea08ceda 100644 --- a/src/components/PlotGrid.test.tsx +++ b/src/components/PlotGrid.test.tsx @@ -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: () =>

, + default: vi.fn(() =>
), })); vi.mock("./NormalizedStackedAreaChart", () => ({ - default: () =>
, + default: vi.fn(() =>
), })); +const mockedMultiLineChart = vi.mocked(MultiLineChart); const SEA: Geography = { global: false, @@ -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( + 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( + 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( = ({ - timeseriesdata, + timeseriesdata: fullTimeseriesdata, datasetId, pathwayGeography, requestedGeography = null, @@ -78,6 +79,12 @@ export const PlotGrid: React.FC = ({ 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( diff --git a/src/pages/PathwayDetailPage.tsx b/src/pages/PathwayDetailPage.tsx index 239bc06b..4c1a0dd8 100644 --- a/src/pages/PathwayDetailPage.tsx +++ b/src/pages/PathwayDetailPage.tsx @@ -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 { @@ -417,7 +418,7 @@ const PathwayDetailPage: React.FC = () => { {tsIndexLoaded && datasets.length > 0 - ? datasets.map((d) => { + ? datasets.map((d, i) => { const label = d.label ?? d.datasetId; const summary = summarizeSummary(d.summary); return ( @@ -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) + } /> ); }) diff --git a/src/utils/plotYears.test.ts b/src/utils/plotYears.test.ts new file mode 100644 index 00000000..708317b7 --- /dev/null +++ b/src/utils/plotYears.test.ts @@ -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); + }); +}); diff --git a/src/utils/plotYears.ts b/src/utils/plotYears.ts new file mode 100644 index 00000000..96756de8 --- /dev/null +++ b/src/utils/plotYears.ts @@ -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( + 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; +}