"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)} /> )}
); }