Rocket Globe Pro

Rocket is currently in beta.

An interactive 3D globe visualization using the Globe.GL library.

Demo

Explanation #

The page-level controls own the target, places, and arcs, while the component mounts Globe.GL from a rendered ref in onFirstRender(). After that first mount, prop observers keep the globe instance in sync without rerendering the container.

Performance Tips for Complex Props #

When working with complex reactive objects or arrays that update frequently, you may encounter performance issues where effects fire repeatedly even when values haven’t changed. This happens because reactive proxies can trigger updates on every reactive read.

Solution:
Use the “stringify and compare” pattern to prevent unnecessary updates.

 1onFirstRender({ refs, observeProps, props }) {
 2    let prevArcs = ''
 3
 4    const syncArcs = () => {
 5        const next = JSON.stringify(props.arcs || [])
 6        if (next === prevArcs) return
 7        prevArcs = next
 8        globe.arcsData(JSON.parse(next))
 9    }
10
11    syncArcs()
12    observeProps(syncArcs, 'arcs')
13}

This pattern ensures the effect only runs when the actual data changes, not on every reactive read.

Usage Example #

1<globe-view
2    data-attr:places='[
3        ["New York", [40.7128, -74.0060]],
4        ["London", [51.5074, -0.1278]]
5    ]'
6    data-attr:arcs='[
7        [[40.7128, -74.0060], [51.5074, -0.1278]]
8    ]'
9></globe-view>

Rocket Component #

  1import { rocket } from 'datastar'
  2
  3const { default: Globe } = await import(
  4  'https://cdn.jsdelivr.net/npm/[email protected]/+esm'
  5)
  6
  7rocket('globe-view', {
  8  props: ({ json }) => ({
  9    paths: json.default([]),
 10    target: json.default([36, -115]),
 11    places: json.default([]),
 12    arcs: json.default([]),
 13  }),
 14  onFirstRender: ({ refs, cleanup, host, observeProps, props }) => {
 15    let globe
 16
 17    const target = () => ({
 18      lat: props.target?.[0] ?? 36,
 19      lng: props.target?.[1] ?? -115,
 20      altitude:
 21        (refs.container?.offsetWidth ?? 1) /
 22          (refs.container?.offsetHeight ?? 1) >
 23        1.5
 24          ? 0.8
 25          : (refs.container?.offsetWidth ?? 1) /
 26                (refs.container?.offsetHeight ?? 1) >
 27              1
 28            ? 1.2
 29            : 1.5,
 30    })
 31    const syncTarget = () => {
 32      if (!globe) return
 33      globe.pointOfView(target(), 2000)
 34    }
 35    const syncPaths = () => {
 36      if (!globe) return
 37      if (props.paths?.length) {
 38        globe
 39          .pathsData(props.paths)
 40          .pathPointLat((point) => point[0])
 41          .pathPointLng((point) => point[1])
 42          .pathColor(() => '#ff0000')
 43          .pathStroke(0.5)
 44        return
 45      }
 46      globe.pathsData([])
 47    }
 48    const syncArcs = () => {
 49      if (!globe) return
 50      if (props.arcs?.length) {
 51        globe
 52          .arcsData(props.arcs)
 53          .arcStartLat((arc) => arc[0][0])
 54          .arcStartLng((arc) => arc[0][1])
 55          .arcEndLat((arc) => arc[1][0])
 56          .arcEndLng((arc) => arc[1][1])
 57          .arcDashLength(0.5)
 58          .arcDashGap(0.5)
 59          .arcDashAnimateTime(1000)
 60        return
 61      }
 62      globe.arcsData([])
 63    }
 64    const syncPlaces = () => {
 65      if (!globe) return
 66      if (!props.places?.length) {
 67        globe.htmlElementsData([])
 68        return
 69      }
 70      globe
 71        .htmlElementsData(
 72          props.places.map(([name, [lat, lng]]) => ({ name, lat, lng })),
 73        )
 74        .htmlLat((entry) => entry.lat)
 75        .htmlLng((entry) => entry.lng)
 76        .htmlElement((entry) => {
 77          const wrapper = document.createElement('div')
 78          wrapper.className = 'marker-wrapper'
 79          wrapper.style.cursor = 'pointer'
 80          wrapper.style.pointerEvents = 'auto'
 81
 82          const icon = document.createElement('div')
 83          icon.className = 'marker-icon'
 84          icon.style.width = '32px'
 85          icon.style.height = '32px'
 86          icon.innerHTML = `<svg viewBox="-4 0 36 36" width="32" height="32"><path fill="currentColor" d="M14,0 C21.732,0 28,5.641 28,12.6 C28,23.963 14,36 14,36 C14,36 0,24.064 0,12.6 C0,5.641 6.268,0 14,0 Z"/><circle fill="black" cx="14" cy="14" r="7"/></svg>`
 87
 88          const label = document.createElement('div')
 89          label.className = 'marker-label'
 90          label.textContent = entry.name
 91
 92          wrapper.append(icon, label)
 93          wrapper.addEventListener('click', (evt) => {
 94            evt.preventDefault()
 95            evt.stopPropagation()
 96            host.dispatchEvent(
 97              new CustomEvent('marker-click', {
 98                detail: { name: entry.name, lat: entry.lat, lng: entry.lng },
 99                bubbles: true,
100                composed: true,
101              }),
102            )
103          })
104          return wrapper
105        })
106    }
107
108    if (refs.container instanceof HTMLElement) {
109      globe = Globe()(refs.container)
110        .width(refs.container.offsetWidth)
111        .height(refs.container.offsetHeight)
112        .globeImageUrl(
113          'https://unpkg.com/three-globe/example/img/earth-blue-marble.jpg',
114        )
115        .bumpImageUrl(
116          'https://unpkg.com/three-globe/example/img/earth-topology.png',
117        )
118        .pointOfView(target(), 0)
119      syncPaths()
120      syncTarget()
121      syncArcs()
122      syncPlaces()
123    }
124
125    observeProps(syncPaths, 'paths')
126    observeProps(syncTarget, 'target')
127    observeProps(syncArcs, 'arcs')
128    observeProps(syncPlaces, 'places')
129
130    cleanup(() => globe?._destructor?.())
131  },
132  render: ({ html }) => html`
133    <style>
134      :host {
135        display: block;
136        width: 100%;
137        height: 100%;
138        position: relative;
139        overflow: hidden;
140      }
141
142      .globe-container {
143        position: absolute;
144        inset: 0;
145        overflow: hidden;
146      }
147
148      .marker-wrapper {
149        display: flex;
150        flex-direction: column;
151        align-items: center;
152        transform: translate(-50%, -100%);
153        position: relative;
154        z-index: 1;
155
156        .marker-icon {
157          width: 32px;
158          height: 32px;
159          color: #9333ea;
160          filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
161
162          svg {
163            width: 100%;
164            height: 100%;
165            display: block;
166          }
167        }
168
169        .marker-label {
170          margin-top: 4px;
171          padding: 4px 8px;
172          background: linear-gradient(135deg, #9333ea, #7c3aed);
173          color: white;
174          border-radius: 4px;
175          font-size: 12px;
176          white-space: nowrap;
177          cursor: pointer;
178          box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
179          font-weight: 600;
180
181          &:hover {
182            background: linear-gradient(
183              135deg,
184              var(--purple-10),
185              var(--purple-11)
186            );
187            transform: scale(1.05);
188            box-shadow: var(--shadow-4);
189          }
190        }
191      }
192    </style>
193    <div class="globe-container" data-ref:container data-ignore-morph></div>
194  `,
195})