diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b9d9ec..b20bad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,97 @@ All notable changes to Terra-View will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.13.2] - 2026-05-30 + +PWA-cache fix for mobile operators. v0.13.0 added the inline PDF preview, `.TXT` download, and Review form to `event-modal.js`, but mobile devices using Terra-View as a PWA never saw any of it — the service worker had `CACHE_VERSION = 'v1'` (unchanged since v0.12.x), so the activate handler never evicted the stale cache and mobile users kept getting served the pre-v0.13.0 modal forever. + +### Fixed + +- **Service worker cache version bumped + tied to the app version**. `CACHE_VERSION` in `backend/static/sw.js` is now `'v0.13.2'`, which causes the SW's activate handler to delete the old `sfm-static-v1` / `sfm-dynamic-v1` / `sfm-data-v1` caches on first visit after the upgrade. Going forward the convention is: any release that touches a static asset must bump `CACHE_VERSION` to match `backend/main.py`'s `VERSION`. Comment in `sw.js` documents this. +- **`event-modal.js` precached** alongside `mobile.js` / `offline-db.js` etc. Lifecycle is now tied to the SW version bump explicitly — old modal JS gets evicted on activate, new modal JS is fetched and cached during install. + +### What mobile users will see after deploy + +On next page navigation the SW update check fires, the new SW installs (skipWaiting), activate evicts the v1 caches, `controllerchange` fires, the page reloads with the v0.13.x modal. On the worst-case device (no recent visit), it might take up to an hour for `registration.update()` to pick up the new SW — operators can force-refresh by closing and re-opening the PWA, or by clearing site data once. + +--- + +## [0.13.1] - 2026-05-29 + +Same-day patch on top of v0.13.0. Fixes the mic-chart unit default — v0.13.0 shipped with `dBL` as the default, but the PDF report renders the mic axis in psi, so the website chart and the printed report didn't match. Operator caught it within an hour of rollout. Also relabels the modal's "Captured at" field to "Time received" so it isn't mistaken for the device's trigger time. + +### Fixed + +- **Event-detail modal: mic chart now defaults to psi**, matching the PDF report's mic axis. The waveform/histogram chart's mic channel now renders in raw psi by default; operators who specifically prefer dB(L) on charts can flip it via Settings → General → "Event Report — Mic Channel Units". Peaks everywhere else (table tiles, modal Peaks section, KPI summaries) stay in dB(L) as before — this is strictly a chart-axis change. +- **Modal label: "Captured at" → "Time received"** (+ tooltip clarifying it's the SFM ingestion time, not the unit-local trigger time at the top of the modal). Same change in seismo-relay's standalone webapp for consistency. + +### Migration Notes + +The bundled `backend/migrate_add_mic_unit_pref.py` is now idempotent across both the v0.13.0 "add column" path and the v0.13.0 → v0.13.1 default flip. Existing rows sitting at the original `'dBL'` default (i.e. nobody touched the setting yet — true for almost everyone) get bumped to `'psi'` on migration. + +```bash +cd /home/serversdown/terra-view +docker compose build terra-view && docker compose up -d terra-view +docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py +``` + +If you _did_ deliberately set the chart to dB(L) via Settings between v0.13.0 rollout and this patch, the migration will reset it — one click in Settings to restore. Trade-off considered acceptable given the very small user base and the freshness of the v0.13.0 release. + +--- + +## [0.13.0] - 2026-05-29 + +The "SFM integration Phase 1" release. Closes the gap between Terra-View and the standalone SFM webapp on port 8200 — operators no longer need to bounce between the two for routine event review. The shared event-detail modal (used on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`) gains a Chart.js waveform/histogram chart, inline PDF preview, original `.TXT` download, and a review form with false-trigger flag + reviewer + notes. `/admin/events` finally gets the modal too. A new Settings field controls the mic chart's display unit. + +### Added — Event-detail modal: Chart.js waveform/histogram panels + +- **4-channel stacked plots** (MicL → Long → Vert → Tran, matching BW Event Report layout) inside the existing `partials/event_detail_modal.html` shell. Ported from seismo-relay's standalone `sfm/sfm_webapp.html:2555-2880`; theme-aware grid + tick colors (light/dark mode via Tailwind's `dark` class on ``). +- **Waveform mode**: line plot, symmetric Y-axis around zero for geo channels, dashed trigger overlay at `t=0` with triangle markers above and below, zero-baseline dashed line + "0.0" label on the right margin. Downsamples at >3000 samples to keep render time bounded. +- **Histogram mode**: bar plot, zero-anchored Y with minimum range (`0.05 in/s` geo, `0.001 psi` mic) so quiet events don't fill the panel. X-axis uses `time_axis.interval_times` (HH:MM:SS labels emitted by seismo-relay v0.20.0+) when available, otherwise falls back to interval index. Trigger/zero-baseline overlays suppressed (no trigger concept on histograms). +- **Mic conversion** — converts raw psi samples to dB(L) for the chart when the operator's `mic_unit_pref` is "dBL" (the default). Rectifies the AC waveform (`abs()`) and floors at `MIC_DBL_FLOOR = 60` so the chart reads as an SPL-vs-time curve instead of a sparse pattern of isolated spikes above the floor. Peak label uses the unrectified value. +- **Chart cleanup** — `_destroyCharts()` runs on modal close so repeated open/close doesn't leak Chart.js instances. +- Chart.js 4.4.1 pinned via cdn.jsdelivr at the bottom of the modal partial; matches the standalone webapp's reference version. + +### Added — Event-detail modal: PDF preview + downloads + review form + +- **"Show Event Report PDF"** toggle opens an inline iframe inside the modal (no second-layer modal, no new browser tab). Iframe lazy-loads on first reveal — closing the modal without opening the PDF never spends bandwidth on the fetch. Sized 80vh / 600px min so a typical letter-portrait single-page report fits with browser-native zoom + download + print controls available. Companion "Download PDF" button for direct save. +- **"Original .TXT report"** download link, rendered only when `sidecar.source.txt_filename` is present (events ingested with seismo-relay's `.TXT` preservation pattern, post-2026-05-27). Hidden for legacy events to avoid 404 dead links. +- **Inline Review form** — `false_trigger` checkbox + reviewer text input + notes textarea + Save button. Persists via `PATCH /api/sfm/db/events/{id}/sidecar` with `{review: {...}}`. Status line shows last-reviewed timestamp + save success/failure feedback. On save fires a `sfm-event-review-saved` `CustomEvent` on `window` so the host page's table can refresh without a full reload — wired up on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`. + +### Added — `/admin/events` row click opens the modal + +- The SFM Event DB Manager at `/admin/events` previously had no detail view — admins had to copy an event ID and load the standalone webapp on port 8200. Now table rows are clickable: `onclick` on `` calls `showEventDetail(id)`, with `event.stopPropagation()` on the checkbox cell so bulk-selection clicks don't also open the modal. +- `partials/event_detail_modal.html` + `event-modal.js` are now included on this page, matching the existing pattern on `/sfm`, `/unit/{id}`, and `/projects/{p}/nrl/{l}`. + +### Added — `mic_unit_pref` user setting (Settings → General) + +- **New `user_preferences.mic_unit_pref` column**, "dBL" default with "psi" as the alternate value. Controls only the event-report modal's waveform chart mic axis — peak values in every other surface (event tables, KPI tiles, modal Peaks section) stay in dB(L) regardless. +- Surfaced as a single dropdown on Settings → General, below the auto-refresh interval. Round-trips through `GET/PUT /api/settings/preferences`. +- New `backend/migrate_add_mic_unit_pref.py` script for existing databases — idempotent ALTER TABLE. + +### Fixed — Docker Compose: SFM container can finally read the DB + +- `../seismo-relay-prod-snap` is now bind-mounted into the SFM container at the same absolute host path it had outside, so the symlinked `seismo_relay.db` + `waveforms/` directory inside `bridges/captures/` resolve. Without it, SFM 500'd on every `/db/*` proxy call because the symlink target wasn't visible from inside the container. Read-write (not `:ro`) because SFM opens the DB in WAL mode, which requires creating `-wal` and `-shm` sidecar files even for reads. + +### Migration Notes + +```bash +cd /home/serversdown/terra-view +# Apply the new column to the database — required. Idempotent. +docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py + +# Rebuild + restart both Terra-View and SFM (compose mounts changed). +docker compose build terra-view && docker compose up -d +``` + +Set Settings → General → "Event Report — Mic Channel Units" if "psi" is preferred over the default "dB(L)". Setting persists in the DB and is fetched once per modal open. + +### What's NOT in this release + +Device-control endpoints (`/device/*` — start/stop monitoring, push compliance config, erase events, etc.) remain unexposed in the Terra-View UI. They proxy through transparently but no page calls them. Phase 2 of the SFM integration will bring them online once the SFM auth layer lands (a hard prerequisite — anything reachable through Terra-View's URL needs to be gated against unauthenticated callers). + +--- + ## [0.12.1] - 2026-05-20 Field-operations polish — three small features and two correctness fixes that smooth out the deployment workflow added in v0.12.0. The new Unit Swap wizard and editable deployment timeline are the operator-facing items; the swap/unassign/promote roster-flag fix closes a long-standing data-consistency hole. diff --git a/README.md b/README.md index 7e2c5ea..32f2afd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Terra-View v0.12.1 +# Terra-View v0.13.2 Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. ## Features diff --git a/backend/main.py b/backend/main.py index 7dd8e17..db48b2f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine) ENVIRONMENT = os.getenv("ENVIRONMENT", "production") # Initialize FastAPI app -VERSION = "0.12.1" +VERSION = "0.13.2" if ENVIRONMENT == "development": _build = os.getenv("BUILD_NUMBER", "0") if _build and _build != "0": diff --git a/backend/migrate_add_mic_unit_pref.py b/backend/migrate_add_mic_unit_pref.py new file mode 100644 index 0000000..c7eebc5 --- /dev/null +++ b/backend/migrate_add_mic_unit_pref.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +Database migration: Add mic_unit_pref column to user_preferences. + +Adds a single field controlling the mic channel's unit on the event- +report waveform chart in the SFM event detail modal. "psi" (default — +matches the PDF report's mic axis) or "dBL". Peaks and KPI tiles +elsewhere are always dBL regardless. + +History: v0.13.0 originally shipped this with default "dBL", which +made the website chart inconsistent with the PDF. v0.13.1 flips the +default to "psi" so they match. This migration is idempotent and +covers three cases: + +1. Fresh DB without the column — adds it with default 'psi'. +2. DB upgraded from v0.13.0 (column exists, value 'dBL') — flips to + 'psi' on the assumption no operator deliberately picked 'dBL' yet. +3. DB upgraded from later — flip step is a no-op for non-'dBL' values. +""" + +import sqlite3 +from pathlib import Path + + +def migrate(): + possible_paths = [ + Path("data/seismo_fleet.db"), + Path("data/sfm.db"), + Path("data/seismo.db"), + ] + db_path = next((p for p in possible_paths if p.exists()), None) + if db_path is None: + print(f"Database not found in any of: {[str(p) for p in possible_paths]}") + print("Will be created with the new column when models.py initialises.") + return + + print(f"Using database: {db_path}") + conn = sqlite3.connect(db_path) + cur = conn.cursor() + + cur.execute("PRAGMA table_info(user_preferences)") + existing = {row[1] for row in cur.fetchall()} + + if "mic_unit_pref" not in existing: + cur.execute( + "ALTER TABLE user_preferences " + "ADD COLUMN mic_unit_pref TEXT DEFAULT 'psi'" + ) + # Backfill any rows where the column ended up NULL. + cur.execute( + "UPDATE user_preferences SET mic_unit_pref = 'psi' " + "WHERE mic_unit_pref IS NULL" + ) + print("Added mic_unit_pref column (default 'psi').") + else: + print("mic_unit_pref column already exists.") + + # v0.13.0 → v0.13.1 default-flip: rows still sitting at the original + # 'dBL' default get bumped to 'psi'. If any operator deliberately + # chose 'dBL' through Settings before this migration runs they'd + # get reset — acceptable trade-off given the small user base and + # the fact the setting is one click to restore. + cur.execute("UPDATE user_preferences SET mic_unit_pref = 'psi' " + "WHERE mic_unit_pref = 'dBL'") + flipped = cur.rowcount + if flipped: + print(f"Flipped {flipped} row(s) from 'dBL' to 'psi' (v0.13.0 default).") + + conn.commit() + conn.close() + + +if __name__ == "__main__": + migrate() diff --git a/backend/models.py b/backend/models.py index 3ba1e11..aae3039 100644 --- a/backend/models.py +++ b/backend/models.py @@ -135,6 +135,11 @@ class UserPreferences(Base): calibration_warning_days = Column(Integer, default=30) status_ok_threshold_hours = Column(Integer, default=12) status_pending_threshold_hours = Column(Integer, default=24) + # Mic display units on the event-report waveform chart only — peaks + # and KPI tiles elsewhere are always dBL. "psi" (default — matches + # the PDF report) or "dBL". Default flipped in v0.13.1 after + # operator feedback that the chart should mirror the PDF. + mic_unit_pref = Column(String, default="psi") updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/routers/settings.py b/backend/routers/settings.py index e32f4d6..ca95067 100644 --- a/backend/routers/settings.py +++ b/backend/routers/settings.py @@ -267,6 +267,7 @@ class PreferencesUpdate(BaseModel): calibration_warning_days: Optional[int] = None status_ok_threshold_hours: Optional[int] = None status_pending_threshold_hours: Optional[int] = None + mic_unit_pref: Optional[str] = None @router.get("/preferences") @@ -293,6 +294,7 @@ def get_preferences(db: Session = Depends(get_db)): "calibration_warning_days": prefs.calibration_warning_days, "status_ok_threshold_hours": prefs.status_ok_threshold_hours, "status_pending_threshold_hours": prefs.status_pending_threshold_hours, + "mic_unit_pref": prefs.mic_unit_pref or "psi", "updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None } @@ -334,6 +336,7 @@ def update_preferences( "calibration_warning_days": prefs.calibration_warning_days, "status_ok_threshold_hours": prefs.status_ok_threshold_hours, "status_pending_threshold_hours": prefs.status_pending_threshold_hours, + "mic_unit_pref": prefs.mic_unit_pref or "psi", "updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None } diff --git a/backend/static/event-modal.js b/backend/static/event-modal.js index b41d961..b279f9f 100644 --- a/backend/static/event-modal.js +++ b/backend/static/event-modal.js @@ -28,6 +28,27 @@ (function () { const MODAL_ID = 'event-detail-modal'; + // ── Chart.js constants (ported from sfm_webapp.html:2555-2880) ── + const _CHANNEL_COLORS = { + MicL: '#e066ff', // purple — distinct from the geo channels + Long: '#3b82f6', // blue + Vert: '#22c55e', // green + Tran: '#ef4444', // red + }; + const _CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran']; + + // dB(L) reference pressure — 20 µPa expressed in psi (Instantel native unit). + const DBL_REF = 2.9e-9; + // Mic display floor — sound-pressure AC samples sit at the digitisation + // noise floor most of the time (1-2 ADC counts ≈ 20-40 dBL). Without + // a floor, the chart looks like a sparse pattern of "moments when sound + // briefly exceeded the Y-axis bottom" instead of an SPL-vs-time curve. + const MIC_DBL_FLOOR = 60; + + let _charts = {}; // ch → Chart instance + let _micUnitPref = 'psi'; // refreshed via fetch on first chart render + let _micUnitPrefLoaded = false; // one-shot fetch guard + function _esc(s) { if (s == null) return ''; return String(s).replace(/&/g, '&') @@ -224,32 +245,370 @@ `; } + function _renderReview(s, eventId) { + const rev = s.review || {}; + const ft = !!rev.false_trigger; + const reviewer = rev.reviewer || ''; + const notes = rev.notes || ''; + const reviewedAt = rev.reviewed_at + ? rev.reviewed_at.replace('T', ' ').slice(0, 19) + : null; + return `
+
+ +
+ + +
+
+
+ + +
+
+ + ${reviewedAt ? `Last reviewed ${reviewedAt}` : 'Not yet reviewed.'} + + +
+
`; + } + + // ── Waveform / histogram chart helpers ────────────────────────── + + async function _loadMicUnitPref() { + if (_micUnitPrefLoaded) return _micUnitPref; + try { + const r = await fetch('/api/settings/preferences'); + if (r.ok) { + const prefs = await r.json(); + _micUnitPref = prefs.mic_unit_pref === 'dBL' ? 'dBL' : 'psi'; + } + } catch (e) { + // Network error → silent fall back to default 'psi'. + } + _micUnitPrefLoaded = true; + return _micUnitPref; + } + + function _psiToDbl(psi) { + if (psi == null || !(psi > 0)) return null; + return 20 * Math.log10(psi / DBL_REF); + } + + // Rectifying psi→dBL converter for per-sample values — see comments in + // sfm_webapp.html:2592-2607 for the floor rationale. + function _psiToDblForChart(psi) { + if (psi == null) return MIC_DBL_FLOOR; + const a = Math.abs(psi); + if (a === 0) return MIC_DBL_FLOOR; + const dbl = 20 * Math.log10(a / DBL_REF); + return dbl > MIC_DBL_FLOOR ? dbl : MIC_DBL_FLOOR; + } + + // Adaptive decimal formatter — sensible precision in the normal range, + // scientific notation only at the extremes. + function _fmtPeak(v, unit) { + if (v == null || (typeof v === 'number' && !isFinite(v))) return ''; + if (typeof v !== 'number') return String(v) + (unit ? ' ' + unit : ''); + if (v === 0) return '0' + (unit ? ' ' + unit : ''); + const a = Math.abs(v); + const u = unit ? ' ' + unit : ''; + if (a >= 0.0001 && a < 10000) { + const d = a >= 100 ? 1 : a >= 10 ? 2 : a >= 1 ? 3 : a >= 0.1 ? 4 : 5; + return v.toFixed(d) + u; + } + return v.toExponential(2) + u; + } + + function _destroyCharts() { + Object.values(_charts).forEach(c => { try { c.destroy(); } catch (e) { /* noop */ } }); + _charts = {}; + } + + // Returns true when Tailwind dark mode is active (the `dark` class is + // toggled on by Terra-View's theme handler). Drives chart grid + // + tick colors so they have contrast on both backgrounds. + function _isDark() { + return document.documentElement.classList.contains('dark'); + } + + function _renderWaveformInto(containerId, data, micUnit) { + const container = document.getElementById(containerId); + if (!container) return; + container.innerHTML = ''; + _destroyCharts(); + + const channels = data.channels || {}; + const ta = data.time_axis || {}; + const sr = ta.sample_rate || 1024; + const dtMs = ta.dt_ms || (1000.0 / sr); + const t0Ms = ta.t0_ms != null ? ta.t0_ms : 0; + const isHistogram = String(data.record_type || '').toLowerCase().includes('histogram'); + + const withData = _CHANNEL_ORDER.filter(ch => + channels[ch] && (channels[ch].values || []).length > 0 + ); + const lastCh = withData[withData.length - 1]; + + // Theme-aware chart colors. Tailwind dark uses bg-slate-800 (~#1e293b); + // light is white. Grids + ticks need contrast on both. + const dark = _isDark(); + const gridColor = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'; + const tickColor = dark ? '#94a3b8' : '#64748b'; + + if (withData.length === 0) { + container.innerHTML = `
+ No waveform samples decoded — codec walker returned 0 valid blocks for this event. +
`; + return; + } + + for (const ch of _CHANNEL_ORDER) { + const chData = channels[ch]; + if (!chData) continue; + let values = chData.values || []; + let chUnit = chData.unit || ''; + let chPeak = chData.peak; + + // Mic: convert psi → dBL when the user pref is dBL (default). + if (ch === 'MicL' && chUnit === 'psi' && micUnit === 'dBL') { + values = values.map(_psiToDblForChart); + chPeak = _psiToDbl(chPeak); + chUnit = 'dB(L)'; + } + + const wrap = document.createElement('div'); + wrap.className = 'bg-gray-50 dark:bg-slate-900/40 border border-gray-200 dark:border-slate-700 rounded-md px-3 pr-8 pb-1 pt-1 mb-1'; + + const lbl = document.createElement('div'); + lbl.className = 'text-[10px] font-semibold uppercase tracking-wider mb-0.5 flex justify-between items-baseline'; + lbl.style.color = _CHANNEL_COLORS[ch]; + const peakStr = chPeak != null ? `peak ${_fmtPeak(chPeak, chUnit)}` : ''; + lbl.innerHTML = `${ch}${peakStr}`; + wrap.appendChild(lbl); + + if (values.length === 0) { + const e = document.createElement('div'); + e.className = 'h-20 flex items-center justify-center text-xs text-gray-400 italic'; + e.textContent = 'no samples decoded'; + wrap.appendChild(e); + container.appendChild(wrap); + continue; + } + + const canvasWrap = document.createElement('div'); + canvasWrap.className = 'relative'; + canvasWrap.style.height = '100px'; + const canvas = document.createElement('canvas'); + canvasWrap.appendChild(canvas); + wrap.appendChild(canvasWrap); + container.appendChild(wrap); + + // X-axis: waveforms use ms-relative-to-trigger; histograms use + // the BW-reported interval timestamps (HH:MM:SS) when the server + // aggregated to BW intervals, else interval index. + let times; + if (isHistogram) { + const intervalTimes = ta.interval_times || []; + times = (intervalTimes.length === values.length) + ? intervalTimes + : values.map((_, i) => i + 1); + } else { + times = values.map((_, i) => t0Ms + i * dtMs); + } + + // Downsample for rendering when very long. + const MAX = 3000; + let rT = times, rV = values; + if (values.length > MAX) { + const step = Math.ceil(values.length / MAX); + rT = times.filter((_, i) => i % step === 0); + rV = values.filter((_, i) => i % step === 0); + } + const showX = (ch === lastCh); + + const xAxisLabel = isHistogram ? '' : ' ms'; + const fmtTick = i => { + const v = rT[i]; + if (typeof v === 'number') { + const s = Number.isInteger(v) ? String(v) : v.toFixed(1); + return s + xAxisLabel; + } + return String(v) + xAxisLabel; + }; + + // Y-axis bounds — see sfm_webapp.html:2744-2786 for the rationale. + let yBounds = {}; + const isGeo = ch !== 'MicL'; + if (isGeo && !isHistogram) { + let absMax = 0; + for (const v of values) { + const a = Math.abs(v); + if (a > absMax) absMax = a; + } + const padded = (absMax || 1) * 1.10; + yBounds = { min: -padded, max: padded }; + } else if (isGeo && isHistogram) { + const HIST_GEO_MIN_INS = 0.05; + let peak = 0; + for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; } + yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_GEO_MIN_INS) }; + } else if (ch === 'MicL' && micUnit === 'dBL') { + const peakDbl = (typeof chPeak === 'number' && isFinite(chPeak)) + ? chPeak + 5 : 100; + yBounds = { min: MIC_DBL_FLOOR, max: Math.max(peakDbl, MIC_DBL_FLOOR + 20) }; + } else if (ch === 'MicL' && isHistogram && micUnit === 'psi') { + const HIST_MIC_MIN_PSI = 0.001; + let peak = 0; + for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; } + yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_MIC_MIN_PSI) }; + } + + _charts[ch] = new Chart(canvas, { + type: isHistogram ? 'bar' : 'line', + data: { + labels: rT.map(t => (typeof t === 'number' ? (Number.isInteger(t) ? String(t) : t.toFixed(2)) : t)), + datasets: isHistogram ? [{ + data: rV, + backgroundColor: _CHANNEL_COLORS[ch], + borderWidth: 0, + barPercentage: 1.0, + categoryPercentage: 1.0, + }] : [{ + data: rV, + borderColor: _CHANNEL_COLORS[ch], + borderWidth: 1, + pointRadius: 0, + tension: 0, + }], + }, + options: { + animation: false, responsive: true, maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + mode: 'index', intersect: false, + callbacks: { + title: items => isHistogram + ? `interval ${items[0].label}` + : `t = ${items[0].label} ms`, + label: item => `${ch}: ${_fmtPeak(item.raw, chUnit)}`, + }, + }, + }, + scales: { + x: { + type: 'category', display: showX, + ticks: { color: tickColor, maxTicksLimit: 8, maxRotation: 0, callback: (v, i) => fmtTick(i) }, + grid: { color: gridColor, drawTicks: showX }, + }, + y: { + ...yBounds, + ticks: { color: tickColor, maxTicksLimit: 4 }, + grid: { color: gridColor }, + title: { display: true, text: chUnit, color: tickColor, font: { size: 9 } }, + }, + }, + }, + plugins: isHistogram ? [] : [{ + id: 'overlays', + afterDraw(chart) { + const ctx = chart.ctx, x = chart.scales.x, y = chart.scales.y; + const zi = rT.findIndex(t => parseFloat(t) >= 0); + if (zi >= 0) { + const px = x.getPixelForValue(zi); + ctx.save(); + ctx.beginPath(); ctx.moveTo(px, y.top); ctx.lineTo(px, y.bottom); + ctx.strokeStyle = 'rgba(239,68,68,0.8)'; ctx.lineWidth = 1.2; + ctx.setLineDash([4, 3]); ctx.stroke(); ctx.restore(); + ctx.save(); + ctx.fillStyle = '#ef4444'; + ctx.beginPath(); + ctx.moveTo(px - 4, y.top - 7); ctx.lineTo(px + 4, y.top - 7); ctx.lineTo(px, y.top - 1); + ctx.closePath(); ctx.fill(); + ctx.beginPath(); + ctx.moveTo(px - 4, y.bottom + 7); ctx.lineTo(px + 4, y.bottom + 7); ctx.lineTo(px, y.bottom + 1); + ctx.closePath(); ctx.fill(); + ctx.restore(); + } + const zy = y.getPixelForValue(0); + if (zy >= y.top && zy <= y.bottom) { + ctx.save(); + ctx.strokeStyle = gridColor; ctx.lineWidth = 0.8; + ctx.setLineDash([2, 2]); + ctx.beginPath(); ctx.moveTo(x.left, zy); ctx.lineTo(x.right, zy); ctx.stroke(); + ctx.restore(); + ctx.save(); + ctx.fillStyle = tickColor; ctx.font = '10px monospace'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText('0.0', x.right + 6, zy); + ctx.restore(); + } + }, + }], + }); + } + } + function _renderFileInfo(s, eventId) { const bw = s.blastware || {}; const src = s.source || {}; const sizeKb = bw.filesize ? (bw.filesize / 1024).toFixed(1) : null; const canDownloadBinary = !!(bw.available && bw.filename && eventId); + const txtFilename = src && src.txt_filename; + const reportPdfUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/report.pdf`; + const reportTxtUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/ascii_report.txt`; const downloadButtons = `
+ + + + + + Download PDF + ${canDownloadBinary ? ` + class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors"> - Download Blastware file - (${_esc(bw.filename)}${sizeKb ? `, ${sizeKb} KB` : ''}) + Blastware binary + ${sizeKb ? `(${sizeKb} KB)` : ''} - ` : ` - + ` : ''} + ${txtFilename ? ` + - + - Blastware file unavailable - - `} + Original .TXT report + + ` : ''}
+