🎮ArcadeLab

Planet Explorer

by ArcadeLab
609 lines18.6 KB
▶ Play
<!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.