Skip to content

Commit ea0648c

Browse files
authored
Feat/Co2 intensity filtering (#8245)
* Added feature to filter Co2 Intensity through a slider in legend * Removed temporary code * Refactored code to remove fixed height for the slider track * Resolved comments. - Updated Icon - Extracted default value to const and memoized And - Fixed alignment issue
1 parent 2d4d202 commit ea0648c

File tree

9 files changed

+256
-7
lines changed

9 files changed

+256
-7
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import { useEffect, useState } from 'react';
3+
4+
import RangeSlider, { RangeSliderProps } from './RangeSlider';
5+
6+
const meta: Meta<typeof RangeSlider> = {
7+
title: 'basics/RangeSlider',
8+
component: RangeSlider,
9+
};
10+
11+
export default meta;
12+
13+
function SliderWithControls(arguments_: RangeSliderProps) {
14+
const [value, setValue] = useState<number[]>(arguments_.value);
15+
16+
const handleOnSelectedIndexChange = (selectedIndex: number[]) => {
17+
setValue(selectedIndex);
18+
};
19+
20+
useEffect(() => {
21+
setValue(arguments_.value);
22+
}, [arguments_.value]);
23+
24+
return (
25+
<RangeSlider {...arguments_} value={value} onChange={handleOnSelectedIndexChange} />
26+
);
27+
}
28+
29+
export const Default = SliderWithControls.bind({}) as StoryObj;
30+
Default.args = {
31+
value: [0, 10],
32+
};
33+
34+
export const WithCustomStepComponent = SliderWithControls.bind({}) as StoryObj;
35+
WithCustomStepComponent.args = {
36+
value: [0, 20],
37+
step: 20,
38+
trackComponent: (
39+
<div className="h-2 w-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500"></div>
40+
),
41+
};
42+
43+
export const WithTrackComponent = SliderWithControls.bind({}) as StoryObj;
44+
WithTrackComponent.args = {
45+
value: [0, 20],
46+
trackComponent: (
47+
<div className="h-2 w-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500"></div>
48+
),
49+
};

web/src/components/RangeSlider.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import * as SliderPrimitive from '@radix-ui/react-slider';
2+
import * as Tooltip from '@radix-ui/react-tooltip';
3+
import { ChevronFirst, ChevronLast } from 'lucide-react';
4+
import { memo, type ReactElement } from 'react';
5+
6+
import LabelTooltip from './tooltips/LabelTooltip';
7+
import TooltipWrapper from './tooltips/TooltipWrapper';
8+
9+
export interface RangeSliderProps {
10+
onChange: (values: number[]) => void;
11+
defaultValue: number[];
12+
maxValue: number;
13+
step: number;
14+
value: number[];
15+
trackComponent?: string | ReactElement;
16+
}
17+
18+
function RangeSlider({
19+
onChange,
20+
defaultValue,
21+
maxValue,
22+
step,
23+
value,
24+
trackComponent,
25+
}: RangeSliderProps): ReactElement {
26+
return (
27+
<Tooltip.Provider delayDuration={0}>
28+
<SliderPrimitive.Root
29+
defaultValue={defaultValue}
30+
max={maxValue}
31+
step={step}
32+
value={value}
33+
onValueChange={onChange}
34+
aria-label="select range"
35+
className="relative flex w-full touch-none hover:cursor-pointer"
36+
minStepsBetweenThumbs={1}
37+
>
38+
<SliderPrimitive.Track className="pointer-events-none relative w-full rounded-md">
39+
{trackComponent ?? (
40+
<div className="h-2 w-full bg-neutral-100 dark:bg-neutral-600"></div>
41+
)}
42+
</SliderPrimitive.Track>
43+
44+
<TooltipWrapper
45+
tooltipContent={<LabelTooltip className={'text-xs'}>{value[0]}</LabelTooltip>}
46+
side="top"
47+
sideOffset={4}
48+
>
49+
<SliderPrimitive.Thumb
50+
data-testid="range-slider-input-1"
51+
className="-ml-2.5 -mt-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-white p-1 outline
52+
outline-1 outline-neutral-200 hover:outline-2 focus-visible:outline-2 focus-visible:outline-brand-green dark:bg-neutral-900 dark:outline-neutral-700 dark:focus-visible:outline-brand-green"
53+
>
54+
<ChevronFirst strokeWidth={3} size={12} pointerEvents="none" />
55+
</SliderPrimitive.Thumb>
56+
</TooltipWrapper>
57+
58+
<TooltipWrapper
59+
tooltipContent={<LabelTooltip className={'text-xs'}>{value[1]}</LabelTooltip>}
60+
side="top"
61+
sideOffset={4}
62+
>
63+
<SliderPrimitive.Thumb
64+
data-testid="range-slider-input-2"
65+
className="-mr-2.5 -mt-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-white p-1 outline
66+
outline-1 outline-neutral-200 hover:outline-2 focus-visible:outline-2 focus-visible:outline-brand-green dark:bg-neutral-900 dark:outline-neutral-700 dark:focus-visible:outline-brand-green"
67+
>
68+
<ChevronLast strokeWidth={3} size={12} pointerEvents="none" />
69+
</SliderPrimitive.Thumb>
70+
</TooltipWrapper>
71+
</SliderPrimitive.Root>
72+
</Tooltip.Provider>
73+
);
74+
}
75+
76+
export default memo(RangeSlider);

web/src/components/legend/Co2Legend.tsx

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,71 @@
1+
import { extent } from 'd3-array';
2+
import { scaleLinear } from 'd3-scale';
13
import { useCo2ColorScale } from 'hooks/theme';
2-
import { memo, type ReactElement } from 'react';
4+
import { useAtom } from 'jotai/index';
5+
import { memo, type ReactElement, useMemo } from 'react';
36
import { useTranslation } from 'react-i18next';
47
import { CarbonUnits } from 'utils/units';
58

6-
import HorizontalColorbar from './ColorBar';
9+
import { useFeatureFlag } from '../../features/feature-flags/api';
10+
import { co2IntensityRangeAtom } from '../../utils/state/atoms';
11+
import RangeSlider from '../RangeSlider';
12+
import HorizontalColorbar, { spreadOverDomain } from './ColorBar';
713
import { LegendItem } from './LegendItem';
814

15+
const TICKS_COUNT = 6;
16+
917
function Co2Legend(): ReactElement {
18+
const isCo2IntensityFilteringFeatureEnabled = useFeatureFlag(
19+
'legend-co2-intensity-filtering'
20+
);
1021
const { t } = useTranslation();
1122
const co2ColorScale = useCo2ColorScale();
23+
24+
const rangeValues = useMemo(
25+
() =>
26+
spreadOverDomain(
27+
scaleLinear().domain(
28+
extent(co2ColorScale.domain()) as unknown as [number, number]
29+
),
30+
TICKS_COUNT
31+
),
32+
[co2ColorScale]
33+
);
34+
35+
const [co2IntensityRange, setCo2IntensityRange] = useAtom(co2IntensityRangeAtom);
36+
const defaultValue = useMemo(
37+
() => [rangeValues.at(0) as number, rangeValues.at(-1) as number],
38+
[rangeValues]
39+
);
40+
1241
return (
1342
<LegendItem
1443
label={t('legends.carbonintensity')}
1544
unit={CarbonUnits.GRAMS_CO2EQ_PER_KILOWATT_HOUR}
1645
>
17-
<HorizontalColorbar colorScale={co2ColorScale} ticksCount={6} id={'co2'} />
46+
{isCo2IntensityFilteringFeatureEnabled ? (
47+
<RangeSlider
48+
value={co2IntensityRange}
49+
defaultValue={defaultValue}
50+
onChange={setCo2IntensityRange}
51+
maxValue={rangeValues.at(-1) as number}
52+
step={20}
53+
trackComponent={
54+
<HorizontalColorbar
55+
colorScale={co2ColorScale}
56+
ticksCount={TICKS_COUNT}
57+
id={'co2'}
58+
labelClassNames="mt-1"
59+
/>
60+
}
61+
/>
62+
) : (
63+
<HorizontalColorbar
64+
colorScale={co2ColorScale}
65+
ticksCount={TICKS_COUNT}
66+
id={'co2'}
67+
/>
68+
)}
1869
</LegendItem>
1970
);
2071
}

web/src/components/legend/ColorBar.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ function HorizontalColorbar({
1616
colorScale,
1717
id,
1818
ticksCount = 5,
19+
labelClassNames = '',
1920
}: {
2021
colorScale: ScaleLinear<string, string, string>;
2122
id: string;
2223
ticksCount?: number;
24+
labelClassNames?: string;
2325
}) {
2426
const linearScale = scaleLinear().domain(
2527
extent(colorScale.domain()) as unknown as [number, number]
@@ -28,7 +30,7 @@ function HorizontalColorbar({
2830
return (
2931
<>
3032
<svg className="flex h-3 w-full flex-col overflow-visible">
31-
<g transform={`translate(8,0)`}>
33+
<g transform={`translate(10,0)`}>
3234
<linearGradient id={`${id}-gradient`} x2="100%">
3335
{spreadOverDomain(colorScale, 10).map((value, index) => (
3436
<stop key={value} offset={index / 9} stopColor={colorScale(value)} />
@@ -44,7 +46,7 @@ function HorizontalColorbar({
4446
</g>
4547
</svg>
4648

47-
<div className="flex flex-row justify-between pr-0.5">
49+
<div className={`flex flex-row justify-between ${labelClassNames}`}>
4850
{spreadOverDomain(linearScale, ticksCount).map((t) => (
4951
<div
5052
key={t}

web/src/features/feature-flags/api.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,5 @@ export function useFeatureFlags(): FeatureFlags {
99

1010
export function useFeatureFlag(name: string): boolean {
1111
const { features } = useMeta();
12-
1312
return features?.[name] || false;
1413
}

web/src/features/map/Map.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,21 @@ import { ErrorEvent, Map, MapRef } from 'react-map-gl/maplibre';
1414
import { useLocation, useParams } from 'react-router-dom';
1515
import { RouteParameters } from 'types';
1616
import {
17+
filterCarbonIntensity,
1718
getCarbonIntensity,
1819
useNavigateWithParameters,
1920
useUserLocation,
2021
} from 'utils/helpers';
2122
import {
23+
co2IntensityRangeAtom,
2224
isConsumptionAtom,
2325
selectedDatetimeStringAtom,
2426
spatialAggregateAtom,
2527
userLocationAtom,
2628
} from 'utils/state/atoms';
2729

2830
import { useCo2ColorScale, useTheme } from '../../hooks/theme';
31+
import { useFeatureFlag } from '../feature-flags/api';
2932
import BackgroundLayer from './map-layers/BackgroundLayer';
3033
import StatesLayer from './map-layers/StatesLayer';
3134
import ZonesLayer from './map-layers/ZonesLayer';
@@ -63,6 +66,7 @@ interface ExtendedWindow extends Window {
6366
// We could even consider not changing it hear, but always reading it from the path parameter?
6467
export default function MapPage({ onMapLoad }: MapPageProps): ReactElement {
6568
const setIsMoving = useSetAtom(mapMovingAtom);
69+
const co2IntensityRange = useAtomValue(co2IntensityRangeAtom);
6670
const setMousePosition = useSetAtom(mousePositionAtom);
6771
const [isLoadingMap, setIsLoadingMap] = useAtom(loadingMapAtom);
6872
const [hoveredZone, setHoveredZone] = useAtom(hoveredZoneAtom);
@@ -90,6 +94,9 @@ export default function MapPage({ onMapLoad }: MapPageProps): ReactElement {
9094
const onMapReferenceChange = useCallback((reference: MapRef) => {
9195
setMapReference(reference);
9296
}, []);
97+
const isCo2IntensityFilteringFeatureEnabled = useFeatureFlag(
98+
'legend-co2-intensity-filtering'
99+
);
93100

94101
useEffect(() => {
95102
let subscription: PluginListenerHandle | null = null;
@@ -181,7 +188,16 @@ export default function MapPage({ onMapLoad }: MapPageProps): ReactElement {
181188
for (const feature of worldGeometries.features) {
182189
const { zoneId } = feature.properties;
183190
const zone = data?.datetimes[selectedDatetimeString]?.z[zoneId];
184-
const co2intensity = zone ? getCarbonIntensity(zone, isConsumption) : undefined;
191+
192+
let co2intensity = zone ? getCarbonIntensity(zone, isConsumption) : undefined;
193+
194+
co2intensity = filterCarbonIntensity(
195+
isCo2IntensityFilteringFeatureEnabled,
196+
co2intensity,
197+
co2IntensityRange[0],
198+
co2IntensityRange[1]
199+
);
200+
185201
const fillColor = co2intensity
186202
? getCo2colorScale(co2intensity)
187203
: theme.clickableFill;
@@ -216,6 +232,8 @@ export default function MapPage({ onMapLoad }: MapPageProps): ReactElement {
216232
theme.clickableFill,
217233
selectedDatetimeString,
218234
isConsumption,
235+
co2IntensityRange,
236+
isCo2IntensityFilteringFeatureEnabled,
219237
]);
220238

221239
useEffect(() => {

web/src/utils/helpers.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { describe, expect, it, vi } from 'vitest';
1313
import { TimeRange } from './constants';
1414
import {
1515
dateToDatetimeString,
16+
filterCarbonIntensity,
1617
getCarbonIntensity,
1718
getDestinationPath,
1819
getFossilFuelRatio,
@@ -424,3 +425,29 @@ describe('getLocalTime', () => {
424425
expect(result).toEqual({ localHours: 8, localMinutes: 0 });
425426
});
426427
});
428+
429+
describe('filterCarbonIntensity', () => {
430+
it('should return carbon intensity if feature is not enabled', () => {
431+
const result = filterCarbonIntensity(false, 420, 1, 10);
432+
433+
expect(result).toEqual(420);
434+
});
435+
436+
it('should return undefined if carbon intensity is undefined', () => {
437+
const result = filterCarbonIntensity(false, undefined, 1, 10);
438+
expect(result).toBeUndefined();
439+
});
440+
441+
it.each([
442+
[10, 10, 1, 10],
443+
[undefined, 10, 1, 9],
444+
[10, 10, 10, 20],
445+
[undefined, 10, 11, 20],
446+
])(
447+
'should return %s for given co2Intensity of %s, min %s and max %s',
448+
(expected, co2Intensity, min, max) => {
449+
const result = filterCarbonIntensity(true, co2Intensity, min, max);
450+
expect(result).toBe(expected);
451+
}
452+
);
453+
});

web/src/utils/helpers.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,30 @@ export const getCarbonIntensity = (
187187
isConsumption: boolean
188188
): number => (isConsumption ? zoneData?.c?.ci : zoneData?.p?.ci) ?? Number.NaN;
189189

190+
/**
191+
* Returns carbon intensity within given range if feature is enabled
192+
* @param isFeatureEnabled
193+
* @param co2intensity
194+
* @param min
195+
* @param max
196+
*/
197+
export const filterCarbonIntensity = (
198+
isFeatureEnabled: boolean,
199+
co2intensity: number | undefined,
200+
min: number,
201+
max: number
202+
): number | undefined => {
203+
if (!isFeatureEnabled) {
204+
return co2intensity;
205+
}
206+
207+
if (co2intensity && (co2intensity < min || co2intensity > max)) {
208+
return undefined;
209+
}
210+
211+
return co2intensity;
212+
};
213+
190214
/**
191215
* Returns the renewable ratio of a zone
192216
* @param zoneData - The zone data

web/src/utils/state/atoms.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import {
2020

2121
export const timeRangeAtom = atom<TimeRange>(TimeRange.H72);
2222

23+
// TODO: Maintain constants for lower and upper limits for co2 intensity and use from there
24+
export const co2IntensityRangeAtom = atom([0, 1500]);
25+
2326
export function useTimeRangeSync() {
2427
const [timeRange, setTimeRange] = useAtom(timeRangeAtom);
2528
const { resolution, urlTimeRange } = useParams<RouteParameters>();

0 commit comments

Comments
 (0)