From af49eb9747a150f5d1ebfe37ecc65a2646123aba881ae89b98e5979a4fcff105 Mon Sep 17 00:00:00 2001 From: ahdoawhfo Date: Fri, 19 Dec 2025 16:50:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(All)=20=E4=BF=AE=E6=94=B9=E6=95=B4?= =?UTF-8?q?=E4=BD=93=E9=A1=B5=E9=9D=A2=E6=A0=BC=E5=BC=8F=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=9C=B0=E5=9B=BE=E4=B8=AD=E9=97=B4=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=B8=8E=E4=B8=8B=E6=96=B9=E8=A1=A8=E6=A0=BC=E9=97=B4=E7=9A=84?= =?UTF-8?q?=E8=81=94=E5=8A=A8=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 2 +- src/client/App.tsx | 32 ++++++----- src/client/components/FloatingHeader.css | 59 ++++++++++++++++++++ src/client/components/FloatingHeader.tsx | 28 ++++++++++ src/client/components/LatencyMap.css | 10 ++++ src/client/components/LatencyMap.tsx | 67 ++++++++++++----------- src/client/components/ResultsPanel.css | 68 +++++++++++++++++++++--- src/client/components/ResultsPanel.tsx | 33 +++++++++--- src/shared/types.ts | 60 ++++++++++++++++----- 9 files changed, 289 insertions(+), 70 deletions(-) create mode 100644 src/client/components/FloatingHeader.css create mode 100644 src/client/components/FloatingHeader.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2cb25cf..f8f38b7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,4 +13,4 @@ ], "deny": [] } -} +} \ No newline at end of file diff --git a/src/client/App.tsx b/src/client/App.tsx index 3ec534a..533d7c4 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -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>(new Map()) const [testing, setTesting] = useState(false) + const [selectedNodeId, setSelectedNodeId] = useState(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 (
-
-

- 🌐 - Latency Test -

- -
+ -
+

Test network latency from global locations to any IP address or domain

- - + +
-

Latency thresholds: <50ms 50-150ms >150ms

+

© 2024 Latency Test. Powered by GlobalPing.

) diff --git a/src/client/components/FloatingHeader.css b/src/client/components/FloatingHeader.css new file mode 100644 index 0000000..1ff02a7 --- /dev/null +++ b/src/client/components/FloatingHeader.css @@ -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; + } +} diff --git a/src/client/components/FloatingHeader.tsx b/src/client/components/FloatingHeader.tsx new file mode 100644 index 0000000..cebb296 --- /dev/null +++ b/src/client/components/FloatingHeader.tsx @@ -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 ( +
+
+ 🌐 + Latency Test +
+ +
+ ) +} diff --git a/src/client/components/LatencyMap.css b/src/client/components/LatencyMap.css index 7ea15ea..9102643 100644 --- a/src/client/components/LatencyMap.css +++ b/src/client/components/LatencyMap.css @@ -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; diff --git a/src/client/components/LatencyMap.tsx b/src/client/components/LatencyMap.tsx index 95c37e1..ccb5ce8 100644 --- a/src/client/components/LatencyMap.tsx +++ b/src/client/components/LatencyMap.tsx @@ -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 + 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(undefined) - const [selectedNode, setSelectedNode] = useState(null) - const [dimensions, setDimensions] = useState({ width: 0, height: 0 }) const containerRef = useRef(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 (
{ 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) {

{selectedNodeData.name}

- +
@@ -136,7 +139,7 @@ export default function LatencyMap({ results }: LatencyMapProps) { )}
- 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'}
) diff --git a/src/client/components/ResultsPanel.css b/src/client/components/ResultsPanel.css index 698d54c..afd36af 100644 --- a/src/client/components/ResultsPanel.css +++ b/src/client/components/ResultsPanel.css @@ -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; diff --git a/src/client/components/ResultsPanel.tsx b/src/client/components/ResultsPanel.tsx index be0c84b..6d1ca10 100644 --- a/src/client/components/ResultsPanel.tsx +++ b/src/client/components/ResultsPanel.tsx @@ -3,9 +3,11 @@ import './ResultsPanel.css' interface ResultsPanelProps { results: Map + 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,11 +36,24 @@ export default function ResultsPanel({ results }: ResultsPanelProps) {

Test Results

- {avgLatency !== null && ( -
- Avg: {avgLatency}ms +
+
+
+
+ 0ms + 50 + 100 + 150 + 200 + 250+ +
- )} + {avgLatency !== null && ( +
+ Avg: {avgLatency}ms +
+ )} +
@@ -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 ( -
+
onNodeSelect(isSelected ? null : node.id)} + >
{node.region}
{node.name}
diff --git a/src/shared/types.ts b/src/shared/types.ts index a1a2d7c..fbe96ec 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 = { - 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] }