diff --git a/package-lock.json b/package-lock.json index 5937d02..d73a7b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "meshexplorer", "version": "0.1.0", "dependencies": { + "@clickhouse/client": "^1.11.2", "@headlessui/react": "^2.2.4", "@heroicons/react": "^2.2.0", "@types/leaflet": "^1.9.19", @@ -62,6 +63,22 @@ "node": ">=6.0.0" } }, + "node_modules/@clickhouse/client": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@clickhouse/client/-/client-1.11.2.tgz", + "integrity": "sha512-ZE7Q1qxsDNXCkGPf1zqmhpZpwAKxKT+1s4Z432J1Mb2Gm26Y4tG/sJoug81AfAJTt6s7taO2vzNBAKfSR3SStg==", + "dependencies": { + "@clickhouse/client-common": "1.11.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@clickhouse/client-common": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@clickhouse/client-common/-/client-common-1.11.2.tgz", + "integrity": "sha512-H4ECHqaipzMgiZKqpb1Z4N3Ofq+lVTCn8I59XsSynqrsfR4jWZD3PipXVvIzMpDmTMvrlJWrOwAdm0DMNiMQbA==" + }, "node_modules/@emnapi/core": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", diff --git a/package.json b/package.json index 6b1935b..c220928 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,13 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint" }, "dependencies": { + "@clickhouse/client": "^1.11.2", "@headlessui/react": "^2.2.4", "@heroicons/react": "^2.2.0", "@types/leaflet": "^1.9.19", diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx index 13c522f..9fc9445 100644 --- a/src/components/MapView.tsx +++ b/src/components/MapView.tsx @@ -16,7 +16,7 @@ const DEFAULT = { }; type NodePosition = { - from_node_id: string; + node_id: string; latitude: number; longitude: number; altitude?: number; @@ -56,10 +56,14 @@ function ClusteredMarkers({ nodes }: ClusteredMarkersProps) { }); const marker = L.marker([node.latitude, node.longitude], { icon }); (marker as any).options.nodeData = node; - let popupHtml = `
ID: ${node.from_node_id}
Lat: ${node.latitude}
Lng: ${node.longitude}
`; - if (node.altitude !== undefined) popupHtml += `
Alt: ${node.altitude}
`; - if (node.last_seen) popupHtml += `
Last seen: ${node.last_seen}
`; - if (node.type) popupHtml += `
Type: ${node.type}
`; + let popupHtml = `
`; + popupHtml += `
ID: ${node.node_id}
`; + popupHtml += `
Short Name: ${node.short_name ?? "-"}
`; + popupHtml += `
Type: ${node.type ?? "-"}
`; + popupHtml += `
Lat: ${node.latitude}
`; + popupHtml += `
Lng: ${node.longitude}
`; + popupHtml += `
Alt: ${node.altitude !== undefined ? node.altitude : "-"}
`; + popupHtml += `
Last seen: ${node.last_seen ?? "-"}
`; popupHtml += `
`; marker.bindPopup(popupHtml); marker.addTo(map); @@ -155,10 +159,14 @@ function ClusteredMarkers({ nodes }: ClusteredMarkersProps) { }); const marker = L.marker([node.latitude, node.longitude], { icon }); (marker as any).options.nodeData = node; - let popupHtml = `
ID: ${node.from_node_id}
Lat: ${node.latitude}
Lng: ${node.longitude}
`; - if (node.altitude !== undefined) popupHtml += `
Alt: ${node.altitude}
`; - if (node.last_seen) popupHtml += `
Last seen: ${node.last_seen}
`; - if (node.type) popupHtml += `
Type: ${node.type}
`; + let popupHtml = `
`; + popupHtml += `
ID: ${node.node_id}
`; + popupHtml += `
Short Name: ${node.short_name ?? "-"}
`; + popupHtml += `
Type: ${node.type ?? "-"}
`; + popupHtml += `
Lat: ${node.latitude}
`; + popupHtml += `
Lng: ${node.longitude}
`; + popupHtml += `
Alt: ${node.altitude !== undefined ? node.altitude : "-"}
`; + popupHtml += `
Last seen: ${node.last_seen ?? "-"}
`; popupHtml += `
`; marker.bindPopup(popupHtml); markers.addLayer(marker); @@ -177,6 +185,7 @@ export default function MapView() { const [nodePositions, setNodePositions] = useState([]); const [bounds, setBounds] = useState<[[number, number], [number, number]] | null>(null); const [loading, setLoading] = useState(false); + const [lastResultCount, setLastResultCount] = useState(0); const fetchController = useRef(null); const lastRequestedBounds = useRef<[[number, number], [number, number]] | null>(null); const { config } = useConfig ? useConfig() : { config: undefined }; @@ -231,7 +240,10 @@ export default function MapView() { fetch(url, { signal: controller.signal }) .then((res) => res.json()) .then((data) => { - if (Array.isArray(data)) setNodePositions(data); + if (Array.isArray(data)) { + setNodePositions(data); + setLastResultCount(data.length); + } if (fetchController.current === controller) setLoading(false); }) .catch((err) => { @@ -255,7 +267,7 @@ export default function MapView() { useMapEvents({ moveend: (e) => { const b = e.target.getBounds(); - const buffer = 0.2; // 20% buffer + const buffer = 0.05; // 5% buffer const latDiff = b.getNorthEast().lat - b.getSouthWest().lat; const lngDiff = b.getNorthEast().lng - b.getSouthWest().lng; const newBounds: [[number, number], [number, number]] = [ @@ -268,13 +280,18 @@ export default function MapView() { b.getNorthEast().lng + lngDiff * buffer, ], ]; - if (!lastRequestedBounds.current || !isBoundsInside(newBounds, lastRequestedBounds.current)) { + // Only always refetch if we have too many nodes depending on clustering setting. + if ( + (lastResultCount > (config?.clustering ? 5000: 1000)) || + !lastRequestedBounds.current || + !isBoundsInside(newBounds, lastRequestedBounds.current) + ) { setBounds(newBounds); } }, zoomend: (e) => { const b = e.target.getBounds(); - const buffer = 0.2; // 20% buffer + const buffer = 0.05; // 5% buffer const latDiff = b.getNorthEast().lat - b.getSouthWest().lat; const lngDiff = b.getNorthEast().lng - b.getSouthWest().lng; const newBounds: [[number, number], [number, number]] = [ @@ -287,7 +304,12 @@ export default function MapView() { b.getNorthEast().lng + lngDiff * buffer, ], ]; - if (!lastRequestedBounds.current || !isBoundsInside(newBounds, lastRequestedBounds.current)) { + // Only always refetch if clustering is disabled and lastResultCount > 1000 + if ( + (config?.clustering === false && lastResultCount > 1000) || + !lastRequestedBounds.current || + !isBoundsInside(newBounds, lastRequestedBounds.current) + ) { setBounds(newBounds); } }, diff --git a/src/lib/clickhouse/actions.ts b/src/lib/clickhouse/actions.ts index 18b8668..c35f527 100644 --- a/src/lib/clickhouse/actions.ts +++ b/src/lib/clickhouse/actions.ts @@ -6,19 +6,34 @@ export async function getNodePositions({ minLat, maxLat, minLng, maxLng, nodeTyp "latitude IS NOT NULL", "longitude IS NOT NULL" ]; - if (minLat) where.push(`latitude >= ${minLat}`); - if (maxLat) where.push(`latitude <= ${maxLat}`); - if (minLng) where.push(`longitude >= ${minLng}`); - if (maxLng) where.push(`longitude <= ${maxLng}`); + const params: Record = {}; + if (minLat !== null && minLat !== undefined && minLat !== "") { + where.push(`latitude >= {minLat:Float64}`); + params.minLat = Number(minLat); + } + if (maxLat !== null && maxLat !== undefined && maxLat !== "") { + where.push(`latitude <= {maxLat:Float64}`); + params.maxLat = Number(maxLat); + } + if (minLng !== null && minLng !== undefined && minLng !== "") { + where.push(`longitude >= {minLng:Float64}`); + params.minLng = Number(minLng); + } + if (maxLng !== null && maxLng !== undefined && maxLng !== "") { + where.push(`longitude <= {maxLng:Float64}`); + params.maxLng = Number(maxLng); + } if (nodeTypes && nodeTypes.length > 0) { - const types = nodeTypes.map(t => `'${t}'`).join(","); - where.push(`type IN (${types})`); + where.push(`type IN {nodeTypes:Array(String)}`); + params.nodeTypes = nodeTypes; } if (lastSeen !== null && lastSeen !== undefined && lastSeen !== "") { - where.push(`last_seen >= now() - INTERVAL ${lastSeen} SECOND`); + where.push(`last_seen >= now() - INTERVAL {lastSeen:UInt32} SECOND`); + params.lastSeen = Number(lastSeen); } const query = `SELECT node_id, name, short_name, latitude, longitude, last_seen, type FROM unified_latest_nodeinfo WHERE ${where.join(" AND ")}`; - const rows = await clickhouse.query(query).toPromise(); + const resultSet = await clickhouse.query({ query, query_params: params, format: 'JSONEachRow' }); + const rows = await resultSet.json(); return rows as Array<{ node_id: string; name?: string | null; @@ -28,4 +43,4 @@ export async function getNodePositions({ minLat, maxLat, minLng, maxLng, nodeTyp last_seen: string; type: string; }>; - } \ No newline at end of file +} \ No newline at end of file diff --git a/src/lib/clickhouse/clickhouse.ts b/src/lib/clickhouse/clickhouse.ts index c06b736..9ccc6df 100644 --- a/src/lib/clickhouse/clickhouse.ts +++ b/src/lib/clickhouse/clickhouse.ts @@ -1,14 +1,13 @@ -import { ClickHouse } from 'clickhouse'; +import { createClient } from '@clickhouse/client'; const host = process.env.CLICKHOUSE_HOST || 'localhost'; const port = process.env.CLICKHOUSE_PORT || '8123'; const user = process.env.CLICKHOUSE_USER || 'default'; const password = process.env.CLICKHOUSE_PASSWORD || 'password'; -export const clickhouse = new ClickHouse({ - url: `http://${host}`, - port: Number(port), - basicAuth: { username: user, password }, - isUseGzip: false, - format: 'json', +export const clickhouse = createClient({ + host: `http://${host}:${port}`, + username: user, + password: password, + // You can add more options as needed, e.g. database, compression, etc. }); \ No newline at end of file