1 звезда на GitHub = 1 новый компонент.

Поставить звезду
Графики и статистика chartsvga11yfoundationtooltip

Chart Frame

Общая доступная оболочка для всех SVG-графиков: карточка, заголовок, легенда, подсказка и таблица данных.

Живое превью

Chart Frame

Использование

Скопируйте и вставьте этот код на вашу страницу 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>

Ручная установка

Если вы не используете пакет npm, создайте файл и вставьте следующий код: 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);

Краткая информация

Категория
Графики и статистика
Имя файла
ChartFrame.astro
Зависимости
apexcharts (peer)
Теги
chartsvga11yfoundationtooltip