Refine beta links UI and lock live feed behavior

This commit is contained in:
Ben committed 2026-03-05 01:25:18 +00:00
1 parent e86912db9e
commit ec861c4332
5 files changed
+48 -136

No files matched your search

@@ -16,6 +16,16 @@ interface FilterPanelProps {
betaPathConfidence?: number | null;
}
export const LinksLegend: React.FC<{ compact?: boolean; muted?: boolean }> = ({ compact = false, muted = false }) => (
<div className={`links-legend-inline${compact ? ' links-legend-inline--compact' : ''}${muted ? ' links-legend-inline--muted' : ''}`}>
<div className="links-legend-inline__title">Links Legend</div>
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#22c55e' }} /> Good (≤120 dB)</div>
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#fbbf24' }} /> Marginal (121-135 dB)</div>
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#ef4444' }} /> Weak (&gt;135 dB)</div>
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#d1d5db' }} /> Unknown (no dB yet)</div>
</div>
);
export const FILTER_ROWS: Array<{ key: keyof Filters; label: string; color: string; hollow?: boolean }> = [
{ key: 'livePackets', label: 'Live Feed', color: '#00c4ff' },
{ key: 'packetPaths', label: 'Packet Paths', color: '#00c4ff', hollow: true },
@@ -30,33 +40,6 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, bet
onChange({ ...filters, [key]: !filters[key] });
};
const applyPreset = (preset: 'minimal' | 'links' | 'beta') => {
const next: Filters = { ...filters };
if (preset === 'minimal') {
next.livePackets = true;
next.packetPaths = false;
next.betaPaths = false;
next.links = false;
next.coverage = false;
next.clientNodes = false;
} else if (preset === 'links') {
next.livePackets = true;
next.packetPaths = false;
next.betaPaths = true;
next.links = true;
next.coverage = false;
next.clientNodes = false;
} else {
next.livePackets = true;
next.packetPaths = false;
next.betaPaths = true;
next.links = true;
next.coverage = true;
next.clientNodes = false;
}
onChange(next);
};
const confidenceLabel = betaPathConfidence == null
? 'N/A'
: betaPathConfidence >= 0.75
@@ -68,11 +51,6 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, bet
return (
<div className="filter-panel">
<div className="filter-panel__title">Layers</div>
<div className="filter-presets">
<button className="filter-preset-btn" onClick={() => applyPreset('minimal')}>Minimal</button>
<button className="filter-preset-btn" onClick={() => applyPreset('links')}>Links + Beta</button>
<button className="filter-preset-btn" onClick={() => applyPreset('beta')}>Full Beta</button>
</div>
{filters.betaPaths && (
<div className="filter-beta-note">
Beta Confidence: <strong>{confidenceLabel}</strong>
@@ -123,6 +101,7 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, bet
)}
</React.Fragment>
))}
<LinksLegend muted={!filters.links} />
</div>
);
};
+3 -21
View File
@@ -1,5 +1,5 @@
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { MapContainer, TileLayer, useMap, Pane, Polygon, Polyline, Tooltip } from 'react-leaflet';
import { MapContainer, TileLayer, useMap, Pane, Polygon, Polyline } from 'react-leaflet';
import type { LatLngExpression, Map as LeafletMap, Polyline as LeafletPolyline } from 'leaflet';
import type { MeshNode, PacketArc } from '../../hooks/useNodes.js';
import type { NodeCoverage } from '../../hooks/useCoverage.js';
@@ -294,16 +294,8 @@ export const MapView: React.FC<MapViewProps> = ({
weight,
opacity,
}}
interactive
>
<Tooltip direction="center" sticky>
<div className="link-tooltip">
<div className="link-tooltip__row"><span>Seen</span><strong>{line.observedCount}</strong></div>
<div className="link-tooltip__row"><span>Path Loss</span><strong>{line.pathLossDb == null ? 'unknown' : `${Math.round(line.pathLossDb)} dB`}</strong></div>
<div className="link-tooltip__row"><span>A→B / B→A</span><strong>{line.countAToB} / {line.countBToA}</strong></div>
</div>
</Tooltip>
</Polyline>
interactive={false}
/>
);
})}
</Pane>
@@ -358,16 +350,6 @@ export const MapView: React.FC<MapViewProps> = ({
)}
</MapContainer>
{showLinks && (
<div className="links-legend">
<div className="links-legend__title">Links Legend</div>
<div className="links-legend__row"><span className="links-legend__swatch" style={{ background: '#22c55e' }} /> Good (≤120 dB)</div>
<div className="links-legend__row"><span className="links-legend__swatch" style={{ background: '#fbbf24' }} /> Marginal (121-135 dB)</div>
<div className="links-legend__row"><span className="links-legend__swatch" style={{ background: '#ef4444' }} /> Weak (&gt;135 dB)</div>
<div className="links-legend__row"><span className="links-legend__swatch" style={{ background: '#d1d5db' }} /> Unknown (no dB yet)</div>
</div>
)}
{/* deck.gl overlay — arcs only */}
<PacketArcLayer
arcs={arcs}
+4 -19
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useMemo } from 'react';
import type { AggregatedPacket, MeshNode } from '../hooks/useNodes.js';
const TYPE_LABELS: Record<number, string> = {
@@ -21,27 +21,14 @@ interface Props {
pinnedPacketId?: string | null;
}
const ROW_HEIGHT = 32;
const VISIBLE_ROWS = 8;
const OVERSCAN = 4;
export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, onPacketClick, pinnedPacketId }) => {
const [scrollTop, setScrollTop] = useState(0);
const total = packets.length;
const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
const end = Math.min(total, start + VISIBLE_ROWS + OVERSCAN * 2);
const visible = useMemo(
() => packets.slice(start, end).map((packet, idx) => ({ packet, index: start + idx })),
[packets, start, end],
);
const visible = useMemo(() => packets.slice(0, VISIBLE_ROWS), [packets]);
return (
<div
className={`packet-feed${packets.length >= VISIBLE_ROWS ? ' packet-feed--overflow' : ''}`}
onScroll={(e) => setScrollTop((e.target as HTMLDivElement).scrollTop)}
>
<div style={{ height: total * ROW_HEIGHT, position: 'relative' }}>
{visible.map(({ packet: p, index }) => {
<div className="packet-feed">
{visible.map((p) => {
const typeLabel = p.packetType !== undefined
? (TYPE_LABELS[p.packetType] ?? `T${p.packetType}`)
: '???';
@@ -63,7 +50,6 @@ export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, onPacke
<div
key={p.id}
className={`packet-item packet-item--clickable${isPinned ? ' packet-item--pinned' : ''}`}
style={{ position: 'absolute', top: index * ROW_HEIGHT, left: 0, right: 0 }}
onClick={() => onPacketClick?.(p)}
role="button"
tabIndex={0}
@@ -87,7 +73,6 @@ export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, onPacke
</div>
);
})}
</div>
</div>
);
});
@@ -1,7 +1,7 @@
import React from 'react';
import type { Map as LeafletMap } from 'leaflet';
import { NodeSearch } from '../Map/NodeSearch.js';
import { FILTER_ROWS, type Filters } from '../FilterPanel/FilterPanel.js';
import { FILTER_ROWS, LinksLegend, type Filters } from '../FilterPanel/FilterPanel.js';
import type { MeshNode } from '../../hooks/useNodes.js';
type MobileControlsProps = {
@@ -42,9 +42,11 @@ export const MobileControls: React.FC<MobileControlsProps> = ({
</div>
))}
</div>
<div className="mobile-links-legend-wrap">
<LinksLegend compact muted={!filters.links} />
</div>
<div className="mobile-search">
<NodeSearch map={map} nodes={nodes} />
</div>
</div>
);
+26 -62
View File
@@ -327,33 +327,6 @@ html, body, #root {
cursor: pointer;
}
.filter-presets {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
margin-bottom: 8px;
}
.filter-preset-btn {
width: 100%;
text-align: left;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-panel-alt);
color: var(--text-secondary);
font-size: 11px;
font-family: var(--font-mono);
padding: 6px 8px;
cursor: pointer;
transition: background var(--transition), color var(--transition), border-color var(--transition);
}
.filter-preset-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-bright);
}
.filter-beta-note {
font-size: 10px;
color: var(--text-muted);
@@ -544,48 +517,39 @@ html, body, #root {
flex-shrink: 0;
}
.link-tooltip {
min-width: 180px;
font-size: 11px;
color: var(--text-primary);
}
.link-tooltip__row {
display: flex;
justify-content: space-between;
gap: 10px;
}
.links-legend {
position: absolute;
left: 16px;
top: 70px;
z-index: 820;
.links-legend-inline {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 10px 12px;
min-width: 180px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
pointer-events: none;
border-radius: var(--radius);
padding: 8px 10px;
margin-top: 8px;
}
.links-legend__title {
.links-legend-inline--muted {
opacity: 0.6;
}
.links-legend-inline--compact {
margin: 6px 8px 0;
padding: 7px 9px;
}
.links-legend-inline__title {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 8px;
margin-bottom: 6px;
}
.links-legend__row {
.links-legend-inline__row {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 4px;
margin-bottom: 3px;
}
.links-legend__swatch {
@@ -603,16 +567,12 @@ html, body, #root {
z-index: 800;
width: 340px;
height: 220px;
overflow-y: auto;
overflow: hidden;
overflow-x: hidden;
pointer-events: auto;
padding-right: 2px;
}
/* Fade the oldest (topmost) item only when the feed is full */
.packet-feed--overflow {
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 40px, black 100%);
mask-image: linear-gradient(to bottom, transparent 0%, black 40px, black 100%);
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.packet-item {
@@ -1758,11 +1718,15 @@ html, body, #root {
.mobile-filter-grid .filter-row { padding: 8px 6px; }
/* ── Mobile search: shown below filter grid ──────────────────────────── */
.mobile-search {
.mobile-search {
display: block;
padding: 8px 12px;
border-top: 1px solid var(--border);
}
.mobile-links-legend-wrap {
border-top: 1px solid var(--border);
padding-top: 2px;
}
.mobile-search .node-search {
position: static;
transform: none;