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.
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.
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.
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.
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
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.
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.
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.
