SVG折れ線グラフ

Catmull-Rom補間で滑らかにしたSVG折れ線と面グラデ。stroke-dashによる描画アニメで時系列データの推移を魅せます。

#svg#chart#animation#path

ライブデモ

使用例(お題: SaaS FlowDesk)

この技法を「SaaS FlowDesk」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- FlowDesk:利用状況ダッシュボード。週次アクティブ数の推移をSVG折れ線で表示 -->
<section class="fd-stage">
  <header class="fd-head">
    <div class="fd-brand"><span class="fd-mark">◆</span> FlowDesk</div>
    <nav class="fd-tabs">
      <span class="is-active">利用状況</span>
      <span>請求</span>
      <span>設定</span>
    </nav>
  </header>

  <div class="fd-card">
    <div class="fd-card__top">
      <div>
        <p class="fd-card__label">週次アクティブユーザー</p>
        <p class="fd-card__value">8,642 <span class="fd-up">+18.7%</span></p>
      </div>
      <span class="fd-card__range">過去8週</span>
    </div>

    <!-- SVG折れ線グラフ(滑らか曲線+面グラデ+描画アニメ) -->
    <svg id="fdLineChart" class="fd-chart" viewBox="0 0 600 240" preserveAspectRatio="none" role="img" aria-label="週次アクティブユーザーの推移">
      <defs>
        <linearGradient id="fdAreaGrad" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="#4f7cff" stop-opacity="0.35" />
          <stop offset="100%" stop-color="#4f7cff" stop-opacity="0" />
        </linearGradient>
      </defs>
      <g id="fdGrid" class="fd-grid"></g>
      <path id="fdAreaPath" class="fd-area" />
      <path id="fdLinePath" class="fd-line" />
      <g id="fdDots" class="fd-dots"></g>
    </svg>
  </div>
</section>
CSS
/* FlowDesk:利用状況ダッシュボード(SVG折れ線が主役) */
:root {
  --navy: #0f1b34;
  --blue: #4f7cff;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  height: 400px;
  font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  background: radial-gradient(120% 120% at 50% -10%, #1a2c50 0%, #0f1b34 60%, #0a1228 100%);
  color: #eef2ff;
}

.fd-stage { height: 400px; padding: 18px 22px; display: flex; flex-direction: column; }

.fd-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.fd-brand { font-size: 15px; font-weight: 800; letter-spacing: 0.04em; }
.fd-mark { color: var(--blue); }
.fd-tabs { display: flex; gap: 14px; font-size: 12px; color: rgba(255,255,255,0.5); }
.fd-tabs .is-active { color: #fff; font-weight: 700; border-bottom: 2px solid var(--blue); padding-bottom: 3px; }

/* チャートカード */
.fd-card {
  flex: 1;
  background: rgba(255,255,255,0.05);
  border: 1px solid rgba(255,255,255,0.09);
  border-radius: 14px;
  padding: 16px 18px 8px;
  display: flex; flex-direction: column;
}
.fd-card__top { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 6px; }
.fd-card__label { margin: 0; font-size: 11px; letter-spacing: 0.06em; color: #9db4ff; }
.fd-card__value { margin: 4px 0 0; font-size: 24px; font-weight: 800; }
.fd-up { font-size: 12px; font-weight: 700; color: #6fe0a8; margin-left: 4px; }
.fd-card__range { font-size: 11px; color: rgba(255,255,255,0.5); }

/* SVGグラフ本体 */
.fd-chart { flex: 1; width: 100%; min-height: 0; overflow: visible; }
.fd-grid line { stroke: rgba(255,255,255,0.08); stroke-width: 1; }
.fd-grid text { fill: rgba(255,255,255,0.4); font-size: 11px; }
.fd-area { fill: url(#fdAreaGrad); }
.fd-line { fill: none; stroke: var(--blue); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.fd-dots circle {
  fill: #fff; stroke: var(--blue); stroke-width: 2.5; r: 0;
  transition: r 0.25s ease;
}
.fd-dots circle.show { r: 4.5px; }

@media (prefers-reduced-motion: reduce) {
  .fd-dots circle { transition: none; }
}
JavaScript
// FlowDesk:週次アクティブ数をSVG折れ線+面で描画(滑らか曲線・描画アニメ)
(() => {
  const svg = document.getElementById('fdLineChart');
  const linePath = document.getElementById('fdLinePath');
  const areaPath = document.getElementById('fdAreaPath');
  const grid = document.getElementById('fdGrid');
  const dots = document.getElementById('fdDots');
  if (!svg || !linePath || !areaPath || !grid || !dots) return; // null安全

  const NS = 'http://www.w3.org/2000/svg';
  const VBW = 600, VBH = 240;
  const pad = { top: 20, right: 18, bottom: 28, left: 40 };

  // 週次アクティブユーザー(百単位のダミー)
  const values = [52, 58, 55, 64, 61, 73, 70, 86];
  const labels = ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8'];
  const maxV = Math.max(...values);
  const plotW = VBW - pad.left - pad.right;
  const plotH = VBH - pad.top - pad.bottom;

  // データ点を画面座標へ
  const pts = values.map((v, i) => ({
    x: pad.left + (plotW / (values.length - 1)) * i,
    y: pad.top + plotH - (v / maxV) * plotH,
  }));

  // Catmull-Rom→ベジェで滑らかに
  function smoothPath(p) {
    if (p.length < 2) return '';
    let d = `M ${p[0].x} ${p[0].y}`;
    for (let i = 0; i < p.length - 1; i++) {
      const p0 = p[i - 1] || p[i];
      const p1 = p[i];
      const p2 = p[i + 1];
      const p3 = p[i + 2] || p2;
      const c1x = p1.x + (p2.x - p0.x) / 6;
      const c1y = p1.y + (p2.y - p0.y) / 6;
      const c2x = p2.x - (p3.x - p1.x) / 6;
      const c2y = p2.y - (p3.y - p1.y) / 6;
      d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`;
    }
    return d;
  }

  // グリッドと目盛り(千単位表示)
  const steps = 4;
  for (let i = 0; i <= steps; i++) {
    const y = pad.top + (plotH / steps) * i;
    const line = document.createElementNS(NS, 'line');
    line.setAttribute('x1', pad.left);
    line.setAttribute('y1', y);
    line.setAttribute('x2', VBW - pad.right);
    line.setAttribute('y2', y);
    grid.appendChild(line);

    const t = document.createElementNS(NS, 'text');
    t.setAttribute('x', pad.left - 8);
    t.setAttribute('y', y + 4);
    t.setAttribute('text-anchor', 'end');
    t.textContent = `${Math.round((maxV / steps) * (steps - i)) / 10}k`;
    grid.appendChild(t);
  }
  labels.forEach((lb, i) => {
    const t = document.createElementNS(NS, 'text');
    t.setAttribute('x', pts[i].x);
    t.setAttribute('y', VBH - 8);
    t.setAttribute('text-anchor', 'middle');
    t.textContent = lb;
    grid.appendChild(t);
  });

  const lineD = smoothPath(pts);
  const areaD = `${lineD} L ${pts[pts.length - 1].x} ${pad.top + plotH} L ${pts[0].x} ${pad.top + plotH} Z`;
  linePath.setAttribute('d', lineD);
  areaPath.setAttribute('d', areaD);

  // データ点(ツールチップ付き)
  pts.forEach((p, i) => {
    const c = document.createElementNS(NS, 'circle');
    c.setAttribute('cx', p.x);
    c.setAttribute('cy', p.y);
    const title = document.createElementNS(NS, 'title');
    title.textContent = `${labels[i]}: ${(values[i] * 100).toLocaleString()}人`;
    c.appendChild(title);
    dots.appendChild(c);
  });

  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const dotEls = Array.from(dots.children);

  if (reduceMotion) {
    areaPath.style.opacity = '1';
    dotEls.forEach((d) => d.classList.add('show'));
  } else {
    // stroke-dashで線を描画
    const len = linePath.getTotalLength();
    linePath.style.strokeDasharray = String(len);
    linePath.style.strokeDashoffset = String(len);
    areaPath.style.opacity = '0';
    areaPath.style.transition = 'opacity .8s ease .3s';

    requestAnimationFrame(() => {
      linePath.style.transition = 'stroke-dashoffset 1.4s cubic-bezier(.65,0,.35,1)';
      linePath.style.strokeDashoffset = '0';
      areaPath.style.opacity = '1';
    });

    dotEls.forEach((d, i) => {
      setTimeout(() => d.classList.add('show'), 300 + i * 150);
    });
  }
})();

コード

HTML
<div class="dv-wrap">
  <figure class="dv-card">
    <figcaption class="dv-head">
      <h2 class="dv-title">週次セッション推移</h2>
      <p class="dv-sub">SVGパスのストローク・アニメーションと面グラデ</p>
    </figcaption>
    <!-- viewBoxで内部座標を固定し、レスポンシブに伸縮させる -->
    <svg id="lineChart" class="dv-svg" viewBox="0 0 600 240"
         role="img" aria-label="週次セッション数の折れ線グラフ">
      <defs>
        <linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="#a78bfa" stop-opacity="0.45" />
          <stop offset="100%" stop-color="#a78bfa" stop-opacity="0" />
        </linearGradient>
        <linearGradient id="lineStroke" x1="0" y1="0" x2="1" y2="0">
          <stop offset="0%" stop-color="#c084fc" />
          <stop offset="100%" stop-color="#60a5fa" />
        </linearGradient>
      </defs>
      <g id="grid" class="dv-grid"></g>
      <path id="areaPath" class="dv-area" fill="url(#areaFill)"></path>
      <path id="linePath" class="dv-line" fill="none" stroke="url(#lineStroke)"></path>
      <g id="dots" class="dv-dots"></g>
    </svg>
  </figure>
</div>
CSS
:root {
  --dv-radius: 18px;
  --dv-ink: #ede9fe;
  --dv-sub: #c4b5fd;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  color: var(--dv-ink);
  background:
    radial-gradient(900px 500px at 80% -20%, #312e81 0%, transparent 60%),
    linear-gradient(160deg, #1e1b4b, #0f0c29);
}

.dv-wrap {
  width: min(92vw, 720px);
  padding: 20px;
}

.dv-card {
  margin: 0;
  padding: 22px 24px 18px;
  border-radius: var(--dv-radius);
  background: rgba(30, 27, 75, 0.45);
  border: 1px solid rgba(167, 139, 250, 0.25);
  box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.75);
  backdrop-filter: blur(6px);
}

.dv-head { margin-bottom: 12px; }
.dv-title { margin: 0; font-size: clamp(18px, 3.4vw, 22px); }
.dv-sub { margin: 4px 0 0; font-size: 13px; color: var(--dv-sub); }

.dv-svg {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 600 / 240;
  max-height: 220px;
  overflow: visible;
}

.dv-grid line { stroke: rgba(196, 181, 253, 0.16); stroke-width: 1; }
.dv-grid text { fill: rgba(196, 181, 253, 0.7); font-size: 11px; }

.dv-line {
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
  filter: drop-shadow(0 4px 10px rgba(96, 165, 250, 0.4));
}

.dv-dots circle {
  fill: #0f0c29;
  stroke: #c084fc;
  stroke-width: 2.5;
  opacity: 0;
  transition: opacity .3s ease, r .2s ease;
}
.dv-dots circle.show { opacity: 1; }
.dv-dots circle:hover { r: 7; }

/* 動きを抑える設定では即時表示 */
@media (prefers-reduced-motion: reduce) {
  .dv-line, .dv-area { transition: none !important; }
}
JavaScript
// SVGで折れ線+面グラフを生成。滑らかなCatmull-Rom曲線とライン描画アニメ
(() => {
  const svg = document.getElementById('lineChart');
  const linePath = document.getElementById('linePath');
  const areaPath = document.getElementById('areaPath');
  const grid = document.getElementById('grid');
  const dots = document.getElementById('dots');
  if (!svg || !linePath || !areaPath || !grid || !dots) return; // null安全

  const NS = 'http://www.w3.org/2000/svg';
  // viewBox基準の座標系(0..600 x 0..240)
  const VBW = 600, VBH = 240;
  const pad = { top: 20, right: 18, bottom: 28, left: 36 };

  const values = [38, 52, 47, 65, 58, 80, 72, 96]; // 週次データ
  const labels = ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8'];
  const maxV = Math.max(...values);
  const plotW = VBW - pad.left - pad.right;
  const plotH = VBH - pad.top - pad.bottom;

  // データ点を画面座標へ変換
  const pts = values.map((v, i) => ({
    x: pad.left + (plotW / (values.length - 1)) * i,
    y: pad.top + plotH - (v / maxV) * plotH,
  }));

  // Catmull-Rom→ベジェ変換で滑らかな線を作る
  function smoothPath(p) {
    if (p.length < 2) return '';
    let d = `M ${p[0].x} ${p[0].y}`;
    for (let i = 0; i < p.length - 1; i++) {
      const p0 = p[i - 1] || p[i];
      const p1 = p[i];
      const p2 = p[i + 1];
      const p3 = p[i + 2] || p2;
      const c1x = p1.x + (p2.x - p0.x) / 6;
      const c1y = p1.y + (p2.y - p0.y) / 6;
      const c2x = p2.x - (p3.x - p1.x) / 6;
      const c2y = p2.y - (p3.y - p1.y) / 6;
      d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`;
    }
    return d;
  }

  // グリッドと目盛りラベル
  const steps = 4;
  for (let i = 0; i <= steps; i++) {
    const y = pad.top + (plotH / steps) * i;
    const line = document.createElementNS(NS, 'line');
    line.setAttribute('x1', pad.left);
    line.setAttribute('y1', y);
    line.setAttribute('x2', VBW - pad.right);
    line.setAttribute('y2', y);
    grid.appendChild(line);

    const t = document.createElementNS(NS, 'text');
    t.setAttribute('x', pad.left - 8);
    t.setAttribute('y', y + 4);
    t.setAttribute('text-anchor', 'end');
    t.textContent = Math.round((maxV / steps) * (steps - i));
    grid.appendChild(t);
  }
  labels.forEach((lb, i) => {
    const t = document.createElementNS(NS, 'text');
    t.setAttribute('x', pts[i].x);
    t.setAttribute('y', VBH - 8);
    t.setAttribute('text-anchor', 'middle');
    t.textContent = lb;
    grid.appendChild(t);
  });

  const lineD = smoothPath(pts);
  const areaD = `${lineD} L ${pts[pts.length - 1].x} ${pad.top + plotH} L ${pts[0].x} ${pad.top + plotH} Z`;
  linePath.setAttribute('d', lineD);
  areaPath.setAttribute('d', areaD);

  // データ点(ホバーで強調)
  pts.forEach((p, i) => {
    const c = document.createElementNS(NS, 'circle');
    c.setAttribute('cx', p.x);
    c.setAttribute('cy', p.y);
    c.setAttribute('r', 4.5);
    const title = document.createElementNS(NS, 'title');
    title.textContent = `${labels[i]}: ${values[i]}`;
    c.appendChild(title);
    dots.appendChild(c);
  });

  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const dotEls = Array.from(dots.children);

  if (reduceMotion) {
    // アニメ無し:即表示
    areaPath.style.opacity = '1';
    dotEls.forEach((d) => d.classList.add('show'));
  } else {
    // ストローク長を使った描画アニメ
    const len = linePath.getTotalLength();
    linePath.style.strokeDasharray = String(len);
    linePath.style.strokeDashoffset = String(len);
    areaPath.style.opacity = '0';
    areaPath.style.transition = 'opacity .8s ease .3s';

    requestAnimationFrame(() => {
      linePath.style.transition = 'stroke-dashoffset 1.4s cubic-bezier(.65,0,.35,1)';
      linePath.style.strokeDashoffset = '0';
      areaPath.style.opacity = '1';
    });

    // 線の描画進行に合わせて点を順番に表示
    dotEls.forEach((d, i) => {
      setTimeout(() => d.classList.add('show'), 300 + i * 150);
    });
  }
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「SVG折れ線グラフ」の効果を追加してください。

# 追加してほしい効果
SVG折れ線グラフ(データ可視化)
Catmull-Rom補間で滑らかにしたSVG折れ線と面グラデ。stroke-dashによる描画アニメで時系列データの推移を魅せます。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<div class="dv-wrap">
  <figure class="dv-card">
    <figcaption class="dv-head">
      <h2 class="dv-title">週次セッション推移</h2>
      <p class="dv-sub">SVGパスのストローク・アニメーションと面グラデ</p>
    </figcaption>
    <!-- viewBoxで内部座標を固定し、レスポンシブに伸縮させる -->
    <svg id="lineChart" class="dv-svg" viewBox="0 0 600 240"
         role="img" aria-label="週次セッション数の折れ線グラフ">
      <defs>
        <linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="#a78bfa" stop-opacity="0.45" />
          <stop offset="100%" stop-color="#a78bfa" stop-opacity="0" />
        </linearGradient>
        <linearGradient id="lineStroke" x1="0" y1="0" x2="1" y2="0">
          <stop offset="0%" stop-color="#c084fc" />
          <stop offset="100%" stop-color="#60a5fa" />
        </linearGradient>
      </defs>
      <g id="grid" class="dv-grid"></g>
      <path id="areaPath" class="dv-area" fill="url(#areaFill)"></path>
      <path id="linePath" class="dv-line" fill="none" stroke="url(#lineStroke)"></path>
      <g id="dots" class="dv-dots"></g>
    </svg>
  </figure>
</div>

【CSS】
:root {
  --dv-radius: 18px;
  --dv-ink: #ede9fe;
  --dv-sub: #c4b5fd;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  color: var(--dv-ink);
  background:
    radial-gradient(900px 500px at 80% -20%, #312e81 0%, transparent 60%),
    linear-gradient(160deg, #1e1b4b, #0f0c29);
}

.dv-wrap {
  width: min(92vw, 720px);
  padding: 20px;
}

.dv-card {
  margin: 0;
  padding: 22px 24px 18px;
  border-radius: var(--dv-radius);
  background: rgba(30, 27, 75, 0.45);
  border: 1px solid rgba(167, 139, 250, 0.25);
  box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.75);
  backdrop-filter: blur(6px);
}

.dv-head { margin-bottom: 12px; }
.dv-title { margin: 0; font-size: clamp(18px, 3.4vw, 22px); }
.dv-sub { margin: 4px 0 0; font-size: 13px; color: var(--dv-sub); }

.dv-svg {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 600 / 240;
  max-height: 220px;
  overflow: visible;
}

.dv-grid line { stroke: rgba(196, 181, 253, 0.16); stroke-width: 1; }
.dv-grid text { fill: rgba(196, 181, 253, 0.7); font-size: 11px; }

.dv-line {
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
  filter: drop-shadow(0 4px 10px rgba(96, 165, 250, 0.4));
}

.dv-dots circle {
  fill: #0f0c29;
  stroke: #c084fc;
  stroke-width: 2.5;
  opacity: 0;
  transition: opacity .3s ease, r .2s ease;
}
.dv-dots circle.show { opacity: 1; }
.dv-dots circle:hover { r: 7; }

/* 動きを抑える設定では即時表示 */
@media (prefers-reduced-motion: reduce) {
  .dv-line, .dv-area { transition: none !important; }
}

【JavaScript】
// SVGで折れ線+面グラフを生成。滑らかなCatmull-Rom曲線とライン描画アニメ
(() => {
  const svg = document.getElementById('lineChart');
  const linePath = document.getElementById('linePath');
  const areaPath = document.getElementById('areaPath');
  const grid = document.getElementById('grid');
  const dots = document.getElementById('dots');
  if (!svg || !linePath || !areaPath || !grid || !dots) return; // null安全

  const NS = 'http://www.w3.org/2000/svg';
  // viewBox基準の座標系(0..600 x 0..240)
  const VBW = 600, VBH = 240;
  const pad = { top: 20, right: 18, bottom: 28, left: 36 };

  const values = [38, 52, 47, 65, 58, 80, 72, 96]; // 週次データ
  const labels = ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8'];
  const maxV = Math.max(...values);
  const plotW = VBW - pad.left - pad.right;
  const plotH = VBH - pad.top - pad.bottom;

  // データ点を画面座標へ変換
  const pts = values.map((v, i) => ({
    x: pad.left + (plotW / (values.length - 1)) * i,
    y: pad.top + plotH - (v / maxV) * plotH,
  }));

  // Catmull-Rom→ベジェ変換で滑らかな線を作る
  function smoothPath(p) {
    if (p.length < 2) return '';
    let d = `M ${p[0].x} ${p[0].y}`;
    for (let i = 0; i < p.length - 1; i++) {
      const p0 = p[i - 1] || p[i];
      const p1 = p[i];
      const p2 = p[i + 1];
      const p3 = p[i + 2] || p2;
      const c1x = p1.x + (p2.x - p0.x) / 6;
      const c1y = p1.y + (p2.y - p0.y) / 6;
      const c2x = p2.x - (p3.x - p1.x) / 6;
      const c2y = p2.y - (p3.y - p1.y) / 6;
      d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`;
    }
    return d;
  }

  // グリッドと目盛りラベル
  const steps = 4;
  for (let i = 0; i <= steps; i++) {
    const y = pad.top + (plotH / steps) * i;
    const line = document.createElementNS(NS, 'line');
    line.setAttribute('x1', pad.left);
    line.setAttribute('y1', y);
    line.setAttribute('x2', VBW - pad.right);
    line.setAttribute('y2', y);
    grid.appendChild(line);

    const t = document.createElementNS(NS, 'text');
    t.setAttribute('x', pad.left - 8);
    t.setAttribute('y', y + 4);
    t.setAttribute('text-anchor', 'end');
    t.textContent = Math.round((maxV / steps) * (steps - i));
    grid.appendChild(t);
  }
  labels.forEach((lb, i) => {
    const t = document.createElementNS(NS, 'text');
    t.setAttribute('x', pts[i].x);
    t.setAttribute('y', VBH - 8);
    t.setAttribute('text-anchor', 'middle');
    t.textContent = lb;
    grid.appendChild(t);
  });

  const lineD = smoothPath(pts);
  const areaD = `${lineD} L ${pts[pts.length - 1].x} ${pad.top + plotH} L ${pts[0].x} ${pad.top + plotH} Z`;
  linePath.setAttribute('d', lineD);
  areaPath.setAttribute('d', areaD);

  // データ点(ホバーで強調)
  pts.forEach((p, i) => {
    const c = document.createElementNS(NS, 'circle');
    c.setAttribute('cx', p.x);
    c.setAttribute('cy', p.y);
    c.setAttribute('r', 4.5);
    const title = document.createElementNS(NS, 'title');
    title.textContent = `${labels[i]}: ${values[i]}`;
    c.appendChild(title);
    dots.appendChild(c);
  });

  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const dotEls = Array.from(dots.children);

  if (reduceMotion) {
    // アニメ無し:即表示
    areaPath.style.opacity = '1';
    dotEls.forEach((d) => d.classList.add('show'));
  } else {
    // ストローク長を使った描画アニメ
    const len = linePath.getTotalLength();
    linePath.style.strokeDasharray = String(len);
    linePath.style.strokeDashoffset = String(len);
    areaPath.style.opacity = '0';
    areaPath.style.transition = 'opacity .8s ease .3s';

    requestAnimationFrame(() => {
      linePath.style.transition = 'stroke-dashoffset 1.4s cubic-bezier(.65,0,.35,1)';
      linePath.style.strokeDashoffset = '0';
      areaPath.style.opacity = '1';
    });

    // 線の描画進行に合わせて点を順番に表示
    dotEls.forEach((d, i) => {
      setTimeout(() => d.classList.add('show'), 300 + i * 150);
    });
  }
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。