Improve map and node detail UI

Map:
- Fix __publicField error by setting esbuild target to esnext
- Replace react-map-gl with direct maplibre-gl imperative API across all map components
- Add dark theme styling for MapLibre popup, attribution, and navigation controls
- Add NavigationControl to NetworkMap and NodeLocationMap
- Fix auto-zoom race condition using mapLoaded state
- Fix node click popup being immediately dismissed
- Add MQTT links as separate dashed purple layer
- Reduce node/dot sizes on NetworkMap
- Switch glyph CDN to fonts.openmaptiles.org
- Extend map legend with SNR line color key and MQTT indicator

Node detail:
- Extract shared ConnectionRow and ConnectionList components
- Compact connections table: single row per entry with color-coded SNR, badges, short time
- Apply same compact style to NeighborInfoPacket neighbor list
- Move Connections section into right column below Last Activity
- Split Gateway Node out of Device Information into its own card
- Fold observed node count into "Recently observed nodes (N)" subtext
- Add formatLastSeenShort for compact time display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel Pupius
2026-03-16 03:20:27 +00:00
parent aee1c50b67
commit c54e4a6a21
9 changed files with 287 additions and 206 deletions

View File

@@ -41,6 +41,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
const popupRef = useRef<maplibregl.Popup | null>(null);
const nodesWithPositionRef = useRef<MapNode[]>([]);
const [autoZoomEnabled, setAutoZoomEnabled] = useState(true);
const [mapLoaded, setMapLoaded] = useState(false);
const { nodes, gateways } = useAppSelector((state) => state.aggregator);
const topologyLinks = useAppSelector((state) => state.topology.links);
@@ -68,7 +69,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
name: node.shortName || node.longName || `!${node.id.toString(16)}`,
fillColor: colors.fill,
strokeColor: colors.stroke,
radius: node.isGateway ? 12 : 8,
radius: node.isGateway ? 8 : 5,
},
};
}),
@@ -89,7 +90,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
return {
type: "Feature" as const,
geometry: { type: "LineString" as const, coordinates: [posMap.get(link.nodeA)!, posMap.get(link.nodeB)!] },
properties: { color, opacity: link.viaMqtt ? 0.4 : 0.7 },
properties: { color, opacity: 0.7, viaMqtt: link.viaMqtt ? 1 : 0 },
};
}),
};
@@ -117,14 +118,24 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
map.addControl(new maplibregl.NavigationControl(), 'top-left');
map.on("load", () => {
setMapLoaded(true);
map.addSource("links", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
map.addLayer({
id: "links-line",
type: "line",
source: "links",
filter: ["==", ["get", "viaMqtt"], 0],
layout: { "line-join": "round", "line-cap": "round", visibility: "visible" },
paint: { "line-color": ["get", "color"], "line-width": 2, "line-opacity": ["get", "opacity"] },
});
map.addLayer({
id: "links-mqtt",
type: "line",
source: "links",
filter: ["==", ["get", "viaMqtt"], 1],
layout: { "line-join": "round", "line-cap": "butt", visibility: "visible" },
paint: { "line-color": "#a855f7", "line-width": 2, "line-opacity": 0.6, "line-dasharray": [2, 3] },
});
map.addSource("nodes", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
map.addLayer({
@@ -144,7 +155,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
id: "nodes-labels",
type: "symbol",
source: "nodes",
layout: { "text-field": ["get", "name"], "text-size": 11, "text-offset": [0, 1.5], "text-anchor": "top", "text-optional": true },
layout: { "text-field": ["get", "name"], "text-font": ["Open Sans Regular"], "text-size": 11, "text-offset": [0, 1.5], "text-anchor": "top", "text-optional": true },
paint: { "text-color": "#e5e7eb", "text-halo-color": "#111827", "text-halo-width": 1.5 },
});
});
@@ -155,7 +166,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
map.on("mouseenter", "nodes-circles", () => { map.getCanvas().style.cursor = "pointer"; });
map.on("mouseleave", "nodes-circles", () => { map.getCanvas().style.cursor = "grab"; });
map.on("click", "nodes-circles", (e: MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
map.on("click", "nodes-circles", (e) => {
const feature = e.features?.[0];
if (!feature) return;
const props = feature.properties as { nodeId: number; name: string; fillColor: string };
@@ -174,16 +185,16 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
popupRef.current?.remove();
const el = document.createElement("div");
el.style.cssText = "font-family:sans-serif;max-width:220px;padding:4px";
el.style.cssText = "font-family:sans-serif;max-width:220px;padding:2px 0";
el.innerHTML = `
<div style="font-weight:600;font-size:14px;color:${colors.fill};margin-bottom:3px">${nodeName}</div>
<div style="font-size:11px;color:#6b7280;margin-bottom:6px">${node.isGateway ? "Gateway" : "Node"} · !${node.id.toString(16)}</div>
<div style="font-size:11px;color:#374151;margin-bottom:4px">
<div style="font-size:11px;color:rgba(255,255,255,0.45);margin-bottom:6px">${node.isGateway ? "Gateway" : "Node"} · !${node.id.toString(16)}</div>
<div style="font-size:11px;color:rgba(255,255,255,0.7);margin-bottom:4px">
<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:${colors.fill};margin-right:5px;vertical-align:middle"></span>
${statusText} · ${lastSeenText}
</div>
<div style="font-size:11px;color:#6b7280;margin-bottom:8px">Packets: ${node.messageCount} · Text: ${node.textMessageCount}</div>
<button id="nav-btn" style="font-size:12px;font-weight:500;color:#3b82f6;background:#f1f5f9;border:none;border-radius:4px;padding:4px 8px;cursor:pointer">View details →</button>
<div style="font-size:11px;color:rgba(255,255,255,0.45);margin-bottom:10px">Packets: ${node.messageCount} · Text: ${node.textMessageCount}</div>
<button id="nav-btn" style="font-size:12px;font-weight:500;color:rgba(147,197,253,0.9);background:none;border:none;padding:0;cursor:pointer;letter-spacing:0.01em">View details →</button>
`;
el.querySelector("#nav-btn")?.addEventListener("click", () => {
popup.remove();
@@ -197,8 +208,9 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
popupRef.current = popup;
});
map.on("click", (e: MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
if (!e.features?.length) popupRef.current?.remove();
map.on("click", (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ["nodes-circles"] });
if (!features.length) popupRef.current?.remove();
});
mapRef.current = map;
@@ -209,22 +221,23 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
// Update node/link data
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
if (!map || !mapLoaded) return;
(map.getSource("nodes") as GeoJSONSource)?.setData(nodesGeoJSON);
(map.getSource("links") as GeoJSONSource)?.setData(linksGeoJSON);
}, [nodesGeoJSON, linksGeoJSON]);
}, [nodesGeoJSON, linksGeoJSON, mapLoaded]);
// Toggle links visibility
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
if (!map || !mapLoaded) return;
map.setLayoutProperty("links-line", "visibility", showLinks ? "visible" : "none");
}, [showLinks]);
map.setLayoutProperty("links-mqtt", "visibility", showLinks ? "visible" : "none");
}, [showLinks, mapLoaded]);
// Auto-zoom to fit nodes
useEffect(() => {
const map = mapRef.current;
if (!autoZoomRef.current || nodesWithPosition.length === 0 || !map) return;
if (!autoZoomRef.current || nodesWithPosition.length === 0 || !map || !mapLoaded) return;
let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity;
for (const n of nodesWithPosition) {
const lng = n.position.longitudeI / 10000000;
@@ -235,7 +248,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
if (lat > maxLat) maxLat = lat;
}
map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 60, maxZoom: 15, duration: 500 });
}, [nodesWithPosition]);
}, [nodesWithPosition, mapLoaded]);
// Notify parent of auto-zoom state
useEffect(() => {

View File

@@ -8,6 +8,7 @@ import {
getNodeColors,
getStatusText,
formatLastSeen,
formatLastSeenShort,
} from "../../lib/activity";
import { cn } from "../../lib/cn";
import {
@@ -36,6 +37,8 @@ import {
import { Separator } from "../Separator";
import { KeyValuePair } from "../ui/KeyValuePair";
import { Section } from "../ui/Section";
import { ConnectionRow, ConnectionList } from "../ui/ConnectionRow";
import type { ConnectionBadge, SnrValue } from "../ui/ConnectionRow";
import { BatteryLevel } from "./BatteryLevel";
import { NodeLocationMap } from "./GoogleMap";
import { NodePositionData } from "./NodePositionData";
@@ -343,95 +346,76 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
</div>
</div>
{/* Show MapReport-specific information for gateways */}
{node.isGateway && (
<div className="mt-4 pt-3 border-t border-neutral-700 space-y-3">
<div className=" mb-2 p-2 rounded effect-inset bg-neutral-700/50 ">
<div className="flex justify-between items-center">
<span className="flex items-center">
<Network className="w-4 h-4 mr-1.5" />
Gateway Node
</span>
{node.observedNodeCount !== undefined && (
<span className="flex items-center">
<Users className="w-4 h-4 mr-1.5" />
{node.observedNodeCount}{" "}
{node.observedNodeCount === 1 ? "node" : "nodes"}
</span>
)}
{node.mapReport?.numOnlineLocalNodes !== undefined && (
<span className="text-xs flex items-center font-mono opacity-80">
{node.mapReport.numOnlineLocalNodes} online local nodes
</span>
)}
</div>
{/* Observed Nodes Grid - integrated into Gateway Node section */}
{gateway?.observedNodes &&
gateway.observedNodes.length > 0 && (
<div>
<div className="my-2 text-xs text-neutral-400">
Recently observed nodes:
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{gateway.observedNodes.map((observedNodeId) => {
const observedNode = nodes[observedNodeId];
const displayName = getNodeDisplayName(
observedNodeId,
observedNode
);
return (
<Link
key={observedNodeId}
to="/node/$nodeId"
params={{
nodeId: observedNodeId
.toString(16)
.toLowerCase(),
}}
className="flex items-center p-2 bg-neutral-800/50 hover:bg-neutral-700/50 rounded transition-colors text-xs border border-neutral-700/30"
>
<BoomBox className="w-3 h-3 mr-2 text-neutral-400 flex-shrink-0" />
<span className="text-neutral-200 truncate">
{displayName}
</span>
</Link>
);
})}
</div>
</div>
)}
</div>
{node.mapReport?.region !== undefined && (
<KeyValuePair
label="Region"
value={getRegionName(node.mapReport.region)}
icon={<Earth className="w-3 h-3" />}
inset={true}
/>
)}
{node.mapReport?.modemPreset !== undefined && (
<KeyValuePair
label="Modem Preset"
value={getModemPresetName(node.mapReport.modemPreset)}
icon={<TableConfig className="w-3 h-3" />}
inset={true}
/>
)}
{node.mapReport?.firmwareVersion && (
<KeyValuePair
label="Firmware"
value={node.mapReport.firmwareVersion}
monospace={true}
icon={<Save className="w-3 h-3" />}
inset={true}
/>
)}
</div>
)}
</Section>
{node.isGateway && (
<Section
title="Gateway Node"
icon={<Network className="w-4 h-4" />}
className="mt-4"
>
{node.mapReport?.numOnlineLocalNodes !== undefined && (
<KeyValuePair
label="Online local"
value={String(node.mapReport.numOnlineLocalNodes)}
icon={<Network className="w-3 h-3" />}
inset={true}
/>
)}
{node.mapReport?.region !== undefined && (
<KeyValuePair
label="Region"
value={getRegionName(node.mapReport.region)}
icon={<Earth className="w-3 h-3" />}
inset={true}
/>
)}
{node.mapReport?.modemPreset !== undefined && (
<KeyValuePair
label="Modem Preset"
value={getModemPresetName(node.mapReport.modemPreset)}
icon={<TableConfig className="w-3 h-3" />}
inset={true}
/>
)}
{node.mapReport?.firmwareVersion && (
<KeyValuePair
label="Firmware"
value={node.mapReport.firmwareVersion}
monospace={true}
icon={<Save className="w-3 h-3" />}
inset={true}
/>
)}
{gateway?.observedNodes && gateway.observedNodes.length > 0 && (
<div className="mt-3">
<div className="text-xs text-neutral-500 mb-2">Recently observed nodes ({gateway.observedNodes.length})</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{gateway.observedNodes.map((observedNodeId) => {
const observedNode = nodes[observedNodeId];
const displayName = getNodeDisplayName(observedNodeId, observedNode);
return (
<Link
key={observedNodeId}
to="/node/$nodeId"
params={{ nodeId: observedNodeId.toString(16).toLowerCase() }}
className="flex items-center p-2 bg-neutral-800/50 hover:bg-neutral-700/50 rounded transition-colors text-xs border border-neutral-700/30"
>
<BoomBox className="w-3 h-3 mr-2 text-neutral-400 flex-shrink-0" />
<span className="text-neutral-200 truncate">{displayName}</span>
</Link>
);
})}
</div>
</div>
)}
</Section>
)}
{/* Device Status - Moved from the right column to here */}
{(node.deviceMetrics ||
node.batteryLevel !== undefined ||
@@ -574,6 +558,15 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
/>
</Section>
)}
{/* Connections */}
<Section
title="Connections"
icon={<Network className="w-4 h-4" />}
className="mt-4"
>
<NodeConnections nodeId={nodeId} links={topologyLinks} nodes={nodes} />
</Section>
</div>
</div>
@@ -604,15 +597,6 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
</Section>
)}
{/* Connections - Full Width */}
<Section
title="Connections"
icon={<Network className="w-4 h-4" />}
className="mt-6"
>
<NodeConnections nodeId={nodeId} links={topologyLinks} nodes={nodes} />
</Section>
{/* Recent Packets - Full Width */}
<Section
title="Recent Packets"
@@ -675,7 +659,7 @@ const NodeConnections: React.FC<NodeConnectionsProps> = ({ nodeId, links, nodes
}
return (
<div className="space-y-2">
<ConnectionList count={edges.length}>
{edges.map((link) => {
const neighborId = link.nodeA === nodeId ? link.nodeB : link.nodeA;
const neighborNode = nodes[neighborId];
@@ -684,60 +668,32 @@ const NodeConnections: React.FC<NodeConnectionsProps> = ({ nodeId, links, nodes
neighborNode?.longName ??
`!${neighborId.toString(16)}`;
// Determine SNR direction relative to nodeId
// snrAtoB = SNR at nodeB receiving from nodeA
// snrBtoA = SNR at nodeA receiving from nodeB
// "outgoing SNR" = SNR that the neighbor measured (i.e., neighbor receiving from nodeId)
// "incoming SNR" = SNR that nodeId measured (i.e., nodeId receiving from neighbor)
const outgoingSnr =
nodeId === link.nodeA ? link.snrAtoB : link.snrBtoA;
const incomingSnr =
nodeId === link.nodeA ? link.snrBtoA : link.snrAtoB;
const outgoingSnr = nodeId === link.nodeA ? link.snrAtoB : link.snrBtoA;
const incomingSnr = nodeId === link.nodeA ? link.snrBtoA : link.snrAtoB;
const source = bestSource(link.sourceAtoB, link.sourceBtoA);
const secondsAgo = Math.floor(Date.now() / 1000) - link.lastSeen;
const snrValues: SnrValue[] = [
...(outgoingSnr !== undefined ? [{ label: "↑", value: outgoingSnr }] : []),
...(incomingSnr !== undefined ? [{ label: "↓", value: incomingSnr }] : []),
];
const badges: ConnectionBadge[] = [
{ label: SOURCE_LABELS[source], className: SOURCE_COLORS[source] },
...(link.viaMqtt ? [{ label: "MQTT", className: "bg-purple-900 text-purple-300" }] : []),
];
return (
<div
<ConnectionRow
key={link.key}
className="flex items-start justify-between p-3 bg-neutral-800 rounded-lg border border-neutral-700"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm text-white font-medium truncate">
{neighborName}
</span>
<span
className={`text-xs px-1.5 py-0.5 rounded font-medium ${SOURCE_COLORS[source]}`}
>
{SOURCE_LABELS[source]}
</span>
{link.viaMqtt && (
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-900 text-purple-300 font-medium">
MQTT
</span>
)}
</div>
<div className="flex gap-3 text-xs text-neutral-400">
{outgoingSnr !== undefined && (
<span> {outgoingSnr.toFixed(1)} dB</span>
)}
{incomingSnr !== undefined && (
<span> {incomingSnr.toFixed(1)} dB</span>
)}
{link.rssiAtoB !== undefined && nodeId === link.nodeB && (
<span className="text-neutral-500">
RSSI {link.rssiAtoB} dBm
</span>
)}
</div>
</div>
<div className="text-xs text-neutral-500 ml-3 shrink-0">
{formatLastSeen(secondsAgo)}
</div>
</div>
name={neighborName}
nameHref={`/node/${neighborId.toString(16)}`}
snrValues={snrValues}
badges={badges}
time={formatLastSeenShort(secondsAgo)}
/>
);
})}
</div>
</ConnectionList>
);
};

View File

@@ -1,9 +1,10 @@
import React from "react";
import { Link } from "@tanstack/react-router";
import { Packet } from "../../lib/types";
import { Network, ExternalLink } from "lucide-react";
import { Network } from "lucide-react";
import { PacketCard } from "./PacketCard";
import { useAppSelector } from "../../hooks";
import { ConnectionRow, ConnectionList } from "../ui/ConnectionRow";
interface NeighborInfoPacketProps {
packet: Packet;
@@ -66,47 +67,17 @@ export const NeighborInfoPacket: React.FC<NeighborInfoPacketProps> = ({ packet }
{/* Neighbors list */}
{neighborInfo.neighbors && neighborInfo.neighbors.length > 0 ? (
<div className="flex flex-col gap-2">
<div className="text-sm text-neutral-400">
{neighborInfo.neighbors.length} neighbor{neighborInfo.neighbors.length !== 1 ? 's' : ''}:
</div>
<div className="grid gap-2">
{neighborInfo.neighbors.map((neighbor, index) => (
<div key={index} className="bg-neutral-800/50 rounded p-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Link
to="/node/$nodeId"
params={{ nodeId: neighbor.nodeId.toString(16) }}
className="text-blue-400 hover:text-blue-300 transition-colors font-mono flex items-center gap-1"
>
{getNodeName(neighbor.nodeId)}
<ExternalLink className="w-3 h-3" />
</Link>
</div>
<div className="flex items-center gap-3 text-xs text-neutral-400">
<div>
<span className="text-neutral-500 mr-1">SNR:</span>
<span className={neighbor.snr > 0 ? "text-green-400" : neighbor.snr > -10 ? "text-yellow-400" : "text-red-400"}>
{neighbor.snr.toFixed(1)}dB
</span>
</div>
{neighbor.lastRxTime && (
<div>
<span className="text-neutral-500 mr-1">Last:</span>
<span>{formatTime(neighbor.lastRxTime)}</span>
</div>
)}
{neighbor.nodeBroadcastIntervalSecs && (
<div>
<span className="text-neutral-500 mr-1">Interval:</span>
<span>{formatInterval(neighbor.nodeBroadcastIntervalSecs)}</span>
</div>
)}
</div>
</div>
))}
</div>
</div>
<ConnectionList count={neighborInfo.neighbors.length} label="neighbor">
{neighborInfo.neighbors.map((neighbor, index) => (
<ConnectionRow
key={index}
name={getNodeName(neighbor.nodeId)}
nameHref={`/node/${neighbor.nodeId.toString(16)}`}
snrValues={[{ label: "", value: neighbor.snr }]}
time={neighbor.lastRxTime ? formatTime(neighbor.lastRxTime) : undefined}
/>
))}
</ConnectionList>
) : (
<div className="text-sm text-neutral-500 italic">No neighbors reported</div>
)}

View File

@@ -0,0 +1,89 @@
import React from "react";
import { Link } from "@tanstack/react-router";
interface ConnectionListProps {
count: number;
label?: string;
children: React.ReactNode;
}
export const ConnectionList: React.FC<ConnectionListProps> = ({ count, label = "connection", children }) => (
<div>
<div className="divide-y divide-neutral-700/50">{children}</div>
<p className="text-xs text-neutral-500 mt-2 px-1">
{count} {label}{count !== 1 ? "s" : ""}
</p>
</div>
);
export interface SnrValue {
label: string; // e.g. "↑" or "↓" or ""
value: number;
}
export interface ConnectionBadge {
label: string;
className: string;
}
interface ConnectionRowProps {
name: string;
/** If provided, the name becomes a link */
nameHref?: string;
snrValues?: SnrValue[];
badges?: ConnectionBadge[];
time?: string;
}
function snrColor(snr: number): string {
if (snr >= 5) return "text-green-400";
if (snr >= 0) return "text-yellow-400";
return "text-red-400";
}
export const ConnectionRow: React.FC<ConnectionRowProps> = ({
name,
nameHref,
snrValues,
badges,
time,
}) => {
return (
<div className="flex items-center gap-3 px-1 py-1.5 text-xs">
{/* Name */}
<span className="font-mono text-xs text-white font-medium w-24 shrink-0 truncate">
{nameHref ? (
<Link to={nameHref} className="cursor-pointer hover:text-blue-300 transition-colors">
{name}
</Link>
) : (
name
)}
</span>
{/* SNR values */}
<span className="font-mono w-28 shrink-0 text-neutral-400">
{snrValues?.map((s, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="text-neutral-600 mx-1">·</span>}
<span className={snrColor(s.value)}>
{s.label}{s.value.toFixed(1)}
</span>
</React.Fragment>
))}
</span>
{/* Badges */}
<div className="flex gap-1 flex-1 min-w-0">
{badges?.map((b, i) => (
<span key={i} className={`px-1.5 py-0.5 rounded font-medium ${b.className}`}>
{b.label}
</span>
))}
</div>
{/* Time */}
{time && <span className="text-neutral-500 shrink-0">{time}</span>}
</div>
);
};

View File

@@ -1,3 +1,5 @@
export { Button } from './Button';
export { Section } from './Section';
export { KeyValuePair } from './KeyValuePair';
export { KeyValuePair } from './KeyValuePair';
export { ConnectionRow, ConnectionList } from './ConnectionRow';
export type { SnrValue, ConnectionBadge } from './ConnectionRow';

View File

@@ -197,6 +197,16 @@ export function formatLastSeen(secondsAgo: number): string {
}
}
/**
* Formats a "last seen" time in compact form: "45s", "5m", "2h", "3d"
*/
export function formatLastSeenShort(secondsAgo: number): string {
if (secondsAgo < 60) return `${secondsAgo}s`;
if (secondsAgo < 3600) return `${Math.floor(secondsAgo / 60)}m`;
if (secondsAgo < 86400) return `${Math.floor(secondsAgo / 3600)}h`;
return `${Math.floor(secondsAgo / 86400)}d`;
}
/**
* Gets style classes based on the activity level
*

View File

@@ -25,7 +25,7 @@ export const CARTO_DARK_STYLE: StyleSpecification = {
/** CartoDB Dark Matter style with glyph support for GL text labels */
export const CARTO_DARK_STYLE_LABELLED: StyleSpecification = {
version: 8,
glyphs: "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf",
glyphs: "https://fonts.openmaptiles.org/{fontstack}/{range}.pbf",
sources: { carto: CARTO_SOURCE },
layers: [{ id: "carto-dark", type: "raster", source: "carto" }],
};

View File

@@ -36,7 +36,7 @@ function MapPage() {
<div className="mt-2 rounded-lg p-2 text-xs flex items-center justify-between effect-inset">
<div className="flex flex-wrap gap-2">
<div className="flex items-center gap-2">
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded ${getNodeColors(ActivityLevel.RECENT, false).textClass} ${getNodeColors(ActivityLevel.RECENT, false).background}`}>
<span className={`w-2 h-2 ${getNodeColors(ActivityLevel.RECENT, false).statusDot} rounded-full mr-1.5`}></span>
Nodes
@@ -45,6 +45,22 @@ function MapPage() {
<span className={`w-2 h-2 ${getNodeColors(ActivityLevel.RECENT, true).statusDot} rounded-full mr-1.5`}></span>
Gateways
</span>
<span className="text-neutral-500">·</span>
<span className="inline-flex items-center gap-1.5 text-neutral-300">
<span className="inline-block w-5 h-0.5 rounded-full bg-[#22c55e]"></span>SNR 5
</span>
<span className="inline-flex items-center gap-1.5 text-neutral-300">
<span className="inline-block w-5 h-0.5 rounded-full bg-[#eab308]"></span>SNR 04
</span>
<span className="inline-flex items-center gap-1.5 text-neutral-300">
<span className="inline-block w-5 h-0.5 rounded-full bg-[#ef4444]"></span>SNR &lt; 0
</span>
<span className="inline-flex items-center gap-1.5 text-neutral-300">
<span className="inline-block w-5 h-0.5 rounded-full bg-[#6b7280]"></span>Unknown
</span>
<span className="inline-flex items-center gap-1.5 text-neutral-300">
<span className="inline-block w-5 border-t-2 border-dashed border-[#a855f7] opacity-80"></span>MQTT
</span>
</div>

View File

@@ -66,6 +66,30 @@
filter: invert(1) opacity(1);
}
/* MapLibre popup — dark theme */
.maplibregl-popup-content {
background: rgba(18, 18, 18, 0.95) !important;
border: 1px solid rgba(255, 255, 255, 0.12) !important;
border-radius: 8px !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5) !important;
backdrop-filter: blur(8px);
padding: 12px 14px !important;
color: rgba(255, 255, 255, 0.85);
}
.maplibregl-popup-tip {
border-top-color: rgba(18, 18, 18, 0.95) !important;
}
.maplibregl-popup-close-button {
color: rgba(255, 255, 255, 0.4) !important;
font-size: 16px !important;
line-height: 1 !important;
padding: 6px 8px !important;
}
.maplibregl-popup-close-button:hover {
color: rgba(255, 255, 255, 0.8) !important;
background: transparent !important;
}
/* Prevent auto-expand on wide maps */
.maplibregl-ctrl-attrib.maplibregl-compact-show {
display: flex !important;