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,
  },
  // … 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];

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 ​

One-to-many equi-join of the incoming rows against an inlined right table on a shared on key.

OptionTypeDefaultDescription
onstringRequired. Shared key field matched between the incoming rows and right.
rightRecord<string, any>[]Required. The right-hand table, inlined as JSON rows.

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.
  • Copied right table — the right rows are copied into the chart itself, so a chart using join can be exported and read back whole. A derive cannot: a function is not data, so its body does not survive the round trip.

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