Compare commits

...

13 Commits

Author SHA1 Message Date
CoreScope Bot b2b5913add revert(live): animation honors VCR.speed in BOTH modes (restore #922)
Operator wants slow-mo / fast-anim available in LIVE as well as REPLAY.
The mode-gated divisor removed that. Restore original #922 behavior:
both stepMs and DURATION_MS divide by VCR.speed unconditionally, and
the speed button stays visible in LIVE so it can be adjusted.

Updates test assertions to match: LIVE @ 0.25× → 132ms, LIVE @ 4× → 8.25ms.
2026-05-24 04:38:12 +00:00
openclaw-bot e610434434 fix(live): animation honors VCR.speed in REPLAY only; hide speed btn in LIVE
- stepMs / DURATION_MS divide by VCR.speed when mode===REPLAY (preserves
  #922 slow-mo @ 0.25× and fast-forward @ 4×/8×)
- LIVE mode always uses divisor=1 (fixes #1346 — persisted high speed no
  longer makes LIVE animation instantaneous)
- updateVCRUI hides speedBtn in LIVE (control is meaningless there)
- Tests cover LIVE@4/8, REPLAY@0.25/1/4 + inter-packet delay regression
  guard + UI hidden-in-LIVE assertion
2026-05-24 04:36:00 +00:00
openclaw-bot 122307fa15 fix(live): per-packet animation always 1× — decouple from VCR.speed (#1346)
VCR.speed is the inter-packet replay-gap multiplier, not a per-packet
animation cadence multiplier. Dividing the constants by VCR.speed made
LIVE mode appear instantaneous whenever a prior REPLAY had cycled to
4×/8× (the speed persists in localStorage across page loads).

Changes:
- drawAnimatedLine: stepMs = 33 (was 33 / VCR.speed)
- drawMatrixLine: DURATION_MS = 1100 (was 1100 / VCR.speed)

Inter-packet replay timing (delay = realGap / VCR.speed, line 507) is
left untouched — that is the legitimate slow-mo / fast-forward axis.

Fixes #1346
2026-05-24 04:30:19 +00:00
openclaw-bot 98dcf4c1e3 test(live): failing test for #1346 — per-packet anim must not divide by VCR.speed
RED commit. Test asserts:
- drawAnimatedLine stepMs is 33 (no /VCR.speed)
- drawMatrixLine DURATION_MS is 1100 (no /VCR.speed)
- inter-packet replay delay still uses VCR.speed (regression guard)
2026-05-24 04:29:54 +00:00
Kpa-clawbot 534227ab89 ci: update go-server-coverage.json [skip ci] 2026-05-24 04:14:45 +00:00
Kpa-clawbot adcca3a8fc ci: update go-ingestor-coverage.json [skip ci] 2026-05-24 04:14:44 +00:00
Kpa-clawbot 67ea45aa31 ci: update frontend-tests.json [skip ci] 2026-05-24 04:14:43 +00:00
Kpa-clawbot 8e86ba57ed ci: update frontend-coverage.json [skip ci] 2026-05-24 04:14:42 +00:00
Kpa-clawbot c266921805 ci: update e2e-tests.json [skip ci] 2026-05-24 04:14:41 +00:00
Kpa-clawbot eeddf46bc9 fix(ingestor): neighbor-builder delta scan + watermark — recovers 97% packet loss from #1289 (fixes #1339) (#1341)
## Summary
PR #1289 moved neighbor-graph construction into the ingestor with a 60s
ticker. `buildAndPersistNeighborEdges` then issued an **unbounded**
`SELECT … FROM observations o JOIN transmissions t …` every tick. On
staging (3.7M observations) one tick took ~2 minutes; with
`max_open_conns=1`, the SQLite single-writer was held continuously and
MQTT ingest collapsed (~6,500 tx/day → ~180 tx/day, 97% loss).

## Fix
Watermark-bounded delta scan. Each call derives the watermark from
`MAX(neighbor_edges.last_seen)` and restricts the SELECT to `WHERE
o.timestamp > ? ORDER BY o.timestamp LIMIT 50000`. `neighbor_edges`
itself is the persistence — no new metadata table, no in-memory state,
restarts resume cleanly from whatever the table reflects.

- Empty edges table → watermark 0 → full warm-up scan (preserves #1289's
synchronous warm-up intent).
- Warm-up loops the builder until a call returns fewer than the batch
cap, so the first server snapshot load sees a fully-populated table even
on fresh DBs.
- 50k batch cap stops any single tick from monopolising the writer; a
backlog drains over successive ticks.
- Per-tick wallclock is logged (`tick: N edges in DUR`); a tick >5s is
logged loudly as a possible regression of #1339. Broader instrumentation
is tracked in #1340.
- Output schema unchanged — server's `neighbor_recomputer.go` is
unaffected.

## Trade-off
An anomalously-old observation that arrives after its timestamp has been
crossed by the watermark will be skipped. Acceptable for an approximate
neighbor graph; a periodic full-rebuild can land later if needed.

## TDD
- **RED** (`d88e2522`): `TestNeighborEdgesBuilderDeltaScan` seeds 100k
observations, asserts an empty-delta tick is a no-op (<1s), and a
100-row delta is upserted in <500ms with no rescan of baseline rows.
Baseline builder fails the empty-delta assertion (sees all 200k baseline
edges).
- **GREEN** (`cf6fbb4e`): watermark + LIMIT — all assertions pass.
- **Mutation**: revert the `WHERE o.timestamp > ?` clause → the test
hangs to lock-contention timeout, confirming the WHERE actually gates
the behavior.

## Benchmark (synthetic, 100k observations, local sqlite)
| | Scan duration |
|---|---|
| Baseline builder, full scan every tick | ~40s |
| Patched builder, empty-delta tick | <50ms |
| Patched builder, 100-row delta | <50ms |

Staging projection: 2–3 min ticks → <1s ticks; SQLite writer freed for
MQTT ingest.

Fixes #1339

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-23 20:54:16 -07:00
Kpa-clawbot 0f7c03ccaf fix(#1293): role-aware marker shapes + outline-ring highlight (#1334)
Fixes #1293

## What

Marker shape now varies per role (WCAG 1.4.1 — colour is no longer the
only carrier of role identity), and the live map's selection/highlight
no longer stacks same-colour concentric markers.

| Role      | Shape    | Why |
|-----------|----------|-----|
| repeater  | circle   | default, most common |
| companion | square   | flat sides, easy to distinguish from circle |
| room      | hexagon  | tessellation hint = group |
| sensor    | triangle | "alert-like" silhouette |
| observer  | diamond  | network-infrastructure suggestion |

Existing role colours are preserved; the shape is the new differentiator
so red/green colourblind operators can still tell roles apart.

## How

- `public/roles.js`: new `window.ROLE_SHAPES` map (single source of
truth), `ROLE_STYLE.shape` synced, shared
`window.makeRoleMarkerSVG(role, color, size)` helper that emits
self-contained `<svg>` strings — including a new `hexagon` branch.
- `public/map.js`: `makeMarkerIcon` switch picks up the `hexagon` case.
- `public/live.js`: `addNodeMarker` now builds an `L.divIcon` via
`makeRoleMarkerSVG` (was a flat `L.circleMarker` — colour only). A
hidden stroke-only `_highlightRing` is allocated per marker; `pulseNode`
grows + fades that ring instead of recolouring the marker fill, so the
blue-on-blue concentric stacking the issue called out cannot occur.
`rescaleMarkers`, `pruneStaleNodes`, matrix mode toggling now drive the
divIcon via small DOM helpers.
- `public/live.js` role legend: emits SVG shape + colour swatch (was a
bare coloured dot).
- `public/live.css`: `.live-shape-swatch` wrapper for the SVG legend
swatches.

## TDD

Red commit: `7e5e2d95` — `test-issue-1293-marker-shapes.js` asserts the
shape map, helper, hexagon branches, divIcon switch in `addNodeMarker`,
SVG-based legend, and outline-ring highlight (no same-colour fill
overlay). Wired into `deploy.yml` JS unit tests.

Green commit: `fb33ca96`.

## Design check

Coblis simulator (deuteranopia / protanopia / tritanopia) — reviewer to
run on the staging build; shapes carry the signal independent of hue, so
all role categories should remain distinguishable. Existing colours are
retained per the issue's "keep colours, vary shape" guidance.

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— all gates pass.

---------

Co-authored-by: corescope-bot <bot@corescope>
2026-05-23 20:54:12 -07:00
Kpa-clawbot adcf29dd6b fix(#1329): accordion map controls on mobile, drop 200px scroll cap (#1333)
## Summary

On mobile (≤640px) the Map controls panel was capped at `max-height:
200px` and forced an internal scrollbar through all the
layer/filter/display toggles. This makes every section a single-open
accordion and drops the cap, so the visible content always fits without
internal scroll.

## Changes

- `public/map.js` — Each `fieldset.mc-section` legend becomes a tappable
`aria-expanded` toggle. On mobile the first section opens by default;
activating any other section auto-closes the previously open one
(single-open). Desktop still renders all sections expanded.
- `public/style.css` — `@media (max-width: 640px)` rules:
  - `max-height: 200px` → `calc(100vh - 80px)`.
- `.mc-collapsed > *:not(legend) { display: none }` hides bodies of
collapsed sections.
- Legend styled as flex row with ▸/▾ indicator (colors via
`var(--text-muted)`).
- All new rules live inside the mobile media query, so desktop layout is
unchanged.

## Test

`test-issue-1329-map-controls-accordion-e2e.js` (added to CI in
`deploy.yml`):

- mobile 375x812: ≥1 accordion toggle present, ≤1 expanded by default,
no internal scroll, clicking another toggle collapses the first.
- desktop 1280x800: `position: absolute`, panel <50% viewport wide, all
controls visible.

Red commit: `85fdc25267eaf210369371f55da767016435dbff` (test fails on
master — no accordion toggles exist; all fieldsets render expanded under
the 200px cap forcing scroll).

E2E assertion added: `test-issue-1329-map-controls-accordion-e2e.js:56`.

Fixes #1329

---------

Co-authored-by: openclaw-bot <bot@openclaw.dev>
2026-05-23 20:54:07 -07:00
Kpa-clawbot 92df28a569 fix(touch-gestures): stamp data-hash on Trace and Filter buttons (#1305) (#1332)
## Summary

Row-overlay Trace and Filter buttons silently did nothing on touch
swipes. `ensureRowOverlay` stamped `data-hash` only on the Copy button,
while `onClickAction` gates both `trace` and `filter` navigation on
`hash && ...` — so the click handler short-circuited before
`location.hash` was set. Users saw the buttons but tapping them was a
no-op.

## Fix

`public/touch-gestures.js` — in `ensureRowOverlay`, stamp `data-hash` on
all three buttons (Trace, Filter, Copy) from the same source the Copy
button already used (`row.getAttribute('data-hash') ||
row.getAttribute('data-id')`). One-line factoring of the attribute
fragment to avoid duplicating the escape logic.

Behavior after fix:
- Trace → `#/packets/<hash>`
- Filter → `#/packets?hash=<hash>`
- Copy → clipboard (unchanged)

All three match the existing branches in `onClickAction`.

## TDD

- **RED commit** (`dd90f72c`): removes the cov1/cov2 workaround in
`test-touch-gestures-coverage-e2e.js` that artificially stamped
`data-hash` on trace/filter buttons from the test harness. With this
commit alone, cov1/cov2 fail their `location.hash` assertions because
`onClickAction`'s guard short-circuits.
- **GREEN commit** (`a526c30f`): production fix in `ensureRowOverlay`.
cov1/cov2 now pass natively against the real production code path with
no harness-side stamping.

## Browser verified

Coverage E2E (`test-touch-gestures-coverage-e2e.js`) exercises the real
swipe → overlay → button-click → navigation path in headless Chromium
against the running server. cov1 asserts `location.hash ===
#/packets/<hash>`, cov2 asserts `location.hash ===
#/packets?hash=<hash>` — these assertions are the regression gate.

E2E assertion added: test-touch-gestures-coverage-e2e.js:227 (cov1
trace) and test-touch-gestures-coverage-e2e.js:259 (cov2 filter).

## Preflight

All hard gates and warnings pass.

Fixes #1305

---------

Co-authored-by: openclaw <bot@openclaw>
2026-05-23 20:54:03 -07:00
15 changed files with 994 additions and 77 deletions
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"e2e tests","message":"659 passed","color":"brightgreen"}
{"schemaVersion":1,"label":"e2e tests","message":"664 passed","color":"brightgreen"}
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"frontend coverage","message":"38.88%","color":"red"}
{"schemaVersion":1,"label":"frontend coverage","message":"37.72%","color":"red"}
+2
View File
@@ -105,6 +105,7 @@ jobs:
node test-channel-fluid-layout.js
node test-issue-1279-p2-code-filter.js
node test-area-filter.js
node test-issue-1293-marker-shapes.js
- name: Verify proto syntax
run: |
@@ -283,6 +284,7 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1244-live-vcr-row-hints-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1329-map-controls-accordion-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1273-qr-overlay-height-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1281-location-row-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1279-legend-p2-e2e.js 2>&1 | tee -a e2e-output.txt
+75 -10
View File
@@ -16,6 +16,20 @@ import (
// pulse here is sufficient to keep the snapshot fresh.
const NeighborEdgesBuilderInterval = 60 * time.Second
// neighborBuilderMaxBatch caps how many observation rows a single
// delta tick may process (#1339). With max_open_conns=1, an unbounded
// scan on a multi-million-row table holds the SQLite write lock for
// minutes and starves MQTT ingest. The cap keeps each tick bounded;
// if a backlog accumulates, successive ticks drain it 50k rows at a
// time without ever blocking ingest for long.
const neighborBuilderMaxBatch = 50000
// neighborBuilderSlowTickThreshold is the per-tick wallclock budget
// for the builder. Exceeding it is logged loudly so operators can
// catch a regression of #1339 quickly. The full instrumentation
// framework is tracked in #1340.
const neighborBuilderSlowTickThreshold = 5 * time.Second
// payloadADVERT mirrors the constant in cmd/server/decoder.go.
// Duplicated rather than imported so the ingestor binary stays
// independent of the server package.
@@ -42,13 +56,25 @@ func (s *Store) StartNeighborEdgesBuilder(interval time.Duration) func() {
stop := make(chan struct{})
done := make(chan struct{})
// Synchronous warm-up: a single pass so the first server load
// after process start sees a populated table.
if n, err := s.buildAndPersistNeighborEdges(); err != nil {
log.Printf("[neighbor-build] initial build error: %v", err)
} else {
log.Printf("[neighbor-build] initial build: %d edges upserted", n)
// Synchronous warm-up: on a fresh DB this is a full scan; on a DB
// with persisted neighbor_edges (most restarts), the watermark
// short-circuits it into a delta scan. Loop until the per-tick
// batch cap stops triggering so we drain any backlog before
// returning — first server load needs a fully-populated table.
wuStart := time.Now()
var wuTotal int
for {
n, err := s.buildAndPersistNeighborEdges()
if err != nil {
log.Printf("[neighbor-build] initial build error: %v", err)
break
}
wuTotal += n
if n < neighborBuilderMaxBatch {
break
}
}
log.Printf("[neighbor-build] initial build: %d edges upserted in %s", wuTotal, time.Since(wuStart))
var stopOnce sync.Once
go func() {
@@ -58,10 +84,16 @@ func (s *Store) StartNeighborEdgesBuilder(interval time.Duration) func() {
for {
select {
case <-t.C:
if n, err := s.buildAndPersistNeighborEdges(); err != nil {
log.Printf("[neighbor-build] tick error: %v", err)
start := time.Now()
n, err := s.buildAndPersistNeighborEdges()
dur := time.Since(start)
if err != nil {
log.Printf("[neighbor-build] tick error after %s: %v", dur, err)
} else if n > 0 {
log.Printf("[neighbor-build] %d edges upserted", n)
log.Printf("[neighbor-build] tick: %d edges in %s (delta from watermark)", n, dur)
}
if dur > neighborBuilderSlowTickThreshold {
log.Printf("[neighbor-build] SLOW tick: %s — possible regression of #1339", dur)
}
case <-stop:
return
@@ -83,6 +115,21 @@ func (s *Store) StartNeighborEdgesBuilder(interval time.Duration) func() {
// observer↔last-hop on all packet types) and upserts them into
// neighbor_edges. Returns count of attempted upserts.
//
// Watermark / delta semantics (#1339): the builder derives a watermark
// from MAX(neighbor_edges.last_seen). On an empty edges table (fresh
// DB), watermark is 0 and the builder does a full warm-up scan. On
// every subsequent call, the SELECT is restricted to observations
// whose timestamp is strictly greater than the watermark, bounded by
// neighborBuilderMaxBatch. neighbor_edges itself is the persistence —
// no metadata table or in-memory state is required, and restarts
// resume cleanly from whatever the table reflects.
//
// Trade-off (documented for #1340 follow-up): an anomalously-old
// observation that arrives AFTER its timestamp has already been
// crossed by the watermark will be skipped. Acceptable for an
// approximate neighbor graph; a periodic full-rebuild can be added
// later if needed.
//
// Resolution of hop-prefix → full pubkey is done via a one-shot
// SELECT of (lowered) pubkey prefixes from nodes. Prefixes with
// multiple candidates are skipped (matches the conservative
@@ -93,6 +140,21 @@ func (s *Store) buildAndPersistNeighborEdges() (int, error) {
return 0, fmt.Errorf("build prefix index: %w", err)
}
// Derive the watermark from the existing edges table. RFC3339
// → epoch seconds so it can be compared against observations.timestamp
// (stored as INTEGER unix epoch). On an empty edges table both the
// query and the parse return zero → full warm-up scan.
var watermarkRFC sql.NullString
if err := s.db.QueryRow(`SELECT MAX(last_seen) FROM neighbor_edges`).Scan(&watermarkRFC); err != nil {
return 0, fmt.Errorf("read watermark: %w", err)
}
var watermarkEpoch int64
if watermarkRFC.Valid && watermarkRFC.String != "" {
if t, parseErr := time.Parse(time.RFC3339, watermarkRFC.String); parseErr == nil {
watermarkEpoch = t.Unix()
}
}
rows, err := s.db.Query(`SELECT
t.payload_type,
t.decoded_json,
@@ -102,7 +164,10 @@ func (s *Store) buildAndPersistNeighborEdges() (int, error) {
o.timestamp
FROM observations o
JOIN transmissions t ON t.id = o.transmission_id
LEFT JOIN observers obs ON obs.rowid = o.observer_idx`)
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
WHERE o.timestamp > ?
ORDER BY o.timestamp
LIMIT ?`, watermarkEpoch, neighborBuilderMaxBatch)
if err != nil {
return 0, fmt.Errorf("scan observations: %w", err)
}
+195
View File
@@ -0,0 +1,195 @@
package main
import (
"fmt"
"path/filepath"
"testing"
"time"
)
// TestNeighborEdgesBuilderDeltaScan enforces issue #1339:
// after the initial (warm-up) full build, subsequent ticks of
// buildAndPersistNeighborEdges MUST scan only observations newer
// than the most recent edge already persisted. The watermark is
// derived from MAX(neighbor_edges.last_seen) — neighbor_edges itself
// is the persistence, no separate metadata table.
//
// RED expectations:
// 1. After warm-up that produces edges, a second build with NO new
// observations is a fast no-op (<1s) and writes nothing.
// 2. After inserting K observations with timestamps strictly newer
// than the prior MAX(last_seen), the next build upserts exactly
// K edges in <1s.
// 3. Initial build (empty neighbor_edges) still does a full scan
// (warm-up preserved).
func TestNeighborEdgesBuilderDeltaScan(t *testing.T) {
if testing.Short() {
t.Skip("synthetic 100k-row benchmark; skipped in -short")
}
dir := t.TempDir()
dbPath := filepath.Join(dir, "delta.db")
store, err := OpenStore(dbPath)
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
if _, err := store.db.Exec(
`INSERT INTO nodes (public_key, name) VALUES (?, ?), (?, ?)`,
"aaaaaaaaaa", "from-node",
"bbbbbbbbbb", "first-hop",
); err != nil {
t.Fatal(err)
}
if _, err := store.db.Exec(
`INSERT INTO observers (id, name) VALUES (?, ?)`,
"obs-1", "observer-1",
); err != nil {
t.Fatal(err)
}
var obsRowid int64
if err := store.db.QueryRow(`SELECT rowid FROM observers WHERE id = ?`, "obs-1").Scan(&obsRowid); err != nil {
t.Fatal(err)
}
// Baseline timestamps: a contiguous block ending at baselineMaxTs.
const baseline = 100_000
const baselineStartTs int64 = 1735689600 // 2025-01-01 UTC
baselineMaxTs := baselineStartTs + int64(baseline) - 1
tx, err := store.db.Begin()
if err != nil {
t.Fatal(err)
}
txStmt, err := tx.Prepare(`INSERT INTO transmissions
(raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, from_pubkey)
VALUES ('', ?, ?, 0, ?, 0, '{}', 'aaaaaaaaaa')`)
if err != nil {
t.Fatal(err)
}
obsStmt, err := tx.Prepare(`INSERT INTO observations
(transmission_id, observer_idx, path_json, timestamp) VALUES (?, ?, '["bb"]', ?)`)
if err != nil {
t.Fatal(err)
}
for i := 0; i < baseline; i++ {
res, err := txStmt.Exec(fmt.Sprintf("h%d", i), baselineStartTs+int64(i), payloadADVERT)
if err != nil {
t.Fatal(err)
}
txID, _ := res.LastInsertId()
if _, err := obsStmt.Exec(txID, obsRowid, baselineStartTs+int64(i)); err != nil {
t.Fatal(err)
}
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
// Initial warm-up: drain to completion (StartNeighborEdgesBuilder
// does the same — call directly so the test doesn't depend on the
// goroutine harness). Full scan allowed because neighbor_edges
// starts empty.
for {
n, err := store.buildAndPersistNeighborEdges()
if err != nil {
t.Fatalf("warm-up build: %v", err)
}
if n == 0 || n < 50000 {
break
}
}
var edgesAfterWarmup int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM neighbor_edges`).Scan(&edgesAfterWarmup); err != nil {
t.Fatal(err)
}
if edgesAfterWarmup == 0 {
t.Fatal("warm-up produced 0 edges; can't establish a watermark")
}
// Sanity: MAX(last_seen) should reflect the baseline tail timestamp.
var maxLastSeen string
if err := store.db.QueryRow(`SELECT MAX(last_seen) FROM neighbor_edges`).Scan(&maxLastSeen); err != nil {
t.Fatal(err)
}
wantMax := time.Unix(baselineMaxTs, 0).UTC().Format(time.RFC3339)
if maxLastSeen != wantMax {
t.Fatalf("MAX(last_seen) after warm-up: want %s, got %s", wantMax, maxLastSeen)
}
// Tick #2: NO new observations. Expect no-op + fast.
noopStart := time.Now()
n2, err := store.buildAndPersistNeighborEdges()
if err != nil {
t.Fatalf("noop build: %v", err)
}
noopDur := time.Since(noopStart)
if n2 != 0 {
t.Fatalf("expected 0 edges on empty-delta tick; got %d (#1339)", n2)
}
if noopDur > time.Second {
t.Fatalf("empty-delta build took %v; expected <1s — builder is "+
"still doing a full table scan. (#1339)", noopDur)
}
// Tick #3: insert K observations with timestamps strictly newer
// than baselineMaxTs.
const delta = 100
deltaStartTs := baselineMaxTs + 1
tx2, err := store.db.Begin()
if err != nil {
t.Fatal(err)
}
txStmt2, err := tx2.Prepare(`INSERT INTO transmissions
(raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, from_pubkey)
VALUES ('', ?, ?, 0, ?, 0, '{}', 'aaaaaaaaaa')`)
if err != nil {
t.Fatal(err)
}
obsStmt2, err := tx2.Prepare(`INSERT INTO observations
(transmission_id, observer_idx, path_json, timestamp) VALUES (?, ?, '["bb"]', ?)`)
if err != nil {
t.Fatal(err)
}
for i := 0; i < delta; i++ {
res, err := txStmt2.Exec(fmt.Sprintf("d%d", i), deltaStartTs+int64(i), payloadADVERT)
if err != nil {
t.Fatal(err)
}
txID, _ := res.LastInsertId()
if _, err := obsStmt2.Exec(txID, obsRowid, deltaStartTs+int64(i)); err != nil {
t.Fatal(err)
}
}
if err := tx2.Commit(); err != nil {
t.Fatal(err)
}
deltaStart := time.Now()
n3, err := store.buildAndPersistNeighborEdges()
if err != nil {
t.Fatalf("delta build: %v", err)
}
deltaDur := time.Since(deltaStart)
// Each ADVERT observation with a non-empty path produces 2 edge
// candidates (from↔hop[0] and observer↔hop[-1]). The watermark
// must clamp the scan to the delta rows ONLY — anything more
// proves the WHERE clause was bypassed.
if n3 != delta*2 {
t.Fatalf("expected %d edges upserted (delta only, 2 per advert obs); got %d. "+
"Builder must only scan observations with timestamp > MAX(neighbor_edges.last_seen). (#1339)",
delta*2, n3)
}
if deltaDur > 500*time.Millisecond {
t.Fatalf("delta build of %d rows took %v; expected <500ms. (#1339)", delta, deltaDur)
}
// Sanity: MAX(last_seen) advanced.
var maxLastSeen2 string
if err := store.db.QueryRow(`SELECT MAX(last_seen) FROM neighbor_edges`).Scan(&maxLastSeen2); err != nil {
t.Fatal(err)
}
if maxLastSeen2 <= maxLastSeen {
t.Fatalf("MAX(last_seen) did not advance: was %s, now %s", maxLastSeen, maxLastSeen2)
}
}
+12
View File
@@ -351,6 +351,18 @@
box-shadow: 0 0 4px currentColor;
}
/* #1293 — SVG shape-aware legend swatch (replaces the flat colour dot).
* Inline-block wrapper keeps SVG aligned with adjacent text labels. */
.live-shape-swatch {
display: inline-block;
width: 14px;
height: 14px;
margin-right: 6px;
vertical-align: middle;
line-height: 0;
}
.live-shape-swatch svg { display: block; }
/* #1274: marker-style swatches — mirror the live map circleMarker ring
* convention (bright white ring = repeater, faded ring = other roles).
* Background uses --role-repeater / --text-muted via CSS variables so
+118 -39
View File
@@ -727,7 +727,10 @@
if (pauseBtn) { pauseBtn.textContent = '⏸'; pauseBtn.setAttribute('aria-label', 'Pause'); }
if (missedEl) missedEl.classList.add('hidden');
}
if (speedBtn) { speedBtn.textContent = speedLabel(VCR.speed); speedBtn.setAttribute('aria-label', 'Speed ' + speedLabel(VCR.speed)); }
if (speedBtn) {
speedBtn.textContent = speedLabel(VCR.speed);
speedBtn.setAttribute('aria-label', 'Speed ' + speedLabel(VCR.speed));
}
updateVCRLcd();
}
@@ -1712,7 +1715,13 @@
if (roleLegendList) {
for (const role of (window.ROLE_SORT || ['repeater', 'companion', 'room', 'sensor', 'observer'])) {
const li = document.createElement('li');
li.innerHTML = `<span class="live-dot" style="background:${ROLE_COLORS[role] || '#6b7280'}" aria-hidden="true"></span> ${(ROLE_LABELS[role] || role).replace(/s$/, '')}`;
// #1293 — SVG swatch shows SHAPE + colour so colourblind ops can
// distinguish roles without relying on hue alone (WCAG 1.4.1).
const color = ROLE_COLORS[role] || '#6b7280';
const swatch = window.makeRoleMarkerSVG
? window.makeRoleMarkerSVG(role, color, 14)
: `<span class="live-dot" style="background:${color}" aria-hidden="true"></span>`;
li.innerHTML = `<span class="live-shape-swatch" aria-hidden="true">${swatch}</span> ${(ROLE_LABELS[role] || role).replace(/s$/, '')}`;
roleLegendList.appendChild(li);
}
}
@@ -2358,49 +2367,115 @@
const isRepeater = n.role === 'repeater';
const zoom = map ? map.getZoom() : 11;
const zoomScale = Math.max(0.4, (zoom - 8) / 6);
const size = Math.round((isRepeater ? 6 : 4) * zoomScale);
// Shape-aware sizing: keep prior visual weight (~6/4 base) but
// route through divIcon so colourblind ops get distinct silhouettes
// (#1293). Size is the SVG box; circleMarker radius ~= size/3.
const sizePx = Math.max(10, Math.round((isRepeater ? 18 : 14) * zoomScale));
const glow = L.circleMarker([n.lat, n.lon], {
radius: size + 4, fillColor: color, fillOpacity: 0.12, stroke: false, interactive: false
}).addTo(nodesLayer);
const svgHtml = (window.makeRoleMarkerSVG
? window.makeRoleMarkerSVG(n.role, color, sizePx)
: '<svg width="' + sizePx + '" height="' + sizePx + '" viewBox="0 0 ' + sizePx + ' ' + sizePx +
'"><circle cx="' + (sizePx/2) + '" cy="' + (sizePx/2) + '" r="' + (sizePx/2 - 2) +
'" fill="' + color + '" stroke="#fff" stroke-width="2"/></svg>');
const marker = L.circleMarker([n.lat, n.lon], {
radius: size, fillColor: color, fillOpacity: 0.85,
color: '#fff', weight: isRepeater ? 1.5 : 0.5, opacity: isRepeater ? 0.6 : 0.3
const icon = L.divIcon({
html: svgHtml,
className: 'live-node-marker live-node-' + (n.role || 'unknown'),
iconSize: [sizePx, sizePx],
iconAnchor: [sizePx / 2, sizePx / 2],
popupAnchor: [0, -sizePx / 2]
});
const marker = L.marker([n.lat, n.lon], { icon: icon, interactive: true }).addTo(nodesLayer);
// Highlight ring (#1293): a separate stroke-only circleMarker layered
// BENEATH the shape. Hidden by default; pulseNodeMarker grows/fades
// its radius + opacity — never fills, so same-hue concentric stacking
// (issue's "blue-on-blue") is impossible.
const ringPos = [n.lat, n.lon];
const ring = L.circleMarker(ringPos, {
radius: sizePx / 2 + 4,
fillOpacity: 0,
fill: false,
color: color,
weight: 0,
opacity: 0,
interactive: false
}).addTo(nodesLayer);
marker.bindTooltip(n.name || n.public_key.slice(0, 8), {
permanent: false, direction: 'top', offset: [0, -10], className: 'live-tooltip'
permanent: false, direction: 'top', offset: [0, -sizePx / 2], className: 'live-tooltip'
});
marker.on('click', () => showNodeDetail(n.public_key));
marker._glowMarker = glow;
marker._highlightRing = ring;
marker._baseColor = color;
marker._baseSize = size;
marker._baseSize = sizePx;
marker._role = n.role || 'unknown';
nodeMarkers[n.public_key] = marker;
// Apply matrix tint if active
// Apply matrix tint if active — re-render the SVG with matrix colour
if (matrixMode) {
marker._matrixPrevColor = color;
marker._baseColor = '#008a22';
marker.setStyle({ fillColor: '#008a22', color: '#008a22', fillOpacity: 0.5, opacity: 0.5 });
glow.setStyle({ fillColor: '#008a22', fillOpacity: 0.15 });
const mxHtml = window.makeRoleMarkerSVG
? window.makeRoleMarkerSVG(marker._role, '#008a22', sizePx)
: svgHtml;
const el = marker.getElement();
if (el) el.innerHTML = mxHtml;
}
return marker;
}
// #1293 — divIcon helpers. The live-map node marker is now an
// L.marker (divIcon SVG), not an L.circleMarker, so setStyle /
// setRadius are no-ops. These helpers update the DOM element
// directly so existing call-sites (rescale, stale-dim, matrix mode,
// highlight pulse) keep working without same-colour fill stacking.
function _liveMarkerEl(marker) {
if (!marker || typeof marker.getElement !== 'function') return null;
return marker.getElement();
}
function _liveSetMarkerOpacity(marker, opacity) {
var el = _liveMarkerEl(marker);
if (el) el.style.opacity = String(opacity);
}
function _liveSetMarkerSize(marker, sizePx) {
var el = _liveMarkerEl(marker);
if (!el) return;
var svg = el.querySelector('svg');
if (svg) {
svg.setAttribute('width', sizePx);
svg.setAttribute('height', sizePx);
}
marker._baseSize = sizePx;
if (marker._highlightRing && typeof marker._highlightRing.setRadius === 'function') {
marker._highlightRing.setRadius(sizePx / 2 + 4);
}
}
function _liveSetMarkerColor(marker, color) {
var el = _liveMarkerEl(marker);
if (!el) return;
if (window.makeRoleMarkerSVG) {
el.innerHTML = window.makeRoleMarkerSVG(marker._role || 'unknown', color, marker._baseSize || 14);
} else {
// Fallback: tweak fill on first shape
var shape = el.querySelector('svg > *');
if (shape) shape.setAttribute('fill', color);
}
}
window._liveSetMarkerSize = _liveSetMarkerSize;
window._liveSetMarkerColor = _liveSetMarkerColor;
function rescaleMarkers() {
const zoom = map.getZoom();
const zoomScale = Math.max(0.4, (zoom - 8) / 6);
for (const [key, marker] of Object.entries(nodeMarkers)) {
const n = nodeData[key];
const isRepeater = n && n.role === 'repeater';
const size = Math.round((isRepeater ? 6 : 4) * zoomScale);
marker.setRadius(size);
marker._baseSize = size;
if (marker._glowMarker) marker._glowMarker.setRadius(size + 4);
const sizePx = Math.max(10, Math.round((isRepeater ? 18 : 14) * zoomScale));
_liveSetMarkerSize(marker, sizePx);
}
}
@@ -2422,15 +2497,14 @@
// API-loaded nodes: dim instead of removing (consistent with static map)
if (marker && !marker._staleDimmed) {
marker._staleDimmed = true;
marker.setStyle({ fillOpacity: 0.25, opacity: 0.15 });
if (marker._glowMarker) marker._glowMarker.setStyle({ fillOpacity: 0.04 });
_liveSetMarkerOpacity(marker, 0.35);
}
} else {
// WS-only nodes: remove to prevent unbounded memory growth
if (marker) {
if (nodesLayer) {
try { nodesLayer.removeLayer(marker); } catch (e) {}
if (marker._glowMarker) try { nodesLayer.removeLayer(marker._glowMarker); } catch (e) {}
if (marker._highlightRing) try { nodesLayer.removeLayer(marker._highlightRing); } catch (e) {}
}
}
delete nodeMarkers[key];
@@ -2441,9 +2515,7 @@
} else if (marker && marker._staleDimmed) {
// Node became active again — restore full opacity
marker._staleDimmed = false;
var isRepeater = n.role === 'repeater';
marker.setStyle({ fillOpacity: 0.85, opacity: isRepeater ? 0.6 : 0.3 });
if (marker._glowMarker) marker._glowMarker.setStyle({ fillOpacity: 0.12 });
_liveSetMarkerOpacity(marker, 1);
}
}
if (pruned) {
@@ -2948,17 +3020,26 @@
requestAnimationFrame(animatePulse);
const baseColor = marker._baseColor || '#6b7280';
const baseSize = marker._baseSize || 6;
marker.setStyle({ fillColor: '#fff', fillOpacity: 1, radius: baseSize + 2, color: color, weight: 2 });
const baseSize = marker._baseSize || 14;
if (marker._glowMarker) {
marker._glowMarker.setStyle({ fillColor: color, fillOpacity: 0.2, radius: baseSize + 6 });
setTimeout(() => marker._glowMarker.setStyle({ fillColor: baseColor, fillOpacity: 0.08, radius: baseSize + 3 }), 500);
// #1293 — highlight via OUTLINE ring (no same-colour concentric
// fill). Use the marker's pre-allocated _highlightRing; grow + fade
// it. Marker shape/colour is left untouched so colourblind silhouette
// stays distinguishable during the pulse.
const ringHl = marker._highlightRing;
if (ringHl && typeof ringHl.setStyle === 'function') {
try {
ringHl.setStyle({ color: color, weight: 3, opacity: 0.95, fillOpacity: 0, fill: false });
ringHl.setRadius(baseSize / 2 + 4);
setTimeout(() => {
try { ringHl.setStyle({ opacity: 0.4, weight: 2 }); ringHl.setRadius(baseSize / 2 + 8); } catch (e) {}
}, 200);
setTimeout(() => {
try { ringHl.setStyle({ opacity: 0, weight: 0 }); } catch (e) {}
}, 700);
} catch (e) { /* circleMarker absent — ignore */ }
}
setTimeout(() => marker.setStyle({ fillColor: color, fillOpacity: 0.95, radius: baseSize + 1, weight: 1.5 }), 150);
setTimeout(() => marker.setStyle({ fillColor: baseColor, fillOpacity: 0.85, radius: baseSize, color: '#fff', weight: marker._baseSize > 6 ? 1.5 : 0.5 }), 700);
nodeActivity[key] = (nodeActivity[key] || 0) + 1;
}
@@ -3112,8 +3193,7 @@
for (const [key, marker] of Object.entries(nodeMarkers)) {
marker._matrixPrevColor = marker._baseColor;
marker._baseColor = '#008a22';
marker.setStyle({ fillColor: '#008a22', color: '#008a22', fillOpacity: 0.5, opacity: 0.5 });
if (marker._glowMarker) marker._glowMarker.setStyle({ fillColor: '#008a22', fillOpacity: 0.15 });
_liveSetMarkerColor(marker, '#008a22');
}
} else {
container.classList.remove('matrix-theme');
@@ -3134,8 +3214,7 @@
for (const [key, marker] of Object.entries(nodeMarkers)) {
if (marker._matrixPrevColor) {
marker._baseColor = marker._matrixPrevColor;
marker.setStyle({ fillColor: marker._matrixPrevColor, color: '#fff', fillOpacity: 0.85, opacity: 1 });
if (marker._glowMarker) marker._glowMarker.setStyle({ fillColor: marker._matrixPrevColor });
_liveSetMarkerColor(marker, marker._matrixPrevColor);
delete marker._matrixPrevColor;
}
}
@@ -3155,7 +3234,7 @@
const matrixGreen = '#00ff41';
const TRAIL_LEN = Math.min(6, bytes.length);
const DURATION_MS = 1100 / VCR.speed;
const DURATION_MS = 1100 / VCR.speed; // #922: animation honors VCR.speed
const CHAR_INTERVAL = 0.06; // spawn a char every 6% of progress
const charMarkers = [];
let nextCharAt = CHAR_INTERVAL;
@@ -3287,7 +3366,7 @@
return;
}
const elapsed = now - lastStep;
const stepMs = 33 / VCR.speed;
const stepMs = 33 / VCR.speed; // #922: animation honors VCR.speed
if (elapsed >= stepMs) {
const ticks = Math.min(Math.floor(elapsed / stepMs), 4);
lastStep = now;
+54
View File
@@ -44,6 +44,18 @@
case 'triangle':
path = `<polygon points="${c},2 ${size-2},${size-2} 2,${size-2}" fill="${fillColor}" stroke="#fff" stroke-width="2"/>`;
break;
case 'hexagon': {
// #1293 — pointy-top hexagon for room servers
const hr = c - 1.5;
let hpts = '';
for (let hi = 0; hi < 6; hi++) {
const ha = (hi * 60 - 90) * Math.PI / 180;
hpts += (c + hr * Math.cos(ha)).toFixed(2) + ',' +
(c + hr * Math.sin(ha)).toFixed(2) + ' ';
}
path = `<polygon points="${hpts.trim()}" fill="${fillColor}" stroke="#fff" stroke-width="2"/>`;
break;
}
case 'star': {
// 5-pointed star
const cx = c, cy = c, outer = c - 1, inner = outer * 0.4;
@@ -262,6 +274,48 @@
toggleBtn.setAttribute('aria-expanded', String(!controlsCollapsed));
});
// #1329: Map controls accordion. Make each section's legend a button-
// style toggle with aria-expanded. On mobile (≤640px) only one section
// is open at a time so the panel never needs internal scrolling. On
// desktop the .mc-collapsed class has no visual effect (CSS only hides
// section bodies inside the mobile media query) so all controls stay
// visible — but single-open behaviour is still tracked for state
// consistency. See test-issue-1329-map-controls-accordion-e2e.js.
(function initMapControlsAccordion() {
const isMobile = window.innerWidth <= 640;
const sections = Array.from(controlsPanel.querySelectorAll('fieldset.mc-section'));
sections.forEach((fs, idx) => {
const legend = fs.querySelector('legend.mc-label');
if (!legend) return;
// Initial state: on mobile only the first section is open; on
// desktop all sections are open.
const open = !isMobile || idx === 0;
legend.setAttribute('role', 'button');
legend.setAttribute('tabindex', '0');
legend.setAttribute('aria-expanded', String(open));
fs.classList.toggle('mc-collapsed', !open);
const setOpen = (target, openNow) => {
target.setAttribute('aria-expanded', String(openNow));
const parent = target.closest('fieldset.mc-section');
if (parent) parent.classList.toggle('mc-collapsed', !openNow);
};
const onActivate = (e) => {
e.preventDefault();
const currentlyOpen = legend.getAttribute('aria-expanded') === 'true';
// Single-open: close every other section first.
sections.forEach(other => {
const otherLegend = other.querySelector('legend.mc-label');
if (otherLegend && otherLegend !== legend) setOpen(otherLegend, false);
});
setOpen(legend, !currentlyOpen);
};
legend.addEventListener('click', onActivate);
legend.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') onActivate(e);
});
});
})();
// Bind controls
var clustersEl = document.getElementById('mcClusters');
if (clustersEl) {
+86 -7
View File
@@ -55,16 +55,95 @@
sensor: 'Sensors', observer: 'Observers'
};
window.ROLE_STYLE = {
repeater: { color: '#dc2626', shape: 'diamond', radius: 10, weight: 2 },
companion: { color: '#2563eb', shape: 'circle', radius: 8, weight: 2 },
room: { color: '#16a34a', shape: 'square', radius: 9, weight: 2 },
sensor: { color: '#d97706', shape: 'triangle', radius: 8, weight: 2 },
observer: { color: '#8b5cf6', shape: 'star', radius: 11, weight: 2 }
// #1293 — Marker shape per role (WCAG 1.4.1 — shape, not only colour).
// Single source of truth; ROLE_STYLE.shape is derived from this map.
window.ROLE_SHAPES = {
repeater: 'circle',
companion: 'square',
room: 'hexagon',
sensor: 'triangle',
observer: 'diamond'
};
window.ROLE_STYLE = {
repeater: { color: '#dc2626', shape: 'circle', radius: 8, weight: 2 },
companion: { color: '#2563eb', shape: 'square', radius: 8, weight: 2 },
room: { color: '#16a34a', shape: 'hexagon', radius: 9, weight: 2 },
sensor: { color: '#d97706', shape: 'triangle', radius: 8, weight: 2 },
observer: { color: '#8b5cf6', shape: 'diamond', radius: 9, weight: 2 }
};
// Glyphs mirror the ROLE_SHAPES (used in tooltips, legends, lists).
window.ROLE_EMOJI = {
repeater: '', companion: '', room: '', sensor: '▲', observer: ''
repeater: '', companion: '', room: '', sensor: '▲', observer: ''
};
/**
* #1293 Shared SVG marker generator. Returns a self-contained
* <svg>...</svg> string for the given role/colour/size, with white
* stroke for contrast (works on both dark + light tiles). Used by:
* - public/live.js addNodeMarker (L.divIcon)
* - public/live.js role legend swatches
* - public/map.js makeMarkerIcon (legacy switch retained for
* per-role overrides + observer star overlay)
*
* Reads ROLE_SHAPES for the role's geometry; falls back to circle.
* Caller controls colour to allow theming overrides (matrix mode,
* stale dim, etc.) without rebuilding the marker.
*/
window.makeRoleMarkerSVG = function (role, color, size) {
var shape = (window.ROLE_SHAPES && window.ROLE_SHAPES[role]) || 'circle';
size = size || 16;
var c = size / 2;
var fill = color || (window.ROLE_COLORS && window.ROLE_COLORS[role]) || '#6b7280';
var path;
switch (shape) {
case 'square':
path = '<rect x="3" y="3" width="' + (size - 6) + '" height="' + (size - 6) +
'" fill="' + fill + '" stroke="#fff" stroke-width="2"/>';
break;
case 'triangle':
path = '<polygon points="' + c + ',2 ' + (size - 2) + ',' + (size - 2) +
' 2,' + (size - 2) + '" fill="' + fill + '" stroke="#fff" stroke-width="2"/>';
break;
case 'diamond':
path = '<polygon points="' + c + ',2 ' + (size - 2) + ',' + c + ' ' +
c + ',' + (size - 2) + ' 2,' + c +
'" fill="' + fill + '" stroke="#fff" stroke-width="2"/>';
break;
case 'hexagon': {
// Pointy-top hexagon centred at (c,c), inscribed radius ≈ c-1.5
var r = c - 1.5;
var pts = '';
for (var i = 0; i < 6; i++) {
var a = (i * 60 - 90) * Math.PI / 180;
pts += (c + r * Math.cos(a)).toFixed(2) + ',' +
(c + r * Math.sin(a)).toFixed(2) + ' ';
}
path = '<polygon points="' + pts.trim() + '" fill="' + fill +
'" stroke="#fff" stroke-width="2"/>';
break;
}
case 'star': {
var cx = c, cy = c, outer = c - 1, inner = outer * 0.4;
var spts = '';
for (var j = 0; j < 5; j++) {
var aO = (j * 72 - 90) * Math.PI / 180;
var aI = ((j * 72) + 36 - 90) * Math.PI / 180;
spts += (cx + outer * Math.cos(aO)) + ',' + (cy + outer * Math.sin(aO)) + ' ';
spts += (cx + inner * Math.cos(aI)) + ',' + (cy + inner * Math.sin(aI)) + ' ';
}
path = '<polygon points="' + spts.trim() + '" fill="' + fill +
'" stroke="#fff" stroke-width="1.5"/>';
break;
}
default: // circle
path = '<circle cx="' + c + '" cy="' + c + '" r="' + (c - 2) +
'" fill="' + fill + '" stroke="#fff" stroke-width="2"/>';
}
return '<svg width="' + size + '" height="' + size +
'" viewBox="0 0 ' + size + ' ' + size +
'" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' + path + '</svg>';
};
window.ROLE_SORT = ['repeater', 'companion', 'room', 'sensor', 'observer'];
+28 -2
View File
@@ -1833,8 +1833,34 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
.search-box { width: 95vw; }
.search-overlay { padding-top: 60px; }
/* Map controls */
.map-controls { width: calc(100vw - 24px); right: 12px; top: 8px; max-height: 200px; font-size: 12px; padding: 10px 12px; }
/* Map controls #1329: drop fixed 200px cap, use accordion sections
instead so visible content fits without internal scrolling. Panel can
grow to fill available height; max-height bound by viewport so it
never escapes the screen. */
.map-controls { width: calc(100vw - 24px); right: 12px; top: 8px; max-height: calc(100vh - 80px); font-size: 12px; padding: 10px 12px; }
/* On mobile, hide collapsed section bodies (everything inside the
fieldset except the legend). The legend remains tappable to expand. */
.map-controls fieldset.mc-section.mc-collapsed > *:not(legend) { display: none; }
.map-controls fieldset.mc-section > legend.mc-label {
cursor: pointer;
user-select: none;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0;
}
/* ▸ / ▾ indicator via ::after so we don't touch markup */
.map-controls fieldset.mc-section > legend.mc-label::after {
content: '▾';
font-size: 10px;
color: var(--text-muted);
margin-left: 8px;
transition: transform 0.15s;
}
.map-controls fieldset.mc-section.mc-collapsed > legend.mc-label::after {
content: '▸';
}
#leaflet-map { z-index: 0; }
#map-wrap { z-index: 0; }
+4 -4
View File
@@ -157,11 +157,11 @@
o.setAttribute('role', 'group');
o.setAttribute('aria-label', 'Row actions');
var hash = row.getAttribute('data-hash') || row.getAttribute('data-id') || '';
var hashAttr = ' data-hash="' + String(hash).replace(/"/g, '&quot;') + '"';
o.innerHTML =
'<button type="button" class="row-action-btn" data-row-action="trace">Trace</button>' +
'<button type="button" class="row-action-btn" data-row-action="filter">Filter</button>' +
'<button type="button" class="row-action-btn" data-row-action="copy" data-hash="' +
String(hash).replace(/"/g, '&quot;') + '">Copy hash</button>';
'<button type="button" class="row-action-btn" data-row-action="trace"' + hashAttr + '>Trace</button>' +
'<button type="button" class="row-action-btn" data-row-action="filter"' + hashAttr + '>Filter</button>' +
'<button type="button" class="row-action-btn" data-row-action="copy"' + hashAttr + '>Copy hash</button>';
document.body.appendChild(o);
rowOverlay = o;
return o;
+126
View File
@@ -0,0 +1,126 @@
/**
* #1293 Marker shape variation per role + colorblind-safe palette.
*
* Acceptance:
* - ROLE_SHAPES map exposed by roles.js, with repeater=circle,
* companion=square, room=hexagon, sensor=triangle, observer=diamond.
* - ROLE_STYLE.shape values match ROLE_SHAPES (single source of truth).
* - A shared helper `window.makeRoleMarkerSVG(role, color, size)` exists
* and can produce a hexagon path for the room role (covers the
* previously-missing shape in map.js's switch).
* - public/live.js uses `L.divIcon` (shape-aware) for node markers,
* NOT the legacy `L.circleMarker` in `addNodeMarker`.
* - public/live.js legend renders SVG marker swatches (not flat dots) so
* colorblind users can distinguish shape, not only colour.
* - public/map.js switch handles `case 'hexagon'`.
* - Selected/highlighted state uses an outline RING (no same-colour
* filled overlay) i.e. the highlight path sets fillOpacity:0
* (or 'transparent') and uses a stroke-based ring helper.
*
* Pure-string assertions; no DOM/browser required so this can land
* in the JS-unit-tests step of the CI workflow (fast red).
*/
'use strict';
const fs = require('fs');
const path = require('path');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
const rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8');
const liveSrc = fs.readFileSync(path.join(__dirname, 'public', 'live.js'), 'utf8');
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
console.log('\n=== #1293: ROLE_SHAPES single source of truth ===');
// ROLE_SHAPES map declared on window
assert(/window\.ROLE_SHAPES\s*=\s*\{/.test(rolesSrc),
'roles.js declares window.ROLE_SHAPES map');
// Required role → shape pairings (line-order independent)
const shapeBlockMatch = rolesSrc.match(/window\.ROLE_SHAPES\s*=\s*\{([\s\S]*?)\};/);
const shapeBlock = shapeBlockMatch ? shapeBlockMatch[1] : '';
const expectedShapes = {
repeater: 'circle',
companion: 'square',
room: 'hexagon',
sensor: 'triangle',
observer: 'diamond',
};
for (const role of Object.keys(expectedShapes)) {
const re = new RegExp(role + '\\s*:\\s*[\'\"]' + expectedShapes[role] + '[\'\"]');
assert(re.test(shapeBlock), `ROLE_SHAPES.${role} === '${expectedShapes[role]}'`);
}
// ROLE_STYLE shape values match the new map
const styleBlockMatch = rolesSrc.match(/window\.ROLE_STYLE\s*=\s*\{([\s\S]*?)\};/);
const styleBlock = styleBlockMatch ? styleBlockMatch[1] : '';
for (const role of Object.keys(expectedShapes)) {
// crude per-line check
const lineRe = new RegExp(role + '\\s*:[^}]*shape:\\s*[\'\"]' + expectedShapes[role] + '[\'\"]');
assert(lineRe.test(styleBlock),
`ROLE_STYLE.${role}.shape === '${expectedShapes[role]}' (matches ROLE_SHAPES)`);
}
console.log('\n=== #1293: shared SVG helper covers hexagon ===');
assert(/window\.makeRoleMarkerSVG\s*=\s*function/.test(rolesSrc),
'roles.js exposes window.makeRoleMarkerSVG(role, color, size)');
// Helper string must include a hexagon branch (matches map.js switch)
const helperMatch = rolesSrc.match(/window\.makeRoleMarkerSVG[\s\S]*?\n\s*\};/);
const helperBlock = helperMatch ? helperMatch[0] : '';
assert(/case\s+['\"]hexagon['\"]/.test(helperBlock),
'helper handles case "hexagon" (room role)');
assert(/case\s+['\"]square['\"]/.test(helperBlock),
'helper handles case "square"');
assert(/case\s+['\"]triangle['\"]/.test(helperBlock),
'helper handles case "triangle"');
assert(/case\s+['\"]diamond['\"]/.test(helperBlock),
'helper handles case "diamond"');
console.log('\n=== #1293: map.js switch handles hexagon ===');
assert(/case\s+['\"]hexagon['\"]/.test(mapSrc),
'map.js makeMarkerIcon switch has a "hexagon" branch');
console.log('\n=== #1293: live.js node markers use shape-aware divIcons ===');
// Carve out addNodeMarker body (best-effort) and assert it uses divIcon.
const addNodeIdx = liveSrc.indexOf('function addNodeMarker');
assert(addNodeIdx > 0, 'live.js addNodeMarker function present');
const addNodeBody = liveSrc.slice(addNodeIdx, addNodeIdx + 2500);
assert(/L\.divIcon|window\.makeRoleMarkerSVG|makeRoleMarkerSVG\s*\(/.test(addNodeBody),
'addNodeMarker uses L.divIcon / makeRoleMarkerSVG (not legacy circleMarker)');
assert(!/L\.circleMarker\(\s*\[\s*n\.lat/.test(addNodeBody),
'addNodeMarker no longer creates L.circleMarker for the node itself');
console.log('\n=== #1293: live.js legend renders shape swatches ===');
// The role legend block (id="roleLegendList") must inject SVG, not a
// flat live-dot span only.
const legendIdx = liveSrc.indexOf("getElementById('roleLegendList')");
assert(legendIdx > 0, 'live.js renders roleLegendList');
const legendBody = liveSrc.slice(legendIdx, legendIdx + 1500);
assert(/<svg|makeRoleMarkerSVG/.test(legendBody),
'roleLegendList swatches include SVG shape (not bare colour dot)');
console.log('\n=== #1293: selected/highlight uses outline ring (no same-colour fill overlay) ===');
// New behaviour: marker highlight pulse must NOT recolor marker fill to
// the same packet colour stacked over a same-coloured base. The fix
// uses a stroke ring (fillOpacity 0 / 'transparent') for the overlay.
assert(/highlightNodeRing|RingHighlight|highlightRing/.test(liveSrc) ||
/fillOpacity:\s*0[,\s}]/.test(liveSrc.slice(liveSrc.indexOf('animatePulse') || 0,
(liveSrc.indexOf('animatePulse') || 0) + 1500)),
'highlight path uses a transparent-fill ring (no same-colour concentric fill)');
console.log('\n=== Summary ===');
console.log(` Passed: ${passed}`);
console.log(` Failed: ${failed}`);
if (failed > 0) { console.error('\n#1293 FAIL'); process.exit(1); }
console.log('\n#1293 PASS');
@@ -0,0 +1,177 @@
/**
* E2E (#1329): Map controls panel on mobile must NOT be capped at 200px
* with internal scroll. Use accordion sections one expanded at a time
* so the visible content always fits without scrolling.
*
* Mobile (375x812):
* - Open Map controls.
* - Panel must have accordion sections (legend acts as toggle, with
* aria-expanded attribute).
* - Default state: at most one section expanded.
* - Panel contents must NOT require internal scroll
* (scrollHeight <= clientHeight + 1).
* - Clicking a different section's legend collapses the previously-open
* section (single-open behavior).
*
* Desktop (1280x800):
* - Existing layout unchanged: all sections visible by default,
* panel position:absolute, modest width.
*
* Run: BASE_URL=http://localhost:13581 node test-issue-1329-map-controls-accordion-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' \u2713 ' + name); }
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
async function run() {
const launchOpts = { args: ['--no-sandbox'] };
if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH;
const browser = await chromium.launch(launchOpts);
// === Mobile: 375x812 ===
const ctx = await browser.newContext({ viewport: { width: 375, height: 812 } });
const page = await ctx.newPage();
await page.goto(BASE + '/#/map', { waitUntil: 'load', timeout: 60000 });
await page.waitForSelector('#leaflet-map', { timeout: 10000 });
await page.waitForSelector('#mapControls', { state: 'attached', timeout: 10000 });
await page.waitForTimeout(500);
// Ensure controls panel is expanded (default is collapsed on mobile).
await page.evaluate(() => {
const panel = document.getElementById('mapControls');
const btn = document.getElementById('mapControlsToggle');
if (panel && panel.classList.contains('collapsed')) btn && btn.click();
});
await page.waitForTimeout(300);
await step('mobile: at least one accordion section present with aria-expanded', async () => {
const data = await page.evaluate(() => {
const panel = document.getElementById('mapControls');
// Accordion section markers: legend (or button) carrying aria-expanded
// inside a .mc-section.mc-accordion (or equivalent) descendant.
const toggles = panel.querySelectorAll('.mc-section [aria-expanded], .mc-accordion-toggle[aria-expanded]');
const sections = panel.querySelectorAll('.mc-section');
return {
toggles: toggles.length,
sections: sections.length,
expandedCount: Array.from(toggles).filter(t => t.getAttribute('aria-expanded') === 'true').length,
};
});
assert(data.toggles >= 1,
'expected ≥1 accordion toggle (aria-expanded), got ' + data.toggles +
' (sections=' + data.sections + ')');
});
await step('mobile: at most one section expanded by default', async () => {
const data = await page.evaluate(() => {
const panel = document.getElementById('mapControls');
const toggles = panel.querySelectorAll('.mc-section [aria-expanded], .mc-accordion-toggle[aria-expanded]');
return {
expandedCount: Array.from(toggles).filter(t => t.getAttribute('aria-expanded') === 'true').length,
total: toggles.length,
};
});
assert(data.expandedCount <= 1,
'expected ≤1 section expanded by default, got ' + data.expandedCount + '/' + data.total);
});
await step('mobile: panel content does NOT require internal scroll', async () => {
const data = await page.evaluate(() => {
const panel = document.getElementById('mapControls');
return {
scrollH: panel.scrollHeight,
clientH: panel.clientHeight,
overflowY: getComputedStyle(panel).overflowY,
};
});
// The accordion sections should keep content within viewport — when only
// one section is expanded, panel must not need to scroll internally.
assert(data.scrollH <= data.clientH + 1,
'panel must not require internal scroll (scrollH=' + data.scrollH +
' clientH=' + data.clientH + ')');
});
await step('mobile: clicking a 2nd toggle collapses the first (single-open)', async () => {
const result = await page.evaluate(() => {
const panel = document.getElementById('mapControls');
const toggles = Array.from(panel.querySelectorAll('.mc-section [aria-expanded], .mc-accordion-toggle[aria-expanded]'));
if (toggles.length < 2) return { skip: true, n: toggles.length };
// Find one currently closed and one open; if all closed, open first then click second.
let openIdx = toggles.findIndex(t => t.getAttribute('aria-expanded') === 'true');
if (openIdx === -1) {
toggles[0].click();
openIdx = 0;
}
const otherIdx = openIdx === 0 ? 1 : 0;
toggles[otherIdx].click();
return {
skip: false,
firstNow: toggles[openIdx].getAttribute('aria-expanded'),
otherNow: toggles[otherIdx].getAttribute('aria-expanded'),
};
});
if (result.skip) {
throw new Error('need at least 2 accordion toggles to test single-open (got ' + result.n + ')');
}
assert(result.otherNow === 'true',
'second toggle should be open after click, got ' + result.otherNow);
assert(result.firstNow === 'false',
'first toggle should auto-close (single-open), got ' + result.firstNow);
});
await ctx.close();
// === Desktop: 1280x800 ===
const ctx2 = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const p2 = await ctx2.newPage();
await p2.goto(BASE + '/#/map', { waitUntil: 'load', timeout: 60000 });
await p2.waitForSelector('#mapControls', { state: 'attached', timeout: 10000 });
await p2.waitForTimeout(300);
await step('desktop (1280px): panel position:absolute, all section contents visible', async () => {
const data = await p2.evaluate(() => {
const panel = document.getElementById('mapControls');
const cs = getComputedStyle(panel);
const rect = panel.getBoundingClientRect();
// Check that section content (e.g., labels) is visible on desktop.
const allInputs = panel.querySelectorAll('input[type=checkbox], select, button');
let visible = 0;
allInputs.forEach(el => {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.height > 0) visible++;
});
return {
position: cs.position,
width: Math.round(rect.width),
vw: window.innerWidth,
visibleControls: visible,
totalControls: allInputs.length,
};
});
assert(data.position === 'absolute',
'desktop panel must be position:absolute, got ' + data.position);
assert(data.width < data.vw * 0.5,
'desktop panel must be <50% viewport width, got ' + data.width + '/' + data.vw);
// All (or nearly all) controls should be visible on desktop — accordion
// collapse must NOT apply at desktop sizes.
assert(data.visibleControls >= data.totalControls - 2,
'desktop must show all controls (got ' + data.visibleControls + '/' + data.totalControls + ')');
});
await browser.close();
console.log('\n' + passed + '/' + (passed + failed) + ' tests passed' +
(failed ? ', ' + failed + ' failed' : ''));
process.exit(failed > 0 ? 1 : 0);
}
run().catch(err => { console.error('Fatal:', err); process.exit(1); });
+106
View File
@@ -0,0 +1,106 @@
/* Tests for #1346 per-packet animation honors VCR.speed in BOTH modes.
*
* Bug history: PR #922 introduced `stepMs = 33 / VCR.speed` / `DURATION_MS = 1100 / VCR.speed`
* so slow-mo (0.25×) and fast-fwd (4×/8×) work for the per-packet animation. An interim fix
* mode-gated that to REPLAY only, which removed the ability to slow down / speed up LIVE
* animation. Operator wants the original #922 behavior restored: animation ALWAYS follows
* VCR.speed regardless of LIVE/REPLAY.
*
* Behavior:
* - LIVE & REPLAY both animation scaled by VCR.speed
* - Inter-packet replay delay `realGap / VCR.speed` unchanged
* - UI: speed button visible in BOTH modes (operator can adjust live-anim speed)
*/
'use strict';
const fs = require('fs');
const assert = require('assert');
const src = fs.readFileSync('public/live.js', 'utf8');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
console.log('\n=== #1346 — per-packet animation honors VCR.speed in BOTH modes ===');
function extractFn(name) {
const start = src.indexOf('function ' + name + '(');
assert.ok(start !== -1, `function ${name} not found`);
const next = src.indexOf('\n function ', start + 1);
return src.substring(start, next === -1 ? start + 4000 : next);
}
function evalWithVCR(expr, VCR) {
return new Function('VCR', `return (${expr});`)(VCR);
}
// --- drawAnimatedLine.stepMs ---
const stepExpr = extractFn('drawAnimatedLine').match(/const\s+stepMs\s*=\s*([^;]+);/)[1];
test('LIVE @ speed=0.25 → stepMs = 132 (slow-mo works in LIVE too)', () => {
const v = evalWithVCR(stepExpr, { mode: 'LIVE', speed: 0.25 });
assert.strictEqual(v, 132, `got ${v}`);
});
test('LIVE @ speed=4 → stepMs = 8.25 (fast-anim works in LIVE too)', () => {
const v = evalWithVCR(stepExpr, { mode: 'LIVE', speed: 4 });
assert.strictEqual(v, 8.25, `got ${v}`);
});
test('LIVE @ speed=1 → stepMs = 33 (baseline)', () => {
const v = evalWithVCR(stepExpr, { mode: 'LIVE', speed: 1 });
assert.strictEqual(v, 33, `got ${v}`);
});
test('REPLAY @ speed=4 → stepMs = 8.25 (fast-forward animation)', () => {
const v = evalWithVCR(stepExpr, { mode: 'REPLAY', speed: 4 });
assert.strictEqual(v, 8.25, `got ${v}`);
});
test('REPLAY @ speed=0.25 → stepMs = 132 (#922 slow-mo preserved)', () => {
const v = evalWithVCR(stepExpr, { mode: 'REPLAY', speed: 0.25 });
assert.strictEqual(v, 132, `got ${v}`);
});
test('REPLAY @ speed=1 → stepMs = 33 (baseline)', () => {
const v = evalWithVCR(stepExpr, { mode: 'REPLAY', speed: 1 });
assert.strictEqual(v, 33, `got ${v}`);
});
// --- drawMatrixLine.DURATION_MS ---
const durExpr = extractFn('drawMatrixLine').match(/const\s+DURATION_MS\s*=\s*([^;]+);/)[1];
test('LIVE @ speed=4 → DURATION_MS = 275 (fast-fwd in LIVE)', () => {
const v = evalWithVCR(durExpr, { mode: 'LIVE', speed: 4 });
assert.strictEqual(v, 275, `got ${v}`);
});
test('LIVE @ speed=0.25 → DURATION_MS = 4400 (slow-mo in LIVE)', () => {
const v = evalWithVCR(durExpr, { mode: 'LIVE', speed: 0.25 });
assert.strictEqual(v, 4400, `got ${v}`);
});
test('REPLAY @ speed=4 → DURATION_MS = 275 (fast-forward)', () => {
const v = evalWithVCR(durExpr, { mode: 'REPLAY', speed: 4 });
assert.strictEqual(v, 275, `got ${v}`);
});
test('REPLAY @ speed=0.25 → DURATION_MS = 4400 (#922 slow-mo)', () => {
const v = evalWithVCR(durExpr, { mode: 'REPLAY', speed: 0.25 });
assert.strictEqual(v, 4400, `got ${v}`);
});
// --- inter-packet replay delay regression guard ---
test('Inter-packet replay delay still divides realGap by VCR.speed', () => {
assert.ok(/delay\s*=\s*Math\.min\([^;]+?\/\s*VCR\.speed/.test(src),
'inter-packet replay delay must still divide realGap by VCR.speed');
});
// --- UI: speed button visible in BOTH modes ---
test('updateVCRUI does NOT hide speed button in LIVE', () => {
const start = src.indexOf('function updateVCRUI(');
assert.ok(start !== -1, 'updateVCRUI not found');
const end = src.indexOf('\n function ', start + 1);
const body = src.substring(start, end === -1 ? start + 4000 : end);
// No branch that adds 'hidden' class to speedBtn based on LIVE mode
assert.ok(!/speedBtn[\s\S]{0,200}VCR\.mode\s*===\s*['"]LIVE['"][\s\S]{0,200}classList\.add\(['"]hidden['"]\)/.test(body)
&& !/VCR\.mode\s*===\s*['"]LIVE['"][\s\S]{0,200}speedBtn[\s\S]{0,200}classList\.add\(['"]hidden['"]\)/.test(body),
'speedBtn must NOT be hidden when VCR.mode === LIVE — operator needs it to adjust live-anim speed');
});
console.log(`\n=== ${passed} passed, ${failed} failed ===`);
process.exit(failed === 0 ? 0 : 1);
+9 -13
View File
@@ -208,14 +208,12 @@ async function main() {
if (!overlayPresent) {
fail('(cov1) precondition — overlay did not appear after left swipe');
} else {
await page.evaluate((h) => {
await page.evaluate(() => {
// Production stamps data-hash on trace/filter/copy buttons natively
// (issue #1305). Just click — no test-side workaround needed.
const btn = document.querySelector('.row-action-overlay [data-row-action="trace"]');
// Production only sets data-hash on the copy button; for the
// trace/filter branch in onClickAction to navigate, the button
// must carry data-hash. Stamp it here from the row's hash so
// the coverage test exercises the real navigation path.
if (btn) { btn.setAttribute('data-hash', h); btn.click(); }
}, r.hash);
if (btn) { btn.click(); }
});
await page.waitForTimeout(120);
const state = await page.evaluate(() => ({
hash: location.hash,
@@ -241,13 +239,11 @@ async function main() {
if (!ok) {
fail('(cov2) precondition — filter button not in overlay');
} else {
await page.evaluate((h) => {
await page.evaluate(() => {
// Production stamps data-hash on filter button natively (#1305).
const btn = document.querySelector('.row-action-overlay [data-row-action="filter"]');
// Same as cov1: production stamps data-hash only on the copy
// button. Stamp it on filter here so onClickAction's hash
// guard passes and we exercise the real navigation branch.
if (btn) { btn.setAttribute('data-hash', h); btn.click(); }
}, r2.hash);
if (btn) { btn.click(); }
});
await page.waitForTimeout(120);
const state = await page.evaluate(() => ({
hash: location.hash,