← ./articles-ja

データ可視化ツールのexportはCSV・HTML・SVG・PNGで役割を分ける

データ可視化ツールのexportは、チェックボックス機能ではありません。

Spreadsheetで分析したい人、HTMLレポートを共有したい人、SVGやPNGをドキュメントに貼りたい人では、必要な出力が違います。全部を「スクリーンショット」として扱うと、データの意味が失われます。

formatごとに役割を分けます。

CSVは分析用

CSVにはviewのpixelではなく、viewの背後にあるdataを出します。

Excel互換を考えるなら、UTF-8 BOMを付けます。

function exportCsv(rows: string[][], filename: string) {
  const csv = rows
    .map((row) => row.map(escapeCsvCell).join(","))
    .join("\r\n");

  downloadBlob(
    new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" }),
    filename,
  );
}

function escapeCsvCell(value: string) {
  if (/[",\r\n]/.test(value)) {
    return `"${value.replaceAll('"', '""')}"`;
  }
  return value;
}

BOMがあると、ExcelがUTF-8として読みやすくなります。日本語labelを含むツールでは重要です。

HTMLは共有用

HTML exportは、アプリなしで直接開ける必要があります。

実用的な構成はこうです。

  • inline CSS
  • embedded JSON data
  • 小さなrenderer JavaScript
  • 必要ならCDNのD3
<!doctype html>
<meta charset="utf-8" />
<title>Visualization Export</title>
<div id="app"></div>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<script type="application/json" id="data">
  {"nodes":[],"edges":[]}
</script>
<script>
  const data = JSON.parse(document.getElementById("data").textContent);
  // render visualization
</script>

デスクトップアプリを持っていない相手に結果を渡す時に便利です。

SVGは編集可能なドキュメント用

SVG exportでは、label、shape、colorを保ちます。

CSS変数を使っている場合は、serialize前にcomputed styleをinline化します。

function inlineSvgStyles(svg: SVGSVGElement) {
  const clone = svg.cloneNode(true) as SVGSVGElement;

  for (const el of clone.querySelectorAll<SVGElement>("*")) {
    const computed = getComputedStyle(el);
    el.style.fill = computed.fill;
    el.style.stroke = computed.stroke;
    el.style.color = computed.color;
  }

  clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
  clone.setAttribute("width", String(svg.clientWidth));
  clone.setAttribute("height", String(svg.clientHeight));

  return new XMLSerializer().serializeToString(clone);
}

これをしないと、アプリ内では正しく見えるのに、別viewerで色が抜けることがあります。

PNGは貼り付け用

PNGはissue comment、slide、chatに向いています。

SVGを2倍scaleのcanvasに描画します。

async function svgToPngBlob(svgText: string, width: number, height: number) {
  const img = new Image();
  const url = URL.createObjectURL(new Blob([svgText], { type: "image/svg+xml" }));

  await new Promise<void>((resolve, reject) => {
    img.onload = () => resolve();
    img.onerror = reject;
    img.src = url;
  });

  const canvas = document.createElement("canvas");
  canvas.width = width * 2;
  canvas.height = height * 2;

  const ctx = canvas.getContext("2d")!;
  ctx.scale(2, 2);
  ctx.drawImage(img, 0, 0);
  URL.revokeObjectURL(url);

  return await new Promise<Blob>((resolve) => {
    canvas.toBlob((blob) => resolve(blob!), "image/png");
  });
}

2倍scaleにすると、高密度画面や資料貼り付けでも文字が読みやすくなります。

共通download helperを使う

download処理は共通化します。

function downloadBlob(blob: Blob, filename: string) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

formatごとの処理は、正しい中身を作ることに集中できます。

検証チェック

  • CSVをExcelで開いて日本語labelが壊れない
  • HTMLをdev serverなしで開ける
  • SVGの色がアプリ外でも残る
  • PNGの文字が資料内で読める
  • filenameにcontextとtimestampが入る
  • export内容が現在のfilterと一致する

参考