Planet Explorer
by ArcadeLab609 lines18.6 KB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Planet Explorer</title>
<style>
:root{
--bg:#070b18;
--panel:#0f1730;
--panel-2:#16213f;
--ink:#e8edff;
--muted:#8b96bd;
--accent:#5b8cff;
--line:#243156;
}
*{box-sizing:border-box;}
html,body{margin:0;height:100%;overflow:hidden;background:var(--bg);}
body{
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
color:var(--ink);
-webkit-font-smoothing:antialiased;
-webkit-tap-highlight-color:transparent;
}
#app{
display:flex;
flex-direction:column;
height:100%;
width:100%;
}
header{
flex:0 0 auto;
padding:14px clamp(12px,3vw,28px) 10px;
background:linear-gradient(180deg,#0c1330 0%,rgba(12,19,48,0) 100%);
}
header h1{
margin:0;
font-size:clamp(18px,2.6vw,24px);
letter-spacing:.2px;
display:flex;
align-items:center;
gap:9px;
}
header p{
margin:4px 0 0;
font-size:clamp(12px,1.6vw,13.5px);
color:var(--muted);
}
#controls{
flex:0 0 auto;
display:flex;
flex-wrap:wrap;
gap:10px;
padding:6px clamp(12px,3vw,28px) 12px;
}
.ctl{
display:flex;
flex-direction:column;
gap:4px;
min-width:0;
flex:1 1 150px;
}
.ctl label{
font-size:11px;
text-transform:uppercase;
letter-spacing:.7px;
color:var(--muted);
font-weight:600;
}
select{
appearance:none;
-webkit-appearance:none;
background:var(--panel-2);
color:var(--ink);
border:1px solid var(--line);
border-radius:12px;
padding:10px 32px 10px 12px;
font-size:15px;
font-family:inherit;
cursor:pointer;
width:100%;
background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="8" viewBox="0 0 12 8"><path d="M1 1l5 5 5-5" stroke="%238b96bd" stroke-width="2" fill="none" stroke-linecap="round"/></svg>');
background-repeat:no-repeat;
background-position:right 12px center;
transition:border-color .15s ease;
}
select:focus{outline:none;border-color:var(--accent);}
#stage{
position:relative;
flex:1 1 auto;
margin:0 clamp(8px,2vw,20px) clamp(8px,2vw,18px);
background:var(--panel);
border:1px solid var(--line);
border-radius:16px;
overflow:hidden;
touch-action:none;
}
#chart{display:block;width:100%;height:100%;}
#tooltip{
position:absolute;
pointer-events:none;
background:rgba(8,12,28,.96);
border:1px solid var(--line);
border-radius:14px;
padding:11px 13px;
font-size:12.5px;
line-height:1.55;
width:200px;
box-shadow:0 12px 34px rgba(0,0,0,.55);
opacity:0;
transform:translateY(4px);
transition:opacity .14s ease,transform .14s ease;
z-index:5;
}
#tooltip.show{opacity:1;transform:translateY(0);}
#tooltip .tt-name{
font-size:14.5px;
font-weight:700;
display:flex;
align-items:center;
gap:7px;
margin-bottom:6px;
}
#tooltip .tt-dot{
width:11px;height:11px;border-radius:50%;
flex:0 0 auto;
box-shadow:0 0 8px currentColor;
}
#tooltip .tt-row{display:flex;justify-content:space-between;gap:10px;}
#tooltip .tt-row span:first-child{color:var(--muted);}
#tooltip .tt-row span:last-child{font-variant-numeric:tabular-nums;}
#legend{
position:absolute;
right:12px;
top:12px;
background:rgba(8,12,28,.78);
border:1px solid var(--line);
border-radius:12px;
padding:8px 10px;
font-size:11px;
color:var(--muted);
max-width:170px;
backdrop-filter:blur(3px);
}
#legend .lg-title{
text-transform:uppercase;
letter-spacing:.6px;
font-weight:600;
margin-bottom:5px;
color:var(--ink);
font-size:10.5px;
}
#legend .lg-bubbles{
display:flex;
align-items:flex-end;
gap:8px;
margin-top:2px;
}
#legend .lg-item{display:flex;flex-direction:column;align-items:center;gap:3px;}
#legend .lg-circle{
border-radius:50%;
border:1.5px solid var(--accent);
background:rgba(91,140,255,.18);
}
@media (max-width:430px){
#legend{font-size:10px;max-width:138px;padding:7px 8px;}
.ctl{flex:1 1 calc(50% - 5px);}
}
</style>
</head>
<body>
<div id="app">
<header>
<h1>🔭 Planet Explorer</h1>
<p>Pick what each axis and bubble size means — then hover or tap a planet for its stats.</p>
</header>
<div id="controls">
<div class="ctl">
<label for="xSel">X axis</label>
<select id="xSel"></select>
</div>
<div class="ctl">
<label for="ySel">Y axis</label>
<select id="ySel"></select>
</div>
<div class="ctl">
<label for="sSel">Bubble size</label>
<select id="sSel"></select>
</div>
</div>
<div id="stage">
<canvas id="chart"></canvas>
<div id="tooltip"></div>
<div id="legend">
<div class="lg-title" id="lgTitle">Bubble size</div>
<div class="lg-bubbles" id="lgBubbles"></div>
</div>
</div>
</div>
<script>
(function(){
"use strict";
// --- Dataset: the eight planets (real, accurate values) ---
var PLANETS = [
{name:"Mercury", emoji:"☿", color:"#b9a78f", diameter:4879, distance:57.9, moons:0, period:0.24, mass:0.055},
{name:"Venus", emoji:"♀", color:"#e8c87a", diameter:12104, distance:108.2, moons:0, period:0.62, mass:0.815},
{name:"Earth", emoji:"🌍", color:"#5b9bd5", diameter:12742, distance:149.6, moons:1, period:1.00, mass:1.000},
{name:"Mars", emoji:"♂", color:"#e2725b", diameter:6779, distance:227.9, moons:2, period:1.88, mass:0.107},
{name:"Jupiter", emoji:"🪐", color:"#d9a066", diameter:139820, distance:778.5, moons:95, period:11.86, mass:317.8},
{name:"Saturn", emoji:"🪐", color:"#e6cf99", diameter:116460, distance:1432.0, moons:146,period:29.46, mass:95.2},
{name:"Uranus", emoji:"🌐", color:"#84d2e3", diameter:50724, distance:2867.0, moons:28, period:84.01, mass:14.5},
{name:"Neptune", emoji:"🌐", color:"#4f74d6", diameter:49244, distance:4515.0, moons:16, period:164.8, mass:17.1}
];
// --- Axis metric definitions ---
var METRICS = {
distance:{label:"Distance from Sun", unit:"M km", key:"distance", fmt:function(v){return fmtNum(v)+" M km";}},
diameter:{label:"Diameter", unit:"km", key:"diameter", fmt:function(v){return fmtNum(v)+" km";}},
moons: {label:"Number of moons", unit:"moons", key:"moons", fmt:function(v){var n=Math.round(v);return n+(n===1?" moon":" moons");}},
period: {label:"Orbital period", unit:"Earth yrs", key:"period", fmt:function(v){return fmtNum(v)+" yr";}},
mass: {label:"Mass", unit:"Earth = 1", key:"mass", fmt:function(v){return fmtNum(v)+"×";}}
};
var METRIC_ORDER = ["distance","diameter","moons","period","mass"];
function fmtNum(v){
if(v >= 1000) return Math.round(v).toLocaleString("en-US");
if(v >= 100) return Math.round(v).toString();
if(v >= 10) return v.toFixed(1).replace(/\.0$/,"");
return v.toFixed(2).replace(/\.?0+$/,"");
}
var canvas = document.getElementById("chart");
var ctx = canvas.getContext("2d");
if(!ctx){ return; }
var xSel = document.getElementById("xSel");
var ySel = document.getElementById("ySel");
var sSel = document.getElementById("sSel");
var tooltip = document.getElementById("tooltip");
var stage = document.getElementById("stage");
var lgTitle = document.getElementById("lgTitle");
var lgBubbles = document.getElementById("lgBubbles");
// populate dropdowns
METRIC_ORDER.forEach(function(k){
[xSel,ySel,sSel].forEach(function(sel){
var o = document.createElement("option");
o.value = k; o.textContent = METRICS[k].label;
sel.appendChild(o);
});
});
xSel.value = "distance";
ySel.value = "diameter";
sSel.value = "moons";
// state
var W = 0, H = 0, DPR = 1;
var PAD = {l:62, r:24, t:22, b:48};
var hoverIndex = -1;
// each planet keeps animated current values + target values
var nodes = PLANETS.map(function(){ return {cx:0,cy:0,cr:0, tx:0,ty:0,tr:0, intro:0}; });
function metricExtent(key){
var min = Infinity, max = -Infinity;
for(var i=0;i<PLANETS.length;i++){
var v = PLANETS[i][key];
if(v < min) min = v;
if(v > max) max = v;
}
return {min:min, max:max};
}
// place a value on a log-friendly scale (handles wide ranges + zero moons)
function scaleVal(v, ext){
var lo = Math.log10(ext.min + 1);
var hi = Math.log10(ext.max + 1);
if(hi - lo < 1e-9) return 0.5;
return (Math.log10(v + 1) - lo) / (hi - lo);
}
function computeTargets(){
var xm = METRICS[xSel.value], ym = METRICS[ySel.value], sm = METRICS[sSel.value];
var xe = metricExtent(xm.key), ye = metricExtent(ym.key), se = metricExtent(sm.key);
var minR = Math.max(8, Math.min(W,H)*0.022);
var maxR = Math.max(minR+6, Math.min(W,H)*0.072);
// Inset the placement region by the largest bubble radius so no bubble or
// its label ever clips against the chart edges.
var m = maxR + 6;
var plotW = Math.max(10, W - PAD.l - PAD.r - m*2);
var plotH = Math.max(10, H - PAD.t - PAD.b - m*2);
for(var i=0;i<PLANETS.length;i++){
var p = PLANETS[i];
var fx = scaleVal(p[xm.key], xe);
var fy = scaleVal(p[ym.key], ye);
var fs = scaleVal(p[sm.key], se);
nodes[i].tx = PAD.l + m + fx * plotW;
nodes[i].ty = PAD.t + m + (1 - fy) * plotH;
nodes[i].tr = minR + fs * (maxR - minR);
}
}
function resize(){
var rect = stage.getBoundingClientRect();
var cw = Math.max(1, rect.width);
var ch = Math.max(1, rect.height);
DPR = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(cw * DPR);
canvas.height = Math.round(ch * DPR);
canvas.style.width = cw + "px";
canvas.style.height = ch + "px";
W = cw; H = ch;
ctx.setTransform(DPR,0,0,DPR,0,0);
// tighter padding on narrow screens
PAD.l = W < 460 ? 50 : 62;
PAD.b = W < 460 ? 42 : 48;
computeTargets();
}
// --- drawing ---
function niceTicks(ext, count){
// produce evenly spaced fractions 0..1 with mapped values for a log scale
var ticks = [];
for(var i=0;i<=count;i++){
var f = i/count;
var lo = Math.log10(ext.min + 1), hi = Math.log10(ext.max + 1);
var val = Math.pow(10, lo + f*(hi-lo)) - 1;
ticks.push({f:f, val:val});
}
return ticks;
}
function drawGrid(){
var xm = METRICS[xSel.value], ym = METRICS[ySel.value];
var xe = metricExtent(xm.key), ye = metricExtent(ym.key);
var plotW = W - PAD.l - PAD.r;
var plotH = H - PAD.t - PAD.b;
ctx.lineWidth = 1;
ctx.font = "11px system-ui,-apple-system,sans-serif";
ctx.textBaseline = "middle";
// horizontal grid + Y labels
var yt = niceTicks(ye, 4);
ctx.textAlign = "right";
for(var i=0;i<yt.length;i++){
var y = PAD.t + (1 - yt[i].f) * plotH;
ctx.strokeStyle = "rgba(255,255,255,0.055)";
ctx.beginPath();
ctx.moveTo(PAD.l, y); ctx.lineTo(W - PAD.r, y);
ctx.stroke();
ctx.fillStyle = "#5e6a92";
ctx.fillText(shortLabel(yt[i].val), PAD.l - 8, y);
}
// vertical grid + X labels
var xt = niceTicks(xe, W < 460 ? 3 : 4);
ctx.textAlign = "center";
ctx.textBaseline = "top";
for(var j=0;j<xt.length;j++){
var x = PAD.l + xt[j].f * plotW;
ctx.strokeStyle = "rgba(255,255,255,0.055)";
ctx.beginPath();
ctx.moveTo(x, PAD.t); ctx.lineTo(x, H - PAD.b);
ctx.stroke();
ctx.fillStyle = "#5e6a92";
ctx.fillText(shortLabel(xt[j].val), x, H - PAD.b + 8);
}
// axis frame
ctx.strokeStyle = "rgba(255,255,255,0.14)";
ctx.beginPath();
ctx.moveTo(PAD.l, PAD.t); ctx.lineTo(PAD.l, H - PAD.b); ctx.lineTo(W - PAD.r, H - PAD.b);
ctx.stroke();
// axis titles
ctx.fillStyle = "#aab4d9";
ctx.font = "600 12px system-ui,-apple-system,sans-serif";
ctx.textAlign = "center"; ctx.textBaseline = "alphabetic";
ctx.fillText(xm.label + " (" + xm.unit + ")", PAD.l + plotW/2, H - 12);
ctx.save();
ctx.translate(15, PAD.t + plotH/2);
ctx.rotate(-Math.PI/2);
ctx.fillText(ym.label + " (" + ym.unit + ")", 0, 0);
ctx.restore();
}
function shortLabel(v){
if(v >= 1000000) return (v/1000000).toFixed(1).replace(/\.0$/,"")+"M";
if(v >= 1000) return (v/1000).toFixed(v>=10000?0:1).replace(/\.0$/,"")+"k";
if(v >= 10) return Math.round(v).toString();
if(v >= 1) return v.toFixed(1).replace(/\.0$/,"");
return v.toFixed(2).replace(/\.?0+$/,"");
}
function hexToRgb(h){
var n = parseInt(h.slice(1),16);
return [(n>>16)&255,(n>>8)&255,n&255];
}
function drawBubble(n, p, i){
var rgb = hexToRgb(p.color);
var isHover = (i === hoverIndex);
var r = n.cr * n.intro;
if(r < 0.5) return;
// glow
var glow = ctx.createRadialGradient(n.cx,n.cy,r*0.2, n.cx,n.cy,r*1.9);
glow.addColorStop(0,"rgba("+rgb[0]+","+rgb[1]+","+rgb[2]+","+(isHover?0.4:0.22)+")");
glow.addColorStop(1,"rgba("+rgb[0]+","+rgb[1]+","+rgb[2]+",0)");
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(n.cx,n.cy,r*1.9,0,Math.PI*2);
ctx.fill();
// body — gradient sphere
var g = ctx.createRadialGradient(
n.cx - r*0.35, n.cy - r*0.35, r*0.1,
n.cx, n.cy, r
);
g.addColorStop(0,"rgba("+Math.min(255,rgb[0]+70)+","+Math.min(255,rgb[1]+70)+","+Math.min(255,rgb[2]+70)+",1)");
g.addColorStop(1,"rgba("+rgb[0]+","+rgb[1]+","+rgb[2]+",1)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(n.cx,n.cy,r,0,Math.PI*2);
ctx.fill();
// rim
ctx.lineWidth = isHover ? 2.5 : 1.2;
ctx.strokeStyle = isHover ? "#ffffff" : "rgba(255,255,255,0.35)";
ctx.stroke();
// label
ctx.fillStyle = isHover ? "#ffffff" : "#cdd5f0";
ctx.font = (isHover?"700 ":"600 ") + Math.max(10, Math.min(13, r*0.55)) + "px system-ui,-apple-system,sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillText(p.name, n.cx, n.cy - r - 4);
}
// legend bubbles reflect the current size metric extent
function refreshLegend(){
var sm = METRICS[sSel.value];
var se = metricExtent(sm.key);
lgTitle.textContent = "Bubble size: " + sm.label;
lgBubbles.innerHTML = "";
var samples = [
{f:0.0, label:sm.fmt(se.min)},
{f:0.55,label:sm.fmt(se.min + (se.max-se.min)*0.35)},
{f:1.0, label:sm.fmt(se.max)}
];
samples.forEach(function(s){
var item = document.createElement("div");
item.className = "lg-item";
var c = document.createElement("div");
c.className = "lg-circle";
var d = 9 + s.f*16;
c.style.width = d + "px";
c.style.height = d + "px";
var t = document.createElement("div");
t.textContent = s.label;
item.appendChild(c); item.appendChild(t);
lgBubbles.appendChild(item);
});
}
// --- animation loop ---
var lastT = 0;
function frame(t){
if(!lastT) lastT = t;
var dt = Math.min(0.05, (t - lastT)/1000);
lastT = t;
ctx.clearRect(0,0,W,H);
// subtle background field
var bg = ctx.createLinearGradient(0,0,0,H);
bg.addColorStop(0,"#0f1730");
bg.addColorStop(1,"#0c1228");
ctx.fillStyle = bg;
ctx.fillRect(0,0,W,H);
drawGrid();
// ease nodes toward targets, draw small bubbles last so big ones don't hide them
var order = [];
for(var i=0;i<nodes.length;i++){
var n = nodes[i];
var k = 1 - Math.pow(0.0025, dt); // smooth critically-damped-ish lerp
n.cx += (n.tx - n.cx) * k;
n.cy += (n.ty - n.cy) * k;
n.cr += (n.tr - n.cr) * k;
n.intro += (1 - n.intro) * (1 - Math.pow(0.02, dt));
if(n.intro > 0.999) n.intro = 1;
order.push(i);
}
order.sort(function(a,b){ return nodes[b].cr - nodes[a].cr; });
for(var j=0;j<order.length;j++){
var idx = order[j];
drawBubble(nodes[idx], PLANETS[idx], idx);
}
requestAnimationFrame(frame);
}
// --- interaction ---
function pickPlanet(px, py){
var best = -1, bestD = Infinity;
for(var i=0;i<nodes.length;i++){
var n = nodes[i];
var dx = px - n.cx, dy = py - n.cy;
var d = Math.sqrt(dx*dx + dy*dy);
var hitR = Math.max(n.cr * n.intro, 14) + 4;
if(d < hitR && d < bestD){ bestD = d; best = i; }
}
return best;
}
function showTooltip(i, px, py){
var p = PLANETS[i];
var rows = [
["Diameter", fmtNum(p.diameter) + " km"],
["Distance from Sun", fmtNum(p.distance) + " M km"],
["Moons", p.moons],
["Orbital period", fmtNum(p.period) + " Earth yr"],
["Mass", fmtNum(p.mass) + " × Earth"]
];
var html = '<div class="tt-name"><span class="tt-dot" style="background:'+p.color+';color:'+p.color+'"></span>'+
p.emoji+' '+p.name+'</div>';
rows.forEach(function(r){
html += '<div class="tt-row"><span>'+r[0]+'</span><span>'+r[1]+'</span></div>';
});
tooltip.innerHTML = html;
tooltip.classList.add("show");
// position, keep inside stage
var tw = 200, th = tooltip.offsetHeight || 150;
var x = px + 16, y = py - th/2;
if(x + tw > W - 6) x = px - tw - 16;
if(x < 6) x = 6;
if(y < 6) y = 6;
if(y + th > H - 6) y = H - th - 6;
tooltip.style.left = x + "px";
tooltip.style.top = y + "px";
}
function hideTooltip(){
tooltip.classList.remove("show");
}
function localPos(e){
var rect = stage.getBoundingClientRect();
return {x: e.clientX - rect.left, y: e.clientY - rect.top};
}
stage.addEventListener("pointermove", function(e){
var pos = localPos(e);
var hit = pickPlanet(pos.x, pos.y);
hoverIndex = hit;
if(hit >= 0){
showTooltip(hit, pos.x, pos.y);
stage.style.cursor = "pointer";
} else {
hideTooltip();
stage.style.cursor = "default";
}
});
stage.addEventListener("pointerdown", function(e){
e.preventDefault();
var pos = localPos(e);
var hit = pickPlanet(pos.x, pos.y);
hoverIndex = hit;
if(hit >= 0) showTooltip(hit, pos.x, pos.y);
else hideTooltip();
});
stage.addEventListener("pointerleave", function(){
hoverIndex = -1;
hideTooltip();
});
function onAxisChange(){
computeTargets();
refreshLegend();
}
xSel.addEventListener("change", onAxisChange);
ySel.addEventListener("change", onAxisChange);
sSel.addEventListener("change", onAxisChange);
window.addEventListener("resize", function(){
resize();
});
// --- boot ---
resize();
refreshLegend();
// start nodes at their targets' x but collapsed for a tasteful intro
for(var i=0;i<nodes.length;i++){
nodes[i].cx = nodes[i].tx;
nodes[i].cy = nodes[i].ty;
nodes[i].cr = 0;
nodes[i].intro = 0;
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
Game Source: Planet Explorer
Creator: ArcadeLab
Libraries: none
Complexity: complex (609 lines, 18.6 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: planet-explorer-arcadelab" to link back to the original. Then publish at arcadelab.ai/publish.