diff --git a/src/components/ComparisonPlots.test.tsx b/src/components/ComparisonPlots.test.tsx
index 123eb468..5f7b4993 100644
--- a/src/components/ComparisonPlots.test.tsx
+++ b/src/components/ComparisonPlots.test.tsx
@@ -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: () =>
,
-}));
-vi.mock("./VerticalBarChart", () => ({
- default: () => ,
+ default: vi.fn(() => ),
}));
vi.mock("./NormalizedStackedAreaChart", () => ({
default: () => ,
@@ -17,6 +15,8 @@ vi.mock("../utils/geographyUtils", () => ({
geographyLabel: (geo: string) => geo,
}));
+const mockedMultiLineChart = vi.mocked(MultiLineChart);
+
function makeEntry(
pathwayId: string,
geos: string[],
@@ -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 },
@@ -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();
+ 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();
+ 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();
+ 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);
+ });
+ });
});
diff --git a/src/components/ComparisonPlots.tsx b/src/components/ComparisonPlots.tsx
index 4675a850..936f0cab 100644
--- a/src/components/ComparisonPlots.tsx
+++ b/src/components/ComparisonPlots.tsx
@@ -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 }[] = [
@@ -105,24 +104,30 @@ const PlotPanel: React.FC = ({
);
case "absoluteEmissions":
return (
-
);
case "emissionsIntensity":
return (
-
);
case "capacity":
@@ -221,13 +226,13 @@ const ComparisonPlots: React.FC = ({ 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) => {
@@ -244,7 +249,9 @@ const ComparisonPlots: React.FC = ({ 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]);
diff --git a/src/components/MultiLineChart.tsx b/src/components/MultiLineChart.tsx
index 8d53ccdc..84b81ec1 100644
--- a/src/components/MultiLineChart.tsx
+++ b/src/components/MultiLineChart.tsx
@@ -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(null);
const gx = useRef(null);
@@ -73,9 +78,7 @@ export default function MultiLineChart({
return `${capitalizeWords(sector)} ${capitalizeWords(metric)} [${unit}]`;
}, [d3data, sector, metric]);
const [selectRef, setSelectRef] = useState(
- 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);
@@ -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)
diff --git a/src/components/PlotSelector.test.tsx b/src/components/PlotSelector.test.tsx
new file mode 100644
index 00000000..4fdeff97
--- /dev/null
+++ b/src/components/PlotSelector.test.tsx
@@ -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(() => ),
+}));
+vi.mock("./NormalizedStackedAreaChart", () => ({
+ default: () => ,
+}));
+vi.mock("../utils/geographyUtils", () => ({
+ geographyLabel: (geo: string) => geo,
+}));
+
+const mockedMultiLineChart = vi.mocked(MultiLineChart);
+
+function makeTimeseries(metric: string): TimeSeries {
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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();
+ });
+});
diff --git a/src/components/PlotSelector.tsx b/src/components/PlotSelector.tsx
index d5ab6d87..2e324e88 100644
--- a/src/components/PlotSelector.tsx
+++ b/src/components/PlotSelector.tsx
@@ -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";
interface DataPoint {
@@ -155,7 +154,7 @@ export const PlotSelector: React.FC = ({
case "absoluteEmissions":
return (
-
= ({
case "emissionsIntensity":
return (
-
);
diff --git a/src/components/VerticalBarChart.tsx b/src/components/VerticalBarChart.tsx
deleted file mode 100644
index 4d87a2c8..00000000
--- a/src/components/VerticalBarChart.tsx
+++ /dev/null
@@ -1,259 +0,0 @@
-import { select } from "d3-selection";
-import { scaleBand, scaleLinear, ScaleBand, ScaleLinear } from "d3-scale";
-import { max } from "d3-array";
-import { axisBottom, axisLeft } from "d3-axis";
-import "d3-transition";
-import { MouseEvent, useRef, useEffect, useMemo } from "react";
-import { capitalizeWords } from "../utils/capitalizeWords";
-
-interface DataPoint {
- sector: string;
- metric: string;
- year: string;
- value: number;
- unit: string;
-}
-
-interface ChartData {
- data: DataPoint[];
-}
-
-interface VerticalBarChartProps {
- data: ChartData;
- width?: number;
- height?: number;
- marginTop?: number;
- marginRight?: number;
- marginBottom?: number;
- marginLeft?: number;
- sector?: string;
- metric?: string;
- barColor?: string;
- yMax?: number;
-}
-
-interface ChartScales {
- x: ScaleBand;
- y: ScaleLinear;
- unit: string;
-}
-
-export default function VerticalBarChart({
- data,
- width = 640,
- height = 400,
- marginTop = 15,
- marginRight = 20,
- marginBottom = 30,
- marginLeft = 40,
- sector = "power",
- metric = "emissionsIntensity",
- barColor = "midnightblue",
- yMax,
-}: VerticalBarChartProps) {
- const d3data = useMemo(
- () => data.data.filter((d) => d.sector === sector && d.metric === metric),
- [data.data, sector, metric],
- );
-
- const ref = useRef(null);
- const gx = useRef(null);
- const gy = useRef(null);
- const bars = useRef(null);
- const tooltips = useRef(null);
-
- const chartTitle = useMemo(() => {
- const unit = d3data[0]?.unit ?? "";
- return `${capitalizeWords(sector)} ${capitalizeWords(metric)} [${unit}]`;
- }, [d3data, sector, metric]);
-
- const chartSetup = useMemo(() => {
- const unit = d3data[0]?.unit ?? "";
-
- const x = scaleBand()
- .domain(d3data.map((d) => d.year).sort())
- .range([marginLeft, width - marginRight])
- .padding(0.6);
-
- const y = scaleLinear()
- .domain([0, yMax ?? max(d3data, (d) => d.value) ?? 0])
- .range([height - marginBottom, marginTop]);
-
- return { x, y, unit };
- }, [
- d3data,
- yMax,
- width,
- height,
- marginLeft,
- marginRight,
- marginTop,
- marginBottom,
- ]);
-
- useEffect(() => {
- if (
- !ref.current ||
- !gx.current ||
- !gy.current ||
- !bars.current ||
- !tooltips.current
- )
- return;
-
- const { x, y } = chartSetup;
-
- // Update X axis
- select(gx.current)
- .transition()
- .duration(750)
- .call(axisBottom(x).tickSize(0))
- .style("font-size", "14px")
- .selectAll("text")
- .attr("transform", "rotate(-45)")
- .attr("text-anchor", "end")
- .attr("dx", "0")
- .attr("dy", "0.71em");
-
- // Update Y axis
- select(gy.current)
- .transition()
- .duration(750)
- .call(axisLeft(y).tickSize(0))
- .style("font-size", "12px");
-
- select(gy.current).select(".domain").remove();
-
- select(gy.current)
- .selectAll(".tick line")
- .clone()
- .attr("x2", width)
- .attr("stroke-opacity", "0.1");
-
- // Update bars
- select(bars.current)
- .attr("fill", barColor)
- .selectAll("rect")
- .data(d3data)
- .join("rect")
- .attr("x", (d) => x(d.year) ?? 0)
- .attr("y", (d) => y(d.value))
- .attr("height", (d) => y(0) - y(d.value))
- .attr("width", x.bandwidth())
- .on("mouseover", onMouseOver)
- .on("mouseout", onMouseOut);
-
- // Update tooltips
- const ttwidth = 110;
-
- select(tooltips.current)
- .selectAll("path")
- .data(d3data)
- .join("path")
- .attr("display", "none")
- .attr("fill", "white")
- .attr("stroke", "black")
- .attr("stroke-width", 1)
- .attr("stroke-linejoin", "round")
- .attr(
- "transform",
- (d) =>
- "translate(" +
- (x(d.year) + x.bandwidth() / 2) +
- " " +
- y(d.value) +
- ")",
- )
- .attr(
- "d",
- "M0,0 l 5,-5 h " +
- (ttwidth / 2 - 5) +
- " v -20 h -" +
- ttwidth +
- " v 20 h " +
- (ttwidth / 2 - 5) +
- " Z",
- );
-
- select(tooltips.current)
- .selectAll("text")
- .data(d3data)
- .join("text")
- .attr("display", "none")
- .attr("x", (d) => x(d.year) + x.bandwidth() / 2)
- .attr("y", (d) => y(d.value) - 10)
- .attr("text-anchor", "middle")
- .attr("font-size", "12px")
- .text((d) => d.value + " " + d.unit);
-
- function setTooltipDisplay(year: string | null) {
- select(tooltips.current)
- .selectAll("text")
- .join()
- .attr("display", (d) =>
- year !== null && d.year === year ? "display" : "none",
- );
-
- select(tooltips.current)
- .selectAll("path")
- .join()
- .attr("display", (d) =>
- year !== null && d.year === year ? "display" : "none",
- );
- }
-
- function onMouseOver(event: MouseEvent) {
- const selectedYear = select(event.currentTarget).datum().year as string;
- setTooltipDisplay(selectedYear);
- }
-
- function onMouseOut() {
- setTooltipDisplay(null);
- }
- }, [
- d3data,
- width,
- height,
- marginTop,
- marginBottom,
- marginLeft,
- marginRight,
- metric,
- barColor,
- chartSetup,
- sector,
- ]);
-
- return (
-
-
- {chartTitle}
-
-
-
- );
-}