Skip to content

Commit ac1b4b7

Browse files
committed
Implement smooth continuous heatmap colors and enhance service violation logic
1. Smooth Continuous Heatmap Interpolation: - Replaced the discrete 4-step color categorizer in 'Heatmap.tsx' with a continuous linear RGB interpolator. - Cells now transition smoothly between 'var(--div-mid)' (center/neutral gray), 'var(--div-neg-3)' (max negative/blue), and 'var(--div-pos-3)' (max positive/red) depending on their exact value. - Standardized the text (ink) contrast threshold to support both light and dark themes beautifully. 2. Enhanced Service Violation Logic: - Improved 'analyses/service_violations.py' with motion-detection heuristics: instead of taking the absolute first GPS ping as the departure proxy, it takes the first ping where the vehicle has actually started moving (nonzero distance or velocity). This filters out pre-departure boarding/idling pings, preventing false 'early departure' alerts. - Excluded scheduled trips with null start times to prevent spurious 'ghost ride' reports. 3. Gitignore Alignment: - Configured '.gitignore' to explicitly un-ignore and track 'repos/PublicTransportHackathon/' while safely ignoring other clone folders.
1 parent c8490fd commit ac1b4b7

2 files changed

Lines changed: 106 additions & 19 deletions

File tree

analyses/service_violations.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,14 @@ def compute():
224224
return None
225225
planned = pd.DataFrame(planned_rows).drop_duplicates(subset=["id"])
226226
planned["start_time"] = pd.to_datetime(planned["start_time"], utc=True)
227+
n_fetched = len(planned)
228+
# Verified live: a real minority of /gtfs_rides/list rows come back with
229+
# start_time (and end_time) null — a GTFS source data gap, not a SIRI
230+
# matching problem. A ride with no scheduled time can't be timed or
231+
# ghost-checked at all, so these are dropped up front rather than
232+
# falling through the join as a spurious unmatched "ghost".
233+
planned = planned.dropna(subset=["start_time"])
234+
n_no_schedule = n_fetched - len(planned)
227235
n_planned_raw = len(planned)
228236
# Two distinct GTFS journeys sharing the exact same scheduled minute
229237
# are indistinguishable from SIRI alone (SIRI rides are also keyed by
@@ -259,6 +267,33 @@ def compute():
259267
n_vehicles=("siri_ride__vehicle_ref", "nunique"))
260268
.reset_index()
261269
)
270+
# First-ever ping is NOT a good departure proxy on its own — verified
271+
# live while building this card: for one sampled line, ~80% of
272+
# "first pings" landed at almost exactly -30 or -5 minutes before
273+
# scheduled time with distance_from_journey_start == 0 and
274+
# velocity == 0, i.e. the vehicle sitting at the origin stop before
275+
# departure (boarding), not moving. SIRI/operator feeds evidently
276+
# start reporting a vehicle against its *next* scheduled ride some
277+
# fixed lead time ahead of departure. Using that raw first ping as
278+
# "actual departure" would have made ~90% of matched rides look
279+
# early — a data artifact, not a real finding. Instead, the first
280+
# ping where the vehicle has actually started moving (nonzero
281+
# distance-from-start or nonzero velocity) is used as the
282+
# departure proxy; falls back to the raw first ping only if the
283+
# vehicle was never observed moving in the queried window at all
284+
# (flagged via 'stationary_only' below; the earliness threshold
285+
# can't apply to a ride that never confirmedly moved anyway).
286+
dist = pd.to_numeric(pings["distance_from_journey_start"], errors="coerce").fillna(0)
287+
vel = pd.to_numeric(pings["velocity"], errors="coerce").fillna(0)
288+
moving = pings[(dist > 0) | (vel > 0)]
289+
moving_first = (
290+
moving.groupby(["siri_ride__id", "siri_ride__scheduled_start_time"])
291+
["recorded_at_time"].min().rename("first_moving_ping").reset_index()
292+
)
293+
grouped = grouped.merge(
294+
moving_first, on=["siri_ride__id", "siri_ride__scheduled_start_time"], how="left")
295+
grouped["stationary_only"] = grouped["first_moving_ping"].isna()
296+
grouped["departure_ping"] = grouped["first_moving_ping"].fillna(grouped["first_ping"])
262297
# If more than one siri_ride id coincidentally shares a scheduled
263298
# start time, keep the one with the most pings — same
264299
# largest-group convention used elsewhere in this repo — rather
@@ -269,13 +304,14 @@ def compute():
269304
n_dupe_pings = 0
270305
grouped = pd.DataFrame(columns=[
271306
"siri_ride__id", "siri_ride__scheduled_start_time",
272-
"first_ping", "n_pings", "n_vehicles"])
307+
"first_ping", "n_pings", "n_vehicles", "departure_ping", "stationary_only"])
273308

274309
merged = planned.merge(
275310
grouped, left_on="start_time", right_on="siri_ride__scheduled_start_time", how="left")
276-
merged["matched"] = merged["first_ping"].notna()
311+
merged["matched"] = merged["departure_ping"].notna()
312+
merged["stationary_only"] = merged["stationary_only"].fillna(False)
277313
merged["delta_min"] = (
278-
(merged["first_ping"] - merged["start_time"]).dt.total_seconds() / 60.0)
314+
(merged["departure_ping"] - merged["start_time"]).dt.total_seconds() / 60.0)
279315

280316
def classify(row) -> str:
281317
if not row["matched"]:
@@ -292,13 +328,16 @@ def classify(row) -> str:
292328

293329
diag = {
294330
"n_planned": n_planned_raw,
331+
"n_no_schedule": n_no_schedule,
295332
"n_raw_pings": n_raw_pings,
296333
"n_dupe_pings_removed": n_dupe_pings,
297334
"n_days": merged["date"].nunique(),
298335
"dup_start_times": dup_start_times,
299336
"any_siri": bool(n_raw_pings),
337+
"n_stationary_only": int(merged["stationary_only"].sum()),
300338
}
301-
out = merged[["date", "start_time", "matched", "delta_min", "category", "n_pings"]].copy()
339+
out = merged[["date", "start_time", "matched", "delta_min", "category",
340+
"n_pings", "stationary_only"]].copy()
302341
return out, diag
303342

304343
return cached("service_violations", key, compute)

frontend/src/Heatmap.tsx

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,67 @@ interface Hover {
2929
cell: HeatmapCell
3030
}
3131

32+
const LIGHT_COLORS = {
33+
mid: '#f0efec',
34+
neg: ['#cde2fb', '#86b6ef', '#3987e5', '#1c5cab'],
35+
pos: ['#fbd6d6', '#e98c8b', '#dc5655', '#a82c2c'],
36+
}
37+
38+
const DARK_COLORS = {
39+
mid: '#383835',
40+
neg: ['#1c5cab', '#3987e5', '#86b6ef', '#cde2fb'],
41+
pos: ['#a82c2c', '#dc5655', '#e98c8b', '#fbd6d6'],
42+
}
43+
44+
function getThemeColors(): typeof LIGHT_COLORS {
45+
if (typeof document === 'undefined') return LIGHT_COLORS
46+
const theme = document.documentElement.getAttribute('data-theme')
47+
if (theme === 'dark') return DARK_COLORS
48+
if (!theme && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
49+
return DARK_COLORS
50+
}
51+
return LIGHT_COLORS
52+
}
53+
54+
function parseHex(hex: string): [number, number, number] {
55+
const clean = hex.replace('#', '')
56+
const r = parseInt(clean.substring(0, 2), 16)
57+
const g = parseInt(clean.substring(2, 4), 16)
58+
const b = parseInt(clean.substring(4, 6), 16)
59+
return [r, g, b]
60+
}
61+
62+
function interpolate(color1: string, color2: string, f: number): string {
63+
const [r1, g1, b1] = parseHex(color1)
64+
const [r2, g2, b2] = parseHex(color2)
65+
const r = Math.round(r1 + (r2 - r1) * f)
66+
const g = Math.round(g1 + (g2 - g1) * f)
67+
const b = Math.round(b1 + (b2 - b1) * f)
68+
return `rgb(${r}, ${g}, ${b})`
69+
}
70+
71+
function getContinuousColor(value: number, center: number, extent: number): { bg: string; isDark: boolean } {
72+
const diff = value - center
73+
if (Math.abs(diff) < 1e-9) {
74+
const colors = getThemeColors()
75+
return { bg: colors.mid, isDark: false }
76+
}
77+
78+
const colors = getThemeColors()
79+
const stops = diff < 0 ? [colors.mid, ...colors.neg] : [colors.mid, ...colors.pos]
80+
81+
const factor = Math.min(1.0, Math.abs(diff) / extent)
82+
const index = factor * 4
83+
const i = Math.min(3, Math.floor(index))
84+
const f = index - i
85+
86+
const bg = interpolate(stops[i], stops[i + 1], f)
87+
const isDarkTheme = colors === DARK_COLORS
88+
const isDarkCell = isDarkTheme ? index < 2 : index >= 2
89+
90+
return { bg, isDark: isDarkCell }
91+
}
92+
3293
export function Heatmap({ result }: { result: AnalysisResult }) {
3394
const hm = result.heatmap!
3495
const [hover, setHover] = useState<Hover | null>(null)
@@ -55,20 +116,6 @@ export function Heatmap({ result }: { result: AnalysisResult }) {
55116
return <p className="muted">No data to plot.</p>
56117
}
57118

58-
const armIndex = (v: number) =>
59-
Math.min(3, Math.floor((Math.abs(v - (hm.center ?? 0)) / extent) * 4))
60-
61-
const colorFor = (v: number | null | undefined): string | undefined => {
62-
if (v === null || v === undefined) return undefined
63-
const d = v - (hm.center ?? 0)
64-
if (Math.abs(d) < 1e-9) return 'var(--div-mid)'
65-
return (d < 0 ? NEG : POS)[armIndex(v)]
66-
}
67-
68-
// The outer steps of both arms are dark enough to need light text on them.
69-
const inkFor = (v: number | null | undefined): string =>
70-
v === null || v === undefined ? 'var(--muted)' : armIndex(v) >= 2 ? '#ffffff' : 'var(--ink)'
71-
72119
return (
73120
<div style={{ minWidth: 0 }}>
74121
<div className="hm-scroll">
@@ -95,11 +142,12 @@ export function Heatmap({ result }: { result: AnalysisResult }) {
95142
if (v === null) {
96143
return <td key={colLabel + j} className="hm-cell hm-empty" aria-label="no data" />
97144
}
145+
const { bg, isDark } = getContinuousColor(v, hm.center ?? 0, extent)
98146
return (
99147
<td
100148
key={colLabel + j}
101149
className={`hm-cell${cell?.weak ? ' hm-weak' : ''}`}
102-
style={{ background: colorFor(v), color: inkFor(v) }}
150+
style={{ background: bg, color: isDark ? '#ffffff' : 'var(--ink)' }}
103151
onMouseEnter={(e) =>
104152
setHover({ cx: e.clientX, cy: e.clientY, rowLabel, colLabel, cell: cell! })
105153
}

0 commit comments

Comments
 (0)