Skip to content

Commit

Permalink
Charts v2: Refactor with hooks (electricitymaps#49)
Browse files Browse the repository at this point in the history
* install d3

* modify types

* tweak timeaxis

* set up helpers

* add chart components

* install currency lib

* add two additional graphs

* change usegetzone hook to no params

* refactor pricechart

* refactor carbonchart

* refactor breakdown chart

* extend types

* change areagraph data format

* set it up

* feedback

* Allows ts-ignore comments with descriptions

Co-authored-by: Mads Nedergaard <[email protected]>
  • Loading branch information
Markus Killendahl and madsnedergaard authored Nov 25, 2022
1 parent ae920f9 commit a97c006
Show file tree
Hide file tree
Showing 14 changed files with 497 additions and 541 deletions.
8 changes: 7 additions & 1 deletion web/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@
"warn", // TODO: Change to error when ready for production
{ "args": "after-used", "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
],

"@typescript-eslint/ban-ts-comment": [
"error",
{
"ts-expect-error": "allow-with-description",
"ts-ignore": "allow-with-description"
}
],
"@typescript-eslint/no-confusing-void-expression": [
"error",
{ "ignoreArrowShorthand": true }
Expand Down
16 changes: 11 additions & 5 deletions web/src/api/getZone.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { useAtom } from 'jotai';
import { useParams } from 'react-router-dom';
import type { ZoneDetails } from 'types';
import { TimeAverages } from 'utils/constants';
import { timeAverageAtom } from 'utils/state';
import { getBasePath, getHeaders, QUERY_KEYS, REFETCH_INTERVAL_MS } from './helpers';

const getZone = async (
Expand All @@ -18,24 +21,27 @@ const getZone = async (

if (response.ok) {
const { data } = (await response.json()) as { data: ZoneDetails };
return data;
// TODO: Fix this in app-backend
// @ts-ignore: app-backend should not return array
return data.length > 0 ? data[0] : data;
}

throw new Error(await response.text());
};

const useGetZone = (
timeAverage: TimeAverages,
zoneId: string,
options?: UseQueryOptions<ZoneDetails>
): UseQueryResult<ZoneDetails> =>
useQuery<ZoneDetails>(
): UseQueryResult<ZoneDetails> => {
const [timeAverage] = useAtom(timeAverageAtom);
const { zoneId } = useParams();
return useQuery<ZoneDetails>(
[QUERY_KEYS.ZONE, zoneId, timeAverage],
async () => getZone(zoneId, timeAverage),
{
staleTime: REFETCH_INTERVAL_MS,
...options,
}
);
};

export default useGetZone;
209 changes: 13 additions & 196 deletions web/src/features/charts/BreakdownChart.tsx
Original file line number Diff line number Diff line change
@@ -1,224 +1,41 @@
import { max as d3Max } from 'd3-array';
import { useCo2ColorScale } from 'hooks/theme';
import { useMemo } from 'react';
import { ZoneDetail } from 'types';
import { modeColor, modeOrder, TimeAverages } from 'utils/constants';
import { scalePower } from 'utils/formatting';
import { PulseLoader } from 'react-spinners';
import { TimeAverages } from 'utils/constants';
import AreaGraph from './elements/AreaGraph';
import { getGenerationTypeKey, getStorageKey, noop } from './graphUtils';

interface ValuesInfo {
valueAxisLabel: string; // For example, GW or tCO₂eq/min
valueFactor: number; // TODO: why is this required
}

const getValuesInfo = (
historyData: ZoneDetail[],
displayByEmissions: boolean
): ValuesInfo => {
const maxTotalValue = d3Max(
historyData,
(d: ZoneDetail) =>
displayByEmissions
? (d.totalCo2Production + d.totalCo2Import + d.totalCo2Discharge) / 1e6 / 60 // in tCO₂eq/min
: d.totalProduction + d.totalImport + d.totalDischarge // in MW
);

const format = scalePower(maxTotalValue);
const valueAxisLabel = displayByEmissions ? 'tCO₂eq / min' : format.unit;
const valueFactor = format.formattingFactor;
return { valueAxisLabel, valueFactor };
};

const prepareGraphData = (
historyData: any,
co2ColorScale: any,
displayByEmissions: any,
electricityMixMode: any,
exchangeKeys: string[]
) => {
if (!historyData || !historyData[0]) {
return {};
}

const { valueAxisLabel, valueFactor } = getValuesInfo(historyData, displayByEmissions);

// Format history data received by the API
// TODO: Simplify this function and make it more readable
// TODO (mk): We should assume that this data is already more typed and we don't need
// all these checks
const data = historyData.map((d: any) => {
const object: any = {
datetime: new Date(d.stateDatetime),
meta: {},
};

const hasProductionData =
d.production && Object.values(d.production).some((v) => v !== null);
if (hasProductionData) {
// Add production
for (const k of modeOrder) {
const isStorage = k.includes('storage');
let value = undefined;
if (isStorage) {
const storageKey = getStorageKey(k);
if (storageKey !== undefined) {
value = -1 * Math.min(0, (d.storage || {})[storageKey]);
}
} else {
const generationKey = getGenerationTypeKey(k);
if (generationKey !== undefined) {
value = (d.production || {})[generationKey];
}
}

// in GW or MW
object[k] = value !== undefined ? value / valueFactor : undefined;
if (Number.isFinite(value) && displayByEmissions && object[k] != undefined) {
// in tCO₂eq/min
if (isStorage && object[k] >= 0) {
object[k] *=
(d.dischargeCo2Intensities || {})[k.replace(' storage', '')] / 1e3 / 60;
} else {
object[k] *= (d.productionCo2Intensities || {})[k] / 1e3 / 60;
}
}
}

if (electricityMixMode === 'consumption') {
// Add exchange
for (const [key, value] of Object.entries(d.exchange)) {
const value_: number = value as number;
// in GW or MW
object[key] = Math.max(0, value_ / valueFactor);
if (Number.isFinite(value) && displayByEmissions && object[key] != undefined) {
// in tCO₂eq/min
object[key] *= (d.exchangeCo2Intensities || {})[key] / 1e3 / 60;
}
}
}
}

// Keep a pointer to original data
object.meta = d;
return object;
});

// Show the exchange layers (if they exist) on top of the standard sources.
const layerKeys = [...modeOrder, ...exchangeKeys];

const layerFill = (key: string) => {
// If exchange layer, set the horizontal gradient by using a different fill for each datapoint.
if (exchangeKeys.includes(key)) {
return (d: any) => co2ColorScale((d.data.meta.exchangeCo2Intensities || {})[key]);
}
// Otherwise use regular production fill.
return modeColor[key];
};

return {
data,
layerKeys,
layerFill,
valueAxisLabel,
};
};

import { noop } from './graphUtils';
import useBreakdownChartData from './hooks/useBreakdownChartData';
interface BreakdownChartProps {
displayByEmissions: boolean;
electricityMixMode: string;
isMobile: boolean;
isOverlayEnabled: boolean;
historyData: any;
exchangeKeys: string[];
datetimes: Date[];
timeAverage: TimeAverages;
}

function BreakdownChart({
displayByEmissions,
electricityMixMode,
isMobile,
isOverlayEnabled,
historyData,
exchangeKeys,
datetimes,
timeAverage,
}: BreakdownChartProps) {
// const [tooltip, setTooltip] = useState(null);
const co2ColorScale = useCo2ColorScale();
function BreakdownChart({ datetimes, timeAverage }: BreakdownChartProps) {
const { data } = useBreakdownChartData();

// Recalculate graph data only when the history data is changed
const { data, layerKeys, layerFill, valueAxisLabel } = useMemo(
() =>
prepareGraphData(
historyData,
co2ColorScale,
displayByEmissions,
electricityMixMode,
exchangeKeys
),
[historyData, co2ColorScale, displayByEmissions, electricityMixMode, exchangeKeys]
);
if (!data) {
return <PulseLoader />;
}

// Graph marker callbacks
// const markerUpdateHandler = useMemo(
// () => (position, datapoint, layerKey) => {
// setTooltip({
// mode: layerKey,
// position: getTooltipPosition(isMobile, position),
// zoneData: datapoint.meta,
// });
// },
// [setTooltip, isMobile]
// );
// const markerHideHandler = useMemo(
// () => () => {
// setTooltip(null);
// },
// [setTooltip]
// );
const { chartData, valueAxisLabel, layerFill, layerKeys } = data;

return (
<div className="ml-3">
<AreaGraph
testId="history-mix-graph"
data={data}
data={chartData}
layerKeys={layerKeys}
layerFill={layerFill}
valueAxisLabel={valueAxisLabel}
markerUpdateHandler={noop}
markerHideHandler={noop}
isMobile={isMobile}
isMobile={false} // Todo: test on mobile
height="10em"
isOverlayEnabled={isOverlayEnabled}
isOverlayEnabled={false} // TODO: create overlay
datetimes={datetimes}
selectedTimeAggregate={timeAverage}
selectedZoneTimeIndex={0}
/>
{/* {tooltip &&
(exchangeKeys.includes(tooltip.mode) ? (
<CountryPanelExchangeTooltip
exchangeKey={tooltip.mode}
position={tooltip.position}
zoneData={tooltip.zoneData}
onClose={() => {
setTooltip(null);
}}
/>
) : (
<CountryPanelProductionTooltip
mode={tooltip.mode}
position={tooltip.position}
zoneData={tooltip.zoneData}
onClose={() => {
setTooltip(null);
}}
/>
))} */}
</div>
);
}

// export default connect(mapStateToProps)(CountryHistoryMixGraph);
export default BreakdownChart;
77 changes: 14 additions & 63 deletions web/src/features/charts/CarbonChart.tsx
Original file line number Diff line number Diff line change
@@ -1,68 +1,28 @@
import { useCo2ColorScale } from 'hooks/theme';
import { useMemo } from 'react';
import { getCO2IntensityByMode } from 'utils/helpers';
import { PulseLoader } from 'react-spinners';
import { TimeAverages } from 'utils/constants';
import AreaGraph from './elements/AreaGraph';
import { noop } from './graphUtils';
import { useCarbonChartData } from './hooks/useCarbonChartData';

const prepareGraphData = (
historyData: any,
co2ColorScale: any,
electricityMixMode: any
) => {
if (!historyData || !historyData[0] || historyData.every((d: any) => !d.isValid)) {
// Incomplete data
return {};
}

const data = historyData.map((d: any) => ({
carbonIntensity: getCO2IntensityByMode(d, electricityMixMode),
datetime: new Date(d.stateDatetime),
// Keep a pointer to original data
meta: d,
}));
const layerKeys = ['carbonIntensity'];
const layerFill = (key: any) => (d: any) => co2ColorScale(d.data[key]);
return { data, layerKeys, layerFill };
};
interface CarbonChartProps {
datetimes: Date[];
timeAverage: TimeAverages;
}

function CarbonChart({
electricityMixMode,
isMobile,
historyData,
datetimes,
timeAverage,
}: any) {
// const [tooltip, setTooltip] = useState(null);
const co2ColorScale = useCo2ColorScale();
function CarbonChart({ datetimes, timeAverage }: CarbonChartProps) {
const { data, isLoading, isError } = useCarbonChartData();

// Recalculate graph data only when the history data is changed
const { data, layerKeys, layerFill } = useMemo(
() => prepareGraphData(historyData, co2ColorScale, electricityMixMode),
[historyData, co2ColorScale, electricityMixMode]
);
if (isLoading || isError || !data) {
return <PulseLoader />;
}

// Graph marker callbacks
// const markerUpdateHandler = useMemo(
// () => (position, datapoint) => {
// setTooltip({
// position: getTooltipPosition(isMobile, position),
// zoneData: datapoint.meta,
// });
// },
// [setTooltip, isMobile]
// );
// const markerHideHandler = useMemo(
// () => () => {
// setTooltip(null);
// },
// [setTooltip]
// );
const { chartData, layerFill, layerKeys } = data;

return (
<div className="ml-3">
<AreaGraph
testId="history-carbon-graph"
data={data}
data={chartData}
layerKeys={layerKeys}
layerFill={layerFill}
valueAxisLabel="g / kWh"
Expand All @@ -75,15 +35,6 @@ function CarbonChart({
selectedTimeAggregate={timeAverage}
selectedZoneTimeIndex={0}
/>
{/* {tooltip && (
<MapCountryTooltip
position={tooltip.position}
zoneData={tooltip.zoneData}
onClose={() => {
setTooltip(null);
}}
/>
)} */}
</div>
);
}
Expand Down
Loading

0 comments on commit a97c006

Please sign in to comment.