Skip to content

← Examples

Ridgeline Chart ​

A ridgeline chart of Seattle's daily high temperatures by month, with each month's density silhouette overlapping the row above, a thin rule under every baseline, and month names in the left margin instead of a shared y axis.

ts
import { Constraint, chart, field, layer, rect, ribbon, scatter, spread, text } from "gofish-graphics";
import { seattleWeather } from "./dataset";

const monthNames = [
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
];

const binWidth = 2.5;

const temps = seattleWeather.map((d) => d.temp_max);

const minTemp = Math.floor(Math.min(...temps) / binWidth) * binWidth;

const maxTemp = Math.ceil(Math.max(...temps) / binWidth) * binWidth;

const binCount = Math.round((maxTemp - minTemp) / binWidth);

const binCenters = Array.from(
  { length: binCount },
  (_, i) => minTemp + (i + 0.5) * binWidth
);

const counts = new Map<string, number>();

const ridgelineData = monthNames.flatMap((month) =>
  binCenters.map((temp_max, bin) => ({
    month,
    temp_max,
    count: counts.get(`${month}|${bin}`) ?? 0,
  }))
);

const rowPitch = 24;

const labelMarginX = -6;

const container = document.getElementById("app");

chart(ridgelineData, { axes: { x: true, y: false } })
  .flow(
    spread({
      by: field("month").sort(monthNames),
      dir: "y",
      anchor: "baseline",
      spacing: rowPitch,
      h: 330,
      axes: { x: true, y: false },
    }),
    scatter({
      x: "temp_max",
      w: 500,
      axes: { x: true, y: false },
    })
  )
  .mark(
    ribbon({
      h: "count",
      fill: "steelblue",
      stroke: "white",
      strokeWidth: 1,
      opacity: 0.85,
      mixBlendMode: "normal",
    })
  )
  // Per-row baseline labeling, in the style of a ggridges ridgeline: a
  // thin rule along each month's baseline with the month name sticking
  // out to the LEFT of the plot (tick-label style), instead of a standard
  // y axis that can't line up with 12 overlapping, unevenly-tall
  // silhouettes. Two extra tiers:
  //
  //  - RULES: the same fixed-pitch baseline spread as the ridges, marking
  //    a bare rect per month. The rect sits at its row's baseline anchor,
  //    so it registers exactly on the ribbon's zero line. `.zOrder(-1)`
  //    on the tier paints the rules BEHIND the ribbons — visible only
  //    outside the silhouettes, the classic look.
  //  - LABELS: a datumless annotation overlay (a bare mark tier — no
  //    flow), one text per month at literal frame coordinates. This is
  //    deliberate: a spread-laid row normalizes away any extent above or
  //    left of its baseline anchor, so a label can never overhang its own
  //    row — but a bare tier shares the frame origin and CAN reach into
  //    the canvas margin (the render's overhang reserve), exactly like
  //    the ridge peaks reach above the first baseline. Each label's END
  //    is constraint-aligned to a same-row invisible anchor rect fixed at
  //    `labelMarginX` (6px left of the plot edge) — see `labelMarginX`'s
  //    comment — and its bottom edge to the anchor, which sits on the
  //    rule at y = k·pitch, so the label stands on its rule.
  .layer(
    chart(monthNames.map((month) => ({ month })))
      .flow(
        spread({
          by: field("month").sort(monthNames),
          dir: "y",
          anchor: "baseline",
          spacing: rowPitch,
          h: 330,
        })
      )
      .mark(rect({ h: 1, w: 500, fill: "#999" }))
      .zOrder(-1)
  )
  .layer(
    layer(
      monthNames.flatMap((month, k) => [
        rect({ w: 0, h: 0, x: labelMarginX, y: rowPitch * k }).name(
          `anchor${k}`
        ),
        text({
          text: month,
          fontSize: 11,
          fill: "#666",
        }).name(`label${k}`),
      ])
    ).relate((g) =>
      monthNames.map((_, k) =>
        Constraint.align({ x: "end", y: "end" }, [
          g[`label${k}`],
          g[`anchor${k}`],
        ])
      )
    )
  )
  .render(container, {
    w: 500,
    h: 330,
  });
dataset.ts
ts
export type SeattleWeather = {
  date: string;
  precipitation: number;
  temp_max: number;
  temp_min: number;
  wind: number;
  weather: string;
};
export const seattleWeather: SeattleWeather[] = [
  { date: "2012-01-01T00:00:00.000Z", precipitation: 0, temp_max: 12.8, temp_min: 5, wind: 4.7, weather: "drizzle" },
  { date: "2012-01-02T00:00:00.000Z", precipitation: 10.9, temp_max: 10.6, temp_min: 2.8, wind: 4.5, weather: "rain" },
  { date: "2012-01-03T00:00:00.000Z", precipitation: 0.8, temp_max: 11.7, temp_min: 7.2, wind: 2.3, weather: "rain" },
  { date: "2012-01-04T00:00:00.000Z", precipitation: 20.3, temp_max: 12.2, temp_min: 5.6, wind: 4.7, weather: "rain" },
  { date: "2012-01-05T00:00:00.000Z", precipitation: 1.3, temp_max: 8.9, temp_min: 2.8, wind: 6.1, weather: "rain" },
  // … 1,456 more rows
];