/* Character Timeline — every character in introduction order, Book 1 → current mapping.
   Computed live from window.DCC data: intro point, lifeline (timeline + mentions), deaths.
   Scales to all seven books: every book gets an EQUAL-width column so the book-to-book
   spacing reads evenly, and chapters are positioned by ordinal rank (so a Prologue or a
   99-numbered Epilogue sits flush at the column edge). A density filter and collapsible
   lanes keep the vertical sprawl manageable as the cast grows. */
(function(){
const { useState, useEffect, useRef, useMemo, useLayoutEffect } = React;
const D = window.DCC;

/* ---------- lanes ---------- */
const CF_LANES = [
  {key:'party',    name:"The Royal Court of Princess Donut", note:'the party',                color:'#ffb13d'},
  {key:'crawlers', name:'Crawlers & Earthlings',             note:'contestants & civilians',  color:'#ff52cf'},
  {key:'dungeon',  name:'Dungeon Denizens, Guides & NPCs',   note:'inside the World Dungeon', color:'#49d6ff'},
  {key:'galaxy',   name:'The Show, Sponsors & the Galaxy',   note:'offworld',                 color:'#b98bff'},
];
const CF_LANE_OF = {};
[['party','carl donut mongo mordecai katia-grim psamathe'],
 ['crawlers','bea mrs-parsons rebecca-w frank-q lucia-mar maggie-my brandon-an chris-andrews yolanda-martinez imani-c agatha eldercare-crawlers le-mouvement elle-mcgibbons jack hekla brynhilds-daughters li-jun yvette daniel-bautista dnadia prepotente miriam-dom florin ifechi quan-ch eva-sigrid'],
 ['dungeon','tally rory-lorelai mistress-tiatha grull sebastian over-city-npcs signet grimaldi apollon fitz quint clarabelle pustule vicente city-elf apito featherfall gumgum manasa miss-quill burgundy limp-richard wendita vernon pierre-porter chaco bomo-sledge astrid gore-gore levi-7th dismember fire-brandy widget madison growler-gary damien'],
 ['galaxy','system-ai the-syndicate borant squim lexis odette titan-conglomerate zev the-bloom maestro mexx-55 admin-mukta skull-clan prince-stalwart skull-empire king-rust valtay sensation-ent ripper-wonton tucker hekla-panel loita'],
].forEach(([lane, ids])=> ids.split(' ').forEach(id=>{ CF_LANE_OF[id]=lane; }));

function laneOf(e){
  if(CF_LANE_OF[e.id]) return CF_LANE_OF[e.id];
  const s = ((e.sub||'')+' '+(e.name||'')).toLowerCase();
  if(/crawler|faction/.test(s)) return 'crawlers';
  if(/show|host|producer|syndicate|borant|admin|agent|panel|corp|empire|sponsor|pundit/.test(s)) return 'galaxy';
  return 'dungeon';
}

/* ---------- confirmed deaths & main-cast kills (audited against the source novels) ----------
   These curated maps DRIVE the timeline's two marker types, replacing the old prose-regex
   death scan. That scan matched "killed/dies" inside mentions that were actually ABOUT OTHER
   characters, so it wrongly flagged living cast (Carl, Donut, Mongo, Mordecai, …) as dead and
   conflated "X died" with "X killed someone". The two events are now SEPARATE:
     CONFIRMED_DEATHS — a character who dies and stays dead  → a † cross on their own line.
     MAIN_CAST_KILLS  — a main-cast member killing a significant character who stays dead
                        → a ⚔ on the KILLER's line (the victim still gets their own †).
   Every entry below was validated chapter-by-chapter against DCC Source/. id -> {b, c}. */
const CONFIRMED_DEATHS = {
  'mrs-parsons':{b:1,c:1}, 'rebecca-w':{b:1,c:13}, 'jack':{b:1,c:38}, 'yvette':{b:1,c:43},
  'vicente':{b:2,c:16}, 'gumgum':{b:2,c:16}, 'maestro':{b:2,c:19}, 'manasa':{b:2,c:19},
  'miss-quill':{b:2,c:22}, 'burgundy':{b:2,c:23},
  'brandon-an':{b:3,c:3}, 'vernon':{b:3,c:5}, 'gore-gore':{b:3,c:11}, 'dismember':{b:3,c:12},
  'widget':{b:3,c:17}, 'frank-q':{b:3,c:20}, 'hekla':{b:3,c:23},
  'ifechi':{b:4,c:4}, 'leon':{b:4,c:12}, 'mad-dune-mage':{b:4,c:22}, 'henrik':{b:4,c:26},
  'vadim-zbar':{b:4,c:27}, 'subterranean-crawlers':{b:4,c:6},
  'xindy':{b:5,c:5}, 'maggie-my':{b:5,c:2}, 'langley-archers':{b:5,c:25}, 'edict':{b:5,c:38},
  'miriam-dom':{b:5,c:47}, 'vrah':{b:5,c:73}, 'firas':{b:5,c:74}, 'eva-sigrid':{b:5,c:99},
  'anton-lopez':{b:6,c:16}, 'prince-stalwart':{b:6,c:22}, 'astrid':{b:6,c:32}, 'wakinyan':{b:6,c:42},
  'quan-ch':{b:6,c:58}, 'tserendolgor':{b:6,c:59}, 'ysalte':{b:6,c:69}, 'paz-lo':{b:6,c:72},
  'dante':{b:7,c:4}, 'herman-the-fleet':{b:7,c:13}, 'dnadia':{b:7,c:27}, 'mork':{b:7,c:51},
  'vinata':{b:7,c:54}, 'li-jun':{b:7,c:68}, 'burcu':{b:7,c:68}, 'architect-houston':{b:7,c:68},
  'nami':{b:7,c:69}, 'warlord-fang':{b:7,c:80}, 'epitome-tagg':{b:7,c:84}, 'huanxin-jinx':{b:7,c:86},
};
const MAIN_CAST_KILLS = {
  'carl':[
    {b:2,c:11, victim:'Grimaldi, the Vine', victimId:'grimaldi', note:'Poisoned the circus City Boss'},
    {b:2,c:22, victim:'Miss Quill', victimId:'miss-quill', note:'Dynamited the masquerade mastermind'},
    {b:3,c:11, victim:'Gore-Gore', victimId:'gore-gore', note:'Drop-kicked onto the electrified third rail'},
    {b:3,c:12, victim:'Dismember the War Mage', victimId:'dismember', note:'Beheaded (Donut assist)'},
    {b:5,c:5,  victim:'Hunter Xindy', victimId:'xindy', note:"Homing missiles — Vrah's little sister"},
    {b:5,c:5,  victim:"Chin'Dua", victimId:'', note:'Beaten down, Ring of Divine Suffering'},
    {b:5,c:14, victim:'Hunter Bravvo', victimId:'', note:'Ring of Divine Suffering'},
    {b:5,c:38, victim:'Edict', victimId:'edict', note:'Super Naughty trap dropped him in a mob nest'},
    {b:5,c:53, victim:'Hunter Iota', victimId:'', note:'Executed after interrogation'},
    {b:5,c:74, victim:'Zabit', victimId:'', note:'Tripper detonated his own landmines'},
    {b:7,c:51, victim:'Mork', victimId:'mork', note:'Black Nimbus — "Kill the Blasphemer"'},
    {b:7,c:68, victim:'Architect Houston', victimId:'architect-houston', note:'Madness faction leader — Faction Wars'},
    {b:7,c:72, victim:'Gustavo 3', victimId:'', note:"Rend's Wrecking Ball on Carl's command"},
    {b:7,c:80, victim:'Warlord Fang', victimId:'warlord-fang', note:'Reaver warlord — Faction Wars'},
  ],
  'donut':[
    {b:2,c:16, victim:'Vicente', victimId:'vicente', note:'Magic Missile decapitation (Carl assist)'},
    {b:2,c:23, victim:'Burgundy', victimId:'burgundy', note:'Magic Missile — a mercy kill'},
    {b:4,c:12, victim:'Leon the Commissar', victimId:'leon', note:'Astral Paw shoved him off the balloon'},
    {b:5,c:10, victim:'Elmer', victimId:'', note:'Astral Paw push won her the settlement'},
    {b:6,c:58, victim:'Quan Ch', victimId:'quan-ch', note:'Bullet Hell totems (Carl assist)'},
    {b:6,c:72, victim:'Ysalte, the Vinegar Bitch', victimId:'ysalte', note:'Ripped her card (combat credit to Paz)'},
    {b:6,c:72, victim:'Paz Lo', victimId:'paz-lo', note:'Ripped his card — a mercy destruction'},
    {b:7,c:76, victim:'Meatus, the feral god', victimId:'', note:'War Crime (Astral Paw), Li Na assist'},
    {b:7,c:86, victim:'Huanxin Jinx', victimId:'huanxin-jinx', note:'Ash-tipped bolt (Mordecai loaded) — Faction Wars'},
  ],
  'mongo':[
    {b:5,c:73, victim:'Vrah', victimId:'vrah', note:"On Carl's order — the Hunt's apex predator"},
    {b:7,c:27, victim:"Empress D'Nadia", victimId:'dnadia', note:'Prism faction leader (Carl marked, Donut directed)'},
    {b:7,c:84, victim:'Epitome Tagg', victimId:'epitome-tagg', note:'Chewed his heart — Dream warlord, Faction Wars'},
  ],
  'katia-grim':[
    {b:3,c:24, victim:'Hekla', victimId:'hekla', note:'Rush battering-ram (aimed at Eva, hit Hekla)'},
    {b:4,c:22, victim:'Ghazi, the Mad Dune Mage', victimId:'mad-dune-mage', note:'Killed off-page and looted'},
    {b:5,c:99, victim:'Eva Sigrid', victimId:'eva-sigrid', note:"\"Die, Eva.\" — Eva's dying act crowns Katia with the Crown of the Sepsis Whore (Donut's old cursed tiara), forcing the 9th→10th floor split"},
    {b:6,c:32, victim:'Astrid', victimId:'astrid', note:'Wolf-totem spike through the head (Carl assist)'},
  ],
};
function pkey(p){ return p.b*1000 + p.c; }

function deriveChar(e, mappedSet){
  const intro = {b:e.book, c:e.chapter};
  /* BEATS = first appearance + curated timeline[] nodes ONLY. mentions[] is the per-chapter
     occurrence log (entry-page "Major events") and is NOT plotted — see BeatMapping.md §1. */
  const pts = [intro];
  (e.timeline||[]).forEach(t=>{ if(mappedSet.has(t.book)) pts.push({b:t.book, c:t.ch, label:t.label||''}); });
  /* deaths & kills now come from the curated, source-audited maps above (no prose scan). */
  const death = (CONFIRMED_DEATHS[e.id] && mappedSet.has(CONFIRMED_DEATHS[e.id].b)) ? CONFIRMED_DEATHS[e.id] : null;
  const kills = (MAIN_CAST_KILLS[e.id]||[]).filter(k=>mappedSet.has(k.b));
  /* dedupe arc beats */
  const seen = {}; const beats = [];
  pts.forEach(p=>{ const k=pkey(p); if(!seen[k]){ seen[k]=1; beats.push(p); } });
  beats.sort((a,b)=>pkey(a)-pkey(b));
  /* the lifeline must extend to reach the last of: beats, kills, death */
  const ends = beats.concat(kills.map(k=>({b:k.b,c:k.c}))).concat(death?[death]:[]);
  ends.sort((a,b)=>pkey(a)-pkey(b));
  const last = ends[ends.length-1];
  const books = new Set(beats.concat(kills.map(k=>({b:k.b,c:k.c}))).map(p=>p.b));
  /* "recurring" = a character the index actively tracks across the crawl, not a one-beat walk-on */
  const recurring = books.size>=2 || beats.length>=4 || !!death || kills.length>0;
  return { e, lane:laneOf(e), intro, beats, kills, death, last, spanBooks:books.size, recurring };
}

/* ---------- the view ---------- */
function CharFlow({go}){
  const mappedBooks = D.BOOKS.filter(b=>b.status==='live').map(b=>b.n);
  const maxBook = Math.max.apply(null, mappedBooks);
  const [horizon, setHorizon] = useState(()=>{
    const v = parseInt(localStorage.getItem('dcc-cf-horizon'),10);
    return (v && mappedBooks.includes(v)) ? v : maxBook;
  });
  useEffect(()=>{ localStorage.setItem('dcc-cf-horizon', String(horizon)); },[horizon]);
  const [density, setDensity] = useState(()=> localStorage.getItem('dcc-cf-density') || 'recurring');
  useEffect(()=>{ localStorage.setItem('dcc-cf-density', density); },[density]);
  const [collapsed, setCollapsed] = useState({});
  const [hover, setHover] = useState(null); // {id, x, y}
  const boxRef = useRef(null);
  const axisRef = useRef(null);
  const canvasRef = useRef(null);

  /* available width drives fit-vs-scroll; measured live so it stays correct on resize */
  const [vw, setVw] = useState(1100);
  useLayoutEffect(()=>{
    function measure(){ if(boxRef.current) setVw(boxRef.current.clientWidth); }
    measure();
    const ro = ('ResizeObserver' in window) ? new ResizeObserver(measure) : null;
    if(ro && boxRef.current) ro.observe(boxRef.current);
    window.addEventListener('resize', measure);
    return ()=>{ if(ro) ro.disconnect(); window.removeEventListener('resize', measure); };
  },[]);

  const model = useMemo(()=>{
    const mappedSet = new Set(mappedBooks);
    const cast = D.allEntries()
      .filter(en=> en.type==='character' || en.id==='mongo')
      .filter(en=> mappedSet.has(en.book))
      .map(en=> deriveChar(en, mappedSet));

    /* x geometry — CONSISTENT SPACING: every book is allotted the SAME column width, so the
       eye reads even book-to-book gaps regardless of chapter count. Fill the frame when the
       whole run fits; otherwise fall back to a readable minimum per book and scroll. */
    const PAD = 4, MIN_BOOK_W = 156, INSET = 0.05;
    const avail = Math.max(560, vw);
    const books = mappedBooks.slice().sort((a,b)=>a-b).map(n=>{
      const meta = D.BOOKS.find(b=>b.n===n);
      const nums = D.chaptersFor(n).map(c=>c.num).sort((a,b)=>a-b);
      return {n, title:meta.title, nums, chCount:nums.length, minCh:nums[0], maxCh:nums[nums.length-1]};
    });
    const nB = books.length || 1;
    const BOOK_W = Math.max(MIN_BOOK_W, (avail - PAD*2)/nB);
    const W = Math.max(avail, nB*BOOK_W + PAD*2);
    let off = PAD;
    books.forEach(b=>{ b.w = BOOK_W; b.x0 = off; off += BOOK_W; });
    const bx = {}; books.forEach(b=>{ bx[b.n]=b; });
    /* position a chapter by its ORDINAL rank among the book's real chapters: a Prologue
       (ch 0) sits flush left and an Epilogue (numbered 99) flush right, no phantom gap,
       and every chapter is evenly spaced regardless of its raw number. */
    function frac(b,c){
      const a=bx[b].nums, L=a.length;
      if(L<=1) return 0.5;
      if(c<=a[0]) return 0; if(c>=a[L-1]) return 1;
      let i=0; while(i<L-1 && a[i+1]<=c) i++;
      return (i + (c-a[i])/(a[i+1]-a[i])) / (L-1);
    }
    const X = (b,c)=> bx[b].x0 + (INSET + frac(b,c)*(1-2*INSET)) * bx[b].w;
    return {cast, books, bx, X, W, scrollable: W > avail + 1};
  },[mappedBooks.join(','), vw]);

  /* horizon clipping + density filter */
  const horizonEnd = pkey({b:horizon, c:999});
  const inHorizon = model.cast.filter(c=> c.intro.b <= horizon);
  const recurCount = inHorizon.filter(c=>c.recurring).length;
  const visible = inHorizon.filter(c=> density==='all' ? true : c.recurring);
  const lanesAll = CF_LANES.map(L=>{
    const rows = visible.filter(c=>c.lane===L.key)
      .sort((a,b)=> pkey(a.intro)-pkey(b.intro) || a.e.name.localeCompare(b.e.name));
    return {...L, rows, collapsed:!!collapsed[L.key]};
  }).filter(L=>L.rows.length);

  /* y geometry */
  const ROW = 30, LANE_H = 64, COLLAPSED_H = 30, GAP = 18, TOP = 14;
  let y = TOP;
  const lanes = lanesAll.map(L=>{
    const yy = y;
    y += L.collapsed ? (COLLAPSED_H + GAP) : (LANE_H + L.rows.length*ROW + GAP);
    return {...L, y:yy};
  });
  const H = y + 8;
  const {books, X, W} = model;
  const deaths = visible.filter(c=> c.death && pkey(c.death) <= horizonEnd).length;
  const killCount = visible.reduce((n,c)=> n + c.kills.filter(k=>pkey(k)<=horizonEnd).length, 0);

  /* keep the sticky axis aligned with the horizontally-scrolled chart */
  function syncScroll(){
    if(canvasRef.current && axisRef.current)
      canvasRef.current.style.transform = `translateX(${-axisRef.current.scrollLeft}px)`;
  }
  useEffect(()=>{ syncScroll(); },[model.W, horizon, density]);
  function onWheel(ev){
    if(!model.scrollable || !axisRef.current) return;
    if(Math.abs(ev.deltaX) > Math.abs(ev.deltaY)){ axisRef.current.scrollLeft += ev.deltaX; syncScroll(); }
  }

  function onMove(ev){
    if(!boxRef.current) return;
    const r = boxRef.current.getBoundingClientRect();
    const id = ev.target.closest && ev.target.closest('[data-cf]') ? ev.target.closest('[data-cf]').getAttribute('data-cf') : null;
    if(id) setHover({id, x: ev.clientX - r.left, y: ev.clientY - r.top});
    else setHover(null);
  }
  const hc = hover ? visible.find(c=>c.e.id===hover.id) : null;
  const toggleLane = k => setCollapsed(s=>({...s, [k]:!s[k]}));

  return (
    <div className="view" data-screen-label="Character Timeline"><div className="wrap">
      <div className="crumbs">
        <a onClick={()=>go({view:'index'})}>Index</a><span className="sep">/</span>
        <span style={{color:'var(--ink)'}}>Character Timeline</span>
      </div>
      <div className="sec-top">
        <h2>Character <span className="am">Timeline</span></h2>
        <p className="note">{visible.length} of {inHorizon.length} cast members in introduction order, Book 1 → Book {horizon}. Each line is a
          character's run on the feed, from first appearance to their last logged beat. {deaths} confirmed death{deaths===1?'':'s'} (†)
          and {killCount} significant kill{killCount===1?'':'s'} by the main cast (⚔) shown.
          {model.scrollable ? ' Scroll sideways to follow the crawl deeper.' : ''}</p>
      </div>

      <div className="cf-bar">
        <div className="cf-legend">
          <span><i className="cf-li dot"></i> first appearance</span>
          <span><i className="cf-li beat"></i> arc beat</span>
          <span><i className="cf-li line"></i> active on the feed</span>
          <span><b className="cf-skull">†</b> death</span>
          <span><b className="cf-blade">⚔</b> killed a major character</span>
        </div>
        <div className="cf-controls">
          <div className="cf-seg" role="group" aria-label="Cast density">
            <button className={'cf-segbtn'+(density==='recurring'?' on':'')} onClick={()=>setDensity('recurring')}>Recurring · {recurCount}</button>
            <button className={'cf-segbtn'+(density==='all'?' on':'')} onClick={()=>setDensity('all')}>All · {inHorizon.length}</button>
          </div>
          <div className="cf-horizon">
            <span className="cf-hl">HORIZON</span>
            {books.map(b=>(
              <button key={b.n} className={'cf-hbtn'+(horizon===b.n?' on':'')} onClick={()=>setHorizon(b.n)}>
                {b.n===1 ? 'B1' : `B1–${b.n}`}
              </button>
            ))}
          </div>
        </div>
      </div>

      <div className="cf-axis" ref={axisRef} onScroll={syncScroll}>
        <div className="cf-axis-inner" style={{width:W}}>
          {books.filter(b=>b.n<=horizon).map(b=>(
            <div key={b.n} className="cf-axis-book" style={{width:b.w}}>
              <b>BOOK {b.n}</b><span>{b.title} · {b.chCount} ch</span>
            </div>
          ))}
          {horizon < books.length && <div className="cf-axis-sealed" style={{flex:1,minWidth:120}}>■ SEALED: raise horizon</div>}
        </div>
      </div>

      <div className="cf-box" ref={boxRef} onMouseMove={onMove} onMouseLeave={()=>setHover(null)} onWheel={onWheel}>
        <div className="cf-canvas" ref={canvasRef} style={{width:W}}>
        <svg className="cf-svg" viewBox={`0 0 ${W} ${H}`} width={W} height={H} style={{display:'block'}}>
          {/* book boundary gridlines + chapter ticks */}
          {books.map(b=>(
            <g key={b.n} opacity={b.n<=horizon?1:0}>
              <line x1={b.x0} y1={0} x2={b.x0} y2={H} stroke="var(--line-2)" strokeWidth="1"/>
              {b.nums.filter(c=>c>0 && c%10===0 && c<b.maxCh).map(c=>(
                <line key={c} x1={X(b.n,c)} y1={0} x2={X(b.n,c)} y2={H} stroke="var(--line)" strokeWidth="1" strokeDasharray="2 6"/>
              ))}
            </g>
          ))}
          {horizon < books.length && (
            <rect x={model.bx[horizon].x0 + model.bx[horizon].w} y={0}
              width={W - (model.bx[horizon].x0 + model.bx[horizon].w)} height={H} className="cf-seal"/>
          )}

          {lanes.map(L=>(
            <g key={L.key}>
              <g className="cf-lane-head" onClick={()=>toggleLane(L.key)} style={{cursor:'pointer'}}>
                <rect x={0} y={L.y} width={W} height={L.collapsed?COLLAPSED_H:46} fill="transparent"/>
                <text x={6} y={L.y+20} className="cf-lane-name" fill={L.color}>
                  <tspan className="cf-caret">{L.collapsed?'▸':'▾'}</tspan> {L.name.toUpperCase()}
                </text>
                {!L.collapsed && <text x={6} y={L.y+38} className="cf-lane-note">{L.note} · {L.rows.length}</text>}
                {L.collapsed && <text x={W-6} y={L.y+20} textAnchor="end" className="cf-lane-note">{L.rows.length} hidden, click to expand</text>}
              </g>
              {!L.collapsed && <line x1={4} y1={L.y+50} x2={W-4} y2={L.y+50} stroke={L.color} strokeOpacity=".35" strokeWidth="1"/>}
              {!L.collapsed && L.rows.map((c,i)=>{
                const cy = L.y + LANE_H + i*ROW + ROW - 9;
                const x1 = X(c.intro.b, c.intro.c);
                const lastVis = pkey(c.last) > horizonEnd ? {b:horizon, c:model.bx[horizon].maxCh} : c.last;
                const x2 = Math.max(X(lastVis.b, lastVis.c), x1);
                const dead = c.death && pkey(c.death) <= horizonEnd;
                const labelLeft = x1 > W*0.84;
                const dim = hover && hover.id!==c.e.id;
                return (
                  <g key={c.e.id} data-cf={c.e.id} className="cf-row" opacity={dim?0.32:1}
                     onClick={()=>go({view:'entry', id:c.e.id})}>
                    <rect x={0} y={cy-ROW+9} width={W} height={ROW} fill="transparent"/>
                    {x2>x1+1 && <line x1={x1} y1={cy} x2={x2} y2={cy} stroke={L.color} strokeWidth="2"
                      strokeOpacity=".75" strokeDasharray={pkey(c.last)>horizonEnd ? '3 4' : 'none'}/>}
                    {c.beats.filter(p=> pkey(p)<=horizonEnd && pkey(p)!==pkey(c.intro)).map((p,j)=>(
                      <circle key={j} cx={X(p.b,p.c)} cy={cy} r="2.4" fill={L.color} fillOpacity=".9"/>
                    ))}
                    <circle cx={x1} cy={cy} r="4" fill={L.color} stroke="var(--bg-0)" strokeWidth="1.5"/>
                    {c.kills.filter(k=> pkey(k)<=horizonEnd).map((k,j)=>(
                      <text key={'k'+j} x={X(k.b,k.c)} y={cy+5} textAnchor="middle" className="cf-blade">⚔</text>
                    ))}
                    {dead && <text x={X(c.death.b,c.death.c)} y={cy+5} textAnchor="middle" className="cf-cross">†</text>}
                    <text x={labelLeft ? x1-9 : x1+9} y={cy-7}
                      textAnchor={labelLeft?'end':'start'} className="cf-name">{c.e.name}</text>
                  </g>
                );
              })}
            </g>
          ))}
        </svg>
        </div>

        {hc && (
          <div className="cf-tip" style={{
            left: Math.min(Math.max(hover.x+14, 8), (boxRef.current?boxRef.current.clientWidth:800)-260),
            top: hover.y+16 }}>
            <div className="cf-tip-name">{hc.e.name}</div>
            <div className="cf-tip-sub">{hc.e.sub}</div>
            <div className="cf-tip-meta">
              <span>First seen <b>B{hc.intro.b}·C{hc.intro.c}</b></span>
              {pkey(hc.last)>pkey(hc.intro) && <span>Last logged <b>B{Math.min(hc.last.b,horizon)}·C{pkey(hc.last)>horizonEnd?'??':hc.last.c}</b></span>}
              {hc.death && pkey(hc.death)<=horizonEnd && <span className="cf-skull">† Dies B{hc.death.b}·C{hc.death.c}</span>}
            </div>
            {hc.kills.filter(k=>pkey(k)<=horizonEnd).length>0 && (
              <div className="cf-tip-kills">
                <div className="cf-tip-klabel"><b className="cf-blade">⚔</b> Kills</div>
                {hc.kills.filter(k=>pkey(k)<=horizonEnd).map((k,j)=>(
                  <div key={j} className="cf-tip-krow"><b>B{k.b}·C{k.c}</b> {k.victim}</div>
                ))}
              </div>
            )}
            <div className="cf-tip-cta">click to open entry ▸</div>
          </div>
        )}
      </div>

      <p className="cf-foot">Each dot is a curated <b>arc beat</b> from the character's appearance map (first
        appearance and turning points) not every cameo; the full per-chapter occurrence log lives on
        each character's entry. Two fates are marked separately: a <b className="cf-skull">†</b> is where a
        character <b>dies and stays dead</b>; a <b className="cf-blade">⚔</b> is where a <b>main-cast member kills a
        significant character</b> who stays dead (the victim carries their own †, and hovering lists the kills).
        Both are validated against the source novels. Chapters between beats are interpolated, and a dashed tail
        means the character is still active past your spoiler horizon. Click a lane header to collapse it; switch
        to <b>All</b> to surface one-scene walk-ons; lower the horizon to hide later-book intros and fates.</p>
    </div></div>
  );
}

window.CharFlow = CharFlow;
})();
