Files
2026-04-20 14:56:28 +08:00

500 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import type React from "react"
import { useState, useRef, useCallback, useEffect } from "react"
import { defaultStars, type EsportsStar } from "../_lib/config-types"
import { getStreamerAvatarUrl, getAvatarPlaceholderUrl } from "@/lib/streamer-avatar"
// 游戏类型颜色
const gameColors: Record<string, string> = {
耀: "#f59e0b",
: "#3b82f6",
: "#10b981",
}
const chinaProvinces = [
{
id: "xinjiang",
name: "新疆",
d: "M85,95 L110,85 L145,80 L180,85 L210,95 L235,115 L250,145 L255,180 L250,215 L235,250 L210,275 L175,290 L140,295 L105,285 L75,265 L55,235 L45,200 L45,160 L55,125 L70,105 Z",
},
{
id: "xizang",
name: "西藏",
d: "M55,265 L105,285 L145,295 L180,300 L210,315 L225,345 L220,380 L200,410 L165,430 L125,435 L85,420 L55,390 L35,350 L35,305 Z",
},
{
id: "qinghai",
name: "青海",
d: "M210,215 L250,205 L285,210 L315,225 L330,255 L325,290 L305,320 L270,335 L235,330 L205,310 L190,280 L195,245 Z",
},
{
id: "gansu",
name: "甘肃",
d: "M285,165 L330,150 L375,145 L410,155 L435,175 L445,205 L440,240 L420,270 L385,290 L345,295 L310,280 L290,250 L285,215 Z",
},
{
id: "neimenggu",
name: "内蒙古",
d: "M330,55 L400,45 L470,40 L540,45 L600,60 L645,85 L665,120 L660,155 L635,185 L590,200 L535,205 L475,200 L415,190 L365,175 L335,150 L320,115 L320,80 Z",
},
{
id: "heilongjiang",
name: "黑龙江",
d: "M665,35 L720,25 L765,35 L795,60 L805,100 L795,140 L770,170 L730,185 L690,180 L660,155 L655,115 L660,75 Z",
},
{
id: "jilin",
name: "吉林",
d: "M665,165 L715,155 L755,165 L780,190 L780,225 L760,255 L720,270 L680,265 L655,240 L650,200 Z",
},
{
id: "liaoning",
name: "辽宁",
d: "M620,215 L680,200 L720,210 L750,235 L755,270 L740,300 L700,320 L655,320 L620,300 L605,265 L605,235 Z",
},
{
id: "hebei",
name: "河北",
d: "M545,150 L590,140 L625,150 L645,180 L645,215 L630,250 L595,275 L555,280 L525,265 L515,230 L520,190 Z",
},
{ id: "beijing", name: "北京", d: "M575,175 L595,170 L605,185 L595,200 L575,200 L568,188 Z", isCity: true },
{ id: "tianjin", name: "天津", d: "M605,200 L620,195 L628,212 L618,228 L602,225 L598,212 Z", isCity: true },
{
id: "shanxi",
name: "山西",
d: "M500,190 L545,175 L565,210 L560,255 L540,290 L505,300 L480,280 L475,240 L480,205 Z",
},
{
id: "shandong",
name: "山东",
d: "M580,245 L630,230 L680,235 L720,255 L735,285 L720,320 L680,340 L630,345 L590,330 L570,295 Z",
},
{
id: "henan",
name: "河南",
d: "M500,280 L555,265 L590,280 L600,320 L580,360 L540,380 L500,375 L475,345 L475,310 Z",
},
{
id: "jiangsu",
name: "江苏",
d: "M620,310 L680,295 L720,305 L740,340 L725,380 L685,400 L645,395 L615,370 L610,335 Z",
},
{
id: "anhui",
name: "安徽",
d: "M575,340 L620,325 L650,345 L660,385 L640,425 L600,440 L560,430 L545,390 L555,360 Z",
},
{ id: "shanghai", name: "上海", d: "M705,355 L725,350 L732,372 L718,385 L700,378 L698,362 Z", isCity: true },
{ id: "zhejiang", name: "浙江", d: "M660,385 L710,370 L740,390 L745,435 L720,470 L680,480 L645,465 L640,425 Z" },
{ id: "fujian", name: "福建", d: "M645,465 L695,450 L735,465 L750,510 L730,555 L685,570 L645,555 L630,510 Z" },
{ id: "taiwan", name: "台湾", d: "M755,480 L780,470 L795,505 L785,550 L760,565 L745,540 L745,505 Z" },
{ id: "jiangxi", name: "江西", d: "M575,420 L640,405 L670,435 L675,485 L650,530 L600,545 L560,525 L550,475 Z" },
{ id: "hubei", name: "湖北", d: "M475,335 L540,320 L580,340 L590,385 L565,425 L520,440 L475,425 L460,385 Z" },
{ id: "hunan", name: "湖南", d: "M475,430 L535,415 L575,435 L585,485 L560,535 L515,555 L470,540 L455,490 Z" },
{
id: "guangdong",
name: "广东",
d: "M505,545 L575,530 L635,540 L680,560 L695,605 L665,645 L605,660 L545,650 L500,620 L485,580 Z",
},
{ id: "guangxi", name: "广西", d: "M395,545 L470,530 L520,545 L535,595 L510,640 L455,655 L400,640 L375,600 Z" },
{ id: "hainan", name: "海南", d: "M485,680 L530,670 L545,710 L520,745 L480,750 L460,720 Z" },
{
id: "sichuan",
name: "四川",
d: "M295,320 L365,300 L420,315 L455,355 L455,410 L430,455 L375,475 L320,460 L280,420 L275,365 Z",
},
{ id: "chongqing", name: "重庆", d: "M420,370 L470,355 L490,390 L480,435 L445,455 L410,445 L400,405 Z" },
{ id: "guizhou", name: "贵州", d: "M375,455 L445,440 L485,460 L495,510 L470,555 L420,570 L375,555 L360,505 Z" },
{
id: "yunnan",
name: "云南",
d: "M275,455 L345,440 L395,465 L410,525 L390,585 L340,620 L280,625 L235,590 L225,530 L240,480 Z",
},
{
id: "shaanxi",
name: "陕西",
d: "M420,210 L480,195 L510,230 L515,285 L495,335 L455,355 L415,345 L395,300 L400,250 Z",
},
{ id: "ningxia", name: "宁夏", d: "M400,195 L435,185 L450,215 L445,250 L425,275 L395,270 L385,235 Z" },
]
export default function ChinaMap() {
const containerRef = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
const [hoveredProvince, setHoveredProvince] = useState<string | null>(null)
const [hoveredStar, setHoveredStar] = useState<EsportsStar | null>(null)
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 })
const [stars, setStars] = useState<EsportsStar[]>(defaultStars)
/** 头像加载失败的主播 id改用首字占位避免裂图 */
const [failedAvatarIds, setFailedAvatarIds] = useState<Set<string>>(new Set())
useEffect(() => {
fetch("/api/screen/streamers")
.then((res) => res.json())
.then((body: { success?: boolean; data?: EsportsStar[] }) => {
if (body.success && Array.isArray(body.data) && body.data.length > 0) {
setStars(body.data)
}
})
.catch(() => {})
}, [])
// 将经纬度映射到SVG坐标系
const getStarPosition = useCallback((lng: number, lat: number) => {
const x = ((lng - 73) / (135 - 73)) * 750 + 30
const y = ((54 - lat) / (54 - 18)) * 680 + 25
return { x, y }
}, [])
// 鼠标滚轮缩放
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault()
const delta = e.deltaY > 0 ? 0.9 : 1.1
setScale((prev) => Math.min(Math.max(prev * delta, 0.5), 4))
}, [])
// 拖拽事件
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (e.button === 0) {
setIsDragging(true)
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y })
}
},
[position],
)
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (isDragging) {
setPosition({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
})
}
setTooltipPos({ x: e.clientX, y: e.clientY })
},
[isDragging, dragStart],
)
const handleMouseUp = useCallback(() => {
setIsDragging(false)
}, [])
// 触摸事件
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
if (e.touches.length === 1) {
setIsDragging(true)
setDragStart({
x: e.touches[0].clientX - position.x,
y: e.touches[0].clientY - position.y,
})
}
},
[position],
)
const handleTouchMove = useCallback(
(e: React.TouchEvent) => {
if (isDragging && e.touches.length === 1) {
setPosition({
x: e.touches[0].clientX - dragStart.x,
y: e.touches[0].clientY - dragStart.y,
})
}
},
[isDragging, dragStart],
)
const handleTouchEnd = useCallback(() => {
setIsDragging(false)
}, [])
const resetView = useCallback(() => {
setScale(1)
setPosition({ x: 0, y: 0 })
}, [])
return (
<div className="w-full h-full rounded-lg overflow-hidden wz-screen-map-wrap relative">
{/* 地图标题和图例 */}
<div className="absolute top-4 left-4 z-20 bg-secondary/90 backdrop-blur-sm rounded-lg p-4 border border-border/50">
<h3 className="text-foreground font-semibold text-sm mb-3 flex items-center">
<span className="w-2 h-2 bg-cyan-400 rounded-full mr-2 animate-pulse"></span>
</h3>
<div className="space-y-2 text-xs">
<div className="flex items-center">
<div className="w-3 h-3 rounded-full bg-yellow-500 mr-2 shadow-lg shadow-yellow-500/50"></div>
<span className="text-muted-foreground">耀</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 rounded-full bg-blue-500 mr-2 shadow-lg shadow-blue-500/50"></div>
<span className="text-muted-foreground"></span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 rounded-full bg-green-500 mr-2 shadow-lg shadow-green-500/50"></div>
<span className="text-muted-foreground"></span>
</div>
</div>
</div>
{/* 缩放控制按钮 */}
<div className="absolute top-4 right-4 z-20 flex flex-col gap-2">
<button
onClick={() => setScale((prev) => Math.min(prev * 1.2, 4))}
className="w-8 h-8 bg-secondary/90 hover:bg-accent backdrop-blur-sm rounded-lg border border-border/50 text-foreground flex items-center justify-center transition-colors text-lg font-bold"
>
+
</button>
<button
onClick={() => setScale((prev) => Math.max(prev * 0.8, 0.5))}
className="w-8 h-8 bg-secondary/90 hover:bg-accent backdrop-blur-sm rounded-lg border border-border/50 text-foreground flex items-center justify-center transition-colors text-lg font-bold"
>
-
</button>
<button
onClick={resetView}
className="w-8 h-8 bg-secondary/90 hover:bg-accent backdrop-blur-sm rounded-lg border border-border/50 text-foreground flex items-center justify-center transition-colors text-sm"
>
</button>
<div className="text-xs text-muted-foreground text-center mt-1 bg-secondary/70 rounded px-1">
{Math.round(scale * 100)}%
</div>
</div>
{/* SVG地图容器 */}
<div
ref={containerRef}
className="w-full h-full cursor-grab active:cursor-grabbing"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<svg
viewBox="0 0 850 800"
className="w-full h-full"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
transformOrigin: "center center",
transition: isDragging ? "none" : "transform 0.1s ease-out",
}}
>
<defs>
{/* 发光滤镜 */}
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* 省份高亮渐变 */}
<linearGradient id="provinceHover" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="hsl(185 80% 50%)" stopOpacity="0.5" />
<stop offset="100%" stopColor="hsl(210 90% 55%)" stopOpacity="0.5" />
</linearGradient>
{/* 涟漪渐变 */}
<radialGradient id="ripple-yellow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#f59e0b" stopOpacity="0.6" />
<stop offset="100%" stopColor="#f59e0b" stopOpacity="0" />
</radialGradient>
<radialGradient id="ripple-blue" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.6" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
</radialGradient>
<radialGradient id="ripple-green" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#10b981" stopOpacity="0.6" />
<stop offset="100%" stopColor="#10b981" stopOpacity="0" />
</radialGradient>
</defs>
{/* 中国地图省份 */}
<g className="provinces">
{chinaProvinces.map((province) => (
<path
key={province.id}
d={province.d}
fill={hoveredProvince === province.id ? "url(#provinceHover)" : "hsl(217.2 32.6% 14%)"}
stroke="hsl(217.2 32.6% 28%)"
strokeWidth={province.isCity ? "1" : "1.5"}
className="transition-all duration-200 cursor-pointer"
onMouseEnter={() => setHoveredProvince(province.id)}
onMouseLeave={() => setHoveredProvince(null)}
/>
))}
</g>
{/* 南海诸岛示意框 */}
<g className="south-sea">
<rect
x="720"
y="620"
width="110"
height="140"
fill="hsl(217.2 32.6% 10%)"
stroke="hsl(217.2 32.6% 28%)"
strokeWidth="1.5"
rx="4"
/>
<text x="775" y="645" fontSize="10" fill="hsl(215 20.2% 65.1%)" textAnchor="middle">
</text>
<circle cx="745" cy="680" r="3" fill="hsl(217.2 32.6% 30%)" />
<circle cx="775" cy="705" r="3" fill="hsl(217.2 32.6% 30%)" />
<circle cx="800" cy="730" r="3" fill="hsl(217.2 32.6% 30%)" />
</g>
{/* 电竞明星标记(带头像)- 与后台主播管理数据互通 */}
{stars.map((star, index) => {
const pos = getStarPosition(star.location.lng, star.location.lat)
const color = gameColors[star.game] || "#06b6d4"
const rippleId =
star.game === "王者荣耀" ? "ripple-yellow" : star.game === "英雄联盟" ? "ripple-blue" : "ripple-green"
const avatarUrl =
failedAvatarIds.has(star.id)
? getAvatarPlaceholderUrl(star.name, 32)
: getStreamerAvatarUrl(star.name, star.avatar, 32)
return (
<g
key={star.id}
className="cursor-pointer"
onMouseEnter={() => setHoveredStar(star)}
onMouseLeave={() => setHoveredStar(null)}
>
{/* 涟漪动画 */}
<circle
cx={pos.x}
cy={pos.y}
r="28"
fill={`url(#${rippleId})`}
className="animate-ping"
style={{
animationDuration: "2.5s",
animationDelay: `${index * 0.2}s`,
transformOrigin: `${pos.x}px ${pos.y}px`,
}}
/>
{/* 外圈发光边框 */}
<circle
cx={pos.x}
cy={pos.y}
r="20"
fill="none"
stroke={color}
strokeWidth="3"
filter="url(#glow)"
className="transition-all duration-200"
style={{
transform: hoveredStar?.id === star.id ? "scale(1.3)" : "scale(1)",
transformOrigin: `${pos.x}px ${pos.y}px`,
}}
/>
{/* 头像背景 */}
<circle cx={pos.x} cy={pos.y} r="18" fill="hsl(217.2 32.6% 12%)" />
{/* 头像图片 */}
<clipPath id={`avatar-clip-${star.id}`}>
<circle cx={pos.x} cy={pos.y} r="16" />
</clipPath>
<image
href={avatarUrl}
x={pos.x - 16}
y={pos.y - 16}
width="32"
height="32"
clipPath={`url(#avatar-clip-${star.id})`}
preserveAspectRatio="xMidYMid slice"
onError={() => setFailedAvatarIds((prev) => new Set(prev).add(star.id))}
/>
{/* 游戏类型小标识 */}
<circle
cx={pos.x + 14}
cy={pos.y + 14}
r="6"
fill={color}
stroke="hsl(217.2 32.6% 10%)"
strokeWidth="2"
/>
</g>
)
})}
</svg>
</div>
{/* 悬停省份名称 */}
{hoveredProvince && (
<div className="absolute bottom-4 left-4 z-20 bg-secondary/90 backdrop-blur-sm rounded-lg px-3 py-2 border border-border/50">
<span className="text-foreground text-sm font-medium">
{chinaProvinces.find((p) => p.id === hoveredProvince)?.name}
</span>
</div>
)}
{/* 悬停明星信息卡片 */}
{hoveredStar && (
<div
className="fixed z-50 bg-secondary/95 backdrop-blur-sm rounded-lg p-4 border border-border/50 shadow-xl pointer-events-none"
style={{
left: Math.min(tooltipPos.x + 15, typeof window !== "undefined" ? window.innerWidth - 280 : 500),
top: tooltipPos.y - 10,
maxWidth: "260px",
}}
>
<div className="flex items-center gap-3 mb-3">
<img
src={
failedAvatarIds.has(hoveredStar.id)
? getAvatarPlaceholderUrl(hoveredStar.name, 48)
: getStreamerAvatarUrl(hoveredStar.name, hoveredStar.avatar, 48)
}
alt={hoveredStar.name}
className="w-12 h-12 rounded-full border-2 object-cover"
style={{ borderColor: gameColors[hoveredStar.game] }}
onError={(e) => {
setFailedAvatarIds((prev) => new Set(prev).add(hoveredStar.id))
const t = e.target as HTMLImageElement
if (t) t.src = getAvatarPlaceholderUrl(hoveredStar.name, 48)
}}
/>
<div>
<div className="font-bold text-foreground">{hoveredStar.name}</div>
<div className="text-xs text-muted-foreground">
{hoveredStar.location.province} · {hoveredStar.location.city}
</div>
</div>
</div>
<div
className="text-xs mb-3 px-2 py-1 rounded-full inline-block"
style={{ backgroundColor: `${gameColors[hoveredStar.game]}20`, color: gameColors[hoveredStar.game] }}
>
{hoveredStar.game}
</div>
<div className="border-t border-border pt-2 grid grid-cols-2 gap-2">
<div className="text-green-400 text-xs">: ¥{hoveredStar.dailyRevenue.toLocaleString()}</div>
<div className="text-blue-400 text-xs">: {(hoveredStar.fans / 1000).toFixed(1)}K</div>
<div className="text-white/70 text-xs">: {hoveredStar.conversionRate}%</div>
<div className="text-yellow-400 text-xs">直播平台: 抖音</div>
</div>
</div>
)}
{/* 操作提示 */}
<div className="absolute bottom-4 right-4 z-20 bg-secondary/70 backdrop-blur-sm rounded-lg px-3 py-2 border border-border/30">
<div className="text-xs text-muted-foreground"> · </div>
</div>
</div>
)
}