Gráficos y Estadísticas chartsvga11yfoundationtooltip
Chart Frame
Contenedor accesible compartido por todos los gráficos SVG: tarjeta, cabecera, leyenda, tooltip y tabla de datos alternativa.
Vista Previa en Vivo
Chart Frame
Uso
Copia y pega este código directamente en tu página de Astro:
astro
---
import { ChartFrame } from 'astro-component-kit/charts';
---
<ChartFrame
title="Custom visual"
subtitle="Any SVG you like"
legend={[{ label: 'A', color: 'var(--chart-1)' }, { label: 'B', color: 'var(--chart-2)' }]}
table={{ headers: ['Item', 'Value'], rows: [['A', 10], ['B', 20]] }}
>
<svg viewBox="0 0 640 200" role="img" aria-label="Custom visual">…</svg>
</ChartFrame> ---
import { ChartFrame } from 'astro-component-kit/charts';
---
<ChartFrame
title="Custom visual"
subtitle="Any SVG you like"
legend={[{ label: 'A', color: 'var(--chart-1)' }, { label: 'B', color: 'var(--chart-2)' }]}
table={{ headers: ['Item', 'Value'], rows: [['A', 10], ['B', 20]] }}
>
<svg viewBox="0 0 640 200" role="img" aria-label="Custom visual">…</svg>
</ChartFrame> Instalación Manual
Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: src/components/lib/ChartFrame.astro:
astro
---
/**
* ChartFrame — Shared accessible shell for every SVG chart in the kit.
* Renders the card surface, header, legend, hover tooltip and a
* "View data" table fallback so no chart relies on color or hover alone.
*
* @param {string} title - Chart heading (names the series when there is only one).
* @param {string} subtitle - Supporting description under the title.
* @param {string} value - Optional headline figure shown on the right of the header.
* @param {string} delta - Optional change label next to the headline figure.
* @param {boolean} deltaPositive - Good news (green ▲) or bad (red ▼); omit for a neutral caption.
* @param {Array<{label: string, color: string}>} legend - Legend items (shown when ≥ 2 series).
* @param {{headers: string[], rows: Array<Array<string|number>>}} table - Data for the table view.
* @param {string} variant - Surface style (glass|solid|ghost).
* @param {string} size - Density (sm|md|lg).
* @param {string} className - Extra CSS class.
*
* Accessibility: the figure is labelled by its title, every mark is focusable via
* the chart itself, and a native <details> data table is always available.
*/
interface Props {
title?: string;
subtitle?: string;
value?: string;
delta?: string;
deltaPositive?: boolean;
legend?: Array<{ label: string; color: string }>;
table?: { headers: string[]; rows: Array<Array<string | number>> };
variant?: 'glass' | 'solid' | 'ghost';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const {
title,
subtitle,
value,
delta,
deltaPositive,
legend = [],
table,
variant = 'glass',
size = 'md',
className = '',
} = Astro.props;
const titleId = `cf-${Math.random().toString(36).slice(2, 9)}`;
---
<figure
class={`cf cf--${variant} cf--${size} ${className}`}
data-chart
aria-labelledby={title ? titleId : undefined}
>
{(title || value) && (
<header class="cf__header">
<div class="cf__heading">
{title && <figcaption id={titleId} class="cf__title">{title}</figcaption>}
{subtitle && <p class="cf__subtitle">{subtitle}</p>}
</div>
{value && (
<div class="cf__headline">
<span class="cf__value">{value}</span>
{delta && (
<span class:list={['cf__delta', { 'cf__delta--pos': deltaPositive === true, 'cf__delta--neg': deltaPositive === false, 'cf__delta--neutral': deltaPositive === undefined }]}>
{deltaPositive !== undefined && (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path d={deltaPositive ? 'M5 1 L9 7 H1 Z' : 'M5 9 L9 3 H1 Z'} fill="currentColor" />
</svg>
)}
{delta}
</span>
)}
</div>
)}
</header>
)}
{legend.length > 1 && (
<ul class="cf__legend" aria-label="Legend">
{legend.map((item) => (
<li class="cf__legend-item">
<span class="cf__swatch" style={`background:${item.color}`} aria-hidden="true"></span>
{item.label}
</li>
))}
</ul>
)}
<div class="cf__plot">
<slot />
</div>
<slot name="footer" />
{table && table.rows.length > 0 && (
<details class="cf__data">
<summary>View data</summary>
<div class="cf__table-wrap">
<table>
<thead>
<tr>{table.headers.map((h) => <th scope="col">{h}</th>)}</tr>
</thead>
<tbody>
{table.rows.map((row) => (
<tr>{row.map((cell, i) => (i === 0 ? <th scope="row">{cell}</th> : <td>{cell}</td>))}</tr>
))}
</tbody>
</table>
</div>
</details>
)}
<div class="cf__tip" data-chart-tip aria-hidden="true"></div>
</figure>
<script>
import './chart-tooltip';
</script>
<style>
.cf {
--cf-pad: var(--sp-6, 1.5rem);
--cf-title: 1.0625rem;
position: relative;
margin: 0;
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: var(--cf-pad);
border-radius: var(--r-lg, 20px);
color: var(--c-text-2, #a3a3a3);
font-family: var(--font-sans, system-ui, sans-serif);
}
.cf--glass {
background: linear-gradient(160deg, rgb(var(--ink-rgb, 255 255 255) / 0.045), rgb(var(--ink-rgb, 255 255 255) / 0.015));
border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.07));
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.cf--solid {
background: var(--chart-surface, #0d0d0f);
border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.08);
}
.cf--ghost {
background: transparent;
border: 1px solid transparent;
padding-inline: 0;
}
.cf--sm { --cf-pad: var(--sp-4, 1rem); --cf-title: 0.9375rem; }
.cf--lg { --cf-pad: var(--sp-8, 2rem); --cf-title: 1.25rem; }
.cf__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--sp-4, 1rem);
margin-bottom: var(--sp-4, 1rem);
}
.cf__title {
margin: 0;
font-size: var(--cf-title);
font-weight: 650;
color: var(--c-text-1, #fff);
letter-spacing: -0.01em;
}
.cf__subtitle {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
color: var(--c-text-2, #a3a3a3);
line-height: 1.45;
}
.cf__headline {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
flex-shrink: 0;
}
.cf__value {
font-size: 1.5rem;
font-weight: 700;
color: var(--c-text-1, #fff);
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.cf__delta {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--c-text-1, #fff);
}
.cf__delta--pos svg { color: var(--chart-pos, #34c38f); }
.cf__delta--neg svg { color: var(--chart-neg, #f06a6a); }
.cf__delta--neutral { color: var(--c-text-3, #8a8a8a); font-weight: 500; }
.cf__legend {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1rem;
list-style: none;
margin: 0 0 var(--sp-3, 0.75rem);
padding: 0;
}
.cf__legend-item {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.8125rem;
color: var(--c-text-2, #a3a3a3);
}
.cf__swatch {
width: 10px;
height: 10px;
border-radius: 3px;
}
.cf__plot {
position: relative;
width: 100%;
}
/* Plot SVGs scale fluidly; small inline icons keep their explicit width/height. */
.cf__plot :global(svg:not([width])) {
display: block;
width: 100%;
height: auto;
overflow: visible;
}
/* ── Shared mark vocabulary for every chart ───────────── */
.cf :global(.cf-grid) {
stroke: var(--chart-grid, rgb(var(--ink-rgb, 255 255 255) / 0.07));
stroke-width: 1;
shape-rendering: crispEdges;
}
.cf :global(.cf-axis) {
stroke: var(--chart-axis, rgb(var(--ink-rgb, 255 255 255) / 0.18));
stroke-width: 1;
shape-rendering: crispEdges;
}
.cf :global(.cf-label) {
fill: var(--c-text-3, #8a8a8a);
font-size: 11px;
font-family: var(--font-sans, system-ui, sans-serif);
font-variant-numeric: tabular-nums;
}
.cf :global(.cf-label--strong) {
fill: var(--c-text-1, #fff);
font-weight: 600;
}
.cf :global(.cf-mark) {
transition: opacity 0.2s ease, filter 0.2s ease;
outline: none;
}
.cf :global(.cf-mark:focus-visible) {
stroke: var(--c-text-1, #fff);
stroke-width: 2px;
}
.cf :global(svg:hover .cf-dim),
.cf :global(svg:focus-within .cf-dim) {
opacity: 0.45;
}
.cf :global(svg .cf-dim:hover),
.cf :global(svg .cf-dim:focus-visible) {
opacity: 1;
}
.cf :global(.cf-hit) {
fill: transparent;
cursor: crosshair;
outline: none;
}
.cf :global(.cf-cross) {
stroke: rgb(var(--ink-rgb, 255 255 255) / 0.35);
stroke-width: 1;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
.cf :global(.cf-hit-group:hover .cf-cross),
.cf :global(.cf-hit-group:focus-within .cf-cross) {
opacity: 1;
}
.cf :global(.cf-hit:focus-visible) {
fill: rgb(var(--ink-rgb, 255 255 255) / 0.04);
}
/* ── Tooltip ─────────────────────────────────────────── */
.cf__tip {
position: absolute;
top: 0;
left: 0;
z-index: 5;
pointer-events: none;
opacity: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 7rem;
max-width: 16rem;
padding: 0.55rem 0.7rem;
border-radius: var(--r-sm, 8px);
background: rgb(var(--paper-rgb, 12 12 14) / 0.96);
border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.12);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
font-size: 0.75rem;
color: var(--c-text-1, #fff);
transition: opacity 0.15s ease;
}
.cf__tip :global(strong) {
font-size: 0.75rem;
font-weight: 650;
margin-bottom: 0.15rem;
}
.cf__tip :global(span) {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--c-text-2, #c4c4c4);
font-variant-numeric: tabular-nums;
}
.cf__tip :global(i) {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
}
/* ── Data table fallback ─────────────────────────────── */
.cf__data {
margin-top: var(--sp-3, 0.75rem);
font-size: 0.8125rem;
}
.cf__data summary {
display: inline-flex;
align-items: center;
min-height: 32px;
cursor: pointer;
color: var(--c-text-3, #8a8a8a);
border-radius: 6px;
list-style-position: inside;
}
.cf__data summary:hover { color: var(--c-text-1, #fff); }
.cf__data summary:focus-visible {
outline: 2px solid var(--c-primary-light, #d4a017);
outline-offset: 2px;
}
.cf__table-wrap {
overflow-x: auto;
margin-top: 0.5rem;
}
.cf__data table {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
.cf__data th,
.cf__data td {
padding: 0.4rem 0.6rem;
text-align: right;
border-bottom: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.06);
white-space: nowrap;
}
.cf__data th:first-child { text-align: left; }
.cf__data thead th { color: var(--c-text-3, #8a8a8a); font-weight: 600; }
.cf__data tbody th { color: var(--c-text-1, #fff); font-weight: 500; }
.cf__data td { color: var(--c-text-2, #a3a3a3); }
@media (prefers-reduced-motion: reduce) {
.cf :global(*) { transition: none !important; animation: none !important; }
}
</style>
// ─────────────────────────────────────────────────────────────
// chart-utils.ts
// ─────────────────────────────────────────────────────────────
// src/components/lib/charts/chart-utils.ts
// Shared, dependency-free math helpers for the SVG chart components.
// Everything here runs at build time (server-side) — no client JS needed.
/** Fixed-order categorical palette (CSS tokens with hex fallbacks). Never cycled past 8. */
export const CHART_COLORS = [
'var(--chart-1, #3987e5)',
'var(--chart-2, #d95926)',
'var(--chart-3, #199e70)',
'var(--chart-4, #c98500)',
'var(--chart-5, #d55181)',
'var(--chart-6, #2f9e2f)',
'var(--chart-7, #9085e9)',
'var(--chart-8, #e66767)',
];
export const POSITIVE = 'var(--chart-pos, #34c38f)';
export const NEGATIVE = 'var(--chart-neg, #f06a6a)';
/** Returns the color for a series index, honouring user overrides. */
export function colorAt(i: number, overrides?: string[]): string {
if (overrides && overrides[i]) return overrides[i];
return CHART_COLORS[Math.min(i, CHART_COLORS.length - 1)];
}
/** Linear scale factory: maps a domain [d0, d1] onto a range [r0, r1]. */
export function scaleLinear(d0: number, d1: number, r0: number, r1: number) {
const span = d1 - d0 || 1;
return (v: number) => r0 + ((v - d0) / span) * (r1 - r0);
}
/** Rounds a raw step to a "nice" 1/2/2.5/5/10 × 10^n value. */
function niceStep(raw: number): number {
const exp = Math.floor(Math.log10(Math.abs(raw) || 1));
const f = raw / 10 ** exp;
const nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10;
return nf * 10 ** exp;
}
/** Produces nice, evenly spaced axis ticks covering [min, max]. */
export function niceTicks(min: number, max: number, count = 5): number[] {
if (min === max) {
max = min + 1;
}
const step = niceStep((max - min) / Math.max(1, count));
const start = Math.floor(min / step) * step;
const end = Math.ceil(max / step) * step;
const ticks: number[] = [];
for (let v = start; v <= end + step / 2; v += step) {
ticks.push(Number(v.toFixed(10)));
}
return ticks;
}
/** Compact number formatter: 1200 → 1.2k, 3400000 → 3.4M. */
export function formatCompact(v: number, decimals = 1): string {
const abs = Math.abs(v);
if (abs >= 1e9) return `${(v / 1e9).toFixed(decimals).replace(/\.0+$/, '')}B`;
if (abs >= 1e6) return `${(v / 1e6).toFixed(decimals).replace(/\.0+$/, '')}M`;
if (abs >= 1e3) return `${(v / 1e3).toFixed(decimals).replace(/\.0+$/, '')}k`;
return `${Number(v.toFixed(2))}`;
}
/** Formats a value with an optional prefix/suffix (e.g. '$', '%'). */
export function formatValue(v: number, prefix = '', suffix = '', compact = true): string {
const body = compact ? formatCompact(v) : v.toLocaleString('en-US');
return `${prefix}${body}${suffix}`;
}
export type Point = [number, number];
/** Straight polyline path. */
export function linePath(points: Point[]): string {
return points.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(2)},${y.toFixed(2)}`).join(' ');
}
/** Step path (horizontal then vertical). */
export function stepPath(points: Point[]): string {
if (!points.length) return '';
let d = `M${points[0][0].toFixed(2)},${points[0][1].toFixed(2)}`;
for (let i = 1; i < points.length; i++) {
const [x, y] = points[i];
d += ` H${x.toFixed(2)} V${y.toFixed(2)}`;
}
return d;
}
/** Monotone cubic interpolation — smooth curve that never overshoots the data. */
export function smoothPath(points: Point[]): string {
const n = points.length;
if (n < 3) return linePath(points);
const dx: number[] = [];
const slope: number[] = [];
for (let i = 0; i < n - 1; i++) {
dx[i] = points[i + 1][0] - points[i][0];
slope[i] = (points[i + 1][1] - points[i][1]) / (dx[i] || 1);
}
const m: number[] = [slope[0]];
for (let i = 1; i < n - 1; i++) {
m[i] = slope[i - 1] * slope[i] <= 0 ? 0 : (slope[i - 1] + slope[i]) / 2;
}
m[n - 1] = slope[n - 2];
for (let i = 0; i < n - 1; i++) {
if (slope[i] === 0) {
m[i] = 0;
m[i + 1] = 0;
continue;
}
const a = m[i] / slope[i];
const b = m[i + 1] / slope[i];
const h = a * a + b * b;
if (h > 9) {
const t = 3 / Math.sqrt(h);
m[i] = t * a * slope[i];
m[i + 1] = t * b * slope[i];
}
}
let d = `M${points[0][0].toFixed(2)},${points[0][1].toFixed(2)}`;
for (let i = 0; i < n - 1; i++) {
const [x0, y0] = points[i];
const [x1, y1] = points[i + 1];
const h = dx[i] / 3;
d += ` C${(x0 + h).toFixed(2)},${(y0 + m[i] * h).toFixed(2)} ${(x1 - h).toFixed(2)},${(y1 - m[i + 1] * h).toFixed(2)} ${x1.toFixed(2)},${y1.toFixed(2)}`;
}
return d;
}
/** Picks the path builder for a curve type. */
export function curvePath(points: Point[], curve: 'linear' | 'smooth' | 'step' = 'smooth'): string {
if (curve === 'step') return stepPath(points);
if (curve === 'linear') return linePath(points);
return smoothPath(points);
}
/** Closes a line path down to a baseline to form an area. */
export function areaPath(points: Point[], baseline: number, curve: 'linear' | 'smooth' | 'step' = 'smooth'): string {
if (!points.length) return '';
const top = curvePath(points, curve);
const last = points[points.length - 1];
const first = points[0];
return `${top} L${last[0].toFixed(2)},${baseline.toFixed(2)} L${first[0].toFixed(2)},${baseline.toFixed(2)} Z`;
}
/** Area between an upper and lower line (for bands / stacked areas). */
export function bandPath(upper: Point[], lower: Point[], curve: 'linear' | 'smooth' | 'step' = 'smooth'): string {
if (!upper.length) return '';
const top = curvePath(upper, curve);
const reversed = [...lower].reverse();
const bottom = curvePath(reversed, curve).replace(/^M/, 'L');
return `${top} ${bottom} Z`;
}
/** Polar → cartesian, with 0 rad at 12 o'clock and clockwise rotation. */
export function polar(cx: number, cy: number, r: number, angle: number): Point {
return [cx + r * Math.sin(angle), cy - r * Math.cos(angle)];
}
/** Annular sector path (donut slice). Angles in radians, 0 at 12 o'clock. */
export function arcPath(cx: number, cy: number, rOuter: number, rInner: number, a0: number, a1: number): string {
const sweep = a1 - a0;
if (sweep >= Math.PI * 2 - 1e-6) {
// Full ring: draw two halves to avoid a degenerate arc.
const mid = a0 + Math.PI;
return `${arcPath(cx, cy, rOuter, rInner, a0, mid)} ${arcPath(cx, cy, rOuter, rInner, mid, a0 + Math.PI * 2)}`;
}
const large = sweep > Math.PI ? 1 : 0;
const [x0, y0] = polar(cx, cy, rOuter, a0);
const [x1, y1] = polar(cx, cy, rOuter, a1);
if (rInner <= 0) {
return `M${cx},${cy} L${x0.toFixed(2)},${y0.toFixed(2)} A${rOuter},${rOuter} 0 ${large} 1 ${x1.toFixed(2)},${y1.toFixed(2)} Z`;
}
const [x2, y2] = polar(cx, cy, rInner, a1);
const [x3, y3] = polar(cx, cy, rInner, a0);
return [
`M${x0.toFixed(2)},${y0.toFixed(2)}`,
`A${rOuter},${rOuter} 0 ${large} 1 ${x1.toFixed(2)},${y1.toFixed(2)}`,
`L${x2.toFixed(2)},${y2.toFixed(2)}`,
`A${rInner},${rInner} 0 ${large} 0 ${x3.toFixed(2)},${y3.toFixed(2)}`,
'Z',
].join(' ');
}
/** Open arc stroke path (for gauges / rings). */
export function arcStroke(cx: number, cy: number, r: number, a0: number, a1: number): string {
const sweep = Math.min(a1 - a0, Math.PI * 2 - 1e-4);
const large = sweep > Math.PI ? 1 : 0;
const [x0, y0] = polar(cx, cy, r, a0);
const [x1, y1] = polar(cx, cy, r, a0 + sweep);
return `M${x0.toFixed(2)},${y0.toFixed(2)} A${r},${r} 0 ${large} 1 ${x1.toFixed(2)},${y1.toFixed(2)}`;
}
/** Sum helper. */
export function sum(values: number[]): number {
return values.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0);
}
/** Quantile on a sorted array (linear interpolation). */
export function quantile(sorted: number[], q: number): number {
if (!sorted.length) return 0;
const pos = (sorted.length - 1) * q;
const lo = Math.floor(pos);
const hi = Math.ceil(pos);
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
}
/** Unique, stable id for SVG gradients/clip paths so multiple charts can coexist. */
let uid = 0;
export function chartId(prefix = 'chart'): string {
uid += 1;
return `${prefix}-${uid}-${Math.random().toString(36).slice(2, 7)}`;
}
export interface Rect {
x: number;
y: number;
w: number;
h: number;
}
/** Squarified treemap layout (Bruls et al.) for a list of positive values. */
export function squarify(values: number[], box: Rect): Rect[] {
const total = sum(values);
if (!total) return values.map(() => ({ x: box.x, y: box.y, w: 0, h: 0 }));
const area = box.w * box.h;
const items = values.map((v, i) => ({ i, a: (v / total) * area })).sort((a, b) => b.a - a.a);
const out: Rect[] = new Array(values.length);
let rect = { ...box };
let row: typeof items = [];
const worst = (r: typeof items, side: number) => {
const s = r.reduce((acc, it) => acc + it.a, 0);
const max = Math.max(...r.map((it) => it.a));
const min = Math.min(...r.map((it) => it.a));
return Math.max((side * side * max) / (s * s), (s * s) / (side * side * min));
};
const layoutRow = (r: typeof items) => {
const s = r.reduce((acc, it) => acc + it.a, 0);
if (rect.w >= rect.h) {
const w = s / rect.h;
let y = rect.y;
r.forEach((it) => {
const h = it.a / w;
out[it.i] = { x: rect.x, y, w, h };
y += h;
});
rect = { x: rect.x + w, y: rect.y, w: rect.w - w, h: rect.h };
} else {
const h = s / rect.w;
let x = rect.x;
r.forEach((it) => {
const w = it.a / h;
out[it.i] = { x, y: rect.y, w, h };
x += w;
});
rect = { x: rect.x, y: rect.y + h, w: rect.w, h: rect.h - h };
}
};
for (const it of items) {
const side = Math.min(rect.w, rect.h);
if (!row.length || worst([...row, it], side) <= worst(row, side)) {
row.push(it);
} else {
layoutRow(row);
row = [it];
}
}
if (row.length) layoutRow(row);
return out;
}
// ─────────────────────────────────────────────────────────────
// chart-tooltip.ts
// ─────────────────────────────────────────────────────────────
// src/components/lib/charts/chart-tooltip.ts
// Tiny, dependency-free tooltip controller shared by every SVG chart.
// Any element inside a `[data-chart]` root that carries `data-tip` becomes a
// hover/focus target. Lines in `data-tip` are separated by "|"; an optional
// `data-tip-title` renders as the bold heading.
function show(root: HTMLElement, tip: HTMLElement, target: Element, clientX?: number, clientY?: number) {
const title = target.getAttribute('data-tip-title') || '';
const body = (target.getAttribute('data-tip') || '').split('|').filter(Boolean);
tip.replaceChildren();
if (title) {
const h = document.createElement('strong');
h.textContent = title;
tip.appendChild(h);
}
body.forEach((line) => {
const row = document.createElement('span');
const [swatch, text] = line.includes('::') ? line.split('::') : ['', line];
if (swatch) {
const dot = document.createElement('i');
dot.style.background = swatch;
row.appendChild(dot);
}
row.appendChild(document.createTextNode(text));
tip.appendChild(row);
});
const box = root.getBoundingClientRect();
let x: number;
let y: number;
if (clientX !== undefined && clientY !== undefined) {
x = clientX - box.left;
y = clientY - box.top;
} else {
const r = target.getBoundingClientRect();
x = r.left + r.width / 2 - box.left;
y = r.top - box.top;
}
tip.style.opacity = '1';
const tw = tip.offsetWidth;
const th = tip.offsetHeight;
const left = Math.min(Math.max(8, x + 14), box.width - tw - 8);
const top = y - th - 12 < 4 ? y + 18 : y - th - 12;
tip.style.transform = `translate(${left}px, ${top}px)`;
}
function hide(tip: HTMLElement) {
tip.style.opacity = '0';
}
export function initChartTooltips() {
document.querySelectorAll<HTMLElement>('[data-chart]:not([data-chart-ready])').forEach((root) => {
root.setAttribute('data-chart-ready', '');
const tip = root.querySelector<HTMLElement>('[data-chart-tip]');
if (!tip) return;
root.addEventListener('pointermove', (e) => {
const target = (e.target as Element).closest('[data-tip]');
if (target && root.contains(target)) show(root, tip, target, e.clientX, e.clientY);
else hide(tip);
});
root.addEventListener('pointerleave', () => hide(tip));
root.addEventListener('focusin', (e) => {
const target = (e.target as Element).closest('[data-tip]');
if (target) show(root, tip, target);
});
root.addEventListener('focusout', () => hide(tip));
root.addEventListener('keydown', (e) => {
if (e.key === 'Escape') hide(tip);
});
});
}
initChartTooltips();
document.addEventListener('astro:page-load', initChartTooltips); ---
/**
* ChartFrame — Shared accessible shell for every SVG chart in the kit.
* Renders the card surface, header, legend, hover tooltip and a
* "View data" table fallback so no chart relies on color or hover alone.
*
* @param {string} title - Chart heading (names the series when there is only one).
* @param {string} subtitle - Supporting description under the title.
* @param {string} value - Optional headline figure shown on the right of the header.
* @param {string} delta - Optional change label next to the headline figure.
* @param {boolean} deltaPositive - Good news (green ▲) or bad (red ▼); omit for a neutral caption.
* @param {Array<{label: string, color: string}>} legend - Legend items (shown when ≥ 2 series).
* @param {{headers: string[], rows: Array<Array<string|number>>}} table - Data for the table view.
* @param {string} variant - Surface style (glass|solid|ghost).
* @param {string} size - Density (sm|md|lg).
* @param {string} className - Extra CSS class.
*
* Accessibility: the figure is labelled by its title, every mark is focusable via
* the chart itself, and a native <details> data table is always available.
*/
interface Props {
title?: string;
subtitle?: string;
value?: string;
delta?: string;
deltaPositive?: boolean;
legend?: Array<{ label: string; color: string }>;
table?: { headers: string[]; rows: Array<Array<string | number>> };
variant?: 'glass' | 'solid' | 'ghost';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const {
title,
subtitle,
value,
delta,
deltaPositive,
legend = [],
table,
variant = 'glass',
size = 'md',
className = '',
} = Astro.props;
const titleId = `cf-${Math.random().toString(36).slice(2, 9)}`;
---
<figure
class={`cf cf--${variant} cf--${size} ${className}`}
data-chart
aria-labelledby={title ? titleId : undefined}
>
{(title || value) && (
<header class="cf__header">
<div class="cf__heading">
{title && <figcaption id={titleId} class="cf__title">{title}</figcaption>}
{subtitle && <p class="cf__subtitle">{subtitle}</p>}
</div>
{value && (
<div class="cf__headline">
<span class="cf__value">{value}</span>
{delta && (
<span class:list={['cf__delta', { 'cf__delta--pos': deltaPositive === true, 'cf__delta--neg': deltaPositive === false, 'cf__delta--neutral': deltaPositive === undefined }]}>
{deltaPositive !== undefined && (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path d={deltaPositive ? 'M5 1 L9 7 H1 Z' : 'M5 9 L9 3 H1 Z'} fill="currentColor" />
</svg>
)}
{delta}
</span>
)}
</div>
)}
</header>
)}
{legend.length > 1 && (
<ul class="cf__legend" aria-label="Legend">
{legend.map((item) => (
<li class="cf__legend-item">
<span class="cf__swatch" style={`background:${item.color}`} aria-hidden="true"></span>
{item.label}
</li>
))}
</ul>
)}
<div class="cf__plot">
<slot />
</div>
<slot name="footer" />
{table && table.rows.length > 0 && (
<details class="cf__data">
<summary>View data</summary>
<div class="cf__table-wrap">
<table>
<thead>
<tr>{table.headers.map((h) => <th scope="col">{h}</th>)}</tr>
</thead>
<tbody>
{table.rows.map((row) => (
<tr>{row.map((cell, i) => (i === 0 ? <th scope="row">{cell}</th> : <td>{cell}</td>))}</tr>
))}
</tbody>
</table>
</div>
</details>
)}
<div class="cf__tip" data-chart-tip aria-hidden="true"></div>
</figure>
<script>
import './chart-tooltip';
</script>
<style>
.cf {
--cf-pad: var(--sp-6, 1.5rem);
--cf-title: 1.0625rem;
position: relative;
margin: 0;
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: var(--cf-pad);
border-radius: var(--r-lg, 20px);
color: var(--c-text-2, #a3a3a3);
font-family: var(--font-sans, system-ui, sans-serif);
}
.cf--glass {
background: linear-gradient(160deg, rgb(var(--ink-rgb, 255 255 255) / 0.045), rgb(var(--ink-rgb, 255 255 255) / 0.015));
border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.07));
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.cf--solid {
background: var(--chart-surface, #0d0d0f);
border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.08);
}
.cf--ghost {
background: transparent;
border: 1px solid transparent;
padding-inline: 0;
}
.cf--sm { --cf-pad: var(--sp-4, 1rem); --cf-title: 0.9375rem; }
.cf--lg { --cf-pad: var(--sp-8, 2rem); --cf-title: 1.25rem; }
.cf__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--sp-4, 1rem);
margin-bottom: var(--sp-4, 1rem);
}
.cf__title {
margin: 0;
font-size: var(--cf-title);
font-weight: 650;
color: var(--c-text-1, #fff);
letter-spacing: -0.01em;
}
.cf__subtitle {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
color: var(--c-text-2, #a3a3a3);
line-height: 1.45;
}
.cf__headline {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
flex-shrink: 0;
}
.cf__value {
font-size: 1.5rem;
font-weight: 700;
color: var(--c-text-1, #fff);
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.cf__delta {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--c-text-1, #fff);
}
.cf__delta--pos svg { color: var(--chart-pos, #34c38f); }
.cf__delta--neg svg { color: var(--chart-neg, #f06a6a); }
.cf__delta--neutral { color: var(--c-text-3, #8a8a8a); font-weight: 500; }
.cf__legend {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1rem;
list-style: none;
margin: 0 0 var(--sp-3, 0.75rem);
padding: 0;
}
.cf__legend-item {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.8125rem;
color: var(--c-text-2, #a3a3a3);
}
.cf__swatch {
width: 10px;
height: 10px;
border-radius: 3px;
}
.cf__plot {
position: relative;
width: 100%;
}
/* Plot SVGs scale fluidly; small inline icons keep their explicit width/height. */
.cf__plot :global(svg:not([width])) {
display: block;
width: 100%;
height: auto;
overflow: visible;
}
/* ── Shared mark vocabulary for every chart ───────────── */
.cf :global(.cf-grid) {
stroke: var(--chart-grid, rgb(var(--ink-rgb, 255 255 255) / 0.07));
stroke-width: 1;
shape-rendering: crispEdges;
}
.cf :global(.cf-axis) {
stroke: var(--chart-axis, rgb(var(--ink-rgb, 255 255 255) / 0.18));
stroke-width: 1;
shape-rendering: crispEdges;
}
.cf :global(.cf-label) {
fill: var(--c-text-3, #8a8a8a);
font-size: 11px;
font-family: var(--font-sans, system-ui, sans-serif);
font-variant-numeric: tabular-nums;
}
.cf :global(.cf-label--strong) {
fill: var(--c-text-1, #fff);
font-weight: 600;
}
.cf :global(.cf-mark) {
transition: opacity 0.2s ease, filter 0.2s ease;
outline: none;
}
.cf :global(.cf-mark:focus-visible) {
stroke: var(--c-text-1, #fff);
stroke-width: 2px;
}
.cf :global(svg:hover .cf-dim),
.cf :global(svg:focus-within .cf-dim) {
opacity: 0.45;
}
.cf :global(svg .cf-dim:hover),
.cf :global(svg .cf-dim:focus-visible) {
opacity: 1;
}
.cf :global(.cf-hit) {
fill: transparent;
cursor: crosshair;
outline: none;
}
.cf :global(.cf-cross) {
stroke: rgb(var(--ink-rgb, 255 255 255) / 0.35);
stroke-width: 1;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
.cf :global(.cf-hit-group:hover .cf-cross),
.cf :global(.cf-hit-group:focus-within .cf-cross) {
opacity: 1;
}
.cf :global(.cf-hit:focus-visible) {
fill: rgb(var(--ink-rgb, 255 255 255) / 0.04);
}
/* ── Tooltip ─────────────────────────────────────────── */
.cf__tip {
position: absolute;
top: 0;
left: 0;
z-index: 5;
pointer-events: none;
opacity: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 7rem;
max-width: 16rem;
padding: 0.55rem 0.7rem;
border-radius: var(--r-sm, 8px);
background: rgb(var(--paper-rgb, 12 12 14) / 0.96);
border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.12);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
font-size: 0.75rem;
color: var(--c-text-1, #fff);
transition: opacity 0.15s ease;
}
.cf__tip :global(strong) {
font-size: 0.75rem;
font-weight: 650;
margin-bottom: 0.15rem;
}
.cf__tip :global(span) {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--c-text-2, #c4c4c4);
font-variant-numeric: tabular-nums;
}
.cf__tip :global(i) {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
}
/* ── Data table fallback ─────────────────────────────── */
.cf__data {
margin-top: var(--sp-3, 0.75rem);
font-size: 0.8125rem;
}
.cf__data summary {
display: inline-flex;
align-items: center;
min-height: 32px;
cursor: pointer;
color: var(--c-text-3, #8a8a8a);
border-radius: 6px;
list-style-position: inside;
}
.cf__data summary:hover { color: var(--c-text-1, #fff); }
.cf__data summary:focus-visible {
outline: 2px solid var(--c-primary-light, #d4a017);
outline-offset: 2px;
}
.cf__table-wrap {
overflow-x: auto;
margin-top: 0.5rem;
}
.cf__data table {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
.cf__data th,
.cf__data td {
padding: 0.4rem 0.6rem;
text-align: right;
border-bottom: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.06);
white-space: nowrap;
}
.cf__data th:first-child { text-align: left; }
.cf__data thead th { color: var(--c-text-3, #8a8a8a); font-weight: 600; }
.cf__data tbody th { color: var(--c-text-1, #fff); font-weight: 500; }
.cf__data td { color: var(--c-text-2, #a3a3a3); }
@media (prefers-reduced-motion: reduce) {
.cf :global(*) { transition: none !important; animation: none !important; }
}
</style>
// ─────────────────────────────────────────────────────────────
// chart-utils.ts
// ─────────────────────────────────────────────────────────────
// src/components/lib/charts/chart-utils.ts
// Shared, dependency-free math helpers for the SVG chart components.
// Everything here runs at build time (server-side) — no client JS needed.
/** Fixed-order categorical palette (CSS tokens with hex fallbacks). Never cycled past 8. */
export const CHART_COLORS = [
'var(--chart-1, #3987e5)',
'var(--chart-2, #d95926)',
'var(--chart-3, #199e70)',
'var(--chart-4, #c98500)',
'var(--chart-5, #d55181)',
'var(--chart-6, #2f9e2f)',
'var(--chart-7, #9085e9)',
'var(--chart-8, #e66767)',
];
export const POSITIVE = 'var(--chart-pos, #34c38f)';
export const NEGATIVE = 'var(--chart-neg, #f06a6a)';
/** Returns the color for a series index, honouring user overrides. */
export function colorAt(i: number, overrides?: string[]): string {
if (overrides && overrides[i]) return overrides[i];
return CHART_COLORS[Math.min(i, CHART_COLORS.length - 1)];
}
/** Linear scale factory: maps a domain [d0, d1] onto a range [r0, r1]. */
export function scaleLinear(d0: number, d1: number, r0: number, r1: number) {
const span = d1 - d0 || 1;
return (v: number) => r0 + ((v - d0) / span) * (r1 - r0);
}
/** Rounds a raw step to a "nice" 1/2/2.5/5/10 × 10^n value. */
function niceStep(raw: number): number {
const exp = Math.floor(Math.log10(Math.abs(raw) || 1));
const f = raw / 10 ** exp;
const nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10;
return nf * 10 ** exp;
}
/** Produces nice, evenly spaced axis ticks covering [min, max]. */
export function niceTicks(min: number, max: number, count = 5): number[] {
if (min === max) {
max = min + 1;
}
const step = niceStep((max - min) / Math.max(1, count));
const start = Math.floor(min / step) * step;
const end = Math.ceil(max / step) * step;
const ticks: number[] = [];
for (let v = start; v <= end + step / 2; v += step) {
ticks.push(Number(v.toFixed(10)));
}
return ticks;
}
/** Compact number formatter: 1200 → 1.2k, 3400000 → 3.4M. */
export function formatCompact(v: number, decimals = 1): string {
const abs = Math.abs(v);
if (abs >= 1e9) return `${(v / 1e9).toFixed(decimals).replace(/\.0+$/, '')}B`;
if (abs >= 1e6) return `${(v / 1e6).toFixed(decimals).replace(/\.0+$/, '')}M`;
if (abs >= 1e3) return `${(v / 1e3).toFixed(decimals).replace(/\.0+$/, '')}k`;
return `${Number(v.toFixed(2))}`;
}
/** Formats a value with an optional prefix/suffix (e.g. '$', '%'). */
export function formatValue(v: number, prefix = '', suffix = '', compact = true): string {
const body = compact ? formatCompact(v) : v.toLocaleString('en-US');
return `${prefix}${body}${suffix}`;
}
export type Point = [number, number];
/** Straight polyline path. */
export function linePath(points: Point[]): string {
return points.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(2)},${y.toFixed(2)}`).join(' ');
}
/** Step path (horizontal then vertical). */
export function stepPath(points: Point[]): string {
if (!points.length) return '';
let d = `M${points[0][0].toFixed(2)},${points[0][1].toFixed(2)}`;
for (let i = 1; i < points.length; i++) {
const [x, y] = points[i];
d += ` H${x.toFixed(2)} V${y.toFixed(2)}`;
}
return d;
}
/** Monotone cubic interpolation — smooth curve that never overshoots the data. */
export function smoothPath(points: Point[]): string {
const n = points.length;
if (n < 3) return linePath(points);
const dx: number[] = [];
const slope: number[] = [];
for (let i = 0; i < n - 1; i++) {
dx[i] = points[i + 1][0] - points[i][0];
slope[i] = (points[i + 1][1] - points[i][1]) / (dx[i] || 1);
}
const m: number[] = [slope[0]];
for (let i = 1; i < n - 1; i++) {
m[i] = slope[i - 1] * slope[i] <= 0 ? 0 : (slope[i - 1] + slope[i]) / 2;
}
m[n - 1] = slope[n - 2];
for (let i = 0; i < n - 1; i++) {
if (slope[i] === 0) {
m[i] = 0;
m[i + 1] = 0;
continue;
}
const a = m[i] / slope[i];
const b = m[i + 1] / slope[i];
const h = a * a + b * b;
if (h > 9) {
const t = 3 / Math.sqrt(h);
m[i] = t * a * slope[i];
m[i + 1] = t * b * slope[i];
}
}
let d = `M${points[0][0].toFixed(2)},${points[0][1].toFixed(2)}`;
for (let i = 0; i < n - 1; i++) {
const [x0, y0] = points[i];
const [x1, y1] = points[i + 1];
const h = dx[i] / 3;
d += ` C${(x0 + h).toFixed(2)},${(y0 + m[i] * h).toFixed(2)} ${(x1 - h).toFixed(2)},${(y1 - m[i + 1] * h).toFixed(2)} ${x1.toFixed(2)},${y1.toFixed(2)}`;
}
return d;
}
/** Picks the path builder for a curve type. */
export function curvePath(points: Point[], curve: 'linear' | 'smooth' | 'step' = 'smooth'): string {
if (curve === 'step') return stepPath(points);
if (curve === 'linear') return linePath(points);
return smoothPath(points);
}
/** Closes a line path down to a baseline to form an area. */
export function areaPath(points: Point[], baseline: number, curve: 'linear' | 'smooth' | 'step' = 'smooth'): string {
if (!points.length) return '';
const top = curvePath(points, curve);
const last = points[points.length - 1];
const first = points[0];
return `${top} L${last[0].toFixed(2)},${baseline.toFixed(2)} L${first[0].toFixed(2)},${baseline.toFixed(2)} Z`;
}
/** Area between an upper and lower line (for bands / stacked areas). */
export function bandPath(upper: Point[], lower: Point[], curve: 'linear' | 'smooth' | 'step' = 'smooth'): string {
if (!upper.length) return '';
const top = curvePath(upper, curve);
const reversed = [...lower].reverse();
const bottom = curvePath(reversed, curve).replace(/^M/, 'L');
return `${top} ${bottom} Z`;
}
/** Polar → cartesian, with 0 rad at 12 o'clock and clockwise rotation. */
export function polar(cx: number, cy: number, r: number, angle: number): Point {
return [cx + r * Math.sin(angle), cy - r * Math.cos(angle)];
}
/** Annular sector path (donut slice). Angles in radians, 0 at 12 o'clock. */
export function arcPath(cx: number, cy: number, rOuter: number, rInner: number, a0: number, a1: number): string {
const sweep = a1 - a0;
if (sweep >= Math.PI * 2 - 1e-6) {
// Full ring: draw two halves to avoid a degenerate arc.
const mid = a0 + Math.PI;
return `${arcPath(cx, cy, rOuter, rInner, a0, mid)} ${arcPath(cx, cy, rOuter, rInner, mid, a0 + Math.PI * 2)}`;
}
const large = sweep > Math.PI ? 1 : 0;
const [x0, y0] = polar(cx, cy, rOuter, a0);
const [x1, y1] = polar(cx, cy, rOuter, a1);
if (rInner <= 0) {
return `M${cx},${cy} L${x0.toFixed(2)},${y0.toFixed(2)} A${rOuter},${rOuter} 0 ${large} 1 ${x1.toFixed(2)},${y1.toFixed(2)} Z`;
}
const [x2, y2] = polar(cx, cy, rInner, a1);
const [x3, y3] = polar(cx, cy, rInner, a0);
return [
`M${x0.toFixed(2)},${y0.toFixed(2)}`,
`A${rOuter},${rOuter} 0 ${large} 1 ${x1.toFixed(2)},${y1.toFixed(2)}`,
`L${x2.toFixed(2)},${y2.toFixed(2)}`,
`A${rInner},${rInner} 0 ${large} 0 ${x3.toFixed(2)},${y3.toFixed(2)}`,
'Z',
].join(' ');
}
/** Open arc stroke path (for gauges / rings). */
export function arcStroke(cx: number, cy: number, r: number, a0: number, a1: number): string {
const sweep = Math.min(a1 - a0, Math.PI * 2 - 1e-4);
const large = sweep > Math.PI ? 1 : 0;
const [x0, y0] = polar(cx, cy, r, a0);
const [x1, y1] = polar(cx, cy, r, a0 + sweep);
return `M${x0.toFixed(2)},${y0.toFixed(2)} A${r},${r} 0 ${large} 1 ${x1.toFixed(2)},${y1.toFixed(2)}`;
}
/** Sum helper. */
export function sum(values: number[]): number {
return values.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0);
}
/** Quantile on a sorted array (linear interpolation). */
export function quantile(sorted: number[], q: number): number {
if (!sorted.length) return 0;
const pos = (sorted.length - 1) * q;
const lo = Math.floor(pos);
const hi = Math.ceil(pos);
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
}
/** Unique, stable id for SVG gradients/clip paths so multiple charts can coexist. */
let uid = 0;
export function chartId(prefix = 'chart'): string {
uid += 1;
return `${prefix}-${uid}-${Math.random().toString(36).slice(2, 7)}`;
}
export interface Rect {
x: number;
y: number;
w: number;
h: number;
}
/** Squarified treemap layout (Bruls et al.) for a list of positive values. */
export function squarify(values: number[], box: Rect): Rect[] {
const total = sum(values);
if (!total) return values.map(() => ({ x: box.x, y: box.y, w: 0, h: 0 }));
const area = box.w * box.h;
const items = values.map((v, i) => ({ i, a: (v / total) * area })).sort((a, b) => b.a - a.a);
const out: Rect[] = new Array(values.length);
let rect = { ...box };
let row: typeof items = [];
const worst = (r: typeof items, side: number) => {
const s = r.reduce((acc, it) => acc + it.a, 0);
const max = Math.max(...r.map((it) => it.a));
const min = Math.min(...r.map((it) => it.a));
return Math.max((side * side * max) / (s * s), (s * s) / (side * side * min));
};
const layoutRow = (r: typeof items) => {
const s = r.reduce((acc, it) => acc + it.a, 0);
if (rect.w >= rect.h) {
const w = s / rect.h;
let y = rect.y;
r.forEach((it) => {
const h = it.a / w;
out[it.i] = { x: rect.x, y, w, h };
y += h;
});
rect = { x: rect.x + w, y: rect.y, w: rect.w - w, h: rect.h };
} else {
const h = s / rect.w;
let x = rect.x;
r.forEach((it) => {
const w = it.a / h;
out[it.i] = { x, y: rect.y, w, h };
x += w;
});
rect = { x: rect.x, y: rect.y + h, w: rect.w, h: rect.h - h };
}
};
for (const it of items) {
const side = Math.min(rect.w, rect.h);
if (!row.length || worst([...row, it], side) <= worst(row, side)) {
row.push(it);
} else {
layoutRow(row);
row = [it];
}
}
if (row.length) layoutRow(row);
return out;
}
// ─────────────────────────────────────────────────────────────
// chart-tooltip.ts
// ─────────────────────────────────────────────────────────────
// src/components/lib/charts/chart-tooltip.ts
// Tiny, dependency-free tooltip controller shared by every SVG chart.
// Any element inside a `[data-chart]` root that carries `data-tip` becomes a
// hover/focus target. Lines in `data-tip` are separated by "|"; an optional
// `data-tip-title` renders as the bold heading.
function show(root: HTMLElement, tip: HTMLElement, target: Element, clientX?: number, clientY?: number) {
const title = target.getAttribute('data-tip-title') || '';
const body = (target.getAttribute('data-tip') || '').split('|').filter(Boolean);
tip.replaceChildren();
if (title) {
const h = document.createElement('strong');
h.textContent = title;
tip.appendChild(h);
}
body.forEach((line) => {
const row = document.createElement('span');
const [swatch, text] = line.includes('::') ? line.split('::') : ['', line];
if (swatch) {
const dot = document.createElement('i');
dot.style.background = swatch;
row.appendChild(dot);
}
row.appendChild(document.createTextNode(text));
tip.appendChild(row);
});
const box = root.getBoundingClientRect();
let x: number;
let y: number;
if (clientX !== undefined && clientY !== undefined) {
x = clientX - box.left;
y = clientY - box.top;
} else {
const r = target.getBoundingClientRect();
x = r.left + r.width / 2 - box.left;
y = r.top - box.top;
}
tip.style.opacity = '1';
const tw = tip.offsetWidth;
const th = tip.offsetHeight;
const left = Math.min(Math.max(8, x + 14), box.width - tw - 8);
const top = y - th - 12 < 4 ? y + 18 : y - th - 12;
tip.style.transform = `translate(${left}px, ${top}px)`;
}
function hide(tip: HTMLElement) {
tip.style.opacity = '0';
}
export function initChartTooltips() {
document.querySelectorAll<HTMLElement>('[data-chart]:not([data-chart-ready])').forEach((root) => {
root.setAttribute('data-chart-ready', '');
const tip = root.querySelector<HTMLElement>('[data-chart-tip]');
if (!tip) return;
root.addEventListener('pointermove', (e) => {
const target = (e.target as Element).closest('[data-tip]');
if (target && root.contains(target)) show(root, tip, target, e.clientX, e.clientY);
else hide(tip);
});
root.addEventListener('pointerleave', () => hide(tip));
root.addEventListener('focusin', (e) => {
const target = (e.target as Element).closest('[data-tip]');
if (target) show(root, tip, target);
});
root.addEventListener('focusout', () => hide(tip));
root.addEventListener('keydown', (e) => {
if (e.key === 'Escape') hide(tip);
});
});
}
initChartTooltips();
document.addEventListener('astro:page-load', initChartTooltips);
Información Rápida
- Categoría
- Gráficos y Estadísticas
- Nombre de archivo
ChartFrame.astro- Dependencias
- apexcharts (peer)
- Etiquetas
- chartsvga11yfoundationtooltip