Author SHA1 Message Date
logan c1c3e89e1d frontend: fix map stacking + honest infra error states (#108)
Build & Deploy / Build & push images (push) Successful in 4m3s
Build & Deploy / Deploy to VM (push) Successful in 2m3s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-06 23:49:19 -04:00
Logan CusanoandClaude Sonnet 5 968134f8ee frontend: fix map stacking + honest infra error states
From a live review of drb.cusano.net.

MapView.tsx / globals.css:
- The Leaflet map painted above the sticky Nav (z-40) and modal overlays, so
  on Live the account dropdown opened *behind* the map. Pin .leaflet-container
  to its own stacking context (position:relative; z-index:0) — keeps Leaflet's
  internal pane order, drops the whole map below app chrome. The map's own
  overlay UI (legend, rail, clock, fit-all) is outside .leaflet-container and
  unaffected. Chosen over raising Nav's z-index, which would float the sticky
  header over modal backdrops on ~7 pages.
- Basemap: the "Dark" tile URL is already CARTO's keyless dark raster (so a
  prod "API KEY REQUIRED" watermark is a stale build or CARTO rate-limiting
  the origin, not this code). Add NEXT_PUBLIC_MAP_TILE_URL as a build-time
  override so a keyed style drops in without a code change; add the OSM
  attribution the keyless CARTO tiles require.

incidents/page.tsx, alerts/page.tsx:
- Both dumped raw Firestore "requires an index / PERMISSION_DENIED" strings
  (with a console.firebase URL) straight into the UI when the composite
  indexes aren't deployed (server-26 #13/#51). Collapse those known infra
  failures to a plain sentence; any other error passes through verbatim so a
  real bug still shows. alerts also now surfaces the events-query error at
  all — it was swallowed, showing a false "No alerts triggered yet." on a
  public-safety screen.

onboarding/page.tsx: stale comment (/dashboard -> "/").

Untypechecked (no node/npm locally); presentational only — one string
helper, one added error branch, a CSS rule, two tile-URL constants, a
comment. next build in deploy.yml gates it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 23:43:22 -04:00
12 changed files with 54 additions and 149 deletions
@@ -670,7 +670,6 @@ async def correlate_call(
reassignment: bool = False, reassignment: bool = False,
embedding: Optional[list] = None, embedding: Optional[list] = None,
severity: Optional[str] = None, severity: Optional[str] = None,
transcript: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
""" """
Link call_id to an existing incident or create a new one. Link call_id to an existing incident or create a new one.
@@ -687,7 +686,7 @@ async def correlate_call(
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name, system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
tags=tags, incident_type=incident_type, location=location, tags=tags, incident_type=incident_type, location=location,
reassignment=reassignment, create_if_new=create_if_new, reassignment=reassignment, create_if_new=create_if_new,
embedding=embedding, severity=severity, transcript=transcript, embedding=embedding, severity=severity,
) )
decision = _run_decision(ctx) decision = _run_decision(ctx)
return await _apply_and_log(decision, ctx) return await _apply_and_log(decision, ctx)
@@ -711,7 +710,6 @@ async def preview_correlation(
reassignment: bool = False, reassignment: bool = False,
embedding: Optional[list] = None, embedding: Optional[list] = None,
severity: Optional[str] = None, severity: Optional[str] = None,
transcript: Optional[str] = None,
) -> dict: ) -> dict:
""" """
Run the rules engine and return the decision WITHOUT committing to Firestore. Run the rules engine and return the decision WITHOUT committing to Firestore.
@@ -732,7 +730,7 @@ async def preview_correlation(
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name, system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
tags=tags, incident_type=incident_type, location=location, tags=tags, incident_type=incident_type, location=location,
reassignment=reassignment, create_if_new=create_if_new, reassignment=reassignment, create_if_new=create_if_new,
embedding=embedding, severity=severity, transcript=transcript, embedding=embedding, severity=severity,
) )
decision = _run_decision(ctx) decision = _run_decision(ctx)
return {"decision": decision, "ctx": ctx} return {"decision": decision, "ctx": ctx}
@@ -767,7 +765,6 @@ async def _build_context(
create_if_new: bool, create_if_new: bool,
embedding: Optional[list] = None, embedding: Optional[list] = None,
severity: Optional[str] = None, severity: Optional[str] = None,
transcript: Optional[str] = None,
) -> dict: ) -> dict:
now = reference_time or datetime.now(timezone.utc) now = reference_time or datetime.now(timezone.utc)
window = timedelta(hours=settings.correlation_window_hours) window = timedelta(hours=settings.correlation_window_hours)
@@ -807,13 +804,6 @@ async def _build_context(
call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or []) call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or [])
call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or []) call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or [])
call_severity = severity or "routine" call_severity = severity or "routine"
# The transcript the LLM correlation tier reasons over. Prefer the SCENE's
# own words (server-26#102) — passed by upload.py's scene loop — and fall
# back to the call doc only when no scene text was supplied (the
# recorrelation sweep, and single-scene calls where the two are identical).
# Without this, every non-primary scene of a multi-scene call was judged by
# the LLM against a transcript containing the OTHER scenes.
scene_transcript = transcript or call_doc.get("transcript_corrected") or call_doc.get("transcript")
# A string that is not a place is not a location anywhere downstream — not # A string that is not a place is not a location anywhere downstream — not
# in the fit tests, not in the thin-call test, not in the LLM prompt, and # in the fit tests, not in the thin-call test, not in the LLM prompt, and
# not on the incident. Its coordinates go with it: coords are geocoded # not on the incident. Its coordinates go with it: coords are geocoded
@@ -836,7 +826,6 @@ async def _build_context(
return { return {
"call_id": call_id, "org_id": org_id, "all_active": all_active, "recent": recent, "call_id": call_id, "org_id": org_id, "all_active": all_active, "recent": recent,
"call_doc": call_doc, "call_embedding": call_embedding, "call_doc": call_doc, "call_embedding": call_embedding,
"scene_transcript": scene_transcript,
"call_units": call_units, "call_vehicles": call_vehicles, "call_units": call_units, "call_vehicles": call_vehicles,
"call_cleared": call_cleared, "call_severity": call_severity, "call_cleared": call_cleared, "call_severity": call_severity,
"coords": coords, "is_thin_call": is_thin_call, "now": now, "coords": coords, "is_thin_call": is_thin_call, "now": now,
+2 -45
View File
@@ -172,7 +172,7 @@ async def extract_scenes(
Each scene dict contains: Each scene dict contains:
tags, incident_type, location, location_coords, resolved, tags, incident_type, location, location_coords, resolved,
severity, vehicles, units, transcript, transcript_corrected, severity, vehicles, units, transcript_corrected,
segment_indices, embedding segment_indices, embedding
Side-effect: updates calls/{call_id} in Firestore with merged tags, Side-effect: updates calls/{call_id} in Firestore with merged tags,
@@ -337,10 +337,6 @@ async def extract_scenes(
) )
embedding = await asyncio.to_thread(_sync_embed, scene_text) embedding = await asyncio.to_thread(_sync_embed, scene_text)
scene_transcript = _scene_transcript_text(
transcript, segments, segment_indices, transcript_corrected
)
processed.append({ processed.append({
"tags": tags, "tags": tags,
"incident_type": incident_type, "incident_type": incident_type,
@@ -352,7 +348,6 @@ async def extract_scenes(
"severity": severity, "severity": severity,
"resolved": resolved, "resolved": resolved,
"reassignment": reassignment, "reassignment": reassignment,
"transcript": scene_transcript,
"transcript_corrected": transcript_corrected, "transcript_corrected": transcript_corrected,
"segment_indices": segment_indices, "segment_indices": segment_indices,
"embedding": embedding, "embedding": embedding,
@@ -576,49 +571,11 @@ def _municipality_from_tg(tg_name: Optional[str]) -> Optional[str]:
def _build_transcript_block(transcript: str, segments: Optional[list[dict]]) -> str: def _build_transcript_block(transcript: str, segments: Optional[list[dict]]) -> str:
"""Format transcript as numbered transmissions if segments are available.""" """Format transcript as numbered transmissions if segments are available."""
if segments and len(segments) > 1: if segments and len(segments) > 1:
# 0-based labels, matching the prompt's "0-based indices into the lines = [f"{i+1}. [{s['start']}s] {s['text']}" for i, s in enumerate(segments)]
# numbered transmissions" — the model echoes these back as
# `segment_indices`, which _build_scene_embed_text and the per-scene
# `transcript` (server-26#102) then slice with directly.
lines = [f"{i}. [{s['start']}s] {s['text']}" for i, s in enumerate(segments)]
return f"Transmissions ({len(segments)}):\n" + "\n".join(lines) return f"Transmissions ({len(segments)}):\n" + "\n".join(lines)
return f"Transcript:\n{transcript}" return f"Transcript:\n{transcript}"
def _scene_transcript_text(
transcript: str,
segments: Optional[list[dict]],
segment_indices: Optional[list[int]],
transcript_corrected: Optional[str],
) -> str:
"""
This scene's own words, unprefixed — the segments it owns, joined.
server-26#102: the correlator's LLM tier reads this per scene instead of
the call doc's whole-call transcript, so on a multi-scene call scene N is
no longer judged against scenes 1..N-1's text.
Never returns "". Anything that would leave the slice empty — no
`segment_indices` (a single-segment call is never numbered by
`_build_transcript_block`), or indices that are out of range / not ints —
falls back to the whole-call transcript, which for a single-scene call is
the same text and for a mis-sliced multi-scene call is at least this
call's own words. `_sync_extract`'s prompt documents 0-based indices and
`_build_transcript_block` numbers to match, so no base normalisation here.
"""
if transcript_corrected:
return transcript_corrected
if segments and segment_indices:
joined = " ".join(
segments[i]["text"]
for i in segment_indices
if isinstance(i, int) and 0 <= i < len(segments)
)
if joined:
return joined
return transcript
def _build_scene_embed_text( def _build_scene_embed_text(
transcript: str, transcript: str,
segments: Optional[list[dict]], segments: Optional[list[dict]],
+1 -7
View File
@@ -61,13 +61,7 @@ def _inc_summary(inc: dict, now: datetime) -> str:
def _call_block(ctx: dict) -> str: def _call_block(ctx: dict) -> str:
lines = [] lines = []
call_doc = ctx["call_doc"] call_doc = ctx["call_doc"]
# The SCENE's own transcript, resolved in _build_context (server-26#102). transcript = call_doc.get("transcript_corrected") or call_doc.get("transcript")
# Falls back to the call doc for a ctx built without a scene (tests, sweep).
transcript = (
ctx.get("scene_transcript")
or call_doc.get("transcript_corrected")
or call_doc.get("transcript")
)
if transcript: if transcript:
lines.append(f"Transcript: {transcript[:700]}") lines.append(f"Transcript: {transcript[:700]}")
if ctx["tags"]: if ctx["tags"]:
@@ -108,7 +108,6 @@ async def _recorrelate_orphan(call: dict) -> bool:
cleared_units = call.get("cleared_units") or [], cleared_units = call.get("cleared_units") or [],
embedding = call.get("embedding"), embedding = call.get("embedding"),
severity = call.get("severity"), severity = call.get("severity"),
transcript = call.get("transcript_corrected") or call.get("transcript"),
reference_time = started_at, # anchor window to when the call happened reference_time = started_at, # anchor window to when the call happened
create_if_new = False, # never create — link-only create_if_new = False, # never create — link-only
) )
+1 -4
View File
@@ -116,7 +116,6 @@ async def _correlate_with_consensus(
reassignment: bool = False, reassignment: bool = False,
embedding: Optional[list] = None, embedding: Optional[list] = None,
severity: Optional[str] = None, severity: Optional[str] = None,
transcript: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
""" """
Consensus correlator: runs the rules engine and the cheap LLM in sequence. Consensus correlator: runs the rules engine and the cheap LLM in sequence.
@@ -134,7 +133,7 @@ async def _correlate_with_consensus(
tags=tags, incident_type=incident_type, location=location, tags=tags, incident_type=incident_type, location=location,
location_coords=location_coords, units=units, vehicles=vehicles, location_coords=location_coords, units=units, vehicles=vehicles,
cleared_units=cleared_units, reassignment=reassignment, cleared_units=cleared_units, reassignment=reassignment,
embedding=embedding, severity=severity, transcript=transcript, embedding=embedding, severity=severity,
) )
ctx = preview["ctx"] ctx = preview["ctx"]
rules_decision = preview["decision"] rules_decision = preview["decision"]
@@ -227,7 +226,6 @@ async def _run_extraction_pipeline(
reassignment=is_reassignment, reassignment=is_reassignment,
embedding=scene.get("embedding"), embedding=scene.get("embedding"),
severity=scene.get("severity"), severity=scene.get("severity"),
transcript=scene.get("transcript"),
) )
if incident_id and incident_id not in incident_ids: if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id) incident_ids.append(incident_id)
@@ -345,7 +343,6 @@ async def _run_intelligence_pipeline(
reassignment=is_reassignment, reassignment=is_reassignment,
embedding=scene.get("embedding"), embedding=scene.get("embedding"),
severity=scene.get("severity"), severity=scene.get("severity"),
transcript=scene.get("transcript"),
) )
if incident_id and incident_id not in incident_ids: if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id) incident_ids.append(incident_id)
@@ -304,39 +304,6 @@ async def test_a_scene_is_judged_on_its_own_embedding_and_severity():
assert ctx["call_severity"] == "major" assert ctx["call_severity"] == "major"
@pytest.mark.asyncio
async def test_the_llm_tier_reads_the_scene_transcript_not_the_whole_call():
"""
server-26#102. intelligence.py writes only the primary scene's corrected
text to calls/{id}. _call_block (the LLM correlation prompt) must reason
over the SCENE being correlated, not a whole-call transcript that also
contains the other scenes. _build_context threads the scene's text in;
with no scene text it falls back to the call doc (sweep / single-scene).
"""
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
mock_fstore.doc_get = AsyncMock(return_value={
"transcript": "scene one about a fire. scene two about a traffic stop.",
})
mock_fstore.collection_list = AsyncMock(return_value=[])
scene = await _build_context(
call_id="call-1", units=None, vehicles=None, cleared_units=None,
location_coords=None, reference_time=NOW,
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
tags=[], incident_type="police", location=None,
reassignment=False, create_if_new=True,
transcript="scene two about a traffic stop.",
)
fallback = await _build_context(
call_id="call-1", units=None, vehicles=None, cleared_units=None,
location_coords=None, reference_time=NOW,
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
tags=[], incident_type="police", location=None,
reassignment=False, create_if_new=True,
)
assert scene["scene_transcript"] == "scene two about a traffic stop."
assert fallback["scene_transcript"] == "scene one about a fire. scene two about a traffic stop."
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_a_bare_number_never_becomes_an_incident_location_or_title(): async def test_a_bare_number_never_becomes_an_incident_location_or_title():
inc = await _create(tags=["flames"], location="49", coords=None, inc = await _create(tags=["flames"], location="49", coords=None,
@@ -1,40 +0,0 @@
"""
server-26#102 — a scene is correlated on its OWN transcript, not the whole call.
_scene_transcript_text slices the segments a scene owns. It must never return
"" (an empty slice would let incident_correlator._build_context fall back to
the call doc's whole-call transcript, re-opening the leak in exactly the case
— bad indices — where it matters).
"""
from app.internal.intelligence import _scene_transcript_text
SEGS = [
{"text": "structure fire, 12 Main"},
{"text": "engine 4 responding"},
{"text": "traffic stop, plate ABC"},
{"text": "one occupant"},
]
WHOLE = "structure fire, 12 Main engine 4 responding traffic stop, plate ABC one occupant"
def test_scene_owns_a_subset_of_segments():
assert _scene_transcript_text(WHOLE, SEGS, [0, 1], None) == "structure fire, 12 Main engine 4 responding"
assert _scene_transcript_text(WHOLE, SEGS, [2, 3], None) == "traffic stop, plate ABC one occupant"
def test_corrected_text_wins_when_present():
assert _scene_transcript_text(WHOLE, SEGS, [0], "cleaned up text") == "cleaned up text"
def test_no_segment_indices_falls_back_to_whole_call():
# single-segment calls are never numbered by _build_transcript_block → null indices
assert _scene_transcript_text(WHOLE, SEGS, None, None) == WHOLE
assert _scene_transcript_text(WHOLE, None, [0, 1], None) == WHOLE
def test_out_of_range_or_nonint_indices_fall_back_never_empty():
assert _scene_transcript_text(WHOLE, SEGS, [9, 10], None) == WHOLE # all out of range
assert _scene_transcript_text(WHOLE, SEGS, ["1", "2"], None) == WHOLE # 1-based strings, rejected
assert _scene_transcript_text(WHOLE, SEGS, [-1], None) == WHOLE # negative
# partial validity: keep what's in range
assert _scene_transcript_text(WHOLE, SEGS, [3, 99], None) == "one occupant"
+7 -1
View File
@@ -186,7 +186,7 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
export default function AlertsPage() { export default function AlertsPage() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { alerts, loading } = useAlerts(); const { alerts, loading, error } = useAlerts();
const [tab, setTab] = useState<"events" | "rules">("events"); const [tab, setTab] = useState<"events" | "rules">("events");
async function handleAcknowledge(id: string) { async function handleAcknowledge(id: string) {
@@ -226,6 +226,12 @@ export default function AlertsPage() {
{tab === "events" && ( {tab === "events" && (
loading ? ( loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-gray-500 text-sm font-mono">Loading…</p>
) : error ? (
<p className="text-red-400 text-sm font-mono">
{/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error)
? "Couldn't load alerts — a database index or security rule isn't deployed on the server yet (server-26 #13 / #51)."
: `Couldn't load alerts: ${error}`}
</p>
) : alerts.length === 0 ? ( ) : alerts.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p> <p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p>
) : ( ) : (
+14
View File
@@ -163,6 +163,20 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
animation: pulse-ring 1.8s ease-out infinite; animation: pulse-ring 1.8s ease-out infinite;
} }
/* ── Leaflet stacking fix ─────────────────────────────────────────────────────
* Leaflet's internal panes (z-index 200–700) and its zoom / layers controls
* (z-index 1000) otherwise paint above the sticky app Nav (z-40) and any modal
* overlay — on Live this put the account dropdown *behind* the map. Pinning the
* map container to its own low stacking context keeps Leaflet's internal layer
* order intact while dropping the whole map (tiles + controls) below the app
* chrome. The map's own overlay UI (legend, incident rail, clock, fit-all) sits
* outside .leaflet-container, so it is unaffected and still renders on top.
*/
.leaflet-container {
position: relative;
z-index: 0;
}
/* ── Form inputs ─────────────────────────────────────────────────────────── */ /* ── Form inputs ─────────────────────────────────────────────────────────── */
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]), html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
html:not(.dark) select, html:not(.dark) select,
+12 -1
View File
@@ -28,6 +28,17 @@ const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, mo
type SortMode = "recent" | "severity"; type SortMode = "recent" | "severity";
// The Firestore client surfaces a missing composite index or an undeployed
// ruleset as a raw multi-line string with a console URL in it — not something
// to put in front of an operator. Collapse the known infra failures to a plain
// line; pass anything else straight through so a real bug still shows.
function friendlyIncidentsError(raw: string): string {
if (/requires an index|PERMISSION_DENIED|Missing or insufficient permissions|failed-precondition/i.test(raw)) {
return "Couldn't load incidents — the incidents database index isn't deployed on the server yet. This is a one-time backend deploy step (server-26 #13 / #51), not a problem with your data.";
}
return `Couldn't load incidents: ${raw}`;
}
function fmtTime(iso: string) { function fmtTime(iso: string) {
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; } try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; }
} }
@@ -286,7 +297,7 @@ export default function IncidentsPage() {
recorded yet" over the top of it told the operator the radio was recorded yet" over the top of it told the operator the radio was
quiet when the page had simply failed to load — server-26#13. */} quiet when the page had simply failed to load — server-26#13. */}
{filtered.length === 0 && error && ( {filtered.length === 0 && error && (
<ErrorBanner message={`Couldn't load incidents: ${error}`} /> <ErrorBanner message={friendlyIncidentsError(error)} />
)} )}
{filtered.length === 0 && !error && ( {filtered.length === 0 && !error && (
+1 -1
View File
@@ -43,7 +43,7 @@ export default function OnboardingPage() {
// Firebase custom claims only show up in a *freshly fetched* ID token — // Firebase custom claims only show up in a *freshly fetched* ID token —
// getIdTokenResult(true) inside refreshClaims forces that fetch, then // getIdTokenResult(true) inside refreshClaims forces that fetch, then
// AuthProvider's own state (orgId) updates and the effect above // AuthProvider's own state (orgId) updates and the effect above
// redirects to /dashboard. // redirects to "/" (Live).
await refreshClaims(); await refreshClaims();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Could not set up your organization. Try again."); setError(err instanceof Error ? err.message : "Could not set up your organization. Try again.");
+14 -3
View File
@@ -24,6 +24,17 @@ L.Icon.Default.mergeOptions({
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
}); });
// ── Basemap tiles ─────────────────────────────────────────────────────────────
// Default is CARTO's keyless dark raster basemap — no token, fits the dark UI.
// Overridable via NEXT_PUBLIC_MAP_TILE_URL so a keyed style (a CARTO account
// style, MapTiler, Mapbox, …) can be dropped in for prod without a code change.
// Whatever is supplied must use Leaflet's {s}/{z}/{x}/{y}{r} placeholder scheme.
const MAP_TILE_URL =
process.env.NEXT_PUBLIC_MAP_TILE_URL ||
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
const MAP_TILE_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/">CARTO</a>';
// ── Colour ──────────────────────────────────────────────────────────────────── // ── Colour ────────────────────────────────────────────────────────────────────
// Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident // Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident
// type is carried by the glyph knocked out of the pin, never by colour, and // type is carried by the glyph knocked out of the pin, never by colour, and
@@ -540,14 +551,14 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
{/* Base layers */} {/* Base layers */}
<LayersControl.BaseLayer checked name="Dark"> <LayersControl.BaseLayer checked name="Dark">
<TileLayer <TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" url={MAP_TILE_URL}
attribution='&copy; <a href="https://carto.com/">CARTO</a>' attribution={MAP_TILE_ATTRIBUTION}
/> />
</LayersControl.BaseLayer> </LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Light"> <LayersControl.BaseLayer name="Light">
<TileLayer <TileLayer
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png" url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://carto.com/">CARTO</a>' attribution={MAP_TILE_ATTRIBUTION}
/> />
</LayersControl.BaseLayer> </LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Streets"> <LayersControl.BaseLayer name="Streets">