@traky-app/ui 1.6.8 → 1.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,4 +23,4 @@ Publicación: `pnpm release:ui` (sync + tests) y luego release GitHub tag `vX.Y.
23
23
 
24
24
  Guía completa: [REGISTRY.md](../../REGISTRY.md) en la raíz del monorepo.
25
25
 
26
- **Versión actual del paquete:** `1.6.8` (`FileManager` + chat `realtimeStatus` / `featureEnabled` / `embedded`).
26
+ **Versión actual del paquete:** `1.6.9` (`cobe` 2.0.1, FileManager, chat).
package/RELEASE.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # Release `@traky-app/ui`
2
2
 
3
- ## 1.6.8 (pendiente de publicar)
3
+ ## 1.6.9 (pendiente de publicar)
4
+
5
+ - `cobe` 2.0.1: Globe usa `globe.update()` por frame (ya no `onRender`).
6
+ - `WorldGlobe`: globo listo para la página web (`@traky-app/ui/world-globe`).
7
+ - Registro `globe.json` y `world-globe.json` regenerados (`pnpm registry:sync`).
8
+
9
+ ## 1.6.8
4
10
 
5
11
  - `FileManager`: galería CRUD de imágenes/PDF con `uploadFile` / `deleteFile` inyectados.
6
12
  - Libs: `file-items`, `file-manager-utils`.
@@ -17,11 +23,11 @@
17
23
 
18
24
  1. `pnpm registry:sync && pnpm --filter @traky-app/ui test`
19
25
  2. Confirma `version` en `package.json` (debe coincidir con el tag).
20
- 3. **GitHub (recomendado):** tag `v1.6.8` + release publicado → workflow `publish.yml`.
26
+ 3. **GitHub (recomendado):** tag `v1.6.9` + release publicado → workflow `publish.yml`.
21
27
  4. **Manual:** `cd packages/ui && npm publish --access public` (token / 2FA).
22
28
 
23
29
  Después en **app-traky**:
24
30
 
25
31
  ```bash
26
- pnpm add @traky-app/ui@1.6.8 --save-exact
32
+ pnpm add @traky-app/ui@1.6.9 --save-exact
27
33
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@traky-app/ui",
3
- "version": "1.6.8",
3
+ "version": "1.6.9",
4
4
  "description": "Componentes Traky UI para importar desde @traky-app/ui, más el registro copy-paste.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -66,7 +66,7 @@
66
66
  "class-variance-authority": "^0.7.1",
67
67
  "clsx": "^2.1.1",
68
68
  "cmdk": "^1.1.1",
69
- "cobe": "^0.6.5",
69
+ "cobe": "^2.0.1",
70
70
  "embla-carousel-react": "^8.6.0",
71
71
  "input-otp": "^1.5.0",
72
72
  "maplibre-gl": "^6.9.1",
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "ui/globe.tsx",
12
- "content": "\"use client\"\n\nimport { useEffect, useMemo, useRef } from \"react\"\nimport createGlobe, { type COBEOptions } from \"cobe\"\nimport { useMotionValue, useSpring } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst MOVEMENT_DAMPING = 1400\nconst MARKER_HOLD_MS = 2200\nconst MARKER_TRANSITION_MS = 1800\n\nfunction easeInOutCubic(t: number): number {\n return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2\n}\n\nfunction lerp(a: number, b: number, t: number): number {\n return a + (b - a) * t\n}\n\nfunction lerpAngle(a: number, b: number, t: number): number {\n let diff = b - a\n while (diff > Math.PI) diff -= 2 * Math.PI\n while (diff < -Math.PI) diff += 2 * Math.PI\n return a + diff * t\n}\n\nconst GLOBE_CONFIG: COBEOptions = {\n width: 800,\n height: 800,\n onRender: () => {},\n devicePixelRatio: 2,\n phi: 0,\n theta: 0.3,\n dark: 0,\n diffuse: 0.4,\n mapSamples: 16000,\n mapBrightness: 1.2,\n baseColor: [1, 1, 1],\n markerColor: [251 / 255, 100 / 255, 21 / 255],\n glowColor: [1, 1, 1],\n markers: [\n { location: [14.5995, 120.9842], size: 0.03 },\n { location: [19.076, 72.8777], size: 0.1 },\n { location: [23.8103, 90.4125], size: 0.05 },\n { location: [30.0444, 31.2357], size: 0.07 },\n { location: [39.9042, 116.4074], size: 0.08 },\n { location: [-23.5505, -46.6333], size: 0.1 },\n { location: [19.4326, -99.1332], size: 0.1 },\n { location: [40.7128, -74.006], size: 0.1 },\n { location: [34.6937, 135.5022], size: 0.05 },\n { location: [41.0082, 28.9784], size: 0.06 },\n ],\n}\n\nexport function Globe({\n className,\n config = GLOBE_CONFIG,\n autoRotate = true,\n interactive = true,\n panBetweenMarkers = false,\n cycleMarkers = false,\n markerHoldMs = MARKER_HOLD_MS,\n markerTransitionMs = MARKER_TRANSITION_MS,\n}: {\n className?: string\n config?: COBEOptions\n autoRotate?: boolean\n interactive?: boolean\n /** @deprecated Usa cycleMarkers */\n panBetweenMarkers?: boolean\n /** Centra cada marker uno por uno con pausa y transición suave. */\n cycleMarkers?: boolean\n markerHoldMs?: number\n markerTransitionMs?: number\n}) {\n const canvasRef = useRef<HTMLCanvasElement>(null)\n const phiRef = useRef(config.phi ?? 0)\n const widthRef = useRef(0)\n const pointerInteracting = useRef<number | null>(null)\n const pointerInteractionMovement = useRef(0)\n\n const shouldCycleMarkers = cycleMarkers || panBetweenMarkers\n\n const fixedTheta = config.theta ?? 0.22\n\n const markerViews = useMemo((): Array<{ phi: number }> | null => {\n if (!shouldCycleMarkers || !config.markers?.length) return null\n return config.markers.map((marker) => ({\n phi: Math.PI - ((marker.location[1] * Math.PI) / 180 - Math.PI / 2),\n }))\n }, [shouldCycleMarkers, config.markers])\n\n const markerPhiRange = useMemo((): [number, number] | null => {\n if (!panBetweenMarkers || cycleMarkers || !config.markers?.length) return null\n const phis = config.markers.map(\n (marker) => Math.PI - ((marker.location[1] * Math.PI) / 180 - Math.PI / 2),\n )\n return [Math.min(...phis), Math.max(...phis)]\n }, [panBetweenMarkers, cycleMarkers, config.markers])\n\n const r = useMotionValue(0)\n const rs = useSpring(r, {\n mass: 1,\n damping: 30,\n stiffness: 100,\n })\n\n const updatePointerInteraction = (value: number | null) => {\n if (!interactive) return\n pointerInteracting.current = value\n if (canvasRef.current) {\n canvasRef.current.style.cursor = value !== null ? \"grabbing\" : \"grab\"\n }\n }\n\n const updateMovement = (clientX: number) => {\n if (!interactive) return\n if (pointerInteracting.current !== null) {\n const delta = clientX - pointerInteracting.current\n pointerInteractionMovement.current = delta\n r.set(r.get() + delta / MOVEMENT_DAMPING)\n }\n }\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n\n const onResize = () => {\n widthRef.current = canvas.offsetWidth\n }\n\n onResize()\n window.addEventListener(\"resize\", onResize)\n\n const resizeObserver = new ResizeObserver(onResize)\n resizeObserver.observe(canvas)\n\n const globe = createGlobe(canvas, {\n ...config,\n width: widthRef.current * 2,\n height: widthRef.current * 2,\n onRender: (state) => {\n if (shouldCycleMarkers && markerViews && markerViews.length > 0) {\n const segment = markerHoldMs + markerTransitionMs\n const total = markerViews.length * segment\n const elapsed = Date.now() % total\n const index = Math.floor(elapsed / segment)\n const local = elapsed % segment\n const from = markerViews[index % markerViews.length]\n const to = markerViews[(index + 1) % markerViews.length]\n\n if (local < markerHoldMs) {\n state.phi = from.phi\n } else {\n const t = easeInOutCubic((local - markerHoldMs) / markerTransitionMs)\n state.phi = lerpAngle(from.phi, to.phi, t)\n }\n state.theta = fixedTheta\n } else if (panBetweenMarkers && markerPhiRange) {\n const [phiMin, phiMax] = markerPhiRange\n const t = Date.now() / 5200\n state.phi = phiMin + ((Math.sin(t) + 1) / 2) * (phiMax - phiMin)\n } else if (autoRotate && !pointerInteracting.current) {\n phiRef.current += 0.005\n state.phi = interactive ? phiRef.current + rs.get() : (config.phi ?? 0)\n } else {\n state.phi = interactive ? phiRef.current + rs.get() : (config.phi ?? 0)\n }\n\n state.theta = shouldCycleMarkers ? fixedTheta : (config.theta ?? state.theta)\n state.scale = config.scale ?? 1\n state.width = widthRef.current * 2\n state.height = widthRef.current * 2\n },\n })\n\n setTimeout(() => {\n canvas.style.opacity = \"1\"\n }, 0)\n\n return () => {\n globe.destroy()\n window.removeEventListener(\"resize\", onResize)\n resizeObserver.disconnect()\n }\n }, [\n rs,\n config,\n autoRotate,\n interactive,\n panBetweenMarkers,\n cycleMarkers,\n shouldCycleMarkers,\n markerViews,\n markerPhiRange,\n markerHoldMs,\n markerTransitionMs,\n ])\n\n return (\n <div\n className={cn(\n \"absolute inset-0 mx-auto aspect-square w-full max-w-150\",\n className\n )}\n >\n <canvas\n className={cn(\n \"size-full opacity-0 transition-opacity duration-500 contain-[layout_paint_size]\",\n interactive ? \"cursor-grab\" : \"cursor-default\",\n )}\n ref={canvasRef}\n onPointerDown={\n interactive\n ? (e) => {\n pointerInteracting.current = e.clientX\n updatePointerInteraction(e.clientX)\n }\n : undefined\n }\n onPointerUp={interactive ? () => updatePointerInteraction(null) : undefined}\n onPointerOut={interactive ? () => updatePointerInteraction(null) : undefined}\n onMouseMove={interactive ? (e) => updateMovement(e.clientX) : undefined}\n onTouchMove={\n interactive\n ? (e) => e.touches[0] && updateMovement(e.touches[0].clientX)\n : undefined\n }\n />\n </div>\n )\n}\n",
12
+ "content": "\"use client\"\n\nimport { useEffect, useMemo, useRef } from \"react\"\nimport createGlobe, { type COBEOptions } from \"cobe\"\nimport { useMotionValue, useSpring } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst MOVEMENT_DAMPING = 1400\nconst MARKER_HOLD_MS = 2200\nconst MARKER_TRANSITION_MS = 1800\n\nfunction easeInOutCubic(t: number): number {\n return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2\n}\n\nfunction lerp(a: number, b: number, t: number): number {\n return a + (b - a) * t\n}\n\nfunction lerpAngle(a: number, b: number, t: number): number {\n let diff = b - a\n while (diff > Math.PI) diff -= 2 * Math.PI\n while (diff < -Math.PI) diff += 2 * Math.PI\n return a + diff * t\n}\n\nconst GLOBE_CONFIG: COBEOptions = {\n width: 800,\n height: 800,\n devicePixelRatio: 2,\n phi: 0,\n theta: 0.3,\n dark: 0,\n diffuse: 0.4,\n mapSamples: 16000,\n mapBrightness: 1.2,\n baseColor: [1, 1, 1],\n markerColor: [251 / 255, 100 / 255, 21 / 255],\n glowColor: [1, 1, 1],\n markers: [\n { location: [14.5995, 120.9842], size: 0.03 },\n { location: [19.076, 72.8777], size: 0.1 },\n { location: [23.8103, 90.4125], size: 0.05 },\n { location: [30.0444, 31.2357], size: 0.07 },\n { location: [39.9042, 116.4074], size: 0.08 },\n { location: [-23.5505, -46.6333], size: 0.1 },\n { location: [19.4326, -99.1332], size: 0.1 },\n { location: [40.7128, -74.006], size: 0.1 },\n { location: [34.6937, 135.5022], size: 0.05 },\n { location: [41.0082, 28.9784], size: 0.06 },\n ],\n}\n\nexport function Globe({\n className,\n config = GLOBE_CONFIG,\n autoRotate = true,\n interactive = true,\n panBetweenMarkers = false,\n cycleMarkers = false,\n centerMarkerLatitude = false,\n playing = true,\n markerHoldMs = MARKER_HOLD_MS,\n markerTransitionMs = MARKER_TRANSITION_MS,\n}: {\n className?: string\n config?: COBEOptions\n autoRotate?: boolean\n interactive?: boolean\n /** @deprecated Usa cycleMarkers */\n panBetweenMarkers?: boolean\n /** Centra cada marker uno por uno con pausa y transición suave. */\n cycleMarkers?: boolean\n /** Inclina el globo para dejar la latitud de cada marker en el centro. */\n centerMarkerLatitude?: boolean\n /** Si es false, el globo se queda en el fotograma actual y no avanza el tour. */\n playing?: boolean\n markerHoldMs?: number\n markerTransitionMs?: number\n}) {\n const canvasRef = useRef<HTMLCanvasElement>(null)\n const playingRef = useRef(playing)\n playingRef.current = playing\n const phiRef = useRef(config.phi ?? 0)\n const widthRef = useRef(0)\n const pointerInteracting = useRef<number | null>(null)\n const pointerInteractionMovement = useRef(0)\n\n const shouldCycleMarkers = cycleMarkers || panBetweenMarkers\n\n const fixedTheta = config.theta ?? 0.22\n\n const markerViews = useMemo((): Array<{ phi: number; theta: number }> | null => {\n if (!shouldCycleMarkers || !config.markers?.length) return null\n return config.markers.map((marker) => ({\n phi: Math.PI - ((marker.location[1] * Math.PI) / 180 - Math.PI / 2),\n theta: centerMarkerLatitude\n ? (marker.location[0] * Math.PI) / 180\n : fixedTheta,\n }))\n }, [shouldCycleMarkers, centerMarkerLatitude, fixedTheta, config.markers])\n\n const markerPhiRange = useMemo((): [number, number] | null => {\n if (!panBetweenMarkers || cycleMarkers || !config.markers?.length) return null\n const phis = config.markers.map(\n (marker) => Math.PI - ((marker.location[1] * Math.PI) / 180 - Math.PI / 2),\n )\n return [Math.min(...phis), Math.max(...phis)]\n }, [panBetweenMarkers, cycleMarkers, config.markers])\n\n const r = useMotionValue(0)\n const rs = useSpring(r, {\n mass: 1,\n damping: 30,\n stiffness: 100,\n })\n\n const updatePointerInteraction = (value: number | null) => {\n if (!interactive) return\n pointerInteracting.current = value\n if (canvasRef.current) {\n canvasRef.current.style.cursor = value !== null ? \"grabbing\" : \"grab\"\n }\n }\n\n const updateMovement = (clientX: number) => {\n if (!interactive) return\n if (pointerInteracting.current !== null) {\n const delta = clientX - pointerInteracting.current\n pointerInteractionMovement.current = delta\n r.set(r.get() + delta / MOVEMENT_DAMPING)\n }\n }\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n\n const host = canvas.parentElement\n const onResize = () => {\n widthRef.current = canvas.offsetWidth\n }\n\n onResize()\n window.addEventListener(\"resize\", onResize)\n\n const resizeObserver = new ResizeObserver(onResize)\n resizeObserver.observe(canvas)\n\n const globe = createGlobe(canvas, {\n ...config,\n devicePixelRatio: config.devicePixelRatio ?? 2,\n width: widthRef.current,\n height: widthRef.current,\n })\n\n let frame = 0\n let elapsedMs = 0\n let lastNow = 0\n const render = () => {\n const now = Date.now()\n if (playingRef.current) {\n if (lastNow !== 0) elapsedMs += now - lastNow\n lastNow = now\n } else {\n lastNow = 0\n }\n\n const cssSize = widthRef.current\n let phi = interactive ? phiRef.current + rs.get() : (config.phi ?? 0)\n let theta = config.theta ?? 0.3\n\n if (shouldCycleMarkers && markerViews && markerViews.length > 0) {\n const segment = markerHoldMs + markerTransitionMs\n const elapsed = elapsedMs\n const index = Math.floor(elapsed / segment) % markerViews.length\n const local = elapsed % segment\n const from = markerViews[index % markerViews.length]\n const to = markerViews[(index + 1) % markerViews.length]\n if (from && to) {\n if (local < markerHoldMs) {\n phi = from.phi\n theta = from.theta\n } else {\n const t = easeInOutCubic((local - markerHoldMs) / markerTransitionMs)\n phi = lerpAngle(from.phi, to.phi, t)\n theta = lerp(from.theta, to.theta, t)\n }\n }\n } else if (panBetweenMarkers && markerPhiRange) {\n const [phiMin, phiMax] = markerPhiRange\n const t = Date.now() / 5200\n phi = phiMin + ((Math.sin(t) + 1) / 2) * (phiMax - phiMin)\n } else if (autoRotate && playingRef.current && !pointerInteracting.current) {\n phiRef.current += 0.005\n phi = interactive ? phiRef.current + rs.get() : (config.phi ?? 0)\n }\n\n globe.update({\n phi,\n theta,\n scale: config.scale ?? 1,\n width: cssSize,\n height: cssSize,\n })\n frame = requestAnimationFrame(render)\n }\n frame = requestAnimationFrame(render)\n\n setTimeout(() => {\n canvas.style.opacity = \"1\"\n }, 0)\n\n return () => {\n cancelAnimationFrame(frame)\n globe.destroy()\n const wrapper = canvas.parentElement\n if (wrapper && host && wrapper !== host) {\n host.insertBefore(canvas, wrapper)\n wrapper.remove()\n }\n window.removeEventListener(\"resize\", onResize)\n resizeObserver.disconnect()\n }\n }, [\n rs,\n config,\n autoRotate,\n interactive,\n panBetweenMarkers,\n cycleMarkers,\n centerMarkerLatitude,\n shouldCycleMarkers,\n markerViews,\n markerPhiRange,\n markerHoldMs,\n markerTransitionMs,\n ])\n\n return (\n <div\n className={cn(\n \"absolute inset-0 mx-auto aspect-square w-full max-w-150\",\n className\n )}\n >\n <canvas\n className={cn(\n \"size-full opacity-0 transition-opacity duration-500 contain-[layout_paint_size]\",\n interactive ? \"cursor-grab\" : \"cursor-default\",\n )}\n ref={canvasRef}\n onPointerDown={\n interactive\n ? (e) => {\n pointerInteracting.current = e.clientX\n updatePointerInteraction(e.clientX)\n }\n : undefined\n }\n onPointerUp={interactive ? () => updatePointerInteraction(null) : undefined}\n onPointerOut={interactive ? () => updatePointerInteraction(null) : undefined}\n onMouseMove={interactive ? (e) => updateMovement(e.clientX) : undefined}\n onTouchMove={\n interactive\n ? (e) => e.touches[0] && updateMovement(e.touches[0].clientX)\n : undefined\n }\n />\n </div>\n )\n}\n",
13
13
  "type": "registry:ui",
14
14
  "target": ""
15
15
  }
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "world-globe",
4
+ "type": "registry:ui",
5
+ "author": "Traky (https://traky.app)",
6
+ "dependencies": [
7
+ "cobe"
8
+ ],
9
+ "registryDependencies": [
10
+ "globe"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "ui/world-globe.tsx",
15
+ "content": "\"use client\"\n\nimport { useEffect, useMemo, useRef, useState, type CSSProperties } from \"react\"\nimport type { COBEOptions } from \"cobe\"\n\nimport { useTheme } from \"@/components/theme-provider\"\nimport { cn } from \"@/lib/utils\"\nimport { Globe } from \"@/components/ui/globe\"\n\n/** Tour: Norteamérica → Suramérica → Europa → África → Asia → Oceanía. */\nconst MARKERS = [\n { id: \"cascade\", location: [49.2827, -123.1207] as [number, number], label: \"Cascade LLC\" },\n { id: \"logistic\", location: [41.8781, -87.6298] as [number, number], label: \"Logistic LLC\" },\n { id: \"alianza\", location: [19.4326, -99.1332] as [number, number], label: \"Alianza SA\" },\n { id: \"bolivar\", location: [4.711, -74.0721] as [number, number], label: \"Bolívar SAS\" },\n { id: \"minerva\", location: [-23.5505, -46.6333] as [number, number], label: \"Minerva SA\" },\n { id: \"cordillera\", location: [-33.4489, -70.6693] as [number, number], label: \"Cordillera SA\" },\n { id: \"iberica\", location: [40.4168, -3.7038] as [number, number], label: \"Ibérica SA\" },\n { id: \"avrora\", location: [55.7558, 37.6173] as [number, number], label: \"Avrora LLC\" },\n { id: \"meridian\", location: [6.5244, 3.3792] as [number, number], label: \"Meridian Ltd\" },\n { id: \"savanna\", location: [-26.2041, 28.0473] as [number, number], label: \"Savanna SA\" },\n { id: \"deccan\", location: [19.076, 72.8777] as [number, number], label: \"Deccan Ltd\" },\n { id: \"straits\", location: [1.3521, 103.8198] as [number, number], label: \"Straits Ltd\" },\n { id: \"huayun\", location: [31.2304, 121.4737] as [number, number], label: \"华运 Ltd\" },\n { id: \"harbour\", location: [-33.8688, 151.2093] as [number, number], label: \"Harbour Ltd\" },\n]\n\nconst ARCS = [\n {\n id: \"alianza-logistic\",\n from: [19.4326, -99.1332] as [number, number],\n to: [41.8781, -87.6298] as [number, number],\n unit: \"AWB\",\n fromValue: 120,\n toValue: 240,\n duration: 28000,\n },\n {\n id: \"iberica-avrora\",\n from: [40.4168, -3.7038] as [number, number],\n to: [55.7558, 37.6173] as [number, number],\n unit: \"BL\",\n fromValue: 100,\n toValue: 220,\n duration: 32000,\n },\n {\n id: \"meridian-savanna\",\n from: [6.5244, 3.3792] as [number, number],\n to: [-26.2041, 28.0473] as [number, number],\n unit: \"WR\",\n fromValue: 160,\n toValue: 280,\n duration: 36000,\n },\n {\n id: \"avrora-huayun\",\n from: [55.7558, 37.6173] as [number, number],\n to: [31.2304, 121.4737] as [number, number],\n unit: \"MAWB\",\n fromValue: 140,\n toValue: 260,\n duration: 30000,\n },\n {\n id: \"harbour-huayun\",\n from: [-33.8688, 151.2093] as [number, number],\n to: [31.2304, 121.4737] as [number, number],\n unit: \"AWB\",\n fromValue: 110,\n toValue: 230,\n duration: 34000,\n },\n {\n id: \"avrora-cascade\",\n from: [55.7558, 37.6173] as [number, number],\n to: [49.2827, -123.1207] as [number, number],\n unit: \"BL\",\n fromValue: 130,\n toValue: 250,\n duration: 31000,\n },\n {\n id: \"bolivar-minerva\",\n from: [4.711, -74.0721] as [number, number],\n to: [-23.5505, -46.6333] as [number, number],\n unit: \"WR\",\n fromValue: 150,\n toValue: 270,\n duration: 33000,\n },\n {\n id: \"deccan-straits\",\n from: [19.076, 72.8777] as [number, number],\n to: [1.3521, 103.8198] as [number, number],\n unit: \"AWB\",\n fromValue: 170,\n toValue: 260,\n duration: 29000,\n },\n]\n\n/** Azul oscuro de marca #000b27. Misma línea en tema claro y oscuro. */\nconst ARC_COLOR: [number, number, number] = [0, 0.043, 0.153]\n\nconst WORLD_GLOBE_CONFIG: COBEOptions = {\n width: 800,\n height: 800,\n devicePixelRatio: 2,\n phi: 0,\n theta: 0.2,\n dark: 0,\n diffuse: 1.5,\n mapSamples: 16000,\n mapBrightness: 10,\n baseColor: [1, 1, 1],\n markerColor: [0.647, 0.867, 0.392],\n glowColor: [0.94, 0.93, 0.91],\n markerElevation: 0.01,\n opacity: 1,\n markers: MARKERS.map((marker) => ({\n location: marker.location,\n size: 0.025,\n id: marker.id,\n })),\n arcs: ARCS.map((arc) => ({\n from: arc.from,\n to: arc.to,\n id: arc.id,\n })),\n arcColor: ARC_COLOR,\n arcWidth: 0.5,\n arcHeight: 0.25,\n}\n\nconst GLOW_LIGHT: [number, number, number] = [0.97, 0.97, 0.98]\nconst GLOW_DARK: [number, number, number] = [0.07, 0.075, 0.08]\n\nfunction anchoredStyle(anchor: string, visible: string): CSSProperties {\n return {\n positionAnchor: anchor,\n bottom: \"anchor(top)\",\n left: \"anchor(center)\",\n translate: \"-50% 0\",\n marginBottom: 8,\n opacity: `var(${visible}, 0)`,\n filter: `blur(var(${visible}, 10px))`,\n transition: \"opacity 0.8s, filter 0.8s\",\n }\n}\n\nfunction glowForTheme(theme: string): [number, number, number] {\n if (theme === \"dark\") return GLOW_DARK\n if (theme === \"light\") return GLOW_LIGHT\n if (typeof document !== \"undefined\" && document.documentElement.classList.contains(\"dark\")) {\n return GLOW_DARK\n }\n return GLOW_LIGHT\n}\n\n/** Globo listo para la página web. No edita empresas ni envíos. */\nexport function WorldGlobe({ className }: { className?: string }) {\n const rootRef = useRef<HTMLDivElement>(null)\n const playingRef = useRef(false)\n const [playing, setPlaying] = useState(false)\n const { theme } = useTheme()\n const glowColor = glowForTheme(theme)\n const config = useMemo(\n () => ({ ...WORLD_GLOBE_CONFIG, glowColor }),\n [glowColor],\n )\n const [lanes, setLanes] = useState(() =>\n ARCS.map((arc) => ({\n id: arc.id,\n unit: arc.unit,\n value: arc.fromValue,\n })),\n )\n\n useEffect(() => {\n const node = rootRef.current\n if (!node) return\n\n let onScreen = false\n const sync = () => {\n const next = onScreen && document.visibilityState === \"visible\"\n playingRef.current = next\n setPlaying(next)\n }\n const observer = new IntersectionObserver(\n ([entry]) => {\n onScreen = entry?.isIntersecting ?? false\n sync()\n },\n { threshold: 0.35 },\n )\n observer.observe(node)\n document.addEventListener(\"visibilitychange\", sync)\n return () => {\n observer.disconnect()\n document.removeEventListener(\"visibilitychange\", sync)\n }\n }, [])\n\n useEffect(() => {\n let frame = 0\n let elapsedMs = 0\n let lastNow = 0\n const shown = ARCS.map((arc) => arc.fromValue)\n\n const tick = (now: number) => {\n if (playingRef.current) {\n if (lastNow !== 0) elapsedMs += now - lastNow\n lastNow = now\n } else {\n lastNow = 0\n }\n\n let changed = false\n const next = ARCS.map((arc, index) => {\n const elapsed = elapsedMs / arc.duration\n const ping = elapsed % 2\n const t = ping < 1 ? ping : 2 - ping\n const eased = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2\n const value = Math.round(arc.fromValue + (arc.toValue - arc.fromValue) * eased)\n if (value !== shown[index]) {\n shown[index] = value\n changed = true\n }\n return value\n })\n if (changed) {\n setLanes((current) =>\n current.map((item, index) => ({ ...item, value: next[index] ?? item.value })),\n )\n }\n frame = requestAnimationFrame(tick)\n }\n\n frame = requestAnimationFrame(tick)\n return () => cancelAnimationFrame(frame)\n }, [])\n\n return (\n <div\n ref={rootRef}\n className={cn(\n \"relative mx-auto aspect-square w-full max-w-lg bg-background\",\n className,\n )}\n >\n <Globe\n config={config}\n autoRotate={false}\n interactive={false}\n cycleMarkers\n centerMarkerLatitude\n playing={playing}\n markerHoldMs={2400}\n markerTransitionMs={1800}\n className=\"max-w-full\"\n />\n {MARKERS.map((marker) => (\n <div\n key={marker.id}\n className=\"pointer-events-none absolute z-10 w-max max-w-36 rounded-sm bg-primary px-1.5 py-px font-mono text-[10px] tracking-normal text-[#000b27]\"\n style={anchoredStyle(`--cobe-${marker.id}`, `--cobe-visible-${marker.id}`)}\n >\n <span className=\"block truncate\">{marker.label}</span>\n <span className=\"absolute top-full left-1/2 -translate-x-1/2 border-[5px] border-transparent border-t-primary\" />\n </div>\n ))}\n {lanes.map((item) => (\n <div\n key={item.id}\n className=\"pointer-events-none absolute z-10 rounded bg-black px-2 py-[3px] font-mono text-[0.5rem] whitespace-nowrap text-white\"\n style={{\n positionAnchor: `--cobe-arc-${item.id}`,\n bottom: \"anchor(top)\",\n left: \"anchor(center)\",\n translate: \"-50% 0\",\n opacity: `var(--cobe-visible-arc-${item.id}, 0)`,\n filter: `blur(calc((1 - var(--cobe-visible-arc-${item.id}, 0)) * 8px))`,\n transition: \"opacity 0.3s, filter 0.3s\",\n }}\n >\n {item.value} {item.unit}/s\n </div>\n ))}\n </div>\n )\n}\n",
16
+ "type": "registry:ui",
17
+ "target": ""
18
+ }
19
+ ]
20
+ }
@@ -773,6 +773,16 @@
773
773
  }
774
774
  ]
775
775
  },
776
+ {
777
+ "name": "world-globe",
778
+ "type": "registry:ui",
779
+ "files": [
780
+ {
781
+ "path": "json/world-globe.json",
782
+ "type": "registry:file"
783
+ }
784
+ ]
785
+ },
776
786
  {
777
787
  "name": "lib-utils",
778
788
  "type": "registry:lib",
@@ -28,7 +28,6 @@ function lerpAngle(a: number, b: number, t: number): number {
28
28
  const GLOBE_CONFIG: COBEOptions = {
29
29
  width: 800,
30
30
  height: 800,
31
- onRender: () => {},
32
31
  devicePixelRatio: 2,
33
32
  phi: 0,
34
33
  theta: 0.3,
@@ -60,6 +59,8 @@ export function Globe({
60
59
  interactive = true,
61
60
  panBetweenMarkers = false,
62
61
  cycleMarkers = false,
62
+ centerMarkerLatitude = false,
63
+ playing = true,
63
64
  markerHoldMs = MARKER_HOLD_MS,
64
65
  markerTransitionMs = MARKER_TRANSITION_MS,
65
66
  }: {
@@ -71,10 +72,16 @@ export function Globe({
71
72
  panBetweenMarkers?: boolean
72
73
  /** Centra cada marker uno por uno con pausa y transición suave. */
73
74
  cycleMarkers?: boolean
75
+ /** Inclina el globo para dejar la latitud de cada marker en el centro. */
76
+ centerMarkerLatitude?: boolean
77
+ /** Si es false, el globo se queda en el fotograma actual y no avanza el tour. */
78
+ playing?: boolean
74
79
  markerHoldMs?: number
75
80
  markerTransitionMs?: number
76
81
  }) {
77
82
  const canvasRef = useRef<HTMLCanvasElement>(null)
83
+ const playingRef = useRef(playing)
84
+ playingRef.current = playing
78
85
  const phiRef = useRef(config.phi ?? 0)
79
86
  const widthRef = useRef(0)
80
87
  const pointerInteracting = useRef<number | null>(null)
@@ -84,12 +91,15 @@ export function Globe({
84
91
 
85
92
  const fixedTheta = config.theta ?? 0.22
86
93
 
87
- const markerViews = useMemo((): Array<{ phi: number }> | null => {
94
+ const markerViews = useMemo((): Array<{ phi: number; theta: number }> | null => {
88
95
  if (!shouldCycleMarkers || !config.markers?.length) return null
89
96
  return config.markers.map((marker) => ({
90
97
  phi: Math.PI - ((marker.location[1] * Math.PI) / 180 - Math.PI / 2),
98
+ theta: centerMarkerLatitude
99
+ ? (marker.location[0] * Math.PI) / 180
100
+ : fixedTheta,
91
101
  }))
92
- }, [shouldCycleMarkers, config.markers])
102
+ }, [shouldCycleMarkers, centerMarkerLatitude, fixedTheta, config.markers])
93
103
 
94
104
  const markerPhiRange = useMemo((): [number, number] | null => {
95
105
  if (!panBetweenMarkers || cycleMarkers || !config.markers?.length) return null
@@ -127,6 +137,7 @@ export function Globe({
127
137
  const canvas = canvasRef.current
128
138
  if (!canvas) return
129
139
 
140
+ const host = canvas.parentElement
130
141
  const onResize = () => {
131
142
  widthRef.current = canvas.offsetWidth
132
143
  }
@@ -139,49 +150,76 @@ export function Globe({
139
150
 
140
151
  const globe = createGlobe(canvas, {
141
152
  ...config,
142
- width: widthRef.current * 2,
143
- height: widthRef.current * 2,
144
- onRender: (state) => {
145
- if (shouldCycleMarkers && markerViews && markerViews.length > 0) {
146
- const segment = markerHoldMs + markerTransitionMs
147
- const total = markerViews.length * segment
148
- const elapsed = Date.now() % total
149
- const index = Math.floor(elapsed / segment)
150
- const local = elapsed % segment
151
- const from = markerViews[index % markerViews.length]
152
- const to = markerViews[(index + 1) % markerViews.length]
153
+ devicePixelRatio: config.devicePixelRatio ?? 2,
154
+ width: widthRef.current,
155
+ height: widthRef.current,
156
+ })
157
+
158
+ let frame = 0
159
+ let elapsedMs = 0
160
+ let lastNow = 0
161
+ const render = () => {
162
+ const now = Date.now()
163
+ if (playingRef.current) {
164
+ if (lastNow !== 0) elapsedMs += now - lastNow
165
+ lastNow = now
166
+ } else {
167
+ lastNow = 0
168
+ }
169
+
170
+ const cssSize = widthRef.current
171
+ let phi = interactive ? phiRef.current + rs.get() : (config.phi ?? 0)
172
+ let theta = config.theta ?? 0.3
153
173
 
174
+ if (shouldCycleMarkers && markerViews && markerViews.length > 0) {
175
+ const segment = markerHoldMs + markerTransitionMs
176
+ const elapsed = elapsedMs
177
+ const index = Math.floor(elapsed / segment) % markerViews.length
178
+ const local = elapsed % segment
179
+ const from = markerViews[index % markerViews.length]
180
+ const to = markerViews[(index + 1) % markerViews.length]
181
+ if (from && to) {
154
182
  if (local < markerHoldMs) {
155
- state.phi = from.phi
183
+ phi = from.phi
184
+ theta = from.theta
156
185
  } else {
157
186
  const t = easeInOutCubic((local - markerHoldMs) / markerTransitionMs)
158
- state.phi = lerpAngle(from.phi, to.phi, t)
187
+ phi = lerpAngle(from.phi, to.phi, t)
188
+ theta = lerp(from.theta, to.theta, t)
159
189
  }
160
- state.theta = fixedTheta
161
- } else if (panBetweenMarkers && markerPhiRange) {
162
- const [phiMin, phiMax] = markerPhiRange
163
- const t = Date.now() / 5200
164
- state.phi = phiMin + ((Math.sin(t) + 1) / 2) * (phiMax - phiMin)
165
- } else if (autoRotate && !pointerInteracting.current) {
166
- phiRef.current += 0.005
167
- state.phi = interactive ? phiRef.current + rs.get() : (config.phi ?? 0)
168
- } else {
169
- state.phi = interactive ? phiRef.current + rs.get() : (config.phi ?? 0)
170
190
  }
191
+ } else if (panBetweenMarkers && markerPhiRange) {
192
+ const [phiMin, phiMax] = markerPhiRange
193
+ const t = Date.now() / 5200
194
+ phi = phiMin + ((Math.sin(t) + 1) / 2) * (phiMax - phiMin)
195
+ } else if (autoRotate && playingRef.current && !pointerInteracting.current) {
196
+ phiRef.current += 0.005
197
+ phi = interactive ? phiRef.current + rs.get() : (config.phi ?? 0)
198
+ }
171
199
 
172
- state.theta = shouldCycleMarkers ? fixedTheta : (config.theta ?? state.theta)
173
- state.scale = config.scale ?? 1
174
- state.width = widthRef.current * 2
175
- state.height = widthRef.current * 2
176
- },
177
- })
200
+ globe.update({
201
+ phi,
202
+ theta,
203
+ scale: config.scale ?? 1,
204
+ width: cssSize,
205
+ height: cssSize,
206
+ })
207
+ frame = requestAnimationFrame(render)
208
+ }
209
+ frame = requestAnimationFrame(render)
178
210
 
179
211
  setTimeout(() => {
180
212
  canvas.style.opacity = "1"
181
213
  }, 0)
182
214
 
183
215
  return () => {
216
+ cancelAnimationFrame(frame)
184
217
  globe.destroy()
218
+ const wrapper = canvas.parentElement
219
+ if (wrapper && host && wrapper !== host) {
220
+ host.insertBefore(canvas, wrapper)
221
+ wrapper.remove()
222
+ }
185
223
  window.removeEventListener("resize", onResize)
186
224
  resizeObserver.disconnect()
187
225
  }
@@ -192,6 +230,7 @@ export function Globe({
192
230
  interactive,
193
231
  panBetweenMarkers,
194
232
  cycleMarkers,
233
+ centerMarkerLatitude,
195
234
  shouldCycleMarkers,
196
235
  markerViews,
197
236
  markerPhiRange,
@@ -0,0 +1,292 @@
1
+ "use client"
2
+
3
+ import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"
4
+ import type { COBEOptions } from "cobe"
5
+
6
+ import { useTheme } from "@/components/theme-provider"
7
+ import { cn } from "@/lib/utils"
8
+ import { Globe } from "@/components/ui/globe"
9
+
10
+ /** Tour: Norteamérica → Suramérica → Europa → África → Asia → Oceanía. */
11
+ const MARKERS = [
12
+ { id: "cascade", location: [49.2827, -123.1207] as [number, number], label: "Cascade LLC" },
13
+ { id: "logistic", location: [41.8781, -87.6298] as [number, number], label: "Logistic LLC" },
14
+ { id: "alianza", location: [19.4326, -99.1332] as [number, number], label: "Alianza SA" },
15
+ { id: "bolivar", location: [4.711, -74.0721] as [number, number], label: "Bolívar SAS" },
16
+ { id: "minerva", location: [-23.5505, -46.6333] as [number, number], label: "Minerva SA" },
17
+ { id: "cordillera", location: [-33.4489, -70.6693] as [number, number], label: "Cordillera SA" },
18
+ { id: "iberica", location: [40.4168, -3.7038] as [number, number], label: "Ibérica SA" },
19
+ { id: "avrora", location: [55.7558, 37.6173] as [number, number], label: "Avrora LLC" },
20
+ { id: "meridian", location: [6.5244, 3.3792] as [number, number], label: "Meridian Ltd" },
21
+ { id: "savanna", location: [-26.2041, 28.0473] as [number, number], label: "Savanna SA" },
22
+ { id: "deccan", location: [19.076, 72.8777] as [number, number], label: "Deccan Ltd" },
23
+ { id: "straits", location: [1.3521, 103.8198] as [number, number], label: "Straits Ltd" },
24
+ { id: "huayun", location: [31.2304, 121.4737] as [number, number], label: "华运 Ltd" },
25
+ { id: "harbour", location: [-33.8688, 151.2093] as [number, number], label: "Harbour Ltd" },
26
+ ]
27
+
28
+ const ARCS = [
29
+ {
30
+ id: "alianza-logistic",
31
+ from: [19.4326, -99.1332] as [number, number],
32
+ to: [41.8781, -87.6298] as [number, number],
33
+ unit: "AWB",
34
+ fromValue: 120,
35
+ toValue: 240,
36
+ duration: 28000,
37
+ },
38
+ {
39
+ id: "iberica-avrora",
40
+ from: [40.4168, -3.7038] as [number, number],
41
+ to: [55.7558, 37.6173] as [number, number],
42
+ unit: "BL",
43
+ fromValue: 100,
44
+ toValue: 220,
45
+ duration: 32000,
46
+ },
47
+ {
48
+ id: "meridian-savanna",
49
+ from: [6.5244, 3.3792] as [number, number],
50
+ to: [-26.2041, 28.0473] as [number, number],
51
+ unit: "WR",
52
+ fromValue: 160,
53
+ toValue: 280,
54
+ duration: 36000,
55
+ },
56
+ {
57
+ id: "avrora-huayun",
58
+ from: [55.7558, 37.6173] as [number, number],
59
+ to: [31.2304, 121.4737] as [number, number],
60
+ unit: "MAWB",
61
+ fromValue: 140,
62
+ toValue: 260,
63
+ duration: 30000,
64
+ },
65
+ {
66
+ id: "harbour-huayun",
67
+ from: [-33.8688, 151.2093] as [number, number],
68
+ to: [31.2304, 121.4737] as [number, number],
69
+ unit: "AWB",
70
+ fromValue: 110,
71
+ toValue: 230,
72
+ duration: 34000,
73
+ },
74
+ {
75
+ id: "avrora-cascade",
76
+ from: [55.7558, 37.6173] as [number, number],
77
+ to: [49.2827, -123.1207] as [number, number],
78
+ unit: "BL",
79
+ fromValue: 130,
80
+ toValue: 250,
81
+ duration: 31000,
82
+ },
83
+ {
84
+ id: "bolivar-minerva",
85
+ from: [4.711, -74.0721] as [number, number],
86
+ to: [-23.5505, -46.6333] as [number, number],
87
+ unit: "WR",
88
+ fromValue: 150,
89
+ toValue: 270,
90
+ duration: 33000,
91
+ },
92
+ {
93
+ id: "deccan-straits",
94
+ from: [19.076, 72.8777] as [number, number],
95
+ to: [1.3521, 103.8198] as [number, number],
96
+ unit: "AWB",
97
+ fromValue: 170,
98
+ toValue: 260,
99
+ duration: 29000,
100
+ },
101
+ ]
102
+
103
+ /** Azul oscuro de marca #000b27. Misma línea en tema claro y oscuro. */
104
+ const ARC_COLOR: [number, number, number] = [0, 0.043, 0.153]
105
+
106
+ const WORLD_GLOBE_CONFIG: COBEOptions = {
107
+ width: 800,
108
+ height: 800,
109
+ devicePixelRatio: 2,
110
+ phi: 0,
111
+ theta: 0.2,
112
+ dark: 0,
113
+ diffuse: 1.5,
114
+ mapSamples: 16000,
115
+ mapBrightness: 10,
116
+ baseColor: [1, 1, 1],
117
+ markerColor: [0.647, 0.867, 0.392],
118
+ glowColor: [0.94, 0.93, 0.91],
119
+ markerElevation: 0.01,
120
+ opacity: 1,
121
+ markers: MARKERS.map((marker) => ({
122
+ location: marker.location,
123
+ size: 0.025,
124
+ id: marker.id,
125
+ })),
126
+ arcs: ARCS.map((arc) => ({
127
+ from: arc.from,
128
+ to: arc.to,
129
+ id: arc.id,
130
+ })),
131
+ arcColor: ARC_COLOR,
132
+ arcWidth: 0.5,
133
+ arcHeight: 0.25,
134
+ }
135
+
136
+ const GLOW_LIGHT: [number, number, number] = [0.97, 0.97, 0.98]
137
+ const GLOW_DARK: [number, number, number] = [0.07, 0.075, 0.08]
138
+
139
+ function anchoredStyle(anchor: string, visible: string): CSSProperties {
140
+ return {
141
+ positionAnchor: anchor,
142
+ bottom: "anchor(top)",
143
+ left: "anchor(center)",
144
+ translate: "-50% 0",
145
+ marginBottom: 8,
146
+ opacity: `var(${visible}, 0)`,
147
+ filter: `blur(var(${visible}, 10px))`,
148
+ transition: "opacity 0.8s, filter 0.8s",
149
+ }
150
+ }
151
+
152
+ function glowForTheme(theme: string): [number, number, number] {
153
+ if (theme === "dark") return GLOW_DARK
154
+ if (theme === "light") return GLOW_LIGHT
155
+ if (typeof document !== "undefined" && document.documentElement.classList.contains("dark")) {
156
+ return GLOW_DARK
157
+ }
158
+ return GLOW_LIGHT
159
+ }
160
+
161
+ /** Globo listo para la página web. No edita empresas ni envíos. */
162
+ export function WorldGlobe({ className }: { className?: string }) {
163
+ const rootRef = useRef<HTMLDivElement>(null)
164
+ const playingRef = useRef(false)
165
+ const [playing, setPlaying] = useState(false)
166
+ const { theme } = useTheme()
167
+ const glowColor = glowForTheme(theme)
168
+ const config = useMemo(
169
+ () => ({ ...WORLD_GLOBE_CONFIG, glowColor }),
170
+ [glowColor],
171
+ )
172
+ const [lanes, setLanes] = useState(() =>
173
+ ARCS.map((arc) => ({
174
+ id: arc.id,
175
+ unit: arc.unit,
176
+ value: arc.fromValue,
177
+ })),
178
+ )
179
+
180
+ useEffect(() => {
181
+ const node = rootRef.current
182
+ if (!node) return
183
+
184
+ let onScreen = false
185
+ const sync = () => {
186
+ const next = onScreen && document.visibilityState === "visible"
187
+ playingRef.current = next
188
+ setPlaying(next)
189
+ }
190
+ const observer = new IntersectionObserver(
191
+ ([entry]) => {
192
+ onScreen = entry?.isIntersecting ?? false
193
+ sync()
194
+ },
195
+ { threshold: 0.35 },
196
+ )
197
+ observer.observe(node)
198
+ document.addEventListener("visibilitychange", sync)
199
+ return () => {
200
+ observer.disconnect()
201
+ document.removeEventListener("visibilitychange", sync)
202
+ }
203
+ }, [])
204
+
205
+ useEffect(() => {
206
+ let frame = 0
207
+ let elapsedMs = 0
208
+ let lastNow = 0
209
+ const shown = ARCS.map((arc) => arc.fromValue)
210
+
211
+ const tick = (now: number) => {
212
+ if (playingRef.current) {
213
+ if (lastNow !== 0) elapsedMs += now - lastNow
214
+ lastNow = now
215
+ } else {
216
+ lastNow = 0
217
+ }
218
+
219
+ let changed = false
220
+ const next = ARCS.map((arc, index) => {
221
+ const elapsed = elapsedMs / arc.duration
222
+ const ping = elapsed % 2
223
+ const t = ping < 1 ? ping : 2 - ping
224
+ const eased = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
225
+ const value = Math.round(arc.fromValue + (arc.toValue - arc.fromValue) * eased)
226
+ if (value !== shown[index]) {
227
+ shown[index] = value
228
+ changed = true
229
+ }
230
+ return value
231
+ })
232
+ if (changed) {
233
+ setLanes((current) =>
234
+ current.map((item, index) => ({ ...item, value: next[index] ?? item.value })),
235
+ )
236
+ }
237
+ frame = requestAnimationFrame(tick)
238
+ }
239
+
240
+ frame = requestAnimationFrame(tick)
241
+ return () => cancelAnimationFrame(frame)
242
+ }, [])
243
+
244
+ return (
245
+ <div
246
+ ref={rootRef}
247
+ className={cn(
248
+ "relative mx-auto aspect-square w-full max-w-lg bg-background",
249
+ className,
250
+ )}
251
+ >
252
+ <Globe
253
+ config={config}
254
+ autoRotate={false}
255
+ interactive={false}
256
+ cycleMarkers
257
+ centerMarkerLatitude
258
+ playing={playing}
259
+ markerHoldMs={2400}
260
+ markerTransitionMs={1800}
261
+ className="max-w-full"
262
+ />
263
+ {MARKERS.map((marker) => (
264
+ <div
265
+ key={marker.id}
266
+ className="pointer-events-none absolute z-10 w-max max-w-36 rounded-sm bg-primary px-1.5 py-px font-mono text-[10px] tracking-normal text-[#000b27]"
267
+ style={anchoredStyle(`--cobe-${marker.id}`, `--cobe-visible-${marker.id}`)}
268
+ >
269
+ <span className="block truncate">{marker.label}</span>
270
+ <span className="absolute top-full left-1/2 -translate-x-1/2 border-[5px] border-transparent border-t-primary" />
271
+ </div>
272
+ ))}
273
+ {lanes.map((item) => (
274
+ <div
275
+ key={item.id}
276
+ className="pointer-events-none absolute z-10 rounded bg-black px-2 py-[3px] font-mono text-[0.5rem] whitespace-nowrap text-white"
277
+ style={{
278
+ positionAnchor: `--cobe-arc-${item.id}`,
279
+ bottom: "anchor(top)",
280
+ left: "anchor(center)",
281
+ translate: "-50% 0",
282
+ opacity: `var(--cobe-visible-arc-${item.id}, 0)`,
283
+ filter: `blur(calc((1 - var(--cobe-visible-arc-${item.id}, 0)) * 8px))`,
284
+ transition: "opacity 0.3s, filter 0.3s",
285
+ }}
286
+ >
287
+ {item.value} {item.unit}/s
288
+ </div>
289
+ ))}
290
+ </div>
291
+ )
292
+ }
package/src/ui/globe.tsx CHANGED
@@ -28,7 +28,6 @@ function lerpAngle(a: number, b: number, t: number): number {
28
28
  const GLOBE_CONFIG: COBEOptions = {
29
29
  width: 800,
30
30
  height: 800,
31
- onRender: () => {},
32
31
  devicePixelRatio: 2,
33
32
  phi: 0,
34
33
  theta: 0.3,
@@ -60,6 +59,8 @@ export function Globe({
60
59
  interactive = true,
61
60
  panBetweenMarkers = false,
62
61
  cycleMarkers = false,
62
+ centerMarkerLatitude = false,
63
+ playing = true,
63
64
  markerHoldMs = MARKER_HOLD_MS,
64
65
  markerTransitionMs = MARKER_TRANSITION_MS,
65
66
  }: {
@@ -71,10 +72,16 @@ export function Globe({
71
72
  panBetweenMarkers?: boolean
72
73
  /** Centra cada marker uno por uno con pausa y transición suave. */
73
74
  cycleMarkers?: boolean
75
+ /** Inclina el globo para dejar la latitud de cada marker en el centro. */
76
+ centerMarkerLatitude?: boolean
77
+ /** Si es false, el globo se queda en el fotograma actual y no avanza el tour. */
78
+ playing?: boolean
74
79
  markerHoldMs?: number
75
80
  markerTransitionMs?: number
76
81
  }) {
77
82
  const canvasRef = useRef<HTMLCanvasElement>(null)
83
+ const playingRef = useRef(playing)
84
+ playingRef.current = playing
78
85
  const phiRef = useRef(config.phi ?? 0)
79
86
  const widthRef = useRef(0)
80
87
  const pointerInteracting = useRef<number | null>(null)
@@ -84,12 +91,15 @@ export function Globe({
84
91
 
85
92
  const fixedTheta = config.theta ?? 0.22
86
93
 
87
- const markerViews = useMemo((): Array<{ phi: number }> | null => {
94
+ const markerViews = useMemo((): Array<{ phi: number; theta: number }> | null => {
88
95
  if (!shouldCycleMarkers || !config.markers?.length) return null
89
96
  return config.markers.map((marker) => ({
90
97
  phi: Math.PI - ((marker.location[1] * Math.PI) / 180 - Math.PI / 2),
98
+ theta: centerMarkerLatitude
99
+ ? (marker.location[0] * Math.PI) / 180
100
+ : fixedTheta,
91
101
  }))
92
- }, [shouldCycleMarkers, config.markers])
102
+ }, [shouldCycleMarkers, centerMarkerLatitude, fixedTheta, config.markers])
93
103
 
94
104
  const markerPhiRange = useMemo((): [number, number] | null => {
95
105
  if (!panBetweenMarkers || cycleMarkers || !config.markers?.length) return null
@@ -127,6 +137,7 @@ export function Globe({
127
137
  const canvas = canvasRef.current
128
138
  if (!canvas) return
129
139
 
140
+ const host = canvas.parentElement
130
141
  const onResize = () => {
131
142
  widthRef.current = canvas.offsetWidth
132
143
  }
@@ -139,49 +150,76 @@ export function Globe({
139
150
 
140
151
  const globe = createGlobe(canvas, {
141
152
  ...config,
142
- width: widthRef.current * 2,
143
- height: widthRef.current * 2,
144
- onRender: (state) => {
145
- if (shouldCycleMarkers && markerViews && markerViews.length > 0) {
146
- const segment = markerHoldMs + markerTransitionMs
147
- const total = markerViews.length * segment
148
- const elapsed = Date.now() % total
149
- const index = Math.floor(elapsed / segment)
150
- const local = elapsed % segment
151
- const from = markerViews[index % markerViews.length]
152
- const to = markerViews[(index + 1) % markerViews.length]
153
+ devicePixelRatio: config.devicePixelRatio ?? 2,
154
+ width: widthRef.current,
155
+ height: widthRef.current,
156
+ })
157
+
158
+ let frame = 0
159
+ let elapsedMs = 0
160
+ let lastNow = 0
161
+ const render = () => {
162
+ const now = Date.now()
163
+ if (playingRef.current) {
164
+ if (lastNow !== 0) elapsedMs += now - lastNow
165
+ lastNow = now
166
+ } else {
167
+ lastNow = 0
168
+ }
169
+
170
+ const cssSize = widthRef.current
171
+ let phi = interactive ? phiRef.current + rs.get() : (config.phi ?? 0)
172
+ let theta = config.theta ?? 0.3
153
173
 
174
+ if (shouldCycleMarkers && markerViews && markerViews.length > 0) {
175
+ const segment = markerHoldMs + markerTransitionMs
176
+ const elapsed = elapsedMs
177
+ const index = Math.floor(elapsed / segment) % markerViews.length
178
+ const local = elapsed % segment
179
+ const from = markerViews[index % markerViews.length]
180
+ const to = markerViews[(index + 1) % markerViews.length]
181
+ if (from && to) {
154
182
  if (local < markerHoldMs) {
155
- state.phi = from.phi
183
+ phi = from.phi
184
+ theta = from.theta
156
185
  } else {
157
186
  const t = easeInOutCubic((local - markerHoldMs) / markerTransitionMs)
158
- state.phi = lerpAngle(from.phi, to.phi, t)
187
+ phi = lerpAngle(from.phi, to.phi, t)
188
+ theta = lerp(from.theta, to.theta, t)
159
189
  }
160
- state.theta = fixedTheta
161
- } else if (panBetweenMarkers && markerPhiRange) {
162
- const [phiMin, phiMax] = markerPhiRange
163
- const t = Date.now() / 5200
164
- state.phi = phiMin + ((Math.sin(t) + 1) / 2) * (phiMax - phiMin)
165
- } else if (autoRotate && !pointerInteracting.current) {
166
- phiRef.current += 0.005
167
- state.phi = interactive ? phiRef.current + rs.get() : (config.phi ?? 0)
168
- } else {
169
- state.phi = interactive ? phiRef.current + rs.get() : (config.phi ?? 0)
170
190
  }
191
+ } else if (panBetweenMarkers && markerPhiRange) {
192
+ const [phiMin, phiMax] = markerPhiRange
193
+ const t = Date.now() / 5200
194
+ phi = phiMin + ((Math.sin(t) + 1) / 2) * (phiMax - phiMin)
195
+ } else if (autoRotate && playingRef.current && !pointerInteracting.current) {
196
+ phiRef.current += 0.005
197
+ phi = interactive ? phiRef.current + rs.get() : (config.phi ?? 0)
198
+ }
171
199
 
172
- state.theta = shouldCycleMarkers ? fixedTheta : (config.theta ?? state.theta)
173
- state.scale = config.scale ?? 1
174
- state.width = widthRef.current * 2
175
- state.height = widthRef.current * 2
176
- },
177
- })
200
+ globe.update({
201
+ phi,
202
+ theta,
203
+ scale: config.scale ?? 1,
204
+ width: cssSize,
205
+ height: cssSize,
206
+ })
207
+ frame = requestAnimationFrame(render)
208
+ }
209
+ frame = requestAnimationFrame(render)
178
210
 
179
211
  setTimeout(() => {
180
212
  canvas.style.opacity = "1"
181
213
  }, 0)
182
214
 
183
215
  return () => {
216
+ cancelAnimationFrame(frame)
184
217
  globe.destroy()
218
+ const wrapper = canvas.parentElement
219
+ if (wrapper && host && wrapper !== host) {
220
+ host.insertBefore(canvas, wrapper)
221
+ wrapper.remove()
222
+ }
185
223
  window.removeEventListener("resize", onResize)
186
224
  resizeObserver.disconnect()
187
225
  }
@@ -192,6 +230,7 @@ export function Globe({
192
230
  interactive,
193
231
  panBetweenMarkers,
194
232
  cycleMarkers,
233
+ centerMarkerLatitude,
195
234
  shouldCycleMarkers,
196
235
  markerViews,
197
236
  markerPhiRange,
@@ -0,0 +1,292 @@
1
+ "use client"
2
+
3
+ import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"
4
+ import type { COBEOptions } from "cobe"
5
+
6
+ import { useTheme } from "../hooks/use-document-theme"
7
+ import { cn } from "../lib/utils"
8
+ import { Globe } from "./globe"
9
+
10
+ /** Tour: Norteamérica → Suramérica → Europa → África → Asia → Oceanía. */
11
+ const MARKERS = [
12
+ { id: "cascade", location: [49.2827, -123.1207] as [number, number], label: "Cascade LLC" },
13
+ { id: "logistic", location: [41.8781, -87.6298] as [number, number], label: "Logistic LLC" },
14
+ { id: "alianza", location: [19.4326, -99.1332] as [number, number], label: "Alianza SA" },
15
+ { id: "bolivar", location: [4.711, -74.0721] as [number, number], label: "Bolívar SAS" },
16
+ { id: "minerva", location: [-23.5505, -46.6333] as [number, number], label: "Minerva SA" },
17
+ { id: "cordillera", location: [-33.4489, -70.6693] as [number, number], label: "Cordillera SA" },
18
+ { id: "iberica", location: [40.4168, -3.7038] as [number, number], label: "Ibérica SA" },
19
+ { id: "avrora", location: [55.7558, 37.6173] as [number, number], label: "Avrora LLC" },
20
+ { id: "meridian", location: [6.5244, 3.3792] as [number, number], label: "Meridian Ltd" },
21
+ { id: "savanna", location: [-26.2041, 28.0473] as [number, number], label: "Savanna SA" },
22
+ { id: "deccan", location: [19.076, 72.8777] as [number, number], label: "Deccan Ltd" },
23
+ { id: "straits", location: [1.3521, 103.8198] as [number, number], label: "Straits Ltd" },
24
+ { id: "huayun", location: [31.2304, 121.4737] as [number, number], label: "华运 Ltd" },
25
+ { id: "harbour", location: [-33.8688, 151.2093] as [number, number], label: "Harbour Ltd" },
26
+ ]
27
+
28
+ const ARCS = [
29
+ {
30
+ id: "alianza-logistic",
31
+ from: [19.4326, -99.1332] as [number, number],
32
+ to: [41.8781, -87.6298] as [number, number],
33
+ unit: "AWB",
34
+ fromValue: 120,
35
+ toValue: 240,
36
+ duration: 28000,
37
+ },
38
+ {
39
+ id: "iberica-avrora",
40
+ from: [40.4168, -3.7038] as [number, number],
41
+ to: [55.7558, 37.6173] as [number, number],
42
+ unit: "BL",
43
+ fromValue: 100,
44
+ toValue: 220,
45
+ duration: 32000,
46
+ },
47
+ {
48
+ id: "meridian-savanna",
49
+ from: [6.5244, 3.3792] as [number, number],
50
+ to: [-26.2041, 28.0473] as [number, number],
51
+ unit: "WR",
52
+ fromValue: 160,
53
+ toValue: 280,
54
+ duration: 36000,
55
+ },
56
+ {
57
+ id: "avrora-huayun",
58
+ from: [55.7558, 37.6173] as [number, number],
59
+ to: [31.2304, 121.4737] as [number, number],
60
+ unit: "MAWB",
61
+ fromValue: 140,
62
+ toValue: 260,
63
+ duration: 30000,
64
+ },
65
+ {
66
+ id: "harbour-huayun",
67
+ from: [-33.8688, 151.2093] as [number, number],
68
+ to: [31.2304, 121.4737] as [number, number],
69
+ unit: "AWB",
70
+ fromValue: 110,
71
+ toValue: 230,
72
+ duration: 34000,
73
+ },
74
+ {
75
+ id: "avrora-cascade",
76
+ from: [55.7558, 37.6173] as [number, number],
77
+ to: [49.2827, -123.1207] as [number, number],
78
+ unit: "BL",
79
+ fromValue: 130,
80
+ toValue: 250,
81
+ duration: 31000,
82
+ },
83
+ {
84
+ id: "bolivar-minerva",
85
+ from: [4.711, -74.0721] as [number, number],
86
+ to: [-23.5505, -46.6333] as [number, number],
87
+ unit: "WR",
88
+ fromValue: 150,
89
+ toValue: 270,
90
+ duration: 33000,
91
+ },
92
+ {
93
+ id: "deccan-straits",
94
+ from: [19.076, 72.8777] as [number, number],
95
+ to: [1.3521, 103.8198] as [number, number],
96
+ unit: "AWB",
97
+ fromValue: 170,
98
+ toValue: 260,
99
+ duration: 29000,
100
+ },
101
+ ]
102
+
103
+ /** Azul oscuro de marca #000b27. Misma línea en tema claro y oscuro. */
104
+ const ARC_COLOR: [number, number, number] = [0, 0.043, 0.153]
105
+
106
+ const WORLD_GLOBE_CONFIG: COBEOptions = {
107
+ width: 800,
108
+ height: 800,
109
+ devicePixelRatio: 2,
110
+ phi: 0,
111
+ theta: 0.2,
112
+ dark: 0,
113
+ diffuse: 1.5,
114
+ mapSamples: 16000,
115
+ mapBrightness: 10,
116
+ baseColor: [1, 1, 1],
117
+ markerColor: [0.647, 0.867, 0.392],
118
+ glowColor: [0.94, 0.93, 0.91],
119
+ markerElevation: 0.01,
120
+ opacity: 1,
121
+ markers: MARKERS.map((marker) => ({
122
+ location: marker.location,
123
+ size: 0.025,
124
+ id: marker.id,
125
+ })),
126
+ arcs: ARCS.map((arc) => ({
127
+ from: arc.from,
128
+ to: arc.to,
129
+ id: arc.id,
130
+ })),
131
+ arcColor: ARC_COLOR,
132
+ arcWidth: 0.5,
133
+ arcHeight: 0.25,
134
+ }
135
+
136
+ const GLOW_LIGHT: [number, number, number] = [0.97, 0.97, 0.98]
137
+ const GLOW_DARK: [number, number, number] = [0.07, 0.075, 0.08]
138
+
139
+ function anchoredStyle(anchor: string, visible: string): CSSProperties {
140
+ return {
141
+ positionAnchor: anchor,
142
+ bottom: "anchor(top)",
143
+ left: "anchor(center)",
144
+ translate: "-50% 0",
145
+ marginBottom: 8,
146
+ opacity: `var(${visible}, 0)`,
147
+ filter: `blur(var(${visible}, 10px))`,
148
+ transition: "opacity 0.8s, filter 0.8s",
149
+ }
150
+ }
151
+
152
+ function glowForTheme(theme: string): [number, number, number] {
153
+ if (theme === "dark") return GLOW_DARK
154
+ if (theme === "light") return GLOW_LIGHT
155
+ if (typeof document !== "undefined" && document.documentElement.classList.contains("dark")) {
156
+ return GLOW_DARK
157
+ }
158
+ return GLOW_LIGHT
159
+ }
160
+
161
+ /** Globo listo para la página web. No edita empresas ni envíos. */
162
+ export function WorldGlobe({ className }: { className?: string }) {
163
+ const rootRef = useRef<HTMLDivElement>(null)
164
+ const playingRef = useRef(false)
165
+ const [playing, setPlaying] = useState(false)
166
+ const { theme } = useTheme()
167
+ const glowColor = glowForTheme(theme)
168
+ const config = useMemo(
169
+ () => ({ ...WORLD_GLOBE_CONFIG, glowColor }),
170
+ [glowColor],
171
+ )
172
+ const [lanes, setLanes] = useState(() =>
173
+ ARCS.map((arc) => ({
174
+ id: arc.id,
175
+ unit: arc.unit,
176
+ value: arc.fromValue,
177
+ })),
178
+ )
179
+
180
+ useEffect(() => {
181
+ const node = rootRef.current
182
+ if (!node) return
183
+
184
+ let onScreen = false
185
+ const sync = () => {
186
+ const next = onScreen && document.visibilityState === "visible"
187
+ playingRef.current = next
188
+ setPlaying(next)
189
+ }
190
+ const observer = new IntersectionObserver(
191
+ ([entry]) => {
192
+ onScreen = entry?.isIntersecting ?? false
193
+ sync()
194
+ },
195
+ { threshold: 0.35 },
196
+ )
197
+ observer.observe(node)
198
+ document.addEventListener("visibilitychange", sync)
199
+ return () => {
200
+ observer.disconnect()
201
+ document.removeEventListener("visibilitychange", sync)
202
+ }
203
+ }, [])
204
+
205
+ useEffect(() => {
206
+ let frame = 0
207
+ let elapsedMs = 0
208
+ let lastNow = 0
209
+ const shown = ARCS.map((arc) => arc.fromValue)
210
+
211
+ const tick = (now: number) => {
212
+ if (playingRef.current) {
213
+ if (lastNow !== 0) elapsedMs += now - lastNow
214
+ lastNow = now
215
+ } else {
216
+ lastNow = 0
217
+ }
218
+
219
+ let changed = false
220
+ const next = ARCS.map((arc, index) => {
221
+ const elapsed = elapsedMs / arc.duration
222
+ const ping = elapsed % 2
223
+ const t = ping < 1 ? ping : 2 - ping
224
+ const eased = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
225
+ const value = Math.round(arc.fromValue + (arc.toValue - arc.fromValue) * eased)
226
+ if (value !== shown[index]) {
227
+ shown[index] = value
228
+ changed = true
229
+ }
230
+ return value
231
+ })
232
+ if (changed) {
233
+ setLanes((current) =>
234
+ current.map((item, index) => ({ ...item, value: next[index] ?? item.value })),
235
+ )
236
+ }
237
+ frame = requestAnimationFrame(tick)
238
+ }
239
+
240
+ frame = requestAnimationFrame(tick)
241
+ return () => cancelAnimationFrame(frame)
242
+ }, [])
243
+
244
+ return (
245
+ <div
246
+ ref={rootRef}
247
+ className={cn(
248
+ "relative mx-auto aspect-square w-full max-w-lg bg-background",
249
+ className,
250
+ )}
251
+ >
252
+ <Globe
253
+ config={config}
254
+ autoRotate={false}
255
+ interactive={false}
256
+ cycleMarkers
257
+ centerMarkerLatitude
258
+ playing={playing}
259
+ markerHoldMs={2400}
260
+ markerTransitionMs={1800}
261
+ className="max-w-full"
262
+ />
263
+ {MARKERS.map((marker) => (
264
+ <div
265
+ key={marker.id}
266
+ className="pointer-events-none absolute z-10 w-max max-w-36 rounded-sm bg-primary px-1.5 py-px font-mono text-[10px] tracking-normal text-[#000b27]"
267
+ style={anchoredStyle(`--cobe-${marker.id}`, `--cobe-visible-${marker.id}`)}
268
+ >
269
+ <span className="block truncate">{marker.label}</span>
270
+ <span className="absolute top-full left-1/2 -translate-x-1/2 border-[5px] border-transparent border-t-primary" />
271
+ </div>
272
+ ))}
273
+ {lanes.map((item) => (
274
+ <div
275
+ key={item.id}
276
+ className="pointer-events-none absolute z-10 rounded bg-black px-2 py-[3px] font-mono text-[0.5rem] whitespace-nowrap text-white"
277
+ style={{
278
+ positionAnchor: `--cobe-arc-${item.id}`,
279
+ bottom: "anchor(top)",
280
+ left: "anchor(center)",
281
+ translate: "-50% 0",
282
+ opacity: `var(--cobe-visible-arc-${item.id}, 0)`,
283
+ filter: `blur(calc((1 - var(--cobe-visible-arc-${item.id}, 0)) * 8px))`,
284
+ transition: "opacity 0.3s, filter 0.3s",
285
+ }}
286
+ >
287
+ {item.value} {item.unit}/s
288
+ </div>
289
+ ))}
290
+ </div>
291
+ )
292
+ }