Skip to content

Commit 57bb283

Browse files
committed
Add chart outlier removal
1 parent 1b9647a commit 57bb283

9 files changed

Lines changed: 184 additions & 9 deletions

File tree

docs/DEVELOPER_GUIDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ Current wrappers:
131131
- `setAccentTheme`
132132
- `setHeatmapFullOpacity`
133133
- `setChartMaxSamples`
134+
- `setChartOutlierRemoval`
134135
- `setHeartRateZoneUpperBoundsBpm`
135136
- `scanImportFolder`
136137
- `listActivities`
@@ -154,7 +155,7 @@ Current wrappers:
154155
- **Onboarding:** directory picker + recursive toggle.
155156
- **Settings:** tabs for Import, Appearance, Athlete Metrics.
156157
- Import actions: incremental rescan and full cache clear + rescan.
157-
- Appearance includes chart sample cap slider and heatmap opacity preference.
158+
- Appearance includes chart sample cap slider, chart outlier-removal toggle, and heatmap opacity preference.
158159
- Athlete Metrics manages heart-rate zone cutoffs.
159160
- **Dashboard:** year/month calendar views with aggregate metrics and drilldowns.
160161
- **Activities:** filter + sort table, navigation into details.
@@ -187,6 +188,7 @@ Registered Tauri commands:
187188
| `set_accent_theme` | Update accent palette |
188189
| `set_heatmap_full_opacity` | Toggle heatmap opacity mode |
189190
| `set_chart_max_samples` | Persist chart sample cap |
191+
| `set_chart_outlier_removal` | Toggle robust outlier suppression in Activity Detail charts |
190192
| `set_heart_rate_zone_upper_bounds_bpm` | Save Z1-Z4 upper bpm limits (Z5 is everything above Z4) |
191193
| `scan_import_folder` | Run incremental/full scan |
192194
| `list_activities` | Query activity list |
@@ -263,6 +265,7 @@ Default settings (current):
263265
- `accentTheme: "citrus-orange"`
264266
- `heatmapFullOpacity: false`
265267
- `chartMaxSamples: 2000`
268+
- `chartOutlierRemoval: true`
266269
- `heartRateZoneUpperBoundsBpm: [120, 140, 160, 180]`
267270

268271
## 7. Contracts Between Frontend and Rust

src-tauri/src/main.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,17 @@ async fn set_chart_max_samples(
186186
})
187187
}
188188

189+
#[tauri::command]
190+
async fn set_chart_outlier_removal(
191+
chart_outlier_removal: bool,
192+
state: State<'_, AppState>,
193+
) -> Result<Settings, String> {
194+
update_app_settings(state.inner(), move |settings| {
195+
settings.chart_outlier_removal = chart_outlier_removal;
196+
Ok(())
197+
})
198+
}
199+
189200
#[tauri::command]
190201
async fn set_heart_rate_zone_upper_bounds_bpm(
191202
upper_bounds_bpm: Vec<u16>,
@@ -340,6 +351,7 @@ fn main() {
340351
set_accent_theme,
341352
set_heatmap_full_opacity,
342353
set_chart_max_samples,
354+
set_chart_outlier_removal,
343355
set_heart_rate_zone_upper_bounds_bpm,
344356
scan_import_folder,
345357
list_activities,

src-tauri/src/models.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub struct Settings {
1111
pub accent_theme: String,
1212
pub heatmap_full_opacity: bool,
1313
pub chart_max_samples: usize,
14+
pub chart_outlier_removal: bool,
1415
pub heart_rate_zone_upper_bounds_bpm: Vec<u16>,
1516
}
1617

@@ -24,6 +25,7 @@ impl Default for Settings {
2425
accent_theme: "citrus-orange".to_string(),
2526
heatmap_full_opacity: false,
2627
chart_max_samples: 2000,
28+
chart_outlier_removal: true,
2729
heart_rate_zone_upper_bounds_bpm: vec![120, 140, 160, 180],
2830
}
2931
}

src/lib/activityDetail/chartHelpers.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,141 @@ export interface CombinedChartPoint {
7373
elevationPlot: number | null;
7474
}
7575

76+
export type CombinedChartBasePoint = Omit<
77+
CombinedChartPoint,
78+
'pacePlot' | 'speedPlot' | 'heartRatePlot' | 'cadencePlot' | 'powerPlot' | 'elevationPlot'
79+
>;
80+
7681
export interface CombinedChartModel {
7782
data: CombinedChartPoint[];
7883
has: Record<ChartSeriesKey, boolean>;
7984
maxDistanceKm: number;
8085
maxElapsedSeconds: number;
8186
}
8287

88+
type OutlierBounds = {
89+
min?: number;
90+
max?: number;
91+
};
92+
93+
const OUTLIER_BOUNDS_BY_KEY: Record<SplitMetricKey, OutlierBounds> = {
94+
paceSecondsPerKm: { min: 90, max: 3600 },
95+
speedKmh: { min: 0, max: 130 },
96+
heartRate: { min: 40, max: 240 },
97+
elevationM: {},
98+
cadence: { min: 20, max: 260 },
99+
powerWatts: { min: 0, max: 2500 }
100+
};
101+
102+
const OUTLIER_WINDOW_RADIUS = 5;
103+
const OUTLIER_MIN_WINDOW_SAMPLES = 7;
104+
const OUTLIER_Z_THRESHOLD = 3.5;
105+
const OUTLIER_MAD_EPSILON = 1e-6;
106+
107+
function median(values: number[]): number {
108+
if (values.length === 0) {
109+
return NaN;
110+
}
111+
112+
const sorted = [...values].sort((left, right) => left - right);
113+
const middle = Math.floor(sorted.length / 2);
114+
if (sorted.length % 2 === 0) {
115+
return (sorted[middle - 1] + sorted[middle]) / 2;
116+
}
117+
return sorted[middle];
118+
}
119+
120+
function filterSeriesOutliers(values: Array<number | null>, bounds: OutlierBounds): Array<number | null> {
121+
const filtered: Array<number | null> = new Array(values.length).fill(null);
122+
123+
for (let index = 0; index < values.length; index += 1) {
124+
const value = values[index];
125+
if (value == null || !Number.isFinite(value)) {
126+
filtered[index] = null;
127+
continue;
128+
}
129+
130+
if ((bounds.min != null && value < bounds.min) || (bounds.max != null && value > bounds.max)) {
131+
filtered[index] = null;
132+
continue;
133+
}
134+
135+
const neighborhood: number[] = [];
136+
const start = Math.max(0, index - OUTLIER_WINDOW_RADIUS);
137+
const end = Math.min(values.length - 1, index + OUTLIER_WINDOW_RADIUS);
138+
for (let neighborIndex = start; neighborIndex <= end; neighborIndex += 1) {
139+
const neighborValue = values[neighborIndex];
140+
if (neighborValue != null && Number.isFinite(neighborValue)) {
141+
neighborhood.push(neighborValue);
142+
}
143+
}
144+
145+
if (neighborhood.length < OUTLIER_MIN_WINDOW_SAMPLES) {
146+
filtered[index] = value;
147+
continue;
148+
}
149+
150+
const center = median(neighborhood);
151+
if (!Number.isFinite(center)) {
152+
filtered[index] = value;
153+
continue;
154+
}
155+
156+
const absoluteDeviations = neighborhood.map((entry) => Math.abs(entry - center));
157+
const mad = median(absoluteDeviations);
158+
if (!Number.isFinite(mad) || mad < OUTLIER_MAD_EPSILON) {
159+
filtered[index] = value;
160+
continue;
161+
}
162+
163+
const robustZScore = Math.abs(value - center) / (1.4826 * mad);
164+
filtered[index] = robustZScore > OUTLIER_Z_THRESHOLD ? null : value;
165+
}
166+
167+
return filtered;
168+
}
169+
170+
export function removeCombinedChartOutliers(points: CombinedChartBasePoint[]): CombinedChartBasePoint[] {
171+
if (points.length === 0) {
172+
return points;
173+
}
174+
175+
const paceValues = filterSeriesOutliers(
176+
points.map((point) => point.paceSecondsPerKm),
177+
OUTLIER_BOUNDS_BY_KEY.paceSecondsPerKm
178+
);
179+
const speedValues = filterSeriesOutliers(
180+
points.map((point) => point.speedKmh),
181+
OUTLIER_BOUNDS_BY_KEY.speedKmh
182+
);
183+
const heartRateValues = filterSeriesOutliers(
184+
points.map((point) => point.heartRate),
185+
OUTLIER_BOUNDS_BY_KEY.heartRate
186+
);
187+
const cadenceValues = filterSeriesOutliers(
188+
points.map((point) => point.cadence),
189+
OUTLIER_BOUNDS_BY_KEY.cadence
190+
);
191+
const powerValues = filterSeriesOutliers(
192+
points.map((point) => point.powerWatts),
193+
OUTLIER_BOUNDS_BY_KEY.powerWatts
194+
);
195+
const elevationValues = filterSeriesOutliers(
196+
points.map((point) => point.elevationM),
197+
OUTLIER_BOUNDS_BY_KEY.elevationM
198+
);
199+
200+
return points.map((point, index) => ({
201+
...point,
202+
paceSecondsPerKm: paceValues[index],
203+
speedKmh: speedValues[index],
204+
heartRate: heartRateValues[index],
205+
cadence: cadenceValues[index],
206+
powerWatts: powerValues[index],
207+
elevationM: elevationValues[index]
208+
}));
209+
}
210+
83211
export function defaultChartSeriesVisibility(sportType?: string): ChartSeriesVisibility {
84212
const normalizedSport = (sportType ?? '').trim().toLowerCase();
85213

src/lib/tauri.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ export const setHeatmapFullOpacity = (heatmapFullOpacity: boolean) =>
3434
export const setChartMaxSamples = (chartMaxSamples: number) =>
3535
invoke<Settings>('set_chart_max_samples', { chartMaxSamples });
3636

37+
export const setChartOutlierRemoval = (chartOutlierRemoval: boolean) =>
38+
invoke<Settings>('set_chart_outlier_removal', { chartOutlierRemoval });
39+
3740
export const setHeartRateZoneUpperBoundsBpm = (upperBoundsBpm: number[]) =>
3841
invoke<Settings>('set_heart_rate_zone_upper_bounds_bpm', { upperBoundsBpm });
3942

src/pages/ActivityDetailPage.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import {
7272
metricRangeForVisibleDomain,
7373
normalizeHeartRateZoneUpperBounds,
7474
normalizeToBand,
75+
removeCombinedChartOutliers,
7576
readHoveredRouteCoordinate,
7677
routeHoverCoordinatesEqual
7778
} from '@/lib/activityDetail/chartHelpers';
@@ -907,6 +908,7 @@ export function ActivityDetailPage() {
907908
const routeMapRef = useRef<ActivityRouteMapHandle | null>(null);
908909
const accentTheme = useAppStore((state) => state.settings?.accentTheme);
909910
const chartMaxSamples = useAppStore((state) => state.settings?.chartMaxSamples ?? 2000);
911+
const chartOutlierRemoval = useAppStore((state) => state.settings?.chartOutlierRemoval ?? true);
910912
const heartRateZoneUpperBoundsBpm = useAppStore((state) =>
911913
normalizeHeartRateZoneUpperBounds(state.settings?.heartRateZoneUpperBoundsBpm)
912914
);
@@ -1008,12 +1010,14 @@ export function ActivityDetailPage() {
10081010
};
10091011
});
10101012

1011-
const paceRange = metricRange(basePoints.map((point) => point.paceSecondsPerKm));
1012-
const speedRange = metricRange(basePoints.map((point) => point.speedKmh));
1013-
const heartRateRange = metricRange(basePoints.map((point) => point.heartRate));
1014-
const cadenceRange = metricRange(basePoints.map((point) => point.cadence));
1015-
const powerRange = metricRange(basePoints.map((point) => point.powerWatts));
1016-
const elevationRange = metricRange(basePoints.map((point) => point.elevationM));
1013+
const filteredBasePoints = chartOutlierRemoval ? removeCombinedChartOutliers(basePoints) : basePoints;
1014+
1015+
const paceRange = metricRange(filteredBasePoints.map((point) => point.paceSecondsPerKm));
1016+
const speedRange = metricRange(filteredBasePoints.map((point) => point.speedKmh));
1017+
const heartRateRange = metricRange(filteredBasePoints.map((point) => point.heartRate));
1018+
const cadenceRange = metricRange(filteredBasePoints.map((point) => point.cadence));
1019+
const powerRange = metricRange(filteredBasePoints.map((point) => point.powerWatts));
1020+
const elevationRange = metricRange(filteredBasePoints.map((point) => point.elevationM));
10171021
const has = {
10181022
pace: paceRange != null,
10191023
speed: speedRange != null,
@@ -1027,7 +1031,7 @@ export function ActivityDetailPage() {
10271031
);
10281032
const bands = buildCombinedChartBands(visibleSeries);
10291033

1030-
const data: CombinedChartPoint[] = basePoints.map((point) => ({
1034+
const data: CombinedChartPoint[] = filteredBasePoints.map((point) => ({
10311035
...point,
10321036
pacePlot: normalizeToBand(point.paceSecondsPerKm, paceRange, bands.pace, true),
10331037
speedPlot: normalizeToBand(point.speedKmh, speedRange, bands.speed),
@@ -1052,7 +1056,7 @@ export function ActivityDetailPage() {
10521056
maxDistanceKm,
10531057
maxElapsedSeconds
10541058
};
1055-
}, [chartSamples, detail, chartSeriesVisibility]);
1059+
}, [chartOutlierRemoval, chartSamples, detail, chartSeriesVisibility]);
10561060

10571061
const fullChartXAxisDomain = useMemo<ChartZoomDomain>(() => {
10581062
if (chartXAxisMode === 'time') {

src/pages/SettingsPage.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export function SettingsPage() {
1717
const updateAccentTheme = useAppStore((state) => state.updateAccentTheme);
1818
const updateHeatmapFullOpacity = useAppStore((state) => state.updateHeatmapFullOpacity);
1919
const updateChartMaxSamples = useAppStore((state) => state.updateChartMaxSamples);
20+
const updateChartOutlierRemoval = useAppStore((state) => state.updateChartOutlierRemoval);
2021
const updateHeartRateZoneUpperBoundsBpm = useAppStore(
2122
(state) => state.updateHeartRateZoneUpperBoundsBpm
2223
);
@@ -357,6 +358,21 @@ export function SettingsPage() {
357358
detail before zooming.
358359
</p>
359360
</div>
361+
<label className="mt-4 flex items-start gap-2 text-sm text-muted">
362+
<input
363+
type="checkbox"
364+
checked={settings?.chartOutlierRemoval ?? true}
365+
onChange={(event) => void updateChartOutlierRemoval(event.target.checked)}
366+
className="mt-0.5 h-4 w-4 rounded border-border bg-bg"
367+
/>
368+
<span>
369+
Remove chart outliers
370+
<span className="mt-1 block text-xs text-muted">
371+
Uses a robust local filter to hide suspicious spikes from pace, speed, heart
372+
rate, cadence, power, and elevation plots.
373+
</span>
374+
</span>
375+
</label>
360376
</div>
361377

362378
<div className="mt-6 border-t border-border pt-5">

src/store/useAppStore.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
scanImportFolder,
66
setAccentTheme,
77
setChartMaxSamples,
8+
setChartOutlierRemoval,
89
setDarkMode,
910
setHeatmapFullOpacity,
1011
setHeartRateZoneUpperBoundsBpm,
@@ -35,6 +36,7 @@ interface AppState {
3536
updateAccentTheme: (accentTheme: AccentThemeId) => Promise<void>;
3637
updateHeatmapFullOpacity: (heatmapFullOpacity: boolean) => Promise<void>;
3738
updateChartMaxSamples: (chartMaxSamples: number) => Promise<void>;
39+
updateChartOutlierRemoval: (chartOutlierRemoval: boolean) => Promise<void>;
3840
updateHeartRateZoneUpperBoundsBpm: (upperBoundsBpm: number[]) => Promise<void>;
3941
runScan: (fullRescan?: boolean) => Promise<void>;
4042
getCachedActivities: (cacheKey: string) => ActivitySummary[] | null;
@@ -96,6 +98,10 @@ export const useAppStore = create<AppState>((set, get) => ({
9698
const settings = await setChartMaxSamples(chartMaxSamples);
9799
set({ settings });
98100
},
101+
updateChartOutlierRemoval: async (chartOutlierRemoval) => {
102+
const settings = await setChartOutlierRemoval(chartOutlierRemoval);
103+
set({ settings });
104+
},
99105
updateHeartRateZoneUpperBoundsBpm: async (upperBoundsBpm) => {
100106
const settings = await setHeartRateZoneUpperBoundsBpm(upperBoundsBpm);
101107
set({ settings });

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface Settings {
66
accentTheme: string;
77
heatmapFullOpacity: boolean;
88
chartMaxSamples: number;
9+
chartOutlierRemoval: boolean;
910
heartRateZoneUpperBoundsBpm: number[];
1011
}
1112

0 commit comments

Comments
 (0)