From a63f4b8a9707e2ea847d43f48005fa232e0f46ab Mon Sep 17 00:00:00 2001 From: jacobvjk Date: Tue, 6 Oct 2026 10:41:02 +0200 Subject: [PATCH 1/3] feat(download): sector_segment columns from the rows, not the metric The timeseries CSV's sector_scope column repeated one taxonomy string per metric ("Power generation" for every Power metric). It is replaced by two columns read from each row's v2 sectorSegment (#915): - sector_segment: the segments the value covers, joined with "; " - definition_sector_segment: what each means, in the same order, next to the existing technology and metric definitions The per-metric sectorScope is removed from timeseriesTaxonomy.ts and from common/metric.v1.json, which had it as required free text; the rows are now the only source. The CSV helpers move to scripts/timeseries-csv.ts so they can be tested (the build script runs on import). A list value is now joined before quoting, so it can never spill commas into later columns. Each dataset's index summary also lists its sectorSegments. Co-Authored-By: Claude Opus 5.5 --- scripts/build-timeseries-files.ts | 33 ++++++++--------- scripts/timeseries-csv.test.ts | 49 +++++++++++++++++++++++++ scripts/timeseries-csv.ts | 61 +++++++++++++++++++++++++++++++ src/data/index.gen.ts | 32 ++++++++++++++++ src/schema/common/metric.v1.json | 7 +--- src/types/common/metric.v1.d.ts | 4 -- src/utils/timeseriesTaxonomy.ts | 6 --- 7 files changed, 159 insertions(+), 33 deletions(-) create mode 100644 scripts/timeseries-csv.test.ts create mode 100644 scripts/timeseries-csv.ts diff --git a/scripts/build-timeseries-files.ts b/scripts/build-timeseries-files.ts index 02e91cf2..50deb4f8 100644 --- a/scripts/build-timeseries-files.ts +++ b/scripts/build-timeseries-files.ts @@ -26,6 +26,7 @@ import { getTechnologyDefinition, UnknownTaxonomyError, } from "../src/utils/timeseriesTaxonomy.ts"; +import { jsonToCsv, sectorSegmentColumns } from "./timeseries-csv.ts"; const STRICT = process.env.TS_INDEX_STRICT === "1"; const DEBUG = process.env.TS_INDEX_DEBUG === "1"; @@ -211,6 +212,7 @@ async function main() { const sectors = new Set(); const geos = new Set(); const metrics = new Set(); + const segments = new Set(); for (const row of rawData as any[]) { rowCount++; @@ -239,6 +241,10 @@ async function main() { // Unique metrics if (typeof row?.metric === "string" && row.metric) metrics.add(row.metric); + // Unique sector segments (v2 rows carry a list) + if (Array.isArray(row?.sectorSegment)) + for (const seg of row.sectorSegment) + if (typeof seg === "string" && seg) segments.add(seg); } if (rowCount > 0) computed.rowCount = rowCount; @@ -253,6 +259,7 @@ async function main() { computed.geographyCount = geos.size; computed.geographies = [...geos].sort(); } + if (segments.size > 0) computed.sectorSegments = [...segments].sort(); if (metrics.size > 0) computed.metrics = [...metrics].sort(); } @@ -310,21 +317,6 @@ async function main() { const PUBLIC_DATA_DIR = path.join(ROOT, "public", "data"); await fs.mkdir(PUBLIC_DATA_DIR, { recursive: true }); - function jsonToCsv(data: any[]): string { - if (!Array.isArray(data) || data.length === 0) return ""; - // Merge metadata into each row - const keys = Array.from(new Set(data.flatMap((row) => Object.keys(row)))); - const escape = (v: any) => - typeof v === "string" - ? `"${v.replace(/"/g, '""')}"` - : v === null || v === undefined - ? "" - : String(v); - const header = keys.join(","); - const rows = data.map((row) => keys.map((k) => escape(row[k])).join(",")); - return [header, ...rows].join("\n"); - } - for (const [dsId, ds] of Object.entries(out.byDataset)) { logDebug(`Processing datasetId: ${dsId}`); // ds.path currently looks like "/data/…file.json" (derived from src earlier). @@ -393,7 +385,6 @@ async function main() { source, sector: sectorDef.displayName, emissionsScope: parsed.emissionsScope, - sectorScope: metricDef?.sectorScope ?? "", metric: metricDef?.displayName ?? "", definitionMetric: metricDef?.definition ?? "", technology: techDef?.displayName ?? "", @@ -437,6 +428,12 @@ async function main() { : String(row.technology); const rowMetadata = getRowMetadata(sector, metric, technology); + const segmentColumns = sectorSegmentColumns( + sector, + Array.isArray(row.sectorSegment) + ? (row.sectorSegment as string[]) + : undefined, + ); const out = { publisher: rowMetadata.publisher, @@ -452,9 +449,11 @@ async function main() { unit: row.unit, source: rowMetadata.source, emissions_scope: rowMetadata.emissionsScope, - sector_scope: rowMetadata.sectorScope, + sector_segment: segmentColumns.sector_segment, definition_technology: rowMetadata.definitionTechnology, definition_metric: rowMetadata.definitionMetric, + definition_sector_segment: + segmentColumns.definition_sector_segment, }; logDebug(` Export row: ${JSON.stringify(out)}`); return out; diff --git a/scripts/timeseries-csv.test.ts b/scripts/timeseries-csv.test.ts new file mode 100644 index 00000000..4dbc05d3 --- /dev/null +++ b/scripts/timeseries-csv.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { csvCell, jsonToCsv, sectorSegmentColumns } from "./timeseries-csv.ts"; + +describe("sectorSegmentColumns", () => { + it("lists the row's segments and their definitions in the same order", () => { + const cols = sectorSegmentColumns("power", [ + "Power generation", + "Energy storage", + ]); + expect(cols.sector_segment).toBe("Power generation; Energy storage"); + const definitions = cols.definition_sector_segment.split("; "); + expect(definitions).toHaveLength(2); + expect(definitions[0]).toMatch(/Generation of electricity/); + expect(definitions[1]).toMatch(/Storing electricity/); + }); + + it("leaves both cells empty for a row with no segments", () => { + expect(sectorSegmentColumns("power", undefined)).toEqual({ + sector_segment: "", + definition_sector_segment: "", + }); + }); + + it("throws on a segment the sector does not define", () => { + expect(() => sectorSegmentColumns("power", ["Ironmaking"])).toThrow( + /Unknown segment "Ironmaking"/, + ); + }); +}); + +describe("CSV writing", () => { + it("quotes strings and joins a list into one quoted cell", () => { + expect(csvCell('say "hi"')).toBe('"say ""hi"""'); + expect(csvCell(["Power generation", "Energy storage"])).toBe( + '"Power generation; Energy storage"', + ); + expect(csvCell(42)).toBe("42"); + expect(csvCell(null)).toBe(""); + }); + + it("keeps a list in its own column", () => { + const csv = jsonToCsv([ + { geography: "Southeast Asia", sector_segment: ["A", "B"], value: 1 }, + ]); + const [header, row] = csv.split("\n"); + expect(header).toBe("geography,sector_segment,value"); + expect(row).toBe('"Southeast Asia","A; B",1'); + }); +}); diff --git a/scripts/timeseries-csv.ts b/scripts/timeseries-csv.ts new file mode 100644 index 00000000..fbe89249 --- /dev/null +++ b/scripts/timeseries-csv.ts @@ -0,0 +1,61 @@ +/** + * CSV pieces of the timeseries download, kept out of + * build-timeseries-files.ts so they can be tested: that script runs on import. + */ +import { + getSectorDefinition, + segmentDefinitionFor, + UnknownTaxonomyError, +} from "../src/utils/timeseriesTaxonomy.ts"; + +/** How a list value is written into one CSV cell. */ +export const LIST_SEPARATOR = "; "; + +/** + * The download's segment columns for one row: the segments it covers and, in + * the same order, what each means. + * + * They replace the former `sector_scope` column, which repeated one taxonomy + * string per metric; segments now come from the row itself (#915). A row + * without segments (a v1 file) gets empty cells rather than a guess. + */ +export function sectorSegmentColumns( + sectorKey: string, + segments: readonly string[] | undefined, +): { sector_segment: string; definition_sector_segment: string } { + const list = segments ?? []; + const sector = getSectorDefinition(sectorKey).displayName; + const definitions = list.map((segment) => { + const def = segmentDefinitionFor(sector, segment); + // Schema validation guarantees this; failing loudly beats a blank cell. + if (!def) + throw new UnknownTaxonomyError( + `Unknown segment "${segment}" for sector "${sector}"`, + ); + return def.definition; + }); + return { + sector_segment: list.join(LIST_SEPARATOR), + definition_sector_segment: definitions.join(LIST_SEPARATOR), + }; +} + +/** + * One CSV cell. Strings are always quoted; a list is joined first, so it can + * never spill its commas into the next columns. + */ +export function csvCell(value: unknown): string { + if (Array.isArray(value)) return csvCell(value.join(LIST_SEPARATOR)); + if (typeof value === "string") return `"${value.replace(/"/g, '""')}"`; + if (value === null || value === undefined) return ""; + return String(value); +} + +/** Rows to CSV text; the header is the union of the rows' keys. */ +export function jsonToCsv(data: readonly Record[]): string { + if (data.length === 0) return ""; + const keys = Array.from(new Set(data.flatMap((row) => Object.keys(row)))); + const header = keys.join(","); + const rows = data.map((row) => keys.map((k) => csvCell(row[k])).join(",")); + return [header, ...rows].join("\n"); +} diff --git a/src/data/index.gen.ts b/src/data/index.gen.ts index b9167247..28aa232d 100644 --- a/src/data/index.gen.ts +++ b/src/data/index.gen.ts @@ -32,6 +32,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 1, geographies: ["ASEAN"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -54,6 +55,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 1, geographies: ["ASEAN"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -76,6 +78,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 1, geographies: ["ASEAN"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -98,6 +101,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 1, geographies: ["ASEAN"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -120,6 +124,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 2, geographies: ["Global", "Southeast Asia"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -142,6 +147,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 1, geographies: ["Global"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -164,6 +170,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 2, geographies: ["Global", "Southeast Asia"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -187,6 +194,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 1, geographies: ["ID"], + sectorSegments: ["Power generation"], metrics: ["capacity", "generation"], }, path: "/data/jetp-id/JETP-CIPP-2023_timeseries.csv", @@ -203,6 +211,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 6, geographies: ["Global", "ID", "MY", "South East Asia", "TH", "VN"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -226,6 +235,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 6, geographies: ["Global", "ID", "MY", "South East Asia", "TH", "VN"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -248,6 +258,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 6, geographies: ["Global", "ID", "MY", "South East Asia", "TH", "VN"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -282,6 +293,7 @@ export const index: TimeseriesIndex = { "TH", "VN", ], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -316,6 +328,7 @@ export const index: TimeseriesIndex = { "TH", "VN", ], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -350,6 +363,7 @@ export const index: TimeseriesIndex = { "TH", "VN", ], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -384,6 +398,7 @@ export const index: TimeseriesIndex = { "TH", "VN", ], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -406,6 +421,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 2, geographies: ["Global", "ID"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -430,6 +446,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 1, geographies: ["ASEAN"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -451,6 +468,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 1, geographies: ["ASEAN"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -472,6 +490,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 1, geographies: ["ASEAN"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -493,6 +512,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 1, geographies: ["ASEAN"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -514,6 +534,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 2, geographies: ["Global", "Southeast Asia"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -535,6 +556,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 1, geographies: ["Global"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -556,6 +578,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 2, geographies: ["Global", "Southeast Asia"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -577,6 +600,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 1, geographies: ["ID"], + sectorSegments: ["Power generation"], metrics: ["capacity", "generation"], }, path: "/data/jetp-id/JETP-CIPP-2023_timeseries.csv", @@ -592,6 +616,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 6, geographies: ["Global", "ID", "MY", "South East Asia", "TH", "VN"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -614,6 +639,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 6, geographies: ["Global", "ID", "MY", "South East Asia", "TH", "VN"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -635,6 +661,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 6, geographies: ["Global", "ID", "MY", "South East Asia", "TH", "VN"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -668,6 +695,7 @@ export const index: TimeseriesIndex = { "TH", "VN", ], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -701,6 +729,7 @@ export const index: TimeseriesIndex = { "TH", "VN", ], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -734,6 +763,7 @@ export const index: TimeseriesIndex = { "TH", "VN", ], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -767,6 +797,7 @@ export const index: TimeseriesIndex = { "TH", "VN", ], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", @@ -788,6 +819,7 @@ export const index: TimeseriesIndex = { sectors: ["power"], geographyCount: 2, geographies: ["Global", "ID"], + sectorSegments: ["Power generation"], metrics: [ "absoluteEmissions", "capacity", diff --git a/src/schema/common/metric.v1.json b/src/schema/common/metric.v1.json index e1a70973..24f04be0 100644 --- a/src/schema/common/metric.v1.json +++ b/src/schema/common/metric.v1.json @@ -7,14 +7,9 @@ "properties": { "displayName": { "$ref": "#/$defs/displayName" - }, - "sectorScope": { - "type": "string", - "maxLength": 1000, - "description": "Specifies which parts of the sector value chain the metric applies to." } }, - "required": ["displayName", "sectorScope"], + "required": ["displayName"], "additionalProperties": false, "$defs": { diff --git a/src/types/common/metric.v1.d.ts b/src/types/common/metric.v1.d.ts index f9b01fca..1d436cab 100644 --- a/src/types/common/metric.v1.d.ts +++ b/src/types/common/metric.v1.d.ts @@ -19,8 +19,4 @@ export type DisplayName = */ export interface MetricV1 { displayName: DisplayName; - /** - * Specifies which parts of the sector value chain the metric applies to. - */ - sectorScope: string; } diff --git a/src/utils/timeseriesTaxonomy.ts b/src/utils/timeseriesTaxonomy.ts index 04d6c407..2f29f1e3 100644 --- a/src/utils/timeseriesTaxonomy.ts +++ b/src/utils/timeseriesTaxonomy.ts @@ -8,7 +8,6 @@ export interface TechnologyDefinition { export interface MetricDefinition { displayName: string; definition: string; - sectorScope?: string; } /** @@ -148,31 +147,26 @@ export const POWER_SECTOR_DEFINITION: SectorDefinition = { displayName: "Absolute Emissions", definition: "Total greenhouse gas emissions produced, regardless of output. Measured in metric tons of CO2 equivalent", - sectorScope: "Power generation", }, capacity: { displayName: "Capacity", definition: "The maximum output a power plant or energy source can produce under ideal conditions, measured in GW", - sectorScope: "Power generation", }, emissionsIntensity: { displayName: "Emissions Intensity", definition: "Amount of greenhouse gases emitted per unit of physical output. Indicates how low-carbon the output production is", - sectorScope: "Power generation", }, generation: { displayName: "Generation", definition: "The actual amount of electricity produced over a specific period, typically measured in TWh", - sectorScope: "Power generation", }, technologyMix: { displayName: "Technology Mix", definition: "The breakdown of energy sources used for electricity generation (e.g., coal, solar, wind, nuclear). Reflects the diversity and sustainability of the energy portfolio", - sectorScope: "Power generation", }, }, /* From d2e21eef429f2460b57c05c7b67ccaf6faafa02b Mon Sep 17 00:00:00 2001 From: jacobvjk Date: Tue, 6 Oct 2026 10:41:02 +0200 Subject: [PATCH 2/3] feat(plots): segment badges from the plotted rows Benchmark plots captioned each panel with the metric's sector scope. They now show the segments of the rows the panel actually draws (metric and resolved geography), via plottedSegments, ordered as the sector defines its segments: - a series spanning several segments shows a badge for each - comparison columns read their own rows, so two pathways whose series of the same metric cover different segments now say so; before, one per-metric value was repeated across every column Co-Authored-By: Claude Opus 5.5 --- src/components/ComparisonPlots.test.tsx | 30 ++++++++++++++-- src/components/ComparisonPlots.tsx | 37 +++++++++---------- src/components/PlotGrid.test.tsx | 33 +++++++++++++++-- src/components/PlotGrid.tsx | 24 ++++++------- src/components/PlotSelector.test.tsx | 2 ++ src/components/PlotSelector.tsx | 2 ++ src/utils/timeseriesSegments.test.ts | 47 +++++++++++++++++++++++++ src/utils/timeseriesSegments.ts | 41 +++++++++++++++++++++ 8 files changed, 178 insertions(+), 38 deletions(-) create mode 100644 src/utils/timeseriesSegments.test.ts create mode 100644 src/utils/timeseriesSegments.ts diff --git a/src/components/ComparisonPlots.test.tsx b/src/components/ComparisonPlots.test.tsx index 04dc89ee..c2b85e2e 100644 --- a/src/components/ComparisonPlots.test.tsx +++ b/src/components/ComparisonPlots.test.tsx @@ -19,6 +19,7 @@ function makeEntry( pathwayId: string, geos: string[], metrics: string[] = ["technologyMix"], + sectorSegment: string[] = ["Power generation"], ): ComparisonPlotsEntry { return { pathwayId, @@ -27,6 +28,7 @@ function makeEntry( metrics.flatMap((metric) => [ { sector: "power", + sectorSegment, metric, geography: geo, year: "2020", @@ -36,6 +38,7 @@ function makeEntry( }, { sector: "power", + sectorSegment, metric, geography: geo, year: "2030", @@ -62,6 +65,7 @@ function makeMetricEntry( timeseriesdata: { data: values.map((value, i) => ({ sector: "power", + sectorSegment: ["Power generation"], metric, geography, year: String(2020 + i * 10), @@ -202,11 +206,33 @@ describe("ComparisonPlots", () => { />, ); - // One per column: the segment follows the shared plot type, but it - // belongs in each column's caption next to that column's geography. + // One per column, next to that column's geography. expect(await screen.findAllByText("Power generation")).toHaveLength(2); }); + it("reads each column's segments from its own rows", async () => { + // Recorded per row (#915), so two pathways' series of the same metric + // can cover different parts of the sector. + const entries = [ + makeEntry("p1", ["Global"]), + makeEntry( + "p2", + ["Global"], + ["technologyMix"], + ["Power generation", "Energy storage"], + ), + ]; + render( + , + ); + + expect(await screen.findAllByText("Power generation")).toHaveLength(2); + expect(screen.getAllByText("Energy storage")).toHaveLength(1); + }); + it("carries the segment's definition as a tooltip", async () => { const entries = [ makeEntry("p1", ["Global"]), diff --git a/src/components/ComparisonPlots.tsx b/src/components/ComparisonPlots.tsx index 6dadfb95..cbcb949e 100644 --- a/src/components/ComparisonPlots.tsx +++ b/src/components/ComparisonPlots.tsx @@ -14,10 +14,8 @@ import { geographyFallbackNote, resolveGeography, } from "../utils/geographyFallback"; -import { - getMetricDefinition, - getSectorDefinition, -} from "../utils/timeseriesTaxonomy"; +import { getSectorDefinition } from "../utils/timeseriesTaxonomy"; +import { plottedSegments } from "../utils/timeseriesSegments"; import { getSectorSegmentTooltip } from "../utils/tooltipUtils"; import { useElementWidth } from "../hooks/useElementWidth"; @@ -212,13 +210,6 @@ const ComparisonPlots: React.FC = ({ const hasAnyData = availablePlotOptions.length > 0; - // Which part of the sector the selected metric describes. One value for the - // whole grid, because it follows the shared plot type rather than the pathway. - const segment = getMetricDefinition( - PLOT_SECTOR.key, - selectedPlot, - ).sectorScope; - const handlePlotChange = useCallback( (e: React.ChangeEvent) => { setSelectedPlot(e.target.value as PlotType); @@ -279,20 +270,23 @@ const ComparisonPlots: React.FC = ({ {/* - Which part of the sector the selected metric describes. A property of - the metric, not of the pathway, so every column shows the same one — - but it belongs in each column's caption alongside the geography, the - way the detail page captions its small multiples. - - `sectorScope` is optional on MetricDefinition, so a metric may declare - no segment; render the badge only when one exists rather than an empty - pill. + Which parts of the sector each column's series covers. Read from that + column's own rows (#915): segments are recorded per row, so two + pathways' series of the same metric can differ, and each column says + what it shows, the way the detail page captions its small multiples. + A column whose rows name no segment renders no segment badge. */} {entries.map((entry, idx) => { const resolution = resolutions[idx]; const used = resolution?.used ?? ""; const usedKind = geographyKind(used); const note = resolution ? geographyFallbackNote(resolution) : null; + const segments = plottedSegments( + entry.timeseriesdata?.data, + PLOT_SECTOR, + selectedPlot, + used, + ); return (
= ({ > {geographyLabel(normalizeGeography(used))} - {segment ? ( + {segments.map((segment) => ( = ({ > {segment} - ) : null} + ))}
) : null} {note ? ( diff --git a/src/components/PlotGrid.test.tsx b/src/components/PlotGrid.test.tsx index 3f4f3253..fdefdcf2 100644 --- a/src/components/PlotGrid.test.tsx +++ b/src/components/PlotGrid.test.tsx @@ -17,9 +17,14 @@ const SEA: Geography = { }; /** Two years of one metric at one geography — the minimum that plots. */ -function rows(metric: string, geography: string) { +function rows( + metric: string, + geography: string, + sectorSegment: string[] = ["Power generation"], +) { return ["2020", "2030"].map((year, i) => ({ sector: "power", + sectorSegment, metric, geography, year, @@ -29,9 +34,11 @@ function rows(metric: string, geography: string) { })); } -function timeseries(...groups: { metric: string; geography: string }[]) { +function timeseries( + ...groups: { metric: string; geography: string; segments?: string[] }[] +) { return { - data: groups.flatMap((g) => rows(g.metric, g.geography)), + data: groups.flatMap((g) => rows(g.metric, g.geography, g.segments)), }; } @@ -118,6 +125,26 @@ describe("PlotGrid", () => { expect(screen.getByText("Power generation")).toBeInTheDocument(); }); + it("shows every segment the panel's rows cover, in the sector's order", () => { + // Segments come from the data (#915), so one series can span several. + render( + , + ); + + const segments = screen + .getAllByText(/^(Power generation|Energy storage)$/) + .map((el) => el.textContent); + // Power defines generation before storage, whatever order the row lists. + expect(segments).toEqual(["Power generation", "Energy storage"]); + }); + it("renders both indicators as badges, in distinct colours", () => { render( = ({ ) : null}
{panels.map((opt) => { - // `sectorScope` is optional on MetricDefinition, so a metric may simply - // not declare a segment — render the badge only when one exists rather - // than an empty pill. - const segment = getMetricDefinition( - PLOT_SECTOR.key, + // The segments this panel's rows cover, read from the data (#915). + // A series may span several; none renders no badge at all. + const segments = plottedSegments( + timeseriesdata?.data, + PLOT_SECTOR, opt.value, - ).sectorScope; + used, + ); return (
= ({ > {usedLabel} - {segment ? ( + {segments.map((segment) => ( = ({ > {segment} - ) : null} + ))}
); diff --git a/src/components/PlotSelector.test.tsx b/src/components/PlotSelector.test.tsx index 4fdeff97..d8e36116 100644 --- a/src/components/PlotSelector.test.tsx +++ b/src/components/PlotSelector.test.tsx @@ -22,6 +22,7 @@ function makeTimeseries(metric: string): TimeSeries { data: [ { sector: "power", + sectorSegment: ["Power generation"], metric, geography: "Global", year: "2020", @@ -31,6 +32,7 @@ function makeTimeseries(metric: string): TimeSeries { }, { sector: "power", + sectorSegment: ["Power generation"], metric, geography: "Global", year: "2030", diff --git a/src/components/PlotSelector.tsx b/src/components/PlotSelector.tsx index b7bf4584..082b8b8f 100644 --- a/src/components/PlotSelector.tsx +++ b/src/components/PlotSelector.tsx @@ -11,6 +11,8 @@ interface DataPoint { value: number; unit: string; geography: string; + /** Segments of the sector this value covers (pathwayTimeseries v2). */ + sectorSegment: string[]; } interface TimeSeries { diff --git a/src/utils/timeseriesSegments.test.ts b/src/utils/timeseriesSegments.test.ts new file mode 100644 index 00000000..4cc05ab6 --- /dev/null +++ b/src/utils/timeseriesSegments.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { plottedSegments } from "./timeseriesSegments"; + +const POWER = { key: "power", displayName: "Power" }; + +const row = (geography: string, metric: string, sectorSegment: string[]) => ({ + sector: "power", + geography, + metric, + sectorSegment, +}); + +describe("plottedSegments", () => { + it("counts only the rows the panel draws", () => { + const rows = [ + row("Global", "capacity", ["Power generation"]), + row("Global", "generation", ["Transmission and distribution"]), + row("ASEAN", "capacity", ["Energy storage"]), + ]; + expect(plottedSegments(rows, POWER, "capacity", "Global")).toEqual([ + "Power generation", + ]); + }); + + it("orders segments as the sector defines them, not as rows list them", () => { + const rows = [ + row("Global", "capacity", ["Energy storage"]), + row("Global", "capacity", ["Power generation", "Energy storage"]), + ]; + expect(plottedSegments(rows, POWER, "capacity", "Global")).toEqual([ + "Power generation", + "Energy storage", + ]); + }); + + it("returns nothing for rows without segments or no rows at all", () => { + expect( + plottedSegments( + [{ sector: "power", geography: "Global", metric: "capacity" }], + POWER, + "capacity", + "Global", + ), + ).toEqual([]); + expect(plottedSegments(undefined, POWER, "capacity", "Global")).toEqual([]); + }); +}); diff --git a/src/utils/timeseriesSegments.ts b/src/utils/timeseriesSegments.ts new file mode 100644 index 00000000..034ffc72 --- /dev/null +++ b/src/utils/timeseriesSegments.ts @@ -0,0 +1,41 @@ +import { segmentsForSector } from "./timeseriesTaxonomy"; + +interface SegmentedRow { + sector: string; + metric: string; + geography: string; + sectorSegment?: readonly string[]; +} + +/** + * The sector segments a plotted series covers: the distinct `sectorSegment` + * values of the rows a panel actually draws (#915). + * + * Read from the data rather than the metric, because v2 records segments per + * row — two pathways' capacity series can differ, one including Energy + * storage and one not. Ordered as the sector defines its segments, so the + * badges read the same way on every panel; anything outside that list keeps + * its first-seen order at the end. + */ +export function plottedSegments( + rows: readonly SegmentedRow[] | undefined, + sector: { key: string; displayName: string }, + metric: string, + geography: string, +): string[] { + const seen = new Set(); + for (const row of rows ?? []) { + if ( + row.sector === sector.key && + row.metric === metric && + row.geography === geography + ) + row.sectorSegment?.forEach((segment) => seen.add(segment)); + } + const order = segmentsForSector(sector.displayName) ?? []; + const rank = (segment: string) => { + const i = order.indexOf(segment); + return i === -1 ? order.length : i; + }; + return [...seen].sort((a, b) => rank(a) - rank(b)); +} From fdce31babd5056f2af34a0d9d879f1eedbc87298 Mon Sep 17 00:00:00 2001 From: jacobvjk Date: Tue, 6 Oct 2026 12:45:25 +0200 Subject: [PATCH 3/3] fix(plots): no segment badge under a series PlotPanel does not draw PlotPanel only draws a series with more than one distinct year; a single-year series shows its empty state. plottedSegments counted any matching row, so a badge could caption an empty chart (#976 review). Both components now apply PlotPanel's own hasDataForMetricAndGeo before reading segments, so the badge and the chart follow one rule. Co-Authored-By: Claude Opus 5.5 --- src/components/ComparisonPlots.test.tsx | 15 +++++++++++++++ src/components/ComparisonPlots.tsx | 22 +++++++++++++++++----- src/components/PlotGrid.test.tsx | 18 ++++++++++++++++++ src/components/PlotGrid.tsx | 25 +++++++++++++++++++------ 4 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/components/ComparisonPlots.test.tsx b/src/components/ComparisonPlots.test.tsx index c2b85e2e..30fd69ea 100644 --- a/src/components/ComparisonPlots.test.tsx +++ b/src/components/ComparisonPlots.test.tsx @@ -273,6 +273,21 @@ describe("ComparisonPlots", () => { }); }); + it("omits the segment badge for a column whose series has one year", () => { + // PlotPanel draws nothing for a single year, so a badge would describe + // an empty chart. + const oneYear = makeEntry("p2", ["Global"]); + oneYear.timeseriesdata!.data = oneYear.timeseriesdata!.data.slice(0, 1); + render( + , + ); + + expect(screen.getAllByText("Power generation")).toHaveLength(1); + }); + it("explains itself rather than relabelling Power series for another sector", () => { const entries = [makeEntry("p1", ["Global"]), makeEntry("p2", ["Global"])]; render( diff --git a/src/components/ComparisonPlots.tsx b/src/components/ComparisonPlots.tsx index cbcb949e..42471848 100644 --- a/src/components/ComparisonPlots.tsx +++ b/src/components/ComparisonPlots.tsx @@ -1,6 +1,11 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import type { PlotType, TimeSeries, HoveredPoint } from "./PlotSelector"; -import { PlotPanel, PLOT_OPTIONS, hasDataForMetric } from "./PlotPanel"; +import { + PlotPanel, + PLOT_OPTIONS, + hasDataForMetric, + hasDataForMetricAndGeo, +} from "./PlotPanel"; import Badge from "./Badge"; import RegionMembersTooltip from "./RegionMembersTooltip"; import type { Geography } from "../types"; @@ -281,12 +286,19 @@ const ComparisonPlots: React.FC = ({ const used = resolution?.used ?? ""; const usedKind = geographyKind(used); const note = resolution ? geographyFallbackNote(resolution) : null; - const segments = plottedSegments( - entry.timeseriesdata?.data, - PLOT_SECTOR, + // Only for a series PlotPanel draws; see PlotGrid. + const segments = hasDataForMetricAndGeo( + entry.timeseriesdata, selectedPlot, used, - ); + ) + ? plottedSegments( + entry.timeseriesdata?.data, + PLOT_SECTOR, + selectedPlot, + used, + ) + : []; return (
{ expect(segments).toEqual(["Power generation", "Energy storage"]); }); + it("omits the segment badge when the resolved series has one year", () => { + // The metric plots elsewhere (two years at Global), so its panel stays, + // but PlotPanel draws nothing for a single year at the requested region — + // and a segment badge under that empty state would describe nothing. + const data = timeseries({ metric: "capacity", geography: "Global" }); + data.data.push({ ...rows("capacity", "South East Asia")[0] }); + render( + , + ); + + expect(screen.getByText("South East Asia")).toBeInTheDocument(); + expect(screen.queryByText("Power generation")).not.toBeInTheDocument(); + }); + it("renders both indicators as badges, in distinct colours", () => { render( = ({
{panels.map((opt) => { // The segments this panel's rows cover, read from the data (#915). - // A series may span several; none renders no badge at all. - const segments = plottedSegments( - timeseriesdata?.data, - PLOT_SECTOR, + // A series may span several; none renders no badge at all. Only for + // a series PlotPanel actually draws: one with a single year shows + // its empty state, and a badge under that would describe nothing. + const segments = hasDataForMetricAndGeo( + timeseriesdata, opt.value, used, - ); + ) + ? plottedSegments( + timeseriesdata?.data, + PLOT_SECTOR, + opt.value, + used, + ) + : []; return (