diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index a09ee04..4d67b82 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -18,7 +18,7 @@ import { } from './bootstrap/caches.js'; import { getNodes, getNodeHistory, getNodeAdverts, getPathHistoryCache, getRecentPacketEvents, getRecentPackets, query } from '../db/index.js'; import { resolveRequestNetwork } from '../http/requestScope.js'; -import { autoLinkOwnerNodeIds, buildOwnerDashboard, resolveOwnerNodeIds, verifyMqttCredentials } from '../owner/ownerAccess.js'; +import { autoLinkOwnerNodeIds, buildOwnerDashboard, listMappedOwnerNodeIds, resolveOwnerNodeIds, verifyMqttCredentials } from '../owner/ownerAccess.js'; import { encryptOwnerSession, getOwnerSession, isSecureRequest } from '../owner/ownerSession.js'; import { getResolveCache, setResolveCache } from '../path-beta/resolveCache.js'; import { resolvePool } from '../path-beta/resolvePool.js'; @@ -52,6 +52,7 @@ router.use(nodeStatusRoutes); router.use(radioRoutes); const OWNER_COOKIE_NAME = 'meshcore_owner_session'; const OWNER_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const OWNER_LAST_HOP_CACHE_TTL_MS = 60 * 60 * 1000; const MQTT_USERNAME_MAX_LEN = 128; const MQTT_PASSWORD_MAX_LEN = 128; @@ -99,6 +100,7 @@ registerOwnerRoutes(router, { ownerCookieName: OWNER_COOKIE_NAME, ownerLiveCacheTtlMs: OWNER_LIVE_CACHE_TTL_MS, ownerLiveCache, + ownerLastHopCacheTtlMs: OWNER_LAST_HOP_CACHE_TTL_MS, ownerSessionTtlMs: OWNER_SESSION_TTL_MS, mqttUsernameMaxLen: MQTT_USERNAME_MAX_LEN, mqttPasswordMaxLen: MQTT_PASSWORD_MAX_LEN, @@ -107,6 +109,7 @@ registerOwnerRoutes(router, { verifyMqttCredentials, resolveOwnerNodeIds, autoLinkOwnerNodeIds, + listMappedOwnerNodeIds, buildOwnerDashboard, encryptOwnerSession, isSecureRequest, diff --git a/backend/src/api/routes/owner.ts b/backend/src/api/routes/owner.ts index 742a012..d3b84c5 100644 --- a/backend/src/api/routes/owner.ts +++ b/backend/src/api/routes/owner.ts @@ -27,6 +27,7 @@ type OwnerRouteDeps = { ownerCookieName: string; ownerLiveCacheTtlMs: number; ownerLiveCache: Map; + ownerLastHopCacheTtlMs: number; ownerSessionTtlMs: number; mqttUsernameMaxLen: number; mqttPasswordMaxLen: number; @@ -35,6 +36,7 @@ type OwnerRouteDeps = { verifyMqttCredentials: VerifyMqttCredentialsFn; resolveOwnerNodeIds: ResolveOwnerNodeIdsFn; autoLinkOwnerNodeIds: ResolveOwnerNodeIdsFn; + listMappedOwnerNodeIds: () => Promise; buildOwnerDashboard: BuildOwnerDashboardFn; encryptOwnerSession: EncryptOwnerSessionFn; isSecureRequest: IsSecureRequestFn; @@ -51,9 +53,11 @@ export function registerOwnerRoutes(router: Router, deps: OwnerRouteDeps): void const service = createOwnerService({ ownerLiveCacheTtlMs: deps.ownerLiveCacheTtlMs, ownerLiveCache: deps.ownerLiveCache, + ownerLastHopCacheTtlMs: deps.ownerLastHopCacheTtlMs, verifyMqttCredentials: deps.verifyMqttCredentials, resolveOwnerNodeIds: deps.resolveOwnerNodeIds, autoLinkOwnerNodeIds: deps.autoLinkOwnerNodeIds, + listMappedOwnerNodeIds: deps.listMappedOwnerNodeIds, buildOwnerDashboard: deps.buildOwnerDashboard, repository, }); diff --git a/backend/src/http/requestScope.ts b/backend/src/http/requestScope.ts index 23c049f..3986294 100644 --- a/backend/src/http/requestScope.ts +++ b/backend/src/http/requestScope.ts @@ -32,8 +32,8 @@ export function inferForcedNetwork(headers: IncomingHttpHeaders): ForcedScope | const candidates = [host, origin, referer]; if (candidates.some((value) => value === 'test.ukmesh.com' || value.endsWith('.test.ukmesh.com'))) return 'test'; - if (candidates.some((value) => value === 'app.ukmesh.com' || value === 'www.ukmesh.com')) return 'all'; - if (candidates.some((value) => value === 'app.teessidemesh.com' || value === 'www.teessidemesh.com')) return 'teesside'; + if (candidates.some((value) => value === 'app.ukmesh.com' || value === 'www.ukmesh.com' || value === 'ukmesh.com')) return 'all'; + if (candidates.some((value) => value === 'app.teessidemesh.com' || value === 'www.teessidemesh.com' || value === 'teessidemesh.com')) return 'teesside'; return undefined; } diff --git a/backend/src/owner/ownerAccess.ts b/backend/src/owner/ownerAccess.ts index 5e3db0a..4985b58 100644 --- a/backend/src/owner/ownerAccess.ts +++ b/backend/src/owner/ownerAccess.ts @@ -1,6 +1,6 @@ import mqtt from 'mqtt'; import { randomBytes } from 'node:crypto'; -import { addOwnerNodeForUsername, getBestNodeForMqttUsername, getOwnerNodeIdsForUsername } from '../db/ownerAuth.js'; +import { addOwnerNodeForUsername, getBestNodeForMqttUsername, getMappedOwnerNodeIds, getOwnerNodeIdsForUsername } from '../db/ownerAuth.js'; import { query } from '../db/index.js'; function parseOwnerMqttUsernameMap(): Map { @@ -44,6 +44,10 @@ export async function autoLinkOwnerNodeIds(mqttUsername: string): Promise { + return getMappedOwnerNodeIds(); +} + export function verifyMqttCredentials(mqttUsername: string, mqttPassword: string): Promise { const brokerUrl = String(process.env['MQTT_BROKER_URL'] ?? 'ws://mosquitto:9001'); const clientId = `owner-auth-${randomBytes(6).toString('hex')}`; diff --git a/backend/src/owner/ownerRepository.ts b/backend/src/owner/ownerRepository.ts index a3e2e41..83c9a48 100644 --- a/backend/src/owner/ownerRepository.ts +++ b/backend/src/owner/ownerRepository.ts @@ -60,7 +60,7 @@ export function createOwnerRepository(deps: OwnerRepositoryDeps) { ROW_NUMBER() OVER (PARTITION BY uh.rx_node_id, uh.receiver_side_hash ORDER BY n.node_id) AS rn FROM unique_receiver_targets uh JOIN nodes n - ON (n.role IS NULL OR n.role = 2) + ON (n.role IS NULL OR n.role NOT IN (1, 3)) AND UPPER(n.node_id) LIKE uh.receiver_side_hash || '%' ), inferred_last_hop AS ( @@ -84,7 +84,7 @@ export function createOwnerRepository(deps: OwnerRepositoryDeps) { FROM unique_receiver_targets uh JOIN nodes rx ON rx.node_id = uh.rx_node_id JOIN nodes n - ON (n.role IS NULL OR n.role = 2) + ON (n.role IS NULL OR n.role NOT IN (1, 3)) AND UPPER(LEFT(n.node_id, 2)) = UPPER(LEFT(uh.receiver_side_hash, 2)) LEFT JOIN node_links nl ON ( @@ -101,19 +101,34 @@ export function createOwnerRepository(deps: OwnerRepositoryDeps) { SELECT time_bucket('1 hour', op.time)::text AS bucket, CASE - WHEN op.hop_count = 0 AND op.src_node_id IS NOT NULL AND NOT (op.src_node_id = ANY($1::text[])) THEN op.src_node_id + WHEN op.hop_count = 0 + AND op.src_node_id IS NOT NULL + AND NOT (op.src_node_id = ANY($1::text[])) + AND src.node_id IS NOT NULL + AND (src.role IS NULL OR src.role NOT IN (1, 3)) + THEN op.src_node_id WHEN rl.match_count = 1 AND rl.rn = 1 THEN rl.node_id WHEN ilh.rn = 1 THEN ilh.node_id ELSE NULL END AS last_hop_node_id, CASE - WHEN op.hop_count = 0 AND op.src_node_id IS NOT NULL AND NOT (op.src_node_id = ANY($1::text[])) THEN COALESCE(src.name, op.src_node_id) + WHEN op.hop_count = 0 + AND op.src_node_id IS NOT NULL + AND NOT (op.src_node_id = ANY($1::text[])) + AND src.node_id IS NOT NULL + AND (src.role IS NULL OR src.role NOT IN (1, 3)) + THEN COALESCE(src.name, op.src_node_id) WHEN rl.match_count = 1 AND rl.rn = 1 THEN rl.name WHEN ilh.rn = 1 THEN ilh.name ELSE 'Unresolved' END AS last_hop_name, CASE - WHEN op.hop_count = 0 AND op.src_node_id IS NOT NULL AND NOT (op.src_node_id = ANY($1::text[])) THEN 'direct' + WHEN op.hop_count = 0 + AND op.src_node_id IS NOT NULL + AND NOT (op.src_node_id = ANY($1::text[])) + AND src.node_id IS NOT NULL + AND (src.role IS NULL OR src.role NOT IN (1, 3)) + THEN 'direct' WHEN rl.match_count = 1 AND rl.rn = 1 THEN 'resolved' WHEN ilh.rn = 1 THEN 'inferred' ELSE 'unresolved' diff --git a/backend/src/owner/ownerService.ts b/backend/src/owner/ownerService.ts index 14b5ab1..9f3755d 100644 --- a/backend/src/owner/ownerService.ts +++ b/backend/src/owner/ownerService.ts @@ -25,9 +25,11 @@ type LastHopStrengthPoint = { type OwnerServiceDeps = { ownerLiveCacheTtlMs: number; ownerLiveCache: Map; + ownerLastHopCacheTtlMs: number; verifyMqttCredentials: (mqttUsername: string, mqttPassword: string) => Promise; resolveOwnerNodeIds: (mqttUsername: string) => Promise; autoLinkOwnerNodeIds: (mqttUsername: string) => Promise; + listMappedOwnerNodeIds: () => Promise; buildOwnerDashboard: (nodeIds: string[]) => Promise; repository: OwnerRepository; }; @@ -38,13 +40,59 @@ export function createOwnerService(deps: OwnerServiceDeps) { const { ownerLiveCacheTtlMs, ownerLiveCache, + ownerLastHopCacheTtlMs, verifyMqttCredentials, resolveOwnerNodeIds, autoLinkOwnerNodeIds, + listMappedOwnerNodeIds, buildOwnerDashboard, repository, } = deps; const ownerLastHopCache = new Map(); + let ownerLastHopRefreshInFlight = false; + + async function buildOwnerLastHopResponse(nodeId: string): Promise<{ points: LastHopStrengthPoint[] }> { + const lastHopStrengthResult = await repository.fetchLastHopStrength([nodeId]); + return { + points: lastHopStrengthResult.rows.map((row) => ({ + bucket: new Date(row.bucket).toISOString(), + lastHopNodeId: row.last_hop_node_id, + lastHopName: row.last_hop_name, + resolution: row.resolution, + avgSnr: row.avg_snr == null ? null : Number(row.avg_snr.toFixed(2)), + avgRssi: row.avg_rssi == null ? null : Number(row.avg_rssi.toFixed(2)), + sampleCount: Number(row.sample_count ?? 0), + })), + }; + } + + async function refreshOwnerLastHopCacheForNode(nodeId: string): Promise { + const responseData = await buildOwnerLastHopResponse(nodeId); + ownerLastHopCache.set(nodeId, { ts: Date.now(), data: responseData }); + } + + async function refreshAllOwnerLastHopCaches(tag: 'initial' | 'scheduled'): Promise { + if (ownerLastHopRefreshInFlight) { + console.warn(`[owner-last-hop] ${tag} refresh skipped; previous refresh still running`); + return; + } + ownerLastHopRefreshInFlight = true; + try { + const nodeIds = Array.from(new Set((await listMappedOwnerNodeIds()).map((nodeId) => nodeId.trim().toUpperCase()).filter(Boolean))); + for (const nodeId of nodeIds) { + try { + await refreshOwnerLastHopCacheForNode(nodeId); + } catch (err) { + console.error(`[owner-last-hop] failed to refresh ${nodeId}:`, (err as Error).message); + } + } + console.log(`[owner-last-hop] ${tag} refresh complete for ${nodeIds.length} node(s)`); + } catch (err) { + console.error(`[owner-last-hop] ${tag} refresh failed`, (err as Error).message); + } finally { + ownerLastHopRefreshInFlight = false; + } + } async function authenticateOwner(mqttUsername: string, mqttPassword: string): Promise<{ dashboard: OwnerDashboard; nodeIds: string[] }> { const authOk = await verifyMqttCredentials(mqttUsername, mqttPassword); @@ -295,29 +343,23 @@ export function createOwnerService(deps: OwnerServiceDeps) { throw new Error('NODE_NOT_OWNED'); } - const cacheKey = ownedNodeIds.slice().sort().join(','); + const cacheKey = selectedNodeId; const cacheEntry = ownerLastHopCache.get(cacheKey); - if (cacheEntry && Date.now() - cacheEntry.ts < 60_000) { + if (cacheEntry && Date.now() - cacheEntry.ts < ownerLastHopCacheTtlMs) { return cacheEntry.data as { points: LastHopStrengthPoint[] }; } - const lastHopStrengthResult = await repository.fetchLastHopStrength(ownedNodeIds); - const responseData = { - points: lastHopStrengthResult.rows.map((row) => ({ - bucket: new Date(row.bucket).toISOString(), - lastHopNodeId: row.last_hop_node_id, - lastHopName: row.last_hop_name, - resolution: row.resolution, - avgSnr: row.avg_snr == null ? null : Number(row.avg_snr.toFixed(2)), - avgRssi: row.avg_rssi == null ? null : Number(row.avg_rssi.toFixed(2)), - sampleCount: Number(row.sample_count ?? 0), - })), - }; - + const responseData = await buildOwnerLastHopResponse(selectedNodeId); ownerLastHopCache.set(cacheKey, { ts: Date.now(), data: responseData }); return responseData; } + void refreshAllOwnerLastHopCaches('initial'); + const ownerLastHopRefreshTimer = setInterval(() => { + void refreshAllOwnerLastHopCaches('scheduled'); + }, ownerLastHopCacheTtlMs); + ownerLastHopRefreshTimer.unref?.(); + return { authenticateOwner, getSessionDashboard, diff --git a/docker-compose.yml b/docker-compose.yml index 7059d46..5bf34c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,7 +54,7 @@ services: OWNER_DATABASE_URL: ${OWNER_DATABASE_URL:-postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${OWNER_POSTGRES_DB:-meshcore_owner_auth}} OWNER_MQTT_USERNAME_MAP: ${OWNER_MQTT_USERNAME_MAP:-} OWNER_COOKIE_SECRET: ${OWNER_COOKIE_SECRET:-} - ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3001,http://localhost:3002},https://test.ukmesh.com + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3001,http://localhost:3002,https://ukmesh.com,https://www.ukmesh.com,https://app.ukmesh.com,https://teessidemesh.com,https://www.teessidemesh.com,https://app.teessidemesh.com,https://test.ukmesh.com} RADIO_BOT_URL: ${RADIO_BOT_URL:-} COVERAGE_MODEL_VERSION: ${COVERAGE_MODEL_VERSION:-5} NODE_ENV: production diff --git a/frontend/src/config/site.ts b/frontend/src/config/site.ts index 542ebfc..641ef8d 100644 --- a/frontend/src/config/site.ts +++ b/frontend/src/config/site.ts @@ -38,7 +38,7 @@ const SITE_CONFIGS: Record = { network: 'ukmesh', networkFilter: 'ukmesh', appUrl: 'https://app.ukmesh.com', - appHomeUrl: 'https://www.ukmesh.com', + appHomeUrl: 'https://ukmesh.com', mapHomeUrl: 'https://app.ukmesh.com', }, dev: { diff --git a/frontend/src/globals.css b/frontend/src/globals.css index 768f4b6..e727ea4 100644 --- a/frontend/src/globals.css +++ b/frontend/src/globals.css @@ -1584,6 +1584,8 @@ html, body, #root { .owner-map { width: 100%; height: 100%; + min-height: 280px; + background: var(--bg-base); } .owner-packets { display: flex; diff --git a/frontend/src/pages/OwnerPortalPage.tsx b/frontend/src/pages/OwnerPortalPage.tsx index 6bb8122..6000db3 100644 --- a/frontend/src/pages/OwnerPortalPage.tsx +++ b/frontend/src/pages/OwnerPortalPage.tsx @@ -2,6 +2,7 @@ import React, { FormEvent, useEffect, useMemo, useState } from 'react'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { Area, AreaChart, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { DEFAULT_CENTER, MAP_STYLE } from '../components/Map/mapConfig'; type OwnerNode = { node_id: string; @@ -260,12 +261,13 @@ const OwnerTelemetryTooltip: React.FC<{ }; const LAST_HOP_COLORS = ['#00c4ff', '#6ddc7a', '#ff9f43', '#f472b6', '#a78bfa', '#facc15']; +const MIN_LAST_HOP_SAMPLES = 10; const LastHopStrengthTooltip: React.FC<{ active?: boolean; payload?: Array<{ dataKey: string; value: number; color: string; payload: Record }>; label?: string; - seriesByKey: Map; + seriesByKey: Map; }> = ({ active, payload, label, seriesByKey }) => { if (!active || !payload?.length) return null; return ( @@ -294,23 +296,35 @@ const LastHopStrengthChart: React.FC<{ points: LastHopStrengthPoint[] }> = ({ po ); const chartState = useMemo(() => { - const totals = new Map(); + const totals = new Map; + resolution: LastHopStrengthPoint['resolution']; + }>(); for (const point of visiblePoints) { const key = point.lastHopNodeId ?? `unresolved:${point.lastHopName}`; const existing = totals.get(key); if (existing) { existing.totalSamples += point.sampleCount; + existing.buckets.add(point.bucket); + existing.bucketCount = existing.buckets.size; } else { totals.set(key, { key, label: point.lastHopName, totalSamples: point.sampleCount, + bucketCount: 1, + buckets: new Set([point.bucket]), resolution: point.resolution, }); } } const allSeries = Array.from(totals.values()) + .filter((series) => series.totalSamples >= MIN_LAST_HOP_SAMPLES) .sort((a, b) => b.totalSamples - a.totalSamples || a.label.localeCompare(b.label)) const selected = selectedSeriesKey ? allSeries.filter((series) => series.key === selectedSeriesKey) @@ -352,11 +366,11 @@ const LastHopStrengthChart: React.FC<{ points: LastHopStrengthPoint[] }> = ({ po
-
No received signal samples yet
+
No last-hop repeaters with 10+ samples yet
Last 7d - No samples + Minimum 10 samples
); @@ -412,7 +426,7 @@ const LastHopStrengthChart: React.FC<{ points: LastHopStrengthPoint[] }> = ({ po
Last 7d - {selectedSeriesKey ? 'Filtered to one repeater' : `${chartState.allSeries.length} repeaters`} + {selectedSeriesKey ? 'Filtered to one repeater' : `${chartState.allSeries.length} repeaters with 10+ samples`}
))}
@@ -522,8 +536,6 @@ const TelemetryStatCard: React.FC<{ ); -const CARTO_DARK_TILES = 'https://{a-d}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; - const OwnerMapView: React.FC<{ ownerCoord: { lat: number; lon: number } | null; peers: MappedPeer[]; @@ -536,25 +548,60 @@ const OwnerMapView: React.FC<{ const map = new maplibregl.Map({ container: containerRef.current, - style: { - version: 8, - sources: { tiles: { type: 'raster', tiles: [CARTO_DARK_TILES], tileSize: 256, maxzoom: 19, attribution: '© OpenStreetMap © CARTO' } }, - layers: [{ id: 'bg', type: 'raster', source: 'tiles' }], - }, - center: [-1.2, 54.6], + style: MAP_STYLE, + center: [DEFAULT_CENTER[1], DEFAULT_CENTER[0]], zoom: 7, attributionControl: false, }); - // Disable interaction — purely static display + // Keep the map as a static regional backdrop while preserving marker clicks. map.scrollZoom.disable(); - map.dragPan.disable(); map.boxZoom.disable(); + map.dragPan.disable(); + map.dragRotate.disable(); map.doubleClickZoom.disable(); map.keyboard.disable(); map.touchZoomRotate.disable(); const EMPTY: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] }; + const popup = new maplibregl.Popup({ + closeButton: true, + closeOnClick: true, + offset: 14, + maxWidth: '260px', + }); + + const applyViewport = () => { + map.resize(); + if (allPoints.length === 1) { + map.setCenter([allPoints[0].lon, allPoints[0].lat]); + map.setZoom(8); + return; + } + if (allPoints.length > 1) { + const centerPoints = peers.length > 0 ? peers : allPoints; + const centerLons = centerPoints.map((pt) => pt.lon); + const centerLats = centerPoints.map((pt) => pt.lat); + const centerLon = (Math.min(...centerLons) + Math.max(...centerLons)) / 2; + const centerLat = (Math.min(...centerLats) + Math.max(...centerLats)) / 2; + const maxLonDelta = Math.max(...allPoints.map((pt) => Math.abs(pt.lon - centerLon))); + const maxLatDelta = Math.max(...allPoints.map((pt) => Math.abs(pt.lat - centerLat))); + const lonExtent = Math.max(0.02, maxLonDelta * 2.25); + const latExtent = Math.max(0.02, maxLatDelta * 2.25); + const paddedBounds = new maplibregl.LngLatBounds( + [centerLon - lonExtent, centerLat - latExtent], + [centerLon + lonExtent, centerLat + latExtent], + ); + const padding = { top: 24, right: 24, bottom: 24, left: 24 }; + const camera = map.cameraForBounds(paddedBounds, { padding }); + map.jumpTo({ + center: [centerLon, centerLat], + zoom: camera?.zoom ?? map.getZoom(), + bearing: 0, + pitch: 0, + }); + } + }; const buildNodeFC = (): GeoJSON.FeatureCollection => ({ type: 'FeatureCollection', @@ -562,12 +609,20 @@ const OwnerMapView: React.FC<{ ...(ownerCoord ? [{ type: 'Feature' as const, geometry: { type: 'Point' as const, coordinates: [ownerCoord.lon, ownerCoord.lat] }, - properties: { kind: 'owner' }, + properties: { + kind: 'owner', + name: 'Selected owner node', + details: ownerCoord ? `${ownerCoord.lat.toFixed(4)}, ${ownerCoord.lon.toFixed(4)}` : '', + }, }] : []), ...peers.map((p) => ({ type: 'Feature' as const, geometry: { type: 'Point' as const, coordinates: [p.lon, p.lat] }, - properties: { kind: 'peer' }, + properties: { + kind: 'peer', + name: p.name ?? p.node_id, + details: `${p.packets_24h} packets / 24h${p.iata ? ` · ${p.iata}` : ''}${p.network ? ` · ${p.network}` : ''}`, + }, })), ], }); @@ -589,23 +644,42 @@ const OwnerMapView: React.FC<{ paint: { 'line-color': '#00c4ff', 'line-width': 1.5, 'line-opacity': 0.6 } }); map.addLayer({ id: 'owner-nodes-layer', type: 'circle', source: 'owner-nodes', paint: { - 'circle-radius': ['case', ['==', ['get', 'kind'], 'owner'], 8, 6], - 'circle-color': 'transparent', + 'circle-radius': ['case', ['==', ['get', 'kind'], 'owner'], 8, 6.5], + 'circle-color': ['case', ['==', ['get', 'kind'], 'owner'], 'rgba(0,196,255,0.18)', 'rgba(255,179,0,0.18)'], 'circle-stroke-width': 2, 'circle-stroke-color': ['case', ['==', ['get', 'kind'], 'owner'], '#00c4ff', '#ffb300'], } }); - if (allPoints.length === 1) { - map.setCenter([allPoints[0].lon, allPoints[0].lat]); - map.setZoom(10); - } else if (allPoints.length > 1) { - const bounds = new maplibregl.LngLatBounds(); - for (const pt of allPoints) bounds.extend([pt.lon, pt.lat]); - map.fitBounds(bounds, { padding: 24, animate: false }); - } + map.on('mouseenter', 'owner-nodes-layer', () => { + map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', 'owner-nodes-layer', () => { + map.getCanvas().style.cursor = ''; + }); + map.on('click', 'owner-nodes-layer', (event) => { + const feature = event.features?.[0]; + if (!feature || feature.geometry.type !== 'Point') return; + const coordinates = feature.geometry.coordinates.slice() as [number, number]; + const name = String(feature.properties?.name ?? 'Node'); + const details = String(feature.properties?.details ?? ''); + popup + .setLngLat(coordinates) + .setHTML(`
${name}
${details}
`) + .addTo(map); + }); + + applyViewport(); + requestAnimationFrame(applyViewport); }); + const resizeObserver = new ResizeObserver(() => { + applyViewport(); + }); + resizeObserver.observe(containerRef.current); + return () => { + resizeObserver.disconnect(); + popup.remove(); map.remove(); void EMPTY; // silence unused warning }; @@ -932,10 +1006,7 @@ export const OwnerPortalPage: React.FC = () => {
-
-

Direct Sender Map

-

0-hop direct senders in the last 24 hours. Nodes at 0,0 are hidden.

-
+

Direct Sender Map

diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 42bb2fe..7ef1b31 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -1624,6 +1624,8 @@ html, body, #root { .owner-map { width: 100%; height: 100%; + min-height: 280px; + background: var(--bg-base); } .owner-packets { display: flex;