Skip to content

How to place labels ​

A label's position can be a value of the Place family. A place reads the mark's outline and tries spots in order, so labels keep off the marks, off each other, and off any nodes you list. A label with no free spot is hidden.

Label points ​

A dot's label goes around it by default (Place.around()): the first of eight spots, upper right first, that covers no other dot or label. Sort the rows so the labels you care about most come first; they are placed first.

ts
import { chart, circle, scatter } from "gofish-graphics";
import data from "vega-datasets";

(async () => {
  const loaded = {
      countries: ((await data["gapminder.json"]()) as any[])
        .filter((d) => d.year === 2005)
        .sort((a, b) => b.pop - a.pop)
        .slice(0, 36),
    };
  const context = { loaded };
  const container = document.getElementById("app");
  chart(context.loaded.countries, { w: 640, h: 440 })
    .flow(scatter({ by: "country", x: "fertility", y: "life_expect" }))
    .mark(circle({ r: 4 }).label("country"))
    .render(container, { axes: true });
})();

Name lines at their ends ​

A line's label goes past its last point by default (Place.end()), so the labels can replace a legend. Labels that would cover each other slide apart vertically. Use Place.start() to name the lines where they begin.

ts
import { Place, Unit, chart, group, line, scatter } from "gofish-graphics";
import data from "vega-datasets";

const yearlyStocks = async () => {
  const rows = (await data["stocks.csv"]()) as any[];
  const acc = new Map<
    string,
    { symbol: string; date: string; sum: number; n: number }
  >();
  for (const d of rows) {
    const year = new Date(d.date).getFullYear();
    const key = `${d.symbol}${year}`;
    const e = acc.get(key) ?? {
      symbol: d.symbol,
      date: `${year}-07-01`,
      sum: 0,
      n: 0,
    };
    e.sum += +d.price;
    e.n += 1;
    acc.set(key, e);
  }
  return [...acc.values()].map((e) => ({
    symbol: e.symbol,
    date: e.date,
    price: Math.round((e.sum / e.n) * 100) / 100,
  }));
};

(async () => {
  const loaded = { stocks: await yearlyStocks() };
  const context = { loaded };
  const container = document.getElementById("app");
  chart(context.loaded.stocks, { schema: { date: Unit.time() }, w: 560, h: 320 })
    .flow(
      group({ by: "symbol" }),
      scatter({ by: "date", x: "date", y: "price" })
    )
    .mark(
      line({ stroke: "symbol", strokeWidth: 2 }).label("symbol", {
        position: Place.end(),
      })
    )
    .render(container, { axes: true, legend: false });
})();

Write a label on its line ​

Place.along({ at, orient: "sloped" }) puts the label on the line, at of the way along it, turned to follow it. Add a halo so the line does not run through the text. A label that would cover another line slides along its own. side puts the label beside the line instead: "above", "below", or a list such as ["above", "below"], tried in order at each spot.

ts
import { Place, Unit, chart, group, line, scatter } from "gofish-graphics";
import data from "vega-datasets";

const yearlyStocks = async () => {
  const rows = (await data["stocks.csv"]()) as any[];
  const acc = new Map<
    string,
    { symbol: string; date: string; sum: number; n: number }
  >();
  for (const d of rows) {
    const year = new Date(d.date).getFullYear();
    const key = `${d.symbol}${year}`;
    const e = acc.get(key) ?? {
      symbol: d.symbol,
      date: `${year}-07-01`,
      sum: 0,
      n: 0,
    };
    e.sum += +d.price;
    e.n += 1;
    acc.set(key, e);
  }
  return [...acc.values()].map((e) => ({
    symbol: e.symbol,
    date: e.date,
    price: Math.round((e.sum / e.n) * 100) / 100,
  }));
};

(async () => {
  const loaded = { stocks: await yearlyStocks() };
  const context = { loaded };
  const container = document.getElementById("app");
  chart(context.loaded.stocks, { schema: { date: Unit.time() }, w: 560, h: 320 })
    .flow(
      group({ by: "symbol" }),
      scatter({ by: "date", x: "date", y: "price" })
    )
    .mark(
      line({ stroke: "symbol", strokeWidth: 2 }).label("symbol", {
        position: Place.along({ at: 0.6, orient: "sloped" }),
        halo: {},
        fontWeight: 600,
      })
    )
    .render(container, { axes: true, legend: false });
})();

Label the bands of a streamgraph ​

A band's label goes inside it by default (Place.interior()), where the label's box can grow largest. A band too thin for its label leaves it out. A polygon is labeled the same way.

ts
import { chart, ribbon, spread, stack } from "gofish-graphics";
import { seafood } from "./dataset";

const container = document.getElementById("app");
chart(seafood, { w: 520, h: 360 })
  .flow(
    spread({ by: "lake", dir: "x", spacing: 64, alignment: "middle" }),
    stack({ by: "species", dir: "y" })
  )
  .mark(
    ribbon({ h: "count", fill: "species", opacity: 0.8 }).label("species")
  )
  .render(container, { legend: false });
Dataset
ts
export type Lakes =
  | "Lake A"
  | "Lake B"
  | "Lake C"
  | "Lake D"
  | "Lake E"
  | "Lake F";

export type CatchData = {
  lake: Lakes;
  species: "Bass" | "Trout" | "Catfish" | "Perch" | "Salmon";
  count: number;
};

export const catchLocations: Record<Lakes, { x: number; y: number }> = {
  "Lake A": { x: 5.26, y: 22.64 },
  "Lake B": { x: 30.87, y: 120.75 },
  "Lake C": { x: 50.01, y: 60.94 },
  "Lake D": { x: 115.13, y: 94.16 },
  "Lake E": { x: 133.05, y: 50.44 },
  "Lake F": { x: 85.99, y: 172.78 },
};

export const catchLocationsArray = Object.entries(catchLocations).map(
  ([lake, { x, y }]) => ({
    lake,
    x,
    y,
  })
);

export const seafood: CatchData[] = [
  {
    lake: "Lake A",
    species: "Bass",
    count: 23,
  },
  {
    lake: "Lake A",
    species: "Trout",
    count: 31,
  },
  {
    lake: "Lake A",
    species: "Catfish",
    count: 29,
  },
  {
    lake: "Lake A",
    species: "Perch",
    count: 12,
  },
  {
    lake: "Lake A",
    species: "Salmon",
    count: 8,
  },
  // … 25 more rows
];

export const catchData = seafood;

export const catchDataWithLocations = seafood.map((catchItem) => {
  const location = catchLocationsArray.find(
    (loc) => loc.lake === catchItem.lake
  );
  return {
    ...catchItem,
    x: location?.x,
    y: location?.y,
  };
});

export type CatchDataWithLocation = (typeof catchDataWithLocations)[0];

Keep labels off other marks ​

avoiding lists more nodes for the labels to keep off: a selectAll(name), or a list of nodes or refs. Draw those nodes before the labels, so they are placed when the labels look for them.

ts
import { blank, chart, circle, line, scatter, selectAll } from "gofish-graphics";
import data from "vega-datasets";

const trendScatter = (countries: any[]) => {
  const n = countries.length;
  const mx = countries.reduce((s, r) => s + r.fertility, 0) / n;
  const my = countries.reduce((s, r) => s + r.life_expect, 0) / n;
  const slope =
    countries.reduce(
      (s, r) => s + (r.fertility - mx) * (r.life_expect - my),
      0
    ) / countries.reduce((s, r) => s + (r.fertility - mx) ** 2, 0);
  const xs = countries.map((r) => r.fertility);
  const ends = [Math.min(...xs), Math.max(...xs)].map((fertility) => ({
    fertility,
    life_expect: my + slope * (fertility - mx),
  }));
  // The trend line is drawn first, so it is placed when the labels look
  // for it.
  return chart(ends, { w: 640, h: 440 })
    .flow(scatter({ by: "fertility", x: "fertility", y: "life_expect" }))
    .mark(blank().name("ends"))
    .layer(
      chart(selectAll("ends")).mark(
        line({ stroke: "#9e9e9e", strokeWidth: 2 }).name("trend")
      )
    )
    .layer(
      chart(countries)
        .flow(scatter({ by: "country", x: "fertility", y: "life_expect" }))
        .mark(
          circle({ r: 4 }).label("country", { avoiding: selectAll("trend") })
        )
    );
};

(async () => {
  const loaded = {
      countries: ((await data["gapminder.json"]()) as any[])
        .filter((d) => d.year === 2005)
        .sort((a, b) => b.pop - a.pop)
        .slice(0, 36),
    };
  const context = { loaded };
  const container = document.getElementById("app");
  trendScatter(context.loaded.countries).render(container, { axes: true });
})();

Put a bar's value above it, or inside ​

Place.around({ directions: ["top"], side: ["outset", "inset"] }) puts the value above the bar, or just inside its top when there is no room above.

ts
chart(sales)
  .flow(spread({ by: "month", dir: "x" }))
  .mark(
    rect({ h: "units" }).label("units", {
      position: Place.around({
        directions: ["top"],
        side: ["outset", "inset"],
      }),
    })
  );

For a horizontal bar, Place.along({ at: "x-max", side: ["outset", "inset"] }) puts the value just past the bar's end, within the bar's height.