Globe.gl Will Eat Your DOM Children
TL;DR
Globe.gl’s
Globe()(container)replaces the container’s innerHTML. Any overlays — HUD panels, sliders, toggle buttons — inside that container get destroyed on mount. The fix is a dedicated child div for the globe, siblings for your UI. This one behavior caused three separate “bugs” that were actually the same root cause.
Globe.gl Eats Its Container
The Globe.gl initialization pattern looks harmless:
const globe = Globe()(document.getElementById('globe-container'));
What it actually does: replaces every child node of globe-container with its own canvas and Three.js scene. If your HTML looks like this:
<div id="globe-container">
<div id="hud">...</div>
<div id="controls">...</div>
</div>
Both #hud and #controls are gone after mount. No error, no warning. The DOM nodes simply vanish.
The fix:
<div id="globe-container">
<div id="globe-mount"></div> <!-- Globe mounts here -->
<div id="hud">...</div> <!-- Siblings survive -->
<div id="controls">...</div>
</div>
Mount into #globe-mount. Siblings are untouched.
This caused three bugs that looked unrelated — HUD not populating, toggle buttons not working, and control overlays vanishing. All the same root cause. Debugging each individually would have been a waste. Reading the Globe.gl source was the real fix.
Zoom-Adaptive Scaling
Globe.gl renders points, hexbins, arcs, rings, and labels at fixed sizes. At globe level, that’s fine. Zoom to street level, and your 0.3-radius point becomes a skyscraper.
The solution: listen to OrbitControls camera changes, read altitude, compute a scale factor.
globe.controls().addEventListener('change', () => {
const alt = globe.pointOfView().altitude;
const scale = Math.max(0.1, Math.min(1, alt / 2.0));
globe.pointRadius(d => d.baseRadius * scale)
.pointAltitude(d => d.baseAlt * scale)
.ringMaxRadius(d => d.baseRing * scale)
.hexAltitude(d => d.baseHex * scale)
.arcStroke(d => d.baseStroke * scale)
.labelSize(d => d.baseLabelSize * scale);
});
The change event fires on every camera movement — drag, scroll, programmatic flyTo. The altitude value is the camera’s distance from the globe surface in globe-radius units. At city level it’s around 0.4, street level around 0.12.
Clamp the scale factor. Below 0.1 everything disappears. Above 1.0 you’re back to skyscrapers.
CartoDB Dark Tiles — Free, No Key, Zoom 20
For dark-themed globes, CartoDB dark_nolabels is the best free tile provider:
https://a.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}@2x.png
Max zoom 20. Retina tiles (@2x). CC-BY 4.0 license. No API key, no rate limit headers, no signup. Subdomains a, b, c, d for parallel fetching.
Other free options tested: Esri satellite (great imagery, no dark mode), OSM (too colorful for data overlays), Stamen Toner (discontinued, redirects to Stadia which needs a key), OpenTopoMap (contour lines are beautiful but noisy under data points).
CartoDB dark wins on every axis for data visualization overlays.
Haversine Distance in Pure SQL
You do not need PostGIS for proximity queries. Pure SQL haversine:
SELECT *, 3959 * ACOS(
LEAST(1,
COS(RADIANS($1)) * COS(RADIANS(lat)) *
COS(RADIANS(lng) - RADIANS($2)) +
SIN(RADIANS($1)) * SIN(RADIANS(lat))
)
) AS dist_mi
FROM geocache
ORDER BY dist_mi
LIMIT 20;
The LEAST(1, ...) is critical. Floating-point arithmetic can produce values like 1.0000000000000002, and ACOS of anything greater than 1 is NaN. Without the clamp, random queries return NULL distances. The bug is intermittent because it depends on which coordinate pairs trigger the floating-point edge case.
Result is in miles (3959 = Earth’s radius in miles). For kilometers, use 6371.
SVG Sprites for HTMX Fragments
HTMX fragments are server-rendered HTML snippets swapped into the page. External icon libraries (font-based or JS-injected) don’t work because the fragment arrives as raw HTML with no JS execution context.
The pattern: build a self-hosted SVG sprite with <symbol> elements, reference via <use href>.
<!-- /static/icons.svg -->
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ti-plug" viewBox="0 0 24 24">
<path d="..."/>
</symbol>
<symbol id="ti-car" viewBox="0 0 24 24">
<path d="..."/>
</symbol>
</svg>
In HTMX fragments:
<svg class="icon"><use href="/static/icons.svg#ti-plug"/></svg>
No JS, no font loading, no extra HTTP requests after the initial sprite load. The sprite is ~8KB for 24 icons cherry-picked from Tabler’s 5,039-icon set.
Download only the icons you need from @tabler/icons npm package, concatenate into one sprite file. Do not ship the full 5,039-icon set for 24 icons.
Progressive Click-to-Zoom
A small UX pattern that works well on globes: each click on a location zooms deeper.
1st click: city level (altitude 0.4) 2nd click: street level (altitude 0.12) 3rd click: location level (altitude 0.05) 4th click: opens Google Street View in a new tab
Track click count per location. Reset when the user clicks a different location. The altitude values are Globe.gl-specific — they represent camera distance in globe-radius units, not meters.
Google Street View URL pattern: https://www.google.com/maps/@{lat},{lng},3a,75y,0h,90t/data=!3m6!1e1!3m4!1s!2e0!7i!8i. Works without an API key for the redirect.
Takeaways
Globe.gl is a powerful library with some rough edges around DOM ownership. The mount-into-child-div pattern should be in every Globe.gl starter template. Zoom-adaptive scaling should be built into the library but isn’t. CartoDB dark tiles are the best-kept secret in free mapping. And if you’re using HTMX, SVG sprites are the only icon strategy that works without client-side JS.