From 979fa7c6c382e5829b63eb64a791337e14f2ae13 Mon Sep 17 00:00:00 2001 From: CJ Yetman Date: Thu, 4 Dec 2025 12:51:31 +0100 Subject: [PATCH 1/6] fix from #659 --- src/components/NormalizedStackedAreaChart.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/NormalizedStackedAreaChart.tsx b/src/components/NormalizedStackedAreaChart.tsx index 48bf6b16..cf6ffac2 100644 --- a/src/components/NormalizedStackedAreaChart.tsx +++ b/src/components/NormalizedStackedAreaChart.tsx @@ -6,6 +6,7 @@ import { group } from "d3-array"; import { extent } from "d3-array"; import { axisBottom, axisLeft } from "d3-axis"; import { stackOffsetExpand } from "d3-shape"; +import "d3-transition"; import { useRef, useEffect, useState, useMemo } from "react"; import { capitalizeWords } from "../utils/capitalizeWords"; From 5ce4228d4a3ec9b35ede3dcbb8a9d662d69a5cf3 Mon Sep 17 00:00:00 2001 From: CJ Yetman Date: Thu, 4 Dec 2025 12:52:01 +0100 Subject: [PATCH 2/6] display emissions data in line charts --- src/components/MultiLineChart.tsx | 13 +++++++++---- src/components/PlotSelector.tsx | 5 ++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/components/MultiLineChart.tsx b/src/components/MultiLineChart.tsx index 5cc4a14b..c6d61ecc 100644 --- a/src/components/MultiLineChart.tsx +++ b/src/components/MultiLineChart.tsx @@ -48,10 +48,15 @@ export default function MultiLineChart({ sector = "power", metric = "capacity", }: 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); diff --git a/src/components/PlotSelector.tsx b/src/components/PlotSelector.tsx index db3e87fc..e82f25ab 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 (
- Date: Mon, 17 Aug 2026 15:03:26 +0200 Subject: [PATCH 3/6] use linecharts for emissions metrics consistenly across views --- src/components/ComparisonPlots.test.tsx | 87 +++++++++++++++++++++++-- src/components/ComparisonPlots.tsx | 23 ++++--- src/components/MultiLineChart.tsx | 28 +++++--- src/components/PlotSelector.test.tsx | 75 +++++++++++++++++++++ src/components/PlotSelector.tsx | 1 + 5 files changed, 191 insertions(+), 23 deletions(-) create mode 100644 src/components/PlotSelector.test.tsx 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 3005b1d8..e38512f5 100644 --- a/src/components/MultiLineChart.tsx +++ b/src/components/MultiLineChart.tsx @@ -233,16 +233,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 d43c671c..2e324e88 100644 --- a/src/components/PlotSelector.tsx +++ b/src/components/PlotSelector.tsx @@ -172,6 +172,7 @@ export const PlotSelector: React.FC = ({ width={450} height={300} metric="emissionsIntensity" + yMin={0} />
); From 00d0980cd48512b9370bc185360d88e775679d27 Mon Sep 17 00:00:00 2001 From: jacobvjk Date: Tue, 18 Aug 2026 11:40:25 +0200 Subject: [PATCH 4/6] feat: use line plots instead of bar charts From dc5e5bca67403453e0d3a9296512ee5d6d1b1608 Mon Sep 17 00:00:00 2001 From: jacobvjk Date: Tue, 18 Aug 2026 11:51:36 +0200 Subject: [PATCH 5/6] address comments --- src/components/MultiLineChart.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/MultiLineChart.tsx b/src/components/MultiLineChart.tsx index e38512f5..84b81ec1 100644 --- a/src/components/MultiLineChart.tsx +++ b/src/components/MultiLineChart.tsx @@ -60,7 +60,7 @@ export default function MultiLineChart({ let filtered = data.data.filter( (d) => d.sector === sector && d.metric === metric, ); - if (metric == "emissionsIntensity" || metric == "absoluteEmissions") { + if (metric === "emissionsIntensity" || metric === "absoluteEmissions") { filtered = filtered.map((d) => ({ ...d, technology: d.metric })); } return filtered; @@ -78,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); From 190fd249128a78a7c99ce4213d7ac1f7667f294e Mon Sep 17 00:00:00 2001 From: jacobvjk Date: Tue, 18 Aug 2026 16:39:13 +0200 Subject: [PATCH 6/6] rm bar chart component --- src/components/VerticalBarChart.tsx | 259 ---------------------------- 1 file changed, 259 deletions(-) delete mode 100644 src/components/VerticalBarChart.tsx 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} -

- - - - - - -
- ); -}