A translucent 3D Earth with a year of earthquakes plotted at their true depth beneath the surface.
---
format: typebulb/v1
name: Earthquake Depths
---
**code.tsx**
```tsx
import React, { useEffect, useRef, useState } from "react"
import { createRoot } from "react-dom/client"
import Globe from "globe.gl"
import * as THREE from "three"
interface Quake { lat: number; lng: number; depth: number; mag: number; place: string }
const BLUE_MARBLE = "https://unpkg.com/[email protected]/example/img/earth-blue-marble.jpg"
const EARTH_RADIUS_KM = 6371
// depth color ramp, warm shallow → cool deep (seismology convention)
const STOPS: [number, [number, number, number]][] = [
[0, [255, 75, 75]],
[70, [255, 165, 60]],
[150, [255, 225, 85]],
[300, [110, 215, 115]],
[500, [80, 145, 255]],
[700, [165, 90, 255]],
]
function depthColor(d: number): [number, number, number] {
const c = Math.max(0, Math.min(700, d))
for (let i = 1; i < STOPS.length; i++) {
const [d1, c1] = STOPS[i]
if (c <= d1) {
const [d0, c0] = STOPS[i - 1]
const t = (c - d0) / (d1 - d0)
return [0, 1, 2].map(k => (c0[k] + (c1[k] - c0[k]) * t) / 255) as [number, number, number]
}
}
return [165 / 255, 90 / 255, 255 / 255]
}
const LEGEND_GRADIENT = `linear-gradient(to right, ${STOPS.map(([d, [r, g, b]]) => `rgb(${r},${g},${b}) ${(d / 700) * 100}%`).join(", ")})`
const VERT = `
attribute float aSize;
attribute vec3 aColor;
varying vec3 vColor;
varying float vFade;
void main() {
vColor = aColor;
// dim points beyond the visible horizon (facing < cos of the limb angle)
float facing = dot(normalize(position), normalize(cameraPosition));
float horizon = 100.0 / length(cameraPosition);
vFade = mix(0.18, 1.0, smoothstep(horizon - 0.4, horizon, facing));
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = aSize * (300.0 / -mv.z);
gl_Position = projectionMatrix * mv;
}
`
const FRAG = `
varying vec3 vColor;
varying float vFade;
void main() {
float d = length(gl_PointCoord - 0.5);
if (d > 0.5) discard;
gl_FragColor = vec4(vColor, smoothstep(0.5, 0.3, d) * 0.9 * vFade);
}
`
function App() {
const ref = useRef<HTMLDivElement>(null)
const worldRef = useRef<any>(null)
const geomRef = useRef<THREE.BufferGeometry | undefined>(undefined)
const quakesRef = useRef<Quake[]>([])
const depthScaleRef = useRef(3)
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading")
const [stats, setStats] = useState<{ count: number; max?: Quake }>({ count: 0 })
const [depthScale, setDepthScale] = useState(3)
const applyDepth = (scale: number) => {
const world = worldRef.current, geom = geomRef.current
if (!world || !geom) return
const pos = geom.getAttribute("position") as THREE.BufferAttribute
quakesRef.current.forEach((q, i) => {
const { x, y, z } = world.getCoords(q.lat, q.lng, -(q.depth * scale) / EARTH_RADIUS_KM)
pos.setXYZ(i, x, y, z)
})
pos.needsUpdate = true
}
useEffect(() => {
depthScaleRef.current = depthScale
applyDepth(depthScale)
}, [depthScale])
useEffect(() => {
const el = ref.current!
let disposed = false
const world: any = (Globe as any)()(el)
.backgroundColor("rgba(0,0,0,0)")
.globeImageUrl(BLUE_MARBLE)
.showAtmosphere(true)
.atmosphereColor("#4895ef")
.atmosphereAltitude(0.12)
.showGraticules(true)
.width(el.clientWidth)
.height(el.clientHeight)
worldRef.current = world
const globeMat = world.globeMaterial()
globeMat.transparent = true
globeMat.opacity = 0.5
world.controls().autoRotate = true
world.controls().autoRotateSpeed = 0.35
// fit globe + atmosphere glow (1.12 radii) to 92% of the smaller viewport dimension
const fitAltitude = () => {
const halfFov = (world.camera().fov / 2) * Math.PI / 180
const aspect = el.clientWidth / el.clientHeight
return 1.12 / (0.92 * Math.tan(halfFov) * Math.min(1, aspect)) - 1
}
world.pointOfView({ lat: -15, lng: 170, altitude: fitAltitude() }, 0)
const onResize = () => {
world.width(el.clientWidth).height(el.clientHeight)
world.pointOfView({ altitude: fitAltitude() })
}
window.addEventListener("resize", onResize)
const geom = new THREE.BufferGeometry()
const mat = new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: FRAG,
transparent: true,
depthWrite: false,
depthTest: false, // interior points must not be occluded by the globe surface
})
const points = new THREE.Points(geom, mat)
points.renderOrder = 3
world.scene().add(points)
;(async () => {
try {
const start = new Date(Date.now() - 365 * 864e5).toISOString().slice(0, 10)
const res = await fetch(
`https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson&starttime=${start}&minmagnitude=4.5&limit=20000`
)
if (!res.ok) throw new Error("feed " + res.status)
const json = await res.json()
if (disposed) return
const quakes: Quake[] = json.features
.map((f: any) => ({
lat: f.geometry?.coordinates?.[1],
lng: f.geometry?.coordinates?.[0],
depth: Math.max(0, f.geometry?.coordinates?.[2] ?? 0),
mag: f.properties?.mag,
place: f.properties?.place,
}))
.filter((q: Quake) => typeof q.mag === "number" && typeof q.lat === "number")
const n = quakes.length
const colors = new Float32Array(n * 3)
const sizes = new Float32Array(n)
quakes.forEach((q, i) => {
colors.set(depthColor(q.depth), i * 3)
sizes[i] = (2.2 + (Math.max(4.5, q.mag) - 4.5) * 2) * devicePixelRatio
})
geom.setAttribute("position", new THREE.BufferAttribute(new Float32Array(n * 3), 3))
geom.setAttribute("aColor", new THREE.BufferAttribute(colors, 3))
geom.setAttribute("aSize", new THREE.BufferAttribute(sizes, 1))
geomRef.current = geom
quakesRef.current = quakes
applyDepth(depthScaleRef.current)
const max = quakes.reduce<Quake | undefined>((a, b) => (b.mag > (a?.mag ?? -99) ? b : a), undefined)
setStats({ count: n, max })
setStatus("ready")
} catch {
if (!disposed) setStatus("error")
}
})()
return () => {
disposed = true
window.removeEventListener("resize", onResize)
geom.dispose()
mat.dispose()
try { world._destructor?.() } catch { /* best-effort */ }
}
}, [])
return (
<div className="wrap">
<div className="hud">
<div className="title">Earthquake depths</div>
<div className="meta">
{status === "loading" && "fetching a year of USGS data…"}
{status === "ready" && (
<>
{stats.count.toLocaleString()} quakes · M 4.5+ · past 12 months
{stats.max && <> · strongest <b>M {stats.max.mag.toFixed(1)}</b> at {Math.round(stats.max.depth)} km</>}
</>
)}
{status === "error" && "couldn't reach the USGS archive"}
</div>
<div className="controls">
<label>
depth ×{depthScale}{depthScale === 1 && " (true scale)"}
<input
type="range" min={1} max={8} step={0.5} value={depthScale}
onChange={e => setDepthScale(Number(e.target.value))}
/>
</label>
<div className="legend">
<span>0 km</span>
<div className="ramp" style={{ background: LEGEND_GRADIENT }} />
<span>700 km</span>
</div>
</div>
</div>
<div className="globe" ref={ref} />
</div>
)
}
createRoot(document.getElementById("root")!).render(<App />)
```
**styles.css**
```css
* { box-sizing: border-box; }
html, body, #root { margin: 0; height: 100%; }
.wrap {
position: relative;
width: 100%;
height: 100dvh;
min-height: 420px;
overflow: hidden;
background: radial-gradient(circle at 50% 40%, #0a1430 0%, #04070e 75%);
font: 13px system-ui, -apple-system, sans-serif;
color: #e7ecf6;
}
.globe { position: absolute; inset: 0; }
.hud {
position: absolute;
top: 16px;
left: 18px;
z-index: 2;
pointer-events: none;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.85);
}
.title { font-size: 16px; font-weight: 600; letter-spacing: 0.2px; }
.meta { margin-top: 3px; font-size: 12px; opacity: 0.8; }
.meta b { color: #ff9d5c; font-weight: 600; }
.controls { margin-top: 10px; pointer-events: auto; }
.controls label { display: flex; flex-direction: column; gap: 3px; font-size: 12px; opacity: 0.9; }
.controls input { width: 160px; accent-color: #4895ef; }
.legend { display: flex; align-items: center; gap: 6px; margin-top: 8px; font-size: 11px; opacity: 0.85; }
.ramp { width: 120px; height: 8px; border-radius: 4px; }
```
**index.html**
```html
<div id="root"></div>
```
**config.json**
```json
{
"description": "A translucent 3D Earth with a year of earthquakes plotted at their true depth beneath the surface.",
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7",
"globe.gl": "^2.34.0",
"three": "^0.180.0"
}
}
```