Skip to content

How to use selection

Selection lets you connect marks across charts — for example, drawing a line through scatterplot points or filling the area between stacked bars. It works in two steps:

  1. Name a mark using .name("layerName") to register its nodes
  2. Reference those nodes using selectAll("layerName") (an array of refs) or ref("layerName") (a single ref) as data for another chart

selectAll is the querySelectorAll of GoFish — one ref per named node, never flattened. ref(name) as data is the singular querySelector: it returns one ref and raises if the layer matched zero or more than one node.

Basic pattern

python
layer([
    # Chart 1: create marks and name them
    chart(data)
        .flow(spread(by="category", dir="x"))
        .mark(rect(h="value").name("bars")),

    # Chart 2: selectAll those marks as data for a connector
    chart(selectAll("bars")).mark(line()),
])

The layer function renders both charts in the same coordinate space, allowing the second chart to overlay the first.

TIP

For the common case of threading a connector through a chart's own marks, .connect() is shorter sugar for this two-layer recipe. Reach for the explicit layer([...]) + selectAll form when connecting a different chart's marks or when you need a custom paint order.

Example: Connected scatterplot

line and ribbon take an array of refs directly and read placed geometry off them, so feed them selectAll:

python
from gofish import layer, chart, scatter, circle, line, selectAll

layer([
    chart(driving_shifts)
        .flow(scatter(by="year", x="miles", y="gas"))
        .mark(circle(r=4, fill="white", stroke="black", strokeWidth=2).name("points")),
    chart(selectAll("points")).mark(line(stroke="black", strokeWidth=2)),
]).render(w=400, h=250, axes=True)

The line mark connects all selected points in order.

Example: Invisible blank with line

Sometimes you want a connecting line without visible points. Use blank() to create invisible anchor points:

python
from gofish import layer, chart, scatter, blank, line, selectAll

layer([
    chart(catch_locations)
        .flow(scatter(by="lake", x="x", y="y"))
        .mark(blank().name("points")),
    chart(selectAll("points")).mark(line(stroke="steelblue", strokeWidth=2)),
]).render(w=400, h=250, axes=True)

Example: Re-encoding a selection by its data

Connectors often need to re-partition the selected nodes — a ribbon/stream chart draws one area per species through bars that were laid out per lake. Because the selected stream is now refs (not raw records), you re-encode by the datum path: group(by="datum.species") rather than group(by="species").

python
from gofish import layer, chart, spread, stack, derive, group, rect, area, selectAll

layer([
    chart(seafood)
        .flow(
            spread(by="lake", dir="x", spacing=64),
            derive(lambda d: sorted(d, key=lambda r: r["count"], reverse=True)),
            stack(by="species", dir="y", label=False),
        )
        .mark(rect(h="count", fill="species").name("bars")),
    chart(selectAll("bars"))
        .flow(group(by="datum.species"))
        .mark(ribbon(opacity=0.8)),
]).render(w=500, h=300, axes=True)

Here each bar is a single species row, so datum.species collapses cleanly to one value. A datum.field path resolves only when every row in the ref's bag agrees on that field (homogeneity collapse); if it is multi-valued the path is None and you must disaggregate first. by also accepts a callable escape hatch: group(by=lambda r: r.datum.species). Note the asymmetry: by reads the selection stream (refs, so datum. paths), while a mark's channel like rect(h="count") reads the raw record and is not path-prefixed. See path-aware by.

Example: A single-node reference

When a layer holds exactly one node, ref(name) as chart data returns that one ref — handy for diagrammatic annotations. It raises if the layer matched more than one node, which catches mistakes early:

python
from gofish import layer, chart, scatter, blank, text, ref, selectAll

layer([
    chart(data).flow(scatter(by="id", x="x", y="y")).mark(blank().name("origin")),
    # ref("origin") returns one ref; errors if "origin" matched 0 or >1 nodes
    chart(ref("origin")).mark(text(text="start")),
])

How it works

When you call .name("layerName") on a mark, each node it produces is registered in a shared layer context during rendering. selectAll("layerName") returns a lazy selector that resolves, when the second chart renders, to one ref per registered node; ref("layerName") as data resolves to the single ref (erroring otherwise).

Each ref:

  • Points at the placed node, so overlay marks position themselves relative to it
  • Exposes the bound datum via ref.datum — the raw bag of rows behind the node (a 1-row list if fully split, all the partition's rows if it is an auto-summed aggregate)

Common use cases

GoalPattern
Line through pointscircle().name("points")selectAll("points") + line()
Area under lineblank().name("points")selectAll("points") + ribbon()
Ribbon / streamrect().name("bars")selectAll("bars") + group(by="datum.species") + ribbon()
Single annotationName one mark → chart(ref("name")) borrows that one node