Skip to content

Commit

Permalink
Bar Breakdown Chart (electricitymaps#65)
Browse files Browse the repository at this point in the history
Co-authored-by: Markus Killendahl <[email protected]>
  • Loading branch information
madsnedergaard and Markus Killendahl authored Dec 6, 2022
1 parent 2fdd169 commit fe330cc
Show file tree
Hide file tree
Showing 19 changed files with 1,625 additions and 178 deletions.
307 changes: 160 additions & 147 deletions web/pnpm-lock.yaml

Large diffs are not rendered by default.

75 changes: 75 additions & 0 deletions web/src/features/charts/bar-breakdown/BarBreakdownChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useAtom } from 'jotai';
import { PulseLoader } from 'react-spinners';
import { useTranslation } from 'translation/translation';
import { TimeAverages } from 'utils/constants';
import { displayByEmissionsAtom } from 'utils/state/atoms';
import { useRefWidthHeightObserver } from 'utils/viewport';
import useBarBreakdownChartData from '../hooks/useBarBreakdownProductionChartData';
import BarBreakdownEmissionsChart from './BarBreakdownEmissionsChart';
import BarBreakdownProductionChart from './BarBreakdownProductionChart';

function BarBreakdownChart({ timeAverage }: { timeAverage: TimeAverages }) {
const { data, productionData, exchangeData, isLoading, height } =
useBarBreakdownChartData();
const [displayByEmissions] = useAtom(displayByEmissionsAtom);
const { ref, width } = useRefWidthHeightObserver();
const { __ } = useTranslation();

if (isLoading || !data) {
// TODO: Replace with skeleton graph (maybe full graph with no data?)
return <PulseLoader />;
}

// TODO: Show CountryTableOverlayIfNoData when required

const todoHandler = () => {
console.warn('TODO: Handle tooltips');
// see countrytable.jsx
// handleProductionRowMouseOver
//handleProductionRowMouseOut
//handleExchangeRowMouseOver
//handleExchangeRowMouseOut
};

return (
<div className="relative w-full text-md" ref={ref}>
<div className="relative top-2 mb-1 text-sm">
{__(
timeAverage !== TimeAverages.HOURLY
? 'country-panel.averagebysource'
: 'country-panel.bysource'
)}
</div>

{displayByEmissions ? (
<BarBreakdownEmissionsChart
data={data}
productionData={productionData}
exchangeData={exchangeData}
onProductionRowMouseOver={todoHandler}
onProductionRowMouseOut={todoHandler}
onExchangeRowMouseOver={todoHandler}
onExchangeRowMouseOut={todoHandler}
width={width}
height={height}
isMobile={false}
/>
) : (
<BarBreakdownProductionChart
data={data}
productionData={productionData}
exchangeData={exchangeData}
onProductionRowMouseOver={todoHandler}
onProductionRowMouseOut={todoHandler}
onExchangeRowMouseOver={todoHandler}
onExchangeRowMouseOut={todoHandler}
width={width}
height={height}
isMobile={false}
/>
)}
</div>
);
}

export default BarBreakdownChart;
122 changes: 122 additions & 0 deletions web/src/features/charts/bar-breakdown/BarBreakdownEmissionsChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { CountryFlag } from 'components/Flag';
import { max as d3Max } from 'd3-array';

import { scaleLinear } from 'd3-scale';
import { useMemo } from 'react';
import { useTranslation } from 'translation/translation';
import { ZoneDetail } from 'types';
import { modeColor } from 'utils/constants';
import { LABEL_MAX_WIDTH, PADDING_X } from './constants';
import Axis from './elements/Axis';
import HorizontalBar from './elements/HorizontalBar';
import Row from './elements/Row';
import { ExchangeDataType, getDataBlockPositions, ProductionDataType } from './utils';

interface BarBreakdownEmissionsChartProps {
height: number;
width: number;
data: ZoneDetail;
exchangeData: ExchangeDataType[];
productionData: ProductionDataType[];
isMobile: boolean;
onProductionRowMouseOver: (data: ZoneDetail) => void;
onProductionRowMouseOut: () => void;
onExchangeRowMouseOver: (data: ZoneDetail) => void;
onExchangeRowMouseOut: () => void;
}

function BarBreakdownEmissionsChart({
data,
exchangeData,
height,
isMobile,
productionData,
onProductionRowMouseOver,
onProductionRowMouseOut,
onExchangeRowMouseOver,
onExchangeRowMouseOut,
width,
}: BarBreakdownEmissionsChartProps) {
const { __ } = useTranslation();
const { productionY, exchangeY } = getDataBlockPositions(
productionData.length || 0,
exchangeData
);

const maxCO2eqExport = d3Max(exchangeData, (d) => Math.max(0, -d.tCo2eqPerMin)) || 0;
const maxCO2eqImport = d3Max(exchangeData, (d) => Math.max(0, d.tCo2eqPerMin));
const maxCO2eqProduction = d3Max(productionData, (d) => d.tCo2eqPerMin);

// in tCO₂eq/min
const co2Scale = useMemo(
() =>
scaleLinear()
.domain([
-maxCO2eqExport || 0,
Math.max(maxCO2eqProduction || 0, maxCO2eqImport || 0),
])
.range([0, width - LABEL_MAX_WIDTH - PADDING_X]),
[maxCO2eqExport, maxCO2eqProduction, maxCO2eqImport, width]
);

const formatTick = (t: number) => {
const [x1, x2] = co2Scale.domain();
if (x2 - x1 <= 1) {
return `${t * 1e3} kg/min`;
}
return `${t} t/min`;
};

return (
<svg className="w-full overflow-visible" height={height}>
<Axis formatTick={formatTick} height={height} scale={co2Scale} />
<g transform={`translate(0, ${productionY})`}>
{productionData.map((d, index) => (
<Row
key={d.mode}
index={index}
label={__(d.mode)}
width={width}
scale={co2Scale}
value={Math.abs(d.tCo2eqPerMin)}
onMouseOver={(event) => onProductionRowMouseOver(d.mode, data, event)}
onMouseOut={onProductionRowMouseOut}
isMobile={isMobile}
>
<HorizontalBar
className="production"
fill={modeColor[d.mode]}
range={[0, Math.abs(d.tCo2eqPerMin)]}
scale={co2Scale}
/>
</Row>
))}
</g>
<g transform={`translate(0, ${exchangeY})`}>
{exchangeData.map((d, index) => (
<Row
key={d.mode}
index={index}
label={d.mode}
width={width}
scale={co2Scale}
value={d.exchange}
onMouseOver={(event) => onExchangeRowMouseOver(d.mode, data, event)}
onMouseOut={onExchangeRowMouseOut}
isMobile={isMobile}
>
<CountryFlag zoneId={d.mode} className="pointer-events-none" />
<HorizontalBar
className="exchange"
fill={'gray'}
range={[0, d.tCo2eqPerMin]}
scale={co2Scale}
/>
</Row>
))}
</g>
</svg>
);
}

export default BarBreakdownEmissionsChart;
163 changes: 163 additions & 0 deletions web/src/features/charts/bar-breakdown/BarBreakdownProductionChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { CountryFlag } from 'components/Flag';
import { max as d3Max, min as d3Min } from 'd3-array';

import { scaleLinear } from 'd3-scale';
import { useCo2ColorScale } from 'hooks/theme';
import { useMemo } from 'react';
import { useTranslation } from 'translation/translation';
import { ZoneDetail } from 'types';
import { modeColor } from 'utils/constants';
import { LABEL_MAX_WIDTH, PADDING_X } from './constants';
import Axis from './elements/Axis';
import HorizontalBar from './elements/HorizontalBar';
import Row from './elements/Row';
import {
ExchangeDataType,
getDataBlockPositions,
getElectricityProductionValue,
ProductionDataType,
} from './utils';

interface BarBreakdownProductionChartProps {
height: number;
width: number;
data: ZoneDetail;
exchangeData: ExchangeDataType[];
productionData: ProductionDataType[];
isMobile: boolean;
onProductionRowMouseOver: (data: ZoneDetail) => void;
onProductionRowMouseOut: () => void;
onExchangeRowMouseOver: (data: ZoneDetail) => void;
onExchangeRowMouseOut: () => void;
}

function BarBreakdownProductionChart({
data,
exchangeData,
height,
isMobile,
productionData,
onProductionRowMouseOver,
onProductionRowMouseOut,
onExchangeRowMouseOver,
onExchangeRowMouseOut,
width,
}: BarBreakdownProductionChartProps) {
const { __ } = useTranslation();
const co2ColorScale = useCo2ColorScale();
const { productionY, exchangeY } = getDataBlockPositions(
productionData.length,
exchangeData
);

// Use the whole history to determine the min/max values in order to avoid
// graph jumping while sliding through the time range.
const [minPower, maxPower] = useMemo(() => {
return [
d3Min(
Object.values(data.zoneStates).map((zoneData) =>
Math.min(
-zoneData.maxStorageCapacity || 0,
-zoneData.maxStorage || 0,
-zoneData.maxExport || 0,
-zoneData.maxExportCapacity || 0
)
)
) || 0,
d3Max(
Object.values(data.zoneStates).map((zoneData) =>
Math.max(
zoneData.maxCapacity || 0,
zoneData.maxProduction || 0,
zoneData.maxDischarge || 0,
zoneData.maxStorageCapacity || 0,
zoneData.maxImport || 0,
zoneData.maxImportCapacity || 0
)
)
) || 0,
];
}, [data]);

// Power in MW
const powerScale = scaleLinear()
.domain([minPower, maxPower])
.range([0, width - LABEL_MAX_WIDTH - PADDING_X]);

const formatTick = (t: number) => {
const [x1, x2] = powerScale.domain();
if (x2 - x1 <= 1) {
return `${t * 1e3} kW`;
}
if (x2 - x1 <= 1e3) {
return `${t} MW`;
}
return `${t * 1e-3} GW`;
};

return (
<svg className="w-full overflow-visible" height={height}>
<Axis formatTick={formatTick} height={height} scale={powerScale} />
<g transform={`translate(0, ${productionY})`}>
{productionData.map((d, index) => (
<Row
key={d.mode}
index={index}
label={__(d.mode)}
width={width}
scale={powerScale}
value={getElectricityProductionValue(d)}
onMouseOver={(event) => onProductionRowMouseOver(d.mode, data, event)}
onMouseOut={onProductionRowMouseOut}
isMobile={isMobile}
>
<HorizontalBar
className="capacity"
fill="rgba(0, 0, 0, 0.15)"
range={d.isStorage ? [-d.capacity, d.capacity] : [0, d.capacity]}
scale={powerScale}
/>
<HorizontalBar
className="production"
fill={modeColor[d.mode]}
range={[0, getElectricityProductionValue(d)]}
scale={powerScale}
/>
</Row>
))}
</g>
<g transform={`translate(0, ${exchangeY})`}>
{exchangeData.map((d, index) => (
<Row
key={d.mode}
index={index}
label={d.mode}
width={width}
scale={powerScale}
value={d.exchange}
onMouseOver={(event) => onExchangeRowMouseOver(d.mode, data, event)}
onMouseOut={onExchangeRowMouseOut}
isMobile={isMobile}
>
<CountryFlag zoneId={d.mode} className="pointer-events-none" />

<HorizontalBar
className="capacity"
fill="rgba(0, 0, 0, 0.15)"
range={d.exchangeCapacityRange}
scale={powerScale}
/>
<HorizontalBar
className="exchange"
fill={co2ColorScale(d.gCo2eqPerkWh)}
range={[0, d.exchange]}
scale={powerScale}
/>
</Row>
))}
</g>
</svg>
);
}

export default BarBreakdownProductionChart;
9 changes: 9 additions & 0 deletions web/src/features/charts/bar-breakdown/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const LABEL_MAX_WIDTH = 102;
export const TEXT_ADJUST_Y = 11;
export const ROW_HEIGHT = 13;
export const PADDING_Y = 7;
export const PADDING_X = 5;
export const RECT_OPACITY = 0.8;
export const X_AXIS_HEIGHT = 15;
export const DEFAULT_FLAG_SIZE = 16;
export const SCALE_TICKS = 4;
Loading

0 comments on commit fe330cc

Please sign in to comment.