DEV Community

SEN LLC
SEN LLC

Posted on

Plotting 5 Years of FX in the Browser — Frankfurter API + Hand-Rolled SVG Line Chart

The "I want a USD/JPY 5-year chart on my page, no backend, no API key" target turns out to be reachable with one stack: Frankfurter (a free, CORS-enabled wrapper around the ECB's official reference rates) for the data, and a hand-rolled SVG <path> for the chart. ~350 lines of vanilla JS, 20 unit tests, ECB data updates daily. This walks through the four pieces that aren't obvious: choosing Frankfurter over the alternatives, pivoting the response into per-symbol series, the "nice numbers" algorithm for y-axis ticks, and the SVG y-flip via inverted range.

forex-history UI: dark layout with controls up top (base USD / vs JPY,EUR,GBP,CNY / range 5 years / refresh button), a 900×400 SVG line chart in the middle with USD/JPY (orange) climbing from ~109 in 2021 to ~158 in 2026, while USD/EUR (pink), USD/GBP (green), and USD/CNY (blue) appear as nearly flat lines along the bottom (the 100-range JPY visually dominates the 1-range pairs). Year labels at the bottom of the x-axis (2022 - 2026), value labels at the y-axis. Below the chart, four stat cards (latest / period start / high / low / % change).

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

Why Frankfurter and not the other FX APIs

The free FX API space is smaller than you'd think once you require "browser-only, no server, no auth, CORS":

  • ECB direct (www.ecb.europa.eu): XML/CSV, no CORS — needs a server proxy.
  • Yahoo Finance: unofficial, no CORS, occasionally blocks bots.
  • Alpha Vantage: free tier capped at 25 requests / day, requires an API key.
  • OpenExchangeRates: 1000 requests / month free, requires an API key.
  • Frankfurter (api.frankfurter.dev): CORS, no auth, JSON, commercial use explicitly allowed.

Frankfurter is a small open-source project that re-serves the ECB's daily reference rates with permissive CORS headers. It updates after ~3 PM CET on ECB business days. Endpoint shape:

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

For a "USD/JPY chart on a static page" use case, this is the answer.

Pivoting the response

Frankfurter's response is keyed by date, then by currency:

{
  "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 }
  }
}
Enter fullscreen mode Exit fullscreen mode

Charts want per-symbol time series, so pivot it:

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 };
}
Enter fullscreen mode Exit fullscreen mode

Three details:

  1. Object.keys(...).sort() because JavaScript object key order isn't reliable across engines for non-integer keys. Sorting explicitly makes the chart deterministic.
  2. Number.isFinite(v) drops null (sometimes appears for holidays Frankfurter didn't fill in) and NaN. Without this, the line chart drops to y=0 wherever a hole exists.
  3. Symbols are collected by union, not assumed from the first row, so a series that only appears on some dates still gets a (sparse) entry.
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);
});
Enter fullscreen mode Exit fullscreen mode

Y-axis ticks: the Heckbert "Nice Numbers" algorithm

A line chart with y-ticks at 127.34, 137.49, 147.65 is correct but unreadable. The classic fix is rounding to 1, 2, or 5 times a power of 10:

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);
}
Enter fullscreen mode Exit fullscreen mode

Two boring-but-important corrections:

  • + step * 1e-9 in the loop bound. Without it, floating-point accumulation drops the last tick. With max=100, step=20, 5 * 20 should be exactly 100, but it isn't — 0 + 20 + 20 + 20 + 20 + 20 ≈ 99.99999... in binary float. The epsilon makes 100 land on the tick.
  • Number(v.toFixed(10)) strips trailing zero artefacts. 0.1 + 0.2 in JavaScript is 0.30000000000000004; calling .toFixed(10) then Number(...) yields the clean 0.3.
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]);
});
Enter fullscreen mode Exit fullscreen mode

This is the same algorithm D3 uses internally for axis.tickArguments() defaults; it's the algorithm worth memorising if you're going to draw axis ticks more than once.

SVG <path d> from a series

The path is one string per series. Generate it in a unit-free way by parameterising the x/y scalers:

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`;   // single-point stub
  }
  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;
}

function fmt(n) {
  return Number(n.toFixed(3)).toString();
}
Enter fullscreen mode Exit fullscreen mode

Three calls worth making:

  1. xFn / yFn are arguments, not closed-over globals. The caller composes them with linearScale(...); the path generator stays pure.
  2. Single-point series get a l 0.01 0 stub — a 1/100 pixel relative line, so a stroke-linecap: round dot still shows up even when the user picks a date range with only one ECB business day in it.
  3. 3-decimal rounding. Sub-pixel precision isn't visible on any realistic display, and on 1,000-point series this saves ~15% bytes in the output SVG.

Inverted-range scale for the SVG y-flip

SVG's y axis grows downward (DOM convention). Charts want y to grow upward. The simple fix is to feed the y scaler an inverted range:

// Top of plot area gets the max value; bottom gets the min.
const yFn = (v) => linearScale(v, yMin, yMax, PAD_TOP + PLOT_H, PAD_TOP);
Enter fullscreen mode Exit fullscreen mode

For this to work, linearScale must not assume 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);
  assert.equal(linearScale(10, 0, 10, 100, 0), 0);
});
Enter fullscreen mode Exit fullscreen mode

That's the entire y-flip. No separate invert flag, no special-case math.

The unavoidable multi-currency scale problem

Look at the screenshot: USD/JPY trends from 109 to 158, while USD/EUR sits at 0.8 and USD/GBP at 0.7. On a shared y-axis the EUR/GBP/CNY lines look almost flat — the JPY's 50-unit movement dominates the 0.1-unit movements of the others.

Two standard fixes:

  • Index to 100: rescale each series so its first point = 100. The chart shows percent change and every series sits in the same range.
  • Dual y-axes: a left axis for the dominant series, a right axis for the others. Bloomberg-style. Confuses some readers; clear once you label it.

I shipped the naive version because the article is about getting the data and drawing it; the index-to-100 option is a linearScale away if you want it.

TL;DR

  • Frankfurter (https://api.frankfurter.dev/) is the right free FX API for browser-only use: CORS, no auth, JSON, ECB data.
  • The response is date → currency → rate; pivot to currency → [{date, value}] for charting.
  • Drop null / non-finite values during the pivot or your line chart will dive to zero on ECB holidays.
  • Use Heckbert's 1/2/5 × 10^n algorithm for y-axis ticks; remember the floating-point epsilon at the loop bound and the toFixed(10) strip for clean labels.
  • Pass xFn / yFn into the path generator so it stays unit-free; round to 3 decimals for compact SVG.
  • Flip the SVG y axis by passing an inverted range to linearScale — no special-case code needed.

Source: https://github.com/sen-ltd/forex-history — MIT, ~350 lines of JS, 20 unit tests, no build step, zero runtime dependencies.


🛠 Built by SEN LLC as part of an ongoing series of small, focused developer tools. Browse the full portfolio for more.

Top comments (0)