join
One-to-many equi-join of the incoming rows against another data table on a shared key. For each incoming (left) row, every right row whose on value matches contributes one output row of the merged columns ({ ...left, ...right }); incoming rows with no match drop out. It's the relational join you know from other tools:
| Language | Equivalent |
|---|---|
| SQL | JOIN right USING (on) |
| pandas / polars | left.merge(right, on=...) |
| dplyr (R) | left_join(right, by = on) |
Unlike resolve — which dereferences columns into the drawn nodes of a prior layer — join relates two plain data tables, so the right table is inlined into the chart's IR and round-trips as JSON.
Pie glyphs from a normalized join
A scatter of lakes by location, where each glyph inherits its lake row and joins in that lake's catch rows to draw a polar pie — two normalized tables instead of one denormalized array:
import { chart, clock, join, rect, scatter, stack } from "gofish-graphics";
import { catchLocationsArray, seafood } from "./dataset";
const container = document.getElementById("app");
chart(catchLocationsArray, { axes: true })
.flow(scatter({ by: "lake", x: "x", y: "y" }))
// The glyph chart leaves off its data: as a nested mark it inherits its
// parent partition (the lake's row), joins in that lake's catch rows, and
// draws them as a polar pie — no `(data) => chart(data, ...)` callback.
.mark(
chart({ coord: clock() })
.flow(
join(seafood, { on: "lake" }),
stack({ by: "species", dir: "x", /* h: "count" */ h: 20 })
)
.mark(rect({ w: "count", fill: "species" }))
)
.render(container, {
w: 400,
h: 400,
});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,
},
{
lake: "Lake B",
species: "Bass",
count: 25,
},
{
lake: "Lake B",
species: "Trout",
count: 34,
},
{
lake: "Lake B",
species: "Catfish",
count: 41,
},
{
lake: "Lake B",
species: "Perch",
count: 21,
},
{
lake: "Lake B",
species: "Salmon",
count: 16,
},
{
lake: "Lake C",
species: "Bass",
count: 15,
},
{
lake: "Lake C",
species: "Trout",
count: 25,
},
{
lake: "Lake C",
species: "Catfish",
count: 31,
},
{
lake: "Lake C",
species: "Perch",
count: 22,
},
{
lake: "Lake C",
species: "Salmon",
count: 31,
},
{
lake: "Lake D",
species: "Bass",
count: 12,
},
{
lake: "Lake D",
species: "Trout",
count: 17,
},
{
lake: "Lake D",
species: "Catfish",
count: 23,
},
{
lake: "Lake D",
species: "Perch",
count: 23,
},
{
lake: "Lake D",
species: "Salmon",
count: 41,
},
{
lake: "Lake E",
species: "Bass",
count: 7,
},
{
lake: "Lake E",
species: "Trout",
count: 9,
},
{
lake: "Lake E",
species: "Catfish",
count: 13,
},
{
lake: "Lake E",
species: "Perch",
count: 20,
},
{
lake: "Lake E",
species: "Salmon",
count: 40,
},
{
lake: "Lake F",
species: "Bass",
count: 4,
},
{
lake: "Lake F",
species: "Trout",
count: 7,
},
{
lake: "Lake F",
species: "Catfish",
count: 9,
},
{
lake: "Lake F",
species: "Perch",
count: 21,
},
{
lake: "Lake F",
species: "Salmon",
count: 47,
},
];
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];The nested glyph chart leaves off its data, so it inherits its parent partition (the lake's row) and joins the catch table onto it:
gf.chart(catchLocationsArray)
.flow(gf.scatter({ by: "lake", x: "x", y: "y" }))
.mark(
gf
.chart({ coord: gf.clock() }) // no data → inherits this lake's partition
.flow(
gf.join(seafood, { on: "lake" }),
gf.stack({ by: "species", dir: "x", h: 20 })
)
.mark(gf.rect({ w: "count", fill: "species" }))
)
.render(root, { w: 400, h: 400 });Signature
join(right, { on });Parameters
| Parameter | Type | Description |
|---|---|---|
right | object[] | The right-hand table — an array of row objects, inlined into the IR. |
on | string | The shared key field matched between the incoming rows and right. |
Returns an Operator for use inside .flow().
Semantics
- One-to-many — each incoming row fans out to one output row per matching
rightrow. A left row matching three right rows yields three output rows. - Inner match — incoming rows with no matching
rightrow drop out (there is no left-outer "keep unmatched with nulls" mode). - Column merge — output rows are
{ ...left, ...right }; on a column-name clash therightvalue wins. - Inlined right table —
righttravels in the IR as JSON, so a chart usingjoinserializes and round-trips without a bridge (contrastderive, whose function body cannot serialize).
join vs. resolve
Both relate two tables on a key, but at different stages:
