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
87 changes: 82 additions & 5 deletions src/components/ComparisonPlots.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import ComparisonPlots from "./ComparisonPlots";
import type { ComparisonPlotsEntry } from "./ComparisonPlots";
import MultiLineChart from "./MultiLineChart";

vi.mock("./MultiLineChart", () => ({
default: () => <div data-testid="multi-line-chart" />,
}));
vi.mock("./VerticalBarChart", () => ({
default: () => <div data-testid="vertical-bar-chart" />,
default: vi.fn(() => <div data-testid="multi-line-chart" />),
}));
vi.mock("./NormalizedStackedAreaChart", () => ({
default: () => <div data-testid="stacked-area-chart" />,
Expand All @@ -17,6 +15,8 @@ vi.mock("../utils/geographyUtils", () => ({
geographyLabel: (geo: string) => geo,
}));

const mockedMultiLineChart = vi.mocked(MultiLineChart);

function makeEntry(
pathwayId: string,
geos: string[],
Expand Down Expand Up @@ -51,7 +51,34 @@ function makeEntry(
};
}

// Builds an entry with explicit values for a single metric, so tests can control
// the min/max that feed into the shared y-axis bounds computation.
function makeMetricEntry(
pathwayId: string,
metric: string,
values: number[],
): ComparisonPlotsEntry {
return {
pathwayId,
timeseriesdata: {
data: values.map((value, i) => ({
sector: "power",
metric,
geography: "Global",
year: String(2020 + i * 10),
value,
unit: "MtCO2e",
technology: metric,
})),
},
};
}

describe("ComparisonPlots", () => {
beforeEach(() => {
mockedMultiLineChart.mockClear();
});

it("shows a 'no timeseries data' message when all entries have null data", () => {
const entries: ComparisonPlotsEntry[] = [
{ pathwayId: "p1", timeseriesdata: null },
Expand Down Expand Up @@ -105,4 +132,54 @@ describe("ComparisonPlots", () => {
),
).toBeInTheDocument();
});

it("renders absolute emissions and emissions intensity as line charts", async () => {
const entries = [
makeEntry("p1", ["Global"], ["absoluteEmissions", "emissionsIntensity"]),
makeEntry("p2", ["Global"], ["absoluteEmissions", "emissionsIntensity"]),
];
render(<ComparisonPlots entries={entries} />);
const plotSelect = screen.getAllByRole("combobox")[0];
const user = userEvent.setup();

await user.selectOptions(plotSelect, "Absolute Emissions");
expect(screen.getAllByTestId("multi-line-chart")).toHaveLength(2);
expect(screen.queryByTestId("vertical-bar-chart")).not.toBeInTheDocument();

await user.selectOptions(plotSelect, "Emissions Intensity");
expect(screen.getAllByTestId("multi-line-chart")).toHaveLength(2);
expect(screen.queryByTestId("vertical-bar-chart")).not.toBeInTheDocument();
});

it("forces emissions intensity y-axis minimum to 0, synced across pathways", async () => {
const entries = [
makeMetricEntry("p1", "emissionsIntensity", [0.6, 0.15]),
makeMetricEntry("p2", "emissionsIntensity", [0.9, 0.3]),
];
render(<ComparisonPlots entries={entries} />);
const plotSelect = screen.getAllByRole("combobox")[0];
await userEvent.setup().selectOptions(plotSelect, "Emissions Intensity");

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

it("syncs absolute emissions y-axis to the natural min across pathways (not forced to 0)", async () => {
const entries = [
makeMetricEntry("p1", "absoluteEmissions", [50, 150]),
makeMetricEntry("p2", "absoluteEmissions", [-20, 300]),
];
render(<ComparisonPlots entries={entries} />);
const plotSelect = screen.getAllByRole("combobox")[0];
await userEvent.setup().selectOptions(plotSelect, "Absolute Emissions");

expect(mockedMultiLineChart.mock.calls.length).toBeGreaterThan(0);
mockedMultiLineChart.mock.calls.forEach(([props]) => {
expect(props.yMin).toBe(-20);
expect(props.yMax).toBe(300);
});
});
});
23 changes: 15 additions & 8 deletions src/components/ComparisonPlots.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { PlotType, TimeSeries } from "./PlotSelector";
import NormalizedStackedAreaChart from "./NormalizedStackedAreaChart";
import MultiLineChart from "./MultiLineChart";
import VerticalBarChart from "./VerticalBarChart";
import { geographyLabel } from "../utils/geographyUtils";

const PLOT_OPTIONS: { value: PlotType; label: string }[] = [
Expand Down Expand Up @@ -105,24 +104,30 @@ const PlotPanel: React.FC<PlotPanelProps> = ({
);
case "absoluteEmissions":
return (
<VerticalBarChart
<MultiLineChart
key={key}
data={filteredData}
width={dims.width}
height={dims.height}
metric="absoluteEmissions"
yMin={yMin}
yMax={yMax}
externalHoveredSeries={hoveredSeries}
onHoverSeries={onHoverSeries}
/>
);
case "emissionsIntensity":
return (
<VerticalBarChart
<MultiLineChart
key={key}
data={filteredData}
width={dims.width}
height={dims.height}
metric="emissionsIntensity"
yMin={yMin}
yMax={yMax}
externalHoveredSeries={hoveredSeries}
onHoverSeries={onHoverSeries}
/>
);
case "capacity":
Expand Down Expand Up @@ -221,13 +226,13 @@ const ComparisonPlots: React.FC<ComparisonPlotsProps> = ({ entries }) => {

// Shared y-axis bounds across all pathways for the current plot type + geography
const sharedYBounds = useMemo(() => {
const isMultiLine =
selectedPlot === "capacity" || selectedPlot === "generation";
const isBar =
const isLineChart =
selectedPlot === "capacity" ||
selectedPlot === "generation" ||
selectedPlot === "absoluteEmissions" ||
selectedPlot === "emissionsIntensity";

if (!isMultiLine && !isBar) return undefined;
if (!isLineChart) return undefined;

const allValues: number[] = [];
entries.forEach((e) => {
Expand All @@ -244,7 +249,9 @@ const ComparisonPlots: React.FC<ComparisonPlotsProps> = ({ entries }) => {
if (allValues.length === 0) return undefined;

const yMax = Math.max(...allValues);
const yMin = isMultiLine ? Math.min(...allValues) : 0;
// Emissions intensity axes always start at 0; other line charts use the natural data min.
const yMin =
selectedPlot === "emissionsIntensity" ? 0 : Math.min(...allValues);
return { yMin, yMax };
}, [entries, selectedPlot, selectedGeography]);

Expand Down
45 changes: 28 additions & 17 deletions src/components/MultiLineChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,15 @@ export default function MultiLineChart({
externalHoveredSeries,
onHoverSeries,
}: MultiLineChartProps) {
const d3data = useMemo(
() => data.data.filter((d) => d.sector === sector && d.metric === metric),
[data.data, sector, metric],
);
const d3data = useMemo(() => {
let filtered = data.data.filter(
(d) => d.sector === sector && d.metric === metric,
);
if (metric === "emissionsIntensity" || metric === "absoluteEmissions") {
filtered = filtered.map((d) => ({ ...d, technology: d.metric }));
}
return filtered;
}, [data.data, sector, metric]);

const ref = useRef<SVGSVGElement>(null);
const gx = useRef<SVGGElement>(null);
Expand All @@ -73,9 +78,7 @@ export default function MultiLineChart({
return `${capitalizeWords(sector)} ${capitalizeWords(metric)} [${unit}]`;
}, [d3data, sector, metric]);
const [selectRef, setSelectRef] = useState<string>(
data.data
.filter((d) => d.sector === sector && d.metric === metric)
.map((d) => d.technology)[0],
d3data.map((d) => d.technology)[0],
);

const isPointerOver = useRef(false);
Expand Down Expand Up @@ -228,16 +231,24 @@ export default function MultiLineChart({
.attr("data-technology", (d) => d[1][0].technology)
.attr("data-unit", (d) => d[1][0].unit);

// Update labels with capitalized technology names
const dodged = dodge(
groupedData.map((d) => y(d[1][d[1].length - 1].value)),
);

const labelData = groupedData.map((d, i) => ({
label: capitalizeWords(d[0]),
x: x(parse(d[1][d[1].length - 1].year) as Date),
y: dodged[i],
}));
// Update labels with capitalized technology names. Sector-level metrics
// (absolute emissions, emissions intensity) only ever have a single series,
// so a label is redundant and can overflow the chart's right margin,
// especially in the space-constrained Comparison View.
const showSeriesLabels =
metric !== "absoluteEmissions" && metric !== "emissionsIntensity";

let labelData: LabelData[] = [];
if (showSeriesLabels) {
const dodged = dodge(
groupedData.map((d) => y(d[1][d[1].length - 1].value)),
);
labelData = groupedData.map((d, i) => ({
label: capitalizeWords(d[0]),
x: x(parse(d[1][d[1].length - 1].year) as Date),
y: dodged[i],
}));
}

(
select(lines.current)
Expand Down
75 changes: 75 additions & 0 deletions src/components/PlotSelector.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PlotSelector } from "./PlotSelector";
import type { TimeSeries } from "./PlotSelector";
import MultiLineChart from "./MultiLineChart";

vi.mock("./MultiLineChart", () => ({
default: vi.fn(() => <div data-testid="multi-line-chart" />),
}));
vi.mock("./NormalizedStackedAreaChart", () => ({
default: () => <div data-testid="stacked-area-chart" />,
}));
vi.mock("../utils/geographyUtils", () => ({
geographyLabel: (geo: string) => geo,
}));

const mockedMultiLineChart = vi.mocked(MultiLineChart);

function makeTimeseries(metric: string): TimeSeries {
Comment thread
jacobvjk marked this conversation as resolved.
return {
data: [
{
sector: "power",
metric,
geography: "Global",
year: "2020",
value: 100,
unit: "MtCO2e",
technology: metric,
},
{
sector: "power",
metric,
geography: "Global",
year: "2030",
value: 200,
unit: "MtCO2e",
technology: metric,
},
],
};
}

describe("PlotSelector", () => {
beforeEach(() => {
mockedMultiLineChart.mockClear();
});

it("forces the emissions intensity line chart's y-axis minimum to 0", async () => {
render(
<PlotSelector timeseriesdata={makeTimeseries("emissionsIntensity")} />,
);

const plotSelect = screen.getByLabelText("Select Plot");
await userEvent.setup().selectOptions(plotSelect, "Emissions Intensity");

expect(mockedMultiLineChart).toHaveBeenCalled();
const props = mockedMultiLineChart.mock.calls.at(-1)?.[0];
expect(props?.yMin).toBe(0);
});

it("leaves the absolute emissions line chart's y-axis minimum unset (natural extent)", async () => {
render(
<PlotSelector timeseriesdata={makeTimeseries("absoluteEmissions")} />,
);

const plotSelect = screen.getByLabelText("Select Plot");
await userEvent.setup().selectOptions(plotSelect, "Absolute Emissions");

expect(mockedMultiLineChart).toHaveBeenCalled();
const props = mockedMultiLineChart.mock.calls.at(-1)?.[0];
expect(props?.yMin).toBeUndefined();
});
});
6 changes: 3 additions & 3 deletions src/components/PlotSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useState, useMemo } from "react";
import NormalizedStackedAreaChart from "./NormalizedStackedAreaChart";
import MultiLineChart from "./MultiLineChart";
import VerticalBarChart from "./VerticalBarChart";
import { geographyLabel } from "../utils/geographyUtils";
Comment thread
jacobvjk marked this conversation as resolved.

interface DataPoint {
Expand Down Expand Up @@ -155,7 +154,7 @@ export const PlotSelector: React.FC<PlotSelectorProps> = ({
case "absoluteEmissions":
return (
<div className="flex flex-col items-center">
<VerticalBarChart
<MultiLineChart
key={`${datasetId}-${selectedPlot}-${selectedGeography}`}
data={filteredData}
width={450}
Expand All @@ -167,12 +166,13 @@ export const PlotSelector: React.FC<PlotSelectorProps> = ({
case "emissionsIntensity":
return (
<div className="flex flex-col items-center">
<VerticalBarChart
<MultiLineChart
key={`${datasetId}-${selectedPlot}-${selectedGeography}`}
data={filteredData}
width={450}
height={300}
metric="emissionsIntensity"
yMin={0}
/>
</div>
);
Expand Down
Loading
Loading