0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブラウザだけで為替レート 5 年履歴を描く — Frankfurter API + 自前 SVG ラインチャート

0
Posted at

「USD/JPY の 5 年チャートを Web ページに 1 枚埋めたい、サーバは立てたくない、API キー登録もしたくない」が一発で叶う構成: Frankfurter API (ECB の公開為替を CORS 対応で配る無料ラッパ) + 自前 SVG ラインチャート。auth 不要、CORS 通る、JSON 直返し、ブラウザだけで完結する。チャート描画は ~120 行、純粋ロジック ~180 行 + テスト 20 件。

forex-history の画面: 暗色テーマで上にコントロール (base USD / vs JPY,EUR,GBP,CNY / range 5 years / refresh)、中央の SVG ラインチャートに USD/JPY (オレンジ) が 2021 109 → 2026 158 まで上昇する曲線、USD/EUR / USD/GBP / USD/CNY が底部に重ね描き。年単位の x 軸ラベルと値ベースの y 軸ラベル。下部に 4 通貨ペアの stat カード (最新値 / 期間始 / 期間最高 / 期間最安 / 変動率)

🌐 デモ: https://sen.ltd/portfolio/forex-history/
📦 GitHub: https://github.com/sen-ltd/forex-history

なぜ Frankfurter を選ぶか

ブラウザから直接叩ける為替 API は、実は思ったより少ない:

  • ECB 公式 (www.ecb.europa.eu): XML/CSV、CORS 無し → サーバ proxy 必須
  • Yahoo Finance: 非公式 + CORS 不可 + たまに止まる
  • Alpha Vantage: API キー必須 + 無料枠が 1 日 25 リクエスト
  • OpenExchangeRates: API キー必須 + 無料枠 1000/月
  • Frankfurter (api.frankfurter.dev): CORS 対応 + auth 不要 + JSON 直返し + ECB データそのもの

Frankfurter は ECB が毎営業日に発表する reference rates をそのまま再配信する個人運営の無料サービス。営業日午後 3 時 CET 過ぎ に更新される。商用利用も明示的に OK。

URL シンプル:

https://api.frankfurter.dev/v1/2021-01-01..2026-01-01?base=USD&symbols=JPY,EUR,GBP

レスポンスを per-symbol 時系列に pivot する

Frankfurter のレスポンスは 日付キーで通貨を持つ 形:

{
  "base": "USD",
  "rates": {
    "2021-01-04": { "JPY": 103.55, "EUR": 0.815, "GBP": 0.731 },
    "2021-01-05": { "JPY": 103.20, "EUR": 0.812, "GBP": 0.730 }
  }
}

可視化側は 通貨ごとの時系列 が欲しいので pivot する:

export function parseFrankfurterResponse(json) {
  if (!json || !json.rates) return null;
  const dates = Object.keys(json.rates).sort();
  const symbols = new Set();
  for (const d of dates) for (const s of Object.keys(json.rates[d])) symbols.add(s);
  const series = {};
  for (const sym of symbols) series[sym] = [];
  for (const date of dates) {
    for (const sym of symbols) {
      const v = json.rates[date]?.[sym];
      if (typeof v === "number" && Number.isFinite(v)) {
        series[sym].push({ date, value: v });
      }
    }
  }
  return { base: json.base, symbols: [...symbols].sort(), series, start: json.start_date, end: json.end_date };
}

ポイント:

  1. Object.keys(rates).sort() で日付昇順に正規化。Frankfurter は日付昇順で返してくれるが、JS object のキー順は仕様上保証されない ので明示的に sort
  2. Number.isFinite(v) で null / NaN を drop。ECB は祝日のデータを 存在しない 扱いするが、稀に null で来ることがある — フィルタしないと line chart が y=0 に飛ぶ
  3. symbols は全日付を見て収集: 一部の日だけしか出ない通貨にも対応 (実用上はほぼ起きない)

テスト:

test("parseFrankfurterResponse drops missing values inside a row", () => {
  const json = {
    base: "USD",
    rates: {
      "2021-01-04": { JPY: 103.0 },
      "2021-01-05": { JPY: null, EUR: 0.815 },
    },
  };
  const out = parseFrankfurterResponse(json);
  assert.equal(out.series.JPY.length, 1);  // null dropped
  assert.equal(out.series.EUR.length, 1);
});

y 軸の "Nice Numbers" アルゴリズム

ライン chart の y 軸は 読みやすい数字 で目盛を切る。100, 200, 300 のような切りの良い値で。これは Heckbert (1990) の "Nice Numbers for Graph Labels" アルゴリズム:

export function niceTicks(min, max, targetCount = 5) {
  if (min === max) return [min];
  if (min > max) [min, max] = [max, min];
  const step = niceStep((max - min) / targetCount);
  const first = Math.ceil(min / step) * step;
  const out = [];
  for (let v = first; v <= max + step * 1e-9; v += step) {
    out.push(Number(v.toFixed(10)));
  }
  return out;
}

function niceStep(rough) {
  const exp = Math.floor(Math.log10(rough));
  const f = rough / Math.pow(10, exp);
  let nice;
  if (f < 1.5) nice = 1;
  else if (f < 3) nice = 2;
  else if (f < 7) nice = 5;
  else nice = 10;
  return nice * Math.pow(10, exp);
}

要点:

  • ステップ候補は 1, 2, 5 × 10^n のいずれか
  • Math.log10 で桁数を取り、Math.pow(10, exp) でその桁の最小単位 (0.01, 1, 100 等) を作る
  • 浮動小数点 epsilon (step * 1e-9) を末尾比較に入れて、最後の tick が落ちるのを防ぐ
  • Number(v.toFixed(10))0.30000000000000004 のような浮動小数点ノイズを除去

D3 が内部で使っているのと同じ alg。テスト 2 つで主要パスを pin:

test("niceTicks chooses 1/2/5 × 10^n step sizes", () => {
  assert.deepEqual(niceTicks(0, 10, 5), [0, 2, 4, 6, 8, 10]);
  assert.deepEqual(niceTicks(0, 100, 5), [0, 20, 40, 60, 80, 100]);
});

test("niceTicks handles fractional ranges", () => {
  const ticks = niceTicks(0.1, 0.5, 5);
  assert.ok(ticks.every((t) => !String(t).includes("0000000")));
});

SVG <path d="..."> を 1 系列に組み立てる

SVG ライン chart の本体は <path d="M x0 y0 L x1 y1 L x2 y2 ..."> 1 行。これを生成する:

export function buildLinePath(series, xFn, yFn) {
  if (!series.length) return "";
  if (series.length === 1) {
    const { x, y } = xyOf(series[0], xFn, yFn);
    return `M ${fmt(x)} ${fmt(y)} l 0.01 0`;  // 単点でも何か描く
  }
  const first = xyOf(series[0], xFn, yFn);
  let d = `M ${fmt(first.x)} ${fmt(first.y)}`;
  for (let i = 1; i < series.length; i++) {
    const { x, y } = xyOf(series[i], xFn, yFn);
    d += ` L ${fmt(x)} ${fmt(y)}`;
  }
  return d;
}

3 つの工夫:

  1. xFnyFn を引数化 して、関数を unit-free に保つ。呼び出し側で linearScale を bind してから渡す
  2. 0 点 / 1 点 / N 点 で分岐。0 点は空文字、1 点は near-zero stub (l 0.01 0) — l 小文字は相対指定で原点に小さい線を引いて、最低 1 ピクセルは表示させる
  3. fmt(n) で 3 桁丸め。sub-pixel 精度より細かくは目に見えないので、出力サイズを圧縮できる。Number(n.toFixed(3)).toString() で末尾の不要な 0 も切る
test("buildLinePath rounds coords to 3 decimal places for compact output", () => {
  const xFn = () => 1.123456789;
  const yFn = () => 2.987654321;
  const d = buildLinePath([{date:"x",value:1},{date:"y",value:2}], xFn, yFn);
  assert.equal(d, "M 1.123 2.988 L 1.123 2.988");
});

SVG y 軸の "flip" は linearScale で吸収

SVG の y 座標は 下方向に増える (DOM 流)。チャートの値は 上方向に増える ようにしたい (グラフ流)。これは scaler に 逆順の range を渡すだけで吸収できる:

// 上端 y=PAD_TOP に max、下端 y=PAD_TOP+PLOT_H に min を割り当てる
const yFn = (v) => linearScale(v, yMin, yMax, PAD_TOP + PLOT_H, PAD_TOP);

linearScale の実装側で reverse 専用ロジックを書かなくても、rangeMin < rangeMax を要求しない設計 にしておくと自然に動く:

export function linearScale(value, domainMin, domainMax, rangeMin, rangeMax) {
  if (domainMax === domainMin) return (rangeMin + rangeMax) / 2;
  const t = (value - domainMin) / (domainMax - domainMin);
  return rangeMin + t * (rangeMax - rangeMin);
}

test("linearScale handles inverted ranges (for y-axis flipping)", () => {
  assert.equal(linearScale(0, 0, 10, 100, 0), 100);  // value at domain min → range "start" 100
  assert.equal(linearScale(10, 0, 10, 100, 0), 0);   // value at domain max → range "end" 0
});

既知の落とし穴

多通貨の スケール問題: USD/JPY (~100-160) と USD/EUR (~0.8) を同じ y 軸に重ね描きすると、JPY が支配的になって他の系列が 底に張り付いた平坦な線 に見える (上のスクショがまさにそれ)。

解決策は 2 つ:

  • indexed-to-100: 各系列を「期間始 = 100」に正規化して 変動率の重ね描き にする。同じスケールになって直接比較できる
  • 複数 y 軸: 系列ごとに左右 / 内側に別の y 軸を描く。Bloomberg 端末のスタイル。ただし軸の意味がパッと分かりにくくなる

本ツールは「Frankfurter から取って素直に描く」を優先したので両方未実装。indexed-to-100 トグルを足すなら linearScale 一段挟むだけで実現できる。

まとめ

  • ブラウザだけ で為替履歴を取りたいなら Frankfurter (CORS + auth 不要 + JSON 直返し)。商用 OK
  • レスポンスは 日付 → 通貨 の object で来るので、可視化前に per-symbol 時系列に pivot
  • y 軸の "Nice Numbers" は 1/2/5 × 10^n の選び方で人間に読みやすくなる
  • SVG <path>xFn / yFn を引数化 + 3 桁丸め で 1 行 N 点 N 倍に大きくならない
  • y 軸 flip は linearScale逆順 range を渡すだけで吸収

ソース: https://github.com/sen-ltd/forex-history — MIT、合計 ~350 行 (JS)、20 ユニットテスト、ビルド不要、依存ゼロ。


🛠 本記事は SEN 合同会社 が公開している小さな開発者ツール群の 1 つ。他は portfolio 一覧 から。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?