feat(All) 修改整体页面格式,增加地图中间模型与下方表格间的联动显示

This commit is contained in:
2025-12-19 16:50:02 +08:00
parent 1a0815759e
commit af49eb9747
9 changed files with 289 additions and 70 deletions

View File

@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react'
import { ThemeProvider } from './contexts/ThemeContext'
import ThemeSwitcher from './components/ThemeSwitcher'
import FloatingHeader from './components/FloatingHeader'
import IpInput from './components/IpInput'
import LatencyMap from './components/LatencyMap'
import ResultsPanel from './components/ResultsPanel'
@@ -11,10 +11,12 @@ import './styles/index.css'
function AppContent() {
const [results, setResults] = useState<Map<string, LatencyResult>>(new Map())
const [testing, setTesting] = useState(false)
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const handleTest = useCallback(async (target: string) => {
setTesting(true)
setResults(new Map())
setSelectedNodeId(null)
await testAllNodes(target, (result) => {
setResults((prev) => new Map(prev).set(result.nodeId, result))
@@ -23,27 +25,33 @@ function AppContent() {
setTesting(false)
}, [])
const handleNodeSelect = useCallback((nodeId: string | null) => {
setSelectedNodeId(nodeId)
}, [])
return (
<div className="app">
<header className="app-header">
<h1 className="app-title">
<span className="title-icon">🌐</span>
Latency Test
</h1>
<ThemeSwitcher />
</header>
<FloatingHeader />
<main className="app-main">
<main className="app-main" style={{ paddingTop: '5rem' }}>
<p className="app-description">
Test network latency from global locations to any IP address or domain
</p>
<IpInput onTest={handleTest} testing={testing} />
<LatencyMap results={results} />
<ResultsPanel results={results} />
<LatencyMap
results={results}
selectedNodeId={selectedNodeId}
onNodeSelect={handleNodeSelect}
/>
<ResultsPanel
results={results}
selectedNodeId={selectedNodeId}
onNodeSelect={handleNodeSelect}
/>
</main>
<footer className="app-footer">
<p>Latency thresholds: <span className="excellent"></span> &lt;50ms <span className="good"></span> 50-150ms <span className="poor"></span> &gt;150ms</p>
<p>© 2024 Latency Test. Powered by GlobalPing.</p>
</footer>
</div>
)

View File

@@ -0,0 +1,59 @@
.floating-header {
position: fixed;
top: 1.25rem;
left: 50%;
transform: translateX(-50%);
z-index: 100;
display: flex;
align-items: center;
gap: 3rem;
padding: 0.75rem 1.5rem 0.75rem 2rem;
background: var(--card-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border: 1px solid var(--border-color);
border-radius: 9999px;
box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.1);
transition: opacity var(--transition-smooth);
width: max-content;
max-width: 90vw;
}
.floating-header.scrolled {
opacity: 0.4;
}
.floating-header.scrolled:hover {
opacity: 1;
}
.header-title {
display: flex;
align-items: center;
gap: 0.6rem;
font-weight: 700;
font-size: 1.15rem;
color: var(--text-color);
white-space: nowrap;
}
.header-title .title-icon {
font-size: 1.4rem;
filter: drop-shadow(0 0 8px var(--accent-glow));
}
.floating-header .theme-switcher {
font-size: 1.1rem;
padding: 6px 10px;
}
@media (max-width: 640px) {
.floating-header {
top: 1rem;
gap: 1rem;
padding: 0.5rem 1rem;
}
}

View File

@@ -0,0 +1,28 @@
import { useState, useEffect } from 'react'
import ThemeSwitcher from './ThemeSwitcher'
import './FloatingHeader.css'
export default function FloatingHeader() {
const [isScrolled, setIsScrolled] = useState(false)
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 20)
}
window.addEventListener('scroll', handleScroll, { passive: true })
handleScroll()
return () => window.removeEventListener('scroll', handleScroll)
}, [])
return (
<header className={`floating-header ${isScrolled ? 'scrolled' : ''}`}>
<div className="header-title">
<span className="title-icon">🌐</span>
Latency Test
</div>
<ThemeSwitcher />
</header>
)
}

View File

@@ -9,6 +9,15 @@
background: #000;
border: 1px solid var(--border-color);
box-shadow: var(--shadow-glow), 0 10px 30px -5px rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.map-container > div:not(.globe-popup):not(.globe-instructions) {
display: flex !important;
align-items: center;
justify-content: center;
}
.map-container canvas {
@@ -32,6 +41,7 @@
}
.globe-popup {
display: block !important;
position: absolute;
top: 20px;
right: 20px;

View File

@@ -1,35 +1,18 @@
import { useMemo, useState, useEffect, useRef } from 'react'
import { useMemo, useEffect, useRef } from 'react'
import Globe, { GlobeMethods } from 'react-globe.gl'
import { TEST_NODES, LatencyResult, getLatencyColor } from '@shared/types'
import './LatencyMap.css'
interface LatencyMapProps {
results: Map<string, LatencyResult>
selectedNodeId: string | null
onNodeSelect: (nodeId: string | null) => void
}
export default function LatencyMap({ results }: LatencyMapProps) {
export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: LatencyMapProps) {
const globeEl = useRef<GlobeMethods | undefined>(undefined)
const [selectedNode, setSelectedNode] = useState<string | null>(null)
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setDimensions({
width: entry.contentRect.width,
height: entry.contentRect.height
})
}
})
if (containerRef.current) {
resizeObserver.observe(containerRef.current)
}
return () => resizeObserver.disconnect()
}, [])
useEffect(() => {
if (globeEl.current) {
globeEl.current.controls().autoRotate = true
@@ -37,37 +20,57 @@ export default function LatencyMap({ results }: LatencyMapProps) {
}
}, [])
// Rotate to selected node
useEffect(() => {
if (selectedNodeId && globeEl.current) {
const node = TEST_NODES.find(n => n.id === selectedNodeId)
if (node) {
globeEl.current.controls().autoRotate = false
globeEl.current.pointOfView({
lat: node.coords[1],
lng: node.coords[0],
altitude: 2
}, 1000)
}
}
}, [selectedNodeId])
const hasTesting = results.size > 0
const { pointsData, ringsData } = useMemo(() => {
if (!hasTesting) {
return { pointsData: [], ringsData: [] }
}
const points = TEST_NODES.map((node) => {
const result = results.get(node.id)
const hasResult = result?.status === 'success' || result?.status === 'failed'
return {
...node,
lat: node.coords[1],
lng: node.coords[0],
color: getLatencyColor(result?.latency ?? null),
color: hasResult ? getLatencyColor(result?.latency ?? null) : '#6b7280',
latency: result?.latency ?? null,
status: result?.status ?? 'pending',
size: selectedNode === node.id ? 1.2 : 0.5
size: selectedNodeId === node.id ? 1.0 : 0.6
}
})
const rings = points.filter(
(p) => p.status === 'testing' || p.status === 'success'
(p) => p.status === 'testing' || p.status === 'success' || p.status === 'failed'
)
return { pointsData: points, ringsData: rings }
}, [results, selectedNode])
}, [results, selectedNodeId, hasTesting])
const selectedNodeData = selectedNode
? pointsData.find(p => p.id === selectedNode)
const selectedNodeData = selectedNodeId
? pointsData.find(p => p.id === selectedNodeId)
: null
return (
<div className="map-container" ref={containerRef}>
<Globe
ref={globeEl}
width={dimensions.width}
height={dimensions.height}
globeImageUrl="//unpkg.com/three-globe/example/img/earth-night.jpg"
backgroundImageUrl="//unpkg.com/three-globe/example/img/night-sky.png"
@@ -88,7 +91,7 @@ export default function LatencyMap({ results }: LatencyMapProps) {
}}
onPointClick={(d: object) => {
const node = d as typeof pointsData[0]
setSelectedNode(node.id)
onNodeSelect(node.id)
if (globeEl.current) globeEl.current.controls().autoRotate = false
}}
@@ -108,7 +111,7 @@ export default function LatencyMap({ results }: LatencyMapProps) {
<div className="globe-popup">
<div className="popup-header">
<h3>{selectedNodeData.name}</h3>
<button className="close-btn" onClick={() => setSelectedNode(null)}>×</button>
<button className="close-btn" onClick={() => onNodeSelect(null)}>×</button>
</div>
<div className="popup-content">
<div className="popup-row">
@@ -136,7 +139,7 @@ export default function LatencyMap({ results }: LatencyMapProps) {
)}
<div className="globe-instructions">
Drag to rotate · Scroll to zoom · Click nodes for details
{hasTesting ? 'Drag to rotate · Scroll to zoom · Click nodes for details' : 'Drag to rotate · Scroll to zoom'}
</div>
</div>
)

View File

@@ -33,6 +33,41 @@
box-shadow: 0 0 10px var(--primary-color);
}
.results-header-right {
display: flex;
align-items: center;
gap: 20px;
}
.latency-legend {
display: flex;
flex-direction: column;
gap: 4px;
}
.legend-gradient {
width: 180px;
height: 8px;
border-radius: 4px;
background: linear-gradient(to right,
#059669 0%,
#10b981 20%,
#34d399 40%,
#a3e635 60%,
#facc15 80%,
#b45309 100%
);
box-shadow: 0 0 8px rgba(16, 185, 129, 0.3);
}
.legend-labels {
display: flex;
justify-content: space-between;
font-size: 0.65rem;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
}
.avg-latency {
font-family: 'JetBrains Mono', monospace;
font-size: 0.9rem;
@@ -82,14 +117,35 @@
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
}
.result-card.excellent { border-color: rgba(16, 185, 129, 0.3); }
.result-card.excellent::before { background: var(--success-color); opacity: 1; box-shadow: 0 0 10px var(--success-color); }
.result-card.excellent { border-color: rgba(5, 150, 105, 0.4); }
.result-card.excellent::before { background: #059669; opacity: 1; box-shadow: 0 0 10px #059669; }
.result-card.good { border-color: rgba(245, 158, 11, 0.3); }
.result-card.good::before { background: var(--warning-color); opacity: 1; box-shadow: 0 0 10px var(--warning-color); }
.result-card.great { border-color: rgba(16, 185, 129, 0.4); }
.result-card.great::before { background: #10b981; opacity: 1; box-shadow: 0 0 10px #10b981; }
.result-card.poor { border-color: rgba(239, 68, 68, 0.3); }
.result-card.poor::before { background: var(--error-color); opacity: 1; box-shadow: 0 0 10px var(--error-color); }
.result-card.good { border-color: rgba(52, 211, 153, 0.4); }
.result-card.good::before { background: #34d399; opacity: 1; box-shadow: 0 0 10px #34d399; }
.result-card.fair { border-color: rgba(163, 230, 53, 0.4); }
.result-card.fair::before { background: #a3e635; opacity: 1; box-shadow: 0 0 10px #a3e635; }
.result-card.moderate { border-color: rgba(250, 204, 21, 0.4); }
.result-card.moderate::before { background: #facc15; opacity: 1; box-shadow: 0 0 10px #facc15; }
.result-card.poor { border-color: rgba(180, 83, 9, 0.4); }
.result-card.poor::before { background: #b45309; opacity: 1; box-shadow: 0 0 10px #b45309; }
.result-card.timeout { border-color: rgba(239, 68, 68, 0.4); }
.result-card.timeout::before { background: #ef4444; opacity: 1; box-shadow: 0 0 10px #ef4444; }
.result-card.selected {
transform: translateY(-4px);
box-shadow: 0 0 0 2px var(--primary-color), 0 10px 25px -5px rgba(0, 0, 0, 0.2);
}
.result-card:hover {
cursor: pointer;
}
.result-region {
font-size: 0.65rem;

View File

@@ -3,9 +3,11 @@ import './ResultsPanel.css'
interface ResultsPanelProps {
results: Map<string, LatencyResult>
selectedNodeId: string | null
onNodeSelect: (nodeId: string | null) => void
}
export default function ResultsPanel({ results }: ResultsPanelProps) {
export default function ResultsPanel({ results, selectedNodeId, onNodeSelect }: ResultsPanelProps) {
if (results.size === 0) return null
const sortedNodes = [...TEST_NODES].sort((a, b) => {
@@ -34,12 +36,25 @@ export default function ResultsPanel({ results }: ResultsPanelProps) {
<div className="results-panel">
<div className="results-header">
<h2>Test Results</h2>
<div className="results-header-right">
<div className="latency-legend">
<div className="legend-gradient"></div>
<div className="legend-labels">
<span>0ms</span>
<span>50</span>
<span>100</span>
<span>150</span>
<span>200</span>
<span>250+</span>
</div>
</div>
{avgLatency !== null && (
<div className="avg-latency">
Avg: <span style={{ color: getLatencyColor(avgLatency) }}>{avgLatency}ms</span>
</div>
)}
</div>
</div>
<div className="results-grid">
{sortedNodes.map((node) => {
@@ -47,8 +62,14 @@ export default function ResultsPanel({ results }: ResultsPanelProps) {
const isTesting = result?.status === 'testing'
const hasResult = result?.status === 'success' || result?.status === 'failed'
const isSelected = selectedNodeId === node.id
return (
<div key={node.id} className={`result-card ${hasResult ? getLatencyLevel(result?.latency ?? null) : ''}`}>
<div
key={node.id}
className={`result-card ${hasResult ? getLatencyLevel(result?.latency ?? null) : ''} ${isSelected ? 'selected' : ''}`}
onClick={() => onNodeSelect(isSelected ? null : node.id)}
>
<div className="result-region">{node.region}</div>
<div className="result-name">{node.name}</div>
<div className="result-latency">

View File

@@ -13,12 +13,15 @@ export interface LatencyResult {
status: 'pending' | 'testing' | 'success' | 'failed'
}
export type LatencyLevel = 'excellent' | 'good' | 'poor'
export type LatencyLevel = 'excellent' | 'great' | 'good' | 'fair' | 'moderate' | 'poor' | 'timeout'
export const LATENCY_THRESHOLDS = {
excellent: 50,
good: 150,
poor: Infinity,
excellent: 50, // < 50ms - deep green
great: 100, // 50-100ms - green
good: 150, // 100-150ms - light green
fair: 200, // 150-200ms - yellow-green
moderate: 250, // 200-250ms - yellow
poor: Infinity, // > 250ms - brown
} as const
export const TEST_NODES: TestNode[] = [
@@ -27,37 +30,68 @@ export const TEST_NODES: TestNode[] = [
{ id: 'us-east', name: 'New York', region: 'North America', coords: [-74.006, 40.7128], country: 'US', city: 'New York' },
{ id: 'us-central', name: 'Dallas', region: 'North America', coords: [-96.797, 32.7767], country: 'US', city: 'Dallas' },
{ id: 'canada', name: 'Toronto', region: 'North America', coords: [-79.3832, 43.6532], country: 'CA', city: 'Toronto' },
// Europe
{ id: 'mexico', name: 'Mexico City', region: 'North America', coords: [-99.1332, 19.4326], country: 'MX', city: 'Mexico City' },
// Europe - Western
{ id: 'eu-west', name: 'London', region: 'Europe', coords: [-0.1276, 51.5074], country: 'GB', city: 'London' },
{ id: 'eu-central', name: 'Frankfurt', region: 'Europe', coords: [8.6821, 50.1109], country: 'DE', city: 'Frankfurt' },
{ id: 'eu-north', name: 'Amsterdam', region: 'Europe', coords: [4.9041, 52.3676], country: 'NL', city: 'Amsterdam' },
{ id: 'eu-south', name: 'Paris', region: 'Europe', coords: [2.3522, 48.8566], country: 'FR', city: 'Paris' },
// Asia
// Europe - Eastern
{ id: 'eu-east', name: 'Warsaw', region: 'Europe', coords: [21.0122, 52.2297], country: 'PL', city: 'Warsaw' },
{ id: 'russia', name: 'Moscow', region: 'Europe', coords: [37.6173, 55.7558], country: 'RU', city: 'Moscow' },
// Asia - East
{ id: 'asia-east', name: 'Tokyo', region: 'Asia', coords: [139.6917, 35.6895], country: 'JP', city: 'Tokyo' },
{ id: 'asia-se', name: 'Singapore', region: 'Asia', coords: [103.8198, 1.3521], country: 'SG', city: 'Singapore' },
{ id: 'asia-south', name: 'Mumbai', region: 'Asia', coords: [72.8777, 19.076], country: 'IN', city: 'Mumbai' },
{ id: 'asia-kr', name: 'Seoul', region: 'Asia', coords: [126.978, 37.5665], country: 'KR', city: 'Seoul' },
{ id: 'asia-hk', name: 'Hong Kong', region: 'Asia', coords: [114.1694, 22.3193], country: 'HK', city: 'Hong Kong' },
{ id: 'asia-tw', name: 'Taipei', region: 'Asia', coords: [121.5654, 25.033], country: 'TW', city: 'Taipei' },
// Asia - Southeast
{ id: 'asia-se', name: 'Singapore', region: 'Asia', coords: [103.8198, 1.3521], country: 'SG', city: 'Singapore' },
{ id: 'asia-th', name: 'Bangkok', region: 'Asia', coords: [100.5018, 13.7563], country: 'TH', city: 'Bangkok' },
{ id: 'asia-vn', name: 'Ho Chi Minh', region: 'Asia', coords: [106.6297, 10.8231], country: 'VN', city: 'Ho Chi Minh City' },
// Asia - South
{ id: 'asia-south', name: 'Mumbai', region: 'Asia', coords: [72.8777, 19.076], country: 'IN', city: 'Mumbai' },
// Middle East
{ id: 'me-uae', name: 'Dubai', region: 'Middle East', coords: [55.2708, 25.2048], country: 'AE', city: 'Dubai' },
{ id: 'me-il', name: 'Tel Aviv', region: 'Middle East', coords: [34.7818, 32.0853], country: 'IL', city: 'Tel Aviv' },
// South America
{ id: 'sa-east', name: 'São Paulo', region: 'South America', coords: [-46.6333, -23.5505], country: 'BR', city: 'Sao Paulo' },
{ id: 'sa-south', name: 'Buenos Aires', region: 'South America', coords: [-58.3816, -34.6037], country: 'AR', city: 'Buenos Aires' },
{ id: 'sa-west', name: 'Santiago', region: 'South America', coords: [-70.6693, -33.4489], country: 'CL', city: 'Santiago' },
// Africa
{ id: 'africa', name: 'Johannesburg', region: 'Africa', coords: [28.0473, -26.2041], country: 'ZA', city: 'Johannesburg' },
{ id: 'africa-south', name: 'Johannesburg', region: 'Africa', coords: [28.0473, -26.2041], country: 'ZA', city: 'Johannesburg' },
{ id: 'africa-north', name: 'Cairo', region: 'Africa', coords: [31.2357, 30.0444], country: 'EG', city: 'Cairo' },
{ id: 'africa-west', name: 'Lagos', region: 'Africa', coords: [3.3792, 6.5244], country: 'NG', city: 'Lagos' },
// Oceania
{ id: 'oceania', name: 'Sydney', region: 'Oceania', coords: [151.2093, -33.8688], country: 'AU', city: 'Sydney' },
{ id: 'oceania-nz', name: 'Auckland', region: 'Oceania', coords: [174.7633, -36.8485], country: 'NZ', city: 'Auckland' },
]
export function getLatencyLevel(latency: number | null): LatencyLevel {
if (latency === null) return 'poor'
if (latency === null) return 'timeout'
if (latency < LATENCY_THRESHOLDS.excellent) return 'excellent'
if (latency < LATENCY_THRESHOLDS.great) return 'great'
if (latency < LATENCY_THRESHOLDS.good) return 'good'
if (latency < LATENCY_THRESHOLDS.fair) return 'fair'
if (latency < LATENCY_THRESHOLDS.moderate) return 'moderate'
return 'poor'
}
export function getLatencyColor(latency: number | null): string {
const level = getLatencyLevel(latency)
const colors: Record<LatencyLevel, string> = {
excellent: '#22c55e',
good: '#eab308',
poor: '#ef4444',
excellent: '#059669', // deep green (emerald-600)
great: '#10b981', // green (emerald-500)
good: '#34d399', // light green (emerald-400)
fair: '#a3e635', // yellow-green (lime-400)
moderate: '#facc15', // yellow (yellow-400)
poor: '#b45309', // brown (amber-700)
timeout: '#ef4444', // red (red-500)
}
return colors[level]
}