Skip to content

Commit

Permalink
CarbonIntensitySquare and CircularGauge with tooltip (#7127)
Browse files Browse the repository at this point in the history
* WIP: CarbonIntensitySquare and CircularGuage with tooltip

* format

* use visx/text directly

* tweak styles

* set up translation strucutre

* run script to remove the unused translations

* add eng translatuons for other tooltips

* put co2eq/kwh inside the square

* Update web/src/components/CircularGauge.tsx

Co-authored-by: Mads Nedergaard <nedergaardmads@gmail.com>

* Update web/src/components/CircularGauge.tsx

Co-authored-by: Mads Nedergaard <nedergaardmads@gmail.com>

* inline tooltips

* update tooltip strings

* adjust info position

* add spacing 15 to tailwind

* optimize styles

---------

Co-authored-by: Mads Nedergaard <nedergaardmads@gmail.com>
  • Loading branch information
VIKTORVAV99 and madsnedergaard authored Sep 10, 2024
1 parent 7a53164 commit bff9c72
Show file tree
Hide file tree
Showing 47 changed files with 502 additions and 856 deletions.
3 changes: 3 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ updates:
sentry:
patterns:
- '@sentry/*'
visx:
patterns:
- '@visx/*'

# Ensure ruff is updated.
- package-ecosystem: 'pip'
Expand Down
4 changes: 3 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@
"@tanstack/react-query": "5.53.1",
"@tanstack/react-virtual": "^3.10.6",
"@turf/helpers": "^6.5.0",
"@visx/group": "^3.3.0",
"@visx/shape": "^3.5.0",
"@visx/text": "^3.3.0",
"country-flag-icons": "^1.5.13",
"currency-symbol-map": "^5.1.0",
"d3-array": "^3.2.4",
Expand All @@ -97,7 +100,6 @@
"react-router-dom": "6.26.1",
"react-spinners": "^0.14.1",
"react-spring-bottom-sheet": "3.5.0-alpha.0",
"recharts": "^2.12.7",
"sunrise-sunset-js": "^2.2.1",
"tailwind-merge": "^2.4.0",
"tiny-invariant": "^1.3.1",
Expand Down
691 changes: 250 additions & 441 deletions web/pnpm-lock.yaml

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { App as Cap } from '@capacitor/app';
import { Capacitor } from '@capacitor/core';
import { ToastProvider } from '@radix-ui/react-toast';
import { useReducedMotion } from '@react-spring/web';
import * as Sentry from '@sentry/react';
import useGetState from 'api/getState';
import LoadingOverlay from 'components/LoadingOverlay';
Expand Down Expand Up @@ -31,6 +32,9 @@ if (isProduction) {
}

export default function App(): ReactElement {
// Triggering the useReducedMotion hook here ensures the global animation settings are set as soon as possible
useReducedMotion();

// Triggering the useGetState hook here ensures that the app starts loading data as soon as possible
// instead of waiting for the map to be lazy loaded.
// TODO: Replace this with prefetching once we have latest endpoints available for all state aggregates
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/CarbonIntensityDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function CarbonIntensityDisplay({
{withSquare && <Square co2Intensity={intensityAsNumber} />}
<p className={className}>
<b>{Math.round(intensityAsNumber) || '?'}</b>&nbsp;
{CarbonUnits.GRAMS_CO2EQ_PER_WATT_HOUR}
{CarbonUnits.GRAMS_CO2EQ_PER_KILOWATT_HOUR}
</p>
</>
);
Expand Down
2 changes: 0 additions & 2 deletions web/src/components/CarbonIntensitySquare.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,11 @@ export const Primary: Story = {
export const WithLabel: Story = {
args: {
intensity: 234,
withSubtext: true,
},
};

export const InvalidNumber: Story = {
args: {
intensity: undefined,
withSubtext: true,
},
};
71 changes: 41 additions & 30 deletions web/src/components/CarbonIntensitySquare.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next';
import { CarbonUnits } from 'utils/units';

import { useCo2ColorScale } from '../hooks/theme';
import InfoIconWithPadding from './InfoIconWithPadding';
import TooltipWrapper from './tooltips/TooltipWrapper';

/**
* This function finds the optimal text color based on a custom formula
Expand All @@ -28,43 +30,52 @@ const getTextColor = (rgbColor: string) => {

interface CarbonIntensitySquareProps {
intensity: number;
withSubtext?: boolean;
tooltipContent?: string | JSX.Element;
}

function CarbonIntensitySquare({ intensity, withSubtext }: CarbonIntensitySquareProps) {
function CarbonIntensitySquare({
intensity,
tooltipContent,
}: CarbonIntensitySquareProps) {
const { t } = useTranslation();
const co2ColorScale = useCo2ColorScale();
const backgroundColor = useSpring({
backgroundColor: co2ColorScale(intensity),
}).backgroundColor;
const [{ backgroundColor }] = useSpring(
{
backgroundColor: co2ColorScale(intensity),
},
[co2ColorScale, intensity]
);

return (
<div>
<div>
<animated.div
style={{
color: getTextColor(co2ColorScale(intensity)),
backgroundColor,
}}
className="mx-auto flex h-[65px] w-[65px] flex-col items-center justify-center rounded-2xl"
>
<p className="select-none text-[1rem]" data-test-id="co2-square-value">
<span className="font-bold">{Math.round(intensity) || '?'}</span>
&nbsp;
<span>g</span>
</p>
</animated.div>
</div>
<div className="mt-2 flex flex-col items-center">
<div className="text-xs font-semibold text-neutral-600 dark:text-neutral-400">
{t('country-panel.carbonintensity')}
</div>
{withSubtext && (
<div className="text-xs font-semibold text-neutral-600 dark:text-neutral-400">
{CarbonUnits.GRAMS_CO2EQ_PER_WATT_HOUR}
<div className="flex flex-col items-center gap-2">
<TooltipWrapper tooltipContent={tooltipContent} side="bottom" sideOffset={8}>
<div className="relative flex flex-col items-center">
<div className="size-20 p-1">
<animated.div
style={{
color: getTextColor(co2ColorScale(intensity)),
backgroundColor,
}}
className="flex h-full w-full flex-col items-center justify-center rounded-2xl"
>
<p
className="select-none text-base leading-none"
data-test-id="co2-square-value"
>
<span className="font-semibold">{Math.round(intensity) || '?'}</span>
<span className="text-xs font-semibold">g</span>
</p>
<div className="text-xxs font-semibold leading-none">
{CarbonUnits.CO2EQ_PER_KILOWATT_HOUR}
</div>
</animated.div>
</div>
)}
</div>
{tooltipContent && <InfoIconWithPadding />}
</div>
</TooltipWrapper>
<p className="text-xs font-semibold text-neutral-600 dark:text-neutral-400">
{t('country-panel.carbonintensity')}
</p>
</div>
);
}
Expand Down
144 changes: 87 additions & 57 deletions web/src/components/CircularGauge.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
import { Label, Pie, PieChart } from 'recharts';
import { animated, useSpring } from '@react-spring/web';
import { Group } from '@visx/group';
import { Arc } from '@visx/shape';
import { Text } from '@visx/text';
import { memo } from 'react';

import InfoIconWithPadding from './InfoIconWithPadding';
import TooltipWrapper from './tooltips/TooltipWrapper';

const PIE_START_ANGLE = 90;
const FULL_CIRCLE = 360;
const HALF_CIRCLE = FULL_CIRCLE / 2;
const PIE_PADDING = 30;

const degreesToRadians = (degrees: number) => (degrees * Math.PI) / HALF_CIRCLE;

/** The start angle of the pie chart, calculated from the `PIE_PADDING` and then adjusted for rotation */
const PIE_START_ANGLE = degreesToRadians(PIE_PADDING + HALF_CIRCLE);
/** The end angle of the pie chart, calculated from the `PIE_PADDING` and then adjusted for rotation */
const PIE_END_ANGLE = degreesToRadians(FULL_CIRCLE - PIE_PADDING + HALF_CIRCLE);

const calculateEndAngle = (ratio: number) =>
PIE_START_ANGLE + degreesToRadians(ratio * (FULL_CIRCLE - PIE_PADDING * 2));

export interface CircularGaugeProps {
ratio: number;
Expand All @@ -11,70 +28,83 @@ export interface CircularGaugeProps {
testId?: string;
}

const BackgroundArc = memo(function BackgroundArc({ radius }: { radius: number }) {
return (
<Arc
outerRadius={radius}
innerRadius={radius * 0.8}
startAngle={PIE_START_ANGLE}
endAngle={PIE_END_ANGLE}
className="fill-gray-200/60 dark:fill-gray-600/50"
cornerRadius={radius}
/>
);
});

const AnimatedArc = animated(Arc);

// Memoized to avoid unnecessary re-renders due to floating point precision issues
const SpringAnimatedArc = memo(function SpringAnimatedArc({
radius,
ratio,
}: {
radius: number;
ratio: number;
}) {
const [spring] = useSpring(
{
to: { ratio: Number.isFinite(ratio) ? ratio : 0 },
from: { ratio: 0 },
},
[ratio]
);

return (
<AnimatedArc
innerRadius={radius * 0.8}
outerRadius={radius}
startAngle={PIE_START_ANGLE}
endAngle={spring.ratio.to((ratio) => calculateEndAngle(ratio))}
cornerRadius={radius}
className="fill-brand-green dark:fill-brand-green-dark"
/>
);
});

export function CircularGauge({
ratio,
name,
tooltipContent,
testId,
tooltipContent,
}: CircularGaugeProps) {
// TODO: To improve performance, the background pie does not need to rerender on percentage change
const data = [{ value: ratio }];
const percentageAsAngle = ratio * 360;
const endAngle = PIE_START_ANGLE - percentageAsAngle;
const height = 80;
const width = 80;
const radius = Math.min(width, height) / 2;
const centerY = height / 2;
const centerX = width / 2;

return (
<div className="flex flex-col items-center">
<TooltipWrapper
side="right"
tooltipContent={tooltipContent}
tooltipClassName="max-w-44"
>
{/* Div required to ensure Tooltip is rendered in right place */}
<div data-test-id={testId}>
<PieChart
width={65}
height={65}
margin={{ top: 0, left: 0, right: 0, bottom: 0 }}
>
<Pie
innerRadius="80%"
outerRadius="100%"
startAngle={90}
endAngle={-360}
paddingAngle={0}
dataKey="value"
data={[{ value: 100 }]}
className="fill-gray-200/60 dark:fill-gray-600/50"
isAnimationActive={false}
strokeWidth={0}
>
<Label
className="select-none fill-gray-900 text-[1rem] font-bold dark:fill-gray-300"
position="center"
offset={0}
formatter={(value: number) =>
Number.isNaN(value) ? '?%' : `${Math.round(value * 100)}%`
}
value={ratio}
/>
</Pie>
<Pie
data={data}
innerRadius="80%"
outerRadius="100%"
startAngle={90}
endAngle={endAngle}
fill="#3C764A"
paddingAngle={0}
dataKey="value"
animationDuration={500}
animationBegin={0}
strokeWidth={0}
/>
</PieChart>
<div className="flex flex-col items-center gap-2">
<TooltipWrapper tooltipContent={tooltipContent} side="bottom" sideOffset={8}>
<div data-test-id={testId} className="relative flex flex-col items-center">
<svg height={height} width={width}>
<Group top={centerY} left={centerX} height={height} width={width}>
<BackgroundArc radius={radius} />
<SpringAnimatedArc radius={radius} ratio={ratio} />
<Text
verticalAnchor="middle"
textAnchor="middle"
fill="currentColor"
className="text-base font-semibold"
>
{Number.isFinite(ratio) ? `${Math.round(ratio * 100)}%` : '?%'}
</Text>
</Group>
</svg>
{tooltipContent && <InfoIconWithPadding />}
</div>
</TooltipWrapper>
<p className="mt-2 text-center text-xs font-semibold text-neutral-600 dark:text-neutral-400">
<p className="text-xs font-semibold text-neutral-600 dark:text-neutral-400">
{name}
</p>
</div>
Expand Down
12 changes: 12 additions & 0 deletions web/src/components/InfoIconWithPadding.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Info } from 'lucide-react';

/** This component is only meant to be used with `CarbonIntensitySquare` and `CircularGauge`
* components.
*/
export default function InfoIconWithPadding() {
return (
<div className="absolute top-15 rounded-full bg-zinc-50 dark:bg-gray-900">
<Info size={28} className="p-1 text-brand-green dark:text-brand-green-dark" />
</div>
);
}
2 changes: 1 addition & 1 deletion web/src/components/legend/Co2Legend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default function Co2Legend(): ReactElement {
return (
<LegendItem
label={t('legends.carbonintensity')}
unit={CarbonUnits.GRAMS_CO2EQ_PER_WATT_HOUR}
unit={CarbonUnits.GRAMS_CO2EQ_PER_KILOWATT_HOUR}
>
<HorizontalColorbar colorScale={co2ColorScale} ticksCount={6} id={'co2'} />
</LegendItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export default function BarElectricityExchangeChart({
</svg>
<div className="pb-2 pt-6">
<div className="mb-1 text-xs font-medium text-neutral-600 dark:text-gray-300">
{t('legends.carbonintensity')} ({CarbonUnits.GRAMS_CO2EQ_PER_WATT_HOUR})
{t('legends.carbonintensity')} ({CarbonUnits.GRAMS_CO2EQ_PER_KILOWATT_HOUR})
</div>
<HorizontalColorbar colorScale={co2ColorScale} ticksCount={6} id={'co2'} />
</div>
Expand Down
4 changes: 2 additions & 2 deletions web/src/features/charts/tooltips/ChartTooltips.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ it('Carbon chart tooltip', () => {
<CarbonChartTooltip zoneDetail={zoneDetailMock} selectedLayerKey="carbonIntensity" />
);
cy.contains('Carbon intensity');
cy.contains(`213 ${CarbonUnits.GRAMS_CO2EQ_PER_WATT_HOUR}`);
cy.contains(`213 ${CarbonUnits.GRAMS_CO2EQ_PER_KILOWATT_HOUR}`);
cy.contains('2023');
});
it('Breakdown chart tooltip', () => {
Expand All @@ -35,7 +35,7 @@ it('Carbon chart tooltip mobile', () => {
<CarbonChartTooltip zoneDetail={zoneDetailMock} selectedLayerKey="carbonIntensity" />
);
cy.contains('Carbon intensity');
cy.contains(`213 ${CarbonUnits.GRAMS_CO2EQ_PER_WATT_HOUR}`);
cy.contains(`213 ${CarbonUnits.GRAMS_CO2EQ_PER_KILOWATT_HOUR}`);
cy.contains('2023');
});
it('Breakdown chart tooltip mobile', () => {
Expand Down
Loading

0 comments on commit bff9c72

Please sign in to comment.