/* ============================================================================
   MATRIA, interactive Knowledge Map v2.
   Women connect to women, chronologically, through the themes of their work.
   · solid gold edges between women, labelled with the connecting theme
   · influence pulses travel from the earlier woman to the later one
   · tap a woman: highlight her lineage (educational focus mode), then expand
   · dashed spokes = question to archive voice; thin lines = voice to theme
   · interactive legend: tap a type to filter it in/out
   ============================================================================ */

function layoutRing(centerId, ids, positions, R, startAngle=-Math.PI/2){
  const next = {...positions};
  if(!next[centerId]) next[centerId] = {x:0,y:0};
  const fresh = ids.filter(id=>!next[id]);
  const n = fresh.length;
  fresh.forEach((id,i)=>{
    const a = startAngle + (i/Math.max(n,1))*Math.PI*2;
    next[id] = {x:Math.cos(a)*R, y:Math.sin(a)*R};
  });
  return next;
}

function KnowledgeMap({question, nodeIds, onOpenNode, variant='mobile', filterState, onFilterChange, legendOpen, onLegendClose, traceOpen=true}){
  const D = window.MATRIA;
  const isPreview = variant==='preview';
  const canvasRef = useRef(null);
  const ringR = variant==='ipad'? 250 : (isPreview? 118 : 155);
  const [expanded, setExpanded] = useState({});
  const [focusId, setFocusId] = useState(null);          // educational lineage focus
  const [positions, setPositions] = useState(()=> layoutRing('__q', nodeIds, {'__q':{x:0,y:0}}, ringR));
  const [view, setView] = useState({x:0, y:0, z: variant==='ipad'?1:(isPreview?0.92:0.74)});
  const drag = useRef(null);
  const moved = useRef(false);
  const pointers = useRef(new Map());   // active pointers on the canvas, for pinch
  const pinch = useRef(null);
  const baseZ = variant==='ipad'?1:(isPreview?0.92:0.74);

  const visibleIds = useMemo(()=>{
    const set = new Set(nodeIds);
    Object.keys(expanded).forEach(id=>{
      const node = D.get(id);
      if(node&&node.related) node.related.forEach(r=>{ if(D.get(r)) set.add(r); });
      // pull in themed lineage partners (contemporaries and influences) so
      // tapping a woman traces her links across time and geography
      D.LINKS.forEach(l=>{ if(l.a===id&&D.get(l.b)) set.add(l.b); if(l.b===id&&D.get(l.a)) set.add(l.a); });
      // and her documented relations (women she read, wrote about, etc.)
      (D.RELATIONS||[]).forEach(r=>{ if(r.a===id&&D.get(r.b)) set.add(r.b); if(r.b===id&&D.get(r.a)) set.add(r.a); });
    });
    return [...set];
  },[expanded, nodeIds, D]);

  /* edge sets:
     spokes , question → first-ring nodes (dashed, faint)
     wlinks , woman ↔ woman themed links (solid gold, labelled, pulsing)
     tlinks , woman/idea expansion links (thin) */
  const spokes = useMemo(()=> nodeIds.map(id=>['__q',id]), [nodeIds]);
  const wlinks = useMemo(()=> D.linksFor(visibleIds), [visibleIds, D]);
  // documented relations among visible women: the bold, sourced edge layer
  const relEdges = useMemo(()=>{ const vis=new Set(visibleIds); return (D.RELATIONS||[]).filter(r=>vis.has(r.a)&&vis.has(r.b)); }, [visibleIds, D]);
  const tlinks = useMemo(()=>{
    const e=[]; const vis=new Set(visibleIds);
    const inW = new Set(); wlinks.forEach(l=>{inW.add(l.a+'|'+l.b); inW.add(l.b+'|'+l.a);});
    Object.keys(expanded).forEach(id=>{
      const node=D.get(id);
      if(node&&node.related) node.related.forEach(r=>{
        if(vis.has(r) && !inW.has(id+'|'+r)) e.push([id,r]);
      });
    });
    return e;
  },[visibleIds, expanded, wlinks, D]);

  /* lineage of the focused woman: her themed links + partners */
  const lineage = useMemo(()=>{
    if(!focusId) return null;
    const partners = new Set([focusId]);
    const links = wlinks.filter(l=>l.a===focusId||l.b===focusId);
    links.forEach(l=>{partners.add(l.a);partners.add(l.b);});
    // also her theme nodes
    const node = D.get(focusId);
    const themes = new Set();
    if(node&&node.related) node.related.forEach(r=>{ if(D.THEME_NODES.includes(r)) themes.add(r); });
    return {partners, links, themes};
  },[focusId, wlinks, D]);

  const filt = filterState || {};
  const anyFilterOn = Object.values(filt).some(Boolean);
  const isHidden = id => {
    if(id==='__q') return false;
    const t = D.get(id)?.type;
    return anyFilterOn && t && !filt[t];
  };
  const isDimmed = id => {
    if(isHidden(id)) return true;
    if(!lineage || id==='__q') return false;
    return !(lineage.partners.has(id) || lineage.themes.has(id));
  };
  const labelClearance = variant==='ipad' ? 34 : 24;
  function edgeLabelPoint(pa, pb, t, offset){
    const dx=pb.x-pa.x, dy=pb.y-pa.y;
    const len=Math.max(1, Math.hypot(dx,dy));
    const nx=-dy/len, ny=dx/len;
    return {x:pa.x+dx*t+nx*offset, y:pa.y+dy*t+ny*offset};
  }
  function labelNearNode(x,y,skipA,skipB){
    return visibleIds.some(id=>{
      if(id===skipA || id===skipB || isHidden(id)) return false;
      const p=positions[id]; if(!p) return false;
      return Math.hypot(p.x-x,p.y-y) < labelClearance;
    });
  }
  useEffect(()=>{
    if(focusId && isHidden(focusId)) setFocusId(null);
  }, [focusId, anyFilterOn, filt]);

  function expandNode(id){
    const node=D.get(id); if(!node||!node.related) return;
    setPositions(p=>{
      const partnerIds = D.LINKS.flatMap(l=> l.a===id?[l.b] : l.b===id?[l.a] : []).filter(r=>D.get(r));
      const relPartners = (D.RELATIONS||[]).flatMap(r=> r.a===id?[r.b] : r.b===id?[r.a] : []).filter(r=>D.get(r));
      const childIds = [...new Set([...node.related.filter(r=>D.get(r)), ...partnerIds, ...relPartners])];
      const base = p[id]||{x:0,y:0};
      const next={...p};
      const fresh = childIds.filter(c=>!next[c]);
      fresh.forEach((c,i)=>{
        // wider fan + a per-ring radius stagger so partners (and their labels)
        // spread out instead of bunching into one another
        const a = Math.atan2(base.y,base.x) + (i-(fresh.length-1)/2)*0.66;
        const r = (variant==='ipad'?165:110) + (i%2)*26;
        next[c]={x:base.x+Math.cos(a)*r, y:base.y+Math.sin(a)*r};
      });
      return next;
    });
    setExpanded(e=>({...e,[id]:true}));
  }

  function onNodePointerDown(e, id){
    e.stopPropagation();
    e.currentTarget.setPointerCapture?.(e.pointerId);
    moved.current=false;
    drag.current={id, startX:e.clientX, startY:e.clientY, orig:positions[id]||{x:0,y:0}};
  }
  function onBgPointerDown(e){
    pointers.current.set(e.pointerId, {x:e.clientX, y:e.clientY});
    if(pointers.current.size===2){
      const p=[...pointers.current.values()];
      pinch.current={dist:Math.hypot(p[0].x-p[1].x, p[0].y-p[1].y), z:view.z};
      drag.current=null;                              // two fingers: pinch, not pan
    }else{
      drag.current={pan:true, startX:e.clientX, startY:e.clientY, orig:{x:view.x,y:view.y}};
    }
  }
  useEffect(()=>{
    function move(e){
      if(pointers.current.has(e.pointerId)) pointers.current.set(e.pointerId, {x:e.clientX, y:e.clientY});
      // two-finger pinch to zoom
      if(pinch.current && pointers.current.size>=2){
        const p=[...pointers.current.values()];
        const dist=Math.hypot(p[0].x-p[1].x, p[0].y-p[1].y);
        moved.current=true;
        const z=Math.min(2.4, Math.max(0.4, pinch.current.z*(dist/(pinch.current.dist||1))));
        setView(v=>({...v, z}));
        return;
      }
      if(!drag.current) return;
      const dx=e.clientX-drag.current.startX, dy=e.clientY-drag.current.startY;
      if(Math.abs(dx)+Math.abs(dy)>4) moved.current=true;
      if(drag.current.pan){
        setView(v=>({...v, x:drag.current.orig.x+dx, y:drag.current.orig.y+dy}));
      }else if(drag.current.id){
        const z=view.z;
        setPositions(p=>({...p,[drag.current.id]:{x:drag.current.orig.x+dx/z, y:drag.current.orig.y+dy/z}}));
      }
    }
    function up(e){ pointers.current.delete(e.pointerId); if(pointers.current.size<2) pinch.current=null; drag.current=null; }
    window.addEventListener('pointermove',move);
    window.addEventListener('pointerup',up);
    window.addEventListener('pointercancel',up);
    return ()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',up);window.removeEventListener('pointercancel',up);};
  },[view.z]);

  function onWheel(e){
    e.preventDefault();
    setView(v=>{
      const dz = Math.exp(-e.deltaY*0.0014);
      const z = Math.min(2.4, Math.max(0.4, v.z*dz));
      return {...v, z};
    });
  }

  /* tap pedagogy:
     1st tap on a woman → focus her lineage (highlight, labels, pulses) + expand
     tap on the focused woman → open her source page
     tap on another archive voice or idea → expand, then open */
  function nodeTap(id){
    if(isPreview || moved.current) return;
    const node=D.get(id);
    const isPerson = node && node.type==='person';
    if(isPerson){
      if(focusId===id){ onOpenNode && onOpenNode(id); return; }
      setFocusId(id);
      if(!expanded[id]) expandNode(id);
    }else{
      if(!expanded[id]) expandNode(id);
      else onOpenNode && onOpenNode(id);
    }
  }
  function bgClick(){ if(!moved.current) setFocusId(null); }

  const traceMode = traceOpen && !focusId && !isPreview;
  const focusCaption = useMemo(()=>{
    if(!focusId) return '';
    const node = D.get(focusId);
    if(!node) return '';
    const period = node.period && node.period !== '-' ? ` (${node.period})` : '';
    const themeLink = lineage?.links?.[0];
    if(themeLink?.theme) return `${node.title}${period} · linked through ${themeLink.theme}`;
    return `${node.title}${period}, her lineage, traced through shared themes`;
  },[focusId, lineage, D]);

  function zoomIn(){ setView(v=>({...v, z:Math.min(2.4, v.z*1.2)})); }
  function zoomOut(){ setView(v=>({...v, z:Math.max(0.4, v.z/1.2)})); }
  function zoomFit(){ setView({x:0, y:0, z:baseZ}); }

  return (
    <div className="kmap" ref={canvasRef}
      style={{position:'absolute', inset:0, overflow:'hidden', touchAction:isPreview?'auto':'none', cursor:isPreview?'default':'grab', pointerEvents:isPreview?'none':'auto'}}
      onPointerDown={isPreview?undefined:onBgPointerDown} onWheel={isPreview?undefined:onWheel} onClick={isPreview?undefined:bgClick}>
      <style>{`
        @keyframes edgeDraw{from{stroke-dashoffset:1;}to{stroke-dashoffset:0;}}
        @keyframes nodeIn{from{opacity:0;transform:scale(.6);}to{opacity:1;transform:scale(1);}}
        .kmap .wedge{stroke-dasharray:1;stroke-dashoffset:1;animation:edgeDraw 1.4s cubic-bezier(.4,0,.1,1) forwards;}
        @media (prefers-reduced-motion:reduce){.kmap .wedge{animation:none;stroke-dashoffset:0;}}
      `}</style>
      <div style={{position:'absolute', left:'50%', top:'50%',
        transform:`translate(${view.x}px,${view.y}px) scale(${view.z})`, transformOrigin:'0 0'}}>
        <svg width="2400" height="2400" viewBox="-1200 -1200 2400 2400"
          style={{position:'absolute', left:-1200, top:-1200, pointerEvents:'none'}}>
          {/* dashed spokes: question to first-ring archive voices */}
          {spokes.map(([a,b],i)=>{
            const pb=positions[b]; if(!pb) return null;
            if(isHidden(b)) return null;
            const dim = lineage && !lineage.partners.has(b) && !lineage.themes.has(b);
            return <line key={'s'+i} x1={0} y1={0} x2={pb.x} y2={pb.y}
              stroke="rgba(200,160,100,.26)" strokeWidth="1" strokeDasharray="2 4" opacity={dim?0.18:1}/>;
          })}
          {/* thin expansion links */}
          {tlinks.map(([a,b],i)=>{
            const pa=positions[a], pb=positions[b];
            if(!pa||!pb||isHidden(a)||isHidden(b)) return null;
            const dim = lineage && !(lineage.partners.has(a)&&lineage.themes.has(b)) && !(lineage.partners.has(b)&&lineage.themes.has(a)) && !(lineage.partners.has(a)&&lineage.partners.has(b));
            return <line key={'t'+i} x1={pa.x} y1={pa.y} x2={pb.x} y2={pb.y}
              stroke="rgba(200,160,100,.3)" strokeWidth=".8" opacity={dim?0.14:1}/>;
          })}
          {/* themed woman ↔ woman edges */}
          {wlinks.map((l,i)=>{
            const pa=positions[l.a], pb=positions[l.b];
            if(!pa||!pb||isHidden(l.a)||isHidden(l.b)) return null;
            const focusedEdge = !!lineage && lineage.partners.has(l.a) && lineage.partners.has(l.b) && (l.a===focusId||l.b===focusId);
            const lt = focusId===l.a?0.70 : focusId===l.b?0.30 : 0.5;
            const lp=edgeLabelPoint(pa,pb,lt,variant==='ipad'?18:14);
            const mx=lp.x, my=lp.y;
            const showLabel = (focusedEdge || traceMode) && !labelNearNode(mx,my,l.a,l.b);
            const groupOpacity = focusedEdge ? 1 : (traceMode ? 0.45 : 0.15);
            const labelOpacity = focusedEdge ? 1 : 0.35;
            return (
              <g key={'w'+i} opacity={groupOpacity} style={{transition:'opacity .3s'}}>
                <line className="wedge" pathLength="1" x1={pa.x} y1={pa.y} x2={pb.x} y2={pb.y}
                  stroke={focusedEdge? '#e9c887':'rgba(217,181,133,.7)'} strokeWidth={focusedEdge?1.6:1.1}/>
                {focusedEdge && <circle r={3} fill="#f0d9a8" opacity=".9">
                  <animateMotion dur={l.kind==='contemporary'?'4.5s':'3.2s'} repeatCount="indefinite"
                    path={`M ${pa.x} ${pa.y} L ${pb.x} ${pb.y}`+(l.kind==='contemporary'?` L ${pa.x} ${pa.y}`:'')}/>
                </circle>}
                {showLabel && <g opacity={labelOpacity}>
                  <rect x={mx-l.theme.length*2.7} y={my-8} width={l.theme.length*5.4} height={16} rx={8}
                    fill="rgba(15,16,22,.85)" stroke="rgba(200,160,100,.35)" strokeWidth=".6"/>
                  <text x={mx} y={my+3} textAnchor="middle" fontSize="8.6" fill="#dcba84"
                    fontFamily="var(--font-display)" fontStyle="italic">{l.theme}</text>
                </g>}
              </g>
            );
          })}
          {/* documented relations: the bold, sourced layer, drawn on top */}
          {relEdges.map((r,i)=>{
            const pa=positions[r.a], pb=positions[r.b];
            if(!pa||!pb||isHidden(r.a)||isHidden(r.b)) return null;
            const focusedEdge = !!lineage && lineage.partners.has(r.a) && lineage.partners.has(r.b) && (r.a===focusId||r.b===focusId);
            const rt = focusId===r.a?0.64 : focusId===r.b?0.36 : 0.5;
            const lp=edgeLabelPoint(pa,pb,rt,variant==='ipad'?20:16);
            const mx=lp.x, my=lp.y;
            const label=r.rel.replace(/-/g,' ');
            const showLabel = (focusedEdge || traceMode) && !labelNearNode(mx,my,r.a,r.b);
            const groupOpacity = focusedEdge ? 1 : (traceMode ? 0.45 : 0.18);
            const labelOpacity = focusedEdge ? 1 : 0.35;
            return (
              <g key={'r'+i} opacity={groupOpacity} style={{transition:'opacity .3s'}}>
                <line x1={pa.x} y1={pa.y} x2={pb.x} y2={pb.y} stroke="#f3e0b4" strokeWidth={focusedEdge?2:1.2} opacity="0.95"/>
                {showLabel && <g opacity={labelOpacity}>
                  <rect x={mx-label.length*2.8} y={my-8} width={label.length*5.6} height={16} rx={8}
                    fill="rgba(20,21,28,.92)" stroke="rgba(240,217,168,.6)" strokeWidth=".8"/>
                  <text x={mx} y={my+3} textAnchor="middle" fontSize="8.6" fill="#f3e0b4"
                    fontFamily="var(--font-display)" fontStyle="italic">{label}</text>
                </g>}
              </g>
            );
          })}
        </svg>
        <QNode text={question} variant={variant} dimmed={!!lineage}
          onPointerDown={isPreview?undefined:e=>onNodePointerDown(e,'__q')} />
        {visibleIds.map((id,idx)=>{
          const node=D.get(id); if(!node) return null;
          const p=positions[id]; if(!p) return null;
          if(isHidden(id)) return null;
          return <GNode key={id} node={node} pos={p} variant={variant} idx={idx}
            faded={false} dimmed={isDimmed(id)} focused={focusId===id} expanded={!!expanded[id]}
            onPointerDown={isPreview?undefined:e=>onNodePointerDown(e,id)}
            onClick={isPreview?undefined:e=>{e.stopPropagation(); nodeTap(id);}} />;
        })}
      </div>
      {focusId && D.get(focusId) && (
        <div key={focusId} className="map-caption" style={{position:'absolute', top:'auto', bottom:variant==='ipad'?18:16, left:'50%',
          transform:'translateX(-50%)', background:'rgba(15,16,22,.92)', border:'1px solid var(--line-gold)',
          borderRadius:18, padding:'7px 16px', fontSize:11, color:'var(--gold-bright)', whiteSpace:'nowrap',
          maxWidth:'calc(100% - 32px)', overflow:'hidden', textOverflow:'ellipsis',
          fontFamily:'var(--font-display)', fontStyle:'italic', pointerEvents:'none', zIndex:4}}>
          {focusCaption}
        </div>
      )}
      {!isPreview && (
        <div className="kmap-zoom" onPointerDown={e=>e.stopPropagation()} onClick={e=>e.stopPropagation()}>
          <button type="button" className="kmap-zoom__btn" onClick={zoomIn} aria-label="Zoom in"><Icon name="plus" size={16} stroke={1.8}/></button>
          <button type="button" className="kmap-zoom__btn" onClick={zoomOut} aria-label="Zoom out"><Icon name="minus" size={16} stroke={1.8}/></button>
          <button type="button" className="kmap-zoom__btn kmap-zoom__btn--fit" onClick={zoomFit} aria-label="Reset zoom">Fit</button>
        </div>
      )}
      {legendOpen && <Legend types={D.TYPES} state={filt} onChange={onFilterChange} onClose={onLegendClose}/>}
    </div>
  );
}

function QNode({text, variant, dimmed, onPointerDown}){
  const s = variant==='ipad'? 150 : (variant==='preview'? 72 : 104);
  const isPreview = variant==='preview';
  const isMobile = !isPreview && variant!=='ipad';
  const plain = (text||'').replace(/\n/g,' ');
  const long = plain.length > 28;
  const clamp = isPreview ? 3 : (isMobile ? 4 : undefined);
  return (
    <div onPointerDown={onPointerDown} style={{
      position:'absolute', left:0, top:0, width:s, height:s, marginLeft:-s/2, marginTop:-s/2,
      borderRadius:'50%', border:'1.5px solid var(--gold)',
      background:'radial-gradient(circle at 50% 35%, rgba(200,160,100,.18), rgba(200,160,100,.05))',
      display:'flex', alignItems:'center', justifyContent:'center', textAlign:'center',
      padding: variant==='ipad'?22:(variant==='preview'?8:16), cursor:onPointerDown?'grab':'default', boxShadow:'0 0 30px rgba(200,160,100,.18)',
      opacity:dimmed?0.35:1, transition:'opacity .3s'}}>
      <span style={{fontFamily:'var(--font-display)', color:'var(--gold-bright)',
        fontSize: variant==='ipad'?18:(isPreview?9:(long?11.5:13.5)), lineHeight:1.2, fontWeight:500,
        display:clamp?'-webkit-box':undefined, WebkitLineClamp:clamp, WebkitBoxOrient:clamp?'vertical':undefined,
        overflow:clamp?'hidden':undefined, wordBreak:'break-word', maxWidth:'100%'}}>{text}</span>
    </div>
  );
}

function GNode({node, pos, variant, idx, faded, dimmed, focused, expanded, onPointerDown, onClick}){
  const D=window.MATRIA;
  const color = D.TYPES[node.type].color;
  const big = variant==='ipad';
  const preview = variant==='preview';
  const s = big? 96 : (preview? 40 : 64);
  const hasPic = !!node.pic;
  const hasArt = !!node.art;
  return (
    <div onPointerDown={onPointerDown} onClick={onClick} style={{
      position:'absolute', left:pos.x, top:pos.y, width:s, marginLeft:-s/2, marginTop:-s/2,
      display:'flex', flexDirection:'column', alignItems:'center', gap:preview?2:5, cursor:onClick?'pointer':'default',
      opacity:faded?0.12:(dimmed?0.22:1), transition:'opacity .3s',
      animation:`nodeIn .5s ${idx*0.05}s cubic-bezier(.2,.8,.2,1) backwards`}}>
      <div style={{width:s, height:s, borderRadius:'50%',
        border:`${focused?2:1.3}px solid ${focused?'#e9c887':(expanded?color:'rgba(200,160,100,.45)')}`,
        background: hasPic? '#23242e' : 'radial-gradient(circle at 50% 35%, rgba(255,255,255,.05), rgba(255,255,255,.015))',
        display:'flex', alignItems:'center', justifyContent:'center', overflow:'hidden',
        boxShadow: focused? '0 0 26px rgba(233,200,135,.5)' : (expanded? `0 0 18px ${color}55`:'none'),
        transition:'box-shadow .3s, border-color .3s'}}>
        {hasPic
          ? <img className="portrait" src={node.pic} alt="" style={{width:'100%',height:'100%'}}/>
          : hasArt
          ? <img src={node.art} alt="" style={{width:'108%',height:'108%',objectFit:'cover',display:'block'}}/>
          : node.type==='idea'
          ? <span style={{color, opacity:.9}}><ConceptGlyph id={node.id} size={big?26:18}/></span>
          : <span style={{color, opacity:.9}}><TypeGlyph type={node.type} size={big?26:18}/></span>}
      </div>
      <span style={{fontFamily:'var(--font-display)', color:'var(--ctext)', textAlign:'center',
        fontSize: big?13:(preview?0:10.5), lineHeight:1.12, fontWeight:600, maxWidth:s+22,
        textShadow:'0 1px 4px rgba(15,16,22,.9)', display:preview?'none':undefined}}>{node.title}</span>
      {node.period && (focused||big) && <span style={{fontSize:big?10:8.5, color:'var(--ctext-faint)', marginTop:-2}}>{node.period}</span>}
    </div>
  );
}

/* interactive legend, tap a type to show only it (toggle) */
function EdgeLegendRow({swatch, label}){
  return (
    <div className="kmap-edge-legend__row">
      <span className="kmap-edge-legend__swatch" aria-hidden="true">{swatch}</span>
      <span className="kmap-edge-legend__label">{label}</span>
    </div>
  );
}

function Legend({types, state={}, onChange, onClose}){
  const anyOn = Object.values(state).some(Boolean);
  return (
    <div className="kmap-legend-panel"
      onPointerDown={e=>e.stopPropagation()} onClick={e=>e.stopPropagation()}>
      <div style={{display:'flex', alignItems:'center', justifyContent:'space-between', gap:16, marginBottom:8}}>
        <div className="label">Archive types · tap to filter</div>
        {onClose && <button onClick={onClose} aria-label="Close legend" style={{background:'none', border:'none', color:'var(--ctext-soft)', cursor:'pointer', fontSize:15, lineHeight:1, padding:'0 0 0 4px'}}>✕</button>}
      </div>
      <div style={{display:'flex', flexDirection:'column', gap:7}}>
        {Object.entries(types).map(([k,t])=>{
          const active = !anyOn || state[k];
          return (
            <button key={k} onClick={()=>onChange&&onChange(k)} style={{display:'flex', alignItems:'center', gap:8,
              background:'none', border:'none', padding:0, cursor:'pointer', opacity:active?1:0.35, transition:'opacity .2s'}}>
              <span style={{width:10,height:10,borderRadius:'50%',background:t.color,flex:'none'}}/>
              <span style={{fontSize:11.5, color:'var(--ctext)', fontFamily:'var(--font-body)'}}>{t.label}</span>
            </button>
          );
        })}
      </div>
      <div className="kmap-edge-legend">
        <div className="label" style={{marginBottom:8}}>Map lines</div>
        <EdgeLegendRow swatch={<svg width="28" height="8" viewBox="0 0 28 8"><line x1="0" y1="4" x2="28" y2="4" stroke="rgba(200,160,100,.5)" strokeWidth="1.5" strokeDasharray="3 4"/></svg>} label="Matched to centre"/>
        <EdgeLegendRow swatch={<svg width="28" height="8" viewBox="0 0 28 8"><line x1="0" y1="4" x2="28" y2="4" stroke="#d9b585" strokeWidth="1.5"/></svg>} label="Shared theme across women"/>
        <EdgeLegendRow swatch={<svg width="28" height="8" viewBox="0 0 28 8"><line x1="0" y1="4" x2="28" y2="4" stroke="#f3e0b4" strokeWidth="2.2"/></svg>} label="Documented relation (sourced)"/>
        <EdgeLegendRow swatch={<svg width="28" height="8" viewBox="0 0 28 8"><circle cx="8" cy="4" r="2.2" fill="#f0d9a8"/><line x1="0" y1="4" x2="28" y2="4" stroke="#d9b585" strokeWidth="1.2" opacity=".6"/></svg>} label="Pulse: earlier to later; loop = peers"/>
      </div>
    </div>
  );
}

function FilterPanel({types, state, onChange, onClose}){
  const anyOn = Object.values(state||{}).some(Boolean);
  return (
    <div style={{position:'absolute', inset:0, background:'rgba(12,13,18,.55)', zIndex:5,
      display:'flex', alignItems:'flex-end'}} onClick={onClose}>
      <div onClick={e=>e.stopPropagation()} style={{width:'100%', background:'#181a24',
        borderTop:'1px solid var(--line-gold)', borderRadius:'20px 20px 0 0', padding:'18px 22px 26px'}}>
        <div className="label" style={{marginBottom:14}}>Filter the map</div>
        <div style={{display:'flex', flexWrap:'wrap', gap:9}}>
          {Object.entries(types).map(([k,t])=>(
            <button key={k} className={'chip'+((!anyOn||state[k])?' on':'')} onClick={()=>onChange(k)}>
              <span style={{width:8,height:8,borderRadius:'50%',background:t.color,marginRight:7,display:'inline-block'}}/>
              {t.label}
            </button>
          ))}
        </div>
      </div>
    </div>
  );
}

Object.assign(window, {KnowledgeMap, FilterPanel, Legend});
