Skip to content

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:

LanguageEquivalent
SQLJOIN right USING (on)
pandas / polarsleft.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:

ts
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
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,
  },
  {
    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:

js
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

ts
join(right, { on });

Parameters

ParameterTypeDescription
rightobject[]The right-hand table — an array of row objects, inlined into the IR.
onstringThe 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 right row. A left row matching three right rows yields three output rows.
  • Inner match — incoming rows with no matching right row drop out (there is no left-outer "keep unmatched with nulls" mode).
  • Column merge — output rows are { ...left, ...right }; on a column-name clash the right value wins.
  • Inlined right tableright travels in the IR as JSON, so a chart using join serializes and round-trips without a bridge (contrast derive, whose function body cannot serialize).

join vs. resolve

Both relate two tables on a key, but at different stages:

  • join relates two data tables before layout — the result is more data rows for the chart to draw.
  • resolve relates a data table to a drawn layer, replacing reference columns with node refs that read each node's post-layout position (node-link edges, label anchoring).