diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 301abfd7d..1672066da 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -584,6 +584,7 @@ jobs: suite 1 test-channel-issue-1111-e2e.js BASE_URL=$B suite 2 test-map-modal-fluid-e2e.js BASE_URL=$B suite 2 test-map-nodes-pagination-e2e.js CHROMIUM_REQUIRE=1 BASE_URL=$B + suite 2 test-map-estimated-e2e.js CHROMIUM_REQUIRE=1 BASE_URL=$B suite 3 test-observer-iata-1188-e2e.js BASE_URL=$B suite 2 test-issue-1639-observers-sort-e2e.js BASE_URL=$B suite 2 test-issue-661-airtime-e2e.js BASE_URL=$B diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 38c7286e8..3ae41e277 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -141,6 +141,7 @@ func routeDescriptions() map[string]routeMeta { {Name: "status", Description: "Filter by status (active/stale/offline)", Type: "string"}, }}, "GET /api/nodes/search": {Summary: "Search nodes", Description: "Search nodes by name or public key prefix.", Tag: "nodes", QueryParams: []paramMeta{{Name: "q", Description: "Search query", Type: "string", Required: true}}}, + "GET /api/nodes/reported": {Summary: "Repeaters known only from their reported name", Description: "Repeaters that told a mobile companion their own name (client_node_names, ANON_REQ_TYPE_OWNER) and have no advert position in nodes. Each carries its reception counts and, when companions heard it directly from at least `method.min_spots` spots at least `method.min_spread_m` apart, an `estimate`: a SNR-weighted centroid of those spots with `radius_m` (never below `method.min_radius_m`). The estimate is where companions stood, weighted, not a measured position. Cached for 5 minutes.", Tag: "nodes"}, "GET /api/nodes/bulk-health": {Summary: "Bulk node health", Description: "Returns health status for all nodes in one call.", Tag: "nodes"}, "GET /api/nodes/network-status": {Summary: "Network status summary", Description: "Returns counts of active, stale, and offline nodes.", Tag: "nodes"}, "GET /api/nodes/{pubkey}": {Summary: "Get node detail", Description: "Returns full detail for a single node by public key. For repeater/room nodes this includes the issue #672 usefulness axes + composite score/grade (see the Node schema).", Tag: "nodes", Response: schemaRef("NodeDetailResponse")}, diff --git a/cmd/server/reported_nodes.go b/cmd/server/reported_nodes.go new file mode 100644 index 000000000..a555f0375 --- /dev/null +++ b/cmd/server/reported_nodes.go @@ -0,0 +1,364 @@ +package main + +import ( + "database/sql" + "log" + "math" + "net/http" + "strings" + "sync" + "time" +) + +// Repeaters known only from client_node_names: a mobile companion asked them +// for their own name (ANON_REQ_TYPE_OWNER) because no advert had ever named +// them, so nodes has no row and no position for them. /api/nodes/reported +// lists every such repeater so the UI can find it, and adds an estimated +// position when the receptions allow one. +// +// The estimate is a SNR-weighted centroid of where companions stood when they +// heard the repeater directly. It is not trilateration: a repeater only ever +// heard from one side lands on that side, which is why the radius is never +// smaller than estimateMinRadiusM and why the UI must present it as an +// estimate, never as a node position. + +const ( + // estimateSpotM merges receptions closer than this into one spot, so a + // companion parked in one place counts once, however long it stays. + estimateSpotM = 100.0 + // estimateMinSpots and estimateMinSpreadM: below this there is no + // estimate, because the centroid of one place is that place, which says + // where the companion was, not where the repeater is. + estimateMinSpots = 3 + estimateMinSpreadM = 200.0 + // estimateMinRadiusM is the smallest uncertainty radius reported. + estimateMinRadiusM = 500.0 + // estimateNoSNRdB stands in for a reception without an SNR value. + estimateNoSNRdB = -20.0 + // reportedNodesTTL bounds how often the list is recomputed. + reportedNodesTTL = 5 * time.Minute + // reportedNodesMax caps the list, so the IN query stays bounded. + reportedNodesMax = 500 +) + +// PositionEstimate is an estimated position with its uncertainty. +type PositionEstimate struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + RadiusM float64 `json:"radius_m"` +} + +// ReportedNode is one repeater known only from its own reported name. +type ReportedNode struct { + Pubkey string `json:"pubkey"` + Name string `json:"name"` + NameReportedAt string `json:"name_reported_at"` + Reporters int `json:"reporters"` + Receptions int `json:"receptions"` + Spots int `json:"spots"` + FirstHeard string `json:"first_heard,omitempty"` + LastHeard string `json:"last_heard,omitempty"` + Estimate *PositionEstimate `json:"estimate,omitempty"` +} + +// ReportedNodesResponse is the /api/nodes/reported reply. Method carries the +// thresholds the estimate used, so the UI can explain a missing estimate. +type ReportedNodesResponse struct { + Nodes []ReportedNode `json:"nodes"` + Method struct { + SpotM float64 `json:"spot_m"` + MinSpots int `json:"min_spots"` + MinSpreadM float64 `json:"min_spread_m"` + MinRadiusM float64 `json:"min_radius_m"` + } `json:"method"` +} + +// rxPoint is one direct reception: where the companion stood, and how well. +type rxPoint struct { + Lat, Lon float64 + SNR *float64 +} + +// distanceM is the great-circle distance in metres. +func distanceM(lat1, lon1, lat2, lon2 float64) float64 { + return haversineKm(lat1, lon1, lat2, lon2) * 1000 +} + +// spotsOf merges receptions within estimateSpotM of a spot's first reception +// into that spot, keeping the best SNR. Greedy in input order, which is fine +// at 100 m: the merge only exists to stop one parked companion counting many +// times. +func spotsOf(pts []rxPoint) []rxPoint { + var spots []rxPoint + for _, p := range pts { + merged := false + for i := range spots { + if distanceM(spots[i].Lat, spots[i].Lon, p.Lat, p.Lon) < estimateSpotM { + if p.SNR != nil && (spots[i].SNR == nil || *p.SNR > *spots[i].SNR) { + spots[i].SNR = p.SNR + } + merged = true + break + } + } + if !merged { + spots = append(spots, p) + } + } + return spots +} + +// estimatePosition returns the estimate and the spot count. The estimate is +// nil below estimateMinSpots spots or when no two spots are +// estimateMinSpreadM apart. +func estimatePosition(pts []rxPoint) (*PositionEstimate, int) { + spots := spotsOf(pts) + if len(spots) < estimateMinSpots { + return nil, len(spots) + } + spread := 0.0 + for i := range spots { + for j := i + 1; j < len(spots); j++ { + spread = math.Max(spread, distanceM(spots[i].Lat, spots[i].Lon, spots[j].Lat, spots[j].Lon)) + } + } + if spread < estimateMinSpreadM { + return nil, len(spots) + } + // Linear amplitude weight: 6 dB better counts twice as much. + var sw, slat, slon float64 + for _, s := range spots { + snr := estimateNoSNRdB + if s.SNR != nil { + snr = *s.SNR + } + w := math.Pow(10, snr/20) + sw += w + slat += w * s.Lat + slon += w * s.Lon + } + est := &PositionEstimate{Lat: slat / sw, Lon: slon / sw, RadiusM: estimateMinRadiusM} + for _, s := range spots { + est.RadiusM = math.Max(est.RadiusM, distanceM(est.Lat, est.Lon, s.Lat, s.Lon)) + } + est.Lat = math.Round(est.Lat*1e5) / 1e5 + est.Lon = math.Round(est.Lon*1e5) / 1e5 + est.RadiusM = math.Round(est.RadiusM) + return est, len(spots) +} + +type reportedNodesCache struct { + mu sync.Mutex + at time.Time + resp *ReportedNodesResponse +} + +func (s *Server) handleReportedNodes(w http.ResponseWriter, r *http.Request) { + if s.db == nil || s.db.conn == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable") + return + } + c := &s.reportedNodes + c.mu.Lock() + defer c.mu.Unlock() + if c.resp == nil || time.Since(c.at) > reportedNodesTTL { + start := time.Now() + resp, err := s.computeReportedNodes() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + log.Printf("[reported-nodes] %d repeaters, %d with an estimate, computed in %s", len(resp.Nodes), countEstimates(resp.Nodes), time.Since(start).Round(time.Millisecond)) + c.resp, c.at = resp, time.Now() + } + writeJSON(w, c.resp) +} + +func countEstimates(nodes []ReportedNode) int { + n := 0 + for _, nd := range nodes { + if nd.Estimate != nil { + n++ + } + } + return n +} + +// tablePresent reports whether a table exists. Used for inactive_nodes and +// client_receptions, which an older or partial database may lack. +func (db *DB) tablePresent(name string) bool { + var n int + err := db.conn.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name).Scan(&n) + return err == nil && n > 0 +} + +// computeReportedNodes builds the list: every client_node_names target with no +// advert position in nodes (or inactive_nodes), with its direct receptions. +func (s *Server) computeReportedNodes() (*ReportedNodesResponse, error) { + resp := &ReportedNodesResponse{Nodes: []ReportedNode{}} + resp.Method.SpotM = estimateSpotM + resp.Method.MinSpots = estimateMinSpots + resp.Method.MinSpreadM = estimateMinSpreadM + resp.Method.MinRadiusM = estimateMinRadiusM + if !s.db.clientNodeNamesPresent() { + return resp, nil + } + + positioned := `EXISTS (SELECT 1 FROM nodes n WHERE n.public_key = c.target AND n.lat IS NOT NULL AND n.lon IS NOT NULL AND NOT (n.lat = 0 AND n.lon = 0))` + if s.db.tablePresent("inactive_nodes") { + positioned += ` OR EXISTS (SELECT 1 FROM inactive_nodes n WHERE n.public_key = c.target AND n.lat IS NOT NULL AND n.lon IS NOT NULL AND NOT (n.lat = 0 AND n.lon = 0))` + } + // The bare name beside MAX(observed_at) is SQLite's bare-column rule: the + // name of the newest observation (same as lookupPrefixes). + rows, err := s.db.conn.Query(` + SELECT target, name, MAX(observed_at), COUNT(DISTINCT rx_pubkey) + FROM client_node_names c + WHERE NOT (`+positioned+`) + GROUP BY target + ORDER BY MAX(observed_at) DESC + LIMIT ?`, reportedNodesMax) + if err != nil { + return nil, err + } + byPK := map[string]*ReportedNode{} + var order []string + for rows.Next() { + var n ReportedNode + if err := rows.Scan(&n.Pubkey, &n.Name, &n.NameReportedAt, &n.Reporters); err != nil { + rows.Close() + return nil, err + } + if s.cfg.IsBlacklisted(n.Pubkey) || s.cfg.IsNameHidden(n.Name) { + continue + } + nn := n + byPK[n.Pubkey] = &nn + order = append(order, n.Pubkey) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + + if len(order) > 0 && s.db.tablePresent("client_receptions") { + pts, err := s.directReceptions(order) + if err != nil { + return nil, err + } + for _, pk := range order { + n := byPK[pk] + rx := pts[pk] + n.Receptions = len(rx.points) + n.FirstHeard, n.LastHeard = rx.first, rx.last + n.Estimate, n.Spots = estimatePosition(rx.points) + } + } + for _, pk := range order { + resp.Nodes = append(resp.Nodes, *byPK[pk]) + } + return resp, nil +} + +type targetReceptions struct { + points []rxPoint + first, last string +} + +// directReceptions collects, per target, the receptions that certainly heard +// that repeater directly: +// - discover/advert rows keyed by the full pubkey or its 8-byte prefix, which +// carry the responder's own identity +// - rxlog rows keyed by a 2-3 byte path hash, only when that prefix resolves +// uniquely to this target (lookupPrefixes); otherwise another node with +// the same hash could be the one heard +// +// src='geo' rows are left out: their 1-byte hop was placed geographically, +// not heard as this node. +func (s *Server) directReceptions(targets []string) (map[string]*targetReceptions, error) { + keyTarget := map[string]string{} + var short []string + for _, pk := range targets { + keyTarget[pk] = pk + if len(pk) >= 16 { + keyTarget[pk[:16]] = pk + } + for _, n := range []int{4, 6} { + if len(pk) >= n { + short = append(short, pk[:n]) + } + } + } + wanted := make(map[string]bool, len(targets)) + for _, pk := range targets { + wanted[pk] = true + } + for k, h := range s.lookupPrefixes(short) { + if s.visible(h) && wanted[h.pubkey] { + keyTarget[k] = h.pubkey + } + } + + out := make(map[string]*targetReceptions, len(targets)) + for _, pk := range targets { + out[pk] = &targetReceptions{} + } + keys := make([]string, 0, len(keyTarget)) + for k := range keyTarget { + keys = append(keys, k) + } + const chunk = 400 + for i := 0; i < len(keys); i += chunk { + end := i + chunk + if end > len(keys) { + end = len(keys) + } + batch := keys[i:end] + args := make([]interface{}, len(batch)) + for j, k := range batch { + args[j] = k + } + rows, err := s.db.conn.Query(` + SELECT heard_key, src, lat, lon, snr, rx_at FROM client_receptions + WHERE heard_key IN (`+sqlPlaceholders(len(batch))+`) AND src IN ('discover','advert','rxlog')`, args...) + if err != nil { + return nil, err + } + for rows.Next() { + var hk, src, at string + var lat, lon float64 + var snr sql.NullFloat64 + if err := rows.Scan(&hk, &src, &lat, &lon, &snr, &at); err != nil { + rows.Close() + return nil, err + } + hk = strings.ToLower(hk) + // A short key only counts as an rxlog path hash; a discover or + // advert row always carries 8 or 32 bytes. + if len(hk) < 16 && src != "rxlog" { + continue + } + pk, ok := keyTarget[hk] + if !ok { + continue + } + t := out[pk] + p := rxPoint{Lat: lat, Lon: lon} + if snr.Valid { + v := snr.Float64 + p.SNR = &v + } + t.points = append(t.points, p) + if t.first == "" || at < t.first { + t.first = at + } + if at > t.last { + t.last = at + } + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + } + return out, nil +} diff --git a/cmd/server/reported_nodes_test.go b/cmd/server/reported_nodes_test.go new file mode 100644 index 000000000..5377da5ee --- /dev/null +++ b/cmd/server/reported_nodes_test.go @@ -0,0 +1,221 @@ +package main + +import ( + "encoding/json" + "fmt" + "math" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" +) + +func snrp(v float64) *float64 { return &v } + +// Offsets in metres around a reference point, small enough for a flat-earth +// conversion to be exact to well under a metre. +func offset(dNorthM, dEastM float64) (float64, float64) { + const lat0, lon0 = 51.0, 5.38 + return lat0 + dNorthM/110540, lon0 + dEastM/(111320*math.Cos(lat0*math.Pi/180)) +} + +func pt(dN, dE float64, snr *float64) rxPoint { + lat, lon := offset(dN, dE) + return rxPoint{Lat: lat, Lon: lon, SNR: snr} +} + +func TestEstimatePositionNeedsThreeSpreadSpots(t *testing.T) { + // Fifteen receptions from one driveway are one spot: no estimate. + var parked []rxPoint + for i := 0; i < 15; i++ { + parked = append(parked, pt(float64(i), 0, snrp(-5))) + } + if est, spots := estimatePosition(parked); est != nil || spots != 1 { + t.Fatalf("parked: est=%v spots=%d, want nil and 1", est, spots) + } + // Two spots: still nothing. + if est, spots := estimatePosition([]rxPoint{pt(0, 0, snrp(-5)), pt(0, 1000, snrp(-5))}); est != nil || spots != 2 { + t.Fatalf("two spots: est=%v spots=%d", est, spots) + } + // Three spots, but all within 200 m of each other: nothing. + close3 := []rxPoint{pt(0, 0, snrp(-5)), pt(0, 120, snrp(-5)), pt(120, 0, snrp(-5))} + if est, spots := estimatePosition(close3); est != nil || spots != 3 { + t.Fatalf("tight cluster: est=%v spots=%d", est, spots) + } +} + +func TestEstimatePositionWeightedCentroid(t *testing.T) { + // Equal SNR: the plain centroid of the three spots. + eq := []rxPoint{pt(0, 0, snrp(-10)), pt(0, 900, snrp(-10)), pt(900, 0, snrp(-10))} + est, spots := estimatePosition(eq) + if est == nil || spots != 3 { + t.Fatalf("est=%v spots=%d", est, spots) + } + wantLat, wantLon := offset(300, 300) + if d := distanceM(est.Lat, est.Lon, wantLat, wantLon); d > 2 { + t.Errorf("centroid off by %.1f m", d) + } + // One spot 20 dB stronger pulls the estimate towards it. + strong := []rxPoint{pt(0, 0, snrp(10)), pt(0, 900, snrp(-10)), pt(900, 0, snrp(-10))} + est2, _ := estimatePosition(strong) + lat0, lon0 := offset(0, 0) + if distanceM(est2.Lat, est2.Lon, lat0, lon0) >= distanceM(est.Lat, est.Lon, lat0, lon0) { + t.Errorf("the strong spot did not pull the estimate towards it: %+v vs %+v", est2, est) + } +} + +func TestEstimatePositionRadius(t *testing.T) { + // Spots far apart: the radius reaches the farthest spot. + wide := []rxPoint{pt(0, 0, snrp(-5)), pt(0, 3000, snrp(-5)), pt(3000, 0, snrp(-5))} + est, _ := estimatePosition(wide) + if est == nil { + t.Fatal("no estimate") + } + far := 0.0 + for _, p := range wide { + far = math.Max(far, distanceM(est.Lat, est.Lon, p.Lat, p.Lon)) + } + if math.Abs(est.RadiusM-far) > 1 { + t.Errorf("radius %.0f, want the farthest spot at %.0f", est.RadiusM, far) + } + // Spots close together: the radius never drops below the floor. + near := []rxPoint{pt(0, 0, snrp(-5)), pt(0, 250, snrp(-5)), pt(250, 0, snrp(-5))} + if est, _ := estimatePosition(near); est == nil || est.RadiusM != estimateMinRadiusM { + t.Errorf("near: %+v, want radius %v", est, estimateMinRadiusM) + } +} + +// A spot keeps its best SNR, and a reception without SNR still counts as a spot. +func TestSpotsOfKeepsBestSNR(t *testing.T) { + spots := spotsOf([]rxPoint{pt(0, 0, snrp(-12)), pt(30, 0, snrp(-3)), pt(60, 0, nil), pt(0, 500, nil)}) + if len(spots) != 2 { + t.Fatalf("spots = %d, want 2", len(spots)) + } + if spots[0].SNR == nil || *spots[0].SNR != -3 { + t.Errorf("first spot SNR = %v, want -3", spots[0].SNR) + } + if spots[1].SNR != nil { + t.Errorf("second spot SNR = %v, want nil", spots[1].SNR) + } +} + +// --- endpoint --- + +const ( + repA = "c0cdee59e0db981b00000000000000000000000000000000000000000000000a" // reported, spread receptions + repB = "b1b1220000000000000000000000000000000000000000000000000000000000" // reported, one spot + repC = "c2c2330000000000000000000000000000000000000000000000000000000000" // reported, but has an advert position + repH = "c3c3440000000000000000000000000000000000000000000000000000000000" // hidden name +) + +func insRec(t *testing.T, db *DB, hk, src string, dN, dE, snr float64, at string) { + t.Helper() + lat, lon := offset(dN, dE) + mustExecDB(t, db, fmt.Sprintf(`INSERT INTO client_receptions (rx_pubkey,heard_key,heard_keylen,snr,lat,lon,rx_at,ingested_at,src) + VALUES ('comp','%s',%d,%f,%f,%f,'%s','x','%s')`, hk, len(hk)/2, snr, lat, lon, at, src)) +} + +func seedReported(t *testing.T) *Server { + t.Helper() + db := seedCoverageDB(t) + createClientNodeNames(t, db) + insClientName(t, db, repA, "Self Named", "2026-10-16T10:00:00.000Z") + insClientName(t, db, repB, "Lonely", "2026-10-16T09:00:00.000Z") + insClientName(t, db, repC, "Has Advert", "2026-10-16T08:00:00.000Z") + insClientName(t, db, repH, "🚫Secret", "2026-10-16T07:00:00.000Z") + mustExecDB(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('`+repC+`','Has Advert','repeater',51.0,5.38)`) + + // repA: an 8-byte discover key at three spread spots, plus a 2-byte rxlog + // hash that resolves uniquely to it. + insRec(t, db, repA[:16], "discover", 0, 0, -5, "2026-10-01T10:00:00Z") + insRec(t, db, repA[:16], "discover", 0, 900, -10, "2026-10-02T10:00:00Z") + insRec(t, db, repA, "discover", 900, 0, -10, "2026-10-03T10:00:00Z") + insRec(t, db, repA[:4], "rxlog", 10, 10, -6, "2026-10-04T10:00:00Z") + // A geo row is not a direct reception of this node and must be ignored. + insRec(t, db, repA[:4], "geo", 5000, 5000, -1, "2026-10-05T10:00:00Z") + // repB: heard from one place only. + insRec(t, db, repB[:16], "discover", 0, 0, -4, "2026-10-06T10:00:00Z") + insRec(t, db, repB[:16], "discover", 20, 0, -6, "2026-10-06T10:01:00Z") + return &Server{db: db, cfg: &Config{HiddenNamePrefixes: []string{"🚫"}}} +} + +func getReported(t *testing.T, srv *Server) ReportedNodesResponse { + t.Helper() + router := mux.NewRouter() + router.HandleFunc("/api/nodes/reported", srv.handleReportedNodes).Methods("GET") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest("GET", "/api/nodes/reported", nil)) + if rr.Code != 200 { + t.Fatalf("status %d: %s", rr.Code, rr.Body.String()) + } + var resp ReportedNodesResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + return resp +} + +func TestReportedNodesEndpoint(t *testing.T) { + resp := getReported(t, seedReported(t)) + if resp.Method.MinSpots != estimateMinSpots || resp.Method.MinRadiusM != estimateMinRadiusM { + t.Errorf("method = %+v", resp.Method) + } + got := map[string]ReportedNode{} + for _, n := range resp.Nodes { + got[n.Pubkey] = n + } + if len(got) != 2 { + t.Fatalf("nodes = %v, want repA and repB only (repC has an advert position, repH is hidden)", resp.Nodes) + } + a := got[repA] + if a.Name != "Self Named" || a.Receptions != 4 || a.Spots != 3 || a.Estimate == nil { + t.Fatalf("repA = %+v", a) + } + if a.FirstHeard != "2026-10-01T10:00:00Z" || a.LastHeard != "2026-10-04T10:00:00Z" { + t.Errorf("repA heard %s..%s", a.FirstHeard, a.LastHeard) + } + far, _ := offset(5000, 5000) + if a.Estimate.Lat > far-0.01 { + t.Errorf("the geo row moved the estimate: %+v", a.Estimate) + } + b := got[repB] + if b.Receptions != 2 || b.Spots != 1 || b.Estimate != nil { + t.Fatalf("repB = %+v, want 2 receptions in 1 spot and no estimate", b) + } + // Newest name report first. + if resp.Nodes[0].Pubkey != repA { + t.Errorf("order: first = %s, want repA", resp.Nodes[0].Pubkey) + } +} + +// A short rxlog hash shared with a node in nodes is ambiguous and must not be +// attributed to the reported repeater. +func TestReportedNodesSkipsAmbiguousPathHash(t *testing.T) { + srv := seedReported(t) + mustExecDB(t, srv.db, `INSERT INTO nodes (public_key, name, role) VALUES ('c0cd990000000000000000000000000000000000000000000000000000000000','Other','repeater')`) + resp := getReported(t, srv) + for _, n := range resp.Nodes { + if n.Pubkey == repA && n.Receptions != 3 { + t.Fatalf("repA receptions = %d, want 3 (the ambiguous c0cd rxlog row dropped)", n.Receptions) + } + } +} + +func TestReportedNodesBlacklisted(t *testing.T) { + srv := seedReported(t) + srv.cfg.NodeBlacklist = []string{repA} + for _, n := range getReported(t, srv).Nodes { + if n.Pubkey == repA { + t.Fatal("blacklisted repeater listed") + } + } +} + +// Without client_node_names (an older ingestor) the endpoint answers an empty +// list, not an error. +func TestReportedNodesWithoutTable(t *testing.T) { + srv := &Server{db: setupTestDBv2(t), cfg: &Config{}} + if resp := getReported(t, srv); len(resp.Nodes) != 0 { + t.Fatalf("nodes = %v, want none", resp.Nodes) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index f6e91e0e4..6805c41d0 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -42,6 +42,9 @@ type Server struct { commit string buildTime string + // reportedNodes caches /api/nodes/reported (reported_nodes.go). + reportedNodes reportedNodesCache + // Cached runtime.MemStats to avoid stop-the-world pauses on every health check memStatsMu sync.Mutex memStatsCache runtime.MemStats @@ -346,6 +349,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { // gated by requireClientRfSamples instead. r.HandleFunc("/api/rf-noise", s.handleRfNoise).Methods("GET") r.HandleFunc("/api/nodes/resolve", s.handleResolvePrefix).Methods("GET") + r.HandleFunc("/api/nodes/reported", s.handleReportedNodes).Methods("GET") r.HandleFunc("/api/rx-coverage", s.handleRxCoverage).Methods("GET") r.HandleFunc("/api/rx-leaderboard", s.handleRxLeaderboard).Methods("GET") r.HandleFunc("/api/nodes/{pubkey}", s.handleNodeDetail).Methods("GET") diff --git a/docs/client-rx-coverage.md b/docs/client-rx-coverage.md index 5ee2b727a..2d3aad8c9 100644 --- a/docs/client-rx-coverage.md +++ b/docs/client-rx-coverage.md @@ -179,6 +179,27 @@ set; it lands in `client_node_names`. - The broker ACL must allow `/names`. Explicit topic lists need `meshcore/client/+/names` (or `meshcore/client/#`). +#### Estimated positions + +A repeater known only from `/names` has no advert, so no position of its own. `GET +/api/nodes/reported` lists every such repeater (no advert position in `nodes` or `inactive_nodes`), +and the map draws an estimated position for the ones it can place. The **Estimated positions** +toggle under Display shows or hides them (on by default). + +- **Which receptions count:** rows in `client_receptions` that certainly heard this repeater + directly. Those are `discover` and `advert` rows keyed by its full pubkey or 8-byte prefix, and + `rxlog` rows whose 2-3 byte path hash resolves uniquely to it. `geo` rows are left out. +- **Spots:** receptions within 100 m of each other count as one spot, with the best SNR of that + spot, so a parked companion counts once. +- **Estimate:** only from 3 spots at least 200 m apart. Below that the list still shows the + repeater, without a position. The position is the SNR-weighted centroid of the spots (weight + `10^(snr/20)`). The radius is the distance to the farthest spot, never below 500 m. +- **On the map:** a hollow dashed marker with "β‰ˆ", a dashed circle with that radius, and a popup + that says "Estimated position" and how it was made. It is never clustered with advert markers. +- **Limitation:** this is a centroid of where companions drove, not trilateration. A repeater heard + from one side only is placed towards that side. The ingestor stores the receptions; the server + computes the estimate on request and caches it for 5 minutes. + ## Capture HARD RULE β€” only what was heard directly The app and ingestor record **only the node the companion physically received**, never upstream diff --git a/public/index.html b/public/index.html index bc9d5bd4f..6b96a94a6 100644 --- a/public/index.html +++ b/public/index.html @@ -218,6 +218,7 @@ + diff --git a/public/map-estimated.js b/public/map-estimated.js new file mode 100644 index 000000000..10f46013a --- /dev/null +++ b/public/map-estimated.js @@ -0,0 +1,81 @@ +/* Estimated positions for repeaters known only from their reported name + (/api/nodes/reported, cmd/server/reported_nodes.go). + + These repeaters never sent an advert, so they have no position of their own. + The server estimates one from where mobile companions heard them directly. + Everything here exists to keep that estimate from ever being read as a real + node position: a hollow dashed marker with "β‰ˆ", a dashed uncertainty circle, + and a popup that says what the position is and is not. */ +(function () { + 'use strict'; + + var esc = function (s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + }; + + // withEstimate keeps only the nodes the server could place. + function withEstimate(nodes) { + return (nodes || []).filter(function (n) { + return n && n.estimate && isFinite(n.estimate.lat) && isFinite(n.estimate.lon); + }); + } + + function formatRadius(m) { + if (!isFinite(m)) return ''; + return m >= 1000 ? (Math.round(m / 100) / 10) + ' km' : Math.round(m) + ' m'; + } + + function coverageHref(n) { + return '#/rx-coverage?days=30&lat=' + n.estimate.lat.toFixed(5) + '&lon=' + n.estimate.lon.toFixed(5) + '&zoom=14'; + } + + function iconHtml() { + return ''; + } + + function popupHtml(n, method) { + var e = n.estimate; + var name = n.name ? esc(n.name) : esc(n.pubkey.slice(0, 8)); + var minSpots = method && method.min_spots ? method.min_spots : 3; + return '
' + + '
Estimated position
' + + '
' + name + '
' + + '
' + esc(n.pubkey.slice(0, 16)) + '…
' + + '

No advert position. This repeater only told a mobile companion its name, ' + + 'so its location is estimated from where companions heard it directly: within about ' + + esc(formatRadius(e.radius_m)) + ' of this point.

' + + '
' + + '
Heard
' + esc(n.receptions) + ' times at ' + esc(n.spots) + ' spots (estimate needs ' + esc(minSpots) + ')
' + + (n.first_heard ? '
First / last
' + esc(String(n.first_heard).slice(0, 10)) + ' / ' + esc(String(n.last_heard).slice(0, 10)) + '
' : '') + + '
Name reported
' + esc(String(n.name_reported_at).slice(0, 10)) + ' by ' + esc(n.reporters) + (n.reporters === 1 ? ' companion' : ' companions') + '
' + + '
' + + '

A centroid of where companions drove, not a measured position: a repeater heard from one side only is placed towards that side.

' + + 'Show receptions on the coverage map' + + '
'; + } + + // addTo draws every estimate onto layer: the uncertainty circle first, so the + // marker stays on top and clickable. L is Leaflet. + function addTo(layer, L, nodes, method) { + var shown = withEstimate(nodes); + shown.forEach(function (n) { + var ll = [n.estimate.lat, n.estimate.lon]; + L.circle(ll, { radius: n.estimate.radius_m, className: 'mc-est-area', interactive: false }).addTo(layer); + var marker = L.marker(ll, { + icon: L.divIcon({ html: iconHtml(), className: 'mc-est-marker', iconSize: [32, 32], iconAnchor: [16, 16], popupAnchor: [0, -15] }), + title: (n.name || n.pubkey.slice(0, 8)) + ' (estimated position)', + alt: 'Estimated position of ' + (n.name || n.pubkey.slice(0, 8)), + keyboard: true, + }); + marker.bindPopup(popupHtml(n, method), { maxWidth: 300 }); + marker.addTo(layer); + }); + return shown.length; + } + + window.MapEstimated = { withEstimate: withEstimate, formatRadius: formatRadius, popupHtml: popupHtml, iconHtml: iconHtml, coverageHref: coverageHref, addTo: addTo }; +})(); diff --git a/public/map.js b/public/map.js index 7ba6a62c2..b1a9aecef 100644 --- a/public/map.js +++ b/public/map.js @@ -5,11 +5,15 @@ let map = null; let routeLayer = null; let markerLayer = null; + // Estimated positions of repeaters known only from their reported name + // (map-estimated.js). A layer of its own: never clustered with real markers. + let estimatedLayer = null; + let reported = { nodes: [], method: null }; let clusterGroup = null; let nodes = []; let targetNodeKey = null; let observers = []; - let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clustering: localStorage.getItem('meshcore-map-clustering') !== 'false', hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false', statusFilter: localStorage.getItem('meshcore-map-status-filter') || 'all', byteSize: localStorage.getItem('meshcore-map-byte-filter') || 'all', multiByteOverlay: localStorage.getItem('meshcore-map-multibyte-overlay') === 'true', scopeState: localStorage.getItem('meshcore-map-scope-filter') || 'all', scopeOverlay: localStorage.getItem('meshcore-map-scope-overlay') === 'true', regionScope: localStorage.getItem('meshcore-map-region-filter') || '' }; + let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clustering: localStorage.getItem('meshcore-map-clustering') !== 'false', hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false', statusFilter: localStorage.getItem('meshcore-map-status-filter') || 'all', byteSize: localStorage.getItem('meshcore-map-byte-filter') || 'all', multiByteOverlay: localStorage.getItem('meshcore-map-multibyte-overlay') === 'true', scopeState: localStorage.getItem('meshcore-map-scope-filter') || 'all', scopeOverlay: localStorage.getItem('meshcore-map-scope-overlay') === 'true', estimated: localStorage.getItem('meshcore-map-estimated') !== 'false', regionScope: localStorage.getItem('meshcore-map-region-filter') || '' }; let selectedReferenceNode = null; // pubkey of the reference node for neighbor filtering let neighborPubkeys = null; // Set of pubkeys that are direct neighbors of selected node let wsHandler = null; @@ -517,6 +521,7 @@ +
@@ -724,6 +729,7 @@ }); markerLayer = L.layerGroup().addTo(map); + estimatedLayer = L.layerGroup().addTo(map); clusterGroup = createClusterGroup(); if (filters.clustering && clusterGroup) clusterGroup.addTo(map); routeLayer = L.layerGroup().addTo(map); @@ -897,6 +903,11 @@ scopeOverlayEl.checked = filters.scopeOverlay; scopeOverlayEl.addEventListener('change', e => { filters.scopeOverlay = e.target.checked; localStorage.setItem('meshcore-map-scope-overlay', e.target.checked); renderMarkers(); }); } + const estimatedEl = document.getElementById('mcEstimated'); + if (estimatedEl) { + estimatedEl.checked = filters.estimated; + estimatedEl.addEventListener('change', e => { filters.estimated = e.target.checked; localStorage.setItem('meshcore-map-estimated', e.target.checked); renderEstimated(); }); + } document.getElementById('mcLastHeard').addEventListener('change', e => { filters.lastHeard = e.target.value; loadNodes(); }); AreaFilter.init(document.getElementById('mapAreaFilter')); @@ -1087,6 +1098,7 @@ } // Hide default markers so only the route is visible if (markerLayer) map.removeLayer(markerLayer); + if (estimatedLayer) map.removeLayer(estimatedLayer); if (clusterGroup) map.removeLayer(clusterGroup); if (heatLayer) map.removeLayer(heatLayer); @@ -1101,6 +1113,7 @@ L.DomEvent.preventDefault(e); routeLayer.clearLayers(); if (markerLayer) map.addLayer(markerLayer); + if (estimatedLayer) map.addLayer(estimatedLayer); if (clusterGroup) map.addLayer(clusterGroup); map.removeControl(closeBtn); var container = map.getContainer(); @@ -1734,12 +1747,20 @@ if (map !== loadingMap) return; nodes = data.nodes || []; observers = obsData.observers || []; + // Optional: an instance without client_node_names answers an empty list, + // and an older server without the route must not break the map. + try { + const rep = await api('/nodes/reported', { ttl: CLIENT_TTL.nodeList }); + if (map !== loadingMap) return; + reported = { nodes: (rep && rep.nodes) || [], method: rep && rep.method }; + } catch (e) { reported = { nodes: [], method: null }; } buildRoleChecks(data.counts || {}); buildRegionFilter(); buildJumpButtons(); renderMarkers(); + renderEstimated(); // Keep the Important Links overlay in sync on a full node reload: re-fetch // the neighbor-graph edges (not just re-render the stale cache) so the @@ -1847,6 +1868,7 @@ lbl.querySelector('input').addEventListener('change', e => { filters[e.target.dataset.role] = e.target.checked; renderMarkers(); + renderEstimated(); }); el.appendChild(lbl); } @@ -2048,6 +2070,14 @@ } } + // renderEstimated draws the estimated positions, hidden with the toggle or + // when repeaters are filtered out (they are all repeaters). + function renderEstimated() { + if (!estimatedLayer || !window.MapEstimated) return; + estimatedLayer.clearLayers(); + if (filters.estimated && filters.repeater) window.MapEstimated.addTo(estimatedLayer, L, reported.nodes, reported.method); + } + function renderMarkers() { if (_renderingMarkers) return; _renderingMarkers = true; @@ -2444,6 +2474,8 @@ map = null; } markerLayer = null; + estimatedLayer = null; + reported = { nodes: [], method: null }; clusterGroup = null; _currentMarkerData = []; routeLayer = null; diff --git a/public/style.css b/public/style.css index 712d0a1b6..8ed72c0b4 100644 --- a/public/style.css +++ b/public/style.css @@ -5875,3 +5875,20 @@ body.embed #app.app-fixed { .role-swatch--room { color: var(--role-room); } .role-swatch--sensor { color: var(--role-sensor); } .role-swatch--observer { color: var(--role-observer); } + +/* Estimated positions (map-estimated.js): hollow, dashed, marked with "β‰ˆ" so + they never read as a node's own advert position. */ +.mc-est-marker { background: none; border: none; } +.mc-est-ring { fill: var(--surface-1); fill-opacity: .85; stroke: var(--mc-role-repeater); stroke-width: 2; stroke-dasharray: 3 3; } +.mc-est-glyph { fill: var(--mc-role-repeater); font: 700 15px/1 var(--mono, ui-monospace, monospace); } +.leaflet-interactive.mc-est-area, path.mc-est-area { stroke: var(--mc-role-repeater); stroke-width: 1.5; stroke-dasharray: 6 6; fill: var(--mc-role-repeater); fill-opacity: .07; } +.mc-est-legend { color: var(--mc-role-repeater); font-weight: 700; } +.mc-est-popup { display: grid; gap: 4px; max-width: 280px; } +.mc-est-badge { justify-self: start; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; padding: 1px 6px; border-radius: 3px; border: 1px dashed var(--mc-role-repeater); color: var(--text); } +.mc-est-name { font-weight: 700; font-size: 14px; } +.mc-est-pk { color: var(--text-muted); font-size: 11px; } +.mc-est-what, .mc-est-caveat { margin: 2px 0; font-size: 12px; } +.mc-est-caveat { color: var(--text-muted); } +.mc-est-facts { display: grid; grid-template-columns: auto 1fr; gap: 1px 8px; margin: 2px 0; font-size: 12px; } +.mc-est-facts dt { color: var(--text-muted); } +.mc-est-facts dd { margin: 0; } diff --git a/scripts/non-unit-tests.json b/scripts/non-unit-tests.json index 2388c1a55..4988d907c 100644 --- a/scripts/non-unit-tests.json +++ b/scripts/non-unit-tests.json @@ -91,6 +91,7 @@ "tests/e2e/test-logo-pulse-1173-e2e.js", "tests/e2e/test-logo-rebrand-e2e.js", "tests/e2e/test-logo-theme-e2e.js", + "tests/e2e/test-map-estimated-e2e.js", "tests/e2e/test-map-modal-fluid-e2e.js", "tests/e2e/test-map-nodes-pagination-e2e.js", "tests/e2e/test-nav-drawer-1064-e2e.js", diff --git a/test-all.sh b/test-all.sh index 4f041e670..552cf060b 100755 --- a/test-all.sh +++ b/test-all.sh @@ -160,6 +160,7 @@ node tests/unit/test-locode-column.js node tests/unit/test-scrape-locode.js node tests/unit/test-discord-announce.js node tests/unit/test-map-clustering.js +node tests/unit/test-map-estimated.js node tests/unit/test-mqtt-status-panel.js node tests/unit/test-my-repeaters-dashboard.js node tests/unit/test-naive-banner-tone.js diff --git a/tests/e2e/test-map-estimated-e2e.js b/tests/e2e/test-map-estimated-e2e.js new file mode 100644 index 000000000..04f3f9658 --- /dev/null +++ b/tests/e2e/test-map-estimated-e2e.js @@ -0,0 +1,101 @@ +/** + * E2E: estimated positions on the map (map-estimated.js, /api/nodes/reported). + * + * A repeater known only from its reported name has no advert position. When + * the server can estimate one, the map draws it as a hollow dashed "β‰ˆ" marker + * with a dashed uncertainty circle and a popup that says it is an estimate. + * The "Estimated positions" toggle hides both, and the choice is remembered. + * A reported repeater without an estimate draws nothing. + * + * Backend-independent: every /api/* call is mocked via page.route. + * + * Run: BASE_URL=http://localhost:13581 node test-map-estimated-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const PLACED = 'c0cdee59e0db981b00000000000000000000000000000000000000000000000a'; +const UNPLACED = 'b1b1220000000000000000000000000000000000000000000000000000000000'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' βœ“ ' + name); } + catch (e) { failed++; console.error(' βœ— ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +const reported = { + nodes: [ + { pubkey: PLACED, name: 'Self Named', name_reported_at: '2026-10-16T10:00:00.000Z', reporters: 1, receptions: 15, spots: 4, + first_heard: '2026-09-18T06:36:06Z', last_heard: '2026-10-16T06:50:48Z', estimate: { lat: 50.99712, lon: 5.37811, radius_m: 2480 } }, + { pubkey: UNPLACED, name: 'Lonely', name_reported_at: '2026-10-16T09:00:00.000Z', reporters: 1, receptions: 2, spots: 1 }, + ], + method: { spot_m: 100, min_spots: 3, min_spread_m: 200, min_radius_m: 500 }, +}; + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + + console.log(`\n=== Map estimated positions E2E against ${BASE} ===`); + + await page.route('**/api/**', (route) => { + const path = new URL(route.request().url()).pathname; + const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + if (path === '/api/nodes/reported') return json(reported); + if (path === '/api/nodes') { + return json({ nodes: [{ public_key: 'aa' + '0'.repeat(62), name: 'Real Repeater', role: 'repeater', lat: 51.0, lon: 5.4, last_seen: new Date().toISOString(), hash_size: 1 }], + total: 1, has_more: false, counts: { repeaters: 1, rooms: 0, companions: 0, sensors: 0 } }); + } + if (path === '/api/observers') return json({ observers: [] }); + if (path === '/api/config/map') return json({ center: [51.0, 5.38], zoom: 12 }); + return json({}); + }); + + await page.goto(BASE + '/#/map', { waitUntil: 'load', timeout: 60000 }); + await page.waitForSelector('#leaflet-map', { timeout: 15000 }); + + await step('the estimate is drawn as a dashed "β‰ˆ" marker with an uncertainty circle', async () => { + await page.waitForSelector('.mc-est-marker', { timeout: 15000 }); + const n = await page.$$eval('.mc-est-marker', (els) => els.length); + assert(n === 1, 'expected 1 estimated marker (the unplaced repeater draws nothing), got ' + n); + const glyph = await page.$eval('.mc-est-marker', (el) => el.textContent.trim()); + assert(glyph === 'β‰ˆ', 'marker glyph ' + JSON.stringify(glyph)); + const dash = await page.$eval('path.mc-est-area', (el) => getComputedStyle(el).strokeDasharray); + assert(dash && dash !== 'none', 'uncertainty circle is not dashed: ' + dash); + }); + + await step('the popup says it is an estimate', async () => { + await page.click('.mc-est-marker'); + await page.waitForSelector('.mc-est-popup'); + const text = await page.$eval('.mc-est-popup', (el) => el.textContent); + assert(text.includes('Estimated position') && text.includes('No advert position'), 'popup text: ' + text); + assert(text.includes('2.5 km'), 'popup radius missing: ' + text); + }); + + await step('the toggle hides marker and circle and is remembered', async () => { + await page.evaluate(() => { const el = document.getElementById('mcEstimated'); el.click(); }); + await page.waitForFunction(() => !document.querySelector('.mc-est-marker') && !document.querySelector('path.mc-est-area')); + const stored = await page.evaluate(() => localStorage.getItem('meshcore-map-estimated')); + assert(stored === 'false', 'localStorage meshcore-map-estimated = ' + stored); + await page.evaluate(() => document.getElementById('mcEstimated').click()); + await page.waitForSelector('.mc-est-marker'); + }); + + await step('no page errors', async () => { + assert(errors.length === 0, errors.join(' | ')); + }); + + await browser.close(); + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/tests/unit/test-map-estimated.js b/tests/unit/test-map-estimated.js new file mode 100644 index 000000000..0473492d8 --- /dev/null +++ b/tests/unit/test-map-estimated.js @@ -0,0 +1,65 @@ +'use strict'; +const REPO_ROOT = require('path').resolve(__dirname, '..', '..'); +// Unit test for map-estimated.js: estimated positions must never read as a +// node's own position. Loads the browser IIFE in a vm sandbox and drives +// addTo with a recording Leaflet stub. +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const code = fs.readFileSync(path.join(REPO_ROOT, 'public', 'map-estimated.js'), 'utf8'); +const sandbox = { window: {} }; +vm.createContext(sandbox); +vm.runInContext(code, sandbox); +const M = sandbox.window.MapEstimated; + +const placed = { + pubkey: 'c0cdee59e0db981b00000000000000000000000000000000000000000000000a', + name: 'Self ', name_reported_at: '2026-10-16T10:00:00.000Z', reporters: 1, + receptions: 15, spots: 4, first_heard: '2026-09-18T06:36:06Z', last_heard: '2026-10-16T06:50:48Z', + estimate: { lat: 50.99712, lon: 5.37811, radius_m: 2480 }, +}; +const unplaced = Object.assign({}, placed, { pubkey: 'b1b1' + '0'.repeat(60), estimate: undefined, spots: 1 }); +const method = { spot_m: 100, min_spots: 3, min_spread_m: 200, min_radius_m: 500 }; + +// Only nodes with an estimate are drawn. +assert.deepStrictEqual(M.withEstimate([placed, unplaced, null]).map(n => n.pubkey), [placed.pubkey]); +assert.strictEqual(M.withEstimate([{ estimate: { lat: NaN, lon: 5 } }]).length, 0, 'a NaN estimate is not drawn'); + +assert.strictEqual(M.formatRadius(500), '500 m'); +assert.strictEqual(M.formatRadius(2480), '2.5 km'); + +// The popup says what this is, escapes the name, and links to the coverage map there. +const html = M.popupHtml(placed, method); +assert.ok(html.includes('Estimated position'), 'badge'); +assert.ok(html.includes('No advert position'), 'states there is no advert position'); +assert.ok(html.includes('2.5 km'), 'uncertainty radius'); +assert.ok(html.includes('15 times at 4 spots (estimate needs 3)'), 'reception facts'); +assert.ok(html.includes('Self <Named>') && !html.includes(''), 'name escaped'); +assert.ok(html.includes('#/rx-coverage?days=30&lat=50.99712&lon=5.37811&zoom=14'), 'coverage link'); +assert.ok(html.includes('1 companion<'), 'singular companion'); + +// The icon is the hollow dashed ring with the β‰ˆ glyph, not a role marker. +assert.ok(M.iconHtml().includes('mc-est-ring') && M.iconHtml().includes('β‰ˆ')); + +// addTo: one uncertainty circle (non-interactive, dashed class) and one marker per estimate. +const added = []; +const layer = {}; +const L = { + circle(ll, opts) { const o = { kind: 'circle', ll, opts, addTo() { added.push(o); return o; } }; return o; }, + divIcon(opts) { return opts; }, + marker(ll, opts) { const o = { kind: 'marker', ll, opts, popup: null, bindPopup(h) { o.popup = h; return o; }, addTo() { added.push(o); return o; } }; return o; }, +}; +assert.strictEqual(M.addTo(layer, L, [placed, unplaced], method), 1); +assert.strictEqual(added.length, 2); +const [circle, marker] = added; +assert.strictEqual(circle.kind, 'circle', 'circle drawn first so the marker stays on top'); +assert.strictEqual(circle.opts.radius, 2480); +assert.strictEqual(circle.opts.className, 'mc-est-area'); +assert.strictEqual(circle.opts.interactive, false); +assert.strictEqual(marker.opts.icon.className, 'mc-est-marker'); +assert.ok(marker.opts.title.includes('(estimated position)'), 'hover title says estimated'); +assert.ok(marker.popup.includes('Estimated position')); + +console.log('map-estimated OK');