From a72f201cca046ac01d5a3012237427dbc7fbf524 Mon Sep 17 00:00:00 2001 From: ajvpot <553597+ajvpot@users.noreply.github.com> Date: Thu, 3 Jul 2025 00:00:00 +0000 Subject: [PATCH] we got some data --- package-lock.json | 9 + package.json | 1 + src/app/api/node-positions/route.ts | 16 ++ src/app/globals.css | 54 ++++ src/app/layout.tsx | 7 +- src/app/not-found.tsx | 19 ++ src/app/page.tsx | 22 +- src/components/ChatBox.tsx | 46 ++-- src/components/ConfigContext.tsx | 111 +------- src/components/MapView.tsx | 240 ++++++++++++------ ...{MapWithChat.tsx => MapWithChatClient.tsx} | 18 +- src/lib/clickhouse/actions.ts | 24 ++ src/lib/{ => clickhouse}/clickhouse.ts | 2 +- 13 files changed, 338 insertions(+), 231 deletions(-) create mode 100644 src/app/api/node-positions/route.ts create mode 100644 src/app/not-found.tsx rename src/components/{MapWithChat.tsx => MapWithChatClient.tsx} (52%) create mode 100644 src/lib/clickhouse/actions.ts rename src/lib/{ => clickhouse}/clickhouse.ts (99%) diff --git a/package-lock.json b/package-lock.json index 1935356..5937d02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "clickhouse": "^2.6.0", "clsx": "^2.1.1", "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "lucide-react": "^0.525.0", "next": "15.3.4", "next-themes": "^0.4.6", @@ -4420,6 +4421,14 @@ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/package.json b/package.json index f681301..6b1935b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "clickhouse": "^2.6.0", "clsx": "^2.1.1", "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "lucide-react": "^0.525.0", "next": "15.3.4", "next-themes": "^0.4.6", diff --git a/src/app/api/node-positions/route.ts b/src/app/api/node-positions/route.ts new file mode 100644 index 0000000..0b0decc --- /dev/null +++ b/src/app/api/node-positions/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { getNodePositions } from "@/lib/clickhouse/actions"; + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const minLat = searchParams.get("minLat"); + const maxLat = searchParams.get("maxLat"); + const minLng = searchParams.get("minLng"); + const maxLng = searchParams.get("maxLng"); + const positions = await getNodePositions({ minLat, maxLat, minLng, maxLng }); + return NextResponse.json(positions); + } catch (error) { + return NextResponse.json({ error: "Failed to fetch node positions" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index 9711daf..5eec4e2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -120,3 +120,57 @@ @apply bg-background text-foreground; } } + +.custom-node-marker { + width: 16px; + height: 16px; + background: #2563eb; /* blue-600 */ + border: 2px solid #fff; + border-radius: 50%; + box-shadow: 0 0 4px rgba(0,0,0,0.2); + display: block; + position: relative; + z-index: 2; +} + +.custom-node-marker--green { + background: #22c55e; /* green-500 */ +} +.custom-node-marker--blue { + background: #2563eb; /* blue-600 */ +} + +.custom-node-marker--top { + z-index: 1002 !important; + position: relative; +} + +.map-spinner { + width: 28px; + height: 28px; + border: 4px solid #e5e7eb; /* gray-200 */ + border-top: 4px solid #2563eb; /* blue-600 */ + border-radius: 50%; + animation: map-spin 1s linear infinite; +} +@keyframes map-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.custom-node-label { + position: absolute; + top: 18px; + left: 50%; + transform: translateX(-50%); + font-size: 12px; + color: #222; + background: rgba(255,255,255,0.85); + padding: 0 4px; + border-radius: 3px; + white-space: nowrap; + pointer-events: none; + font-weight: 500; + box-shadow: 0 1px 2px rgba(0,0,0,0.07); + z-index: 1; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..bf43a87 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import Header from "../components/Header"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -26,8 +27,12 @@ export default function RootLayout({ - {children} +
+
+
{children}
+
); diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..c8136b2 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; + +export default function NotFound() { + return ( +
+

404

+

Page Not Found

+

+ Sorry, the page you are looking for does not exist or has been moved. +

+ + Go Home + +
+ ); +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 3932fa3..e63c0ce 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,21 +1,5 @@ -import MapWithChat from "../components/MapWithChat"; -import { clickhouse } from "../lib/clickhouse"; +import MapWithChatClient from "../components/MapWithChatClient"; -// --- Server action --- -export async function getNodePositions() { - const rows = await clickhouse.query( - "SELECT from_node_id, latitude, longitude, altitude, last_seen FROM meshtastic_position_latest" - ).toPromise(); - return rows as Array<{ - from_node_id: string; - latitude: number; - longitude: number; - altitude?: number; - last_seen?: string; - }>; -} - -export default async function Home() { - const nodePositions = await getNodePositions(); - return ; +export default function Home() { + return ; } diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index 6fd6c04..fb6be61 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -1,35 +1,35 @@ "use client"; import { useState } from "react"; +import { MinusIcon, PlusIcon } from "@heroicons/react/24/outline"; export default function ChatBox() { const [value, setValue] = useState(""); + const [minimized, setMinimized] = useState(true); return ( -
-
-
Chat coming soon...
-
-
{ - e.preventDefault(); - setValue(""); - }} - > - setValue(e.target.value)} - disabled - /> +
+
+ Chat - +
+ {!minimized && ( +
+
Chat coming soon...
+
+ )}
); } \ No newline at end of file diff --git a/src/components/ConfigContext.tsx b/src/components/ConfigContext.tsx index 4ffe8ec..c6c2eb3 100644 --- a/src/components/ConfigContext.tsx +++ b/src/components/ConfigContext.tsx @@ -1,113 +1,12 @@ "use client"; -import React, { createContext, useContext, useState, useEffect, ReactNode, useRef } from "react"; - -interface Config { - theme: "light" | "dark"; - // Add more config options as needed -} - -interface ConfigContextType { - config: Config; - setConfig: (config: Config) => void; - openConfig: () => void; -} - -const defaultConfig: Config = { - theme: "light", -}; - -const ConfigContext = createContext(undefined); +import React, { ReactNode } from "react"; +// Placeholder ConfigProvider that just renders children export function ConfigProvider({ children }: { children: ReactNode }) { - const [config, setConfigState] = useState(defaultConfig); - const [showPopover, setShowPopover] = useState(false); - const buttonRef = useRef(null); - const popoverRef = useRef(null); - - useEffect(() => { - const stored = localStorage.getItem("globalConfig"); - if (stored) setConfigState(JSON.parse(stored)); - }, []); - - useEffect(() => { - localStorage.setItem("globalConfig", JSON.stringify(config)); - }, [config]); - - // Close popover on outside click or Escape - useEffect(() => { - if (!showPopover) return; - function onClick(e: MouseEvent) { - if ( - popoverRef.current && - !popoverRef.current.contains(e.target as Node) && - buttonRef.current && - !buttonRef.current.contains(e.target as Node) - ) { - setShowPopover(false); - } - } - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") setShowPopover(false); - } - document.addEventListener("mousedown", onClick); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onClick); - document.removeEventListener("keydown", onKey); - }; - }, [showPopover]); - - const setConfig = (newConfig: Config) => setConfigState(newConfig); - const openConfig = () => setShowPopover(v => !v); - - // Provide ref to button for Header - const contextValue = React.useMemo(() => ({ config, setConfig, openConfig }), [config]); - - return ( - - {/* Clone header to inject ref */} - {React.Children.map(children, child => { - if ( - React.isValidElement(child) && - child.type && - (child as any).type.name === "Header" - ) { - return React.cloneElement(child, { configButtonRef: buttonRef }); - } - return child; - })} - {/* Popover */} - {showPopover && ( -
-

Configuration

- -
- )} -
- ); + return <>{children}; } +// Dummy useConfig hook export function useConfig() { - const ctx = useContext(ConfigContext); - if (!ctx) throw new Error("useConfig must be used within a ConfigProvider"); - return ctx; + return {}; } \ No newline at end of file diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx index 7b802f9..4ff07fb 100644 --- a/src/components/MapView.tsx +++ b/src/components/MapView.tsx @@ -1,7 +1,12 @@ "use client"; -import { MapContainer, TileLayer, useMapEvents, Marker, Popup } from "react-leaflet"; +import { MapContainer, TileLayer, useMapEvents, Marker, Popup, MapContainerProps, useMap } from "react-leaflet"; import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import 'leaflet/dist/leaflet.css'; +import L from "leaflet"; +import 'leaflet.markercluster/dist/leaflet.markercluster.js'; +import 'leaflet.markercluster/dist/MarkerCluster.css'; +import 'leaflet.markercluster/dist/MarkerCluster.Default.css'; const DEFAULT = { lat: 47.6062, // Seattle @@ -9,90 +14,173 @@ const DEFAULT = { zoom: 12, }; -function parseQuery(searchParams: ReturnType) { - const lat = parseFloat(searchParams.get("lat") || ""); - const lng = parseFloat(searchParams.get("lng") || ""); - const zoom = parseInt(searchParams.get("zoom") || ""); - return { - lat: isNaN(lat) ? DEFAULT.lat : lat, - lng: isNaN(lng) ? DEFAULT.lng : lng, - zoom: isNaN(zoom) ? DEFAULT.zoom : zoom, - }; -} - -function MapSync() { - const map = useMapEvents({}); - const router = useRouter(); - const searchParams = useSearchParams(); - const last = useRef({ lat: 0, lng: 0, zoom: 0 }); - - useEffect(() => { - const onMove = () => { - const center = map.getCenter(); - const zoom = map.getZoom(); - if ( - Math.abs(center.lat - last.current.lat) > 1e-6 || - Math.abs(center.lng - last.current.lng) > 1e-6 || - zoom !== last.current.zoom - ) { - last.current = { lat: center.lat, lng: center.lng, zoom }; - const params = new URLSearchParams(searchParams.toString()); - params.set("lat", center.lat.toFixed(5)); - params.set("lng", center.lng.toFixed(5)); - params.set("zoom", String(zoom)); - router.replace("?" + params.toString(), { scroll: false }); - } - }; - map.on("moveend", onMove); - map.on("zoomend", onMove); - return () => { - map.off("moveend", onMove); - map.off("zoomend", onMove); - }; - }, [map, router, searchParams]); - return null; -} - type NodePosition = { from_node_id: string; latitude: number; longitude: number; altitude?: number; last_seen?: string; + type?: string; + short_name?: string; }; -interface MapViewProps { - nodePositions?: NodePosition[]; +type ClusteredMarkersProps = { nodes: NodePosition[] }; +function ClusteredMarkers({ nodes }: ClusteredMarkersProps) { + const map = useMap(); + useEffect(() => { + if (!map) return; + const markers = (L as any).markerClusterGroup(); + nodes.forEach((node: NodePosition) => { + const markerClass = + node.type === "meshtastic" + ? "custom-node-marker custom-node-marker--green" + : node.type === "meshcore" + ? "custom-node-marker custom-node-marker--blue custom-node-marker--top" + : "custom-node-marker"; + const label = node.short_name ? `
${node.short_name}
` : ''; + const icon = L.divIcon({ + className: 'custom-node-marker-container', + iconSize: [16, 32], + iconAnchor: [8, 8], + html: `${label}
`, + }); + const marker = L.marker([node.latitude, node.longitude], { icon }); + 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}
`; + popupHtml += `
`; + marker.bindPopup(popupHtml); + markers.addLayer(marker); + }); + map.addLayer(markers); + return () => { + map.removeLayer(markers); + }; + }, [map, nodes]); + return null; } -export default function MapView({ nodePositions = [] }: MapViewProps) { - const searchParams = useSearchParams(); - const { lat, lng, zoom } = useMemo(() => parseQuery(searchParams), [searchParams]); +export default function MapView() { + const [nodePositions, setNodePositions] = useState([]); + const [bounds, setBounds] = useState<[[number, number], [number, number]] | null>(null); + const [loading, setLoading] = useState(false); + const fetchController = useRef(null); + const lastRequestedBounds = useRef<[[number, number], [number, number]] | null>(null); + + function fetchNodes(bounds?: [[number, number], [number, number]]) { + if (fetchController.current) { + fetchController.current.abort(); + } + const controller = new AbortController(); + fetchController.current = controller; + setLoading(true); + let url = "/api/node-positions"; + if (bounds) { + const [[minLat, minLng], [maxLat, maxLng]] = bounds; + url += `?minLat=${minLat}&maxLat=${maxLat}&minLng=${minLng}&maxLng=${maxLng}`; + } + fetch(url, { signal: controller.signal }) + .then((res) => res.json()) + .then((data) => { + if (Array.isArray(data)) setNodePositions(data); + if (fetchController.current === controller) setLoading(false); + }) + .catch((err) => { + if (err.name !== "AbortError") setNodePositions([]); + if (fetchController.current === controller) setLoading(false); + }); + } + + function isBoundsInside(inner: [[number, number], [number, number]], outer: [[number, number], [number, number]]) { + // inner: [[minLat, minLng], [maxLat, maxLng]] + // outer: [[minLat, minLng], [maxLat, maxLng]] + return ( + inner[0][0] >= outer[0][0] && // minLat + inner[0][1] >= outer[0][1] && // minLng + inner[1][0] <= outer[1][0] && // maxLat + inner[1][1] <= outer[1][1] // maxLng + ); + } + + function MapEventCatcher() { + useMapEvents({ + moveend: (e) => { + const b = e.target.getBounds(); + const buffer = 0.2; // 20% buffer + const latDiff = b.getNorthEast().lat - b.getSouthWest().lat; + const lngDiff = b.getNorthEast().lng - b.getSouthWest().lng; + const newBounds: [[number, number], [number, number]] = [ + [ + b.getSouthWest().lat - latDiff * buffer, + b.getSouthWest().lng - lngDiff * buffer, + ], + [ + b.getNorthEast().lat + latDiff * buffer, + b.getNorthEast().lng + lngDiff * buffer, + ], + ]; + if (!lastRequestedBounds.current || !isBoundsInside(newBounds, lastRequestedBounds.current)) { + setBounds(newBounds); + } + }, + zoomend: (e) => { + const b = e.target.getBounds(); + const buffer = 0.2; // 20% buffer + const latDiff = b.getNorthEast().lat - b.getSouthWest().lat; + const lngDiff = b.getNorthEast().lng - b.getSouthWest().lng; + const newBounds: [[number, number], [number, number]] = [ + [ + b.getSouthWest().lat - latDiff * buffer, + b.getSouthWest().lng - lngDiff * buffer, + ], + [ + b.getNorthEast().lat + latDiff * buffer, + b.getNorthEast().lng + lngDiff * buffer, + ], + ]; + if (!lastRequestedBounds.current || !isBoundsInside(newBounds, lastRequestedBounds.current)) { + setBounds(newBounds); + } + }, + }); + return null; + } + + useEffect(() => { + fetchController.current?.abort(); // abort any in-flight request on effect cleanup + if (bounds) { + fetchNodes(bounds); + lastRequestedBounds.current = bounds; + } else { + fetchNodes(); + lastRequestedBounds.current = null; + } + return () => { + fetchController.current?.abort(); + }; + }, [bounds]); + return ( - - - {nodePositions.map((node) => ( - - -
-
ID: {node.from_node_id}
-
Lat: {node.latitude}
-
Lng: {node.longitude}
- {node.altitude !== undefined &&
Alt: {node.altitude}
} - {node.last_seen &&
Last seen: {node.last_seen}
} -
-
-
- ))} - -
+
+ {loading && ( +
+
+
+ )} + + + + + +
); } \ No newline at end of file diff --git a/src/components/MapWithChat.tsx b/src/components/MapWithChatClient.tsx similarity index 52% rename from src/components/MapWithChat.tsx rename to src/components/MapWithChatClient.tsx index 5c2c43f..51f8c99 100644 --- a/src/components/MapWithChat.tsx +++ b/src/components/MapWithChatClient.tsx @@ -1,6 +1,5 @@ "use client"; -import MapView from "./MapView"; -import ChatBox from "./ChatBox"; +import dynamic from "next/dynamic"; type NodePosition = { from_node_id: string; @@ -11,14 +10,23 @@ type NodePosition = { }; interface MapWithChatProps { - nodePositions: NodePosition[]; + nodePositions?: NodePosition[]; } +const MapView = dynamic( + () => import("./MapView"), + { ssr: false } +); +const ChatBox = dynamic(() => import("./ChatBox"), { ssr: false }); + export default function MapWithChat({ nodePositions }: MapWithChatProps) { return ( -
+
- +
diff --git a/src/lib/clickhouse/actions.ts b/src/lib/clickhouse/actions.ts new file mode 100644 index 0000000..0037210 --- /dev/null +++ b/src/lib/clickhouse/actions.ts @@ -0,0 +1,24 @@ +"use server"; +import { clickhouse } from "./clickhouse"; + +export async function getNodePositions({ minLat, maxLat, minLng, maxLng }: { minLat?: string | null, maxLat?: string | null, minLng?: string | null, maxLng?: string | null } = {}) { + let where = [ + "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 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(); + return rows as Array<{ + node_id: string; + name?: string | null; + short_name?: string | null; + latitude: number; + longitude: number; + last_seen: string; + type: string; + }>; + } \ No newline at end of file diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse/clickhouse.ts similarity index 99% rename from src/lib/clickhouse.ts rename to src/lib/clickhouse/clickhouse.ts index 0279c08..c06b736 100644 --- a/src/lib/clickhouse.ts +++ b/src/lib/clickhouse/clickhouse.ts @@ -11,4 +11,4 @@ export const clickhouse = new ClickHouse({ basicAuth: { username: user, password }, isUseGzip: false, format: 'json', -}); \ No newline at end of file +}); \ No newline at end of file