Skip to content

Commit ecb99cf

Browse files
Philipcursoragent
andcommitted
feat: add activity flags by reason for flag chart for single satellite
Add a bar chart and per-year table breaking down a satellite's activity flags by reason for flag, with a year filter. Data is aggregated client-side from the existing activity events endpoint, and the section is wired into the satellite activity accordion. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 016b21c commit ecb99cf

5 files changed

Lines changed: 237 additions & 4 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { useTranslations } from 'next-intl';
2+
import type { ReactNode } from 'react';
3+
4+
import BaseBar from '../base-bar/BaseBar';
5+
6+
export type ActivityFlagsByReasonDatum = {
7+
reason: string;
8+
count: number;
9+
color: string;
10+
};
11+
12+
export type ActivityFlagsByReasonChartProps = {
13+
actionButtons: ReactNode;
14+
data: ActivityFlagsByReasonDatum[];
15+
};
16+
17+
export function ActivityFlagsByReasonChart({
18+
actionButtons,
19+
data,
20+
}: ActivityFlagsByReasonChartProps) {
21+
const t = useTranslations('Charts.Activity_flags_by_reason');
22+
23+
const datasets = {
24+
labels: data.map(({ reason }) => reason),
25+
datasets: [
26+
{
27+
label: t('flags'),
28+
data: data.map(({ count }) => count),
29+
backgroundColor: data.map(({ color }) => color),
30+
borderColor: data.map(({ color }) => color),
31+
},
32+
],
33+
};
34+
35+
return (
36+
<BaseBar
37+
data={datasets}
38+
actionButtons={actionButtons}
39+
stacked={false}
40+
showTotal={false}
41+
ariaLabel="Activity flags by reason for flag"
42+
yAxisTitle={t('y_axis_title')}
43+
xAxisTitle={t('x_axis_title')}
44+
/>
45+
);
46+
}
47+
48+
export default ActivityFlagsByReasonChart;

src/components/satellite/SatelliteAccordion.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ import Accordion from '@/ui/accordion/accordion';
77
import { isAgencyApprover, isAgencyUser, isRegulatorUser, isSatteliteOperator } from '@/utils/Roles';
88

99
import { SatelliteActivityEvents } from './SatelliteActivityEvents';
10+
import { SatelliteActivityFlagsByReason } from './SatelliteActivityFlagsByReason';
1011
import { SatelliteAdditionalInformations } from './SatelliteAdditionalInformation';
1112
import { SatelliteConjunctionEvents } from './SatelliteConjunctionEvents';
1213
import { SatelliteConjunctionEventsByPoC } from './SatelliteConjunctionEventsByPoC';
1314
import { SatelliteEphemerisData } from './SatelliteEphemerisData';
1415
import { SatelliteFragmentationsEvents } from './SatelliteFragmentationsEvents';
1516
import { SatelliteInformation } from './SatelliteInformation';
16-
import { SatellitePositionHistory } from './SatellitePositionHistory';
1717
import { SatelliteReentriesEvents } from './SatelliteReentriesEvents';
1818

1919
type SatelliteAccordionProps = {
@@ -98,10 +98,15 @@ const SatelliteAccordion = async ({
9898
content: <SatelliteActivityEvents commonName={object.common_name} noradId={noradId} />,
9999
},
100100
{
101-
id: 'position_history',
102-
heading: t('position_history'),
103-
content: <SatellitePositionHistory noradId={noradId} commonName={object.common_name} />,
101+
id: 'activity_flags_by_reason',
102+
heading: t('activity_flags_by_reason'),
103+
content: <SatelliteActivityFlagsByReason noradId={noradId} />,
104104
},
105+
// {
106+
// id: 'position_history',
107+
// heading: t('position_history'),
108+
// content: <SatellitePositionHistory noradId={noradId} commonName={object.common_name} />,
109+
// },
105110
]}
106111
/>
107112
</>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { getTranslations } from 'next-intl/server';
2+
3+
import { getActivityEventsByNoradId } from '@/actions/getActivityEventsNoradId';
4+
import Details from '@/ui/details/details';
5+
6+
import { DownloadData } from '../DownloadData';
7+
import { SatelliteActivityFlagsByReasonContent } from './SatelliteActivityFlagsByReasonContent';
8+
9+
type SatelliteActivityFlagsByReasonProps = {
10+
noradId: string;
11+
};
12+
13+
const SatelliteActivityFlagsByReason = async ({ noradId }: SatelliteActivityFlagsByReasonProps) => {
14+
const t = await getTranslations('Satellite.Activity_flags_by_reason');
15+
const data = await getActivityEventsByNoradId(noradId);
16+
17+
const downloadData = async () => {
18+
'use server';
19+
return getActivityEventsByNoradId(noradId);
20+
};
21+
22+
return (
23+
<div>
24+
<SatelliteActivityFlagsByReasonContent initialData={data} />
25+
<DownloadData type={t('title')} params={{ norad_id: noradId }} downloadAction={downloadData} ariaLabel="Activity flags by reason for flag" />
26+
<Details summary={t.rich('details.title')}>
27+
{t('details.content')}
28+
</Details>
29+
</div>
30+
);
31+
};
32+
33+
export { SatelliteActivityFlagsByReason };
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
'use client';
2+
3+
import { useTranslations } from 'next-intl';
4+
import { useMemo, useState } from 'react';
5+
6+
import type { TypeActivityEvent } from '@/__generated__/data-contracts';
7+
import ActivityFlagsByReasonChart from '@/components/charts/activity-flags-by-reason/ActivityFlagsByReason';
8+
import { chartPalette } from '@/components/charts/base/theme';
9+
import { DataTable } from '@/components/DataTable';
10+
import { Scrollable } from '@/components/Scrollable';
11+
import type { TranslatedColumnDef } from '@/types';
12+
import Select from '@/ui/select/select';
13+
14+
const REASONS = [
15+
{ key: 'planned', match: 'Manoeuvre (planned)', labelKey: 'manoeuvre_planned', color: chartPalette.nspocBlue },
16+
{ key: 'unplanned', match: 'Manoeuvre (unplanned)', labelKey: 'manoeuvre_unplanned', color: chartPalette.nspocRed },
17+
{ key: 'positionChange', match: 'Position change', labelKey: 'position_change', color: chartPalette.nspocYellow },
18+
{ key: 'missingData', match: 'Missing data', labelKey: 'missing_data', color: chartPalette.nspocGreen },
19+
] as const;
20+
21+
type ReasonCounts = {
22+
positionChange: number;
23+
planned: number;
24+
unplanned: number;
25+
missingData: number;
26+
};
27+
28+
type YearRow = ReasonCounts & {
29+
year: string;
30+
total: number;
31+
};
32+
33+
const countByReason = (events: TypeActivityEvent[]): ReasonCounts => ({
34+
positionChange: events.filter(e => e.reason_for_flag === 'Position change').length,
35+
planned: events.filter(e => e.reason_for_flag === 'Manoeuvre (planned)').length,
36+
unplanned: events.filter(e => e.reason_for_flag === 'Manoeuvre (unplanned)').length,
37+
missingData: events.filter(e => e.reason_for_flag === 'Missing data' || e.reason_for_flag == null).length,
38+
});
39+
40+
const tableColumns: TranslatedColumnDef<YearRow>[] = [
41+
{ accessorKey: 'year', id: 'year', header: 'Activity_flags_by_reason.year', enableSorting: false },
42+
{ accessorKey: 'planned', id: 'planned', header: 'Activity_flags_by_reason.manoeuvre_planned', enableSorting: false },
43+
{ accessorKey: 'unplanned', id: 'unplanned', header: 'Activity_flags_by_reason.manoeuvre_unplanned', enableSorting: false },
44+
{ accessorKey: 'positionChange', id: 'positionChange', header: 'Activity_flags_by_reason.position_change', enableSorting: false },
45+
{ accessorKey: 'missingData', id: 'missingData', header: 'Activity_flags_by_reason.missing_data', enableSorting: false },
46+
{ accessorKey: 'total', id: 'total', header: 'Activity_flags_by_reason.total', enableSorting: false },
47+
];
48+
49+
type SatelliteActivityFlagsByReasonContentProps = {
50+
initialData: TypeActivityEvent[];
51+
};
52+
53+
const SatelliteActivityFlagsByReasonContent = ({ initialData }: SatelliteActivityFlagsByReasonContentProps) => {
54+
const t = useTranslations('Charts.Activity_flags_by_reason');
55+
const [selectedYear, setSelectedYear] = useState('all');
56+
57+
const years = useMemo(
58+
() => Array.from(new Set(initialData.map(e => e.year))).sort((a, b) => b - a),
59+
[initialData],
60+
);
61+
62+
const chartData = useMemo(() => {
63+
const events = selectedYear === 'all'
64+
? initialData
65+
: initialData.filter(e => String(e.year) === selectedYear);
66+
const counts = countByReason(events);
67+
return REASONS.map(({ key, labelKey, color }) => ({
68+
reason: t(labelKey),
69+
count: counts[key],
70+
color,
71+
}));
72+
}, [initialData, selectedYear, t]);
73+
74+
const rows: YearRow[] = useMemo(
75+
() => years.map((year) => {
76+
const events = initialData.filter(e => e.year === year);
77+
return {
78+
year: String(year),
79+
...countByReason(events),
80+
total: events.length,
81+
};
82+
}),
83+
[initialData, years],
84+
);
85+
86+
const yearFilter = (
87+
<Select
88+
name="activity-flags-by-reason-year"
89+
id="activity-flags-by-reason-year"
90+
label={t('year_filter')}
91+
value={selectedYear}
92+
options={[
93+
{ children: t('all_years'), value: 'all' },
94+
...years.map(year => ({ children: String(year), value: String(year) })),
95+
]}
96+
onChange={e => setSelectedYear(e.target.value)}
97+
/>
98+
);
99+
100+
return (
101+
<div>
102+
<p className="govuk-body">{t('description')}</p>
103+
<div className="mb-4">
104+
<ActivityFlagsByReasonChart data={chartData} actionButtons={yearFilter} />
105+
</div>
106+
<Scrollable>
107+
<DataTable
108+
columns={tableColumns}
109+
data={rows}
110+
enableSorting={false}
111+
ariaLabel="Information on activity flags by reason for flag"
112+
emptyLabel="No activity flags found."
113+
/>
114+
</Scrollable>
115+
</div>
116+
);
117+
};
118+
119+
export { SatelliteActivityFlagsByReasonContent };

src/locales/en.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,7 @@
431431
"conjunction_events_by_probability_of_collision": "Conjunction events by probability of collision",
432432
"activity_information": "Activity information",
433433
"all_activity_events": "All activity events",
434+
"activity_flags_by_reason": "Activity flags by reason for flag",
434435
"position_history": "Position history",
435436
"reentries": "Re-entry events",
436437
"all_reentries": "All re-entry events",
@@ -479,6 +480,13 @@
479480
"content": "<p>This table shows all ongoing and closed activity flags for {commonName}.</p><p>Select the event ID to view more information</p>"
480481
}
481482
},
483+
"Activity_flags_by_reason": {
484+
"title": "Activity flags by reason for flag",
485+
"details": {
486+
"title": "Help with this table<screenReaderOnly>- Activity flags by reason for flag</screenReaderOnly>",
487+
"content": "This table shows the number of activity flags for this satellite broken down by reason for flag and year, with the total number of flags per year."
488+
}
489+
},
482490
"Position_history": {
483491
"description": "<p>This section shows the position of {common_name} at every received TLE.</p><p>TLE data is pulled from SpaceTrack.org and may occasionally be inaccurate. All flagged position changes have been verified by NSpOC Orbital Analysts.</p>"
484492
}
@@ -640,6 +648,18 @@
640648
"y_axis_title": "Number of events",
641649
"x_axis_title": "Date"
642650
},
651+
"Activity_flags_by_reason": {
652+
"description": "Number of activity flags broken down by reason for flag. Use the filter to view a single year or all years.",
653+
"flags": "Number of flags",
654+
"year_filter": "Year",
655+
"all_years": "All years",
656+
"position_change": "Unexpected position change",
657+
"manoeuvre_planned": "Manoeuvre as planned",
658+
"manoeuvre_unplanned": "Manoeuvre not as planned",
659+
"missing_data": "Missing data",
660+
"y_axis_title": "Number of flags",
661+
"x_axis_title": "Reason for flag"
662+
},
643663
"Organisations_and_users": {
644664
"data_range": "Data range",
645665
"12_months": "12 months",
@@ -1264,6 +1284,14 @@
12641284
"latest_tle_epoch": "Latest TLE epoch",
12651285
"object_information": "Object information"
12661286
},
1287+
"Activity_flags_by_reason": {
1288+
"year": "Year",
1289+
"position_change": "Unexpected position change",
1290+
"manoeuvre_planned": "Manoeuvre as planned",
1291+
"manoeuvre_unplanned": "Manoeuvre not as planned",
1292+
"missing_data": "Missing data",
1293+
"total": "Total"
1294+
},
12671295
"Satellites": {
12681296
"common_name": "Common name",
12691297
"norad_id": "NORAD ID",

0 commit comments

Comments
 (0)