Skip to content

Commit 1962322

Browse files
NoamGaashclaude
andcommitted
Give maps a legend and a dark basemap
Both map cards colored their routes by elapsed time with nothing to say so — a reader could see two marks differ but not by how much or which way, which makes the gradient decoration rather than encoding. The source notebooks had this (branca's colormap.caption); the port had dropped it. Adds GeoLegend to the contract and a `legend=` param to geo(): an ordered low→high color ramp with min/max labels, plus free-form swatch items for things a ramp can't express. schedule-adherence-map uses those items for its dashed "Planned (GTFS)" vs solid "Measured (GPS average)" distinction, which previously only existed in prose under the chart. Also swaps the basemap to CARTO's dark tiles when the page is in dark mode, tracked via a MutationObserver on the theme attribute plus the prefers-color-scheme media query. A blinding white map inside a dark card was the one part of the dashboard that ignored the theme. CSS-inverting the tiles was the alternative and is worse — it makes the map's own labels unreadable. Verified in a real browser, light and dark: legend text correct on both map cards, and the tile host actually switches (tile.openstreetmap.org → basemaps.cartocdn.com). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 4e5ca5c commit 1962322

6 files changed

Lines changed: 146 additions & 11 deletions

File tree

analyses/gps_trace_map.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,17 @@
99
rather than raw HTTP calls.
1010
1111
The companion notebooks that overlay a *planned* route on the same map
12-
("load gtfs timetable...ipynb", "compare gtfs planned vs siri actual.ipynb") need
13-
per-stop GTFS shape coordinates, which openbus_hack.stride doesn't wrap yet (only
14-
noamf2001's bus_times package fetches that, via /route_timetable/list) — left as
15-
a follow-up rather than rushed.
12+
("load gtfs timetable...ipynb", "compare gtfs planned vs siri actual.ipynb") are
13+
covered by ``analyses/schedule_adherence_average.py``'s map card, which draws the
14+
GTFS plan dashed against a GPS-derived measured route.
1615
"""
1716

1817
from __future__ import annotations
1918

2019
import datetime
2120
from zoneinfo import ZoneInfo
2221

23-
from openbus_hack import AnalysisRequest, analysis, geo, metrics, stride
22+
from openbus_hack import AnalysisRequest, GeoLegend, analysis, geo, metrics, stride
2423

2524
ISRAEL_TZ = ZoneInfo("Asia/Jerusalem")
2625
_CREDIT = "Analysis by yuvalko1 (github.com/yuvalko1/talpiot-hackathon-public-transportation)."
@@ -114,6 +113,12 @@ def run(req: AnalysisRequest):
114113
return geo(
115114
features,
116115
title="One bus, actual GPS trace",
116+
legend=GeoLegend(
117+
label="minutes into the ride",
118+
colors=_GRADIENT,
119+
min_label=t_start.tz_convert(ISRAEL_TZ).strftime("%H:%M"),
120+
max_label=t_end.tz_convert(ISRAEL_TZ).strftime("%H:%M"),
121+
),
117122
subtitle=(f"Line {line} ({operator}) · ride {int(ride_id)} · "
118123
f"{t_start.tz_convert(ISRAEL_TZ).strftime('%Y-%m-%d %H:%M')} "
119124
f"Israel time · {len(trace)} pings"),

analyses/schedule_adherence_average.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
OptionSpec,
4747
Point,
4848
Series,
49+
GeoLegend,
4950
analysis,
5051
bar_chart,
5152
geo,
@@ -385,6 +386,16 @@ def wmean(vals, w):
385386
return geo(
386387
features,
387388
title="Planned route vs. where buses actually were",
389+
legend=GeoLegend(
390+
label="minutes since departure",
391+
colors=_GRADIENT,
392+
min_label="0",
393+
max_label=f"{vmax:.0f}",
394+
items=[
395+
{"label": "Planned (GTFS)", "color": "#666", "dashed": True},
396+
{"label": "Measured (GPS average)", "color": "#666", "dashed": False},
397+
],
398+
),
388399
subtitle=(f"{data['label']} · {data['time_of_day']} departure · "
389400
f"{len(per_day)} days · {int(n_pings.sum())} matched pings"),
390401
notes=[

frontend/src/GeoMap.tsx

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77
// Leaflet's default marker image paths break under bundlers unless you copy
88
// its asset files by hand, and a colored dot fits this data better anyway.
99

10-
import { useEffect } from 'react'
10+
import { useEffect, useState } from 'react'
1111
import { GeoJSON, MapContainer, TileLayer, useMap } from 'react-leaflet'
1212
import type { GeoJsonObject } from 'geojson'
1313
import type { LatLngBoundsExpression, PathOptions } from 'leaflet'
1414
import * as L from 'leaflet'
1515
import 'leaflet/dist/leaflet.css'
1616

17-
import type { AnalysisResult } from './api'
17+
import type { AnalysisResult, GeoLegendData } from './api'
1818

1919
const DEFAULT_COLOR = '#2a78d6'
2020

@@ -48,20 +48,57 @@ function FitBounds({ data }: { data: GeoJsonObject }) {
4848
return null
4949
}
5050

51+
/** Tracks the page's light/dark state so the basemap doesn't stay blinding
52+
white inside a dark card. The toggle stamps data-theme on <html>. */
53+
function useIsDark(): boolean {
54+
const read = () => {
55+
const attr = document.documentElement.dataset.theme
56+
if (attr === 'dark') return true
57+
if (attr === 'light') return false
58+
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false
59+
}
60+
const [dark, setDark] = useState(read)
61+
useEffect(() => {
62+
const obs = new MutationObserver(() => setDark(read()))
63+
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
64+
const mq = window.matchMedia?.('(prefers-color-scheme: dark)')
65+
const onMq = () => setDark(read())
66+
mq?.addEventListener('change', onMq)
67+
return () => {
68+
obs.disconnect()
69+
mq?.removeEventListener('change', onMq)
70+
}
71+
}, [])
72+
return dark
73+
}
74+
5175
export function GeoMap({ result }: { result: AnalysisResult }) {
5276
const data = result.geojson as GeoJsonObject | null
77+
const isDark = useIsDark()
5378
if (!data) return <p className="muted">No map data.</p>
5479

5580
return (
81+
<div style={{ minWidth: 0 }}>
5682
<MapContainer
5783
center={[32.08, 34.78]}
5884
zoom={13}
5985
style={{ height: 420, width: '100%', borderRadius: 8, minWidth: 0 }}
6086
scrollWheelZoom={false}
6187
>
88+
{/* CARTO's basemaps ship a matching dark variant; plain OSM has none, and
89+
CSS-inverting tiles turns the map's own labels unreadable. */}
6290
<TileLayer
63-
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
64-
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
91+
key={isDark ? 'dark' : 'light'}
92+
attribution={
93+
isDark
94+
? '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
95+
: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
96+
}
97+
url={
98+
isDark
99+
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
100+
: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
101+
}
65102
/>
66103
<GeoJSON
67104
data={data}
@@ -84,5 +121,53 @@ export function GeoMap({ result }: { result: AnalysisResult }) {
84121
/>
85122
<FitBounds data={data} />
86123
</MapContainer>
124+
{result.geo_legend && <GeoLegend legend={result.geo_legend} />}
125+
</div>
126+
)
127+
}
128+
129+
function GeoLegend({ legend }: { legend: GeoLegendData }) {
130+
return (
131+
<div className="legend" style={{ marginTop: 10, alignItems: 'center' }}>
132+
{legend.colors.length > 0 && (
133+
<>
134+
<span className="muted" style={{ fontSize: 11 }}>
135+
{legend.min_label ?? ''}
136+
</span>
137+
<span
138+
aria-label={legend.label}
139+
style={{
140+
width: 120,
141+
height: 12,
142+
borderRadius: 2,
143+
background: `linear-gradient(to right, ${legend.colors.join(', ')})`,
144+
}}
145+
/>
146+
<span className="muted" style={{ fontSize: 11 }}>
147+
{legend.max_label ?? ''}
148+
</span>
149+
<span className="muted" style={{ fontSize: 11 }}>
150+
{legend.label}
151+
</span>
152+
</>
153+
)}
154+
{legend.items.map((it, i) => (
155+
<span key={i} className="legend-item">
156+
{/* Dashed entries get a line, not a block — the distinction the
157+
swatch is there to make is the stroke style itself. */}
158+
<i
159+
className="swatch"
160+
style={{
161+
width: 18,
162+
height: it.dashed ? 0 : 10,
163+
borderRadius: 2,
164+
background: it.dashed ? 'transparent' : (it.color ?? 'var(--ink-2)'),
165+
borderTop: it.dashed ? `2px dashed ${it.color ?? 'var(--ink-2)'}` : undefined,
166+
}}
167+
/>
168+
{it.label}
169+
</span>
170+
))}
171+
</div>
87172
)
88173
}

frontend/src/api.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ export interface Table {
5555
rows: unknown[][]
5656
}
5757

58+
export interface GeoLegendData {
59+
label: string
60+
/** Ordered low→high; painted as a continuous ramp. */
61+
colors: string[]
62+
min_label: string | null
63+
max_label: string | null
64+
/** Extra swatches the ramp doesn't cover, e.g. dashed "planned" vs solid. */
65+
items: { label?: string; color?: string; dashed?: boolean }[]
66+
}
67+
5868
export interface HeatmapCell {
5969
row: number
6070
col: number
@@ -98,6 +108,7 @@ export interface AnalysisResult {
98108
image_alt: string | null
99109
heatmap: HeatmapData | null
100110
geojson: unknown | null
111+
geo_legend: GeoLegendData | null
101112
notes: string[]
102113
error_message: string | null
103114
error_traceback: string | null

openbus_hack/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .contract import (
1212
AnalysisRequest,
1313
AnalysisResult,
14+
GeoLegend,
1415
Heatmap,
1516
HeatmapCell,
1617
Metric,
@@ -48,6 +49,7 @@
4849
"Series",
4950
"Point",
5051
"Table",
52+
"GeoLegend",
5153
"Heatmap",
5254
"HeatmapCell",
5355
# data access

openbus_hack/contract.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def run(req: AnalysisRequest) -> AnalysisResult:
2626
__all__ = [
2727
"AnalysisRequest",
2828
"AnalysisResult",
29+
"GeoLegend",
2930
"Heatmap",
3031
"HeatmapCell",
3132
"Metric",
@@ -158,6 +159,20 @@ class HeatmapCell(BaseModel):
158159
weak: bool = False
159160

160161

162+
class GeoLegend(BaseModel):
163+
"""What a map's colors mean. Without this a gradient is decoration — the
164+
reader can see two marks differ but not by how much, or in which direction."""
165+
166+
label: str
167+
# Ordered low→high; the frontend paints them as a continuous ramp.
168+
colors: list[str] = Field(default_factory=list)
169+
min_label: str | None = None
170+
max_label: str | None = None
171+
# Extra swatches for things the ramp doesn't cover — e.g. a dashed
172+
# "planned" line vs. a solid measured one.
173+
items: list[dict[str, Any]] = Field(default_factory=list)
174+
175+
161176
class Heatmap(BaseModel):
162177
"""A labelled grid of values — segment × hour, stop × day, and so on.
163178
@@ -223,8 +238,9 @@ class AnalysisResult(BaseModel):
223238
# kind="heatmap" — raw grid, rendered client-side (interactive, small payload)
224239
heatmap: Heatmap | None = None
225240

226-
# kind="geo" — a GeoJSON FeatureCollection
241+
# kind="geo" — a GeoJSON FeatureCollection, plus what its colors mean
227242
geojson: dict[str, Any] | None = None
243+
geo_legend: GeoLegend | None = None
228244

229245
# Free-text caveats shown under the chart. Use these! "only 3 days of SIRI
230246
# data available" belongs here, not in a print().
@@ -460,14 +476,18 @@ def image(fig: Any = None, *, title: str | None = None, subtitle: str | None = N
460476

461477

462478
def geo(features: list[dict[str, Any]], *, title: str | None = None,
463-
subtitle: str | None = None, notes: list[str] | None = None) -> AnalysisResult:
479+
subtitle: str | None = None, legend: GeoLegend | None = None,
480+
notes: list[str] | None = None) -> AnalysisResult:
464481
"""A map, from a list of GeoJSON Feature dicts (LineString/Point geometries).
465482
466483
Per-feature styling is read from ``properties``: ``color`` (any CSS color),
467484
``weight`` (line width / marker radius), ``dashed`` (bool), and ``popup``
468485
(text shown on click). The frontend auto-fits the map to the data — no
469486
center/zoom to pass in.
470487
488+
Pass ``legend`` whenever color carries meaning: a gradient nobody can read
489+
is decoration, not encoding.
490+
471491
>>> geo([
472492
... {"type": "Feature", "geometry": {"type": "LineString", "coordinates": [[lon1, lat1], [lon2, lat2]]},
473493
... "properties": {"color": "#31688e", "weight": 4}},
@@ -476,6 +496,7 @@ def geo(features: list[dict[str, Any]], *, title: str | None = None,
476496
return AnalysisResult(
477497
kind="geo", title=title, subtitle=subtitle, notes=notes or [],
478498
geojson={"type": "FeatureCollection", "features": features},
499+
geo_legend=legend,
479500
)
480501

481502

0 commit comments

Comments
 (0)