# Insight Cards

> Paged agent insights with scrubbable SVG charts and follow-up prompts.

- **Slug:** `insight-cards`
- **Kind:** component · **Category:** Data
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/InsightCards.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/insight-cards
- **All files in one bundle:** https://viewership.ai/ui/components/insight-cards.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/insight-cards.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy these files into `src/components/viewership/insight-cards/`:

   - `InsightCards.astro` (raw: https://viewership.ai/ui/raw/insight-cards/InsightCards.astro.txt)
   - `insight-chart.ts` (raw: https://viewership.ai/ui/raw/insight-cards/insight-chart.ts.txt)

npm packages: none.

## Usage

```astro
---
import InsightCards from "../components/viewership/insight-cards/InsightCards.astro";
---

<InsightCards />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import InsightCards from "./InsightCards.astro";
import type { InsightPage } from "./insight-chart";

const days = ["Mar 6", "Mar 7", "Mar 8", "Mar 9", "Mar 10", "Mar 11", "Mar 12", "Mar 13"];

const pages: InsightPage[] = [
  {
    key: "channels",
    prose: [
      "The weakest channel for ",
      { entity: "Northwind", color: "var(--vs-orange)" },
      " is Paid Social: down ",
      { mono: "-6%", tone: "red" },
      " or ",
      { mono: "-$2,453", tone: "red" },
      ".",
    ],
    card: {
      kind: "compare",
      caption: "Revenue vs. last week",
      series: [
        { name: "Paid Social", values: [-2.9, -3.4, -3.05, -3.86, -3.52, -4.1, -3.82, -4.41], sub: "-$2,377", tone: "red", color: "var(--vs-orange)" },
        { name: "Organic Search", values: [0.22, 0.58, 0.42, 0.91, 0.76, 1.08, 0.96, 1.15], sub: "+$617", tone: "green", color: "var(--vs-accent)" },
      ],
      labels: days,
    },
    pill: "Should I rebalance the channel mix?",
  },
  {
    key: "spend",
    prose: ["Unusually high ad spend on ", { strong: "Mar 13" }, ": ", { mono: "+$1,834", tone: "red" }, " above your daily average."],
    card: {
      kind: "anomaly",
      title: "High ad spend",
      metrics: [
        { key: "spend", label: "Spend", values: [274, 289, 264, 307, 331, 1210, 1718, 2112], format: { style: "money" }, threshold: "$2,112 threshold" },
        { key: "usage", label: "Usage", values: [18, 19, 17, 21, 22, 58, 81, 96], format: { style: "unit", unit: "clicks" }, threshold: "82 clicks threshold" },
      ],
      total: "$2,112 spent",
      delta: "+$1,834",
      deltaNote: "vs 3 weeks",
      labels: days,
    },
    pill: "Get tips on cutting wasted ad spend",
  },
  {
    key: "mix",
    prose: ["You're heavily reliant on ", { entity: "Organic Search", color: "var(--vs-orange)" }, ": it's ", { strong: "72.5%" }, " of your tracked revenue."],
    card: {
      kind: "allocation",
      title: "Organic Search allocation",
      monogram: "O",
      color: "var(--vs-orange)",
      segments: [
        { name: "ORGANIC", label: "Organic Search", pct: 72.5, amount: "$51,785", color: "var(--vs-orange)" },
        { name: "PAID", label: "Paid Search", pct: 22.8, amount: "$16,278", color: "var(--vs-line-strong)", tone: "var(--vs-ink-2)" },
        { name: "EMAIL", label: "Email", pct: 4.7, amount: "$3,357", color: "var(--vs-line)", tone: "var(--vs-ink-3)" },
      ],
      note: "Share of current attributed revenue. Choosing a channel changes the inspected group without moving the card.",
    },
    pill: "If we add a GEO campaign, what changes?",
  },
];
---

<InsightCards pages={pages} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `pages` | `InsightPage[]` |  | { key, prose, card, pill? }. Prose tokens: string, { entity, color? }, { mono, tone }, { strong }. Types are in insight-chart.ts. |
| `card (compare)` | `{ kind: "compare"; series: { name, values, sub, tone, color }[]; caption?; badge?; format?; labels? }` |  | Two-series comparison with a scrubbable line chart. |
| `card (anomaly)` | `{ kind: "anomaly"; title; metrics: { key, label, values, format, threshold }[]; total; delta; deltaNote; color?; labels? }` |  | Line chart with a metric toggle. |
| `card (allocation)` | `{ kind: "allocation"; title; segments: { name, label, pct, amount, color, tone? }[]; note; monogram?; color? }` |  | Segmented bar with legend and detail box. |
| `format` | `{ style: "percent" \| "money" \| "unit"; unit?; decimals?; signed?; currency? }` |  | Value format in tooltips, readouts and summaries. |
| `title` | `string` | `"Insights"` | Pager heading. |
| `page` | `number` | `0` | Initial page. |
| `autoplay / interval` | `boolean / number` | `false / 6000` | Advance pages while in view. Pauses on hover or focus, stops on interaction, off with reduced motion. |
| `Events` | `CustomEvent` |  | vs-insight-page { index, key }, vs-insight-ask { key, text }. |

## Source

### `src/components/viewership/insight-cards/InsightCards.astro`

```astro
---
/**
 * InsightCards: paged agent insights. Each page has a sentence with inline
 * entities and deltas, a mini chart and a suggested follow-up. Card kinds:
 * comparison chart, anomaly chart with a metric toggle, allocation bar.
 *
 * Ported from Beautiful UI `components/primitives/InsightCards.tsx`
 * (MIT, © 2026 Shane Levine).
 * Charts are native SVG (see insight-chart.ts) and render on the server.
 *
 * - Charts scrub by pointer, or focus (a slider) and use ←/→, Home/End;
 *   Escape clears. Each has a text summary.
 * - Charts draw in on scroll into view (instantly under reduced motion or
 *   without JS).
 * - `autoplay` pauses on hover/focus and stops on any interaction.
 *
 * Events (bubbling CustomEvents on the root):
 *   vs-insight-page  detail: { index, key }
 *   vs-insight-ask   detail: { key, text }      (the follow-up pill)
 *
 * @example
 * <InsightCards pages={pages} title="Insights" />
 */
import type { HTMLAttributes } from "astro/types";
import {
  areaPath,
  domain,
  formatValue,
  linePath,
  niceTicks,
  smooth,
  summarize,
  yOf,
  ANOMALY_BOX,
  COMPARE_BOX,
  type AnomalyCard,
  type CompareCard,
  type InsightPage,
  type ProseEntity,
  type ProseMono,
  type ProseStrong,
  type ProseToken,
} from "./insight-chart";

type Props = HTMLAttributes<"div"> & {
  pages: InsightPage[];
  /** Heading shown before the page count. */
  title?: string;
  /** Initially shown page. */
  page?: number;
  /** Advance pages automatically while in view. */
  autoplay?: boolean;
  /** Autoplay interval in ms. */
  interval?: number;
};

const { pages, title = "Insights", page = 0, autoplay = false, interval = 6000, class: className, ...rest } = Astro.props;

const uid = `vs-ic-${Math.random().toString(36).slice(2, 8)}`;
const start = Math.min(Math.max(0, page), Math.max(0, pages.length - 1));

const isEntity = (t: ProseToken): t is ProseEntity => typeof t === "object" && "entity" in t;
const isMono = (t: ProseToken): t is ProseMono => typeof t === "object" && "mono" in t;
const isStrong = (t: ProseToken): t is ProseStrong => typeof t === "object" && "strong" in t;

const compareChart = (card: CompareCard) => {
  const f = card.format ?? { style: "percent" as const };
  const dense = card.series.map((s) => smooth(s.values));
  const dom = domain(dense);
  return {
    f,
    dense,
    dom,
    lines: dense.map((d) => linePath(d, dom, COMPARE_BOX)),
    areas: dense.map((d) => areaPath(d, dom, COMPARE_BOX)),
    ends: dense.map((d) => yOf(d[d.length - 1] ?? 0, dom, COMPARE_BOX)),
    summary: summarize(card.series, f, card.labels),
    last: card.series.map((s) => s.values[s.values.length - 1] ?? 0),
  };
};
const anomalyChart = (card: AnomalyCard) => {
  const m = card.metrics[0];
  const dense = smooth(m.values);
  const dom = domain([dense]);
  return {
    m,
    line: linePath(dense, dom, ANOMALY_BOX),
    end: yOf(dense[dense.length - 1] ?? 0, dom, ANOMALY_BOX),
    ticks: niceTicks(dom, 3).map((v) => ({ v, y: yOf(v, dom, ANOMALY_BOX), label: formatValue(v, { ...m.format, signed: false }) })),
    summary: summarize([{ name: m.label, values: m.values }], m.format, card.labels),
  };
};
const xLabels = (labels?: string[]) =>
  labels?.length ? [labels[0], labels[Math.floor((labels.length - 1) / 2)], labels[labels.length - 1]] : null;
const chevrons = ["M15 18l-6-6 6-6", "M9 6l6 6-6 6"];
---

<div
  class:list={["vs-insight-cards", className]}
  data-autoplay={autoplay ? "" : undefined}
  data-interval={interval}
  {...rest}
>
  <script type="application/json" data-vs-config set:html={JSON.stringify({ pages, start })} />
  <div class="ic-pager">
    <h3 class="ic-heading">
      <span class="ic-heading-title">{title}</span>
      <span class="ic-heading-count"><span class="ic-sr">(</span>{pages.length}<span class="ic-sr"> total)</span></span>
    </h3>
    <span class="ic-pager-btns">
      {
        chevrons.map((d, i) => (
          <button type="button" class="ic-pager-btn" data-move={i === 0 ? -1 : 1} aria-label={i === 0 ? "Previous insight" : "Next insight"} aria-controls={`${uid}-slides`}>
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={d} /></svg>
          </button>
        ))
      }
    </span>
  </div>

  <div class="ic-slides" id={`${uid}-slides`} aria-live={autoplay ? "off" : "polite"} data-slides>
    {
      pages.map((p, pi) => {
        const card = p.card;
        return (
          <section class="ic-slide" role="group" aria-roledescription="insight" aria-label={`${pi + 1} of ${pages.length}`} hidden={pi !== start} data-slide={pi}>
            <p class="ic-prose">
              {p.prose.map((t) =>
                typeof t === "string" ? (
                  t
                ) : isEntity(t) ? (
                  <span class="ic-entity"><span class="ic-entity-dot" style={`background: ${t.color ?? "var(--vs-orange)"}`} aria-hidden="true" />@{t.entity}</span>
                ) : isMono(t) ? (
                  <code class:list={["ic-mono", `ic-tone-${t.tone}`]}>{t.mono}</code>
                ) : isStrong(t) ? (
                  <span class="ic-strong">{t.strong}</span>
                ) : null,
              )}
            </p>

            <div class="ic-card-wrap">
              {card.kind === "compare" && (() => {
                const c = compareChart(card);
                const xl = xLabels(card.labels);
                return (
                  <div class="ic-card">
                    <div class="ic-legend">
                      {card.series.map((s, i) => (
                        <div class="ic-legend-item">
                          <span class="ic-legend-name"><span class="ic-dot" style={`background: ${s.color}`} aria-hidden="true" />{s.name}</span>
                          <span class:list={["ic-big", `ic-tone-${s.tone}`]}>{formatValue(c.last[i], c.f)}</span>
                          <code class:list={["ic-mono", `ic-tone-${s.tone}`]}>{s.sub}</code>
                        </div>
                      ))}
                    </div>
                    <div class="ic-chart">
                      <div class="ic-chart-bar">
                        <span class="ic-caption">{card.caption ?? "Trend snapshot"}</span>
                        <span class="ic-badge">{card.badge ?? "Snapshot"}</span>
                      </div>
                      <div
                        class="ic-stage"
                        data-stage
                        data-page={pi}
                        tabindex="0"
                        role="slider"
                        aria-label={`${card.caption ?? "Trend"} chart. Use the arrow keys to scrub.`}
                        aria-describedby={`${uid}-sum-${pi}`}
                        aria-valuemin="0"
                        aria-valuemax={Math.max(0, card.series[0].values.length - 1)}
                        aria-valuenow={Math.max(0, card.series[0].values.length - 1)}
                        aria-valuetext={card.series.map((s, i) => `${s.name} ${formatValue(c.last[i], c.f)}`).join(", ")}
                      >
                        <svg class="ic-svg" viewBox="0 0 1000 166" preserveAspectRatio="none" aria-hidden="true">
                          <defs>
                            {card.series.map((s, i) => (
                              <linearGradient id={`${uid}-g${pi}-${i}`} x1="0" y1="0" x2="0" y2="1">
                                <stop offset="0" style={`stop-color: ${s.color}; stop-opacity: 0.1`} />
                                <stop offset="1" style={`stop-color: ${s.color}; stop-opacity: 0`} />
                              </linearGradient>
                            ))}
                          </defs>
                          <g class="ic-lines">
                            {card.series.map((s, i) => (
                              <>
                                <path class="ic-area" d={c.areas[i]} fill={`url(#${uid}-g${pi}-${i})`} data-area={i} />
                                <path class="ic-line" d={c.lines[i]} style={`stroke: ${s.color}`} data-line={i} />
                              </>
                            ))}
                          </g>
                        </svg>
                        {card.series.map((s, i) => (
                          <span class="ic-end" style={`top: ${c.ends[i].toFixed(1)}px; color: ${s.color}`} data-end={i} aria-hidden="true" />
                        ))}
                        {xl && <div class="ic-xlabels" aria-hidden="true">{xl.map((l) => <span>{l}</span>)}</div>}
                      </div>
                      <p class="ic-sr" id={`${uid}-sum-${pi}`}>{c.summary}</p>
                    </div>
                  </div>
                );
              })()}

              {card.kind === "anomaly" && (() => {
                const a = anomalyChart(card);
                const color = card.color ?? "var(--vs-red)";
                const xl = xLabels(card.labels);
                return (
                  <div class="ic-card">
                    <div class="ic-row">
                      <span class="ic-title">
                        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--vs-red)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
                        {card.title}
                      </span>
                      <span class="ic-badge">{card.badge ?? "Snapshot"}</span>
                    </div>
                    <div class="ic-chart">
                      <div class="ic-chart-bar">
                        <span class="ic-caption" data-readout>{a.m.threshold}</span>
                        {card.metrics.length > 1 && (
                          <span class="ic-seg" role="group" aria-label="Metric">
                            {card.metrics.map((m, i) => (
                              <button type="button" class="ic-seg-btn" aria-pressed={i === 0 ? "true" : "false"} data-metric={i}>{m.label}</button>
                            ))}
                          </span>
                        )}
                      </div>
                      <div
                        class="ic-stage"
                        data-stage
                        data-page={pi}
                        tabindex="0"
                        role="slider"
                        aria-label={`${card.title} chart. Use the arrow keys to scrub.`}
                        aria-describedby={`${uid}-sum-${pi}`}
                        aria-valuemin="0"
                        aria-valuemax={Math.max(0, a.m.values.length - 1)}
                        aria-valuenow={Math.max(0, a.m.values.length - 1)}
                        aria-valuetext={`${a.m.label} ${formatValue(a.m.values[a.m.values.length - 1] ?? 0, a.m.format)}`}
                        style={`--ic-line: ${color}`}
                      >
                        <svg class="ic-svg" viewBox="0 0 1000 166" preserveAspectRatio="none" aria-hidden="true">
                          <g data-grid>
                            {a.ticks.map((t) => <path class="ic-grid-line" d={`M0,${t.y.toFixed(1)}H1000`} />)}
                          </g>
                          <g class="ic-lines">
                            <path class="ic-line" d={a.line} style={`stroke: ${color}`} data-line="0" />
                          </g>
                        </svg>
                        <div class="ic-grid-labels" data-grid-labels aria-hidden="true">
                          {a.ticks.map((t) => <span style={`top: ${t.y.toFixed(1)}px`}>{t.label}</span>)}
                        </div>
                        <span class="ic-end" style={`top: ${a.end.toFixed(1)}px; color: ${color}`} data-end="0" aria-hidden="true" />
                        {xl && <div class="ic-xlabels" aria-hidden="true">{xl.map((l) => <span>{l}</span>)}</div>}
                      </div>
                      <p class="ic-sr" id={`${uid}-sum-${pi}`} data-summary>{a.summary}</p>
                    </div>
                    <div class="ic-foot">
                      <span class="ic-total">{card.total}</span>
                      <code class:list={["ic-mono", `ic-tone-${card.deltaTone ?? "red"}`]}>{card.delta}</code>
                      <span class="ic-note">{card.deltaNote}</span>
                    </div>
                  </div>
                );
              })()}

              {card.kind === "allocation" && (() => {
                const al = card;
                const first = al.segments[0];
                return (
                  <div class="ic-card" data-allocation={pi}>
                    <span class="ic-title">
                      <span class="ic-mono-disc" style={`background: ${al.color ?? first?.color ?? "var(--vs-orange)"}`} aria-hidden="true">{al.monogram ?? al.title.charAt(0)}</span>
                      {al.title}
                    </span>
                    <span class="ic-amount" data-amount>{first?.amount}</span>
                    <div class="ic-bar" role="group" aria-label="Allocation segments">
                      {al.segments.map((s, i) => (
                        <button
                          type="button"
                          class="ic-bar-seg"
                          aria-pressed={i === 0 ? "true" : "false"}
                          aria-label={`${s.label}: ${s.pct}%`}
                          data-seg={i}
                          style={`width: ${s.pct}%; background: ${s.color}`}
                        >
                          <span class="ic-bar-shine" />
                        </button>
                      ))}
                    </div>
                    <div class="ic-legend-row" role="group" aria-label="Allocation legend">
                      {al.segments.map((s, i) => (
                        <button type="button" class="ic-chip" aria-pressed={i === 0 ? "true" : "false"} data-seg={i}>
                          <span class="ic-chip-dot" style={`background: ${s.color}`} aria-hidden="true" />
                          {s.name} <span class="ic-tabular">{s.pct}%</span>
                        </button>
                      ))}
                    </div>
                    <div class="ic-detail" aria-live="polite">
                      <span class="ic-detail-label" data-detail-label style={`color: ${first?.tone ?? first?.color}`}>{first?.label}</span>
                      <span class="ic-detail-note">{al.note}</span>
                    </div>
                  </div>
                );
              })()}
            </div>

            {p.pill && <button type="button" class="ic-pill" data-ask={p.key}>{p.pill}</button>}
          </section>
        );
      })
    }
  </div>
</div>

<script>
  import {
    ANOMALY_BOX,
    COMPARE_BOX,
    SAMPLES_PER_SEGMENT,
    VIEW_W,
    areaPath,
    domain,
    formatValue,
    linePath,
    niceTicks,
    smooth,
    summarize,
    yOf,
    type AllocationCard,
    type AnomalyCard,
    type CompareCard,
    type InsightPage,
    type ValueFormat,
  } from "./insight-chart";

  const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

  interface Series {
    name: string;
    values: number[];
    dense: number[];
    color: string;
  }

  /** Wire one scrubbable chart. */
  function initChart(stage: HTMLElement, page: InsightPage, reduce: boolean) {
    const card = page.card as CompareCard | AnomalyCard;
    const isAnomaly = card.kind === "anomaly";
    const box = isAnomaly ? ANOMALY_BOX : COMPARE_BOX;
    const chart = stage.closest(".ic-card")!;
    const readout = chart.querySelector<HTMLElement>("[data-readout]");
    const summaryEl = chart.querySelector<HTMLElement>("[data-summary]");
    let metricIndex = 0;

    const seriesFor = (): Series[] => {
      if (card.kind === "anomaly") {
        const m = card.metrics[metricIndex];
        const color = card.color ?? "var(--vs-red)";
        return [{ name: m.label, values: m.values, dense: smooth(m.values), color }];
      }
      return card.series.map((s) => ({ name: s.name, values: s.values, dense: smooth(s.values), color: s.color }));
    };
    const formatFor = (): ValueFormat =>
      card.kind === "anomaly" ? card.metrics[metricIndex].format : (card.format ?? { style: "percent" });

    let series = seriesFor();
    let dom = domain(series.map((s) => s.dense));
    const lines = Array.from(stage.querySelectorAll<SVGPathElement>("[data-line]"));
    const areas = Array.from(stage.querySelectorAll<SVGPathElement>("[data-area]"));
    const ends = Array.from(stage.querySelectorAll<HTMLElement>("[data-end]"));

    /* scrub layer */
    const cursor = document.createElement("span");
    cursor.className = "ic-cursor";
    cursor.hidden = true;
    const tipAnchor = document.createElement("span");
    tipAnchor.className = "ic-tip-anchor";
    tipAnchor.hidden = true;
    const dots = series.map(() => {
      const d = document.createElement("span");
      d.className = "ic-scrub-dot";
      d.hidden = true;
      return d;
    });
    stage.append(cursor, ...dots, tipAnchor);

    let index: number | null = null;
    const pointCount = () => series[0]?.dense.length ?? 0;
    const render = () => {
      const n = pointCount();
      const show = index !== null && n > 1;
      cursor.hidden = !show;
      tipAnchor.hidden = !show;
      dots.forEach((d) => (d.hidden = !show));
      const f = formatFor();
      if (show && index !== null) {
        const pct = (index / (n - 1)) * 100;
        cursor.style.left = `${pct}%`;
        tipAnchor.style.left = `${Math.min(Math.max(pct, 28), 72)}%`;
        series.forEach((s, i) => {
          dots[i].style.left = `${pct}%`;
          dots[i].style.top = `${yOf(s.dense[index!], dom, box)}px`;
          dots[i].style.color = s.color;
        });
        tipAnchor.innerHTML = `<span class="ic-tip">${series
          .map((s) => `<span class="ic-tip-item"><span class="ic-tip-dot" style="background:${escapeHtml(s.color)}"></span>${escapeHtml(formatValue(s.dense[index!], f))}</span>`)
          .join("")}</span>`;
        if (readout) readout.textContent = formatValue(series[0].dense[index], { ...f, signed: false });
      } else if (readout && card.kind === "anomaly") {
        readout.textContent = card.metrics[metricIndex].threshold;
      }
      // Slider semantics follow the original (unsmoothed) readings.
      const raw = index === null ? series[0].values.length - 1 : Math.round(index / SAMPLES_PER_SEGMENT);
      const labels = card.labels;
      stage.setAttribute("aria-valuenow", String(raw));
      stage.setAttribute(
        "aria-valuetext",
        `${labels?.[raw] ? `${labels[raw]}: ` : ""}${series.map((s) => `${s.name} ${formatValue(s.values[raw] ?? 0, f)}`).join(", ")}`,
      );
    };

    const fromPointer = (e: PointerEvent) => {
      const rect = stage.getBoundingClientRect();
      const progress = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
      index = Math.round(progress * (pointCount() - 1));
      render();
    };
    const clear = () => {
      index = null;
      render();
    };
    stage.addEventListener("pointerdown", fromPointer);
    stage.addEventListener("pointermove", fromPointer);
    stage.addEventListener("pointerleave", clear);
    stage.addEventListener("pointercancel", clear);
    stage.addEventListener("pointerup", (e) => {
      if (e.pointerType !== "mouse") clear();
    });
    stage.addEventListener("blur", clear);
    stage.addEventListener("keydown", (e) => {
      const n = pointCount();
      const last = n - 1;
      const step = SAMPLES_PER_SEGMENT;
      const current = index ?? last;
      let next: number | null | undefined;
      if (e.key === "ArrowLeft" || e.key === "ArrowDown") next = Math.max(0, (index === null ? last : Math.ceil(current / step) * step) - step);
      else if (e.key === "ArrowRight" || e.key === "ArrowUp") next = Math.min(last, Math.floor(current / step) * step + step);
      else if (e.key === "Home") next = 0;
      else if (e.key === "End") next = last;
      else if (e.key === "Escape") next = null;
      if (next === undefined) return;
      e.preventDefault();
      index = next;
      render();
    });

    /* geometry */
    const paint = (denseSets: number[][], d: [number, number]) => {
      denseSets.forEach((dense, i) => {
        lines[i]?.setAttribute("d", linePath(dense, d, box));
        areas[i]?.setAttribute("d", areaPath(dense, d, box));
        if (ends[i]) ends[i].style.top = `${yOf(dense[dense.length - 1] ?? 0, d, box)}px`;
      });
    };
    const paintGrid = () => {
      const grid = stage.querySelector("[data-grid]");
      const labels = stage.querySelector("[data-grid-labels]");
      if (!grid || !labels) return;
      const f = { ...formatFor(), signed: false };
      const ticks = niceTicks(dom, 3);
      grid.innerHTML = ticks.map((v) => `<path class="ic-grid-line" d="M0,${yOf(v, dom, box).toFixed(1)}H${VIEW_W}" />`).join("");
      labels.innerHTML = ticks.map((v) => `<span style="top:${yOf(v, dom, box).toFixed(1)}px">${escapeHtml(formatValue(v, f))}</span>`).join("");
    };

    /* metric toggle morphs the line */
    let raf = 0;
    chart.querySelectorAll<HTMLButtonElement>("[data-metric]").forEach((btn) => {
      btn.addEventListener("click", () => {
        const next = Number(btn.dataset.metric);
        if (next === metricIndex || card.kind !== "anomaly") return;
        chart.querySelectorAll("[data-metric]").forEach((b) => b.setAttribute("aria-pressed", String(b === btn)));
        const fromSets = series.map((s) => s.dense);
        const fromDom = dom;
        metricIndex = next;
        series = seriesFor();
        dom = domain(series.map((s) => s.dense));
        if (summaryEl) summaryEl.textContent = summarize([{ name: series[0].name, values: series[0].values }], formatFor(), card.labels);
        render();
        cancelAnimationFrame(raf);
        if (reduce || fromSets[0].length !== series[0].dense.length) {
          paint(series.map((s) => s.dense), dom);
          paintGrid();
          return;
        }
        const t0 = performance.now();
        const ease = (t: number) => 1 - (1 - t) ** 3;
        const frame = (now: number) => {
          const t = ease(Math.min(1, (now - t0) / 380));
          const sets = series.map((s, i) => s.dense.map((v, j) => fromSets[i][j] + (v - fromSets[i][j]) * t));
          const d: [number, number] = [fromDom[0] + (dom[0] - fromDom[0]) * t, fromDom[1] + (dom[1] - fromDom[1]) * t];
          paint(sets, d);
          if (t < 1) raf = requestAnimationFrame(frame);
          else paintGrid();
        };
        paintGrid();
        raf = requestAnimationFrame(frame);
      });
    });

    /* draw in once visible */
    if (!reduce) {
      stage.classList.add("is-pending");
      const io = new IntersectionObserver(([entry]) => {
        if (!entry.isIntersecting) return;
        io.disconnect();
        stage.classList.remove("is-pending");
      });
      io.observe(stage);
      return () => {
        io.disconnect();
        cancelAnimationFrame(raf);
      };
    }
    return () => cancelAnimationFrame(raf);
  }

  function initAllocation(cardEl: HTMLElement, card: AllocationCard) {
    const amount = cardEl.querySelector<HTMLElement>("[data-amount]")!;
    const label = cardEl.querySelector<HTMLElement>("[data-detail-label]")!;
    const select = (i: number) => {
      const s = card.segments[i];
      if (!s) return;
      cardEl.querySelectorAll<HTMLElement>("[data-seg]").forEach((el) => el.setAttribute("aria-pressed", String(Number(el.dataset.seg) === i)));
      amount.textContent = s.amount;
      label.textContent = s.label;
      label.style.color = s.tone ?? s.color;
    };
    cardEl.querySelectorAll<HTMLButtonElement>("[data-seg]").forEach((btn) => btn.addEventListener("click", () => select(Number(btn.dataset.seg))));
  }

  function init(root: HTMLElement) {
    const cfg = JSON.parse(root.querySelector("script[data-vs-config]")?.textContent || "{}") as { pages: InsightPage[]; start: number };
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const slides = Array.from(root.querySelectorAll<HTMLElement>("[data-slide]"));
    const slidesEl = root.querySelector<HTMLElement>("[data-slides]")!;
    const cleanups: Array<() => void> = [];
    let current = cfg.start;

    root.querySelectorAll<HTMLElement>("[data-stage]").forEach((stage) => {
      const page = cfg.pages[Number(stage.dataset.page)];
      if (page) cleanups.push(initChart(stage, page, reduce));
    });
    root.querySelectorAll<HTMLElement>("[data-allocation]").forEach((el) => {
      const page = cfg.pages[Number(el.dataset.allocation)];
      if (page?.card.kind === "allocation") initAllocation(el, page.card);
    });

    const show = (i: number) => {
      const next = (i + slides.length) % slides.length;
      if (next === current) return;
      slides[current].hidden = true;
      const slide = slides[next];
      slide.hidden = false;
      if (!reduce) {
        slide.classList.remove("is-entering");
        void slide.offsetWidth;
        slide.classList.add("is-entering");
      }
      current = next;
      root.dispatchEvent(new CustomEvent("vs-insight-page", { bubbles: true, detail: { index: next, key: cfg.pages[next]?.key } }));
    };
    root.querySelectorAll<HTMLButtonElement>("[data-move]").forEach((btn) => btn.addEventListener("click", () => show(current + Number(btn.dataset.move))));
    root.querySelectorAll<HTMLButtonElement>("[data-ask]").forEach((btn) =>
      btn.addEventListener("click", () => root.dispatchEvent(new CustomEvent("vs-insight-ask", { bubbles: true, detail: { key: btn.dataset.ask, text: btn.textContent?.trim() } }))),
    );

    /* autoplay: in view only, pauses on hover/focus, yields to any interaction */
    if (root.hasAttribute("data-autoplay") && !reduce && slides.length > 1) {
      const interval = Number(root.dataset.interval) || 6000;
      let timer: number | undefined;
      let inView = false;
      let paused = false;
      let stopped = false;
      const schedule = () => {
        window.clearTimeout(timer);
        if (stopped || paused || !inView) return;
        timer = window.setTimeout(() => {
          show(current + 1);
          schedule();
        }, interval);
      };
      const stop = () => {
        stopped = true;
        window.clearTimeout(timer);
        slidesEl.setAttribute("aria-live", "polite");
      };
      const io = new IntersectionObserver(([entry]) => {
        inView = entry.isIntersecting;
        schedule();
      });
      io.observe(root);
      root.addEventListener("pointerdown", stop, true);
      root.addEventListener("keydown", stop, true);
      root.addEventListener("mouseenter", () => ((paused = true), schedule()));
      root.addEventListener("mouseleave", () => ((paused = false), schedule()));
      root.addEventListener("focusin", () => ((paused = true), schedule()));
      root.addEventListener("focusout", () => ((paused = false), schedule()));
      cleanups.push(() => {
        io.disconnect();
        window.clearTimeout(timer);
      });
    }

    return () => cleanups.forEach((fn) => fn());
  }

  const teardowns = new Set<() => void>();
  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-insight-cards:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      teardowns.add(init(root));
    });
  boot();
  document.addEventListener("astro:page-load", boot);
  document.addEventListener("astro:before-swap", () => {
    teardowns.forEach((fn) => fn());
    teardowns.clear();
  });
</script>

<style>
  .vs-insight-cards {
    width: 100%;
    max-width: 344px;
    min-height: 408px;
    color: var(--vs-ink);
    font-family: var(--vs-font-sans);
    text-align: left;
  }
  .ic-sr {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  button {
    font: inherit;
    color: inherit;
    background: none;
    border: 0;
    padding: 0;
    cursor: pointer;
  }
  button:focus-visible,
  .ic-stage:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }

  /* pager header */
  .ic-pager {
    display: flex;
    align-items: center;
    justify-content: space-between;
  }
  .ic-heading {
    display: flex;
    align-items: baseline;
    gap: 6px;
    margin: 0;
    font-size: 13px;
    font-weight: 600;
  }
  .ic-heading-count {
    color: var(--vs-ink-3);
    font-weight: 400;
    font-variant-numeric: tabular-nums;
  }
  .ic-pager-btns {
    display: flex;
    align-items: center;
    gap: 2px;
  }
  .ic-pager-btn {
    display: flex;
    width: 24px;
    height: 24px;
    align-items: center;
    justify-content: center;
    border-radius: 6px;
    color: var(--vs-ink-3);
    transition: background-color 100ms ease-out, color 100ms ease-out, transform 100ms ease-out;
  }
  .ic-pager-btn:hover {
    background: var(--vs-hover);
    color: var(--vs-ink);
  }
  .ic-pager-btn:active {
    transform: scale(0.96);
  }

  /* page */
  .ic-slide.is-entering {
    animation: vs-insight-cards-in 250ms var(--vs-ease-out) both;
  }
  @keyframes vs-insight-cards-in {
    from {
      opacity: 0;
      filter: blur(4px);
    }
    to {
      opacity: 1;
      filter: blur(0);
    }
  }
  .ic-prose {
    margin: 6px 0 0;
    color: var(--vs-ink-2);
    font-size: 12.5px;
    line-height: 1.625;
  }
  .ic-entity {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    color: var(--vs-ink);
    font-weight: 500;
    vertical-align: baseline;
  }
  .ic-entity-dot {
    display: inline-block;
    width: 10px;
    height: 10px;
    border-radius: 50%;
  }
  .ic-strong {
    color: var(--vs-ink);
    font-weight: 500;
  }
  .ic-mono {
    font-family: var(--vs-font-sans);
    font-variant-numeric: tabular-nums;
    font-size: 11.5px;
  }
  .ic-tone-red {
    color: var(--vs-red);
  }
  .ic-tone-green {
    color: var(--vs-green);
  }
  .ic-card-wrap {
    margin-top: 8px;
  }
  .ic-card {
    min-height: 278px;
    padding: 12px;
    border-radius: var(--vs-radius-card);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-hairline);
  }
  .ic-pill {
    margin-top: 8px;
    padding: 6px 12px;
    border-radius: var(--vs-radius-pill);
    background: var(--vs-surface);
    color: var(--vs-ink);
    box-shadow: var(--vs-shadow-btn);
    font-size: 12px;
    text-align: left;
    transition: background-color 100ms ease-out;
  }
  .ic-pill:hover {
    background: var(--vs-hover);
  }

  /* compare legend */
  .ic-legend {
    display: flex;
    align-items: center;
    gap: 16px;
  }
  .ic-legend-item {
    flex: 1;
  }
  .ic-legend-name {
    display: flex;
    align-items: center;
    gap: 6px;
    color: var(--vs-ink-2);
    font-size: 11.5px;
  }
  .ic-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
  }
  .ic-big {
    display: block;
    font-size: 17px;
    font-weight: 600;
    letter-spacing: -0.01em;
    font-variant-numeric: tabular-nums;
  }

  /* chart well */
  .ic-chart {
    margin-top: 8px;
    overflow: hidden;
    border-radius: var(--vs-radius-control);
    background: var(--vs-inset);
    box-shadow: var(--vs-shadow-hairline);
  }
  .ic-chart-bar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 6px 10px;
    border-bottom: 1px solid var(--vs-line);
  }
  .ic-caption {
    color: var(--vs-ink-3);
    font-size: 11px;
    font-variant-numeric: tabular-nums;
  }
  .ic-badge {
    padding: 2px 8px;
    border-radius: var(--vs-radius-pill);
    background: var(--vs-field);
    color: var(--vs-ink-2);
    font-size: 10.5px;
    font-weight: 500;
  }
  .ic-seg {
    display: flex;
    padding: 2px;
    border-radius: var(--vs-radius-pill);
    background: var(--vs-field);
  }
  .ic-seg-btn {
    padding: 2px 8px;
    border-radius: var(--vs-radius-pill);
    color: var(--vs-ink-3);
    font-size: 10.5px;
    font-weight: 500;
    transition: background-color 150ms ease-out, color 150ms ease-out, box-shadow 150ms ease-out, transform 150ms ease-out;
  }
  .ic-seg-btn:hover {
    color: var(--vs-ink-2);
  }
  .ic-seg-btn[aria-pressed="true"] {
    background: var(--vs-surface);
    color: var(--vs-ink);
    box-shadow: var(--vs-shadow-btn);
  }
  .ic-seg-btn:active {
    transform: scale(0.96);
  }

  .ic-stage {
    position: relative;
    height: 166px;
    overflow: hidden;
    cursor: crosshair;
    touch-action: pan-y;
    -webkit-user-select: none;
    user-select: none;
  }
  .ic-stage:focus-visible {
    box-shadow: inset 0 0 0 2px var(--vs-accent);
  }
  .ic-svg {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    overflow: visible;
  }
  .ic-lines {
    clip-path: inset(0 0 0 0);
    transition: clip-path 900ms var(--vs-ease-out);
  }
  .ic-stage:global(.is-pending) .ic-lines {
    clip-path: inset(0 100% 0 0);
    transition: none;
  }
  .ic-line {
    fill: none;
    stroke-width: 2.25;
    stroke-linecap: round;
    stroke-linejoin: round;
    vector-effect: non-scaling-stroke;
  }
  .ic-area {
    stroke: none;
  }
  .vs-insight-cards :global(.ic-grid-line) {
    fill: none;
    stroke: var(--vs-line-strong);
    stroke-width: 1;
    stroke-dasharray: 3 4;
    vector-effect: non-scaling-stroke;
  }
  .ic-grid-labels {
    position: absolute;
    inset: 0;
    pointer-events: none;
  }
  .ic-grid-labels :global(span) {
    position: absolute;
    left: 8px;
    transform: translateY(-115%);
    color: var(--vs-ink-3);
    font-size: 10px;
    font-variant-numeric: tabular-nums;
  }
  .ic-xlabels {
    position: absolute;
    right: 8px;
    bottom: 4px;
    left: 8px;
    display: flex;
    justify-content: space-between;
    color: var(--vs-ink-3);
    font-size: 10px;
    pointer-events: none;
  }
  .ic-end {
    position: absolute;
    left: calc(100% - 6px);
    width: 7px;
    height: 7px;
    border-radius: 50%;
    background: currentColor;
    box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 22%, transparent);
    transform: translate(-50%, -50%);
    transition: opacity 300ms ease-out 700ms;
  }
  .ic-stage:global(.is-pending) .ic-end {
    opacity: 0;
    transition: none;
  }

  /* scrub cursor + tooltip */
  .vs-insight-cards :global(.ic-cursor) {
    position: absolute;
    z-index: 4;
    top: 0;
    bottom: 0;
    width: 1px;
    background: var(--vs-ink);
    opacity: 0.26;
    pointer-events: none;
  }
  .vs-insight-cards :global(.ic-scrub-dot) {
    position: absolute;
    z-index: 4;
    width: 8px;
    height: 8px;
    border: 2px solid var(--vs-surface);
    border-radius: 50%;
    background: currentColor;
    transform: translate(-50%, -50%);
    pointer-events: none;
  }
  .vs-insight-cards :global(.ic-tip-anchor) {
    position: absolute;
    z-index: 5;
    top: 8px;
    transform: translateX(-50%);
    pointer-events: none;
  }
  .vs-insight-cards :global(.ic-tip) {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 5px 9px;
    border: 1px solid var(--vs-line-strong);
    border-radius: 8px;
    background: var(--vs-tooltip-bg);
    color: var(--vs-tooltip-fg);
    box-shadow: var(--vs-shadow-overlay);
    font-size: 11.5px;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
  }
  .vs-insight-cards :global(.ic-tip-item) {
    display: inline-flex;
    align-items: center;
    gap: 6px;
  }
  .vs-insight-cards :global(.ic-tip-dot) {
    width: 8px;
    height: 8px;
    flex: 0 0 8px;
    border-radius: 50%;
  }

  /* anomaly */
  .ic-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
  }
  .ic-title {
    display: flex;
    align-items: center;
    gap: 6px;
    color: var(--vs-ink);
    font-size: 12px;
    font-weight: 500;
  }
  .ic-foot {
    display: flex;
    align-items: baseline;
    gap: 8px;
    margin-top: 6px;
  }
  .ic-total {
    color: var(--vs-ink);
    font-size: 17px;
    font-weight: 600;
    letter-spacing: -0.01em;
    font-variant-numeric: tabular-nums;
  }
  .ic-note {
    color: var(--vs-ink-3);
    font-size: 11px;
  }

  /* allocation */
  .ic-mono-disc {
    display: flex;
    width: 14px;
    height: 14px;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    color: white;
    font-size: 8px;
    font-weight: 700;
  }
  .ic-amount {
    display: block;
    margin-top: 4px;
    color: var(--vs-ink);
    font-size: 20px;
    font-weight: 600;
    letter-spacing: -0.01em;
    font-variant-numeric: tabular-nums;
  }
  .ic-bar {
    display: flex;
    height: 36px;
    gap: 2px;
    margin-top: 12px;
    padding: 2px;
    overflow: hidden;
    border-radius: var(--vs-radius-pill);
    background: var(--vs-field);
  }
  .ic-bar-seg {
    position: relative;
    height: 100%;
    overflow: hidden;
    border-radius: var(--vs-radius-pill);
    opacity: 0.58;
    transition:
      opacity 300ms var(--vs-ease-link),
      transform 300ms var(--vs-ease-link),
      box-shadow 300ms var(--vs-ease-link);
  }
  .ic-bar-seg[aria-pressed="true"] {
    opacity: 1;
    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22);
  }
  .ic-bar-seg:active {
    transform: scale(0.98);
  }
  .ic-bar-shine {
    position: absolute;
    top: 4px;
    bottom: 4px;
    left: 4px;
    width: 0%;
    border-radius: var(--vs-radius-pill);
    background: rgba(255, 255, 255, 0.2);
    opacity: 0;
    transition:
      width 500ms var(--vs-ease-link),
      opacity 500ms var(--vs-ease-link);
  }
  .ic-bar-seg[aria-pressed="true"] .ic-bar-shine {
    width: calc(100% - 8px);
    opacity: 1;
  }
  .ic-legend-row {
    display: flex;
    align-items: center;
    gap: 6px;
    margin-top: 8px;
  }
  .ic-chip {
    display: flex;
    min-height: 24px;
    align-items: center;
    gap: 4px;
    padding: 2px 6px;
    border-radius: var(--vs-radius-pill);
    color: var(--vs-ink-2);
    font-size: 11px;
    transition: background-color 150ms ease-out, color 150ms ease-out, transform 150ms ease-out;
  }
  .ic-chip:hover {
    background: var(--vs-hover);
    color: var(--vs-ink);
  }
  .ic-chip[aria-pressed="true"] {
    background: var(--vs-field);
    color: var(--vs-ink);
  }
  .ic-chip:active {
    transform: scale(0.96);
  }
  .ic-chip-dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
  }
  .ic-tabular {
    font-variant-numeric: tabular-nums;
  }
  .ic-detail {
    min-height: 64px;
    margin-top: 12px;
    padding: 8px 10px;
    border-radius: var(--vs-radius-control);
    background: var(--vs-inset);
    box-shadow: var(--vs-shadow-hairline);
  }
  .ic-detail-label {
    display: block;
    font-size: 11.5px;
    font-weight: 500;
  }
  .ic-detail-note {
    display: block;
    margin-top: 4px;
    color: var(--vs-ink-3);
    font-size: 11px;
    line-height: 1.625;
  }

  @media (prefers-reduced-motion: reduce) {
    .ic-slide.is-entering {
      animation: none;
    }
    .ic-lines,
    .ic-end,
    .ic-bar-seg,
    .ic-bar-shine {
      transition: none;
    }
  }
  @media (forced-colors: active) {
    .ic-line {
      stroke: CanvasText !important;
    }
    .ic-bar-seg {
      forced-color-adjust: none;
      outline: 1px solid CanvasText;
    }
  }
</style>
```

### `src/components/viewership/insight-cards/insight-chart.ts`

```ts
/**
 * insight-chart: types and chart maths for <InsightCards>.
 *
 * Ported from Beautiful UI `components/primitives/InsightCards.tsx`
 * (MIT, © 2026 Shane Levine). Native SVG line chart: Catmull-Rom
 * smoothing, paths, grid ticks, value formatting and a text summary. No DOM
 * access, so server render and client script share it.
 */

export type Tone = "red" | "green";

/** Prose is a list of tokens: plain strings, @entities, mono deltas and emphasis. */
export interface ProseEntity {
  entity: string;
  /** Dot color, any CSS color. */
  color?: string;
}
export interface ProseMono {
  mono: string;
  tone: Tone;
}
export interface ProseStrong {
  strong: string;
}
export type ProseToken = string | ProseEntity | ProseMono | ProseStrong;

export interface ValueFormat {
  style: "percent" | "money" | "unit";
  /** Suffix for style "unit", e.g. "clicks". */
  unit?: string;
  /** Fraction digits (percent defaults to 2, others to 0). */
  decimals?: number;
  /** Prefix positive values with "+" (percent defaults to true). */
  signed?: boolean;
  /** Currency symbol for style "money". Default "$". */
  currency?: string;
}

export interface CompareSeries {
  name: string;
  values: number[];
  /** Mono sub-line under the big value, e.g. "-$2,377.66". */
  sub: string;
  tone: Tone;
  /** Line color, any CSS color. */
  color: string;
}
export interface CompareCard {
  kind: "compare";
  /** Chart toolbar caption. */
  caption?: string;
  badge?: string;
  series: CompareSeries[];
  format?: ValueFormat;
  /** X-axis labels, one per value (first, middle and last are shown). */
  labels?: string[];
}

export interface AnomalyMetric {
  key: string;
  label: string;
  values: number[];
  format: ValueFormat;
  /** Shown in the chart toolbar when not scrubbing, e.g. "$2,112 threshold". */
  threshold: string;
}
export interface AnomalyCard {
  kind: "anomaly";
  title: string;
  badge?: string;
  metrics: AnomalyMetric[];
  /** Line color. Default var(--vs-red). */
  color?: string;
  /** Footer: big value, mono delta and a note. */
  total: string;
  delta: string;
  deltaTone?: Tone;
  deltaNote: string;
  labels?: string[];
}

export interface AllocationSegment {
  name: string;
  label: string;
  pct: number;
  amount: string;
  /** Segment fill, any CSS color. */
  color: string;
  /** Label color in the detail box (defaults to `color`). */
  tone?: string;
}
export interface AllocationCard {
  kind: "allocation";
  title: string;
  monogram?: string;
  /** Monogram disc color. */
  color?: string;
  segments: AllocationSegment[];
  note: string;
}

export type InsightCard = CompareCard | AnomalyCard | AllocationCard;

export interface InsightPage {
  key: string;
  prose: ProseToken[];
  card: InsightCard;
  /** Suggested follow-up shown as a pill button. */
  pill?: string;
}

/* ── maths ─────────────────────────────────────────────── */

export const SAMPLES_PER_SEGMENT = 9;
export const VIEW_W = 1000;

/** Catmull-Rom resample: a sparse series becomes a dense, smoothly curved one. */
export function smooth(values: number[], perSegment = SAMPLES_PER_SEGMENT): number[] {
  if (values.length < 3) return values.slice();
  const out: number[] = [];
  const n = values.length;
  for (let i = 0; i < n - 1; i += 1) {
    const p0 = values[Math.max(0, i - 1)];
    const p1 = values[i];
    const p2 = values[i + 1];
    const p3 = values[Math.min(n - 1, i + 2)];
    for (let s = 0; s < perSegment; s += 1) {
      const t = s / perSegment;
      const t2 = t * t;
      const t3 = t2 * t;
      out.push(0.5 * (2 * p1 + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3));
    }
  }
  out.push(values[n - 1]);
  return out;
}

export interface Box {
  height: number;
  top: number;
  bottom: number;
}

/** Plot boxes (px in the 1000x166 viewBox); must match the viewBox in InsightCards.astro. */
export const COMPARE_BOX: Box = { height: 166, top: 40, bottom: 22 };
export const ANOMALY_BOX: Box = { height: 166, top: 34, bottom: 22 };

export function domain(series: number[][]): [number, number] {
  const all = series.flat();
  let min = Math.min(...all);
  let max = Math.max(...all);
  if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];
  if (min === max) {
    min -= 1;
    max += 1;
  }
  const pad = (max - min) * 0.06;
  return [min - pad, max + pad];
}

export function yOf(value: number, [min, max]: [number, number], box: Box): number {
  const plot = box.height - box.top - box.bottom;
  return box.top + (1 - (value - min) / (max - min)) * plot;
}

/** SVG path for a dense series across the full view width. */
export function linePath(dense: number[], dom: [number, number], box: Box): string {
  if (!dense.length) return "";
  const step = dense.length > 1 ? VIEW_W / (dense.length - 1) : 0;
  return dense.map((v, i) => `${i ? "L" : "M"}${(i * step).toFixed(1)},${yOf(v, dom, box).toFixed(1)}`).join("");
}

export function areaPath(dense: number[], dom: [number, number], box: Box): string {
  const line = linePath(dense, dom, box);
  return line ? `${line}L${VIEW_W},${box.height}L0,${box.height}Z` : "";
}

/** About `count` round tick values inside the domain. */
export function niceTicks([min, max]: [number, number], count = 3): number[] {
  const raw = (max - min) / (count + 1);
  const mag = 10 ** Math.floor(Math.log10(raw));
  const norm = raw / mag;
  const step = (norm >= 5 ? 5 : norm >= 2 ? 2 : 1) * mag;
  const ticks: number[] = [];
  for (let v = Math.ceil(min / step) * step; v <= max; v += step) ticks.push(Number(v.toFixed(10)));
  return ticks;
}

export function formatValue(value: number, f: ValueFormat = { style: "percent" }): string {
  const decimals = f.decimals ?? (f.style === "percent" ? 2 : 0);
  const signed = f.signed ?? f.style === "percent";
  const abs = Math.abs(value).toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
  const sign = value < 0 ? "-" : signed && value > 0 ? "+" : "";
  if (f.style === "percent") return `${sign}${abs}%`;
  if (f.style === "money") return `${sign}${f.currency ?? "$"}${abs}`;
  return `${sign}${abs}${f.unit ? ` ${f.unit}` : ""}`;
}

/** Plain-language chart summary for assistive tech. */
export function summarize(series: Array<{ name: string; values: number[] }>, f: ValueFormat, labels?: string[]): string {
  const span = labels?.length ? ` from ${labels[0]} to ${labels[labels.length - 1]}` : ` over ${series[0]?.values.length ?? 0} readings`;
  const parts = series.map((s) => {
    const first = s.values[0] ?? 0;
    const last = s.values[s.values.length - 1] ?? 0;
    const peak = Math.max(...s.values);
    const low = Math.min(...s.values);
    const dir = last > first ? "rose" : last < first ? "fell" : "held";
    return `${s.name} ${dir} from ${formatValue(first, f)} to ${formatValue(last, f)} (high ${formatValue(peak, f)}, low ${formatValue(low, f)})`;
  });
  return `Line chart${span}. ${parts.join("; ")}.`;
}
```
