central/src/central/gui/templates/_events_rows.html
Matt Johnson ed9b6b53be feat(map-rework): fit-to-results, marker clustering, map-filter toggle, shape/opacity encoding (v0.7.2)
PR #3 of the v0.7.x GUI rework arc. Makes the /events Leaflet map readable and
intentional. Production code; central-gui restart only (no adapter change).

- Fit-to-results default: the map now fits the actual event distribution on
  load (previously disabled -> fixed global zoom-4). Empty result set falls
  back to the CONUS setView (no crash). Re-fits after each HTMX swap, but only
  when the map-filter toggle is OFF (when ON the viewport drives the bbox, so
  re-fitting would fight/loop the filter).
- leaflet.markercluster (1.5.3, via CDN): point markers cluster into numbered
  badges (disableClusteringAtZoom=9, showCoverageOnHover=false,
  spiderfyOnMaxZoom=true). markercluster supports point markers only, so
  polygons/lines render in a separate un-clustered featureGroup; fit unions both.
- Map-filter toggle ("Filter table by map view"), default OFF. When off the
  table shows all filter-matching events regardless of map zoom; the backend
  ignores region_* unless map_filter is set (guards bookmarked URLs too). URL
  carries map_filter=1 only when on (hidden input disabled otherwise).
- Per-event_type marker shape (derived event_type = first category segment):
  circle = quake/hydro/space (points), square = fire (areas),
  triangle = wx (NWS alerts/warnings), star = disaster (GDACS/EONET).
  Rendered as divIcon + CSS clip-path; point markers switched from circleMarker
  to L.marker(divIcon) (also required for markercluster compatibility).
- Per-severity opacity: critical(4)=1.0, high(3)=0.85, moderate(2)=0.7,
  low(1)=0.5, unknown(0/NULL)=0.4. Needed adding severity to the _fetch_events
  SELECT + event dict (row.get for mock-tolerance) + a data-severity row attr.

Adds 4 tests (map_filter gating on/off, bbox reaches query only when on,
severity in SELECT); updates test_events_bbox_guard for the new toggle contract.

Full suite: 662 passed, 1 skipped (central and unprivileged zvx). Vanilla JS +
HTMX + Leaflet/markercluster; CSS functional-only (polish deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:20:04 +00:00

94 lines
4.2 KiB
HTML

{% if filter_error %}
<article aria-label="Filter Error" style="background-color: var(--pico-del-color); padding: 1rem; margin-bottom: 1rem;">
<strong>Filter Error:</strong> {{ filter_error }}
</article>
{% endif %}
{% if events %}
<table class="events-table">
<thead>
<tr>
<th style="width: 2rem;"></th>
<th>Time</th>
<th>Location</th>
<th>Subject</th>
<th>Adapter</th>
</tr>
</thead>
<tbody>
{% for event in events %}
{# Per-adapter one-line summary, dispatched by adapter name with a generic
fallback (no hardcoded list). Captured once so it serves both the
Subject cell and the map popup (via data-subject). #}
{% set subject_summary %}{% include ["_event_summaries/" ~ event.adapter ~ ".html", "_event_summaries/_default.html"] %}{% endset %}
{# Location: generic _enriched.geocoder reader, then top-level named
fields, then coordinates. No adapter-specific logic. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% set gc = (d.get('_enriched') or {}).get('geocoder') or {} %}
{% set loc_local = gc.get('city') or d.get('city') or gc.get('county') or d.get('county') %}
{% set loc_state = gc.get('state') or d.get('state') %}
{% set loc_country = gc.get('country') or d.get('country') %}
{% set loc_parts = [loc_local, loc_state, loc_country] | select | list %}
<tr class="event-row" data-row-idx="{{ loop.index0 }}"
data-event-id="{{ event.id }}"
data-adapter="{{ event.adapter }}"
data-category="{{ event.category }}"
data-severity="{{ event.severity if event.severity is not none else '' }}"
data-time="{{ event.time }}"
data-subject="{{ subject_summary | trim }}"
{% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}>
<td><button type="button" class="expand-row" aria-label="Expand">&#9656;</button></td>
<td title="{{ event.time }}">{{ event.time_human }}</td>
<td>{% if loc_parts %}{{ loc_parts | join(', ') }}{% elif gc.get('landclass') %}{{ gc.landclass }}{% else %}—{% endif %}</td>
<td>{{ subject_summary | trim or '—' }}</td>
<td>{{ event.adapter_display }}</td>
</tr>
<tr class="event-detail" hidden>
<td colspan="5">
<dl class="event-detail-list">
<dt>Event ID</dt>
<dd><code>{{ event.id }}</code></dd>
<dt>Received</dt>
<dd>{{ event.received }}</dd>
{% if event.regions %}
<dt>Regions</dt>
<dd>{{ event.regions | join(", ") }}</dd>
{% endif %}
{# Per-adapter curated fields. Dispatch by adapter name with a
generic fallback; the chosen template is whichever exists
first, so new adapters fall through to _default.html with
no hardcoded adapter list here. #}
{% include ["_event_rows/" ~ event.adapter ~ ".html", "_event_rows/_default.html"] %}
<dt>Data</dt>
<dd><pre class="event-data-pre">{{ event.data | tojson(indent=2) }}</pre></dd>
</dl>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pagination-info">
<span>Showing {{ events | length }} event{{ 's' if events | length != 1 else '' }}.</span>
{% if next_cursor %}
{% set next_qs = "cursor=" ~ next_cursor ~ ("&" ~ query_string if query_string else "") %}
<a href="/events?{{ next_qs }}"
role="button"
hx-get="/events/rows?{{ next_qs }}"
hx-target="#events-rows"
hx-push-url="true">
Next &rarr;
</a>
{% else %}
<span><em>End of results</em></span>
{% endif %}
</div>
{% else %}
<article>
<p>No events match the filters.</p>
</article>
{% endif %}
{% if oob_pills %}
{# Out-of-band update of the page-level active-pills bar on each HTMX swap. #}
<div id="active-pills" hx-swap-oob="true">{% include "_active_pills.html" %}</div>
{% endif %}