Merge branch 'ajvpot:main' into main

This commit is contained in:
Bryant Kelley
2025-08-01 17:01:59 -07:00
committed by GitHub
4 changed files with 143 additions and 70 deletions
+4 -3
View File
@@ -75,10 +75,11 @@ export default function ChatBox({ showAllMessagesTab = false, className = "", st
const res = await fetch(buildApiUrl(url));
const data = await res.json();
if (Array.isArray(data)) {
if (after && data.length > 0) {
if (after) {
// Add newer messages to the beginning (most recent first)
setMessages((prev) => [...data, ...prev]);
// Don't update hasMore or lastBefore for auto-refresh
if (data.length > 0) {
setMessages((prev) => [...data, ...prev]);
}
} else {
setMessages((prev) => replace ? data : [...prev, ...data]);
setHasMore(data.length === PAGE_SIZE);
+7 -3
View File
@@ -1,5 +1,7 @@
import React from 'react';
import moment from "moment";
import { formatPublicKey } from '../lib/meshcore';
import { getNameIconLabel } from '../lib/meshcore-map-nodeutils';
type NodePosition = {
node_id: string;
@@ -39,7 +41,9 @@ export function NodeMarker({ node, showNodeNames = true }: NodeMarkerProps) {
return (
<div className="custom-node-marker-container">
{showNodeNames && node.short_name && (
<div className="custom-node-label">{node.short_name}</div>
<div className="custom-node-label">
{node.type === "meshcore" ? getNameIconLabel(node.name || node.short_name) : node.short_name}
</div>
)}
<div className={getMarkerClass()}></div>
</div>
@@ -144,9 +148,9 @@ export function ClusterMarker({ children }: ClusterMarkerProps) {
export function PopupContent({ node }: PopupContentProps) {
return (
<div>
<div><b>ID:</b> {node.node_id}</div>
<div><b>ID:</b> {node.type === "meshcore" ? formatPublicKey(node.node_id) : node.node_id}</div>
<div><b>Full Name:</b> {node.name ?? "-"}</div>
<div><b>Short Name:</b> {node.short_name ?? "-"}</div>
<div><b>Short Name:</b> {node.type === "meshcore" && node.short_name ? getNameIconLabel(node.name || node.short_name) : (node.short_name ?? "-")}</div>
<div><b>Type:</b> {node.type ?? "-"}</div>
<div><b>Lat:</b> {node.latitude}</div>
<div><b>Lng:</b> {node.longitude}</div>
+126 -64
View File
@@ -31,76 +31,137 @@ type NodePosition = {
};
type ClusteredMarkersProps = { nodes: NodePosition[] };
function ClusteredMarkers({ nodes }: ClusteredMarkersProps) {
// Individual marker component
function IndividualMarker({ node, showNodeNames }: { node: NodePosition; showNodeNames: boolean }) {
const map = useMap();
const configResult = useConfig();
const config = configResult?.config;
const markerRef = useRef<L.Marker | null>(null);
useEffect(() => {
if (!map) return;
// Remove any previous layers
map.eachLayer((layer: any) => {
if (layer && layer._isClusterLayer) {
map.removeLayer(layer);
}
const icon = L.divIcon({
className: 'custom-node-marker-container',
iconSize: [16, 32],
iconAnchor: [8, 8],
html: renderToString(<NodeMarker node={node} showNodeNames={showNodeNames} />),
});
if (config?.clustering === false) {
// Add markers individually
const markerLayers: any[] = [];
nodes.forEach((node: NodePosition) => {
const icon = L.divIcon({
className: 'custom-node-marker-container',
iconSize: [16, 32],
iconAnchor: [8, 8],
html: renderToString(<NodeMarker node={node} showNodeNames={config?.showNodeNames !== false} />),
});
const marker = L.marker([node.latitude, node.longitude], { icon });
(marker as any).options.nodeData = node;
marker.bindPopup(renderToString(<PopupContent node={node} />));
marker.addTo(map);
markerLayers.push(marker);
const marker = L.marker([node.latitude, node.longitude], { icon });
(marker as any).options.nodeData = node;
marker.bindPopup(renderToString(<PopupContent node={node} />));
marker.addTo(map);
markerRef.current = marker;
return () => {
if (markerRef.current && map.hasLayer(markerRef.current)) {
map.removeLayer(markerRef.current);
}
};
}, [map, node.latitude, node.longitude, node.node_id, showNodeNames]);
// Update marker when node data changes
useEffect(() => {
if (markerRef.current) {
const currentPos = markerRef.current.getLatLng();
if (currentPos.lat !== node.latitude || currentPos.lng !== node.longitude) {
markerRef.current.setLatLng([node.latitude, node.longitude]);
}
// Update icon and popup
const icon = L.divIcon({
className: 'custom-node-marker-container',
iconSize: [16, 32],
iconAnchor: [8, 8],
html: renderToString(<NodeMarker node={node} showNodeNames={showNodeNames} />),
});
// Mark for cleanup
markerLayers.forEach(layer => { layer._isClusterLayer = true; });
return () => {
markerLayers.forEach(layer => map.removeLayer(layer));
};
} else {
// Clustered mode (existing logic)
const iconCreateFunction = (cluster: any) => {
const children = cluster.getAllChildMarkers();
return L.divIcon({
html: renderToString(<ClusterMarker>{children}</ClusterMarker>),
className: 'custom-cluster-icon',
iconSize: [40, 40],
iconAnchor: [20, 20],
});
};
const markers = (L as any).markerClusterGroup({
iconCreateFunction,
maxClusterRadius: 40,
});
nodes.forEach((node: NodePosition) => {
const icon = L.divIcon({
className: 'custom-node-marker-container',
iconSize: [16, 32],
iconAnchor: [8, 8],
html: renderToString(<NodeMarker node={node} showNodeNames={config?.showNodeNames !== false} />),
});
const marker = L.marker([node.latitude, node.longitude], { icon });
(marker as any).options.nodeData = node;
marker.bindPopup(renderToString(<PopupContent node={node} />));
markers.addLayer(marker);
});
markers._isClusterLayer = true;
map.addLayer(markers);
return () => {
map.removeLayer(markers);
};
markerRef.current.setIcon(icon);
markerRef.current.getPopup()?.setContent(renderToString(<PopupContent node={node} />));
}
}, [map, nodes, config?.clustering, config?.showNodeNames]);
}, [node, showNodeNames]);
return null;
}
// Clustered markers component
function ClusteredMarkersGroup({ nodes, showNodeNames }: { nodes: NodePosition[]; showNodeNames: boolean }) {
const map = useMap();
const clusterGroupRef = useRef<any>(null);
useEffect(() => {
if (!map) return;
const iconCreateFunction = (cluster: any) => {
const children = cluster.getAllChildMarkers();
return L.divIcon({
html: renderToString(<ClusterMarker>{children}</ClusterMarker>),
className: 'custom-cluster-icon',
iconSize: [40, 40],
iconAnchor: [20, 20],
});
};
const markers = (L as any).markerClusterGroup({
iconCreateFunction,
maxClusterRadius: 40,
});
nodes.forEach((node: NodePosition) => {
const icon = L.divIcon({
className: 'custom-node-marker-container',
iconSize: [16, 32],
iconAnchor: [8, 8],
html: renderToString(<NodeMarker node={node} showNodeNames={showNodeNames} />),
});
const marker = L.marker([node.latitude, node.longitude], { icon });
(marker as any).options.nodeData = node;
marker.bindPopup(renderToString(<PopupContent node={node} />));
markers.addLayer(marker);
});
markers._isClusterLayer = true;
map.addLayer(markers);
clusterGroupRef.current = markers;
return () => {
if (clusterGroupRef.current && map.hasLayer(clusterGroupRef.current)) {
map.removeLayer(clusterGroupRef.current);
}
};
}, [map, nodes, showNodeNames]);
return null;
}
function ClusteredMarkers({ nodes }: ClusteredMarkersProps) {
const configResult = useConfig();
const config = configResult?.config;
const showNodeNames = config?.showNodeNames !== false;
if (config?.clustering === false) {
// Render individual marker components
return (
<>
{nodes.map((node) => (
<IndividualMarker
key={node.node_id}
node={node}
showNodeNames={showNodeNames}
/>
))}
</>
);
} else {
// Render clustered markers
return (
<ClusteredMarkersGroup
nodes={nodes}
showNodeNames={showNodeNames}
/>
);
}
}
export default function MapView() {
const [nodePositions, setNodePositions] = useState<NodePosition[]>([]);
const [bounds, setBounds] = useState<[[number, number], [number, number]] | null>(null);
@@ -115,7 +176,7 @@ export default function MapView() {
const tileLayerOptions: Record<TileLayerKey, { url: string; attribution: string; maxZoom: number; subdomains?: string[] }> = {
openstreetmap: {
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: 'Tiles &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> | Data from <a target="_blank" href="https://meshtastic.org/docs/software/integrations/mqtt/">Meshtastic</a>',
attribution: 'Tiles &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 22,
},
opentopomap: {
@@ -125,7 +186,7 @@ export default function MapView() {
},
esri: {
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
attribution: 'Tiles &copy; <a href="https://developers.arcgis.com/documentation/mapping-apis-and-services/deployment/basemap-attribution/">Esri</a> | Data from <a target="_blank" href="https://meshtastic.org/docs/software/integrations/mqtt/">Meshtastic</a>',
attribution: 'Tiles &copy; <a href="https://developers.arcgis.com/documentation/mapping-apis-and-services/deployment/basemap-attribution/">Esri</a>',
maxZoom: 21,
},
};
@@ -248,6 +309,7 @@ export default function MapView() {
[b.getSouthWest().lat, b.getSouthWest().lng],
[b.getNorthEast().lat, b.getNorthEast().lng],
]);
map.attributionControl.setPrefix('map.w0z.is')
}
}, [map]);
return null;
@@ -305,7 +367,7 @@ export default function MapView() {
opacity={0.7}
/>
)}
<ClusteredMarkers nodes={nodePositions} />
<ClusteredMarkers key={`clustering-${config?.clustering}-${config?.showNodeNames}`} nodes={nodePositions} />
</MapContainer>
</div>
);
+6
View File
@@ -191,4 +191,10 @@ export async function decryptMeshcoreGroupMessage({
});
}
return null;
}
export function formatPublicKey(pubKey: string): string {
// Take the first 8 characters, add ellipsis, and then the last 8 characters
const formattedKey = `<${pubKey.slice(0, 8)}...${pubKey.slice(-8)}>`;
return formattedKey;
}