diff --git a/app.js b/app.js new file mode 100644 index 0000000..8ae0590 --- /dev/null +++ b/app.js @@ -0,0 +1,411 @@ +/* + * 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. + */ + +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]; + +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); + } + }); + if (min === Infinity || max === -Infinity) return [0, 1]; + if (min === max) return [min - 0.5, max + 0.5]; + 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 { + if (typeof THREE === 'undefined' || typeof ThreeGlobe === 'undefined') { + throw new Error('Three.js or ThreeGlobe not loaded'); + } + 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 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://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); + + 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); + if (cfg.seriesKey) setStatus(`${cfg.label}: ${TIMELINE_YEARS[currentYearIndex]}`); +} + +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..67a9e81 --- /dev/null +++ b/index.html @@ -0,0 +1,72 @@ + + + + + + PlanetPulse – Global Metrics Globe + + + + + + + + + +
+
+
+
+

PlanetPulse

+

Global Metrics Globe

+
+
+
+ + + +
+
+ +
+
+
+
+
+ +
+ + +
+ 2024 +
+
+
+ +
+ + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..bdf1117 --- /dev/null +++ b/styles.css @@ -0,0 +1,201 @@ +: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: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); } + +.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; } +}