feat(All) 修改整体页面格式,增加地图中间模型与下方表格间的联动显示
This commit is contained in:
@@ -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> <50ms <span className="good">●</span> 50-150ms <span className="poor">●</span> >150ms</p>
|
||||
<p>© 2024 Latency Test. Powered by GlobalPing.</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
59
src/client/components/FloatingHeader.css
Normal file
59
src/client/components/FloatingHeader.css
Normal 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;
|
||||
}
|
||||
}
|
||||
28
src/client/components/FloatingHeader.tsx
Normal file
28
src/client/components/FloatingHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user