/* App.jsx — Viewer using hand-edited snapshots_v2 geometry +
   area-weighted precomputed populations (see precompute_populations.py). */

const PALETTE = {
  AL:"oklch(0.74 0.10 80.3)",AK:"oklch(0.70 0.10 52.5)",AZ:"oklch(0.74 0.10 72.6)",
  AR:"oklch(0.72 0.10 145.2)",CA:"oklch(0.74 0.10 275.0)",CO:"oklch(0.72 0.10 295.1)",
  CT:"oklch(0.74 0.10 290.4)",DE:"oklch(0.70 0.10 15.3)",FL:"oklch(0.72 0.10 355.3)",
  GA:"oklch(0.70 0.10 217.8)",HI:"oklch(0.72 0.10 190.0)",ID:"oklch(0.70 0.10 105.0)",
  IL:"oklch(0.72 0.10 197.7)",IN:"oklch(0.72 0.10 250.2)",IA:"oklch(0.74 0.10 230.2)",
  KS:"oklch(0.72 0.10 40.1)",KY:"oklch(0.70 0.10 165.2)",LA:"oklch(0.74 0.10 282.7)",
  ME:"oklch(0.72 0.10 257.9)",MD:"oklch(0.72 0.10 152.8)",MA:"oklch(0.72 0.10 205.4)",
  MI:"oklch(0.70 0.10 112.7)",MN:"oklch(0.72 0.10 92.7)",MS:"oklch(0.74 0.10 335.2)",
  MO:"oklch(0.70 0.10 7.7)",MT:"oklch(0.72 0.10 242.6)",NE:"oklch(0.70 0.10 262.6)",
  NV:"oklch(0.74 0.10 327.5)",NH:"oklch(0.70 0.10 120.4)",NJ:"oklch(0.74 0.10 237.8)",
  NM:"oklch(0.70 0.10 210.1)",NY:"oklch(0.72 0.10 100.3)",NC:"oklch(0.70 0.10 270.3)",
  ND:"oklch(0.72 0.10 347.6)",OH:"oklch(0.74 0.10 27.7)",OK:"oklch(0.74 0.10 177.6)",
  OR:"oklch(0.72 0.10 137.5)",PA:"oklch(0.70 0.10 322.8)",RI:"oklch(0.70 0.10 67.9)",
  SC:"oklch(0.74 0.10 132.8)",SD:"oklch(0.74 0.10 125.1)",TN:"oklch(0.72 0.10 302.7)",
  TX:"oklch(0.70 0.10 315.1)",UT:"oklch(0.70 0.10 157.6)",VT:"oklch(0.74 0.10 342.9)",
  VA:"oklch(0.72 0.10 47.8)",WA:"oklch(0.70 0.10 0.0)",WV:"oklch(0.74 0.10 185.3)",
  WI:"oklch(0.70 0.10 60.2)",WY:"oklch(0.74 0.10 20.1)",
};
const SN = {AL:"Alabama",AK:"Alaska",AZ:"Arizona",AR:"Arkansas",CA:"California",CO:"Colorado",CT:"Connecticut",DE:"Delaware",FL:"Florida",GA:"Georgia",HI:"Hawaii",ID:"Idaho",IL:"Illinois",IN:"Indiana",IA:"Iowa",KS:"Kansas",KY:"Kentucky",LA:"Louisiana",ME:"Maine",MD:"Maryland",MA:"Massachusetts",MI:"Michigan",MN:"Minnesota",MS:"Mississippi",MO:"Missouri",MT:"Montana",NE:"Nebraska",NV:"Nevada",NH:"New Hampshire",NJ:"New Jersey",NM:"New Mexico",NY:"New York",NC:"North Carolina",ND:"North Dakota",OH:"Ohio",OK:"Oklahoma",OR:"Oregon",PA:"Pennsylvania",RI:"Rhode Island",SC:"South Carolina",SD:"South Dakota",TN:"Tennessee",TX:"Texas",UT:"Utah",VT:"Vermont",VA:"Virginia",WA:"Washington",WV:"West Virginia",WI:"Wisconsin",WY:"Wyoming"};
const ALL = Object.keys(SN);
const TPOP = 334914895;  // 2023 US total

// Waterway labels drawn on top of the map. Coordinates are in canvas-space
// (viewBox 0..1023, 0..708). `fromRound` / `toRound` bound when each label
// is visible (e.g. "Gulf of Mexico" → "Gulf of New Mexico" at R27).
// All waterway labels share the same font (rendered uniformly in the SVG —
// see the render below). Per-label fontSize is intentionally not set.
const WATERWAYS = [
  {text:"Pacific Ocean", x:55, y:340, rot:-90, fromRound:0},
  {text:"Atlantic Ocean", x:1000, y:300, rot:90, fromRound:0},
  {text:"Gulf of Mexico", x:655, y:540, fromRound:0, toRound:26},
  {text:"Gulf of New Mexico", x:655, y:540, fromRound:27},
  {text:"Great Hawaiian Sea", x:330, y:510, rot:15, fromRound:20},
  {text:"Superior Canadian Void", x:620, y:48, fromRound:0},
];
const fmt = n => n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e3 ? (n/1e3).toFixed(0)+'K' : String(n);

// ── Polygon helpers ──────────────────────────────────────────────
// snapshots_v2 stores each state as a MultiPolygon:
//   [ poly[ ring[ [x,y], … ], … ], … ]
// AK / HI in the basemap are stored as 2-level [poly][point]; normalize.
function normalizeMP(mp){
  if(!mp) return [];
  return mp.map(poly => {
    if(!poly || !poly.length) return [poly];
    const first = poly[0];
    if(Array.isArray(first) && Array.isArray(first[0])) return poly;
    return [poly];
  });
}
function mpToPath(mp){
  let d = "";
  for(const poly of mp){
    if(!poly) continue;
    for(const ring of poly){
      if(!ring || ring.length < 3) continue;
      d += "M";
      for(let i = 0; i < ring.length; i++){
        const [x,y] = ring[i];
        d += (i ? "L" : "") + x.toFixed(2) + "," + y.toFixed(2);
      }
      d += "Z";
    }
  }
  return d;
}
// Point-in-ring via ray casting (even-odd rule).
function pointInRing(x, y, ring){
  let inside = false;
  for(let i = 0, j = ring.length - 1; i < ring.length; j = i++){
    const xi = ring[i][0], yi = ring[i][1];
    const xj = ring[j][0], yj = ring[j][1];
    if(((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / ((yj - yi) || 1e-9) + xi)){
      inside = !inside;
    }
  }
  return inside;
}
// Point-in-MultiPolygon. Each `poly` = [outer, hole?, hole?, …]. We require
// the point to be inside the outer ring and NOT inside any hole of that poly.
function pointInMP(x, y, mp){
  for(const poly of mp){
    if(!poly?.length) continue;
    if(!pointInRing(x, y, poly[0])) continue;
    let inHole = false;
    for(let i = 1; i < poly.length; i++){
      if(pointInRing(x, y, poly[i])){ inHole = true; break; }
    }
    if(!inHole) return true;
  }
  return false;
}
function distToSegSq(x, y, x1, y1, x2, y2){
  const dx = x2 - x1, dy = y2 - y1;
  if(dx === 0 && dy === 0) return (x-x1)**2 + (y-y1)**2;
  const t = Math.max(0, Math.min(1, ((x-x1)*dx + (y-y1)*dy) / (dx*dx + dy*dy)));
  return (x - (x1 + t*dx))**2 + (y - (y1 + t*dy))**2;
}
function minDistToEdgeSq(x, y, mp){
  let m = Infinity;
  for(const poly of mp) for(const ring of poly){
    for(let i = 0; i < ring.length; i++){
      const j = (i + 1) % ring.length;
      const d = distToSegSq(x, y, ring[i][0], ring[i][1], ring[j][0], ring[j][1]);
      if(d < m) m = d;
    }
  }
  return m;
}
// "Pole of inaccessibility" — the interior point farthest from any edge. For
// irregular state shapes (FL panhandle, MI mitten, LA boot, AK), this lands
// the label inside the visually-dominant blob instead of outside the polygon
// or sitting on a coastline. Grid-search-with-refinement implementation —
// fast enough since it only runs on round change.
function mpLabelPos(mp){
  let xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity, n = 0;
  for(const poly of mp) for(const ring of poly) for(const [x,y] of ring){
    if(x<xmin)xmin=x;if(y<ymin)ymin=y;if(x>xmax)xmax=x;if(y>ymax)ymax=y;n++;
  }
  if(!n) return null;
  const cx = (xmin+xmax)/2, cy = (ymin+ymax)/2;

  function search(x0, y0, x1, y1, steps){
    const dx = (x1 - x0) / steps, dy = (y1 - y0) / steps;
    let bestX = (x0+x1)/2, bestY = (y0+y1)/2, bestD = -1;
    for(let i = 0; i <= steps; i++) for(let j = 0; j <= steps; j++){
      const x = x0 + i*dx, y = y0 + j*dy;
      if(!pointInMP(x, y, mp)) continue;
      const d = minDistToEdgeSq(x, y, mp);
      if(d > bestD){ bestD = d; bestX = x; bestY = y; }
    }
    return { x: bestX, y: bestY, dSq: bestD };
  }

  // Coarse pass over the full bounding box, then refine in a tight box
  // around the coarse winner for sub-cell precision.
  const coarse = search(xmin, ymin, xmax, ymax, 18);
  if(coarse.dSq < 0) return [cx, cy];  // no interior point found — fallback
  const w = (xmax - xmin) / 18, h = (ymax - ymin) / 18;
  const fine = search(coarse.x - w, coarse.y - h, coarse.x + w, coarse.y + h, 10);
  return fine.dSq > coarse.dSq ? [fine.x, fine.y] : [coarse.x, coarse.y];
}
function mpArea(mp){
  let total=0;
  for(const poly of mp) for(const ring of poly){
    let a=0;
    for(let i=0;i<ring.length;i++){
      const [x1,y1]=ring[i],[x2,y2]=ring[(i+1)%ring.length];
      a += x1*y2 - x2*y1;
    }
    total += Math.abs(a)/2;
  }
  return total;
}

// Build a snapshot-keyed dict of {postal: MultiPolygon} from
// either the basemap (round 0) or snapshots_v2.rounds[N].
function snapshotFor(round, basemap, snaps){
  if(round === 0){
    const out = {};
    for(const p of ALL){
      const mp = normalizeMP(basemap.polygons[p] || []);
      if(mpArea(mp) > 1) out[p] = mp;
    }
    return out;
  }
  const round_polys = snaps.rounds[String(round)] || {};
  const out = {};
  for(const p of ALL){
    const mp = normalizeMP(round_polys[p] || []);
    if(mpArea(mp) > 100) out[p] = mp;  // strip residual slivers
  }
  return out;
}

// Per-state populations (used for the cumulative "displaced" calc; matches
// the 2023 Census numbers baked into round_populations.json).
const STATE_POP = {AL:5108468,AK:733406,AZ:7431344,AR:3067732,CA:38965193,CO:5877610,CT:3617176,DE:1031890,FL:22610726,GA:11029227,HI:1435138,ID:1964726,IL:12549689,IN:6862199,IA:3207004,KS:2940546,KY:4526154,LA:4573749,ME:1395722,MD:6180253,MA:7001399,MI:10037261,MN:5737915,MS:2939690,MO:6196156,MT:1132812,NE:1978379,NV:3194176,NH:1402054,NJ:9290841,NM:2114371,NY:19571216,NC:10835491,ND:783926,OH:11785935,OK:4053824,OR:4233358,PA:12961683,RI:1095962,SC:5373555,SD:919318,TN:7126489,TX:30503301,UT:3417734,VT:647464,VA:8715698,WA:7812880,WV:1770071,WI:5910955,WY:584057};

function precomputeAllRounds(events, populations){
  const snapshots = [];
  for(let r = 0; r <= 49; r++){
    const teams = populations[String(r)] || {};
    const sorted = Object.entries(teams).sort((a,b) => b[1].pop - a[1].pop);
    const ev = r > 0 ? events[r-1] : null;

    // Per-round displacement: population of the team eliminated AT R(r).
    // Lookup uses populations[r-1] (state BEFORE R(r)'s deletion) so we get
    // the absorbing team's full pop at the moment it was deleted — that's the
    // "actual population displaced" this round, even when the team had
    // already absorbed many former states (e.g., Crabbalachia at R47).
    let displacedPop = 0;
    if(ev?.deleted){
      const prevPops = populations[String(r-1)];
      displacedPop = prevPops?.[ev.deleted]?.pop || STATE_POP[ev.deleted] || 0;
    }

    snapshots.push({
      round: r,
      teamCount: Object.keys(teams).length,
      displacedPop,
      displacedPct: TPOP ? displacedPop / TPOP * 100 : 0,
      topTeams: sorted.slice(0, 7).map(([code, t]) => ({
        code, name: SN[code], loreName: null,
        pop: t.pop, members: t.members?.length || 1,
      })),
      deletedName: ev?.deleted_name || SN[ev?.deleted] || ev?.deleted || null,
      deletedPostal: ev?.deleted || null,
      deletedColor: ev?.deleted ? PALETTE[ev.deleted] : null,
      absorberCount: ev?.absorbers?.length || 0,
      absorberNames: (ev?.absorbers || []).map(a => SN[a] || a),
    });
  }
  return snapshots;
}

function App({events, snapshots, basemap, populations, authors, commentMedia,
              mediaLookup, postScores, subredditInfo, leaderboard, pollPost}){
  const [round, setRound] = React.useState(0);
  // Default to autoplay on load. Any user interaction (step button, slider,
  // ←/→, space) flips this off and hands control to the user.
  const [playing, setPlaying] = React.useState(true);
  const [hovered, setHovered] = React.useState(null);
  const [mpos, setMpos] = React.useState({x:0,y:0});
  const [chartType, setChartType] = React.useState(null);
  const playRef = React.useRef(null);
  const mapRef = React.useRef(null);

  // Two distinct events drive the sidebar:
  //   evOutcome — the deletion that produced the current map (past-tense:
  //   lore, deleted state, renames). null at step 1 since nothing's happened.
  //   evPost    — the Reddit post being viewed at this step (its comments,
  //   media, OP-painted map, score). null at step 50 since post 50 doesn't
  //   exist yet (R49's finale poll is still active).
  const evOutcome = round > 0 ? events[round-1] : null;
  // Step 50 (round=49) maps to the finale poll post (round_poll.json), not a
  // numbered R N post — there is no R50. The poll record is shaped like a
  // normal event-post so SidePanel renders it via the same code path.
  const evPost = round < 49 ? (events[round] || null) : (pollPost || null);
  const ev = evOutcome;  // legacy alias for the timeline lore strip

  // Geometry + paths for the current round
  const snap = React.useMemo(() => snapshotFor(round, basemap, snapshots), [round, basemap, snapshots]);
  const paths = React.useMemo(() => {
    const out = {};
    for(const k of Object.keys(snap)) out[k] = mpToPath(snap[k]);
    return out;
  }, [snap]);
  const centroids = React.useMemo(() => {
    const out = {};
    for(const k of Object.keys(snap)) out[k] = mpLabelPos(snap[k]);
    return out;
  }, [snap]);

  // Cumulative renames (visual labels only; doesn't affect ownership)
  const renames = React.useMemo(() => {
    const m = {};
    for(let n = 1; n <= round; n++){
      const e = events[n-1];
      if(!e?.renames) continue;
      for(const r of e.renames) if(r.state !== 'GULF') m[r.state] = r.new_name;
    }
    return m;
  }, [events, round]);

  // Teams for this round = populations[round]
  const teams = populations[String(round)] || {};
  const allRoundData = React.useMemo(() => precomputeAllRounds(events, populations), [events, populations]);

  const largest = React.useMemo(() => {
    let b=null,bp=0;
    for(const [k,t] of Object.entries(teams)) if(t.pop>bp){bp=t.pop;b=k;}
    return b;
  }, [teams]);
  const lt = teams[largest];
  const pct = largest ? (lt.pop/TPOP*100).toFixed(1) : '0';
  const tc = Object.keys(teams).length;

  // Per-round series (this round's deletion's displacement only, NOT cumulative).
  const displacedPopSeries = React.useMemo(() => allRoundData.map(d => d.displacedPop), [allRoundData]);
  const cur = allRoundData[round] || {};
  const latestDeletedPostal = cur.deletedPostal;
  // Prefer the lore name at time of death (renames cumulative through `round`).
  const latestDeleted = latestDeletedPostal
    ? (renames[latestDeletedPostal] || SN[latestDeletedPostal])
    : null;
  const displacedPct = cur.displacedPct || 0;
  const displacedPop = cur.displacedPop || 0;

  React.useEffect(() => {
    if(playing) playRef.current = setInterval(() => setRound(r => {if(r>=49){setPlaying(false);return 49;}return r+1;}), 600);
    return () => clearInterval(playRef.current);
  }, [playing]);

  React.useEffect(() => {
    const h = e => {
      if(e.target.tagName==='INPUT') return;
      if(e.key==='ArrowRight'){setPlaying(false);setRound(r=>Math.min(49,r+1));}
      else if(e.key==='ArrowLeft'){setPlaying(false);setRound(r=>Math.max(0,r-1));}
      else if(e.key===' '){e.preventDefault();setPlaying(p=>!p);}
      else if(e.key==='Escape') setChartType(null);
    };
    document.addEventListener('keydown',h);
    return()=>document.removeEventListener('keydown',h);
  },[]);

  const ht = hovered ? teams[hovered] : null;

  return (
    <div style={{maxWidth:1280,margin:'0 auto',padding:'20px 24px 60px',fontFamily:'var(--font)'}}>
      {/* Header */}
      <div style={{display:'flex',alignItems:'center',gap:12,marginBottom:20}}>
        <svg width="36" height="36" viewBox="0 0 36 36" style={{flexShrink:0}}>
          <path fillRule="evenodd" fill="var(--accent)"
            d="M18,0A18,18,0,1,1,18,36A18,18,0,1,1,18,0Z M3.1,9.3L12.0,8.1L12.7,9.1L21.9,8.2L22.2,9.0L22.6,7.0L24.5,7.2L25.8,10.6L29.1,14.8L32.5,21.3L33.0,25.7L32.4,27.2L33.0,26.7L31.7,29.0L32.3,27.7L30.0,28.4L28.8,26.4L27.2,26.0L24.8,23.2L25.0,22.0L24.5,23.1L22.2,20.2L23.2,18.9L22.1,18.3L22.2,19.9L21.6,19.0L21.8,16.1L20.9,14.1L20.0,14.3L17.4,11.7L15.1,11.2L15.0,12.0L12.4,13.1L13.6,13.1L11.6,13.5L11.3,12.4L8.2,11.2L3.7,12.1L4.0,10.7L3.0,9.8Z" />
        </svg>
        <div style={{flex:1,minWidth:0}}>
          <h1 style={{fontSize:20,fontWeight:700,margin:0,letterSpacing:'-0.02em',lineHeight:1.2}}>
            Top Comment <span style={{color:'var(--accent)'}}>Deletes</span> a US State
          </h1>
          <div style={{fontSize:12,color:'var(--muted)',marginTop:2}}>
            r/{subredditInfo?.name || 'geographymemes'} · u/Jfullr92 · 49 rounds
            {subredditInfo?.subscribers ? ` · ${fmt(subredditInfo.subscribers)} subscribers` : ''}
            {leaderboard ? (
              <> · <button onClick={()=>setChartType('leaderboard')}
                style={{background:'none',border:'none',color:'var(--blue)',cursor:'pointer',padding:0,font:'inherit'}}>
                {Object.keys(leaderboard).length} winners →
              </button></>
            ) : null}
            {' '}· <a href="journeys.html" style={{color:'var(--blue)'}}>state journeys →</a>
          </div>
        </div>
      </div>

      <div className="main-grid">
        <div>
          <div style={{background:'var(--surface)',border:'1px solid var(--line)',borderRadius:'var(--radius-lg)',overflow:'hidden',marginBottom:12}}>
            <div ref={mapRef} style={{position:'relative',background:'var(--water)',aspectRatio:'1023/708',overflow:'hidden'}}>
              <svg viewBox="0 0 1023 708" preserveAspectRatio="xMidYMid meet" style={{width:'100%',height:'100%',display:'block'}}>
                {Object.keys(paths).map(code => {
                  const fill = PALETTE[code] || '#e8e8e8';
                  const isHovered = hovered === code;
                  return <path key={code} d={paths[code]} fill={fill} fillRule="evenodd"
                    stroke="rgba(255,255,255,0.7)" strokeWidth={isHovered?2:0.5} strokeLinejoin="round"
                    style={{cursor:'pointer',opacity:isHovered?1:(hovered?0.45:1),transition:'opacity 120ms,fill 250ms'}}
                    onMouseEnter={()=>setHovered(code)}
                    onMouseMove={e=>{const r=mapRef.current?.getBoundingClientRect();if(r)setMpos({x:e.clientX-r.left,y:e.clientY-r.top});}}
                    onMouseLeave={()=>setHovered(null)} />;
                })}
                {/* State / team label — every team gets at least its 2-letter
                    postal code; teams with a lore rename use the renamed
                    string. Uniform 10px font, no shadow. */}
                {Object.entries(teams).map(([code,t]) => {
                  const c = centroids[code];
                  if(!c) return null;
                  const label = renames[code] || code;
                  return <text key={'l'+code} x={c[0]} y={c[1]} textAnchor="middle" dominantBaseline="middle"
                    style={{fontSize:10,fontFamily:'var(--font)',fontWeight:600,fill:'rgba(0,0,0,0.72)',pointerEvents:'none'}}>
                    {label}
                  </text>;
                })}
                {/* Waterways — oceans, gulf, great-lakes void. Some have lore
                    rename arcs (Gulf of Mexico → Gulf of New Mexico at R27). */}
                {WATERWAYS.filter(w => round >= (w.fromRound||0) && round <= (w.toRound??49)).map((w,i) => (
                  <text key={'w'+i} x={w.x} y={w.y} textAnchor="middle" dominantBaseline="middle"
                    transform={w.rot ? `rotate(${w.rot} ${w.x} ${w.y})` : undefined}
                    style={{fontSize:11,fontFamily:'var(--font)',fontStyle:'italic',fontWeight:500,fill:'rgba(50,100,150,0.7)',pointerEvents:'none',letterSpacing:'0.04em'}}>
                    {w.text}
                  </text>
                ))}
              </svg>
              {hovered && ht && (
                <div style={{position:'absolute',left:Math.min(mpos.x+14,(mapRef.current?.offsetWidth||600)-220),top:Math.min(mpos.y-10,(mapRef.current?.offsetHeight||400)-90),background:'white',border:'1px solid var(--line)',borderRadius:10,padding:'10px 14px',fontSize:12,minWidth:170,maxWidth:220,boxShadow:'var(--shadow-md)',pointerEvents:'none',zIndex:10}}>
                  <div style={{fontWeight:600,fontSize:13,display:'flex',alignItems:'center',gap:6,marginBottom:3}}>
                    <span style={{width:10,height:10,borderRadius:3,background:PALETTE[hovered],flexShrink:0}}></span>
                    {renames[hovered]||SN[hovered]}
                  </div>
                  {renames[hovered] && <div style={{fontSize:11,color:'var(--muted)',fontStyle:'italic',marginBottom:3}}>orig. {SN[hovered]}</div>}
                  <div style={{display:'flex',justifyContent:'space-between',color:'var(--ink-2)',fontSize:11}}>
                    <span>{fmt(ht.pop)}</span>
                    <span>{(ht.members?.length||1)} state{(ht.members?.length||1)>1?'s':''}</span>
                  </div>
                </div>
              )}
            </div>
            {/* Timeline.
                Row 1: controls + step counter (no lore — it overflowed and got
                       truncated mid-sentence on most rounds).
                Row 2: full-width slider.
                Row 3: round lore on its own line, italic, can wrap freely.
                Sidebar's "OUTCOME OF ROUND N" card still shows the same text
                with more emphasis; this strip is just a contextual one-liner
                that stays in view as the user scrubs. */}
            <div style={{padding:'12px 16px 14px'}}>
              <div style={{display:'flex',alignItems:'center',gap:6,marginBottom:10}}>
                <StepBtn disabled={round<=0} onClick={()=>{setPlaying(false);setRound(r=>Math.max(0,r-1));}} title="Previous round (←)">‹</StepBtn>
                <button onClick={()=>setPlaying(p=>!p)} aria-label={playing?'Pause':'Play'} style={{width:32,height:32,borderRadius:999,border:'none',background:playing?'var(--accent)':'var(--surface-2)',color:playing?'#fff':'var(--ink)',cursor:'pointer',display:'flex',alignItems:'center',justifyContent:'center',fontSize:12,transition:'all 150ms'}}>
                  {/* U+FE0E (Variation Selector-15) forces text presentation
                      so iOS Safari doesn't render these as colored emojis. */}
                  {playing ? '⏸︎' : '▶︎'}
                </button>
                <StepBtn disabled={round>=49} onClick={()=>{setPlaying(false);setRound(r=>Math.min(49,r+1));}} title="Next round (→)">›</StepBtn>
                <div style={{flex:1}} />
                <div style={{display:'flex',alignItems:'baseline',gap:4}}>
                  <span style={{fontSize:10,color:'var(--muted)',fontWeight:600,letterSpacing:'0.06em'}}>STEP</span>
                  <span style={{fontSize:22,fontWeight:700,letterSpacing:'-0.02em',lineHeight:1}}>{round+1}</span>
                  <span style={{fontSize:12,color:'var(--muted)',fontFamily:'var(--font-mono)'}}>/&thinsp;50</span>
                </div>
              </div>
              <input type="range" min="0" max="49" value={round} step="1"
                onChange={e=>{setPlaying(false);setRound(+e.target.value);}}
                style={{width:'100%',height:4,borderRadius:2,outline:'none',cursor:'pointer',
                  background:`linear-gradient(to right,var(--accent) ${round/49*100}%,var(--line) ${round/49*100}%)`}} />
              <div style={{marginTop:10,fontSize:12,color:'var(--ink-2)',fontStyle:'italic',lineHeight:1.45,minHeight:'1.45em'}}>
                {round===0
                  ? "All 50 states present. Each round, Reddit's top comment chose one to delete."
                  : (ev?.lore || ev?.narration || '')}
              </div>
            </div>
          </div>

          {/* Stats — two cards: who was just eliminated, and the population
              shockwave of that single round's deletion. */}
          <div className="stats-grid">
            <StatCard label="Latest casualty"
              value={latestDeleted || 'No deletions yet'}
              sub={`${round} / 50 states eliminated`}
              color={cur.deletedColor || 'var(--ink)'}
              onClick={()=>setChartType('timeline')} />
            <StatCard label="Population displaced this round"
              value={displacedPop > 0 ? fmt(displacedPop) : '—'}
              sub={displacedPop > 0
                ? `${displacedPct.toFixed(1)}% of US pop. fell under another flag`
                : 'all 50 still standing'}
              sparkData={displacedPopSeries} color="var(--blue)"
              currentIndex={round}
              warn={displacedPct > 25}
              onClick={()=>setChartType('population')} />
          </div>
        </div>

        {/* Sidebar grows to its natural content height; the whole page scrolls
            rather than the sidebar scrolling internally. */}
        <div style={{alignSelf:'start'}}>
          <SidePanel evOutcome={evOutcome} evPost={evPost} round={round}
            renames={renames} teams={teams} largest={largest} lt={lt} pct={pct}
            authors={authors} commentMedia={commentMedia} mediaLookup={mediaLookup}
            postScores={postScores}
            onShowTimeline={()=>setChartType('timeline')} />
        </div>
      </div>

      <div style={{fontSize:11,color:'var(--muted)',textAlign:'center',marginTop:28,lineHeight:1.8,maxWidth:520,margin:'28px auto 0'}}>
        Made by <a href="https://anran.li/portfolio" style={{color:'var(--blue)'}}>Anran Li</a> because <a href="https://www.reddit.com/user/Jfullr92/" style={{color:'var(--blue)'}}>u/Jfullr92</a>'s
        {' '}series was too good not to visualize properly…
        {' '}even if aligning 49 hand-painted maps <a href="media/round_16_17_jackie.gif" target="_blank" rel="noopener" style={{color:'var(--blue)'}}><i className='bx bxs-skull' style={{verticalAlign:'-2px',fontSize:13}} /> nearly broke me</a>.
        {' '}<a href="https://anran.li/contact" style={{color:'var(--blue)'}}>
          <i className='bx bxs-hand' style={{verticalAlign:'-2px',fontSize:13}} /> Say hi
        </a>,{' '}
        <a href="https://anran.li/contact" style={{color:'var(--blue)'}}>
          <i className='bx bxs-bug' style={{verticalAlign:'-2px',fontSize:13}} /> report a bug
        </a>,{' '}or{' '}
        <a href="https://www.paypal.com/paypalme/AnranLi" style={{color:'var(--blue)'}}>
          <i className='bx bxs-coffee' style={{verticalAlign:'-2px',fontSize:13}} /> buy me a coffee
        </a>. :)
      </div>

      <ChartModal type={chartType} onClose={()=>setChartType(null)} allRoundData={allRoundData} currentRound={round}
        leaderboard={leaderboard} authors={authors} />
    </div>
  );
}

function StepBtn({onClick, disabled, title, children}){
  return (
    <button onClick={onClick} disabled={disabled} title={title}
      style={{width:28,height:28,borderRadius:999,border:'1px solid var(--line)',
        background:disabled?'var(--surface)':'var(--surface)',color:disabled?'var(--muted)':'var(--ink-2)',
        cursor:disabled?'default':'pointer',display:'flex',alignItems:'center',justifyContent:'center',
        fontSize:18,lineHeight:1,fontFamily:'var(--font)',padding:0,
        opacity:disabled?0.4:1,transition:'all 120ms'}}
      onMouseEnter={e=>{ if(!disabled) e.currentTarget.style.borderColor='var(--line-hover)'; }}
      onMouseLeave={e=>{ if(!disabled) e.currentTarget.style.borderColor='var(--line)'; }}>
      {children}
    </button>
  );
}

function authorKey(name){ return (name||'').replace(/^u\//,''); }

// The scraper stores permalink as either an absolute URL or a "/r/…" path.
// Normalize to an absolute reddit URL without double-prepending the host.
function permalinkUrl(p){
  if(!p) return '#';
  if(/^https?:\/\//i.test(p)) return p;
  if(p.startsWith('/')) return 'https://www.reddit.com' + p;
  return 'https://www.reddit.com/' + p;
}
function Avatar({name, iconUrl, authors, size=28}){
  // Prefer locally-cached avatar (authors.json.icon_local); fall back to the
  // per-comment scraped URL, then authors.json.icon_img, then placeholder.
  const key = authorKey(name);
  const a = authors?.[key];
  const url = a?.icon_local || iconUrl || a?.icon_img || a?.snoovatar_img;
  const safeUrl = url ? url.replace(/&amp;/g,'&') : null;
  if(!safeUrl){
    return <div style={{width:size,height:size,borderRadius:999,background:'var(--surface-2)',flexShrink:0,display:'flex',alignItems:'center',justifyContent:'center',fontSize:size*0.42,color:'var(--muted)'}}>👤</div>;
  }
  return <img src={safeUrl} alt={key} style={{width:size,height:size,borderRadius:999,background:'var(--surface-2)',flexShrink:0,objectFit:'cover'}}
    onError={e=>{e.target.style.display='none';}} />;
}

// Render a reddit comment body as HTML. Handles:
//   ![gif](giphy|TOKEN)          → embedded Giphy gif
//   ![alt](https://…)            → embedded image
//   [text](https://…)            → link
//   bare preview.redd.it / i.redd.it / i.imgur.com URLs → embedded image
//   other bare URLs              → link
//   &amp; / &lt; / &gt; entities are decoded so they don't leak as text
//
// `mediaLookup` (optional): `{remoteUrl: localPath, 'giphy:<id>': localPath}`
// — when present, image URLs are rewritten to the local file so the page
// works offline and doesn't depend on Reddit/Giphy CDN availability.
function linkify(s, mediaLookup){
  const localize = url => {
    if(!mediaLookup) return url;
    return mediaLookup[url] || mediaLookup[url.split('?')[0]] || url;
  };
  const giphyLocal = token => mediaLookup?.['giphy:' + token];
  if(!s) return '';
  const esc = t => t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  // Reddit returns HTML-entity-encoded ampersands in URLs. Decode first so the
  // resulting <a href> attribute and <img src> get a clean URL.
  let body = s.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&#x200B;/g,'');
  // Tokenize: stash each replacement as a placeholder so later regexes can't
  // accidentally re-match inside an <a href> or <img src>.
  const tokens = [];
  const ph = html => { const i = tokens.length; tokens.push(html); return ' PH'+i+' '; };
  const imgStyle = 'max-width:100%;border-radius:6px;margin-top:4px;display:block;background:var(--surface-2)';

  // 1. Giphy embeds: ![gif|caption](giphy|TOKEN)
  body = body.replace(/!\[[^\]]*\]\(giphy\|([A-Za-z0-9_-]+)\)/g, (_, t) => {
    const local = giphyLocal(t);
    const src = local || `https://i.giphy.com/media/${t}/giphy.gif`;
    return ph(`<a href="https://giphy.com/gifs/${esc(t)}" target="_blank" rel="noopener"><img src="${esc(src)}" loading="lazy" style="${imgStyle};max-height:240px;object-fit:contain" alt="gif"></a>`);
  });

  // 2. Markdown image: ![alt](url)
  body = body.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, (_, alt, url) => {
    const src = localize(url);
    return ph(`<a href="${esc(url)}" target="_blank" rel="noopener"><img src="${esc(src)}" alt="${esc(alt)}" loading="lazy" style="${imgStyle};max-height:280px;object-fit:contain"></a>`);
  });

  // 3. Markdown link: [text](url)
  body = body.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_, text, url) =>
    ph(`<a href="${esc(url)}" target="_blank" rel="noopener">${esc(text)}</a>`));

  // 4. Bare Reddit/Imgur preview-image URLs → render as inline image
  body = body.replace(/https?:\/\/(?:preview\.redd\.it|i\.redd\.it|i\.imgur\.com)\/[^\s<>"]+/g, url => {
    const src = localize(url);
    return ph(`<a href="${esc(url)}" target="_blank" rel="noopener"><img src="${esc(src)}" loading="lazy" style="${imgStyle};max-height:280px;object-fit:contain"></a>`);
  });

  // 5. Other bare URLs → link (display truncated)
  body = body.replace(/https?:\/\/[^\s<>"]+/g, url => {
    const display = url.length > 50 ? url.slice(0, 50) + '…' : url;
    return ph(`<a href="${esc(url)}" target="_blank" rel="noopener">${esc(display)}</a>`);
  });

  // Escape any remaining < > so a comment can't inject HTML.
  body = body.replace(/[<>]/g, c => c === '<' ? '&lt;' : '&gt;');

  // Restore placeholders
  body = body.replace(/ PH(\d+) /g, (_, i) => tokens[+i]);
  return body;
}

function CommentRow({c, authors, roundMedia, mediaLookup, size=20, bodySize=12, bold=false, accent=false, topBadge=false}){
  const nameBold = bold || topBadge;
  return (
    <div style={{display:'flex',gap:8}}>
      <Avatar name={c.author} iconUrl={c.author_icon} authors={authors} size={size} />
      <div style={{flex:1,minWidth:0}}>
        <div style={{display:'flex',alignItems:'center',gap:6,marginBottom:2,flexWrap:'wrap'}}>
          {topBadge && <span title="Highest-scored comment of the round" style={{fontSize:9,padding:'1px 5px',borderRadius:3,background:'var(--accent)',color:'#fff',fontWeight:700,letterSpacing:'0.04em'}}>TOP</span>}
          {c.permalink ? (
            <a href={permalinkUrl(c.permalink)} target="_blank" rel="noopener"
              style={{fontSize:12,fontWeight:nameBold?600:500,color:'var(--ink)',textDecoration:'none'}}>
              {c.author}
            </a>
          ) : (
            <span style={{fontSize:12,fontWeight:nameBold?600:500}}>{c.author}</span>
          )}
          <span style={{fontFamily:'var(--font-mono)',fontSize:nameBold?11:10,color:'var(--accent)',fontWeight:600}}>
            ▲ {(c.score||0).toLocaleString()}
          </span>
          {c.gilded > 0 && <span title="Gilded" style={{fontSize:10}}>🥇{c.gilded > 1 ? `×${c.gilded}` : ''}</span>}
          {c.is_submitter && <span title="OP" style={{fontSize:9,padding:'1px 4px',borderRadius:3,background:'var(--accent-soft)',color:'var(--accent)',fontWeight:600,letterSpacing:'0.04em'}}>OP</span>}
          {c.stickied && <span title="Stickied" style={{fontSize:10}}>📌</span>}
          {c.edited && <span style={{fontSize:10,color:'var(--muted)',fontStyle:'italic'}}>edited</span>}
        </div>
        <div style={{fontSize:bodySize,lineHeight:1.4}}
          dangerouslySetInnerHTML={{__html: linkify(c.body, mediaLookup)}} />
        {/* CommentMedia (comment_media.json) is a fallback for rounds where
            the scraped comment body doesn't already contain the media URL.
            linkify() inlines embeds present in c.body; this catches the rest. */}
        <CommentMediaFallback mediaList={roundMedia} author={c.author} score={c.score} body={c.body} />
      </div>
    </div>
  );
}

// Only renders media that the body text didn't already inline. Prevents the
// same gif/image from appearing twice when both comment_media.json AND the
// scraped comment body have the URL.
function CommentMediaFallback({mediaList, author, score, body}){
  if(!mediaList?.length) return null;
  const a = (author||'').replace(/^u\//,'');
  const bodyKey = (body || '').replace(/&amp;/g,'&');
  const seen = new Set();
  const matches = mediaList.filter(m => {
    if(m.comment_author?.replace(/^u\//,'') !== a) return false;
    if(score != null && m.comment_score !== score) return false;
    const base = (m.url||'').split('?')[0];
    if(seen.has(base)) return false;
    // Skip if the URL is already in the comment body (linkify rendered it).
    if(bodyKey.includes(base)) return false;
    // Skip giphy URLs whose token already appears in the body's ![gif](giphy|TOKEN).
    const giphy = base.match(/giphy\.com\/(?:media|gifs)\/([A-Za-z0-9_-]+)/);
    if(giphy && bodyKey.includes('giphy|' + giphy[1])) return false;
    seen.add(base); return true;
  });
  if(!matches.length) return null;
  return (
    <div style={{marginTop:8,display:'flex',flexDirection:'column',gap:6}}>
      {matches.map((m,i) => (
        <a key={i} href={m.url} target="_blank" rel="noopener" style={{display:'block',borderRadius:6,overflow:'hidden',border:'1px solid var(--line)'}}>
          <img src={m.local_path || m.url} alt="" loading="lazy"
            style={{width:'100%',display:'block',maxHeight:280,objectFit:'contain',background:'var(--surface-2)'}}
            onError={e=>{e.target.parentNode.style.display='none';}} />
        </a>
      ))}
    </div>
  );
}

// Both stat cards share the same value typography (18px / weight 700) so they
// read as a uniform pair. Text on the left; if a sparkline is provided, it
// sits to the right of the text occupying roughly half the card width.
const STAT_VALUE_STYLE = {fontSize:18,fontWeight:700,letterSpacing:'-0.01em',marginTop:2,lineHeight:1.2};
function StatCard({ label, value, sub, sparkData, currentIndex, color, warn, onClick }){
  return (
    <div onClick={onClick} style={{
      background:'var(--surface)',border:'1px solid var(--line)',borderRadius:'var(--radius)',
      padding:'12px 14px',cursor:onClick?'pointer':'default',transition:'border-color 150ms',
      display:'flex',flexDirection:'column',
    }}
    onMouseEnter={e=>e.currentTarget.style.borderColor='var(--line-hover)'}
    onMouseLeave={e=>e.currentTarget.style.borderColor='var(--line)'}>
      <div style={{display:'flex',alignItems:'center',gap:10}}>
        <div style={{flex:1,minWidth:0}}>
          <div style={{fontSize:11,color:'var(--muted)',fontWeight:500,display:'flex',justifyContent:'space-between',alignItems:'center'}}>
            <span>{label}</span>
            {onClick && <span style={{fontSize:11,color:'var(--muted)',opacity:0.5}}>↗</span>}
          </div>
          <div style={{...STAT_VALUE_STYLE, color:warn?'var(--danger)':(color||'var(--ink)')}}>{value}</div>
          {sub && <div style={{fontSize:10,color:'var(--muted)',marginTop:1}}>{sub}</div>}
        </div>
        {sparkData && (
          <div style={{flex:'0 0 45%',height:48,display:'flex',alignItems:'stretch'}}>
            <MiniSparkline data={sparkData} color={color} width={200} height={48} fill currentIndex={currentIndex} />
          </div>
        )}
      </div>
    </div>
  );
}

// Finale-poll card — surfaced only at step 50 from round_poll.json.
function PollCard({ poll }){
  if(!poll?.options?.length) return null;
  const total = poll.total_vote_count;
  const ends = poll.voting_end_timestamp;
  const endStr = ends ? new Date(ends).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : null;
  const now = Date.now();
  const closed = ends && now > ends;
  const hasVotes = poll.options.some(o => o.vote_count != null);
  const winnerId = poll.resolved_option_id;
  return (
    <div style={{background:'var(--surface)',border:'1px solid var(--accent)',borderRadius:'var(--radius-lg)',padding:'14px 16px'}}>
      <div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
        <span style={{fontSize:10,fontWeight:700,color:'var(--accent)',letterSpacing:'0.04em'}}>🗳 FINALE VOTE</span>
        <span style={{fontFamily:'var(--font-mono)',fontSize:11,color:'var(--muted)'}}>
          {total?.toLocaleString()} votes
        </span>
      </div>
      <div style={{display:'flex',flexDirection:'column',gap:6}}>
        {poll.options.map(o => {
          const pct = (hasVotes && total) ? (o.vote_count / total * 100) : 0;
          const isWinner = winnerId && o.id === winnerId;
          return (
            <div key={o.id} style={{position:'relative',overflow:'hidden',display:'flex',justifyContent:'space-between',padding:'8px 10px',background:'var(--surface-2)',borderRadius:6,fontSize:13,fontWeight:600,border:isWinner ? '1.5px solid var(--accent)' : '1.5px solid transparent'}}>
              {hasVotes && <div style={{position:'absolute',top:0,left:0,bottom:0,width:`${pct}%`,background:isWinner ? 'rgba(var(--accent-rgb,99,202,183),0.18)' : 'rgba(0,0,0,0.06)',borderRadius:6,transition:'width 0.4s ease'}} />}
              <span style={{position:'relative',zIndex:1}}>{isWinner ? '👑 ' : ''}{o.text}</span>
              <span style={{position:'relative',zIndex:1,fontFamily:'var(--font-mono)',color:'var(--muted)',fontWeight:500}}>
                {o.vote_count != null ? `${o.vote_count.toLocaleString()} (${pct.toFixed(1)}%)` : '—'}
              </span>
            </div>
          );
        })}
      </div>
      {hasVotes && poll.options.some(o => o.core_vote_count != null) && (
        <div style={{marginTop:6,display:'flex',flexDirection:'column',gap:2}}>
          {poll.options.map(o => o.core_vote_count != null ? (
            <div key={o.id} style={{fontSize:10,color:'var(--muted)',fontFamily:'var(--font-mono)',paddingLeft:10}}>
              {o.text}: {o.core_vote_count.toLocaleString()} core contributor votes
            </div>
          ) : null)}
        </div>
      )}
      <div style={{marginTop:8,fontSize:10,color:'var(--muted)',lineHeight:1.5}}>
        {endStr && <div>{closed ? 'Closed' : 'Closes'} {endStr}</div>}
        {!hasVotes && <div style={{fontStyle:'italic'}}>Reddit's public API hides per-option counts while voting is open.</div>}
      </div>
    </div>
  );
}

function SidePanel({evOutcome, evPost, round, renames, teams, largest, lt, pct, authors, commentMedia, mediaLookup, postScores, onShowTimeline}){
  // Comment media is keyed by Reddit-round number — use the post being viewed,
  // not the snapshot's round index.
  // postRound is either a Reddit round number (1..49) or the string "poll"
  // for the finale-vote post that doesn't have a round number.
  const postRound = evPost?.round;
  const isPoll = postRound === 'poll' || evPost?.is_poll_post;
  const postLabel = isPoll ? 'POLL POST (THE FINALE)' : `POST #${postRound}`;
  const mediaKey = isPoll ? 'poll' : String(postRound);
  const roundMedia = postRound != null ? (commentMedia?.[mediaKey] || []) : [];
  // No PRAW score snapshot for the poll post; fall back to evPost's own score.
  const scoreSnap = isPoll
    ? { score: evPost.post_score, upvote_ratio: evPost.post_upvote_ratio, num_comments: evPost.post_num_comments }
    : (postRound ? postScores?.[String(postRound)] : null);
  return (
    <div className="sidebar-cards">
      {/* Outcome card — describes the deletion that produced the CURRENT map.
          Hidden on step 1 since no deletion has happened yet. */}
      {evOutcome && (
        <div style={{background:'var(--surface)',border:'1px solid var(--line)',borderRadius:'var(--radius-lg)',overflow:'hidden'}}>
          <div style={{padding:'14px 16px'}}>
            <div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:4}}>
              <span style={{fontSize:11,color:'var(--muted)',fontWeight:500}}>OUTCOME OF ROUND {evOutcome.round}</span>
              <button onClick={onShowTimeline} style={{fontSize:10,color:'var(--blue)',background:'none',border:'none',cursor:'pointer',padding:0,fontFamily:'var(--font)'}}>
                All rounds →
              </button>
            </div>
            <div style={{fontSize:15,fontWeight:600,lineHeight:1.3}}>{evOutcome.lore || evOutcome.narration || ''}</div>
          </div>
          {evOutcome.deleted && (
            <div style={{margin:'0 16px 12px',display:'flex',alignItems:'center',gap:8,padding:'8px 10px',background:'var(--danger-bg)',borderRadius:6,border:'1px solid #fecaca'}}>
              <span style={{fontSize:16,fontWeight:700,color:'var(--danger)'}}>✕</span>
              <div>
                <div style={{fontSize:13,fontWeight:600,color:'var(--danger)'}}>{evOutcome.deleted_name||evOutcome.deleted}</div>
                <div style={{fontSize:11,color:'var(--ink-2)'}}>→ {(evOutcome.absorbers||[]).map(a=>SN[a]||a).join(', ')}</div>
              </div>
            </div>
          )}
          {evOutcome.renames?.length > 0 && (
            <div style={{margin:'0 16px 12px',padding:'8px 10px',background:'var(--good-bg)',borderRadius:6,border:'1px solid #bbf7d0',fontSize:12}}>
              <div style={{fontWeight:600,fontSize:10,color:'var(--good)',marginBottom:3}}>RENAMED</div>
              {evOutcome.renames.map((r,i) => (
                <div key={i} style={{color:'var(--ink-2)'}}>{SN[r.state]||r.state} → <strong style={{color:'var(--ink)'}}>{r.new_name}</strong></div>
              ))}
            </div>
          )}
        </div>
      )}

      {/* Post header — describes the Reddit post being viewed (step 1 = R1
          post, step 49 = R49 post, step 50 = nothing since post 50 doesn't
          exist yet). Lists live score / upvote / comment count. */}
      {evPost && (
        <div style={{background:'var(--surface)',border:'1px solid var(--line)',borderRadius:'var(--radius-lg)',padding:'14px 16px'}}>
          <div style={{fontSize:11,color:'var(--muted)',fontWeight:500,marginBottom:4,letterSpacing:'0.02em'}}>{postLabel}</div>
          <div style={{fontSize:14,fontWeight:600,lineHeight:1.3}}>
            {evPost.post_url
              ? <a href={evPost.post_url} target="_blank" rel="noopener" style={{color:'var(--ink)',textDecoration:'none'}}>{evPost.post_title}</a>
              : evPost.post_title}
          </div>
          {scoreSnap && (
            <div style={{marginTop:8,display:'flex',gap:10,fontSize:11,color:'var(--muted)',fontFamily:'var(--font-mono)'}}>
              <span>▲ {scoreSnap.score?.toLocaleString()}</span>
              <span>{Math.round((scoreSnap.upvote_ratio||0)*100)}% upvoted</span>
              <span>{scoreSnap.num_comments?.toLocaleString()} comments</span>
            </div>
          )}
        </div>
      )}

      {evPost?.stickied_comment && (
        <div style={{background:'var(--good-bg)',border:'1px solid #bbf7d0',borderRadius:'var(--radius-lg)',padding:'12px 14px'}}>
          <div style={{fontSize:10,fontWeight:600,color:'var(--good)',marginBottom:6}}>📌 STICKIED</div>
          <div style={{fontSize:12,lineHeight:1.5,color:'var(--ink-2)'}}
            dangerouslySetInnerHTML={{__html: linkify(evPost.stickied_comment, mediaLookup)}} />
        </div>
      )}

      {/* Poll card — only on step 50. Shows the two finale options + total
          vote count. Per-option counts aren't exposed by Reddit's public API. */}
      {isPoll && evPost?.poll_data && (
        <PollCard poll={evPost.poll_data} />
      )}

      {/* Top comments — highest-scored gets a TOP badge inline. OP replies
          nest beneath the top comment. Threaded replies (recursive) sit
          under the comments panel as a separate "REPLIES" card. */}
      {evPost?.top_comments?.length > 0 && (
        <div style={{background:'var(--surface)',border:'1px solid var(--line)',borderRadius:'var(--radius-lg)',padding:'14px 16px'}}>
          <div style={{fontSize:10,fontWeight:600,color:'var(--muted)',marginBottom:8,letterSpacing:'0.04em'}}>TOP COMMENTS</div>
          {evPost.top_comments.slice(0, 5).map((c,i) => (
            <div key={c.id||i} style={{padding:i===0?'0 0 8px':'8px 0',borderTop:i>0?'1px solid var(--line)':'none'}}>
              <CommentRow c={c} authors={authors} roundMedia={roundMedia} mediaLookup={mediaLookup}
                size={i===0?24:20} bodySize={i===0?13:12}
                topBadge={i===0} />
              {i===0 && evPost.op_reply_to_top_comment && (
                <div style={{marginTop:8,marginLeft:8,paddingLeft:10,borderLeft:'2px solid var(--accent)'}}>
                  <div style={{fontSize:9,fontWeight:600,color:'var(--accent)',marginBottom:4,letterSpacing:'0.04em'}}>OP REPLIED</div>
                  <CommentRow c={evPost.op_reply_to_top_comment} authors={authors} roundMedia={roundMedia} mediaLookup={mediaLookup} size={18} bodySize={12} />
                </div>
              )}
            </div>
          ))}
          {evPost.top_comments.length > 5 && (
            <div style={{fontSize:10,color:'var(--muted)',marginTop:8,paddingTop:6,borderTop:'1px solid var(--line)'}}>+ {evPost.top_comments.length-5} more</div>
          )}
        </div>
      )}

      {evPost?.post_image_local && (
        <a href={evPost.post_url || '#'} target="_blank" rel="noopener"
          title={evPost.post_url ? 'Open original Reddit post' : undefined}
          style={{display:'block',background:'var(--surface)',border:'1px solid var(--line)',borderRadius:'var(--radius-lg)',overflow:'hidden',textDecoration:'none',color:'inherit',cursor:evPost.post_url?'pointer':'default'}}>
          <div style={{padding:'10px 16px 6px',fontSize:10,fontWeight:600,color:'var(--muted)',display:'flex',justifyContent:'space-between',alignItems:'center'}}>
            <span>OP'S MAP (POST #{postRound})</span>
            {evPost.post_url && <span style={{color:'var(--blue)'}}>↗</span>}
          </div>
          <img src={evPost.post_image_local} alt={`Round ${postRound}`}
            style={{width:'100%',display:'block',borderTop:'1px solid var(--line)'}}
            onError={e=>e.target.style.display='none'} />
        </a>
      )}

      {/* Step 50 (round=49): finale projected but no post exists. */}
      {!evPost && evOutcome && (
        <div style={{background:'var(--accent-soft)',border:'1px solid #fecaca',borderRadius:'var(--radius-lg)',padding:'14px 16px',fontSize:12,color:'var(--ink-2)',lineHeight:1.5}}>
          <strong style={{color:'var(--accent)'}}>The finale poll is still active.</strong> Post #50 hasn't been published — the result above is projected based on R49's finale-vote thread.
        </div>
      )}

      {largest && lt && (lt.members?.length||1) > 1 && (
        <div style={{background:'var(--surface)',border:'1px solid var(--line)',borderRadius:'var(--radius-lg)',padding:'14px 16px'}}>
          <div style={{fontSize:10,fontWeight:600,color:'var(--muted)',marginBottom:6}}>LARGEST TERRITORY</div>
          <div style={{display:'flex',alignItems:'center',gap:6,marginBottom:6}}>
            <span style={{width:10,height:10,borderRadius:3,background:PALETTE[largest]}}></span>
            <span style={{fontSize:13,fontWeight:700}}>{renames[largest]||SN[largest]}</span>
          </div>
          <div style={{height:3,borderRadius:2,background:'var(--surface-2)',overflow:'hidden',marginBottom:4}}>
            <div style={{height:'100%',width:pct+'%',background:'var(--accent)',transition:'width 250ms',borderRadius:2}}></div>
          </div>
          <div style={{display:'flex',justifyContent:'space-between',fontSize:10,color:'var(--muted)'}}>
            <span>{pct}% of US pop.</span>
            <span>{lt.members?.length||1} states</span>
          </div>
          <div style={{marginTop:6,display:'flex',gap:3,flexWrap:'wrap'}}>
            {(lt.members||[]).map(m => (
              <span key={m} style={{fontFamily:'var(--font-mono)',fontSize:9,padding:'1px 4px',borderRadius:3,background:'var(--surface-2)',color:'var(--ink-2)'}}>{m}</span>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

window.STATE_FULL_NAMES = SN;
window.App = App;
