feat(loader): real Natural Earth globe via d3-geo + topojson + world-atlas
Per user — GlobeLoader должен показывать реальные континенты (Asia/Africa/ Australia outline) с rotating orthographic projection, как в прототипе Downloads/Globe loader/Globe Loader.html. Прошлая версия использовала faked ellipses graticule — выглядело как абстрактные кольца, не как глобус. Implementation: - d3-geo.geoOrthographic + d3.geoPath для projection math - topojson-client.feature для распаковки world-atlas land-110m - dynamic import обоих deps + JSON — не блокирует main bundle, lazy chunk - requestAnimationFrame loop пересчитывает d-attr на каждом кадре (8s per revolution per прототип, tilt -15° lean) - LAND_CACHE module-level — повторные mount'ы не re-fetch - CSS .globe-sphere rotation удалена — d3 projection делает работу, CSS transform distort'ила бы land mass (нет projection math) Bundle hit: ~55kb (land-110m.json) + ~15kb gzipped (d3-geo + topojson) — lazy chunk, fetched один раз и cached.
This commit is contained in:
@@ -31,6 +31,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"d3-force": "^3.0.0",
|
"d3-force": "^3.0.0",
|
||||||
|
"d3-geo": "^3.1.1",
|
||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.3",
|
||||||
"i18next-browser-languagedetector": "^8.0.2",
|
"i18next-browser-languagedetector": "^8.0.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
@@ -44,7 +45,9 @@
|
|||||||
"react-oidc-context": "^3.3.1",
|
"react-oidc-context": "^3.3.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"topojson-client": "^3.1.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"world-atlas": "^2.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@semantic-release/changelog": "^6.0.3",
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
@@ -59,9 +62,12 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/d3-force": "^3.0.10",
|
"@types/d3-force": "^3.0.10",
|
||||||
|
"@types/d3-geo": "^3.1.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@types/topojson-client": "^3.1.5",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"conventional-changelog-conventionalcommits": "^9.3.1",
|
"conventional-changelog-conventionalcommits": "^9.3.1",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
|||||||
Generated
+80
@@ -68,6 +68,9 @@ importers:
|
|||||||
d3-force:
|
d3-force:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
|
d3-geo:
|
||||||
|
specifier: ^3.1.1
|
||||||
|
version: 3.1.1
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^26.0.3
|
specifier: ^26.0.3
|
||||||
version: 26.0.8(typescript@5.9.3)
|
version: 26.0.8(typescript@5.9.3)
|
||||||
@@ -107,9 +110,15 @@ importers:
|
|||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
|
topojson-client:
|
||||||
|
specifier: ^3.1.0
|
||||||
|
version: 3.1.0
|
||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
|
world-atlas:
|
||||||
|
specifier: ^2.0.2
|
||||||
|
version: 2.0.2
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@semantic-release/changelog':
|
'@semantic-release/changelog':
|
||||||
specifier: ^6.0.3
|
specifier: ^6.0.3
|
||||||
@@ -147,6 +156,12 @@ importers:
|
|||||||
'@types/d3-force':
|
'@types/d3-force':
|
||||||
specifier: ^3.0.10
|
specifier: ^3.0.10
|
||||||
version: 3.0.10
|
version: 3.0.10
|
||||||
|
'@types/d3-geo':
|
||||||
|
specifier: ^3.1.0
|
||||||
|
version: 3.1.0
|
||||||
|
'@types/geojson':
|
||||||
|
specifier: ^7946.0.16
|
||||||
|
version: 7946.0.16
|
||||||
'@types/leaflet':
|
'@types/leaflet':
|
||||||
specifier: ^1.9.21
|
specifier: ^1.9.21
|
||||||
version: 1.9.21
|
version: 1.9.21
|
||||||
@@ -156,6 +171,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.3(@types/react@19.2.14)
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
|
'@types/topojson-client':
|
||||||
|
specifier: ^3.1.5
|
||||||
|
version: 3.1.5
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.3.4
|
specifier: ^4.3.4
|
||||||
version: 4.7.0(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0))
|
version: 4.7.0(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0))
|
||||||
@@ -1619,6 +1637,9 @@ packages:
|
|||||||
'@types/d3-force@3.0.10':
|
'@types/d3-force@3.0.10':
|
||||||
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
|
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
|
||||||
|
|
||||||
|
'@types/d3-geo@3.1.0':
|
||||||
|
resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@@ -1642,6 +1663,12 @@ packages:
|
|||||||
'@types/react@19.2.14':
|
'@types/react@19.2.14':
|
||||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||||
|
|
||||||
|
'@types/topojson-client@3.1.5':
|
||||||
|
resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==}
|
||||||
|
|
||||||
|
'@types/topojson-specification@1.0.5':
|
||||||
|
resolution: {integrity: sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==}
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.7.0':
|
'@vitejs/plugin-react@4.7.0':
|
||||||
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
||||||
engines: {node: ^14.18.0 || >=16.0.0}
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
@@ -1898,6 +1925,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
commander@2.20.3:
|
||||||
|
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||||
|
|
||||||
compare-func@2.0.0:
|
compare-func@2.0.0:
|
||||||
resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==}
|
resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==}
|
||||||
|
|
||||||
@@ -1966,6 +1996,10 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-dispatch@3.0.1:
|
d3-dispatch@3.0.1:
|
||||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1974,6 +2008,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
|
resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-geo@3.1.1:
|
||||||
|
resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-quadtree@3.0.1:
|
d3-quadtree@3.0.1:
|
||||||
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
|
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2407,6 +2445,10 @@ packages:
|
|||||||
ini@1.3.8:
|
ini@1.3.8:
|
||||||
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||||
|
|
||||||
|
internmap@2.0.3:
|
||||||
|
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
is-arrayish@0.2.1:
|
is-arrayish@0.2.1:
|
||||||
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
|
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
|
||||||
|
|
||||||
@@ -3422,6 +3464,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
|
|
||||||
|
topojson-client@3.1.0:
|
||||||
|
resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
tough-cookie@6.0.1:
|
tough-cookie@6.0.1:
|
||||||
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
|
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -3677,6 +3723,9 @@ packages:
|
|||||||
wordwrap@1.0.0:
|
wordwrap@1.0.0:
|
||||||
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
|
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
|
||||||
|
|
||||||
|
world-atlas@2.0.2:
|
||||||
|
resolution: {integrity: sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==}
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -5017,6 +5066,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/d3-force@3.0.10': {}
|
'@types/d3-force@3.0.10': {}
|
||||||
|
|
||||||
|
'@types/d3-geo@3.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/geojson@7946.0.16': {}
|
'@types/geojson@7946.0.16': {}
|
||||||
@@ -5037,6 +5090,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/topojson-client@3.1.5':
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
'@types/topojson-specification': 1.0.5
|
||||||
|
|
||||||
|
'@types/topojson-specification@1.0.5':
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.7.0(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0))':
|
'@vitejs/plugin-react@4.7.0(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
@@ -5317,6 +5379,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
delayed-stream: 1.0.0
|
delayed-stream: 1.0.0
|
||||||
|
|
||||||
|
commander@2.20.3: {}
|
||||||
|
|
||||||
compare-func@2.0.0:
|
compare-func@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
array-ify: 1.0.0
|
array-ify: 1.0.0
|
||||||
@@ -5386,6 +5450,10 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
dependencies:
|
||||||
|
internmap: 2.0.3
|
||||||
|
|
||||||
d3-dispatch@3.0.1: {}
|
d3-dispatch@3.0.1: {}
|
||||||
|
|
||||||
d3-force@3.0.0:
|
d3-force@3.0.0:
|
||||||
@@ -5394,6 +5462,10 @@ snapshots:
|
|||||||
d3-quadtree: 3.0.1
|
d3-quadtree: 3.0.1
|
||||||
d3-timer: 3.0.1
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
|
d3-geo@3.1.1:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 3.2.4
|
||||||
|
|
||||||
d3-quadtree@3.0.1: {}
|
d3-quadtree@3.0.1: {}
|
||||||
|
|
||||||
d3-timer@3.0.1: {}
|
d3-timer@3.0.1: {}
|
||||||
@@ -5856,6 +5928,8 @@ snapshots:
|
|||||||
|
|
||||||
ini@1.3.8: {}
|
ini@1.3.8: {}
|
||||||
|
|
||||||
|
internmap@2.0.3: {}
|
||||||
|
|
||||||
is-arrayish@0.2.1: {}
|
is-arrayish@0.2.1: {}
|
||||||
|
|
||||||
is-binary-path@2.1.0:
|
is-binary-path@2.1.0:
|
||||||
@@ -6697,6 +6771,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-number: 7.0.0
|
is-number: 7.0.0
|
||||||
|
|
||||||
|
topojson-client@3.1.0:
|
||||||
|
dependencies:
|
||||||
|
commander: 2.20.3
|
||||||
|
|
||||||
tough-cookie@6.0.1:
|
tough-cookie@6.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
tldts: 7.0.30
|
tldts: 7.0.30
|
||||||
@@ -6888,6 +6966,8 @@ snapshots:
|
|||||||
|
|
||||||
wordwrap@1.0.0: {}
|
wordwrap@1.0.0: {}
|
||||||
|
|
||||||
|
world-atlas@2.0.2: {}
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
|
|||||||
@@ -399,16 +399,13 @@ body {
|
|||||||
transform-box: fill-box;
|
transform-box: fill-box;
|
||||||
animation: ord-globe-pulse 2.4s ease-in-out infinite;
|
animation: ord-globe-pulse 2.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.globe-loader .globe-sphere {
|
/* Sphere/land внутри globe-loader rotates через d3 projection (rAF
|
||||||
transform-origin: 100px 100px;
|
* пересчитывает path d-attr), не через CSS transform — иначе land
|
||||||
transform-box: fill-box;
|
* distort'илась бы при rotation (no projection math). */
|
||||||
animation: ord-globe-spin 14s linear infinite;
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.globe-loader .globe-whirl,
|
.globe-loader .globe-whirl,
|
||||||
.globe-loader .globe-whirl-rev,
|
.globe-loader .globe-whirl-rev,
|
||||||
.globe-loader .globe-pulse,
|
.globe-loader .globe-pulse {
|
||||||
.globe-loader .globe-sphere {
|
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,123 @@
|
|||||||
import { useId as useReactId } from 'react'
|
import { useEffect, useId as useReactId, useRef, useState } from 'react'
|
||||||
|
import { geoOrthographic, geoPath, geoGraticule10 } from 'd3-geo'
|
||||||
|
import type { Feature, FeatureCollection, Geometry } from 'geojson'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GlobeLoader — orbital globe spinner per Downloads/Globe loader prototype.
|
* GlobeLoader — orbital globe spinner per /Users/zimin/Downloads/Globe loader
|
||||||
|
* prototype. Real Natural Earth land outline через d3-geo orthographic
|
||||||
|
* projection, rotating longitude continuously. Outer whirl arcs + pulsing
|
||||||
|
* ring CSS-only.
|
||||||
*
|
*
|
||||||
* Чистый CSS/SVG (без d3 / topojson) — упрощённая sphere с graticule
|
* <p>Lazy-loads world-atlas (~55kb) — пока геометрия не доехала, видны
|
||||||
* meridians + pulsing outer ring + два контр-вращающихся whirl arcs с tail
|
* только sphere + graticule + orbital arcs (still looks like a loader).
|
||||||
* gradient. Stylized в дизайн-системе: stroke=currentColor (берёт цвет ink
|
|
||||||
* из родителя — works на любом фоне, в т.ч. dark mode).
|
|
||||||
*
|
*
|
||||||
* Использование:
|
* <p>Использование:
|
||||||
* <GlobeLoader /> // 64px default
|
* <GlobeLoader /> // 64px default
|
||||||
* <GlobeLoader size={120} /> // larger
|
* <GlobeLoader size={120} /> // larger
|
||||||
* <GlobeLoader className="text-mute" /> // tint
|
* <GlobeLoader className="text-mute" /> // tint via currentColor
|
||||||
*
|
|
||||||
* Performance: всё в SVG + CSS animations (transform / opacity) — paint-only,
|
|
||||||
* без JS frame loop. Безопасно держать одновременно на странице.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type GlobeLoaderProps = {
|
export type GlobeLoaderProps = {
|
||||||
/** Pixel size of the spinner square (default 64). */
|
/** Pixel size of the spinner square (default 64). */
|
||||||
size?: number
|
size?: number
|
||||||
/** Optional label rendered below (text-body, мute). */
|
/** Optional label rendered below. */
|
||||||
label?: React.ReactNode
|
label?: React.ReactNode
|
||||||
/** Extra wrapper class — обычно text-ink/text-mute для tint. */
|
/** Extra wrapper class. */
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache загруженной land geometry между mount'ами loader'а (типично загрузка
|
||||||
|
// списка → переход в editor → ещё loader). Без cache каждый GlobeLoader
|
||||||
|
// триггерил бы import заново — Vite кеширует chunk, но parse cost есть.
|
||||||
|
let LAND_CACHE: Feature<Geometry> | null = null
|
||||||
|
let LAND_PROMISE: Promise<Feature<Geometry>> | null = null
|
||||||
|
|
||||||
|
async function loadLand(): Promise<Feature<Geometry>> {
|
||||||
|
if (LAND_CACHE) return LAND_CACHE
|
||||||
|
if (LAND_PROMISE) return LAND_PROMISE
|
||||||
|
LAND_PROMISE = (async () => {
|
||||||
|
const [{ feature }, world] = await Promise.all([
|
||||||
|
import('topojson-client'),
|
||||||
|
import('world-atlas/land-110m.json'),
|
||||||
|
])
|
||||||
|
const data = (world as { default: unknown }).default ?? world
|
||||||
|
// topojson-client expects Topology с objects.land mesh.
|
||||||
|
const f = feature(
|
||||||
|
data as Parameters<typeof feature>[0],
|
||||||
|
(data as { objects: { land: Parameters<typeof feature>[1] } }).objects.land,
|
||||||
|
) as Feature<Geometry> | FeatureCollection<Geometry>
|
||||||
|
const result = 'features' in f ? (f.features[0] as Feature<Geometry>) : f
|
||||||
|
LAND_CACHE = result
|
||||||
|
return result
|
||||||
|
})()
|
||||||
|
return LAND_PROMISE
|
||||||
|
}
|
||||||
|
|
||||||
export function GlobeLoader({ size = 64, label, className }: GlobeLoaderProps) {
|
export function GlobeLoader({ size = 64, label, className }: GlobeLoaderProps) {
|
||||||
// ID gradient unique per instance чтобы несколько loader'ов не конфликтовали
|
|
||||||
// через shared <defs id>. React 19 useId — SSR-safe + stable.
|
|
||||||
const id = useReactId().replace(/:/g, '')
|
const id = useReactId().replace(/:/g, '')
|
||||||
const tailA = `globe-tail-a-${id}`
|
const tailA = `globe-tail-a-${id}`
|
||||||
const tailB = `globe-tail-b-${id}`
|
const tailB = `globe-tail-b-${id}`
|
||||||
|
|
||||||
|
const sphereRef = useRef<SVGPathElement>(null)
|
||||||
|
const gratRef = useRef<SVGPathElement>(null)
|
||||||
|
const landRef = useRef<SVGPathElement>(null)
|
||||||
|
const [landReady, setLandReady] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Setup projection: orthographic, R=70 (как в прототипе), tilt -15° (легкий
|
||||||
|
// northern lean). viewBox 200x200 — fixed; scale projection относительно него.
|
||||||
|
const projection = geoOrthographic()
|
||||||
|
.scale(70)
|
||||||
|
.translate([100, 100])
|
||||||
|
.clipAngle(90)
|
||||||
|
.rotate([0, -15, 0])
|
||||||
|
|
||||||
|
const path = geoPath(projection)
|
||||||
|
const graticule = geoGraticule10()
|
||||||
|
|
||||||
|
// Initial render (без land — оно lazy).
|
||||||
|
if (sphereRef.current) sphereRef.current.setAttribute('d', path({ type: 'Sphere' }) ?? '')
|
||||||
|
if (gratRef.current) gratRef.current.setAttribute('d', path(graticule) ?? '')
|
||||||
|
|
||||||
|
let landFeature: Feature<Geometry> | null = LAND_CACHE
|
||||||
|
let cancelled = false
|
||||||
|
if (!landFeature) {
|
||||||
|
void loadLand().then((f) => {
|
||||||
|
if (cancelled) return
|
||||||
|
landFeature = f
|
||||||
|
if (landRef.current) landRef.current.setAttribute('d', path(f) ?? '')
|
||||||
|
setLandReady(true)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
if (landRef.current) landRef.current.setAttribute('d', path(landFeature) ?? '')
|
||||||
|
setLandReady(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate rotation — 8s per revolution per prototype.
|
||||||
|
const start = performance.now()
|
||||||
|
const period = 8000
|
||||||
|
let raf = 0
|
||||||
|
|
||||||
|
const frame = (now: number) => {
|
||||||
|
if (cancelled) return
|
||||||
|
const t = ((now - start) % period) / period
|
||||||
|
const lambda = t * 360
|
||||||
|
projection.rotate([lambda, -15, 0])
|
||||||
|
if (gratRef.current) gratRef.current.setAttribute('d', path(graticule) ?? '')
|
||||||
|
if (landFeature && landRef.current) {
|
||||||
|
landRef.current.setAttribute('d', path(landFeature) ?? '')
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(frame)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
@@ -41,10 +125,8 @@ export function GlobeLoader({ size = 64, label, className }: GlobeLoaderProps) {
|
|||||||
aria-label="loading"
|
aria-label="loading"
|
||||||
className={cn('inline-flex flex-col items-center justify-center gap-2 text-ink', className)}
|
className={cn('inline-flex flex-col items-center justify-center gap-2 text-ink', className)}
|
||||||
>
|
>
|
||||||
<div
|
<div className="globe-loader relative" style={{ width: size, height: size }}>
|
||||||
className="globe-loader relative"
|
{/* Outer overlay svg: whirl arcs + pulse ring (CSS-animated). */}
|
||||||
style={{ width: size, height: size }}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 200 200" className="absolute inset-0 size-full overflow-visible">
|
<svg viewBox="0 0 200 200" className="absolute inset-0 size-full overflow-visible">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={tailA} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="200" y2="0">
|
<linearGradient id={tailA} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="200" y2="0">
|
||||||
@@ -92,7 +174,7 @@ export function GlobeLoader({ size = 64, label, className }: GlobeLoaderProps) {
|
|||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
{/* Inner whirl, counter-rotating, tighter */}
|
{/* Inner whirl, counter-rotating */}
|
||||||
<g className="globe-whirl-rev">
|
<g className="globe-whirl-rev">
|
||||||
<path
|
<path
|
||||||
d="M 100 18 A 82 82 0 1 1 18 100"
|
d="M 100 18 A 82 82 0 1 1 18 100"
|
||||||
@@ -104,31 +186,40 @@ export function GlobeLoader({ size = 64, label, className }: GlobeLoaderProps) {
|
|||||||
/>
|
/>
|
||||||
<circle cx="100" cy="18" r="1.6" fill="currentColor" opacity="0.85" />
|
<circle cx="100" cy="18" r="1.6" fill="currentColor" opacity="0.85" />
|
||||||
</g>
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
{/* Sphere + graticule. Без d3 — фиксированные meridians/parallels.
|
{/* Globe svg: real orthographic projection с rotating land. */}
|
||||||
* Meridians = vertical ellipses with varying rx (orthographic-like
|
<svg viewBox="0 0 200 200" className="absolute inset-0 size-full overflow-visible">
|
||||||
* projection приближение). Parallels = horizontal lines (clipped
|
{/* Sphere — disk silhouette (fill = bg чтобы land не светилось через
|
||||||
* sphere implicit через circle clip). */}
|
* прозрачное место). */}
|
||||||
<g className="globe-sphere">
|
<path
|
||||||
<circle cx="100" cy="100" r="60" fill="none" stroke="currentColor" strokeWidth="1" />
|
ref={sphereRef}
|
||||||
{/* meridians at different rx values — looks like rotating projection */}
|
fill="var(--color-bg, #f4f1ec)"
|
||||||
<g stroke="currentColor" strokeWidth="0.5" strokeOpacity="0.35" fill="none">
|
stroke="currentColor"
|
||||||
<ellipse cx="100" cy="100" rx="60" ry="60" />
|
strokeWidth="1.25"
|
||||||
<ellipse cx="100" cy="100" rx="45" ry="60" />
|
/>
|
||||||
<ellipse cx="100" cy="100" rx="25" ry="60" />
|
{/* Graticule — subtle lat/lon grid. */}
|
||||||
<ellipse cx="100" cy="100" rx="5" ry="60" />
|
<path
|
||||||
{/* parallels — horizontal lines clipped within the sphere */}
|
ref={gratRef}
|
||||||
<line x1="44" y1="80" x2="156" y2="80" />
|
fill="none"
|
||||||
<line x1="40" y1="100" x2="160" y2="100" />
|
stroke="currentColor"
|
||||||
<line x1="44" y1="120" x2="156" y2="120" />
|
strokeWidth="0.4"
|
||||||
<line x1="55" y1="60" x2="145" y2="60" />
|
strokeOpacity="0.18"
|
||||||
<line x1="55" y1="140" x2="145" y2="140" />
|
/>
|
||||||
</g>
|
{/* Land — Natural Earth outline, rotated continuously. fill-opacity
|
||||||
</g>
|
* 0.88 чтобы slightly soften. */}
|
||||||
|
<path
|
||||||
|
ref={landRef}
|
||||||
|
fill="currentColor"
|
||||||
|
fillOpacity={landReady ? 0.88 : 0}
|
||||||
|
stroke="var(--color-bg, #f4f1ec)"
|
||||||
|
strokeWidth="0.35"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
style={{ transition: 'fill-opacity 0.3s ease' }}
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{label && <span className="text-body text-mute">{label}</span>}
|
{label && <span className="text-body text-mute">{label}</span>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user