mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 20:04:43 +02:00
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>
94 lines
4.2 KiB
HTML
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">▸</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 →
|
|
</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 %}
|