Skip to content

Instantly share code, notes, and snippets.

@fmorency
Created October 27, 2025 14:23
Show Gist options
  • Save fmorency/8d69008e41a01b419acdc63b3d18037b to your computer and use it in GitHub Desktop.
Save fmorency/8d69008e41a01b419acdc63b3d18037b to your computer and use it in GitHub Desktop.
Custom globe
<script lang="ts">
import {Canvas, Layer} from 'svelte-canvas';
import {geoCentroid, geoDistance, geoOrthographic, geoPath} from 'd3-geo';
import {quadtree, type QuadtreeLeaf} from 'd3-quadtree';
import {worldFeatures} from "$lib/utils/worldTopology";
import {computedColor, getColorFromCSS} from "$lib/utils/colors";
import {mode} from '$lib/stores/theme';
import {onDestroy} from "svelte";
import type {GeoRecord, GeoRecordArray} from "$lib/schemas/geo";
import throttle from 'lodash/throttle';
import Paused from 'carbon-icons-svelte/lib/Pause.svelte';
import Play from 'carbon-icons-svelte/lib/Play.svelte';
interface Cluster {
readonly members: readonly GeoRecord[];
readonly coords: readonly [number, number];
}
const {data} = $props<{ data: GeoRecordArray }>();
let fillColor = $state(computedColor(getColorFromCSS('--color-surface-600-400')));
let strokeColor = $state(computedColor(getColorFromCSS('--color-primary-100-900')));
let pointColor = $state(computedColor(getColorFromCSS('--color-tertiary-500')));
let cityCount = $state<Record<string, number>>({});
let countriesWithData = $state<Set<string>>(new Set());
let citiesWithData = $state<Map<string, GeoRecord>>(new Map());
let width = $state<number>(0);
let height = $state<number>(0);
const pad = $derived(width * 0.02);
let rotation = $state<[number, number]>([98, -26]); // Initialize over the USA
let dragging = $state(false);
let _x = $state(0);
let _y = $state(0);
let hoverCluster = $state<Cluster | null>(null);
let tooltipX = $state(0);
let tooltipY = $state(0);
let fontLoaded = $state(false);
let componentActive = true;
let autoRotating = $state(true);
let manuallyDisabledRotation = $state(false);
let inactivityTimeout = $state<number | null>(null);
let animationFrame = $state<number | null>(null);
const rotationSpeed = 0.2; // degrees per frame
const inactivityDelay = 1000; // ms before autorotation starts after user stops interacting
const CLUSTER_RADIUS = 8;
let clusters = $derived.by(() => {
// Build a quadtree on screen-space points
const visiblePoints = [...citiesWithData.values()].filter(isVisible);
const tree = quadtree<GeoRecord>()
.x(d => projection([d.longitude, d.latitude])![0])
.y(d => projection([d.longitude, d.latitude])![1])
.addAll(visiblePoints);
const out: Cluster[] = [];
// Radius-based neighbor lookup
function neighbors(x: number, y: number) {
const result: GeoRecord[] = [];
const r2 = CLUSTER_RADIUS * CLUSTER_RADIUS;
tree.visit((node, x0, y0, x1, y1) => {
const dx = Math.max(0, x0 - x, x - x1);
const dy = Math.max(0, y0 - y, y - y1);
if (dx * dx + dy * dy > r2) return true;
if (!node.length) {
let d: QuadtreeLeaf<GeoRecord> | undefined = node;
do {
const pt = d.data as GeoRecord;
const [px, py] = projection([pt.longitude, pt.latitude])!;
if ((px - x) ** 2 + (py - y) ** 2 <= r2) result.push(pt);
d = d.next;
} while (d);
}
return false;
});
return result as readonly GeoRecord[];
}
// Build clusters
for (const pt of visiblePoints) {
const [x, y] = projection([pt.longitude, pt.latitude])!;
if (out.some(c => c.members.includes(pt))) continue;
out.push({members: neighbors(x, y), coords: [x, y] as const});
}
return out;
});
// Hover detection for clusters
function checkClusterHover(mx: number, my: number): Cluster | null {
for (const c of clusters) {
if (Math.hypot(c.coords[0] - mx, c.coords[1] - my) <= CLUSTER_RADIUS) {
return c;
}
}
return null;
}
const throttledCheck = throttle(
(x: number, y: number) => {
const tmp = checkClusterHover(x, y);
hoverCluster = tmp ? {
...tmp,
members: [...tmp.members].sort((a, b) =>
a.city.localeCompare(b.city)
)
} : null;
},
16,
{leading: true, trailing: true}
);
function animateGlobe() {
if (autoRotating && !dragging) {
rotation = [rotation[0] + rotationSpeed, rotation[1]];
}
animationFrame = requestAnimationFrame(animateGlobe);
}
// Start animation when component initializes
$effect(() => {
animationFrame = requestAnimationFrame(animateGlobe);
});
// Make sure the `Inter` font is loaded before rendering the points
$effect(() => {
if (document.fonts) {
document.fonts.ready.then(() => {
document.fonts.load('12px Inter').then(() => {
if (componentActive) fontLoaded = true;
});
});
}
});
// Clean up the font loading effect when the component is destroyed
onDestroy(() => {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
if (inactivityTimeout) {
clearTimeout(inactivityTimeout);
inactivityTimeout = null;
}
componentActive = false;
throttledCheck.cancel();
});
function isVisible(point: GeoRecord): boolean {
return geoDistance([point.longitude, point.latitude], center) <= Math.PI / 2;
}
// getCityCount returns the number of reported server per city
const getCityCount = (city: string, country: string) => {
const key = `${city},${country}`;
return cityCount[key] || 1;
};
// Compute the countries with at least one reported server
$effect(() => {
if (data && data.length > 0) {
data.forEach((item: GeoRecord) => {
let country_name = item.country_name;
// Handle the case where the country name is "United States"
// The country name is "United States of America" in the world map
if (country_name === 'United States') {
country_name = 'United States of America'
}
countriesWithData.add(country_name)
citiesWithData.set(`${item.city},${item.country_name}`, item)
})
}
});
// Compute and cache the number of reported server per city
$effect(() => {
if (data && data.length > 0) {
cityCount = data.reduce((acc: Record<string, number>, d: GeoRecord) => {
const key = `${d.city},${d.country_name}`;
acc[key] = (acc[key] || 0) + 1;
return acc;
}, {} as Record<string, number>);
}
})
// An orthographic projection with clipping disabled so we can see the far side of the globe
const projection = $derived(
geoOrthographic()
.fitExtent(
[
[pad, pad],
[width - pad, height - pad],
],
{type: 'Sphere'},
)
.rotate(rotation)
.clipAngle(180)
);
const onDown = (e: Event) => {
dragging = true;
autoRotating = false;
if (e instanceof PointerEvent) {
const mouseEvent = e as PointerEvent;
_x = mouseEvent.clientX;
_y = mouseEvent.clientY;
}
};
const onUp = (_: Event) => {
dragging = false;
if (inactivityTimeout) clearTimeout(inactivityTimeout);
inactivityTimeout = setTimeout(() => {
if (!dragging && !manuallyDisabledRotation) autoRotating = true;
}, inactivityDelay);
};
const onMove = (e: Event) => {
if (e instanceof PointerEvent) {
const mouseEvent = e as PointerEvent;
const rect = (e.target as HTMLCanvasElement).getBoundingClientRect();
const x = mouseEvent.clientX - rect.left;
const y = mouseEvent.clientY - rect.top;
throttledCheck(x, y);
if (hoverCluster) {
tooltipX = mouseEvent.clientX;
tooltipY = mouseEvent.clientY;
}
if (dragging) {
autoRotating = false;
const dx = mouseEvent.clientX - _x;
const dy = mouseEvent.clientY - _y;
const sensitivity = 0.2;
rotation = [
rotation[0] + dx * sensitivity,
Math.max(-85, Math.min(85, rotation[1] - dy * sensitivity)),
];
_x = mouseEvent.clientX;
_y = mouseEvent.clientY;
if (inactivityTimeout) clearTimeout(inactivityTimeout);
inactivityTimeout = setTimeout(() => {
if (!dragging && !manuallyDisabledRotation) autoRotating = true;
}, inactivityDelay);
}
}
};
const path = $derived(geoPath(projection));
const center: [number, number] = $derived([-rotation[0], -rotation[1]])
// Compute which countries are on the far side of the globe
const backCountries = $derived(worldFeatures.filter(d =>
geoDistance(geoCentroid(d), center) > Math.PI / 2
)
);
// Compute which countries are on the visible side of the globe
const frontCountries = $derived(worldFeatures.filter(d =>
geoDistance(geoCentroid(d), center) <= Math.PI / 2
)
);
$effect(() => {
// Reference mode to create the dependency
const _ = $mode;
// Update colors
fillColor = computedColor(getColorFromCSS('--color-surface-600-400'));
strokeColor = computedColor(getColorFromCSS('--color-primary-100-900'));
pointColor = computedColor(getColorFromCSS('--color-tertiary-500'));
});
function handleGlobeRotation() {
autoRotating = !autoRotating;
manuallyDisabledRotation = !manuallyDisabledRotation
}
</script>
<main class="globe-root">
<Canvas layerEvents
onresize={(e) => {
width = e.width;
height = e.height;
}}
onpointerdown={onDown}
onpointerup={onUp}
onpointermove={onMove}
onpointerleave={() => { onUp(new PointerEvent('pointerleave')); hoverCluster = null; }}
>
<!-- The Map -->
<!-- Render the far side of the globe first -->
<Layer
render={({ context }) => {
path.context(context);
context.lineWidth = 0.5;
context.strokeStyle = strokeColor;
backCountries.forEach(country => {
context.fillStyle = countriesWithData.has(country.properties?.name)
? computedColor(getColorFromCSS('--color-primary-600-400'))
: fillColor;
context.beginPath();
path(country);
context.fill();
context.stroke();
});
}}
/>
<!-- The render the visible side of the globe -->
<Layer
render={({ context }) => {
path.context(context);
context.lineWidth = 0.5;
context.strokeStyle = strokeColor;
frontCountries.forEach(country => {
context.fillStyle = countriesWithData.has(country.properties?.name)
? computedColor(getColorFromCSS('--color-primary-600-400'))
: fillColor;
context.beginPath();
path(country);
context.fill();
context.stroke();
});
}}
/>
<!-- Then render one point per city with at least one reported server -->
<Layer
render={({ context }) => {
if (!fontLoaded) return;
context.fillStyle = pointColor;
context.font = '12px Inter';
context.textAlign = 'center';
context.textBaseline = 'top';
clusters.forEach(c => {
const isCluster = c.members.length > 1;
const radius = isCluster ? 7 : 5;
context.beginPath();
context.arc(c.coords[0], c.coords[1], radius, 0, 2*Math.PI);
context.fillStyle = isCluster ? computedColor(getColorFromCSS('--color-tertiary-500')) : pointColor;
context.fill();
context.lineWidth = 1;
context.stroke();
});
}
}
/>
</Canvas>
<div class="absolute bottom-4 left-4 z-10 pointer-events-auto group">
<button type="button" class="btn preset-outlined-primary-800-200" onclick={handleGlobeRotation}
aria-label="Toggle globe rotation">
{#if autoRotating}
<Paused size={24}/>
{:else}
<Play size={24}/>
{/if}
</button>
<!-- Pure CSS tooltip on Canvas -->
<div class="tooltip absolute top-full opacity-0 group-hover:opacity-100 group-hover:delay-1000 transition-opacity pointer-events-none delay-0 z-20"
style:left="{60}px" style:top="{10}px">
<div class="tooltip-content">
Toggle globe rotation
</div>
</div>
</div>
{#if hoverCluster}
<div class="tooltip" style:left="{tooltipX + 10}px" style:top="{tooltipY - 10}px">
<div class="tooltip-content">
{#each hoverCluster.members as cityRec}
<div><b>{cityRec.city}</b>: {getCityCount(cityRec.city, cityRec.country_name)}</div>
{/each}
</div>
</div>
{/if}
</main>
<style>
.globe-root {
position: relative;
width: 100%;
height: 85vh;
touch-action: none;
}
.tooltip {
position: absolute;
background: var(--color-surface-200-800);
border: 1px solid var(--color-primary-500);
border-radius: 4px;
padding: 8px;
pointer-events: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
z-index: 10;
transform: translate(0, -100%);
}
.tooltip-content {
font-size: 14px;
white-space: nowrap;
}
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment