Skip to content

Commit f8dbc1e

Browse files
committed
🎉 add aggregation window feature with toggle buttons and chips, need to handle negative numbers
1 parent 770c982 commit f8dbc1e

File tree

3 files changed

+164
-29
lines changed

3 files changed

+164
-29
lines changed

src/components/IconBar.tsx

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,18 @@ import SkipNextRounded from '@mui/icons-material/SkipNextRounded';
1313
import SkipPreviousRounded from '@mui/icons-material/SkipPreviousRounded';
1414
import ToggleButton from '@mui/material/ToggleButton';
1515
import PercentIcon from '@mui/icons-material/Percent';
16-
import ButtonGroup from '@mui/material/ButtonGroup';
16+
import Chip from '@mui/material/Chip';
1717
import {useAppDispatch, useAppSelector} from 'store/hooks';
18-
import {nextDay, previousDay, selectDate, toggleRelativeNumbers} from 'store/DataSelectionSlice';
18+
import {
19+
AggregationWindow,
20+
nextDay,
21+
previousDay,
22+
selectDate,
23+
setAggregationWindow,
24+
toggleRelativeNumbers,
25+
} from 'store/DataSelectionSlice';
1926
import {useTranslation} from 'react-i18next';
27+
import {ToggleButtonGroup} from '@mui/material';
2028

2129
export default function IconBar(): JSX.Element {
2230
const fsApi = useFullscreen();
@@ -30,6 +38,13 @@ export default function IconBar(): JSX.Element {
3038
const minDate = useAppSelector((state) => state.dataSelection.minDate);
3139
const maxDate = useAppSelector((state) => state.dataSelection.maxDate);
3240
const relativeNumbers = useAppSelector((state) => state.dataSelection.relativeNumbers ?? false);
41+
const aggregationWindow = useAppSelector((state) => state.dataSelection.aggregationWindow ?? AggregationWindow.Total);
42+
const windowLabel =
43+
aggregationWindow === AggregationWindow.SevenDays
44+
? '7d'
45+
: aggregationWindow === AggregationWindow.OneDay
46+
? '1d'
47+
: 'Total';
3348

3449
const toggleFullscreen = () => {
3550
if (fsApi.isFullscreenEnabled) {
@@ -125,12 +140,47 @@ export default function IconBar(): JSX.Element {
125140
</Button>
126141
</Tooltip>
127142
<Tooltip title='Aggregation window'>
128-
<ButtonGroup aria-label='Basic button group'>
129-
<Button>1d</Button>
130-
<Button>7d</Button>
131-
<Button>Total</Button>
132-
</ButtonGroup>
143+
<ToggleButtonGroup aria-label='Basic button group'>
144+
<ToggleButton
145+
value={AggregationWindow.Total}
146+
selected={aggregationWindow === AggregationWindow.Total}
147+
onClick={() => dispatch(setAggregationWindow(AggregationWindow.Total))}
148+
>
149+
Total
150+
</ToggleButton>
151+
<ToggleButton
152+
value={AggregationWindow.OneDay}
153+
selected={aggregationWindow === AggregationWindow.OneDay}
154+
onClick={() => dispatch(setAggregationWindow(AggregationWindow.OneDay))}
155+
>
156+
1d
157+
</ToggleButton>
158+
<ToggleButton
159+
value={AggregationWindow.SevenDays}
160+
selected={aggregationWindow === AggregationWindow.SevenDays}
161+
onClick={() => dispatch(setAggregationWindow(AggregationWindow.SevenDays))}
162+
>
163+
7d
164+
</ToggleButton>
165+
</ToggleButtonGroup>
133166
</Tooltip>
167+
168+
{/* Status chips: window and scale */}
169+
<Box sx={{display: 'flex', alignItems: 'center', gap: 1, marginLeft: 1}}>
170+
<Chip
171+
size='small'
172+
variant='outlined'
173+
label={`Window: ${windowLabel}`}
174+
aria-label={`Aggregation window ${windowLabel}`}
175+
/>
176+
<Chip
177+
size='small'
178+
color={relativeNumbers ? 'primary' : 'default'}
179+
variant={relativeNumbers ? 'filled' : 'outlined'}
180+
label={relativeNumbers ? 'per 100k' : 'absolute'}
181+
aria-label={relativeNumbers ? 'Scale per 100k' : 'Scale absolute'}
182+
/>
183+
</Box>
134184
</Box>
135185
);
136186
}

src/context/SelectedDataContext.tsx

Lines changed: 90 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
import {GeoJSON, GeoJsonProperties} from 'geojson';
3131
import {AuthContext} from 'react-oauth2-code-pkce';
3232
import {setToken} from 'store/AuthSlice';
33-
import {ScenarioVisibility} from 'store/DataSelectionSlice';
33+
import {AggregationWindow, ScenarioVisibility} from 'store/DataSelectionSlice';
3434

3535
interface DataContextType {
3636
geoData: GeoJSON;
@@ -68,9 +68,26 @@ export default function SelectedDataContext(props: {baseData: BaseData; children
6868
const referenceDate = useAppSelector((state) => state.dataSelection.simulationStart);
6969
const groupFilters = useAppSelector((state) => state.dataSelection.groupFilters);
7070
const relativeNumbers = useAppSelector((state) => state.dataSelection.relativeNumbers);
71+
const aggregationWindow = useAppSelector((state) => state.dataSelection.aggregationWindow);
7172

7273
const {token} = useContext(AuthContext);
7374

75+
// Helper to subtract days from an ISO date string (YYYY-MM-DD)
76+
function subtractDays(isoDate: string, days: number): string {
77+
const d = new Date(isoDate);
78+
d.setUTCDate(d.getUTCDate() - days);
79+
const year = d.getUTCFullYear();
80+
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
81+
const day = String(d.getUTCDate()).padStart(2, '0');
82+
return `${year}-${month}-${day}`;
83+
}
84+
85+
const aggregationOffset = useMemo(() => {
86+
if (aggregationWindow === AggregationWindow.OneDay) return 1;
87+
if (aggregationWindow === AggregationWindow.SevenDays) return 7;
88+
return 0;
89+
}, [aggregationWindow]);
90+
7491
useEffect(() => {
7592
dispatch(setToken(token));
7693
}, [dispatch, token]);
@@ -93,7 +110,11 @@ export default function SelectedDataContext(props: {baseData: BaseData; children
93110
scenarioId: caseData || '',
94111
},
95112
query: {
96-
startDate: referenceDate!,
113+
startDate: referenceDate
114+
? aggregationOffset > 0
115+
? subtractDays(referenceDate, aggregationOffset)
116+
: referenceDate
117+
: undefined,
97118
endDate: referenceDate!,
98119
nodes: [selectedDistrict],
99120
percentiles: ['50'],
@@ -131,7 +152,8 @@ export default function SelectedDataContext(props: {baseData: BaseData; children
131152
{
132153
pathIds: activeScenarios,
133154
query: {
134-
startDate: selectedDate!,
155+
startDate:
156+
selectedDate && aggregationOffset > 0 ? subtractDays(selectedDate, aggregationOffset) : selectedDate!,
135157
endDate: selectedDate!,
136158
nodes: [selectedDistrict],
137159
percentiles: ['50'],
@@ -156,7 +178,8 @@ export default function SelectedDataContext(props: {baseData: BaseData; children
156178
{
157179
pathIds: activeScenarios,
158180
query: {
159-
startDate: selectedDate!,
181+
startDate:
182+
selectedDate && aggregationOffset > 0 ? subtractDays(selectedDate, aggregationOffset) : selectedDate!,
160183
endDate: selectedDate!,
161184
nodes: [selectedDistrict],
162185
percentiles: ['50'],
@@ -208,7 +231,8 @@ export default function SelectedDataContext(props: {baseData: BaseData; children
208231
{
209232
path: {scenarioId: selectedScenario!},
210233
query: {
211-
startDate: selectedDate!,
234+
startDate:
235+
selectedDate && aggregationOffset > 0 ? subtractDays(selectedDate, aggregationOffset) : selectedDate!,
212236
endDate: selectedDate!,
213237
compartments: [selectedCompartment!],
214238
groups: totalGroup ? [totalGroup.id] : [],
@@ -225,42 +249,86 @@ export default function SelectedDataContext(props: {baseData: BaseData; children
225249
return map;
226250
}, [props.baseData.nodes]);
227251

228-
const normalizeInfectionData = useMemo(() => {
252+
// Derive windowed series (total/new1d/new7d) per node/group/compartment/percentile
253+
const deriveWindowedSeries = useMemo(() => {
254+
return (data: InfectionData, window: AggregationWindow | null): InfectionData => {
255+
if (!data || !Array.isArray(data) || window === null || window === AggregationWindow.Total) {
256+
console.log('deriveWindowedSeries', data, window);
257+
return data ?? [];
258+
}
259+
260+
const groups = new Map<string, InfectionData>();
261+
const makeKey = (e: (typeof data)[number]) =>
262+
`${e.node ?? ''}|${e.group ?? ''}|${e.compartment ?? ''}|${e.aggregation ?? ''}|${e.percentile}`;
263+
264+
for (const e of data) {
265+
const k = makeKey(e);
266+
if (!groups.has(k)) groups.set(k, []);
267+
groups.get(k)!.push(e);
268+
}
269+
270+
const out: InfectionData = [];
271+
for (const entries of groups.values()) {
272+
const sorted = [...entries].sort((a, b) => (a.date ?? '').localeCompare(b.date ?? ''));
273+
const offset = window === AggregationWindow.OneDay ? 1 : 7;
274+
for (let i = 0; i < sorted.length; i++) {
275+
const curr = sorted[i];
276+
const j = i - offset;
277+
278+
// skip if previous date < start of data
279+
if (j < 0) continue;
280+
const prev = sorted[j];
281+
const diff = curr.value - prev.value;
282+
out.push({...curr, value: diff});
283+
}
284+
}
285+
286+
return out;
287+
};
288+
}, []);
289+
290+
// Transform utility: apply window derivation (optionally) then relative normalization
291+
const transformInfectionData = useMemo(() => {
229292
return (data: InfectionData | undefined): InfectionData => {
230-
if (!data || !relativeNumbers) return data ?? [];
293+
if (!data) return [];
294+
console.log('transformInfectionData', data, aggregationWindow);
231295

232-
return data.map((entry) => {
296+
const windowed = deriveWindowedSeries(data, aggregationWindow);
297+
298+
if (!relativeNumbers) return windowed ?? [];
299+
console.log('transformInfectionData window', windowed);
300+
301+
return windowed.map((entry) => {
233302
const nuts = entry.node ? nodeIdToNuts[entry.node] : undefined;
234303
const pop = nuts ? props.baseData.populationByNuts[nuts] : undefined;
235304
const validPop = typeof pop === 'number' && isFinite(pop) && pop > 0 ? pop : undefined;
236305
const value = validPop ? (entry.value / validPop) * 100000 : entry.value;
237306
return {...entry, value};
238307
});
239308
};
240-
}, [nodeIdToNuts, props.baseData.populationByNuts, relativeNumbers]);
309+
}, [aggregationWindow, deriveWindowedSeries, nodeIdToNuts, props.baseData.populationByNuts, relativeNumbers]);
241310

242-
const normalizeMultiInfectionData = useMemo(() => {
311+
const transformMultiInfectionData = useMemo(() => {
243312
return (multi: Record<string, InfectionData> | undefined): Record<string, InfectionData> => {
244-
if (!multi || !relativeNumbers) return multi ?? {};
245-
313+
if (!multi) return {};
246314
const result: Record<string, InfectionData> = {};
247315
Object.entries(multi).forEach(([k, v]) => {
248-
result[k] = normalizeInfectionData(v);
316+
result[k] = transformInfectionData(v);
249317
});
250318
return result;
251319
};
252-
}, [normalizeInfectionData, relativeNumbers]);
320+
}, [transformInfectionData]);
253321

254322
const contextValue: DataContextType = useMemo(
255323
() => ({
256324
...props.baseData,
257-
mapData: normalizeInfectionData(mapData) ?? [],
258-
lineChartData: normalizeMultiInfectionData(lineChartData) ?? {},
259-
referenceDateValues: normalizeInfectionData(referenceDateValues) ?? [],
260-
scenarioCardData: normalizeMultiInfectionData(scenarioCardData) ?? {},
325+
mapData: transformInfectionData(mapData) ?? [],
326+
lineChartData: transformMultiInfectionData(lineChartData) ?? {},
327+
referenceDateValues: transformInfectionData(referenceDateValues) ?? [],
328+
scenarioCardData: transformMultiInfectionData(scenarioCardData) ?? {},
261329
scenarioCardMetaData: scenarioCardMetaData ?? {},
262-
groupFilterCardData: normalizeMultiInfectionData(groupFilterCardData) ?? {},
263-
groupFilterLineChartData: normalizeInfectionData(groupFilterLineChartData) ?? [],
330+
groupFilterCardData: transformMultiInfectionData(groupFilterCardData) ?? {},
331+
groupFilterLineChartData: transformInfectionData(groupFilterLineChartData) ?? [],
264332
selectedScenarioData: selectedScenarioData!,
265333
selectedSimulationModel: selectedSimulationModel!,
266334
parameterDefinitions: parameterDefinitions ?? {},
@@ -277,8 +345,8 @@ export default function SelectedDataContext(props: {baseData: BaseData; children
277345
selectedScenarioData,
278346
selectedSimulationModel,
279347
parameterDefinitions,
280-
normalizeInfectionData,
281-
normalizeMultiInfectionData,
348+
transformInfectionData,
349+
transformMultiInfectionData,
282350
]
283351
);
284352

src/store/DataSelectionSlice.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ export enum ScenarioVisibility {
2323
Hidden,
2424
}
2525

26+
export enum AggregationWindow {
27+
/** The aggregation window is total. */
28+
Total,
29+
30+
/** The aggregation window is 1 day. */
31+
OneDay,
32+
33+
/** The aggregation window is 7 days. */
34+
SevenDays,
35+
}
36+
2637
export interface ScenarioState {
2738
name: string;
2839
description: string;
@@ -48,6 +59,7 @@ export interface DataSelection {
4859
maxDate: string | null;
4960
groupFilters: Record<string, GroupFilter>;
5061
relativeNumbers: boolean | null;
62+
aggregationWindow: AggregationWindow | null;
5163
}
5264

5365
const initialState: DataSelection = {
@@ -62,6 +74,7 @@ const initialState: DataSelection = {
6274
maxDate: null,
6375
groupFilters: {},
6476
relativeNumbers: null,
77+
aggregationWindow: null,
6578
};
6679

6780
/**
@@ -182,6 +195,9 @@ export const DataSelectionSlice = createSlice({
182195
toggleRelativeNumbers(state) {
183196
state.relativeNumbers = !state.relativeNumbers;
184197
},
198+
setAggregationWindow(state, action: PayloadAction<AggregationWindow>) {
199+
state.aggregationWindow = action.payload;
200+
},
185201
},
186202
});
187203

@@ -204,6 +220,7 @@ export const {
204220
deleteGroupFilter,
205221
toggleGroupFilter,
206222
toggleRelativeNumbers,
223+
setAggregationWindow,
207224
} = DataSelectionSlice.actions;
208225

209226
export default DataSelectionSlice.reducer;

0 commit comments

Comments
 (0)