| @@ -11,4 +11,4 @@ | |||||
| data/events.jsonl | data/events.jsonl | ||||
| # local prompts | # local prompts | ||||
| prompt.txt | |||||
| prompt*.txt | |||||
| @@ -4,7 +4,11 @@ Go-based SDRplay RSP1b live spectrum + waterfall visualizer with a minimal event | |||||
| ## Features | ## Features | ||||
| - Live spectrum + waterfall web UI (WebSocket streaming) | - Live spectrum + waterfall web UI (WebSocket streaming) | ||||
| - Event timeline view (time vs frequency) with detail drawer | |||||
| - In-browser spectrogram slice for selected events | |||||
| - Basic detector with event JSONL output (`data/events.jsonl`) | - Basic detector with event JSONL output (`data/events.jsonl`) | ||||
| - Events API (`/api/events?limit=...&since=...`) | |||||
| - Recorded clips list placeholder (metadata only for now) | |||||
| - Windows + Linux support | - Windows + Linux support | ||||
| - Mock mode for testing without hardware | - Mock mode for testing without hardware | ||||
| @@ -50,6 +54,20 @@ Edit `config.yaml`: | |||||
| ## Web UI | ## Web UI | ||||
| The UI is served from `web/` and connects to `/ws` for spectrum frames. | The UI is served from `web/` and connects to `/ws` for spectrum frames. | ||||
| ### Event Timeline | |||||
| - The timeline panel displays recent events (time vs frequency). | |||||
| - Click any event block to open the detail drawer with event stats and a mini spectrogram slice from the latest frame. | |||||
| ### Events API | |||||
| `/api/events` reads from the JSONL event log and returns the most recent events: | |||||
| - `limit` (optional): max number of events (default 200, max 2000) | |||||
| - `since` (optional): Unix milliseconds or RFC3339 timestamp | |||||
| Example: | |||||
| ```bash | |||||
| curl "http://localhost:8080/api/events?limit=100&since=1700000000000" | |||||
| ``` | |||||
| ## Tests | ## Tests | ||||
| ```bash | ```bash | ||||
| @@ -9,6 +9,7 @@ import ( | |||||
| "os" | "os" | ||||
| "os/signal" | "os/signal" | ||||
| "path/filepath" | "path/filepath" | ||||
| "strconv" | |||||
| "sync" | "sync" | ||||
| "syscall" | "syscall" | ||||
| "time" | "time" | ||||
| @@ -17,6 +18,7 @@ import ( | |||||
| "sdr-visual-suite/internal/config" | "sdr-visual-suite/internal/config" | ||||
| "sdr-visual-suite/internal/detector" | "sdr-visual-suite/internal/detector" | ||||
| "sdr-visual-suite/internal/events" | |||||
| fftutil "sdr-visual-suite/internal/fft" | fftutil "sdr-visual-suite/internal/fft" | ||||
| "sdr-visual-suite/internal/mock" | "sdr-visual-suite/internal/mock" | ||||
| "sdr-visual-suite/internal/sdr" | "sdr-visual-suite/internal/sdr" | ||||
| @@ -134,6 +136,31 @@ func main() { | |||||
| _ = json.NewEncoder(w).Encode(cfg) | _ = json.NewEncoder(w).Encode(cfg) | ||||
| }) | }) | ||||
| http.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { | |||||
| w.Header().Set("Content-Type", "application/json") | |||||
| limit := 200 | |||||
| if v := r.URL.Query().Get("limit"); v != "" { | |||||
| if parsed, err := strconv.Atoi(v); err == nil { | |||||
| limit = parsed | |||||
| } | |||||
| } | |||||
| var since time.Time | |||||
| if v := r.URL.Query().Get("since"); v != "" { | |||||
| if parsed, err := parseSince(v); err == nil { | |||||
| since = parsed | |||||
| } else { | |||||
| http.Error(w, "invalid since", http.StatusBadRequest) | |||||
| return | |||||
| } | |||||
| } | |||||
| evs, err := events.ReadRecent(cfg.EventPath, limit, since) | |||||
| if err != nil { | |||||
| http.Error(w, "failed to read events", http.StatusInternalServerError) | |||||
| return | |||||
| } | |||||
| _ = json.NewEncoder(w).Encode(evs) | |||||
| }) | |||||
| http.Handle("/", http.FileServer(http.Dir(cfg.WebRoot))) | http.Handle("/", http.FileServer(http.Dir(cfg.WebRoot))) | ||||
| server := &http.Server{Addr: cfg.WebAddr} | server := &http.Server{Addr: cfg.WebAddr} | ||||
| @@ -184,3 +211,19 @@ func runDSP(ctx context.Context, src sdr.Source, cfg config.Config, det *detecto | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| func parseSince(raw string) (time.Time, error) { | |||||
| if raw == "" { | |||||
| return time.Time{}, nil | |||||
| } | |||||
| if ms, err := strconv.ParseInt(raw, 10, 64); err == nil { | |||||
| if ms > 1e12 { | |||||
| return time.UnixMilli(ms), nil | |||||
| } | |||||
| return time.Unix(ms, 0), nil | |||||
| } | |||||
| if t, err := time.Parse(time.RFC3339Nano, raw); err == nil { | |||||
| return t, nil | |||||
| } | |||||
| return time.Parse(time.RFC3339, raw) | |||||
| } | |||||
| @@ -0,0 +1,111 @@ | |||||
| package events | |||||
| import ( | |||||
| "bytes" | |||||
| "encoding/json" | |||||
| "errors" | |||||
| "io" | |||||
| "os" | |||||
| "time" | |||||
| "sdr-visual-suite/internal/detector" | |||||
| ) | |||||
| const ( | |||||
| defaultLimit = 200 | |||||
| maxLimit = 2000 | |||||
| readChunk = 64 * 1024 | |||||
| ) | |||||
| // ReadRecent reads the newest events from a JSONL file. | |||||
| // If since is non-zero, older events (by End time) are skipped. | |||||
| func ReadRecent(path string, limit int, since time.Time) ([]detector.Event, error) { | |||||
| if limit <= 0 { | |||||
| limit = defaultLimit | |||||
| } | |||||
| if limit > maxLimit { | |||||
| limit = maxLimit | |||||
| } | |||||
| file, err := os.Open(path) | |||||
| if err != nil { | |||||
| if errors.Is(err, os.ErrNotExist) { | |||||
| return nil, nil | |||||
| } | |||||
| return nil, err | |||||
| } | |||||
| defer file.Close() | |||||
| info, err := file.Stat() | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| if info.Size() == 0 { | |||||
| return nil, nil | |||||
| } | |||||
| lines, err := readLinesReverse(file, info.Size(), limit) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| events := make([]detector.Event, 0, len(lines)) | |||||
| for _, line := range lines { | |||||
| var ev detector.Event | |||||
| if err := json.Unmarshal([]byte(line), &ev); err != nil { | |||||
| continue | |||||
| } | |||||
| if !since.IsZero() && ev.End.Before(since) { | |||||
| break | |||||
| } | |||||
| events = append(events, ev) | |||||
| } | |||||
| for i, j := 0, len(events)-1; i < j; i, j = i+1, j-1 { | |||||
| events[i], events[j] = events[j], events[i] | |||||
| } | |||||
| return events, nil | |||||
| } | |||||
| func readLinesReverse(file *os.File, size int64, limit int) ([]string, error) { | |||||
| pos := size | |||||
| remainder := []byte{} | |||||
| lines := make([]string, 0, limit) | |||||
| for pos > 0 && len(lines) < limit { | |||||
| chunkSize := int64(readChunk) | |||||
| if chunkSize > pos { | |||||
| chunkSize = pos | |||||
| } | |||||
| pos -= chunkSize | |||||
| buf := make([]byte, chunkSize) | |||||
| n, err := file.ReadAt(buf, pos) | |||||
| if err != nil && !errors.Is(err, io.EOF) { | |||||
| return nil, err | |||||
| } | |||||
| buf = buf[:n] | |||||
| data := append(buf, remainder...) | |||||
| i := len(data) | |||||
| for i > 0 && len(lines) < limit { | |||||
| j := bytes.LastIndexByte(data[:i], '\n') | |||||
| if j == -1 { | |||||
| break | |||||
| } | |||||
| line := bytes.TrimSpace(data[j+1 : i]) | |||||
| if len(line) > 0 { | |||||
| lines = append(lines, string(line)) | |||||
| } | |||||
| i = j | |||||
| } | |||||
| remainder = data[:i] | |||||
| } | |||||
| if len(lines) < limit { | |||||
| line := bytes.TrimSpace(remainder) | |||||
| if len(line) > 0 { | |||||
| lines = append(lines, string(line)) | |||||
| } | |||||
| } | |||||
| return lines, nil | |||||
| } | |||||
| @@ -0,0 +1,60 @@ | |||||
| package events | |||||
| import ( | |||||
| "encoding/json" | |||||
| "os" | |||||
| "path/filepath" | |||||
| "testing" | |||||
| "time" | |||||
| "sdr-visual-suite/internal/detector" | |||||
| ) | |||||
| func TestReadRecent(t *testing.T) { | |||||
| dir := t.TempDir() | |||||
| path := filepath.Join(dir, "events.jsonl") | |||||
| start := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Millisecond) | |||||
| events := []detector.Event{ | |||||
| {ID: 1, Start: start, End: start.Add(2 * time.Second), CenterHz: 100, Bandwidth: 5, PeakDb: -10, SNRDb: 12}, | |||||
| {ID: 2, Start: start.Add(10 * time.Second), End: start.Add(12 * time.Second), CenterHz: 200, Bandwidth: 10, PeakDb: -5, SNRDb: 15}, | |||||
| {ID: 3, Start: start.Add(20 * time.Second), End: start.Add(22 * time.Second), CenterHz: 300, Bandwidth: 20, PeakDb: -3, SNRDb: 18}, | |||||
| } | |||||
| file, err := os.Create(path) | |||||
| if err != nil { | |||||
| t.Fatalf("create: %v", err) | |||||
| } | |||||
| enc := json.NewEncoder(file) | |||||
| for _, ev := range events { | |||||
| if err := enc.Encode(ev); err != nil { | |||||
| t.Fatalf("encode: %v", err) | |||||
| } | |||||
| } | |||||
| if err := file.Close(); err != nil { | |||||
| t.Fatalf("close: %v", err) | |||||
| } | |||||
| got, err := ReadRecent(path, 2, time.Time{}) | |||||
| if err != nil { | |||||
| t.Fatalf("read: %v", err) | |||||
| } | |||||
| if len(got) != 2 { | |||||
| t.Fatalf("expected 2 events, got %d", len(got)) | |||||
| } | |||||
| if got[0].ID != 2 || got[1].ID != 3 { | |||||
| t.Fatalf("unexpected IDs: %v, %v", got[0].ID, got[1].ID) | |||||
| } | |||||
| since := start.Add(15 * time.Second) | |||||
| got, err = ReadRecent(path, 10, since) | |||||
| if err != nil { | |||||
| t.Fatalf("read since: %v", err) | |||||
| } | |||||
| if len(got) != 1 { | |||||
| t.Fatalf("expected 1 event, got %d", len(got)) | |||||
| } | |||||
| if got[0].ID != 3 { | |||||
| t.Fatalf("expected ID 3, got %d", got[0].ID) | |||||
| } | |||||
| } | |||||
| @@ -1,7 +1,18 @@ | |||||
| const spectrumCanvas = document.getElementById('spectrum'); | const spectrumCanvas = document.getElementById('spectrum'); | ||||
| const waterfallCanvas = document.getElementById('waterfall'); | const waterfallCanvas = document.getElementById('waterfall'); | ||||
| const timelineCanvas = document.getElementById('timeline'); | |||||
| const statusEl = document.getElementById('status'); | const statusEl = document.getElementById('status'); | ||||
| const metaEl = document.getElementById('meta'); | const metaEl = document.getElementById('meta'); | ||||
| const timelineRangeEl = document.getElementById('timelineRange'); | |||||
| const drawerEl = document.getElementById('eventDrawer'); | |||||
| const drawerCloseBtn = document.getElementById('drawerClose'); | |||||
| const detailCenterEl = document.getElementById('detailCenter'); | |||||
| const detailBwEl = document.getElementById('detailBw'); | |||||
| const detailStartEl = document.getElementById('detailStart'); | |||||
| const detailEndEl = document.getElementById('detailEnd'); | |||||
| const detailSnrEl = document.getElementById('detailSnr'); | |||||
| const detailDurEl = document.getElementById('detailDur'); | |||||
| const detailSpectrogram = document.getElementById('detailSpectrogram'); | |||||
| let latest = null; | let latest = null; | ||||
| let zoom = 1.0; | let zoom = 1.0; | ||||
| @@ -9,6 +20,15 @@ let pan = 0.0; | |||||
| let isDragging = false; | let isDragging = false; | ||||
| let dragStartX = 0; | let dragStartX = 0; | ||||
| let dragStartPan = 0; | let dragStartPan = 0; | ||||
| let timelineDirty = true; | |||||
| let detailDirty = false; | |||||
| const events = []; | |||||
| const eventsById = new Map(); | |||||
| let lastEventEndMs = 0; | |||||
| let eventsFetchInFlight = false; | |||||
| let timelineRects = []; | |||||
| let selectedEventId = null; | |||||
| function resize() { | function resize() { | ||||
| const dpr = window.devicePixelRatio || 1; | const dpr = window.devicePixelRatio || 1; | ||||
| @@ -18,6 +38,14 @@ function resize() { | |||||
| const rect2 = waterfallCanvas.getBoundingClientRect(); | const rect2 = waterfallCanvas.getBoundingClientRect(); | ||||
| waterfallCanvas.width = rect2.width * dpr; | waterfallCanvas.width = rect2.width * dpr; | ||||
| waterfallCanvas.height = rect2.height * dpr; | waterfallCanvas.height = rect2.height * dpr; | ||||
| const rect3 = timelineCanvas.getBoundingClientRect(); | |||||
| timelineCanvas.width = rect3.width * dpr; | |||||
| timelineCanvas.height = rect3.height * dpr; | |||||
| const rect4 = detailSpectrogram.getBoundingClientRect(); | |||||
| detailSpectrogram.width = rect4.width * dpr; | |||||
| detailSpectrogram.height = rect4.height * dpr; | |||||
| timelineDirty = true; | |||||
| detailDirty = true; | |||||
| } | } | ||||
| window.addEventListener('resize', resize); | window.addEventListener('resize', resize); | ||||
| @@ -31,6 +59,12 @@ function colorMap(v) { | |||||
| return [r, g, b]; | return [r, g, b]; | ||||
| } | } | ||||
| function snrColor(snr) { | |||||
| const norm = Math.max(0, Math.min(1, (snr + 5) / 30)); | |||||
| const [r, g, b] = colorMap(norm); | |||||
| return `rgb(${r}, ${g}, ${b})`; | |||||
| } | |||||
| function renderSpectrum() { | function renderSpectrum() { | ||||
| if (!latest) return; | if (!latest) return; | ||||
| const ctx = spectrumCanvas.getContext('2d'); | const ctx = spectrumCanvas.getContext('2d'); | ||||
| @@ -129,9 +163,130 @@ function renderWaterfall() { | |||||
| ctx.putImageData(row, 0, 0); | ctx.putImageData(row, 0, 0); | ||||
| } | } | ||||
| function renderTimeline() { | |||||
| const ctx = timelineCanvas.getContext('2d'); | |||||
| const w = timelineCanvas.width; | |||||
| const h = timelineCanvas.height; | |||||
| ctx.clearRect(0, 0, w, h); | |||||
| if (events.length === 0) { | |||||
| timelineRangeEl.textContent = 'No events yet'; | |||||
| return; | |||||
| } | |||||
| const now = Date.now(); | |||||
| const windowMs = 5 * 60 * 1000; | |||||
| const endMs = now; | |||||
| const startMs = endMs - windowMs; | |||||
| let minHz = Infinity; | |||||
| let maxHz = -Infinity; | |||||
| if (latest) { | |||||
| minHz = latest.center_hz - latest.sample_rate / 2; | |||||
| maxHz = latest.center_hz + latest.sample_rate / 2; | |||||
| } else { | |||||
| for (const ev of events) { | |||||
| minHz = Math.min(minHz, ev.center_hz - ev.bandwidth_hz / 2); | |||||
| maxHz = Math.max(maxHz, ev.center_hz + ev.bandwidth_hz / 2); | |||||
| } | |||||
| } | |||||
| if (!isFinite(minHz) || !isFinite(maxHz) || minHz === maxHz) { | |||||
| minHz = 0; | |||||
| maxHz = 1; | |||||
| } | |||||
| ctx.strokeStyle = '#13263b'; | |||||
| ctx.lineWidth = 1; | |||||
| for (let i = 1; i < 6; i++) { | |||||
| const y = (h / 6) * i; | |||||
| ctx.beginPath(); | |||||
| ctx.moveTo(0, y); | |||||
| ctx.lineTo(w, y); | |||||
| ctx.stroke(); | |||||
| } | |||||
| timelineRects = []; | |||||
| for (const ev of events) { | |||||
| if (ev.end_ms < startMs || ev.start_ms > endMs) continue; | |||||
| const x1 = ((Math.max(ev.start_ms, startMs) - startMs) / (endMs - startMs)) * w; | |||||
| const x2 = ((Math.min(ev.end_ms, endMs) - startMs) / (endMs - startMs)) * w; | |||||
| const bw = Math.max(ev.bandwidth_hz, 1); | |||||
| const topHz = ev.center_hz + bw / 2; | |||||
| const bottomHz = ev.center_hz - bw / 2; | |||||
| const y1 = ((maxHz - topHz) / (maxHz - minHz)) * h; | |||||
| const y2 = ((maxHz - bottomHz) / (maxHz - minHz)) * h; | |||||
| const rectH = Math.max(2, y2 - y1); | |||||
| ctx.fillStyle = snrColor(ev.snr_db || 0); | |||||
| ctx.fillRect(x1, y1, Math.max(2, x2 - x1), rectH); | |||||
| const rect = { x: x1, y: y1, w: Math.max(2, x2 - x1), h: rectH, id: ev.id }; | |||||
| timelineRects.push(rect); | |||||
| } | |||||
| if (selectedEventId) { | |||||
| const hit = timelineRects.find((r) => r.id === selectedEventId); | |||||
| if (hit) { | |||||
| ctx.strokeStyle = '#ffffff'; | |||||
| ctx.lineWidth = 2; | |||||
| ctx.strokeRect(hit.x - 1, hit.y - 1, hit.w + 2, hit.h + 2); | |||||
| } | |||||
| } | |||||
| const startLabel = new Date(startMs).toLocaleTimeString(); | |||||
| const endLabel = new Date(endMs).toLocaleTimeString(); | |||||
| timelineRangeEl.textContent = `${startLabel} - ${endLabel}`; | |||||
| } | |||||
| function renderDetailSpectrogram(ev) { | |||||
| const ctx = detailSpectrogram.getContext('2d'); | |||||
| const w = detailSpectrogram.width; | |||||
| const h = detailSpectrogram.height; | |||||
| ctx.clearRect(0, 0, w, h); | |||||
| if (!latest || !ev) return; | |||||
| const span = Math.min(latest.sample_rate, Math.max(ev.bandwidth_hz * 3, latest.sample_rate / 8)); | |||||
| const startHz = ev.center_hz - span / 2; | |||||
| const endHz = ev.center_hz + span / 2; | |||||
| const { spectrum_db, sample_rate, center_hz } = latest; | |||||
| const n = spectrum_db.length; | |||||
| const minDb = -120; | |||||
| const maxDb = 0; | |||||
| const row = ctx.createImageData(w, 1); | |||||
| for (let x = 0; x < w; x++) { | |||||
| const freq = startHz + (x / (w - 1)) * (endHz - startHz); | |||||
| const bin = Math.floor((freq - (center_hz - sample_rate / 2)) / (sample_rate / n)); | |||||
| if (bin >= 0 && bin < n) { | |||||
| const v = spectrum_db[bin]; | |||||
| const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb))); | |||||
| const [r, g, b] = colorMap(norm); | |||||
| row.data[x * 4 + 0] = r; | |||||
| row.data[x * 4 + 1] = g; | |||||
| row.data[x * 4 + 2] = b; | |||||
| row.data[x * 4 + 3] = 255; | |||||
| } else { | |||||
| row.data[x * 4 + 3] = 255; | |||||
| } | |||||
| } | |||||
| for (let y = 0; y < h; y++) { | |||||
| ctx.putImageData(row, 0, y); | |||||
| } | |||||
| } | |||||
| function tick() { | function tick() { | ||||
| renderSpectrum(); | renderSpectrum(); | ||||
| renderWaterfall(); | renderWaterfall(); | ||||
| if (timelineDirty) { | |||||
| renderTimeline(); | |||||
| timelineDirty = false; | |||||
| } | |||||
| if (detailDirty && drawerEl.classList.contains('open')) { | |||||
| const ev = eventsById.get(selectedEventId); | |||||
| renderDetailSpectrogram(ev); | |||||
| detailDirty = false; | |||||
| } | |||||
| requestAnimationFrame(tick); | requestAnimationFrame(tick); | ||||
| } | } | ||||
| @@ -143,6 +298,8 @@ function connect() { | |||||
| }; | }; | ||||
| ws.onmessage = (ev) => { | ws.onmessage = (ev) => { | ||||
| latest = JSON.parse(ev.data); | latest = JSON.parse(ev.data); | ||||
| detailDirty = true; | |||||
| timelineDirty = true; | |||||
| }; | }; | ||||
| ws.onclose = () => { | ws.onclose = () => { | ||||
| statusEl.textContent = 'Disconnected - retrying...'; | statusEl.textContent = 'Disconnected - retrying...'; | ||||
| @@ -173,5 +330,105 @@ window.addEventListener('mousemove', (ev) => { | |||||
| pan = Math.max(-0.5, Math.min(0.5, pan)); | pan = Math.max(-0.5, Math.min(0.5, pan)); | ||||
| }); | }); | ||||
| function normalizeEvent(ev) { | |||||
| const startMs = new Date(ev.start).getTime(); | |||||
| const endMs = new Date(ev.end).getTime(); | |||||
| return { | |||||
| ...ev, | |||||
| start_ms: startMs, | |||||
| end_ms: endMs, | |||||
| duration_ms: Math.max(0, endMs - startMs), | |||||
| }; | |||||
| } | |||||
| function upsertEvents(list, replace) { | |||||
| if (replace) { | |||||
| events.length = 0; | |||||
| eventsById.clear(); | |||||
| } | |||||
| for (const raw of list) { | |||||
| if (eventsById.has(raw.id)) continue; | |||||
| const ev = normalizeEvent(raw); | |||||
| eventsById.set(ev.id, ev); | |||||
| events.push(ev); | |||||
| } | |||||
| events.sort((a, b) => a.end_ms - b.end_ms); | |||||
| const maxEvents = 1500; | |||||
| if (events.length > maxEvents) { | |||||
| const drop = events.length - maxEvents; | |||||
| for (let i = 0; i < drop; i++) { | |||||
| eventsById.delete(events[i].id); | |||||
| } | |||||
| events.splice(0, drop); | |||||
| } | |||||
| if (events.length > 0) { | |||||
| lastEventEndMs = events[events.length - 1].end_ms; | |||||
| } | |||||
| timelineDirty = true; | |||||
| } | |||||
| async function fetchEvents(initial) { | |||||
| if (eventsFetchInFlight) return; | |||||
| eventsFetchInFlight = true; | |||||
| try { | |||||
| let url = '/api/events?limit=1000'; | |||||
| if (!initial && lastEventEndMs > 0) { | |||||
| url = `/api/events?since=${lastEventEndMs - 1}`; | |||||
| } | |||||
| const res = await fetch(url); | |||||
| if (!res.ok) return; | |||||
| const data = await res.json(); | |||||
| if (Array.isArray(data)) { | |||||
| upsertEvents(data, initial); | |||||
| } | |||||
| } finally { | |||||
| eventsFetchInFlight = false; | |||||
| } | |||||
| } | |||||
| function openDrawer(ev) { | |||||
| if (!ev) return; | |||||
| selectedEventId = ev.id; | |||||
| detailCenterEl.textContent = `${(ev.center_hz / 1e6).toFixed(6)} MHz`; | |||||
| detailBwEl.textContent = `${(ev.bandwidth_hz / 1e3).toFixed(2)} kHz`; | |||||
| detailStartEl.textContent = new Date(ev.start_ms).toLocaleString(); | |||||
| detailEndEl.textContent = new Date(ev.end_ms).toLocaleString(); | |||||
| detailSnrEl.textContent = `${(ev.snr_db || 0).toFixed(1)} dB`; | |||||
| detailDurEl.textContent = `${(ev.duration_ms / 1000).toFixed(2)} s`; | |||||
| drawerEl.classList.add('open'); | |||||
| drawerEl.setAttribute('aria-hidden', 'false'); | |||||
| resize(); | |||||
| detailDirty = true; | |||||
| timelineDirty = true; | |||||
| } | |||||
| function closeDrawer() { | |||||
| drawerEl.classList.remove('open'); | |||||
| drawerEl.setAttribute('aria-hidden', 'true'); | |||||
| selectedEventId = null; | |||||
| timelineDirty = true; | |||||
| } | |||||
| drawerCloseBtn.addEventListener('click', closeDrawer); | |||||
| timelineCanvas.addEventListener('click', (ev) => { | |||||
| const rect = timelineCanvas.getBoundingClientRect(); | |||||
| const scaleX = timelineCanvas.width / rect.width; | |||||
| const scaleY = timelineCanvas.height / rect.height; | |||||
| const x = (ev.clientX - rect.left) * scaleX; | |||||
| const y = (ev.clientY - rect.top) * scaleY; | |||||
| for (let i = timelineRects.length - 1; i >= 0; i--) { | |||||
| const r = timelineRects[i]; | |||||
| if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) { | |||||
| const hit = eventsById.get(r.id); | |||||
| openDrawer(hit); | |||||
| return; | |||||
| } | |||||
| } | |||||
| }); | |||||
| connect(); | connect(); | ||||
| requestAnimationFrame(tick); | requestAnimationFrame(tick); | ||||
| fetchEvents(true); | |||||
| setInterval(() => fetchEvents(false), 2000); | |||||
| @@ -18,7 +18,40 @@ | |||||
| <section class="panel"> | <section class="panel"> | ||||
| <canvas id="waterfall"></canvas> | <canvas id="waterfall"></canvas> | ||||
| </section> | </section> | ||||
| <section class="panel timeline-panel"> | |||||
| <div class="panel-header"> | |||||
| <div>Event Timeline</div> | |||||
| <div class="panel-subtitle" id="timelineRange">Waiting for events...</div> | |||||
| </div> | |||||
| <canvas id="timeline"></canvas> | |||||
| </section> | |||||
| </main> | </main> | ||||
| <aside class="drawer" id="eventDrawer" aria-hidden="true"> | |||||
| <div class="drawer-header"> | |||||
| <div>Event Detail</div> | |||||
| <button class="drawer-close" id="drawerClose" type="button">Close</button> | |||||
| </div> | |||||
| <div class="drawer-body"> | |||||
| <div class="detail-grid"> | |||||
| <div class="detail-label">Center</div> | |||||
| <div id="detailCenter">-</div> | |||||
| <div class="detail-label">Bandwidth</div> | |||||
| <div id="detailBw">-</div> | |||||
| <div class="detail-label">Start</div> | |||||
| <div id="detailStart">-</div> | |||||
| <div class="detail-label">End</div> | |||||
| <div id="detailEnd">-</div> | |||||
| <div class="detail-label">SNR</div> | |||||
| <div id="detailSnr">-</div> | |||||
| <div class="detail-label">Duration</div> | |||||
| <div id="detailDur">-</div> | |||||
| </div> | |||||
| <div class="detail-section-title">Latest Spectrogram Slice</div> | |||||
| <canvas id="detailSpectrogram"></canvas> | |||||
| <div class="detail-section-title">Recorded Clips (placeholder)</div> | |||||
| <div class="clips-empty">No clips recorded yet.</div> | |||||
| </div> | |||||
| </aside> | |||||
| <footer> | <footer> | ||||
| <div id="status">Connecting...</div> | <div id="status">Connecting...</div> | ||||
| </footer> | </footer> | ||||
| @@ -46,6 +46,7 @@ footer { | |||||
| main { | main { | ||||
| flex: 1; | flex: 1; | ||||
| display: grid; | display: grid; | ||||
| grid-template-columns: 2fr 1fr; | |||||
| grid-template-rows: 1fr 1.2fr; | grid-template-rows: 1fr 1.2fr; | ||||
| gap: 12px; | gap: 12px; | ||||
| padding: 12px; | padding: 12px; | ||||
| @@ -66,12 +67,133 @@ canvas { | |||||
| background: #06090d; | background: #06090d; | ||||
| } | } | ||||
| .timeline-panel { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| gap: 8px; | |||||
| grid-row: 1 / span 2; | |||||
| grid-column: 2; | |||||
| } | |||||
| .timeline-panel canvas { | |||||
| flex: 1; | |||||
| cursor: crosshair; | |||||
| } | |||||
| .panel-header { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| font-size: 0.95rem; | |||||
| color: var(--text); | |||||
| } | |||||
| .panel-subtitle { | |||||
| font-size: 0.8rem; | |||||
| color: var(--muted); | |||||
| } | |||||
| #spectrum { | |||||
| cursor: grab; | |||||
| } | |||||
| #spectrum:active { | |||||
| cursor: grabbing; | |||||
| } | |||||
| .drawer { | |||||
| position: fixed; | |||||
| right: 12px; | |||||
| top: 70px; | |||||
| bottom: 70px; | |||||
| width: 320px; | |||||
| background: #0d141e; | |||||
| border: 1px solid #13263b; | |||||
| border-radius: 12px; | |||||
| box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45); | |||||
| display: none; | |||||
| flex-direction: column; | |||||
| z-index: 10; | |||||
| } | |||||
| .drawer.open { | |||||
| display: flex; | |||||
| } | |||||
| .drawer-header { | |||||
| padding: 12px 16px; | |||||
| border-bottom: 1px solid #13263b; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| font-weight: 600; | |||||
| } | |||||
| .drawer-close { | |||||
| background: #162030; | |||||
| border: 1px solid #20344b; | |||||
| color: var(--text); | |||||
| padding: 4px 10px; | |||||
| border-radius: 8px; | |||||
| cursor: pointer; | |||||
| } | |||||
| .drawer-body { | |||||
| padding: 12px 16px 16px; | |||||
| overflow: auto; | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| gap: 12px; | |||||
| } | |||||
| .detail-grid { | |||||
| display: grid; | |||||
| grid-template-columns: 1fr 1fr; | |||||
| gap: 8px 12px; | |||||
| font-size: 0.9rem; | |||||
| } | |||||
| .detail-label { | |||||
| color: var(--muted); | |||||
| } | |||||
| .detail-section-title { | |||||
| font-size: 0.85rem; | |||||
| color: var(--muted); | |||||
| text-transform: uppercase; | |||||
| letter-spacing: 0.08em; | |||||
| } | |||||
| #detailSpectrogram { | |||||
| height: 90px; | |||||
| } | |||||
| .clips-empty { | |||||
| color: var(--muted); | |||||
| font-size: 0.9rem; | |||||
| } | |||||
| #status { | #status { | ||||
| color: var(--muted); | color: var(--muted); | ||||
| } | } | ||||
| @media (max-width: 820px) { | @media (max-width: 820px) { | ||||
| main { | main { | ||||
| grid-template-rows: 1fr 1fr; | |||||
| grid-template-columns: 1fr; | |||||
| grid-template-rows: 1fr 1fr 1fr; | |||||
| } | |||||
| .timeline-panel { | |||||
| grid-row: auto; | |||||
| grid-column: auto; | |||||
| } | |||||
| .drawer { | |||||
| left: 12px; | |||||
| right: 12px; | |||||
| width: auto; | |||||
| top: auto; | |||||
| bottom: 12px; | |||||
| height: 45vh; | |||||
| } | } | ||||
| } | } | ||||