Skip to content

Commit 7caf5af

Browse files
silkeholmebonnenalejandranavcasVIKTORVAV99
authored
feat(web): add grid alerts to app (#8281)
* first draft current grid alert card left panel * not showing card if no alerts also run pnpx prettier@2 --write . * add grid alerts layer on map calculate centroid of polygon show alert icon in centroid for zones where there is an alert with "action" type create warning icon for the map * update CurrentGridAlertsCard remove props not used in CurrentGridAlertsCard * restyle alert card format date to show on card implemented visual configuration of style according to alert type * fix typecheck errors * custom warning icon for action The icon is only showed where there is Action alert in a zone. Warning icon's color depends on carbon intensity color of the zone on the map following the logic in CarbonIntensitySquare. * Update GridAlertsLayer.tsx * smaller icons, remove alert type and change datetime format * WIP * Everything but truncated text * change brand yellow * add message type to zoneMessage * delete warning icon * add translations * modify estimation card to new zonemessage type * add truncate text, 5 min gran and text with links * use react icon instead of custom alert icon * fix type errors * add experimental disclaimer * fix icon color * extract getWarningIconData * changes based on viktors feedback * avoid flickering * Revert "avoid flickering" This reverts commit 36d98a6. * avoid flickering * Update web/src/locales/en.json Co-authored-by: Viktor Andersson <[email protected]> * Update web/src/locales/en.json Co-authored-by: Viktor Andersson <[email protected]> --------- Co-authored-by: Alejandra Navarro <[email protected]> Co-authored-by: Alejandra Navarro Castillo <[email protected]> Co-authored-by: Viktor Andersson <[email protected]>
1 parent 9f00da3 commit 7caf5af

File tree

11 files changed

+371
-6
lines changed

11 files changed

+371
-6
lines changed

web/src/components/CarbonIntensitySquare.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import TooltipWrapper from './tooltips/TooltipWrapper';
1717
* See https://github.com/electricitymaps/electricitymaps-contrib/issues/3365 for more informations.
1818
* @param {string} rgbColor a string with the background color (e.g. "rgb(0,5,4)")
1919
*/
20-
const getTextColor = (rgbColor: string) => {
20+
export const getTextColor = (rgbColor: string) => {
2121
const colors = rgbColor.replaceAll(/[^\d,.]/g, '').split(',');
2222
const r = Number.parseInt(colors[0], 10);
2323
const g = Number.parseInt(colors[1], 10);

web/src/features/map/Map.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
import { useCo2ColorScale, useTheme } from '../../hooks/theme';
3131
import { useFeatureFlag } from '../feature-flags/api';
3232
import BackgroundLayer from './map-layers/BackgroundLayer';
33+
import GridAlertsLayer from './map-layers/GridAlertsLayer';
3334
import StatesLayer from './map-layers/StatesLayer';
3435
import ZonesLayer from './map-layers/ZonesLayer';
3536
import CustomLayer from './map-utils/CustomLayer';
@@ -475,6 +476,7 @@ export default function MapPage({ onMapLoad }: MapPageProps): ReactElement {
475476
<BackgroundLayer />
476477
<ZonesLayer />
477478
<StatesLayer />
479+
<GridAlertsLayer />
478480
<CustomLayer>
479481
<WindLayer />
480482
</CustomLayer>
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import useGetState from 'api/getState';
2+
import { getTextColor } from 'components/CarbonIntensitySquare';
3+
import { useCo2ColorScale } from 'hooks/theme';
4+
import { useAtomValue } from 'jotai';
5+
import { useMemo } from 'react';
6+
import { FiAlertTriangle } from 'react-icons/fi';
7+
import { Marker } from 'react-map-gl/maplibre';
8+
import { GridState, MapGeometries } from 'types';
9+
import { getCarbonIntensity } from 'utils/helpers';
10+
import { isConsumptionAtom, selectedDatetimeStringAtom } from 'utils/state/atoms';
11+
12+
import { useGetGeometries } from '../map-utils/getMapGrid';
13+
14+
// Helper function to calculate the centroid of a polygon
15+
const getPolygonCentroid = (coordinates: number[][][]) => {
16+
let x = 0,
17+
y = 0,
18+
area = 0;
19+
const ring = coordinates[0]; // Use outer ring
20+
21+
for (let index = 0; index < ring.length - 1; index++) {
22+
const xi = ring[index][0];
23+
const yi = ring[index][1];
24+
const xi1 = ring[index + 1][0];
25+
const yi1 = ring[index + 1][1];
26+
const a = xi * yi1 - xi1 * yi;
27+
area += a;
28+
x += (xi + xi1) * a;
29+
y += (yi + yi1) * a;
30+
}
31+
32+
area *= 0.5;
33+
x /= 6 * area;
34+
y /= 6 * area;
35+
36+
return [x, y];
37+
};
38+
39+
function getWarningIconData(worldGeometries: MapGeometries, data: GridState | undefined) {
40+
if (!worldGeometries?.features || !data?.alerts) {
41+
return null;
42+
}
43+
44+
const warningZones = data.alerts;
45+
const features = [];
46+
47+
for (const zoneId of warningZones) {
48+
const zoneFeature = worldGeometries.features.find(
49+
(feature) => feature.properties?.zoneId === zoneId
50+
);
51+
52+
if (zoneFeature?.geometry) {
53+
let centroid;
54+
55+
if (zoneFeature.geometry.type === 'Polygon') {
56+
centroid = getPolygonCentroid(zoneFeature.geometry.coordinates);
57+
} else if (zoneFeature.geometry.type === 'MultiPolygon') {
58+
// Use the centroid of the largest polygon
59+
let largest = zoneFeature.geometry.coordinates[0];
60+
for (let index = 1; index < zoneFeature.geometry.coordinates.length; index++) {
61+
if (zoneFeature.geometry.coordinates[index][0].length > largest[0].length) {
62+
largest = zoneFeature.geometry.coordinates[index];
63+
}
64+
}
65+
centroid = getPolygonCentroid(largest);
66+
}
67+
68+
if (centroid) {
69+
features.push({
70+
type: 'Feature',
71+
geometry: {
72+
type: 'Point',
73+
coordinates: centroid,
74+
},
75+
properties: {
76+
zoneId: zoneId,
77+
},
78+
});
79+
}
80+
}
81+
}
82+
83+
return {
84+
type: 'FeatureCollection',
85+
features,
86+
};
87+
}
88+
89+
export default function GridAlertsLayer() {
90+
const { worldGeometries } = useGetGeometries();
91+
const { data } = useGetState();
92+
93+
const co2ColorScale = useCo2ColorScale();
94+
95+
const warningIconData = useMemo(
96+
() => getWarningIconData(worldGeometries, data),
97+
[worldGeometries, data]
98+
);
99+
100+
const selectedDatetimeString = useAtomValue(selectedDatetimeStringAtom);
101+
const isConsumption = useAtomValue(isConsumptionAtom);
102+
103+
return (
104+
<>
105+
{/* */}
106+
{warningIconData?.features.map((feature) => {
107+
const { zoneId } = feature.properties;
108+
const [longitude, latitude] = feature.geometry.coordinates;
109+
const zoneData = data?.datetimes[selectedDatetimeString]?.z[zoneId];
110+
const intensity = zoneData ? getCarbonIntensity(zoneData, isConsumption) : 0; // Default to 0 if no data
111+
const bgColor = co2ColorScale(intensity);
112+
const iconColor = getTextColor(bgColor);
113+
114+
return (
115+
<Marker
116+
key={zoneId}
117+
longitude={longitude}
118+
latitude={latitude}
119+
anchor="center"
120+
className="pointer-events-none"
121+
>
122+
<div className="flex items-center justify-center rounded-full bg-white/10 p-2">
123+
<FiAlertTriangle
124+
size={16}
125+
className={`-translate-y-px text-${iconColor}`}
126+
/>
127+
</div>
128+
</Marker>
129+
);
130+
})}
131+
</>
132+
);
133+
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import useGetZone from 'api/getZone';
2+
import HorizontalDivider from 'components/HorizontalDivider';
3+
import { FormattedTime } from 'components/Time';
4+
import LabelTooltip from 'components/tooltips/LabelTooltip';
5+
import TooltipWrapper from 'components/tooltips/TooltipWrapper';
6+
import { RoundedCard } from 'features/charts/RoundedCard';
7+
import { useAtomValue } from 'jotai';
8+
import { AlertTriangle, CircleAlert, FlaskConical } from 'lucide-react';
9+
import { useCallback, useState } from 'react';
10+
import { useTranslation } from 'react-i18next';
11+
import { useParams } from 'react-router-dom';
12+
import i18n from 'translation/i18n';
13+
import { RouteParameters } from 'types';
14+
import { TimeRange } from 'utils/constants';
15+
import { getZoneTimezone } from 'utils/helpers';
16+
import {
17+
isFiveMinuteOrHourlyGranularityAtom,
18+
selectedDatetimeIndexAtom,
19+
useTimeRangeSync,
20+
} from 'utils/state/atoms';
21+
22+
export default function CurrentGridAlertsCard() {
23+
const { data, isLoading } = useGetZone();
24+
const selectedDatetime = useAtomValue(selectedDatetimeIndexAtom);
25+
const [selectedTimeRange, _] = useTimeRangeSync();
26+
const isFineGranularity = useAtomValue(isFiveMinuteOrHourlyGranularityAtom);
27+
const { t } = useTranslation();
28+
29+
const zoneMessage = data?.zoneMessage;
30+
const icon = getAlertIcon(zoneMessage?.alert_type ?? 'default');
31+
const colorClass = getAlertColorClass(zoneMessage?.alert_type ?? 'default');
32+
const [title, ...rest] = zoneMessage?.message.split('\n') ?? [];
33+
const text = rest.join('\n');
34+
const isLongMessage = text.length > 100;
35+
const [isCollapsed, setIsCollapsed] = useState(true);
36+
const { zoneId } = useParams<RouteParameters>();
37+
const timezone = getZoneTimezone(zoneId);
38+
39+
const handleCollapseToggle = useCallback(() => {
40+
setIsCollapsed((previous) => !previous);
41+
}, []);
42+
43+
if (
44+
isLoading ||
45+
!zoneMessage ||
46+
!isFineGranularity ||
47+
zoneMessage.message_type !== 'grid_alert'
48+
) {
49+
return null;
50+
}
51+
52+
const startTime = new Date(zoneMessage.start_time ?? '');
53+
if (selectedTimeRange === TimeRange.H72) {
54+
startTime.setMinutes(0, 0, 0);
55+
} else {
56+
// set minutes to the closest 5 minutes in the past
57+
const minutes = startTime.getMinutes();
58+
const closest5Minutes = Math.round(minutes / 5) * 5;
59+
startTime.setMinutes(closest5Minutes, 0, 0);
60+
}
61+
if (selectedDatetime.datetime < startTime) {
62+
return null;
63+
}
64+
65+
return (
66+
<RoundedCard className="gap-2 px-0 pb-0 text-sm text-neutral-600 dark:text-neutral-300">
67+
<div className="px-4 py-3 pb-2">
68+
<div className="flex flex-row items-center gap-1">
69+
{icon}
70+
<span className={`line-clamp-1 font-semibold ${colorClass}`} title={title}>
71+
{title}
72+
</span>
73+
</div>
74+
<div className="flex flex-row items-center gap-1 text-xs">
75+
{!zoneMessage?.end_time && (
76+
<FormattedTime
77+
datetime={new Date(zoneMessage.start_time ?? '')}
78+
language={i18n.languages[0]}
79+
timeRange={TimeRange.H72}
80+
/>
81+
)}
82+
{zoneMessage?.end_time && (
83+
<p className="text-xs">
84+
{getAlertTimeRange(
85+
new Date(zoneMessage.start_time ?? ''),
86+
new Date(zoneMessage.end_time),
87+
i18n.languages[0],
88+
timezone
89+
)}
90+
</p>
91+
)}
92+
</div>
93+
<HorizontalDivider />
94+
{!isLongMessage && <div className="font-normal">{parseTextWithLinks(text)}</div>}
95+
{isLongMessage && (
96+
<div className="flex flex-col gap-1">
97+
<div className={`text-wrap font-normal ${isCollapsed ? 'line-clamp-2' : ''}`}>
98+
{parseTextWithLinks(text)}
99+
</div>
100+
<div className="items-left flex flex-row gap-1">
101+
<button
102+
onClick={handleCollapseToggle}
103+
className="font-semibold text-brand-yellow underline underline-offset-2 dark:text-brand-green-dark"
104+
>
105+
{isCollapsed
106+
? t('grid-alerts-card.show-more')
107+
: t('grid-alerts-card.show-less')}
108+
</button>
109+
</div>
110+
</div>
111+
)}
112+
</div>
113+
<TooltipWrapper
114+
side="bottom"
115+
tooltipContent={
116+
<LabelTooltip className=" w-[278px] max-w-[278px] rounded-lg text-left text-xs text-stone-500 dark:text-stone-400 sm:rounded-lg">
117+
{t('grid-alerts-card.tooltip')}
118+
</LabelTooltip>
119+
}
120+
>
121+
<div className="m-0 flex flex-row gap-1 border-t border-neutral-200 bg-[#EFF6FF] text-[#1E40AF] dark:border-neutral-700/80 dark:bg-blue-950 dark:text-blue-200">
122+
<div className="flex flex-row items-center gap-2 p-3">
123+
<FlaskConical className="font-bold " size={12} />
124+
<span className=" text-xs font-normal underline decoration-dotted underline-offset-2">
125+
{t('grid-alerts-card.experimental-mode')}
126+
</span>
127+
</div>
128+
</div>
129+
</TooltipWrapper>
130+
</RoundedCard>
131+
);
132+
}
133+
134+
function getAlertTimeRange(
135+
startTime: Date,
136+
endTime: Date,
137+
lang: string,
138+
timezone?: string
139+
) {
140+
return new Intl.DateTimeFormat(lang, {
141+
year: 'numeric',
142+
month: 'short',
143+
day: 'numeric',
144+
hour: 'numeric',
145+
minute: 'numeric',
146+
timeZoneName: 'short',
147+
timeZone: timezone,
148+
}).formatRange(startTime, endTime);
149+
}
150+
151+
// Helper to parse text and replace URLs in parentheses with styled links
152+
function parseTextWithLinks(text: string) {
153+
const urlRegex = /\((https?:\/\/[^)]+)\)/g;
154+
const parts: (string | JSX.Element)[] = [];
155+
let lastIndex = 0;
156+
let match;
157+
let key = 0;
158+
while ((match = urlRegex.exec(text)) !== null) {
159+
if (match.index > lastIndex) {
160+
parts.push(text.slice(lastIndex, match.index));
161+
}
162+
const url = match[1];
163+
// Truncate: show only domain (or first 30 chars if no domain)
164+
let display = url;
165+
try {
166+
const { hostname } = new URL(url);
167+
display = hostname;
168+
} catch {
169+
display = url.slice(0, 30) + (url.length > 30 ? '...' : '');
170+
}
171+
parts.push(
172+
<a
173+
key={`link-${key++}`}
174+
href={url}
175+
target="_blank"
176+
rel="noopener noreferrer"
177+
className="text-emerald-800 underline underline-offset-2 dark:text-emerald-500"
178+
>
179+
{display}
180+
</a>
181+
);
182+
lastIndex = match.index + match[0].length;
183+
}
184+
// Push remaining text
185+
if (lastIndex < text.length) {
186+
parts.push(text.slice(lastIndex));
187+
}
188+
return parts;
189+
}
190+
191+
const getAlertIcon = (alertType: string) =>
192+
alertType === 'action' ? (
193+
<AlertTriangle
194+
className="inline min-w-4 text-warning dark:text-warning-dark"
195+
size={16}
196+
/>
197+
) : (
198+
<CircleAlert className="inline min-w-4" size={16} />
199+
);
200+
201+
const getAlertColorClass = (alertType: string) =>
202+
alertType === 'action'
203+
? 'text-warning dark:text-warning-dark'
204+
: 'text-secondary dark:text-secondary-dark';

web/src/features/panels/zone/EstimationCard.cy.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ describe('OutageCard', () => {
4040
<I18nextProvider i18n={i18n}>
4141
<OutageCard
4242
estimationMethod={EstimationMethods.CONSTRUCT_BREAKDOWN}
43-
zoneMessage={{ message: 'Outage Message', issue: 'issue' }}
43+
zoneMessage={{
44+
message: 'Outage Message',
45+
issue: 'issue',
46+
message_type: 'custom',
47+
}}
4448
/>
4549
</I18nextProvider>
4650
);

0 commit comments

Comments
 (0)