---
/**
* 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>