"""Break map โ the lens of the single-page app.
One Scattermap trace (per-point colour = skill level; skill legend is
rendered as HTML chips by the lens panel). Clean carto-positron tiles.
Selecting a spot re-centers the map on it ("fly-to") with a gold star โ
point order always matches the ``records`` list so callers can resolve
selections by index.
"""
from __future__ import annotations
from plotly import graph_objects as go
try: # keep colours identical to the v1 front end while it exists
from app.config import AUSTRALIA_CENTER, AUSTRALIA_ZOOM, SKILL_COLORS, SKILL_ORDER
except ImportError: # self-contained fallback (app/ is deleted at deploy)
AUSTRALIA_CENTER = {"lat": -25.5, "lon": 134.0}
AUSTRALIA_ZOOM = 3.5
SKILL_ORDER = ["beginner", "intermediate", "advanced", "expert", "pro-only"]
SKILL_COLORS = {
"beginner": "#2ca02c",
"intermediate": "#1f77b4",
"advanced": "#ff7f0e",
"expert": "#d62728",
"pro-only": "#9467bd",
}
def skill_legend_html() -> str:
"""Lo-fi chips explaining the per-point skill colours."""
spans = [
f'{s}'
for s in SKILL_ORDER
]
return '
' + "".join(spans) + "
"
def _lat(break_: dict) -> float | None:
try:
return float(break_["location"]["coordinates"]["lat"])
except (KeyError, TypeError, ValueError):
return None
def _lng(break_: dict) -> float | None:
try:
return float(break_["location"]["coordinates"]["lng"])
except (KeyError, TypeError, ValueError):
return None
def _zoom_for_span(span: float) -> float:
"""Pick a map zoom level that fits a lat/lng degree span."""
if span > 25:
return 3.5
if span > 12:
return 4.2
if span > 6:
return 5.0
if span > 3:
return 6.0
if span > 1.5:
return 7.0
if span > 0.7:
return 8.0
if span > 0.3:
return 9.0
return 10.0
def build_map(records: list[dict], default_view: bool = False,
selected: dict | None = None) -> go.Figure:
"""Scattermap of breaks; point order matches ``records`` order.
Recenters (and zooms) to fit the given records, so filtering
reframes the map on the matching spots. ``default_view`` frames the
whole of Australia instead. When ``selected`` is set the map flies
to that spot (zoom 11.5) and drops a gold takeoff star on it.
"""
lats = [_lat(r) for r in records]
lngs = [_lng(r) for r in records]
names = [r.get("name", "?") for r in records]
skills = [str(r.get("skillLevel", "?")) for r in records]
regions = [r.get("region", "?") for r in records]
sel_lat, sel_lng = (_lat(selected), _lng(selected)) if selected else (None, None)
valid = [(la, lo) for la, lo in zip(lats, lngs) if la is not None and lo is not None]
if sel_lat is not None and sel_lng is not None:
center_lat, center_lng, zoom = sel_lat, sel_lng, 11.5
elif valid and not default_view:
center_lat = sum(la for la, _ in valid) / len(valid)
center_lng = sum(lo for _, lo in valid) / len(valid)
span = max(
max(la for la, _ in valid) - min(la for la, _ in valid),
max(lo for _, lo in valid) - min(lo for _, lo in valid),
)
zoom = _zoom_for_span(span)
else:
center_lat, center_lng, zoom = (
AUSTRALIA_CENTER["lat"],
AUSTRALIA_CENTER["lon"],
AUSTRALIA_ZOOM,
)
fig = go.Figure()
if records:
fig.add_trace(
go.Scattermap(
lat=lats,
lon=lngs,
mode="markers",
marker={
"size": 10,
"color": [SKILL_COLORS.get(s.lower(), "#1f77b4") for s in skills],
},
text=names,
customdata=list(zip(names, regions, skills)),
hovertemplate=(
"%{customdata[0]}
"
"%{customdata[1]} ยท %{customdata[2]}"
""
),
name="breaks",
showlegend=False,
)
)
if selected is not None and sel_lat is not None and sel_lng is not None:
fig.add_trace(
go.Scattermap(
lat=[sel_lat], lon=[sel_lng], mode="markers",
marker={"size": 16, "color": "#FFD700", "symbol": "star"},
hovertemplate=f"{selected.get('name', '?')} โ selected",
name="selected",
showlegend=False,
)
)
fig.update_layout(
map_style="carto-positron",
hovermode="closest",
height=560,
margin={"l": 0, "r": 0, "t": 0, "b": 0},
map=dict(center={"lat": center_lat, "lon": center_lng}, zoom=zoom),
showlegend=False,
)
if not records:
fig.add_annotation(
text="No spots match these filters",
showarrow=False,
font={"size": 16},
)
return fig