diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..2cb25cf --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "https://api.a6.wiki", + "ANTHROPIC_AUTH_TOKEN": "sk-ahdoawhfo", + "ANTHROPIC_MODEL": "claude-opus-4-5-thinking" + }, + "permissions": { + "allow": [ + "mcp__gemini__gemini", + "mcp__codex__codex", + "Bash(npm run build)", + "Bash(npm install react-globe.gl three)" + ], + "deny": [] + } +} diff --git a/index.html b/index.html index 546bb1a..644af17 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,9 @@ + + + Latency Test diff --git a/package-lock.json b/package-lock.json index 18e4125..e6586df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "ipaddr.js": "^2.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-simple-maps": "^3.0.0" + "react-globe.gl": "^2.37.0", + "react-simple-maps": "^3.0.0", + "three": "^0.182.0" }, "devDependencies": { "@types/cors": "^2.8.17", @@ -263,6 +265,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -1118,6 +1129,55 @@ "win32" ] }, + "node_modules/@turf/boolean-point-in-polygon": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.1.tgz", + "integrity": "sha512-BUPW63vE43LctwkgannjmEFTX1KFR/18SS7WzFahJWK1ZoP0s1jrfxGX+pi0BH/3Dd9mA71hkGKDDnj1Ndcz0g==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "point-in-polygon-hao": "^1.1.0", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.1.tgz", + "integrity": "sha512-zkL34JVhi5XhsuMEO0MUTIIFEJ8yiW1InMu4hu/oRqamlY4mMoZql0viEmH6Dafh/p+zOl8OYvMJ3Vm3rFshgg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/invariant": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.3.1.tgz", + "integrity": "sha512-IdZJfDjIDCLH+Gu2yLFoSM7H23sdetIo5t4ET1/25X8gi3GE2XSqbZwaGjuZgNh02nisBewLqNiJs2bo+hrqZA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1227,6 +1287,12 @@ "@types/send": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -1360,6 +1426,15 @@ "node": ">= 0.6" } }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1708,6 +1783,18 @@ "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==", "license": "BSD-3-Clause" }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-dispatch": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz", @@ -1730,6 +1817,15 @@ "integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==", "license": "BSD-3-Clause" }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-geo": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz", @@ -1739,6 +1835,45 @@ "d3-array": "^2.5.0" } }, + "node_modules/d3-geo-voronoi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/d3-geo-voronoi/-/d3-geo-voronoi-2.1.0.tgz", + "integrity": "sha512-kqE4yYuOjPbKdBXG0xztCacPwkVSK2REF1opSNrnqqtXJmNcM++UbwQ8SxvwP6IQTj9RvIjjK4qeiVsEfj0Z2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-delaunay": "6", + "d3-geo": "3", + "d3-tricontour": "1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo-voronoi/node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo-voronoi/node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-interpolate": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz", @@ -1748,12 +1883,71 @@ "d3-color": "1 - 2" } }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-selection": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz", "integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==", "license": "BSD-3-Clause" }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-timer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz", @@ -1776,6 +1970,19 @@ "d3-selection": "2" } }, + "node_modules/d3-tricontour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-tricontour/-/d3-tricontour-1.1.0.tgz", + "integrity": "sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ==", + "license": "ISC", + "dependencies": { + "d3-delaunay": "6", + "d3-scale": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-zoom": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz", @@ -1789,6 +1996,18 @@ "d3-transition": "2" } }, + "node_modules/data-bind-mapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/data-bind-mapper/-/data-bind-mapper-1.0.3.tgz", + "integrity": "sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==", + "license": "MIT", + "dependencies": { + "accessor-fn": "1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1807,6 +2026,15 @@ } } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1840,6 +2068,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2093,6 +2327,20 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/float-tooltip": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", + "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", + "license": "MIT", + "dependencies": { + "d3-selection": "2 - 3", + "kapsule": "^1.16", + "preact": "10" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2102,6 +2350,15 @@ "node": ">= 0.6" } }, + "node_modules/frame-ticker": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/frame-ticker/-/frame-ticker-1.0.3.tgz", + "integrity": "sha512-E0X2u2JIvbEMrqEg5+4BpTqaD22OwojJI63K7MdKHdncjtAhGRbCR8nJCr2vwEt9NWBPCPcu70X9smPviEBy8Q==", + "license": "MIT", + "dependencies": { + "simplesignal": "^2.1.6" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -2205,6 +2462,23 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/globe.gl": { + "version": "2.45.0", + "resolved": "https://registry.npmjs.org/globe.gl/-/globe.gl-2.45.0.tgz", + "integrity": "sha512-fjkLHVBrnbESkUgklTd4UbcGLciu4nIl49IIi1hclLjI6MU3ASu6JYmf/K5qwPf7I+tNOauQRr4i5Y28JTtHQg==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "kapsule": "^1.16", + "three": ">=0.154 <1", + "three-globe": "^2.45", + "three-render-objects": "^1.40" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2217,6 +2491,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/h3-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz", + "integrity": "sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog==", + "license": "Apache-2.0", + "engines": { + "node": ">=4", + "npm": ">=3", + "yarn": ">=1.3.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2283,6 +2568,15 @@ "node": ">=0.10.0" } }, + "node_modules/index-array-by": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", + "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2314,6 +2608,15 @@ "node": ">=8" } }, + "node_modules/jerrypick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz", + "integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2346,6 +2649,24 @@ "node": ">=6" } }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/lodash-es": { + "version": "4.17.22", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", + "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2546,6 +2867,27 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/point-in-polygon-hao": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz", + "integrity": "sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==", + "license": "MIT", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/polished": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", + "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2575,12 +2917,21 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.28.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.0.tgz", + "integrity": "sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -2673,12 +3024,43 @@ "react": "^18.3.1" } }, + "node_modules/react-globe.gl": { + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/react-globe.gl/-/react-globe.gl-2.37.0.tgz", + "integrity": "sha512-nN1FDOJBhFvWfKrOY0SnkDuA8wk9FSTBN0HFfAdTqqcFM5R+OXBIxK0BM6t8n3oNVYpEJVxEzjYFwLyk4BC7Cw==", + "license": "MIT", + "dependencies": { + "globe.gl": "^2.45", + "prop-types": "15", + "react-kapsule": "^2.5" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-kapsule": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.5.7.tgz", + "integrity": "sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==", "license": "MIT", - "peer": true + "dependencies": { + "jerrypick": "^1.1.1" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": ">=16.13.1" + } }, "node_modules/react-refresh": { "version": "0.17.0", @@ -2727,6 +3109,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.53.5", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", @@ -2969,6 +3357,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simplesignal": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/simplesignal/-/simplesignal-2.1.7.tgz", + "integrity": "sha512-PEo2qWpUke7IMhlqiBxrulIFvhJRLkl1ih52Rwa+bPjzhJepcd4GIjn2RiQmFSx3dQvsEAgF0/lXMwMN7vODaA==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3032,6 +3426,168 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/three": { + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", + "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", + "license": "MIT" + }, + "node_modules/three-conic-polygon-geometry": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/three-conic-polygon-geometry/-/three-conic-polygon-geometry-2.1.2.tgz", + "integrity": "sha512-NaP3RWLJIyPGI+zyaZwd0Yj6rkoxm4FJHqAX1Enb4L64oNYLCn4bz1ESgOEYavgcUwCNYINu1AgEoUBJr1wZcA==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "^7.2", + "d3-array": "1 - 3", + "d3-geo": "1 - 3", + "d3-geo-voronoi": "2", + "d3-scale": "1 - 4", + "delaunator": "5", + "earcut": "3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.72.0" + } + }, + "node_modules/three-geojson-geometry": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/three-geojson-geometry/-/three-geojson-geometry-2.1.1.tgz", + "integrity": "sha512-dC7bF3ri1goDcihYhzACHOBQqu7YNNazYLa2bSydVIiJUb3jDFojKSy+gNj2pMkqZNSVjssSmdY9zlmnhEpr1w==", + "license": "MIT", + "dependencies": { + "d3-geo": "1 - 3", + "d3-interpolate": "1 - 3", + "earcut": "3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.72.0" + } + }, + "node_modules/three-globe": { + "version": "2.45.0", + "resolved": "https://registry.npmjs.org/three-globe/-/three-globe-2.45.0.tgz", + "integrity": "sha512-Ur6BVkezvmHnvsEg8fbq6gIscSZtknSQMWwDRbiJ95o6OSDjDbGTc4oO6nP7mOM9aAA3YrF7YZyOwSkP4T56QA==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "d3-array": "3", + "d3-color": "3", + "d3-geo": "3", + "d3-interpolate": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "data-bind-mapper": "1", + "frame-ticker": "1", + "h3-js": "4", + "index-array-by": "1", + "kapsule": "^1.16", + "three-conic-polygon-geometry": "2", + "three-geojson-geometry": "2", + "three-slippy-map-globe": "1", + "tinycolor2": "1" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.154" + } + }, + "node_modules/three-globe/node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/three-globe/node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/three-globe/node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/three-globe/node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/three-render-objects": { + "version": "1.40.4", + "resolved": "https://registry.npmjs.org/three-render-objects/-/three-render-objects-1.40.4.tgz", + "integrity": "sha512-Ukpu1pei3L5r809izvjsZxwuRcYLiyn6Uvy3lZ9bpMTdvj3i6PeX6w++/hs2ZS3KnEzGjb6YvTvh4UQuwHTDJg==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "float-tooltip": "^1.7", + "kapsule": "^1.16", + "polished": "4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.168" + } + }, + "node_modules/three-slippy-map-globe": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/three-slippy-map-globe/-/three-slippy-map-globe-1.0.4.tgz", + "integrity": "sha512-am8A4PP38AfTdrhXBDucwPRHLTbBl93yhpjIs56K1TLs9VuUWzg68oim4Dibs9QC1riXbj5SoBp/okA1VN9eYg==", + "license": "MIT", + "dependencies": { + "d3-geo": "1 - 3", + "d3-octree": "^1.1", + "d3-scale": "1 - 4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.154" + } + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3086,7 +3642,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tsx": { diff --git a/package.json b/package.json index 6fd5e2d..2f1cb3e 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "ipaddr.js": "^2.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-simple-maps": "^3.0.0" + "react-globe.gl": "^2.37.0", + "react-simple-maps": "^3.0.0", + "three": "^0.182.0" }, "devDependencies": { "@types/cors": "^2.8.17", diff --git a/src/client/App.tsx b/src/client/App.tsx index 8605237..3ec534a 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -12,11 +12,11 @@ function AppContent() { const [results, setResults] = useState>(new Map()) const [testing, setTesting] = useState(false) - const handleTest = useCallback(async (ip: string) => { + const handleTest = useCallback(async (target: string) => { setTesting(true) setResults(new Map()) - await testAllNodes(ip, (result) => { + await testAllNodes(target, (result) => { setResults((prev) => new Map(prev).set(result.nodeId, result)) }) @@ -35,7 +35,7 @@ function AppContent() {

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

diff --git a/src/client/api/latency.ts b/src/client/api/latency.ts index eca0985..1c10e20 100644 --- a/src/client/api/latency.ts +++ b/src/client/api/latency.ts @@ -6,10 +6,17 @@ export interface IpInfoResponse { ip: string } -export interface LatencyTestResponse { - nodeId: string - latency: number | null - success: boolean +export interface BatchMeasurementResponse { + measurementId: string +} + +export interface BatchResultResponse { + status: 'in-progress' | 'finished' + results: Array<{ + nodeId: string + latency: number | null + success: boolean + }> } export async function fetchUserIp(): Promise { @@ -19,34 +26,67 @@ export async function fetchUserIp(): Promise { return data.ip } -export async function testLatency( - targetIp: string, - nodeId: string -): Promise { - const res = await fetch(`${API_BASE}/latency`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ targetIp, nodeId }), - }) - if (!res.ok) { - return { nodeId, latency: null, status: 'failed' } - } - const data: LatencyTestResponse = await res.json() - return { - nodeId: data.nodeId, - latency: data.latency, - status: data.success ? 'success' : 'failed', - } -} - export async function testAllNodes( - targetIp: string, + target: string, onProgress: (result: LatencyResult) => void ): Promise { - const promises = TEST_NODES.map(async (node) => { - onProgress({ nodeId: node.id, latency: null, status: 'testing' }) - const result = await testLatency(targetIp, node.id) - onProgress(result) + for (const node of TEST_NODES) { + onProgress({ nodeId: node.id, latency: null, status: 'pending' }) + } + + const res = await fetch(`${API_BASE}/latency/batch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target }), }) - await Promise.all(promises) + + if (!res.ok) { + for (const node of TEST_NODES) { + onProgress({ nodeId: node.id, latency: null, status: 'failed' }) + } + return + } + + const { measurementId }: BatchMeasurementResponse = await res.json() + + for (const node of TEST_NODES) { + onProgress({ nodeId: node.id, latency: null, status: 'testing' }) + } + + const startTime = Date.now() + const timeout = 60000 + const completedNodes = new Set() + + while (Date.now() - startTime < timeout) { + await new Promise(r => setTimeout(r, 800)) + + const pollRes = await fetch(`${API_BASE}/latency/batch/${measurementId}`) + if (!pollRes.ok) continue + + const data: BatchResultResponse = await pollRes.json() + + for (const result of data.results) { + if (result.success && !completedNodes.has(result.nodeId)) { + completedNodes.add(result.nodeId) + onProgress({ + nodeId: result.nodeId, + latency: result.latency, + status: 'success' + }) + } + } + + if (data.status === 'finished') { + for (const result of data.results) { + if (!completedNodes.has(result.nodeId)) { + onProgress({ + nodeId: result.nodeId, + latency: result.latency, + status: result.success ? 'success' : 'failed' + }) + } + } + break + } + } } diff --git a/src/client/components/IpInput.css b/src/client/components/IpInput.css index b436081..880c3cb 100644 --- a/src/client/components/IpInput.css +++ b/src/client/components/IpInput.css @@ -1,33 +1,42 @@ .ip-input-form { display: flex; justify-content: center; - gap: 0; - margin-bottom: 2rem; - flex-wrap: wrap; + position: relative; + max-width: 500px; + margin: 0 auto 3rem; + filter: drop-shadow(var(--shadow-glow)); } .input-wrapper { position: relative; flex: 1; - max-width: 300px; - min-width: 200px; + z-index: 1; } .ip-input { width: 100%; - box-sizing: border-box; + padding: 16px 20px; + font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 1rem; - padding: 12px 16px; - border-radius: 8px 0 0 8px; - border: 2px solid var(--border-color); - background-color: var(--input-bg); + background: var(--input-bg); + border: 1px solid var(--border-color); + border-right: none; + border-radius: 12px 0 0 12px; color: var(--text-color); outline: none; - transition: border-color 0.2s; + transition: all var(--transition-fast); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); +} + +.ip-input::placeholder { + color: var(--text-secondary); + opacity: 0.7; } .ip-input:focus { border-color: var(--primary-color); + box-shadow: inset 0 0 20px rgba(56, 189, 248, 0.08); } .ip-input-error { @@ -41,46 +50,86 @@ .error-hint { position: absolute; - left: 0; - bottom: -20px; + left: 4px; + bottom: -24px; font-size: 0.75rem; color: var(--error-color); + font-weight: 500; } .test-button { display: flex; align-items: center; - gap: 8px; - font-size: 1rem; - padding: 12px 24px; - border-radius: 0 8px 8px 0; - border: 2px solid var(--primary-color); - background-color: var(--primary-color); - color: white; - cursor: pointer; - transition: background-color 0.2s, transform 0.1s; + justify-content: center; + gap: 10px; + padding: 0 32px; font-weight: 600; + font-size: 0.95rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #fff; + background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)); + border: 1px solid transparent; + border-radius: 0 12px 12px 0; + cursor: pointer; + transition: all var(--transition-fast); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + position: relative; + overflow: hidden; +} + +.test-button::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(rgba(255,255,255,0.15), transparent); + opacity: 0; + transition: opacity 0.2s; } .test-button:hover:not(:disabled) { - background-color: var(--primary-hover); - border-color: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4); +} + +.test-button:hover:not(:disabled)::after { + opacity: 1; } .test-button:active:not(:disabled) { - transform: scale(0.98); + transform: translateY(1px); } .test-button:disabled { - opacity: 0.6; + background: var(--text-secondary); cursor: not-allowed; + opacity: 0.7; + box-shadow: none; +} + +.test-button:disabled::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 50%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent); + animation: scan 1.5s infinite; +} + +@keyframes scan { + 100% { left: 200%; } } .spinner { - width: 16px; - height: 16px; - border: 2px solid white; - border-top-color: transparent; + width: 18px; + height: 18px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; } @@ -89,24 +138,22 @@ to { transform: rotate(360deg); } } -@media (max-width: 480px) { +@media (max-width: 640px) { .ip-input-form { flex-direction: column; - align-items: stretch; - gap: 8px; + max-width: 100%; padding: 0 16px; } - .input-wrapper { - max-width: none; - } - .ip-input { - border-radius: 8px; + border-radius: 12px 12px 0 0; + border-right: 1px solid var(--border-color); + border-bottom: none; } .test-button { - border-radius: 8px; - justify-content: center; + border-radius: 0 0 12px 12px; + padding: 16px; + width: 100%; } } diff --git a/src/client/components/IpInput.tsx b/src/client/components/IpInput.tsx index 674712a..2531257 100644 --- a/src/client/components/IpInput.tsx +++ b/src/client/components/IpInput.tsx @@ -3,32 +3,39 @@ import { fetchUserIp } from '../api/latency' import './IpInput.css' interface IpInputProps { - onTest: (ip: string) => void + onTest: (target: string) => void testing: boolean } const IP_REGEX = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/ +const DOMAIN_REGEX = /^(?!:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/ + +function isValidTarget(value: string): boolean { + const trimmed = value.trim().toLowerCase() + return IP_REGEX.test(trimmed) || DOMAIN_REGEX.test(trimmed) +} export default function IpInput({ onTest, testing }: IpInputProps) { - const [ip, setIp] = useState('') + const [target, setTarget] = useState('') const [loading, setLoading] = useState(true) const [error, setError] = useState('') useEffect(() => { fetchUserIp() - .then(setIp) + .then(setTarget) .catch(() => setError('Failed to detect IP')) .finally(() => setLoading(false)) }, []) const handleSubmit = (e: React.FormEvent) => { e.preventDefault() - if (!IP_REGEX.test(ip)) { - setError('Invalid IP address') + const trimmed = target.trim() + if (!isValidTarget(trimmed)) { + setError('Invalid IP address or domain') return } setError('') - onTest(ip) + onTest(trimmed) } return ( @@ -36,18 +43,18 @@ export default function IpInput({ onTest, testing }: IpInputProps) {
{ - setIp(e.target.value) + setTarget(e.target.value) setError('') }} - placeholder={loading ? 'Detecting IP...' : 'Enter IP address'} + placeholder={loading ? 'Detecting IP...' : 'Enter IP or domain (e.g., 8.8.8.8 or google.com)'} className={`ip-input ${error ? 'ip-input-error' : ''}`} disabled={testing || loading} /> {error && {error}}
- + +
+
+ Region: + {selectedNodeData.region} +
+
+ Country: + {selectedNodeData.country} +
+
+ Latency: + + {selectedNodeData.latency ? `${selectedNodeData.latency}ms` : 'N/A'} + +
+
+ Status: + + {selectedNodeData.status} + +
+
+ + )} + +
+ Drag to rotate · Scroll to zoom · Click nodes for details +
) } diff --git a/src/client/components/ResultsPanel.css b/src/client/components/ResultsPanel.css index 92066a5..698d54c 100644 --- a/src/client/components/ResultsPanel.css +++ b/src/client/components/ResultsPanel.css @@ -1,80 +1,122 @@ .results-panel { - max-width: 900px; + max-width: 1000px; margin: 0 auto; + animation: fadeIn 0.5s ease-out; } .results-header { display: flex; justify-content: space-between; - align-items: center; - margin-bottom: 1rem; - padding: 0 4px; + align-items: flex-end; + margin-bottom: 1.5rem; + padding: 0 8px; + border-bottom: 1px solid var(--border-color); + padding-bottom: 1rem; } .results-header h2 { - margin: 0; - font-size: 1.25rem; + font-size: 1.5rem; + font-weight: 700; color: var(--text-color); + display: flex; + align-items: center; + gap: 10px; +} + +.results-header h2::before { + content: ''; + display: block; + width: 4px; + height: 24px; + background: var(--primary-color); + border-radius: 2px; + box-shadow: 0 0 10px var(--primary-color); } .avg-latency { - font-size: 1rem; + font-family: 'JetBrains Mono', monospace; + font-size: 0.9rem; color: var(--text-secondary); + background: var(--card-bg); + padding: 4px 12px; + border-radius: 20px; + border: 1px solid var(--border-color); } .results-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 16px; } .result-card { - background-color: var(--card-bg); + position: relative; + background: var(--card-bg); border: 1px solid var(--border-color); - border-radius: 8px; - padding: 12px; + border-radius: 12px; + padding: 16px; + display: flex; + flex-direction: column; + justify-content: space-between; + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + overflow: hidden; text-align: center; - transition: transform 0.2s, box-shadow 0.2s; +} + +.result-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 3px; + background: var(--text-secondary); + opacity: 0.3; + transition: background-color 0.3s, opacity 0.3s, box-shadow 0.3s; } .result-card:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px var(--shadow-color); + transform: translateY(-4px); + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1); } -.result-card.excellent { - border-color: #22c55e; -} +.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.good { - border-color: #eab308; -} +.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.poor { - border-color: #ef4444; -} +.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-region { - font-size: 0.7rem; - color: var(--text-secondary); + font-size: 0.65rem; text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 4px; + letter-spacing: 1px; + color: var(--text-secondary); + margin-bottom: 6px; + opacity: 0.8; } .result-name { - font-size: 0.9rem; + font-size: 0.95rem; font-weight: 600; color: var(--text-color); - margin-bottom: 8px; + margin-bottom: 12px; + line-height: 1.2; } .result-latency { - font-size: 1.1rem; + font-family: 'JetBrains Mono', monospace; + font-size: 1.5rem; font-weight: 700; + letter-spacing: -1px; } .testing-indicator { + font-size: 0.9rem; color: var(--primary-color); animation: pulse 1s ease-in-out infinite; } @@ -83,6 +125,11 @@ color: var(--text-secondary); } +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } diff --git a/src/client/styles/index.css b/src/client/styles/index.css index f2224b4..88ae8d0 100644 --- a/src/client/styles/index.css +++ b/src/client/styles/index.css @@ -1,32 +1,57 @@ :root { + /* Core Palette */ --primary-color: #3b82f6; --primary-hover: #2563eb; + --accent-glow: rgba(59, 130, 246, 0.4); + + --success-color: #10b981; + --warning-color: #f59e0b; --error-color: #ef4444; + + /* Text */ --text-color: #1f2937; --text-secondary: #6b7280; - --background-color: #f9fafb; - --card-bg: #ffffff; + + /* Backgrounds */ + --background-color: #f3f4f6; + --card-bg: rgba(255, 255, 255, 0.85); --input-bg: #ffffff; --border-color: #e5e7eb; --hover-bg: rgba(0, 0, 0, 0.05); + + /* Effects */ + --glass-blur: blur(12px); --shadow-color: rgba(0, 0, 0, 0.1); - --map-land: #e5e7eb; - --map-border: #d1d5db; + --shadow-glow: 0 0 0 transparent; + --transition-fast: 0.2s ease; + --transition-smooth: 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + /* Map */ + --map-land: #d1d5db; + --map-border: #9ca3af; --marker-inactive: #9ca3af; } [data-theme='dark'] { - --text-color: #f9fafb; - --text-secondary: #9ca3af; - --background-color: #111827; - --card-bg: #1f2937; - --input-bg: #374151; - --border-color: #374151; + --primary-color: #38bdf8; + --primary-hover: #0ea5e9; + --accent-glow: rgba(56, 189, 248, 0.35); + + --text-color: #e2e8f0; + --text-secondary: #94a3b8; + + --background-color: #0f172a; + --card-bg: rgba(30, 41, 59, 0.75); + --input-bg: rgba(15, 23, 42, 0.9); + --border-color: rgba(148, 163, 184, 0.2); --hover-bg: rgba(255, 255, 255, 0.1); + --shadow-color: rgba(0, 0, 0, 0.3); - --map-land: #374151; - --map-border: #4b5563; - --marker-inactive: #6b7280; + --shadow-glow: 0 0 20px rgba(56, 189, 248, 0.12); + + --map-land: #1e293b; + --map-border: #334155; + --marker-inactive: #475569; } * { @@ -36,11 +61,16 @@ } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: var(--background-color); + background-image: + radial-gradient(circle at 50% 0%, rgba(56, 189, 248, 0.08) 0%, transparent 50%), + radial-gradient(circle at 85% 85%, rgba(139, 92, 246, 0.05) 0%, transparent 40%); + background-attachment: fixed; color: var(--text-color); - line-height: 1.5; - transition: background-color 0.3s, color 0.3s; + line-height: 1.6; + min-height: 100vh; + transition: background-color var(--transition-smooth), color var(--transition-smooth); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } @@ -55,26 +85,39 @@ body { display: flex; justify-content: space-between; align-items: center; - padding: 1rem 2rem; + padding: 1.25rem 2rem; + background: var(--card-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); border-bottom: 1px solid var(--border-color); - background-color: var(--card-bg); + position: sticky; + top: 0; + z-index: 50; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.07); } .app-title { display: flex; align-items: center; - gap: 8px; + gap: 12px; font-size: 1.5rem; - font-weight: 700; + font-weight: 800; + letter-spacing: -0.025em; + background: linear-gradient(135deg, var(--text-color) 0%, var(--primary-color) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; } .title-icon { font-size: 1.75rem; + -webkit-text-fill-color: initial; + filter: drop-shadow(0 0 8px var(--accent-glow)); } .app-main { flex: 1; - padding: 2rem; + padding: 3rem 2rem; max-width: 1200px; margin: 0 auto; width: 100%; @@ -83,31 +126,30 @@ body { .app-description { text-align: center; color: var(--text-secondary); - margin-bottom: 1.5rem; + margin-bottom: 2.5rem; + font-size: 1.1rem; + max-width: 600px; + margin-left: auto; + margin-right: auto; } .app-footer { text-align: center; - padding: 1rem; + padding: 1.5rem; border-top: 1px solid var(--border-color); - font-size: 0.85rem; + background: var(--card-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + font-size: 0.875rem; color: var(--text-secondary); } -.app-footer .excellent { color: #22c55e; } -.app-footer .good { color: #eab308; } -.app-footer .poor { color: #ef4444; } +.app-footer .excellent { color: var(--success-color); text-shadow: 0 0 6px var(--success-color); } +.app-footer .good { color: var(--warning-color); text-shadow: 0 0 6px var(--warning-color); } +.app-footer .poor { color: var(--error-color); text-shadow: 0 0 6px var(--error-color); } @media (max-width: 768px) { - .app-header { - padding: 1rem; - } - - .app-title { - font-size: 1.25rem; - } - - .app-main { - padding: 1rem; - } + .app-header { padding: 1rem; } + .app-title { font-size: 1.25rem; } + .app-main { padding: 1.5rem 1rem; } } diff --git a/src/server/index.ts b/src/server/index.ts index d7c4b3e..564a505 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -2,6 +2,7 @@ import express, { Request, Response } from 'express' import cors from 'cors' import rateLimit from 'express-rate-limit' import ipaddr from 'ipaddr.js' +import { TEST_NODES } from '../shared/types' const app = express() const PORT = process.env.PORT || 3000 @@ -11,25 +12,10 @@ app.use(express.json()) app.use(cors({ origin: 'http://localhost:5173' })) app.use(rateLimit({ windowMs: 60 * 1000, - limit: 10, + limit: 20, message: { error: 'Rate limit exceeded' } })) -interface GlobalPingLocation { - country: string - city?: string -} - -const NODE_LOCATIONS: Record = { - 'us-west': { country: 'US', city: 'San Francisco' }, - 'us-east': { country: 'US', city: 'New York' }, - 'europe': { country: 'DE', city: 'Frankfurt' }, - 'asia': { country: 'JP', city: 'Tokyo' }, - 'south-america': { country: 'BR', city: 'Sao Paulo' }, - 'africa': { country: 'ZA', city: 'Cape Town' }, - 'oceania': { country: 'AU', city: 'Sydney' } -} - function extractClientIp(req: Request): string | null { const forwarded = req.headers['x-forwarded-for'] const raw = typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : req.socket.remoteAddress @@ -45,38 +31,63 @@ function isPublicIp(ip: string): boolean { return parsed.range() === 'unicast' } +const DOMAIN_REGEX = /^(?!:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/ + +function isValidDomain(domain: string): boolean { + if (domain.length > 253) return false + return DOMAIN_REGEX.test(domain) +} + +function isValidTarget(target: string): boolean { + return isPublicIp(target) || isValidDomain(target) +} + interface MeasurementResponse { id: string probesCount: number } +interface ProbeResult { + probe: { + continent: string + country: string + city: string + asn: number + network: string + } + result: { + status: string + rawOutput: string + stats?: { + min: number + max: number + avg: number + total: number + loss: number + } + } +} + interface MeasurementResult { id: string type: string status: 'in-progress' | 'finished' - results?: Array<{ - probe: { - continent: string - country: string - city: string - asn: number - network: string - } - result: { - status: string - rawOutput: string - stats?: { - min: number - max: number - avg: number - total: number - loss: number - } - } - }> + results?: ProbeResult[] } -async function createMeasurement(target: string, location: GlobalPingLocation): Promise { +interface BatchLatencyResult { + nodeId: string + latency: number | null + success: boolean +} + +async function createBatchMeasurement(target: string): Promise { + const locations = TEST_NODES.map(node => ({ + country: node.country, + city: node.city, + limit: 1 + })) + const res = await fetch(`${GLOBALPING_API}/measurements`, { method: 'POST', headers: { @@ -87,7 +98,8 @@ async function createMeasurement(target: string, location: GlobalPingLocation): body: JSON.stringify({ type: 'ping', target, - locations: [location], + inProgressUpdates: true, + locations, measurementOptions: { packets: 3 } @@ -103,35 +115,35 @@ async function createMeasurement(target: string, location: GlobalPingLocation): return data.id } -async function getMeasurementResult(id: string, timeout = 30000): Promise<{ latency: number | null; success: boolean }> { - const startTime = Date.now() +function normalizeLocationName(value?: string | null): string { + if (!value) return '' + return value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9]+/g, ' ') + .trim() + .toLowerCase() +} - while (Date.now() - startTime < timeout) { - await new Promise(r => setTimeout(r, 500)) +function matchProbeToNode(probe: ProbeResult['probe']): string | null { + const candidates = TEST_NODES.filter(node => node.country === probe.country) + if (candidates.length === 0) return null - const res = await fetch(`${GLOBALPING_API}/measurements/${id}`, { - headers: { - 'Accept-Encoding': 'gzip', - 'User-Agent': 'LatencyTest/1.0.0' + const probeCity = normalizeLocationName(probe.city) + if (probeCity) { + for (const node of candidates) { + const nodeCity = normalizeLocationName(node.city) + if (nodeCity && (probeCity.includes(nodeCity) || nodeCity.includes(probeCity))) { + return node.id } - }) - - if (!res.ok) { - throw new Error(`Failed to get measurement: ${res.status}`) - } - - const data = await res.json() as MeasurementResult - - if (data.status !== 'in-progress') { - const result = data.results?.[0]?.result - if (result?.status === 'finished' && result.stats?.avg != null) { - return { latency: Math.round(result.stats.avg), success: true } - } - return { latency: null, success: false } } } - return { latency: null, success: false } + if (candidates.length === 1) { + return candidates[0].id + } + + return null } app.get('/api/ip', (req: Request, res: Response) => { @@ -142,28 +154,77 @@ app.get('/api/ip', (req: Request, res: Response) => { res.json({ ip }) }) -app.post('/api/latency', async (req: Request, res: Response) => { - const { targetIp, nodeId } = req.body +app.post('/api/latency/batch', async (req: Request, res: Response) => { + const { target } = req.body - if (!targetIp || typeof targetIp !== 'string') { - return res.status(400).json({ error: 'targetIp is required' }) + if (!target || typeof target !== 'string') { + return res.status(400).json({ error: 'target is required' }) } - if (!nodeId || !NODE_LOCATIONS[nodeId]) { - return res.status(400).json({ error: `Invalid nodeId. Available: ${Object.keys(NODE_LOCATIONS).join(', ')}` }) - } + const trimmedTarget = target.trim().toLowerCase() - if (!isPublicIp(targetIp)) { - return res.status(400).json({ error: 'Invalid or private IP address' }) + if (!isValidTarget(trimmedTarget)) { + return res.status(400).json({ error: 'Invalid target. Please enter a valid IP address or domain name.' }) } try { - const measurementId = await createMeasurement(targetIp, NODE_LOCATIONS[nodeId]) - const { latency, success } = await getMeasurementResult(measurementId) - res.json({ nodeId, latency, success }) + const measurementId = await createBatchMeasurement(trimmedTarget) + res.json({ measurementId }) } catch (error) { - console.error('Latency test error:', error) - res.status(500).json({ nodeId, latency: null, success: false }) + console.error('Batch measurement creation error:', error) + res.status(500).json({ error: 'Failed to create measurement' }) + } +}) + +app.get('/api/latency/batch/:measurementId', async (req: Request, res: Response) => { + const { measurementId } = req.params + + try { + const fetchRes = await fetch(`${GLOBALPING_API}/measurements/${measurementId}`, { + headers: { + 'Accept-Encoding': 'gzip', + 'User-Agent': 'LatencyTest/1.0.0' + } + }) + + if (!fetchRes.ok) { + return res.status(fetchRes.status).json({ error: 'Failed to get measurement' }) + } + + const data = await fetchRes.json() as MeasurementResult + const results: BatchLatencyResult[] = [] + const matchedNodes = new Set() + + if (data.results) { + for (const probeResult of data.results) { + const result = probeResult.result + const nodeId = matchProbeToNode(probeResult.probe) + + if (nodeId && !matchedNodes.has(nodeId)) { + matchedNodes.add(nodeId) + if (result.status === 'finished') { + const latency = result.stats?.avg != null ? Math.round(result.stats.avg) : null + results.push({ nodeId, latency, success: latency !== null }) + } else { + results.push({ nodeId, latency: null, success: false }) + } + } + } + } + + for (const node of TEST_NODES) { + if (!matchedNodes.has(node.id)) { + results.push({ nodeId: node.id, latency: null, success: false }) + } + } + + res.json({ + status: data.status, + results + }) + } catch (error) { + console.error('Get measurement error:', error) + res.status(500).json({ error: 'Failed to get measurement' }) } }) diff --git a/src/shared/types.js b/src/shared/types.js deleted file mode 100644 index 15ed02b..0000000 --- a/src/shared/types.js +++ /dev/null @@ -1,32 +0,0 @@ -export const LATENCY_THRESHOLDS = { - excellent: 50, - good: 150, - poor: Infinity, -}; -export const TEST_NODES = [ - { id: 'us-west', name: 'US West', region: 'North America', coords: [-122.4194, 37.7749] }, - { id: 'us-east', name: 'US East', region: 'North America', coords: [-74.006, 40.7128] }, - { id: 'europe', name: 'Frankfurt', region: 'Europe', coords: [8.6821, 50.1109] }, - { id: 'asia', name: 'Tokyo', region: 'Asia', coords: [139.6917, 35.6895] }, - { id: 'south-america', name: 'São Paulo', region: 'South America', coords: [-46.6333, -23.5505] }, - { id: 'africa', name: 'Cape Town', region: 'Africa', coords: [18.4241, -33.9249] }, - { id: 'oceania', name: 'Sydney', region: 'Oceania', coords: [151.2093, -33.8688] }, -]; -export function getLatencyLevel(latency) { - if (latency === null) - return 'poor'; - if (latency < LATENCY_THRESHOLDS.excellent) - return 'excellent'; - if (latency < LATENCY_THRESHOLDS.good) - return 'good'; - return 'poor'; -} -export function getLatencyColor(latency) { - const level = getLatencyLevel(latency); - const colors = { - excellent: '#22c55e', - good: '#eab308', - poor: '#ef4444', - }; - return colors[level]; -} diff --git a/src/shared/types.ts b/src/shared/types.ts index e244af5..a1a2d7c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3,6 +3,8 @@ export interface TestNode { name: string region: string coords: [number, number] // [longitude, latitude] + country: string + city?: string } export interface LatencyResult { @@ -20,13 +22,27 @@ export const LATENCY_THRESHOLDS = { } as const export const TEST_NODES: TestNode[] = [ - { id: 'us-west', name: 'US West', region: 'North America', coords: [-122.4194, 37.7749] }, - { id: 'us-east', name: 'US East', region: 'North America', coords: [-74.006, 40.7128] }, - { id: 'europe', name: 'Frankfurt', region: 'Europe', coords: [8.6821, 50.1109] }, - { id: 'asia', name: 'Tokyo', region: 'Asia', coords: [139.6917, 35.6895] }, - { id: 'south-america', name: 'São Paulo', region: 'South America', coords: [-46.6333, -23.5505] }, - { id: 'africa', name: 'Cape Town', region: 'Africa', coords: [18.4241, -33.9249] }, - { id: 'oceania', name: 'Sydney', region: 'Oceania', coords: [151.2093, -33.8688] }, + // North America + { id: 'us-west', name: 'Los Angeles', region: 'North America', coords: [-118.2437, 34.0522], country: 'US', city: 'Los Angeles' }, + { 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: '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 + { 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-hk', name: 'Hong Kong', region: 'Asia', coords: [114.1694, 22.3193], country: 'HK', city: 'Hong Kong' }, + // South America + { id: 'sa-east', name: 'São Paulo', region: 'South America', coords: [-46.6333, -23.5505], country: 'BR', city: 'Sao Paulo' }, + // Africa + { id: 'africa', name: 'Johannesburg', region: 'Africa', coords: [28.0473, -26.2041], country: 'ZA', city: 'Johannesburg' }, + // Oceania + { id: 'oceania', name: 'Sydney', region: 'Oceania', coords: [151.2093, -33.8688], country: 'AU', city: 'Sydney' }, ] export function getLatencyLevel(latency: number | null): LatencyLevel {