diff --git a/ordinis-admin-ui/package.json b/ordinis-admin-ui/package.json index f4c2bba..83d9f90 100644 --- a/ordinis-admin-ui/package.json +++ b/ordinis-admin-ui/package.json @@ -31,6 +31,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "d3-force": "^3.0.0", + "d3-geo": "^3.1.1", "i18next": "^26.0.3", "i18next-browser-languagedetector": "^8.0.2", "leaflet": "^1.9.4", @@ -44,7 +45,9 @@ "react-oidc-context": "^3.3.1", "sonner": "^2.0.7", "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": { "@semantic-release/changelog": "^6.0.3", @@ -59,9 +62,12 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/d3-force": "^3.0.10", + "@types/d3-geo": "^3.1.0", + "@types/geojson": "^7946.0.16", "@types/leaflet": "^1.9.21", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/topojson-client": "^3.1.5", "@vitejs/plugin-react": "^4.3.4", "conventional-changelog-conventionalcommits": "^9.3.1", "jsdom": "^29.1.1", diff --git a/ordinis-admin-ui/pnpm-lock.yaml b/ordinis-admin-ui/pnpm-lock.yaml index a77534c..71c8276 100644 --- a/ordinis-admin-ui/pnpm-lock.yaml +++ b/ordinis-admin-ui/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: d3-force: specifier: ^3.0.0 version: 3.0.0 + d3-geo: + specifier: ^3.1.1 + version: 3.1.1 i18next: specifier: ^26.0.3 version: 26.0.8(typescript@5.9.3) @@ -107,9 +110,15 @@ importers: tailwind-merge: specifier: ^3.6.0 version: 3.6.0 + topojson-client: + specifier: ^3.1.0 + version: 3.1.0 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + world-atlas: + specifier: ^2.0.2 + version: 2.0.2 devDependencies: '@semantic-release/changelog': specifier: ^6.0.3 @@ -147,6 +156,12 @@ importers: '@types/d3-force': specifier: ^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': specifier: ^1.9.21 version: 1.9.21 @@ -156,6 +171,9 @@ importers: '@types/react-dom': specifier: ^19.0.0 version: 19.2.3(@types/react@19.2.14) + '@types/topojson-client': + specifier: ^3.1.5 + version: 3.1.5 '@vitejs/plugin-react': specifier: ^4.3.4 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': 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': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1642,6 +1663,12 @@ packages: '@types/react@19.2.14': 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': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1898,6 +1925,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} @@ -1966,6 +1996,10 @@ packages: csstype@3.2.3: 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: resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} engines: {node: '>=12'} @@ -1974,6 +2008,10 @@ packages: resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} engines: {node: '>=12'} + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + d3-quadtree@3.0.1: resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} engines: {node: '>=12'} @@ -2407,6 +2445,10 @@ packages: ini@1.3.8: 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: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -3422,6 +3464,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + topojson-client@3.1.0: + resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==} + hasBin: true + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -3677,6 +3723,9 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + world-atlas@2.0.2: + resolution: {integrity: sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -5017,6 +5066,10 @@ snapshots: '@types/d3-force@3.0.10': {} + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + '@types/estree@1.0.8': {} '@types/geojson@7946.0.16': {} @@ -5037,6 +5090,15 @@ snapshots: dependencies: 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))': dependencies: '@babel/core': 7.29.0 @@ -5317,6 +5379,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@2.20.3: {} + compare-func@2.0.0: dependencies: array-ify: 1.0.0 @@ -5386,6 +5450,10 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + d3-dispatch@3.0.1: {} d3-force@3.0.0: @@ -5394,6 +5462,10 @@ snapshots: d3-quadtree: 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-timer@3.0.1: {} @@ -5856,6 +5928,8 @@ snapshots: ini@1.3.8: {} + internmap@2.0.3: {} + is-arrayish@0.2.1: {} is-binary-path@2.1.0: @@ -6697,6 +6771,10 @@ snapshots: dependencies: is-number: 7.0.0 + topojson-client@3.1.0: + dependencies: + commander: 2.20.3 + tough-cookie@6.0.1: dependencies: tldts: 7.0.30 @@ -6888,6 +6966,8 @@ snapshots: wordwrap@1.0.0: {} + world-atlas@2.0.2: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/ordinis-admin-ui/src/styles.css b/ordinis-admin-ui/src/styles.css index 6f5d7b4..a99ad65 100644 --- a/ordinis-admin-ui/src/styles.css +++ b/ordinis-admin-ui/src/styles.css @@ -399,16 +399,13 @@ body { transform-box: fill-box; animation: ord-globe-pulse 2.4s ease-in-out infinite; } -.globe-loader .globe-sphere { - transform-origin: 100px 100px; - transform-box: fill-box; - animation: ord-globe-spin 14s linear infinite; -} +/* Sphere/land внутри globe-loader rotates через d3 projection (rAF + * пересчитывает path d-attr), не через CSS transform — иначе land + * distort'илась бы при rotation (no projection math). */ @media (prefers-reduced-motion: reduce) { .globe-loader .globe-whirl, .globe-loader .globe-whirl-rev, - .globe-loader .globe-pulse, - .globe-loader .globe-sphere { + .globe-loader .globe-pulse { animation: none; } } diff --git a/ordinis-admin-ui/src/ui/components/globe-loader.tsx b/ordinis-admin-ui/src/ui/components/globe-loader.tsx index 62ee49f..7b40eee 100644 --- a/ordinis-admin-ui/src/ui/components/globe-loader.tsx +++ b/ordinis-admin-ui/src/ui/components/globe-loader.tsx @@ -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' /** - * 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 - * meridians + pulsing outer ring + два контр-вращающихся whirl arcs с tail - * gradient. Stylized в дизайн-системе: stroke=currentColor (берёт цвет ink - * из родителя — works на любом фоне, в т.ч. dark mode). + *

Lazy-loads world-atlas (~55kb) — пока геометрия не доехала, видны + * только sphere + graticule + orbital arcs (still looks like a loader). * - * Использование: + *

Использование: * // 64px default * // larger - * // tint - * - * Performance: всё в SVG + CSS animations (transform / opacity) — paint-only, - * без JS frame loop. Безопасно держать одновременно на странице. + * // tint via currentColor */ export type GlobeLoaderProps = { /** Pixel size of the spinner square (default 64). */ size?: number - /** Optional label rendered below (text-body, мute). */ + /** Optional label rendered below. */ label?: React.ReactNode - /** Extra wrapper class — обычно text-ink/text-mute для tint. */ + /** Extra wrapper class. */ className?: string } +// Cache загруженной land geometry между mount'ами loader'а (типично загрузка +// списка → переход в editor → ещё loader). Без cache каждый GlobeLoader +// триггерил бы import заново — Vite кеширует chunk, но parse cost есть. +let LAND_CACHE: Feature | null = null +let LAND_PROMISE: Promise> | null = null + +async function loadLand(): Promise> { + 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[0], + (data as { objects: { land: Parameters[1] } }).objects.land, + ) as Feature | FeatureCollection + const result = 'features' in f ? (f.features[0] as Feature) : f + LAND_CACHE = result + return result + })() + return LAND_PROMISE +} + export function GlobeLoader({ size = 64, label, className }: GlobeLoaderProps) { - // ID gradient unique per instance чтобы несколько loader'ов не конфликтовали - // через shared . React 19 useId — SSR-safe + stable. const id = useReactId().replace(/:/g, '') const tailA = `globe-tail-a-${id}` const tailB = `globe-tail-b-${id}` + const sphereRef = useRef(null) + const gratRef = useRef(null) + const landRef = useRef(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 | 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 (

-
+
+ {/* Outer overlay svg: whirl arcs + pulse ring (CSS-animated). */} @@ -92,7 +174,7 @@ export function GlobeLoader({ size = 64, label, className }: GlobeLoaderProps) { /> - {/* Inner whirl, counter-rotating, tighter */} + {/* Inner whirl, counter-rotating */} + - {/* Sphere + graticule. Без d3 — фиксированные meridians/parallels. - * Meridians = vertical ellipses with varying rx (orthographic-like - * projection приближение). Parallels = horizontal lines (clipped - * sphere implicit через circle clip). */} - - - {/* meridians at different rx values — looks like rotating projection */} - - - - - - {/* parallels — horizontal lines clipped within the sphere */} - - - - - - - + {/* Globe svg: real orthographic projection с rotating land. */} + + {/* Sphere — disk silhouette (fill = bg чтобы land не светилось через + * прозрачное место). */} + + {/* Graticule — subtle lat/lon grid. */} + + {/* Land — Natural Earth outline, rotated continuously. fill-opacity + * 0.88 чтобы slightly soften. */} +
{label && {label}}
) } -