From cfa5c82d749a338636503b1af4f702b5bb719f4f Mon Sep 17 00:00:00 2001 From: James White Date: Sat, 29 Nov 2025 15:47:29 +0000 Subject: [PATCH 1/4] feat(globe): add interactive 3D globe visualization with multiple global metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce a client-only PlanetPulse global metrics globe visualizing CO₂ per capita, renewable share, and GDP per capita. - Implement smooth interactive 3D globe using Three.js and ThreeGlobe with orbital controls and auto-rotate. - Add temporal timeline support (2010 to 2024) for relevant metrics with playback controls. - Provide detailed city information on hover and click, including population and metric snapshots. - Include color-coded legend, status messages, and responsive UI interactions. - Gracefully degrade UI with static fallback if WebGL is unsupported. - Add supporting HTML and CSS for layout, branding, controls, timeline, legend, and detail panels. Co-authored-by: terragon-labs[bot] --- app.js | 403 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 73 ++++++++++ styles.css | 196 ++++++++++++++++++++++++++ 3 files changed, 672 insertions(+) create mode 100644 app.js create mode 100644 index.html create mode 100644 styles.css diff --git a/app.js b/app.js new file mode 100644 index 0000000..048eb43 --- /dev/null +++ b/app.js @@ -0,0 +1,403 @@ +/* + * PlanetPulse architecture (client-only, no build step) + * ---------------------------------------------------- + * data.js section: hard-coded dataset of cities with multiple metrics; METRICS config describes how to read & display each metric. + * scene.js section: builds Three.js renderer, camera, controls, and the ThreeGlobe instance; handles resize, spin loop, and user interaction. + * ui.js section: wires controls (metric select, spin toggle, reset, time slider/playback), legend rendering, status messaging, and detail panel updates. + * resilience: wraps globe creation in try/catch; graceful message if WebGL or assets fail; keeps UI usable with static content. + * trade-offs: uses CDN textures & three-globe for speed; dataset is small for clarity; point-based overlay (no choropleth) to stay performant without build tools. + */ + +// ---------------------- data.js ---------------------- +const TIMELINE_YEARS = [2010, 2015, 2020, 2024]; + +const DATA_POINTS = [ + { name: 'New York, USA', lat: 40.7128, lon: -74.006, co2Series: [15.2, 14.8, 14.1, 13.4], renewableSeries: [24, 26, 32, 38], gdp: 82500, population: 19.6 }, + { name: 'London, UK', lat: 51.5072, lon: -0.1276, co2Series: [6.3, 6.0, 5.8, 5.5], renewableSeries: [30, 35, 44, 52], gdp: 56500, population: 14.3 }, + { name: 'Tokyo, Japan', lat: 35.6764, lon: 139.65, co2Series: [9.8, 9.4, 9.2, 9.1], renewableSeries: [12, 15, 18, 24], gdp: 60500, population: 37.5 }, + { name: 'São Paulo, Brazil', lat: -23.55, lon: -46.633, co2Series: [2.9, 2.8, 2.7, 2.6], renewableSeries: [78, 80, 82, 83], gdp: 21500, population: 22 }, + { name: 'Lagos, Nigeria', lat: 6.5244, lon: 3.3792, co2Series: [0.7, 0.65, 0.62, 0.6], renewableSeries: [30, 32, 34, 36], gdp: 5200, population: 21 }, + { name: 'Cairo, Egypt', lat: 30.0444, lon: 31.2357, co2Series: [2.7, 2.6, 2.55, 2.5], renewableSeries: [9, 10, 11, 12], gdp: 13100, population: 20.9 }, + { name: 'Sydney, Australia', lat: -33.8688, lon: 151.2093, co2Series: [16.2, 15.8, 15.5, 15.2], renewableSeries: [18, 23, 28, 32], gdp: 57500, population: 5.3 }, + { name: 'Toronto, Canada', lat: 43.6532, lon: -79.3832, co2Series: [13.1, 12.9, 12.6, 12.3], renewableSeries: [54, 58, 61, 65], gdp: 63000, population: 6.3 }, + { name: 'Delhi, India', lat: 28.7041, lon: 77.1025, co2Series: [1.6, 1.7, 1.8, 1.9], renewableSeries: [17, 19, 21, 23], gdp: 9300, population: 30.3 }, + { name: 'Paris, France', lat: 48.8566, lon: 2.3522, co2Series: [5.1, 4.9, 4.7, 4.5], renewableSeries: [43, 45, 49, 51], gdp: 56500, population: 11.2 }, + { name: 'Johannesburg, South Africa', lat: -26.2041, lon: 28.0473, co2Series: [8.4, 8.2, 8.0, 7.8], renewableSeries: [7, 8, 10, 11], gdp: 15200, population: 5.8 }, + { name: 'Beijing, China', lat: 39.9042, lon: 116.4074, co2Series: [9.7, 9.4, 9.1, 8.9], renewableSeries: [18, 21, 25, 28], gdp: 33000, population: 21.5 }, + { name: 'Reykjavík, Iceland', lat: 64.1466, lon: -21.9426, co2Series: [7.2, 7.0, 6.9, 6.8], renewableSeries: [97, 98, 99, 99], gdp: 70400, population: 0.13 }, + { name: 'Dubai, UAE', lat: 25.2048, lon: 55.2708, co2Series: [24.2, 24.0, 23.7, 23.5], renewableSeries: [5, 6, 8, 9], gdp: 43000, population: 3.6 } +]; + +const METRICS = { + co2: { + label: 'CO₂ per capita', + unit: 't', + accessor: d => d.co2Series, + description: 'Tons of CO₂ emitted per person annually.', + palette: ['#3b82f6', '#f97316', '#ef4444'], + seriesKey: 'co2Series' + }, + renewable: { + label: 'Renewable share', + unit: '%', + accessor: d => d.renewableSeries, + description: 'Percent of electricity from renewables.', + palette: ['#0ea5e9', '#22c55e', '#84cc16'], + seriesKey: 'renewableSeries' + }, + gdp: { + label: 'GDP per capita', + unit: '$', + accessor: d => d.gdp, + description: 'GDP per person (USD, nominal).', + palette: ['#a855f7', '#f59e0b', '#f97316'] + } +}; + +// ---------------------- utilities ---------------------- +const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); + +function computeExtent(arr, accessor) { + let min = Infinity, max = -Infinity; + arr.forEach(item => { + const val = accessor(item); + if (Number.isFinite(val)) { + min = Math.min(min, val); + max = Math.max(max, val); + } + }); + return [min, max]; +} + +function lerpColor(a, b, t) { + const ca = parseInt(a.slice(1), 16); + const cb = parseInt(b.slice(1), 16); + const ar = (ca >> 16) & 255, ag = (ca >> 8) & 255, ab = ca & 255; + const br = (cb >> 16) & 255, bg = (cb >> 8) & 255, bb = cb & 255; + const r = Math.round(ar + (br - ar) * t); + const g = Math.round(ag + (bg - ag) * t); + const b = Math.round(ab + (bb - ab) * t); + return `rgb(${r}, ${g}, ${b})`; +} + +function makeColorScale(palette) { + return value => { + if (!Number.isFinite(value)) return '#ffffff'; + const [a, b, c] = palette; + const mid = 0.5; + const t = clamp(value, 0, 1); + if (t < mid) return lerpColor(a, b, t / mid); + return lerpColor(b, c, (t - mid) / (1 - mid)); + }; +} + +// ---------------------- scene.js ---------------------- +let renderer, camera, scene, controls, globe; +let autoRotate = true; +let isUserInteracting = false; +let currentMetricKey = 'co2'; +let colorScale = makeColorScale(METRICS[currentMetricKey].palette); +let valueExtent = [0, 1]; +let currentYearIndex = TIMELINE_YEARS.length - 1; +let playTimer = null; + +const globeContainer = document.getElementById('globeContainer'); +const legendEl = document.getElementById('legend'); +const statusEl = document.getElementById('status'); +const selectEl = document.getElementById('metricSelect'); +const spinBtn = document.getElementById('spinToggle'); +const resetBtn = document.getElementById('resetView'); +const playBtn = document.getElementById('timePlay'); +const yearSlider = document.getElementById('yearSlider'); +const yearDisplay = document.getElementById('yearDisplay'); +const detailEl = document.getElementById('detailCard'); +const summaryEl = document.getElementById('metricSummary'); + +yearSlider.max = TIMELINE_YEARS.length - 1; +yearSlider.value = currentYearIndex; + +init(); + +function init() { + populateMetricSelect(); + try { + buildScene(); + applyMetric(currentMetricKey); + animate(); + setStatus('Globe ready.'); + } catch (err) { + console.error(err); + setStatus('Unable to start 3D globe; showing fallback.'); + globeContainer.innerHTML = '
WebGL not available.
'; + } + wireUI(); + updateYearUI(); + window.addEventListener('resize', handleResize); +} + +function buildScene() { + const width = globeContainer.clientWidth || globeContainer.parentElement.clientWidth; + const height = globeContainer.clientHeight || 520; + + renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setSize(width, height); + renderer.setPixelRatio(window.devicePixelRatio); + globeContainer.appendChild(renderer.domElement); + + scene = new THREE.Scene(); + + camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 2000); + camera.position.set(0, 120, 320); + + controls = new THREE.OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.minDistance = 120; + controls.maxDistance = 480; + controls.addEventListener('start', () => { isUserInteracting = true; autoRotate = false; updateSpinButton(); }); + controls.addEventListener('end', () => { isUserInteracting = false; }); + + const ambient = new THREE.AmbientLight(0xffffff, 0.8); + scene.add(ambient); + const dir = new THREE.DirectionalLight(0xffffff, 0.6); + dir.position.set(1, 1, 1); + scene.add(dir); + + globe = new ThreeGlobe({ animateIn: true }) + .globeImageUrl('https://unpkg.com/three-globe/example/img/earth-dark.jpg') + .bumpImageUrl('https://unpkg.com/three-globe/example/img/earth-topology.png') + .atmosphereColor('#6ee7ff') + .atmosphereAltitude(0.18); + + globe + .pointAltitude(d => sizeForValue(getMetricValue(d, METRICS[currentMetricKey]))) + .pointColor(d => colorScale(normalizeValue(getMetricValue(d, METRICS[currentMetricKey])))) + .pointLabel(d => labelForPoint(d)); + + globe.onPointHover(handleHover); + globe.onPointClick(handleClick); + + scene.add(globe); +} + +function animate() { + requestAnimationFrame(animate); + if (autoRotate && globe) globe.rotation.y += 0.0008; + controls?.update(); + renderer?.render(scene, camera); +} + +function handleResize() { + if (!renderer || !camera) return; + const width = globeContainer.clientWidth; + const height = globeContainer.clientHeight || 520; + renderer.setSize(width, height); + camera.aspect = width / height; + camera.updateProjectionMatrix(); +} + +// ---------------------- ui.js ---------------------- +function populateMetricSelect() { + Object.entries(METRICS).forEach(([key, cfg]) => { + const opt = document.createElement('option'); + opt.value = key; + opt.textContent = cfg.label; + selectEl.appendChild(opt); + }); + selectEl.value = currentMetricKey; +} + +function wireUI() { + selectEl.addEventListener('change', e => { + applyMetric(e.target.value); + }); + spinBtn.addEventListener('click', () => { + autoRotate = !autoRotate; + updateSpinButton(); + }); + resetBtn.addEventListener('click', resetView); + playBtn.addEventListener('click', toggleTimelinePlay); + yearSlider.addEventListener('input', () => { + currentYearIndex = Number(yearSlider.value); + stopTimelinePlay(); + updateYearUI(); + refreshPoints(); + }); +} + +function updateSpinButton() { + spinBtn.textContent = autoRotate ? 'Pause Spin' : 'Resume Spin'; +} + +function updateYearUI() { + const year = TIMELINE_YEARS[currentYearIndex]; + yearDisplay.textContent = year; + yearSlider.value = currentYearIndex; + playBtn.textContent = playTimer ? 'Pause' : 'Play'; + const metricCfg = METRICS[currentMetricKey]; + const timelineEl = document.querySelector('.timeline'); + const isTemporal = Boolean(metricCfg.seriesKey); + if (timelineEl) timelineEl.style.display = isTemporal ? 'flex' : 'none'; + yearSlider.disabled = !isTemporal; + playBtn.disabled = !isTemporal; +} + +function toggleTimelinePlay() { + if (playTimer) { + stopTimelinePlay(); + return; + } + playTimer = setInterval(() => { + currentYearIndex = (currentYearIndex + 1) % TIMELINE_YEARS.length; + updateYearUI(); + refreshPoints(); + }, 1300); + updateYearUI(); +} + +function stopTimelinePlay() { + if (!playTimer) return; + clearInterval(playTimer); + playTimer = null; + updateYearUI(); +} + +function resetView() { + controls.reset(); + camera.position.set(0, 120, 320); + autoRotate = true; + updateSpinButton(); +} + +function applyMetric(metricKey) { + currentMetricKey = metricKey; + const cfg = METRICS[metricKey]; + valueExtent = computeExtent(DATA_POINTS, d => getMetricValue(d, cfg)); + colorScale = makeColorScale(cfg.palette); + + globe + ?.pointsData(DATA_POINTS) + .pointColor(d => colorScale(normalizeValue(getMetricValue(d, cfg)))) + .pointAltitude(d => sizeForValue(getMetricValue(d, cfg))); + + updateLegend(); + updateSummary(cfg); + setStatus(`${cfg.label} loaded.`); + updateYearUI(); + if (!cfg.seriesKey) stopTimelinePlay(); +} + +function refreshPoints() { + const cfg = METRICS[currentMetricKey]; + valueExtent = computeExtent(DATA_POINTS, d => getMetricValue(d, cfg)); + globe + ?.pointColor(d => colorScale(normalizeValue(getMetricValue(d, cfg)))) + .pointAltitude(d => sizeForValue(getMetricValue(d, cfg))) + .pointsData(DATA_POINTS); + updateLegend(); + updateSummary(cfg); +} + +function normalizeValue(value) { + const [min, max] = valueExtent; + if (!Number.isFinite(value)) return 0; + if (max === min) return 0.5; + return clamp((value - min) / (max - min), 0, 1); +} + +function sizeForValue(val) { + if (!Number.isFinite(val)) return 0.02; + const t = normalizeValue(val); + return 0.04 + t * 0.08; // keeps points visible but varied +} + +function getMetricValue(d, cfg) { + if (cfg.seriesKey) { + const series = Array.isArray(d[cfg.seriesKey]) ? d[cfg.seriesKey] : []; + return series[currentYearIndex] ?? series[series.length - 1]; + } + return cfg.accessor(d); +} + +function labelForPoint(d) { + const cfg = METRICS[currentMetricKey]; + const val = getMetricValue(d, cfg); + const formatted = cfg.unit === '$' + ? `$${val.toLocaleString('en-US')}` + : `${val}${cfg.unit}`; + const yearPart = cfg.seriesKey ? ` (${TIMELINE_YEARS[currentYearIndex]})` : ''; + return `${d.name}
${cfg.label}${yearPart}: ${formatted}
Population: ${d.population}M`; +} + +function handleHover(point) { + if (!point) { + detailEl.classList.add('muted'); + detailEl.innerHTML = 'Hover or tap a city to inspect.'; + return; + } + setDetail(point, false); +} + +function handleClick(point) { + if (!point) return; + setDetail(point, true); +} + +function setDetail(point, pinned) { + const cfg = METRICS[currentMetricKey]; + const val = getMetricValue(point, cfg); + const formatted = cfg.unit === '$' + ? `$${val.toLocaleString('en-US')}` + : `${val}${cfg.unit}`; + const co2Now = getMetricValue(point, METRICS.co2); + const renNow = getMetricValue(point, METRICS.renewable); + + detailEl.classList.remove('muted'); + detailEl.innerHTML = ` +
+
+
${pinned ? 'Pinned' : 'Hover'} city
+

${point.name}

+
+ ${cfg.label} +
+
Value${cfg.seriesKey ? ' (' + TIMELINE_YEARS[currentYearIndex] + ')' : ''}${formatted}
+
Population${point.population}M
+
Renewables (${TIMELINE_YEARS[currentYearIndex]})${renNow}%
+
CO₂ (${TIMELINE_YEARS[currentYearIndex]})${co2Now}t
+
GDP per capita$${point.gdp.toLocaleString('en-US')}
+ `; +} + +function updateLegend() { + const [min, max] = valueExtent; + const mid = (min + max) / 2; + const cfg = METRICS[currentMetricKey]; + const year = cfg.seriesKey ? ` (${TIMELINE_YEARS[currentYearIndex]})` : ''; + legendEl.innerHTML = ` +
Legend
+
${cfg.label}${year}
+
+ ${formatValue(min, cfg.unit)} +
+ ${formatValue(max, cfg.unit)} +
+
Median ~ ${formatValue(mid, cfg.unit)}
+ `; +} + +function updateSummary(cfg) { + const [min, max] = valueExtent; + const year = cfg.seriesKey ? ` (${TIMELINE_YEARS[currentYearIndex]})` : ''; + summaryEl.innerHTML = `${cfg.description}${year}
Range: ${formatValue(min, cfg.unit)} – ${formatValue(max, cfg.unit)}`; +} + +function formatValue(val, unit) { + if (!Number.isFinite(val)) return 'n/a'; + if (unit === '$') return `$${Math.round(val).toLocaleString('en-US')}`; + return `${Math.round(val * 10) / 10}${unit}`; +} + +function setStatus(msg) { + statusEl.textContent = msg; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..8699fe3 --- /dev/null +++ b/index.html @@ -0,0 +1,73 @@ + + + + + + PlanetPulse – Global Metrics Globe + + + + + + + + + + +
+
+
+
+

PlanetPulse

+

Global Metrics Globe

+
+
+
+ + + +
+
+ +
+
+
+
+
+ +
+ + +
+ 2024 +
+
+
+ +
+ + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..5c2dbbd --- /dev/null +++ b/styles.css @@ -0,0 +1,196 @@ +:root { + --bg: #0c1628; + --panel: #0f1f36; + --card: #132742; + --accent: #6ee7ff; + --accent-2: #ff9f1c; + --text: #e6eef7; + --muted: #9bb0cb; + --border: rgba(255, 255, 255, 0.08); + --shadow: 0 20px 40px rgba(0, 0, 0, 0.35); + --radius: 16px; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: radial-gradient(circle at 20% 20%, rgba(110, 231, 255, 0.08), transparent 35%), + radial-gradient(circle at 80% 10%, rgba(255, 159, 28, 0.08), transparent 40%), + var(--bg); + color: var(--text); + font-family: 'Space Grotesk', system-ui, -apple-system, sans-serif; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + background: rgba(12, 22, 40, 0.6); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} + +.brand { display: flex; gap: 12px; align-items: center; } +.glow-dot { + width: 14px; height: 14px; border-radius: 50%; + background: radial-gradient(circle, var(--accent) 0%, #0c1628 70%); + box-shadow: 0 0 12px var(--accent); +} +.eyebrow { margin: 0; color: var(--muted); font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; } +h1 { margin: 2px 0 0; font-size: 22px; letter-spacing: -0.02em; } + +.controls { display: flex; gap: 12px; align-items: center; } +.control { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); } +.control select { + background: var(--card); + color: var(--text); + border: 1px solid var(--border); + padding: 8px 12px; + border-radius: 10px; + font-size: 14px; +} +.control-btn, .ghost-btn { + border-radius: 10px; + padding: 10px 14px; + border: 1px solid var(--border); + color: var(--text); + cursor: pointer; + background: linear-gradient(120deg, rgba(110, 231, 255, 0.2), rgba(110, 231, 255, 0.05)); + transition: transform 160ms ease, box-shadow 160ms ease; +} +.control-btn.sm { padding: 8px 10px; font-size: 13px; } +.ghost-btn { background: transparent; } +.control-btn:hover, .ghost-btn:hover { transform: translateY(-1px); box-shadow: 0 10px 20px rgba(0,0,0,0.25); } + +.layout { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 16px; + padding: 16px 24px 32px; + flex: 1; + min-height: 0; +} + +.globe-panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + position: relative; + overflow: hidden; + box-shadow: var(--shadow); + min-height: 480px; +} +#globeContainer { width: 100%; height: 100%; } +.fallback { + width: 100%; height: 100%; display: grid; place-items: center; + color: var(--muted); font-size: 14px; +} + +.timeline { + position: absolute; + left: 16px; + top: 16px; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: rgba(12,22,40,0.8); + border: 1px solid var(--border); + border-radius: 12px; + backdrop-filter: blur(6px); + font-size: 13px; +} +.slider-wrap { display: flex; flex-direction: column; gap: 6px; } +.slider-wrap input[type=range] { + width: 160px; + accent-color: var(--accent); +} +.year-labels { display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; } + + +.legend { + position: absolute; + left: 16px; + bottom: 16px; + padding: 10px 12px; + background: rgba(12, 22, 40, 0.85); + border: 1px solid var(--border); + border-radius: 12px; + font-size: 12px; + color: var(--muted); + backdrop-filter: blur(6px); +} +.legend-scale { display: flex; align-items: center; gap: 8px; margin-top: 8px; } +.legend-bar { + height: 10px; + width: 140px; + border-radius: 999px; + background: linear-gradient(90deg, #3b82f6, #f97316, #ef4444); + border: 1px solid var(--border); +} + +.status { + position: absolute; + right: 16px; + bottom: 16px; + background: rgba(0,0,0,0.45); + color: var(--muted); + padding: 8px 10px; + border-radius: 10px; + font-size: 12px; + border: 1px solid var(--border); +} + +.info-panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: 12px; +} +.info-panel h2 { margin: 0 0 4px; } + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 14px; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); +} +.muted { color: var(--muted); } +.label { color: var(--muted); font-size: 12px; letter-spacing: 0.05em; text-transform: uppercase; margin: 0 0 6px; } +.metric-summary { font-size: 14px; line-height: 1.5; } +.list { margin: 0; padding-left: 16px; color: var(--text); line-height: 1.5; } + +.mini-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; } + +.detail-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; } +.pill { padding: 4px 8px; background: rgba(110, 231, 255, 0.12); border-radius: 999px; font-size: 12px; color: var(--accent); border: 1px solid var(--border); } +.data-row { display: flex; justify-content: space-between; margin: 6px 0; color: var(--muted); } +.data-row span:last-child { color: var(--text); } + +@media (max-width: 900px) { + .layout { grid-template-columns: 1fr; } + .globe-panel { min-height: 360px; } + .topbar { flex-direction: column; align-items: flex-start; gap: 12px; } + .controls { width: 100%; flex-wrap: wrap; } + .timeline { top: auto; bottom: 12px; left: 12px; right: 12px; justify-content: space-between; } + .slider-wrap input[type=range] { width: 120px; } +} + +@media (max-width: 480px) { + body { padding-bottom: 16px; } + .topbar { position: sticky; } + .legend { font-size: 11px; } +} From 6972d3e4bc1a98b2a8d06a053c8bb0891dc12e8c Mon Sep 17 00:00:00 2001 From: James White Date: Sat, 29 Nov 2025 15:51:52 +0000 Subject: [PATCH 2/4] feat(ui): add disabled styles for control and ghost buttons - Added styles to reduce opacity, disable cursor, and remove box-shadow when .control-btn and .ghost-btn are disabled - Added extent computation improvements for edge cases - Enhanced refreshPoints function to update status with series label and year Co-authored-by: terragon-labs[bot] --- app.js | 3 +++ styles.css | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/app.js b/app.js index 048eb43..43cd8f4 100644 --- a/app.js +++ b/app.js @@ -66,6 +66,8 @@ function computeExtent(arr, accessor) { max = Math.max(max, val); } }); + if (min === Infinity || max === -Infinity) return [0, 1]; + if (min === max) return [min - 0.5, max + 0.5]; return [min, max]; } @@ -296,6 +298,7 @@ function refreshPoints() { .pointsData(DATA_POINTS); updateLegend(); updateSummary(cfg); + if (cfg.seriesKey) setStatus(`${cfg.label}: ${TIMELINE_YEARS[currentYearIndex]}`); } function normalizeValue(value) { diff --git a/styles.css b/styles.css index 5c2dbbd..bdf1117 100644 --- a/styles.css +++ b/styles.css @@ -66,6 +66,11 @@ h1 { margin: 2px 0 0; font-size: 22px; letter-spacing: -0.02em; } background: linear-gradient(120deg, rgba(110, 231, 255, 0.2), rgba(110, 231, 255, 0.05)); transition: transform 160ms ease, box-shadow 160ms ease; } +.control-btn:disabled, .ghost-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + box-shadow: none; +} .control-btn.sm { padding: 8px 10px; font-size: 13px; } .ghost-btn { background: transparent; } .control-btn:hover, .ghost-btn:hover { transform: translateY(-1px); box-shadow: 0 10px 20px rgba(0,0,0,0.25); } From 685905efb25a0a5aea17a559ed3f026a7c5001ef Mon Sep 17 00:00:00 2001 From: James White Date: Sun, 30 Nov 2025 15:31:38 +0000 Subject: [PATCH 3/4] fix(deps): switch CDN to jsDelivr and add Three.js load checks Replaced unpkg CDN URLs with jsDelivr for three.js, OrbitControls, and three-globe scripts and assets to improve reliability and loading performance. Added runtime checks in app.js to verify three.js and ThreeGlobe are loaded before building the scene, throwing an explicit error if missing. Co-authored-by: terragon-labs[bot] --- app.js | 7 +++++-- index.html | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app.js b/app.js index 43cd8f4..63c9efe 100644 --- a/app.js +++ b/app.js @@ -123,6 +123,9 @@ init(); function init() { populateMetricSelect(); try { + if (typeof THREE === 'undefined' || typeof ThreeGlobe === 'undefined') { + throw new Error('Three.js or ThreeGlobe not loaded'); + } buildScene(); applyMetric(currentMetricKey); animate(); @@ -166,8 +169,8 @@ function buildScene() { scene.add(dir); globe = new ThreeGlobe({ animateIn: true }) - .globeImageUrl('https://unpkg.com/three-globe/example/img/earth-dark.jpg') - .bumpImageUrl('https://unpkg.com/three-globe/example/img/earth-topology.png') + .globeImageUrl('https://cdn.jsdelivr.net/npm/three-globe/example/img/earth-dark.jpg') + .bumpImageUrl('https://cdn.jsdelivr.net/npm/three-globe/example/img/earth-topology.png') .atmosphereColor('#6ee7ff') .atmosphereAltitude(0.18); diff --git a/index.html b/index.html index 8699fe3..4cda64d 100644 --- a/index.html +++ b/index.html @@ -9,9 +9,9 @@ - - - + + +
From 1e317e08f20a35b0193b5a48ce53f182ea942f65 Mon Sep 17 00:00:00 2001 From: James White Date: Sun, 30 Nov 2025 15:46:15 +0000 Subject: [PATCH 4/4] refactor(threejs): migrate OrbitControls to ES module import - Import OrbitControls from three.js ES module instead of global script - Update three.js and three-globe to compatible CDN versions - Load app.js as a module in HTML script tag This modernizes the codebase by using ES module imports, improving tree shaking and maintainability. Co-authored-by: terragon-labs[bot] --- app.js | 4 +++- index.html | 7 +++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app.js b/app.js index 63c9efe..8ae0590 100644 --- a/app.js +++ b/app.js @@ -8,6 +8,8 @@ * trade-offs: uses CDN textures & three-globe for speed; dataset is small for clarity; point-based overlay (no choropleth) to stay performant without build tools. */ +import { OrbitControls } from 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js'; + // ---------------------- data.js ---------------------- const TIMELINE_YEARS = [2010, 2015, 2020, 2024]; @@ -154,7 +156,7 @@ function buildScene() { camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 2000); camera.position.set(0, 120, 320); - controls = new THREE.OrbitControls(camera, renderer.domElement); + controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.minDistance = 120; diff --git a/index.html b/index.html index 4cda64d..67a9e81 100644 --- a/index.html +++ b/index.html @@ -9,9 +9,8 @@ - - - + +
@@ -68,6 +67,6 @@

Details

- +