|
1 | | -function median(sorted: number[]): number { |
2 | | - const mid = Math.floor(sorted.length / 2) |
3 | | - return sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]! |
4 | | -} |
5 | | - |
6 | 1 | /** |
7 | | - * IQR-based outlier filter. |
8 | | - * |
9 | | - * 1. Compute Q1, Q3, IQR across all values globally |
10 | | - * 2. Flag any point outside [Q1 - k×IQR, Q3 + k×IQR] as an outlier |
11 | | - * 3. Replace each outlier with the median of its non-outlier local neighbors |
12 | | - * |
13 | | - * This handles sustained anomalies (multi-week spikes) regardless of |
14 | | - * their position in the dataset — including at boundaries. |
| 2 | + * Bidirectional moving average. Blends a trailing (left-anchored) and leading |
| 3 | + * (right-anchored) average by position so transitions from both fixed endpoints |
| 4 | + * are smooth. |
| 5 | + * First and last points are preserved. |
15 | 6 | * |
16 | | - * @param data - array of objects with a `value` property |
17 | | - * @param multiplier - IQR multiplier k (0 = disabled, standard: 1.5, less aggressive: 3) |
18 | | - * @param windowSize - half-window for local median replacement (default: 6) |
| 7 | + * @param halfWindow - number of points on each side (0 = disabled) |
19 | 8 | */ |
20 | | -export function iqrFilter<T extends { value: number }>( |
21 | | - data: T[], |
22 | | - multiplier: number, |
23 | | - windowSize: number = 6, |
24 | | -): T[] { |
25 | | - if (multiplier <= 0 || data.length < 4) return data |
26 | | - |
27 | | - const sorted = data.map(d => d.value).sort((a, b) => a - b) |
28 | | - const q1 = median(sorted.slice(0, Math.floor(sorted.length / 2))) |
29 | | - const q3 = median(sorted.slice(Math.ceil(sorted.length / 2))) |
30 | | - const iqr = q3 - q1 |
| 9 | +export function movingAverage<T extends { value: number }>(data: T[], halfWindow: number): T[] { |
| 10 | + if (halfWindow <= 0 || data.length < 3) return data |
31 | 11 |
|
32 | | - if (iqr <= 0) return data |
| 12 | + const n = data.length |
33 | 13 |
|
34 | | - const lowerBound = q1 - multiplier * iqr |
35 | | - const upperBound = q3 + multiplier * iqr |
| 14 | + // Trailing average (anchored to start): average of [max(0, i-halfWindow), i] |
| 15 | + const trailing: number[] = Array.from({ length: n }) |
| 16 | + for (let i = 0; i < n; i++) { |
| 17 | + const lo = Math.max(0, i - halfWindow) |
| 18 | + let sum = 0 |
| 19 | + for (let j = lo; j <= i; j++) sum += data[j]!.value |
| 20 | + trailing[i] = sum / (i - lo + 1) |
| 21 | + } |
36 | 22 |
|
37 | | - const isOutlier = data.map(d => d.value < lowerBound || d.value > upperBound) |
38 | | - if (!isOutlier.some(Boolean)) return data |
| 23 | + // Leading average (anchored to end): average of [i, min(n-1, i+halfWindow)] |
| 24 | + const leading: number[] = Array.from({ length: n }) |
| 25 | + for (let i = 0; i < n; i++) { |
| 26 | + const hi = Math.min(n - 1, i + halfWindow) |
| 27 | + let sum = 0 |
| 28 | + for (let j = i; j <= hi; j++) sum += data[j]!.value |
| 29 | + leading[i] = sum / (hi - i + 1) |
| 30 | + } |
39 | 31 |
|
| 32 | + // Position-based blend: near start → mostly trailing, near end → mostly leading |
40 | 33 | const result = data.map(d => ({ ...d })) |
| 34 | + for (let i = 1; i < n - 1; i++) { |
| 35 | + const t = i / (n - 1) |
| 36 | + result[i]!.value = (1 - t) * trailing[i]! + t * leading[i]! |
| 37 | + } |
| 38 | + |
| 39 | + return result |
| 40 | +} |
| 41 | + |
| 42 | +/** |
| 43 | + * Forward-backward exponential smoothing (zero-phase). |
| 44 | + * Smooths without introducing lag — preserves the dynamics/timing of trends. |
| 45 | + * First and last points are preserved. |
| 46 | + * |
| 47 | + * @param tau - time constant (0 = disabled, higher = smoother) |
| 48 | + */ |
| 49 | +export function smoothing<T extends { value: number }>(data: T[], tau: number): T[] { |
| 50 | + if (tau <= 0 || data.length < 3) return data |
| 51 | + |
| 52 | + const alpha = 1 / (1 + tau) |
41 | 53 | const n = data.length |
42 | 54 |
|
43 | | - for (let i = 1; i < n - 1; i++) { |
44 | | - if (!isOutlier[i]) continue |
| 55 | + // Forward pass |
| 56 | + const forward: number[] = Array.from({ length: n }) |
| 57 | + forward[0] = data[0]!.value |
| 58 | + for (let i = 1; i < n; i++) { |
| 59 | + forward[i] = alpha * data[i]!.value + (1 - alpha) * forward[i - 1]! |
| 60 | + } |
45 | 61 |
|
46 | | - // Collect non-outlier neighbors within the window |
47 | | - const lo = Math.max(0, i - windowSize) |
48 | | - const hi = Math.min(n - 1, i + windowSize) |
49 | | - const neighbors: number[] = [] |
50 | | - for (let j = lo; j <= hi; j++) { |
51 | | - if (!isOutlier[j]) neighbors.push(data[j]!.value) |
52 | | - } |
| 62 | + // Backward pass |
| 63 | + const backward: number[] = Array.from({ length: n }) |
| 64 | + backward[n - 1] = data[n - 1]!.value |
| 65 | + for (let i = n - 2; i >= 0; i--) { |
| 66 | + backward[i] = alpha * data[i]!.value + (1 - alpha) * backward[i + 1]! |
| 67 | + } |
53 | 68 |
|
54 | | - if (neighbors.length > 0) { |
55 | | - neighbors.sort((a, b) => a - b) |
56 | | - result[i]!.value = median(neighbors) |
57 | | - } |
| 69 | + // Position-based blend: near start → mostly forward, near end → mostly backward |
| 70 | + // This ensures smooth transitions from both fixed endpoints |
| 71 | + const result = data.map(d => ({ ...d })) |
| 72 | + for (let i = 1; i < n - 1; i++) { |
| 73 | + const t = i / (n - 1) |
| 74 | + result[i]!.value = (1 - t) * forward[i]! + t * backward[i]! |
58 | 75 | } |
59 | 76 |
|
60 | 77 | return result |
61 | 78 | } |
62 | 79 |
|
63 | 80 | export interface ChartFilterSettings { |
64 | | - iqrMultiplier: number |
| 81 | + averageWindow: number |
| 82 | + smoothingTau: number |
65 | 83 | } |
66 | 84 |
|
67 | 85 | /** |
68 | | - * Applies IQR-based outlier filter to download data. |
| 86 | + * Applies moving average then smoothing in sequence. |
69 | 87 | */ |
70 | 88 | export function applyDownloadFilter<T extends { value: number }>( |
71 | 89 | data: T[], |
72 | 90 | settings: ChartFilterSettings, |
73 | 91 | ): T[] { |
74 | | - return iqrFilter(data, settings.iqrMultiplier) |
| 92 | + let result = data |
| 93 | + result = movingAverage(result, settings.averageWindow) |
| 94 | + result = smoothing(result, settings.smoothingTau) |
| 95 | + return result |
75 | 96 | } |
0 commit comments