// mariusson — Radial Filter Wheel
//
// Click anywhere on the atlas surface (outside of UI / artworks) and a
// translucent picker blooms at the click point. Each wedge is a piece of
// soft glass; the wheel itself is just the wedges (no separate disc behind).
//
// Rings, inside → outside: Country · Style · Method · Theme
//   • Country is the full inner ring (all 31 European regions).
//   • Style / Method / Theme are dynamic FANS that radiate out of the
//     wedge picked in the parent ring — the wheel grows like petals
//     unfolding toward the user's chosen direction, not as four
//     concentric full rings.
//
// No inline labels: the hub does all naming — hover a wedge, read it in
// the centre disc. Keeps the wheel calm even with 31 country slivers and
// the deeper rings cascading outward.
//
// Elastic edge: a RAF spring loop nudges each wedge toward the cursor
// (mouse or finger), so the wheel reads as a living surface.
//
// path schema: [country, style, method, theme]

const RW_LAYER_LABELS = ['Country', 'Style', 'Method', 'Theme'];
const RW_LAYER_FIELDS = ['region', 'style', 'method', 'theme'];

// Inner hub + four ring outer radii (px). The visible outer radius equals
// the outermost currently-exposed ring; Atlas reads this to push tiles
// away from the wheel's footprint.
const HUB_R = 80;
// Decreasing band widths as you go outward — Country gets the most
// real-estate (it has 31 wedges to show), deeper fans get thinner bands
// so the wheel feels denser/finer the further it cascades outward.
// Hub 80 + 62 + 52 + 42 + 32 = 268 px outer radius.
const RING_OUTERS = [142, 194, 236, 268];
const WHEEL_PADDING = 16;
const WHEEL_MAX_SIZE = (RING_OUTERS[3] + WHEEL_PADDING) * 2;

// Outer rings (Style / Method / Theme) are NOT full circles — they fan out
// of the wedge picked in the parent ring. Each wedge in an outer ring gets
// this fixed angular width; total fan arc = items × FAN_WEDGE_ANGLE.
// 18° gives a 180° fan for the typical 10-item ring — wide enough for
// comfortable touch targets, narrow enough that the wheel reads as
// "petals unfolding toward the user's pick" rather than another full ring.
const FAN_WEDGE_ANGLE = (18 * Math.PI) / 180;

const getRwCanonical = () => [
  window.REGIONS, window.STYLES, window.METHODS, window.THEMES,
];

// How many rings should currently be visible. Country is always on; each
// committed pick exposes the next ring outward, capped at 4.
function visibleRingCount(path) {
  const committed = path.filter(Boolean).length;
  return Math.min(4, committed + 1);
}

// The radius the wheel actually occupies on screen right now. Atlas uses
// this for tile repulsion.
function rwOuterRadius(path) {
  return RING_OUTERS[visibleRingCount(path) - 1];
}

// Same drill-down maths as before — items only appear in a ring if at
// least one work matches the path so far. Each item: { value, count }.
function rwComputeAvailable(catalogue, path, ringIdx) {
  const ordering = getRwCanonical();
  const counts = new Map();
  for (const w of catalogue) {
    let ok = true;
    for (let i = 0; i < ringIdx; i++) {
      if (path[i] && w[RW_LAYER_FIELDS[i]] !== path[i]) { ok = false; break; }
    }
    if (!ok) continue;
    const v = w[RW_LAYER_FIELDS[ringIdx]];
    counts.set(v, (counts.get(v) || 0) + 1);
  }
  const out = [];
  const order = ordering[ringIdx] || [];
  for (const v of order) {
    const n = counts.get(v);
    if (n) out.push({ value: v, count: n });
  }
  const known = new Set(order);
  const extras = [...counts.keys()].filter((v) => !known.has(v)).sort();
  for (const v of extras) out.push({ value: v, count: counts.get(v) });
  return out;
}

// SVG arc path for an annular wedge segment between r0 and r1, spanning
// angles a0..a1 (radians, measured clockwise from 12 o'clock).
function annularPath(r0, r1, a0, a1) {
  // Pad each wedge by a hair so adjacent wedges show a thin ink gap.
  const polar = (r, a) => ({ x: Math.cos(a) * r, y: Math.sin(a) * r });
  const sweep = a1 - a0;
  const large = sweep > Math.PI ? 1 : 0;
  const p0 = polar(r0, a0);
  const p1 = polar(r1, a0);
  const p2 = polar(r1, a1);
  const p3 = polar(r0, a1);
  return [
    `M ${p0.x.toFixed(2)} ${p0.y.toFixed(2)}`,
    `L ${p1.x.toFixed(2)} ${p1.y.toFixed(2)}`,
    `A ${r1} ${r1} 0 ${large} 1 ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`,
    `L ${p3.x.toFixed(2)} ${p3.y.toFixed(2)}`,
    `A ${r0} ${r0} 0 ${large} 0 ${p0.x.toFixed(2)} ${p0.y.toFixed(2)}`,
    'Z',
  ].join(' ');
}

// Compute per-ring angular geometry. Ring 0 (Country) is always a full
// circle. Outer rings are dynamic fans: each wedge is FAN_WEDGE_ANGLE
// wide, total arc = items × wedge angle, centred on the wedge picked in
// the previous ring. So picks cascade outward as a focused petal cone
// instead of stacking up as four full concentric rings.
//
// Returns: [{ a0Base, step, n, fanArc, fanCenter }, …] one per visible ring.
function computeRingGeometry(ringsData, path) {
  const geom = [];
  let prevPickedAngle = 0;
  for (let i = 0; i < ringsData.length; i++) {
    const items = ringsData[i];
    const n = Math.max(items.length, 1);
    let fanArc, step, a0Base, fanCenter;
    if (i === 0) {
      fanArc = 2 * Math.PI;
      step = fanArc / n;
      a0Base = -Math.PI / 2;
      fanCenter = 0;
    } else {
      step = FAN_WEDGE_ANGLE;
      fanArc = n * step;
      fanCenter = prevPickedAngle;
      a0Base = fanCenter - fanArc / 2;
    }
    geom.push({ a0Base, step, n, fanArc, fanCenter });
    const pickedValue = path[i];
    if (pickedValue) {
      const idx = items.findIndex((it) => it.value === pickedValue);
      if (idx >= 0) prevPickedAngle = a0Base + (idx + 0.5) * step;
    }
  }
  return geom;
}

// One ring (or fan) of selectable wedges. Translucent fill per wedge so
// the wheel itself IS the glass — there's no separate disc behind it.
// Idle fills are the lightest paper-with-alpha; hover deepens; selected
// is an accent-with-alpha so the colour stays glassy even when locked in.
function Ring({ items, ringIdx, innerR, outerR, a0Base, step, selectedValue, onPick, onHoverItem }) {
  // Per-ring fill alpha — outer rings noticeably less transparent than
  // inner ones, but ALL rings clearly more opaque than the old uniform
  // 0.28 so the wedges read as proper frosted glass.
  const IDLE_ALPHAS  = [0.36, 0.50, 0.64, 0.76];
  const HOVER_ALPHAS = [0.18, 0.26, 0.34, 0.42];
  const idleA = IDLE_ALPHAS[ringIdx] ?? 0.50;
  const hovA  = HOVER_ALPHAS[ringIdx] ?? 0.30;
  const idleFill   = `rgba(250, 250, 247, ${idleA})`;
  const hoverFill  = `rgba(14, 14, 12, ${hovA})`;
  // Bright, glowing orange accent so the committed wedge pops.
  const selFill    = 'rgba(255, 122, 36, 0.80)';
  const idleStroke = 'rgba(14, 14, 12, 0.18)';
  const hoverStroke = 'rgba(14, 14, 12, 0.55)';
  const selStroke  = 'rgba(255, 145, 60, 1)';
  // Glow on selected paths — orange halo at two radii so it reads as
  // soft light rather than a hard outline.
  const selGlow = 'drop-shadow(0 0 14px rgba(255, 122, 36, 0.85)) drop-shadow(0 0 4px rgba(255, 145, 60, 0.7))';

  return (
    <g style={{
      animation: 'an-ring-grow 320ms cubic-bezier(0.22, 1, 0.36, 1) both',
    }}>
      {items.map((it, i) => {
        const a0 = a0Base + i * step;
        const a1 = a0 + step;
        const isSel = it.value === selectedValue;
        const dim = it.count === 0;
        const midAngle = a0 + step / 2;
        const baseFill = isSel ? selFill : idleFill;
        const baseStroke = isSel ? selStroke : idleStroke;
        return (
          <g key={it.value}>
            <path
              d={annularPath(innerR, outerR, a0 + 0.005, a1 - 0.005)}
              fill={baseFill}
              stroke={baseStroke}
              strokeWidth={isSel ? 1.4 : 0.6}
              data-rw-ring={ringIdx}
              data-rw-value={it.value}
              data-rw-count={it.count}
              data-rw-dim={dim ? '1' : '0'}
              style={{
                cursor: dim ? 'default' : 'pointer',
                transition: 'fill 220ms var(--ease-fluid), stroke 220ms var(--ease-fluid), stroke-width 220ms var(--ease-fluid), filter 260ms var(--ease-fluid)',
                opacity: dim ? 0.32 : 1,
                filter: isSel ? selGlow : 'none',
                touchAction: 'none',
              }}
              onPointerDown={(e) => {
                try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
              }}
              onPointerEnter={(e) => {
                onHoverItem({ ringIdx, item: it });
                if (!isSel) {
                  e.currentTarget.setAttribute('fill', hoverFill);
                  e.currentTarget.setAttribute('stroke', hoverStroke);
                  e.currentTarget.style.strokeWidth = '1';
                }
              }}
              onPointerLeave={(e) => {
                onHoverItem(null);
                if (!isSel) {
                  e.currentTarget.setAttribute('fill', baseFill);
                  e.currentTarget.setAttribute('stroke', baseStroke);
                  e.currentTarget.style.strokeWidth = '0.6';
                }
              }}
              onClick={(e) => {
                // Picking happens in the document-level pointerup hit-
                // test (works for mouse + touch in one path); this
                // handler only suppresses the synthesised click so it
                // doesn't bubble to the surface and toggle the wheel.
                e.stopPropagation();
              }}
            />
            {/* Selection is communicated by the wedge's orange fill +
                glowing stroke; no extra marker dot. */}
          </g>
        );
      })}
    </g>
  );
}


// Public component — Atlas wraps this in a mount-during-exit pattern so
// the wheel can play a bloom-in AND collapse-out animation. `visible`
// flips true a frame after mount, false during exit.
function RadialWheel({ anchor, path, setPath, catalogue, onClose, visible, mobile }) {
  const [hover, setHover] = React.useState(null);
  const wrapRef = React.useRef(null);
  // Wheel diameter at max depth is RING_OUTERS[3] × 2 = 536 px — way
  // bigger than a phone viewport. Scale the whole wrapper down so the
  // outer ring fits + wedges remain finger-sized.
  const mobileScale = mobile ? 0.82 : 1;

  const visibleRings = visibleRingCount(path);
  const outerRadius = rwOuterRadius(path);
  const accent = 'var(--accent)';

  // ── Whole-wheel lean ─────────────────────────────────────────
  // The RING STACK leans toward the cursor as one piece. The hub disc
  // and centred text DO NOT lean — they stay anchored so the text
  // doesn't drift off-centre.
  const leanGroupRef = React.useRef(null);
  const cursorRef = React.useRef({ has: false, x: 0, y: 0 });
  const tickRef = React.useRef(0);
  const leanRef = React.useRef({ cx: 0, cy: 0 });

  React.useEffect(() => {
    const MAX_LEAN = 9; // peak displacement in px
    const REACH = 360;  // distance at which lean saturates
    const tick = () => {
      tickRef.current = 0;
      const cursor = cursorRef.current;
      let tx = 0, ty = 0;
      if (cursor.has && visible) {
        const d = Math.hypot(cursor.x, cursor.y);
        if (d > 0.1) {
          const k = Math.min(1, d / REACH) * (MAX_LEAN / d);
          tx = cursor.x * k;
          ty = cursor.y * k;
        }
      }
      const lean = leanRef.current;
      lean.cx += (tx - lean.cx) * 0.12;
      lean.cy += (ty - lean.cy) * 0.12;
      if (leanGroupRef.current) {
        leanGroupRef.current.setAttribute(
          'transform',
          `translate(${lean.cx.toFixed(2)} ${lean.cy.toFixed(2)})`
        );
      }
      if (
        Math.abs(lean.cx - tx) > 0.05 ||
        Math.abs(lean.cy - ty) > 0.05
      ) {
        tickRef.current = requestAnimationFrame(tick);
      }
    };
    const kick = () => {
      if (!tickRef.current) tickRef.current = requestAnimationFrame(tick);
    };
    const onMove = (e) => {
      if (!wrapRef.current) return;
      const rect = wrapRef.current.getBoundingClientRect();
      const cx = rect.left + rect.width / 2;
      const cy = rect.top + rect.height / 2;
      cursorRef.current = { has: true, x: e.clientX - cx, y: e.clientY - cy };
      kick();
    };
    const onEnd = () => {
      cursorRef.current = { has: false, x: 0, y: 0 };
      kick();
    };
    document.addEventListener('pointermove', onMove);
    document.addEventListener('pointerleave', onEnd);
    document.addEventListener('pointercancel', onEnd);
    return () => {
      document.removeEventListener('pointermove', onMove);
      document.removeEventListener('pointerleave', onEnd);
      document.removeEventListener('pointercancel', onEnd);
      if (tickRef.current) cancelAnimationFrame(tickRef.current);
      tickRef.current = 0;
    };
  }, [visible]);

  // Build the wedge list for each visible ring. computeAvailable already
  // filters out zero-count entries; if the user's selected value somehow
  // doesn't survive (deeper filter shifted), append it with count 0 so
  // the chip can still be cleared.
  const ringsData = React.useMemo(() => {
    const data = [];
    for (let i = 0; i < visibleRings; i++) {
      const items = rwComputeAvailable(catalogue, path, i);
      const sel = path[i];
      if (sel && !items.some((it) => it.value === sel)) {
        items.push({ value: sel, count: 0 });
      }
      data.push(items);
    }
    return data;
  }, [catalogue, path, visibleRings]);

  // Ring geometry — full-circle ring 0, dynamic fans for ring 1+.
  const ringGeom = React.useMemo(
    () => computeRingGeometry(ringsData, path),
    [ringsData, path]
  );

  // Pick handler — same toggle/truncate semantics as the old wheel.
  const pick = (ringIdx, value) => {
    setPath((p) => {
      const np = [...p];
      if (np[ringIdx] === value) return np.slice(0, ringIdx);
      np[ringIdx] = value;
      return np.slice(0, ringIdx + 1);
    });
  };

  // ── Wheel scrub hit-test ─────────────────────────────────────
  // Each wedge carries data-rw-* attributes. While a pointer is held
  // (mouse or finger) over the wheel, we elementFromPoint at every
  // pointermove to figure out which wedge is currently under the
  // pointer — that drives the hub label live and decides the pick on
  // release. This is reliable on touch (where pointerenter on adjacent
  // wedges otherwise doesn't fire because of implicit capture).
  React.useEffect(() => {
    if (!visible) return;
    let activeId = null;
    let activeWedge = null;
    const findWedge = (x, y) => {
      const el = document.elementFromPoint(x, y);
      if (!el) return null;
      const p = el.closest && el.closest('[data-rw-ring]');
      if (!p) return null;
      const ringIdx = parseInt(p.getAttribute('data-rw-ring'), 10);
      const value = p.getAttribute('data-rw-value');
      const count = parseInt(p.getAttribute('data-rw-count') || '0', 10);
      const dim = p.getAttribute('data-rw-dim') === '1';
      if (!Number.isFinite(ringIdx) || !value) return null;
      return { ringIdx, value, count, dim };
    };
    // Commit a pick to `path` — used both during scrub (live preview)
    // and on release. Bails out early if the value is unchanged so we
    // don't re-render the surface on every pointer sample.
    const commit = (w) => {
      if (!w || w.dim) return;
      setPath((p) => {
        if (p[w.ringIdx] === w.value) return p;
        const np = [...p];
        np[w.ringIdx] = w.value;
        return np.slice(0, w.ringIdx + 1);
      });
    };
    const onDown = (e) => {
      const w = findWedge(e.clientX, e.clientY);
      if (!w) return;
      activeId = e.pointerId;
      activeWedge = w;
      setHover({ ringIdx: w.ringIdx, item: { value: w.value, count: w.count } });
      commit(w);
    };
    const onMove = (e) => {
      if (activeId !== e.pointerId) return;
      const w = findWedge(e.clientX, e.clientY);
      activeWedge = w;
      if (w) {
        setHover({ ringIdx: w.ringIdx, item: { value: w.value, count: w.count } });
        commit(w);
      } else {
        setHover(null);
      }
    };
    const onUp = (e) => {
      if (activeId !== e.pointerId) return;
      activeId = null;
      activeWedge = null;
      setHover(null);
      // No commit on release — the scrub already filtered live, so the
      // path matches whatever wedge the user last passed over.
    };
    document.addEventListener('pointerdown', onDown, true);
    document.addEventListener('pointermove', onMove, true);
    document.addEventListener('pointerup', onUp, true);
    document.addEventListener('pointercancel', onUp, true);
    return () => {
      document.removeEventListener('pointerdown', onDown, true);
      document.removeEventListener('pointermove', onMove, true);
      document.removeEventListener('pointerup', onUp, true);
      document.removeEventListener('pointercancel', onUp, true);
    };
  }, [visible, setPath]);

  // Esc closes the wheel. Outside-click handling lives up in Atlas (which
  // owns the click-anywhere → relocate-or-close behaviour); we don't try
  // to dismiss-on-outside-click from here.
  React.useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [onClose]);

  // Hub content — what to show in the centre disc. Priority:
  //   1. Hovered item → full name + count
  //   2. Most-recent path step → its label
  //   3. Idle prompt → "Pick a country"
  const hubLine = (() => {
    if (hover) {
      const layer = RW_LAYER_LABELS[hover.ringIdx];
      return { eyebrow: layer, value: hover.item.value, count: hover.item.count };
    }
    const lastCommittedIdx = path.reduce((acc, v, i) => v ? i : acc, -1);
    if (lastCommittedIdx >= 0) {
      const v = path[lastCommittedIdx];
      return { eyebrow: RW_LAYER_LABELS[lastCommittedIdx], value: v, count: null };
    }
    return { eyebrow: 'Filter', value: 'Pick a country', count: null };
  })();

  // The wheel sits inside the atlas surface (a child of [data-atlas-surface]).
  // Positioning uses `top:50% / left:50%` + a translate by anchor (atlas-
  // surface coords measured from centre), so the wheel naturally tracks
  // the centre of the surface even if it resizes.
  return (
    <div
      ref={wrapRef}
      data-atlas-panel
      data-rw-wheel
      onClick={(e) => e.stopPropagation()}
      onMouseDown={(e) => e.stopPropagation()}
      style={{
        position: 'absolute',
        top: '50%',
        left: '50%',
        width: WHEEL_MAX_SIZE,
        height: WHEEL_MAX_SIZE,
        marginLeft: -WHEEL_MAX_SIZE / 2,
        marginTop: -WHEEL_MAX_SIZE / 2,
        transform: `translate(${anchor.ax}px, ${anchor.ay}px) scale(${(visible ? 1 : 0.55) * mobileScale})`,
        opacity: visible ? 1 : 0,
        // Elastic open (slight overshoot) + smooth close. `transform`
        // and `opacity` both transition; nothing inside re-fires on
        // every render so the animation is reliable across mounts.
        transition: visible
          ? 'transform 460ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 220ms cubic-bezier(0.22, 1, 0.36, 1)'
          : 'transform 280ms cubic-bezier(0.4, 0, 0.6, 1), opacity 220ms cubic-bezier(0.4, 0, 0.6, 1)',
        transformOrigin: 'center center',
        willChange: 'transform, opacity',
        zIndex: 26,
        // Wrapper is pass-through. A circular click-eater inside catches
        // dead-air clicks within the wheel's actual disc; everything
        // outside that disc (tile corners, surface) keeps its clicks.
        pointerEvents: 'none',
      }}
    >
      <svg
        width={WHEEL_MAX_SIZE}
        height={WHEEL_MAX_SIZE}
        viewBox={`${-WHEEL_MAX_SIZE / 2} ${-WHEEL_MAX_SIZE / 2} ${WHEEL_MAX_SIZE} ${WHEEL_MAX_SIZE}`}
        style={{
          position: 'absolute',
          inset: 0,
          overflow: 'visible',
          pointerEvents: 'none',
        }}
      >
        {/* Lean group — RAF writes a translate(…) here so the RING STACK
            slides as one piece toward the cursor. The hub disc lives
            OUTSIDE this group so the centred text never drifts. */}
        <g ref={leanGroupRef} style={{ pointerEvents: 'auto', willChange: 'transform' }}>
          {/* Ring stack — drawn outside-in so inner rings sit on top of any
              anti-aliasing seams. Each ring is keyed by ringIdx so React
              mounts a new <Ring> when one appears, firing its grow-in.
              Outer rings use computed fan geometry; ring 0 is full circle. */}
          {ringsData.slice().reverse().map((items, revIdx) => {
            const i = ringsData.length - 1 - revIdx;
            const innerR = i === 0 ? HUB_R : RING_OUTERS[i - 1];
            const outerR = RING_OUTERS[i];
            const g = ringGeom[i];
            return (
              <Ring
                key={`ring-${i}`}
                items={items}
                ringIdx={i}
                innerR={innerR}
                outerR={outerR}
                a0Base={g.a0Base}
                step={g.step}
                selectedValue={path[i]}
                onPick={pick}
                onHoverItem={setHover}
              />
            );
          })}
        </g>

        {/* Hub disc — outside the lean group, so the centre is fixed.
            Drawn at HUB_R + 12 so it overlaps the inner edge of the
            Country ring by enough to hide the gap that would otherwise
            appear there when the ring stack leans. */}
        <circle r={HUB_R + 12} cx="0" cy="0" fill="rgba(250, 250, 247, 0.86)" stroke="rgba(14, 14, 12, 0.18)" strokeWidth="0.8" />
      </svg>

      {/* Hub HTML overlay — easier to lay out type and a close button in
          DOM than in SVG. Sits centred over the SVG hub circle and DOES
          NOT lean, so the text never drifts off-centre. We deliberately
          use `alignItems: center` (not stretch) + auto-width children so
          horizontal centring can't be broken by an upstream width rule. */}
      <div style={{
        position: 'absolute',
        top: '50%',
        left: '50%',
        width: HUB_R * 2,
        height: HUB_R * 2,
        marginLeft: -HUB_R,
        marginTop: -HUB_R,
        borderRadius: '50%',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center',
        boxSizing: 'border-box',
        pointerEvents: 'auto',
        textAlign: 'center',
      }}>
        <div style={{
          maxWidth: HUB_R * 2 - 24,
          textAlign: 'center',
          fontFamily: 'var(--mono)',
          fontSize: 10,
          letterSpacing: '0.20em',
          textTransform: 'uppercase',
          color: 'var(--graphite)',
          marginBottom: 6,
          whiteSpace: 'nowrap',
          overflow: 'hidden',
          textOverflow: 'ellipsis',
        }}>{hubLine.eyebrow}</div>
        <div style={{
          maxWidth: HUB_R * 2 - 24,
          textAlign: 'center',
          fontFamily: 'var(--serif)',
          fontStyle: hover ? 'normal' : 'italic',
          fontSize: hubLine.value.length > 16 ? 16 : 20,
          color: 'var(--ink)',
          lineHeight: 1.12,
          letterSpacing: '-0.01em',
          whiteSpace: 'nowrap',
          overflow: 'hidden',
          textOverflow: 'ellipsis',
        }}>{hubLine.value}</div>
        {/* Action chip — orange pill that's ALWAYS in the same slot so the
            hub layout never flickers when the count line below changes.
            When there are filters to clear it reads "Clear"; once cleared
            it collapses to a round × that closes the wheel. */}
        {(() => {
          const hasFilters = path.some(Boolean);
          const onTap = (e) => {
            e.stopPropagation();
            if (hasFilters) setPath([]);
            else onClose();
          };
          const isCircle = !hasFilters;
          return (
            <button
              onClick={onTap}
              title={hasFilters ? 'Clear filters' : 'Close'}
              aria-label={hasFilters ? 'Clear filters' : 'Close filter wheel'}
              style={{
                alignSelf: 'center',
                marginTop: 12,
                width: isCircle ? 26 : 'auto',
                height: isCircle ? 26 : 'auto',
                background: 'rgba(255, 122, 36, 0.92)',
                border: 'none',
                borderRadius: 'var(--r-pill)',
                padding: isCircle ? 0 : '5px 14px',
                fontFamily: 'var(--mono)',
                fontSize: isCircle ? 14 : 9.5,
                fontWeight: isCircle ? 400 : 500,
                lineHeight: 1,
                letterSpacing: isCircle ? 0 : '0.18em',
                textTransform: 'uppercase',
                color: 'var(--paper)',
                cursor: 'pointer',
                display: 'inline-flex',
                alignItems: 'center',
                justifyContent: 'center',
                boxShadow: '0 0 12px rgba(255, 122, 36, 0.55), 0 2px 6px rgba(0, 0, 0, 0.10)',
                transition: 'box-shadow 160ms var(--ease-fluid)',
              }}
              onMouseEnter={(e) => {
                e.currentTarget.style.boxShadow = '0 0 18px rgba(255, 122, 36, 0.78), 0 2px 8px rgba(0, 0, 0, 0.14)';
              }}
              onMouseLeave={(e) => {
                e.currentTarget.style.boxShadow = '0 0 12px rgba(255, 122, 36, 0.55), 0 2px 6px rgba(0, 0, 0, 0.10)';
              }}
            >{hasFilters ? 'Clear' : '×'}</button>
          );
        })()}
        {/* Count line — sits in the flex flow with reserved height so
            appearing/disappearing doesn't push the column. */}
        <div style={{
          maxWidth: HUB_R * 2 - 24,
          textAlign: 'center',
          fontFamily: 'var(--mono)',
          fontSize: 10,
          letterSpacing: '0.14em',
          color: hubLine.count === 0 ? 'var(--graphite-2)' : 'var(--graphite)',
          marginTop: 8,
          minHeight: 14,
          pointerEvents: 'none',
          opacity: hubLine.count != null ? 1 : 0,
          transition: 'opacity 160ms var(--ease-fluid)',
        }}>
          {hubLine.count != null ? `${hubLine.count} ${hubLine.count === 1 ? 'work' : 'works'}` : ' '}
        </div>
      </div>

    </div>
  );
}

Object.assign(window, { RadialWheel, rwOuterRadius });
