From 8d094ad5cc6c5a5da7bb3390aeb7b433f0aebb40 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sun, 15 Mar 2026 14:19:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=202D/3D=20=E5=9C=B0?= =?UTF-8?q?=E5=9B=BE=E5=88=87=E6=8D=A2=E4=B8=8E=E5=9D=90=E6=A0=87=E5=81=8F?= =?UTF-8?q?=E7=A7=BB=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 WorldMap 组件,支持 2D 世界地图视图 - 主页添加 2D/3D 视图切换按钮 - 实现确定性坐标偏移算法,分散同城用户位置 - 更新 heatmap 和 register API 使用坐标偏移 --- app/[locale]/page.tsx | 115 +++++++------ app/api/v1/heatmap/route.ts | 95 ++++++----- app/api/v1/register/route.ts | 14 +- components/map/world-map.tsx | 319 +++++++++++++++++++++++++++++++++++ lib/geo/offset.ts | 72 ++++++++ 5 files changed, 513 insertions(+), 102 deletions(-) create mode 100644 components/map/world-map.tsx create mode 100644 lib/geo/offset.ts diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index e1d5806..e7437c2 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import dynamic from "next/dynamic"; import { useTranslations } from "next-intl"; -import { Map } from "lucide-react"; +import { Globe, Map } from "lucide-react"; import { Navbar } from "@/components/layout/navbar"; import { InstallBanner } from "@/components/layout/install-banner"; import { ParticleBg } from "@/components/layout/particle-bg"; @@ -14,20 +14,29 @@ import { ClawFeed } from "@/components/dashboard/claw-feed"; import { RegionRanking } from "@/components/dashboard/region-ranking"; import { cn } from "@/lib/utils"; -const ContinentMap = dynamic( +const WorldMap = dynamic( () => - import("@/components/map/continent-map").then((m) => ({ - default: m.ContinentMap, + import("@/components/map/world-map").then((m) => ({ + default: m.WorldMap, })), - { ssr: false } + { + ssr: false, + loading: () => ( +
+
+
+ Loading map... +
+
+ ), + } ); -const continentSlugs = ["asia", "europe", "americas", "africa", "oceania"] as const; +type ViewMode = "2d" | "3d"; export default function HomePage() { - const [activeContinent, setActiveContinent] = useState("asia"); - const tContinents = useTranslations("continents"); - const tRegion = useTranslations("regionExplorer"); + const [viewMode, setViewMode] = useState("2d"); + const tNavbar = useTranslations("navbar"); return (
@@ -35,11 +44,12 @@ export default function HomePage() {
- {/* Section 1: Globe + Dashboard */} + {/* Section 1: Main Map View + Dashboard */}
+
{/* Left Panel */}
@@ -47,11 +57,49 @@ export default function HomePage() {
- {/* Center - Globe + Timeline */} + {/* Center - Map/Globe + Timeline */}
-
- + {/* View Switcher + Map Container */} +
+ {/* View Mode Toggle */} +
+ + +
+ + {/* Map View */} + {viewMode === "2d" && ( + + )} + + {/* Globe View */} + {viewMode === "3d" && ( + + )}
+
@@ -61,47 +109,6 @@ export default function HomePage() {
- - {/* Section 2: Region Explorer */} -
-
-

- - {tRegion("title")} -

-
- - {/* Continent Tabs */} -
- {continentSlugs.map((slug) => ( - - ))} -
- - {/* Map — explicit height so MapLibre can render */} - -
); diff --git a/app/api/v1/heatmap/route.ts b/app/api/v1/heatmap/route.ts index 4cbd23b..40ac315 100644 --- a/app/api/v1/heatmap/route.ts +++ b/app/api/v1/heatmap/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from "next/server"; -import { and, isNotNull, sql } from "drizzle-orm"; +import { and, isNotNull } from "drizzle-orm"; import { db } from "@/lib/db"; import { claws } from "@/lib/db/schema"; import { getCacheHeatmap, setCacheHeatmap } from "@/lib/redis"; +import { applyDeterministicOffset } from "@/lib/geo/offset"; export async function GET() { try { @@ -13,31 +14,7 @@ export async function GET() { const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); - // Query all claws with coordinates, grouped by location - const locationRows = await db - .select({ - city: claws.city, - country: claws.country, - latitude: claws.latitude, - longitude: claws.longitude, - count: sql`count(*)`, - onlineCount: sql`sum(case when ${claws.lastHeartbeat} >= ${fiveMinutesAgo} then 1 else 0 end)`, - }) - .from(claws) - .where( - and( - isNotNull(claws.latitude), - isNotNull(claws.longitude) - ) - ) - .groupBy( - claws.city, - claws.country, - claws.latitude, - claws.longitude - ); - - // Fetch individual claw details for all claws with coordinates + // Fetch all claws with base coordinates const clawDetails = await db .select({ id: claws.id, @@ -56,30 +33,56 @@ export async function GET() { ) ); - // Group claw details by location key - const clawsByLocation = new Map>(); + // Apply deterministic offset to each claw and group by unique coordinates + const pointMap = new Map; + onlineCount: number; + }>(); + for (const claw of clawDetails) { - const key = `${claw.latitude}:${claw.longitude}`; + if (!claw.latitude || !claw.longitude) continue; + + const baseLat = Number(claw.latitude); + const baseLng = Number(claw.longitude); + + // Apply deterministic offset based on claw ID + const offset = applyDeterministicOffset(baseLat, baseLng, claw.id); const isOnline = claw.lastHeartbeat ? claw.lastHeartbeat >= fiveMinutesAgo : false; - const list = clawsByLocation.get(key) ?? []; - list.push({ id: claw.id, name: claw.name, isOnline }); - clawsByLocation.set(key, list); + + // Use rounded coordinates as key for grouping (within ~100m) + const key = `${offset.lat.toFixed(4)}:${offset.lng.toFixed(4)}`; + + const existing = pointMap.get(key); + if (existing) { + existing.claws.push({ id: claw.id, name: claw.name, isOnline }); + if (isOnline) existing.onlineCount++; + } else { + pointMap.set(key, { + lat: offset.lat, + lng: offset.lng, + city: claw.city, + country: claw.country, + claws: [{ id: claw.id, name: claw.name, isOnline }], + onlineCount: isOnline ? 1 : 0, + }); + } } - const points = locationRows.map((row) => { - const key = `${row.latitude}:${row.longitude}`; - const clawList = (clawsByLocation.get(key) ?? []).slice(0, 20); - return { - lat: Number(row.latitude), - lng: Number(row.longitude), - weight: row.count, - clawCount: row.count, - onlineCount: Number(row.onlineCount ?? 0), - city: row.city, - country: row.country, - claws: clawList, - }; - }); + // Convert map to array of points + const points = Array.from(pointMap.values()).map((point) => ({ + lat: point.lat, + lng: point.lng, + weight: point.claws.length, + clawCount: point.claws.length, + onlineCount: point.onlineCount, + city: point.city, + country: point.country, + claws: point.claws.slice(0, 20), // Limit to 20 claws per point for display + })); const lastUpdated = new Date().toISOString(); const responseData = { points, lastUpdated }; diff --git a/app/api/v1/register/route.ts b/app/api/v1/register/route.ts index 8f05d1e..4ef7133 100644 --- a/app/api/v1/register/route.ts +++ b/app/api/v1/register/route.ts @@ -11,6 +11,7 @@ import { } from "@/lib/redis"; import { generateApiKey } from "@/lib/auth/api-key"; import { getGeoLocation } from "@/lib/geo/ip-location"; +import { applyDeterministicOffset } from "@/lib/geo/offset"; import { registerSchema } from "@/lib/validators/schemas"; function getClientIp(req: NextRequest): string { @@ -39,6 +40,15 @@ export async function POST(req: NextRequest) { const geo = await getGeoLocation(clientIp); const now = new Date(); + // Apply deterministic offset to spread out users in the same city + let finalLat: number | null = null; + let finalLng: number | null = null; + if (geo) { + const offset = applyDeterministicOffset(geo.latitude, geo.longitude, clawId); + finalLat = offset.lat; + finalLng = offset.lng; + } + await db.insert(claws).values({ id: clawId, apiKey, @@ -46,8 +56,8 @@ export async function POST(req: NextRequest) { model: model ?? null, platform: platform ?? null, ip: clientIp, - latitude: geo ? String(geo.latitude) : null, - longitude: geo ? String(geo.longitude) : null, + latitude: finalLat !== null ? String(finalLat) : null, + longitude: finalLng !== null ? String(finalLng) : null, city: geo?.city ?? null, country: geo?.country ?? null, countryCode: geo?.countryCode ?? null, diff --git a/components/map/world-map.tsx b/components/map/world-map.tsx new file mode 100644 index 0000000..85dbdce --- /dev/null +++ b/components/map/world-map.tsx @@ -0,0 +1,319 @@ +"use client"; + +import { useState, useMemo, useCallback } from "react"; +import { useLocale } from "next-intl"; +import Map, { Source, Layer, Popup, NavigationControl } from "react-map-gl/maplibre"; +import type { MapLayerMouseEvent, LngLatLike, MapRef } from "react-map-gl/maplibre"; +import type { LayerSpecification } from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import { useHeatmapData, type HeatmapPoint } from "@/hooks/use-heatmap-data"; +import { cn } from "@/lib/utils"; +import { MapPopup } from "./map-popup"; + +const CARTO_STYLE = + "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"; + +// Generate claw icon as ImageData using Canvas +function createClawIconImage(): HTMLImageElement | null { + if (typeof document === "undefined") return null; + + const canvas = document.createElement("canvas"); + canvas.width = 64; + canvas.height = 64; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + + // Draw lobster body + ctx.fillStyle = "#ff6b35"; + ctx.beginPath(); + ctx.ellipse(32, 38, 12, 18, 0, 0, Math.PI * 2); + ctx.fill(); + + // Tail segments + ctx.fillStyle = "#e55a2b"; + ctx.beginPath(); + ctx.ellipse(32, 54, 8, 6, 0, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = "#cc4a1f"; + ctx.beginPath(); + ctx.ellipse(32, 60, 5, 4, 0, 0, Math.PI * 2); + ctx.fill(); + + // Head + ctx.fillStyle = "#ff6b35"; + ctx.beginPath(); + ctx.ellipse(32, 22, 10, 8, 0, 0, Math.PI * 2); + ctx.fill(); + + // Eyes + ctx.fillStyle = "#1a1a2e"; + ctx.beginPath(); + ctx.arc(27, 18, 3, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(37, 18, 3, 0, Math.PI * 2); + ctx.fill(); + + // Eye highlights + ctx.fillStyle = "#fff"; + ctx.beginPath(); + ctx.arc(27.5, 17, 1, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(37.5, 17, 1, 0, Math.PI * 2); + ctx.fill(); + + // Antennae + ctx.strokeStyle = "#ff6b35"; + ctx.lineWidth = 2; + ctx.lineCap = "round"; + ctx.beginPath(); + ctx.moveTo(24, 14); + ctx.quadraticCurveTo(18, 8, 14, 4); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(40, 14); + ctx.quadraticCurveTo(46, 8, 50, 4); + ctx.stroke(); + + // Left claw + ctx.fillStyle = "#ff6b35"; + ctx.beginPath(); + ctx.ellipse(14, 32, 8, 6, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.ellipse(10, 28, 5, 4, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.ellipse(10, 36, 5, 4, 0, 0, Math.PI * 2); + ctx.fill(); + + // Right claw + ctx.beginPath(); + ctx.ellipse(50, 32, 8, 6, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.ellipse(54, 28, 5, 4, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.ellipse(54, 36, 5, 4, 0, 0, Math.PI * 2); + ctx.fill(); + + // Legs + ctx.strokeStyle = "#e55a2b"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(22, 40); + ctx.lineTo(14, 44); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(22, 46); + ctx.lineTo(14, 52); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(42, 40); + ctx.lineTo(50, 44); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(42, 46); + ctx.lineTo(50, 52); + ctx.stroke(); + + const img = new Image(); + img.src = canvas.toDataURL(); + return img; +} + +const heatmapLayer: LayerSpecification = { + id: "claw-heat", + type: "heatmap", + source: "claws", + paint: { + "heatmap-weight": ["get", "weight"], + "heatmap-intensity": 1, + "heatmap-radius": 30, + "heatmap-opacity": 0.6, + "heatmap-color": [ + "interpolate", + ["linear"], + ["heatmap-density"], + 0, + "rgba(0,0,0,0)", + 0.2, + "rgba(0,240,255,0.15)", + 0.4, + "rgba(0,240,255,0.3)", + 0.6, + "rgba(0,200,220,0.5)", + 1, + "rgba(0,240,255,0.8)", + ], + }, +}; + +const symbolLayer: LayerSpecification = { + id: "claw-icons", + type: "symbol", + source: "claws", + layout: { + "icon-image": "claw-icon", + "icon-size": [ + "interpolate", + ["linear"], + ["get", "weight"], + 1, + 0.3, + 5, + 0.5, + 10, + 0.7, + ], + "icon-allow-overlap": true, + "icon-anchor": "center", + }, + paint: { + "icon-opacity": [ + "case", + [">", ["get", "onlineCount"], 0], + 1, + 0.4, + ], + }, +}; + +interface WorldMapProps { + className?: string; +} + +export function WorldMap({ className }: WorldMapProps) { + const locale = useLocale(); + const { points } = useHeatmapData(30000); + const [popupPoint, setPopupPoint] = useState(null); + const [popupLngLat, setPopupLngLat] = useState(null); + + // Build a MapLibre expression: coalesce(get("name:zh"), get("name_en"), get("name")) + // For English, just use name_en with name fallback. + const localizedTextField = useMemo( + () => + locale === "en" + ? ["coalesce", ["get", "name_en"], ["get", "name"]] + : ["coalesce", ["get", `name:${locale}`], ["get", "name_en"], ["get", "name"]], + [locale] + ); + + const handleLoad = useCallback( + (e: { target: MapRef["getMap"] extends () => infer M ? M : never }) => { + const map = e.target; + + // Create and load claw icon + const iconImg = createClawIconImage(); + if (iconImg) { + iconImg.onload = () => { + if (!map.hasImage("claw-icon")) { + map.addImage("claw-icon", iconImg); + } + }; + } + + for (const layer of map.getStyle().layers ?? []) { + if (layer.type === "symbol") { + const tf = map.getLayoutProperty(layer.id, "text-field"); + if (tf != null) { + map.setLayoutProperty(layer.id, "text-field", localizedTextField); + } + } + } + }, + [localizedTextField] + ); + + const geojson = useMemo(() => { + const features = points.map((p) => ({ + type: "Feature" as const, + geometry: { + type: "Point" as const, + coordinates: [p.lng, p.lat], + }, + properties: { + weight: p.weight, + clawCount: p.clawCount, + onlineCount: p.onlineCount, + city: p.city, + country: p.country, + claws: JSON.stringify(p.claws), + }, + })); + return { + type: "FeatureCollection" as const, + features, + }; + }, [points]); + + const handleClick = useCallback( + (e: MapLayerMouseEvent) => { + const feature = e.features?.[0]; + if (!feature || feature.geometry.type !== "Point") { + setPopupPoint(null); + return; + } + const props = feature.properties; + const [lng, lat] = feature.geometry.coordinates; + const matched = points.find( + (p) => p.city === props.city && p.country === props.country + ); + if (matched) { + setPopupPoint(matched); + setPopupLngLat([lng, lat]); + } + }, + [points] + ); + + return ( +
+ + + + + + + + + {popupPoint && popupLngLat && ( + setPopupPoint(null)} + closeButton={false} + className="maplibre-dark-popup" + maxWidth="280px" + > + setPopupPoint(null)} + /> + + )} + +
+ ); +} diff --git a/lib/geo/offset.ts b/lib/geo/offset.ts new file mode 100644 index 0000000..6ac39ea --- /dev/null +++ b/lib/geo/offset.ts @@ -0,0 +1,72 @@ +/** + * Generate a deterministic offset based on a unique identifier. + * This ensures the same ID always gets the same offset, + * allowing multiple users in the same city to be spread out + * while maintaining consistent positions. + * + * @param id - Unique identifier (e.g., claw ID) + * @param maxOffsetKm - Maximum offset in kilometers (default: 15km) + * @returns Object with lat and lng offsets in degrees + */ +export function generateDeterministicOffset( + id: string, + maxOffsetKm: number = 15 +): { latOffset: number; lngOffset: number } { + // Simple hash function to convert string to number + let hash = 0; + for (let i = 0; i < id.length; i++) { + const char = id.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + + // Use the hash to generate two pseudo-random values between -1 and 1 + // Using simple modulo arithmetic for determinism + const seed1 = Math.abs(hash % 10000) / 10000; // 0 to 1 + const seed2 = Math.abs((hash >> 8) % 10000) / 10000; // 0 to 1 + + // Convert to -1 to 1 range with better distribution + const factor1 = (seed1 * 2 - 1); + const factor2 = (seed2 * 2 - 1); + + // Convert km to degrees (approximate) + // 1 degree latitude ≈ 111km + // 1 degree longitude varies by latitude (cos(lat) * 111km) + // We use a rough average here + const kmPerDegree = 111; + const maxOffsetDegrees = maxOffsetKm / kmPerDegree; + + return { + latOffset: factor1 * maxOffsetDegrees, + lngOffset: factor2 * maxOffsetDegrees, + }; +} + +/** + * Apply deterministic offset to coordinates. + * This spreads out users in the same city while keeping + * each user's position consistent. + * + * @param lat - Original latitude + * @param lng - Original longitude + * @param id - Unique identifier for deterministic offset + * @param maxOffsetKm - Maximum offset in kilometers + * @returns New coordinates with offset applied + */ +export function applyDeterministicOffset( + lat: number, + lng: number, + id: string, + maxOffsetKm: number = 15 +): { lat: number; lng: number } { + const { latOffset, lngOffset } = generateDeterministicOffset(id, maxOffsetKm); + + // Adjust longitude offset based on latitude + // (longitude degrees get smaller towards poles) + const lngCorrection = Math.cos((lat * Math.PI) / 180); + + return { + lat: lat + latOffset, + lng: lng + lngOffset * lngCorrection, + }; +}