@traky-app/ui 1.6.7 → 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/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
+ }