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/components/ComparisonPlots.test.tsx b/src/components/ComparisonPlots.test.tsx index 04dc89ee..30fd69ea 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"]), @@ -247,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 6dadfb95..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"; @@ -14,10 +19,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 +215,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 +275,30 @@ 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; + // Only for a series PlotPanel draws; see PlotGrid. + const segments = hasDataForMetricAndGeo( + entry.timeseriesdata, + selectedPlot, + used, + ) + ? 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..74cfc8ac 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,44 @@ 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("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( = ({ ) : 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. 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, - ).sectorScope; + used, + ) + ? plottedSegments( + timeseriesdata?.data, + PLOT_SECTOR, + opt.value, + 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/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/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)); +} 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", }, }, /*