Interactions / linked-views

Interaction

Link plots, controls, and a table

Five Palmer penguins per species, so every row a selection touches is visible in the table beside the chart.

Interaction

Pointer
Select points in either plot, hover or focus table controls to emphasize rows, and use the external controls to update every linked view.
Keyboard
Tab to a plot and press Enter or Space to select its focused point. Continue into the table to toggle or emphasize rows with ordinary buttons.
Touch
Tap points in either plot or use the large external controls; linked selection updates both plots and the table.
Penguins by flipper length and body mass, colored by speciesSelect in either view175175180180185185190190195195200200205205210210215215220220225225300030003500350040004000450045005000500055005500Flipper length (mm)Body mass (g)SpeciesAdelieChinstrapGentoo

Use Arrow keys to inspect data. Press Escape to dismiss.

No active datum

Penguins by flipper length and body mass in a quieter styleThe same keys, quieter styling175175180180185185190190195195200200205205210210215215220220225225300030003500350040004000450045005000500055005500Flipper length (mm)Body mass (g)

Use Arrow keys to inspect data. Press Escape to dismiss.

No active datum

External controls

Select a point in either plot, or use a control below.

Linked semantic rows — 0 selected, 0 emphasized
PenguinSpeciesFlipperMassSelection
adelie-001Adelie181 mm3,750 g
adelie-030Adelie195 mm3,325 g
adelie-059Adelie184 mm2,850 g
adelie-088Adelie186 mm4,450 g
adelie-117Adelie176 mm3,450 g
chinstrap-001Chinstrap192 mm3,500 g
chinstrap-014Chinstrap201 mm4,050 g
chinstrap-027Chinstrap200 mm3,400 g
chinstrap-040Chinstrap205 mm4,500 g
chinstrap-053Chinstrap193 mm3,600 g
gentoo-001Gentoo211 mm4,500 g
gentoo-024Gentoo215 mm5,050 g
gentoo-047Gentoo225 mm5,400 g
gentoo-070Gentoo221 mm5,000 g
gentoo-093Gentoo214 mm4,850 g

Source

Svelte, builder, JSON

<script lang="ts">
  import {
    createPlotInteraction,
    GeomPoint,
    GGPlot,
    Labs,
    ThemeMinimal,
  } from "@ggsvelte/svelte";

  import { penguins, type PenguinRow } from "./data.js";

  const scope = { keys: "penguin-id", x: "flipper-mm", y: "mass-g" } as const;

  // The emphasis control targets the second Adelie row. Derive it rather than
  // naming a key: an id that no longer exists would leave the button reading as
  // pressed while emphasizing nothing at all.
  const emphasisTarget = penguins.filter((row) => row.species === "Adelie")[1];
  if (emphasisTarget === undefined) {
    throw new Error(
      "linked-views needs at least two Adelie rows for the emphasis control",
    );
  }
  let rows = $state<readonly PenguinRow[]>(penguins);
  let showingAllRows = $state(true);
  let status = $state("Select a point in either plot, or use a control below.");

  const interaction = createPlotInteraction<string>({
    onchange: (transition) => {
      status = `${transition.kind} changed by ${transition.source}; revision ${String(transition.revision)}.`;
    },
  });
  const selected = $derived(interaction.selected(scope));
  const emphasized = $derived(interaction.emphasized(scope));

  function selectSpecies(species: PenguinRow["species"]): void {
    interaction.setSelection(
      rows.filter((row) => row.species === species).map((row) => row.id),
      { scope, source: "programmatic" },
    );
  }

  function emphasize(id: string): void {
    interaction.setEmphasis([id], { scope, source: "programmatic" });
  }

  function clearEmphasis(): void {
    interaction.clearEmphasis({ scope, source: "programmatic" });
  }

  function replaceRows(): void {
    showingAllRows = !showingAllRows;
    rows = showingAllRows
      ? penguins
      : penguins.filter((row) => row.species !== "Chinstrap");
    interaction.reconcileKeys(
      rows.map((row) => row.id),
      { scope, source: "programmatic" },
    );
    status = showingAllRows
      ? "Restored every row; stable selections remain linked."
      : "Removed Chinstrap rows and explicitly reconciled shared keys.";
  }

  function clearAll(): void {
    interaction.clearSelection({ scope, source: "programmatic" });
    interaction.clearEmphasis({ scope, source: "programmatic" });
    interaction.resetZoom({ scope, source: "programmatic" });
    status = "Selection, emphasis, and zoom cleared.";
  }
</script>

<div class="linked-demo">
  <div class="plots">
    <GGPlot
      data={rows}
      aes={{ x: "flipper", y: "mass", color: "species" }}
      key="id"
      select={{ type: "point", multiple: true }}
      zoom={{ mode: "x" }}
      {interaction}
      interactionScope={scope}
      width="container"
      height={360}
      ariaLabel="Penguins by flipper length and body mass, colored by species"
    >
      <ThemeMinimal />
      <Labs
        title="Select in either view"
        x="Flipper length (mm)"
        y="Body mass (g)"
        color="Species"
      />
      <GeomPoint size={4} alpha={0.86} />
    </GGPlot>

    <GGPlot
      data={rows}
      aes={{ x: "flipper", y: "mass" }}
      key="id"
      select={{ type: "point", multiple: true }}
      zoom={{ mode: "x" }}
      {interaction}
      interactionScope={scope}
      width="container"
      height={360}
      ariaLabel="Penguins by flipper length and body mass in a quieter style"
    >
      <ThemeMinimal />
      <Labs
        title="The same keys, quieter styling"
        x="Flipper length (mm)"
        y="Body mass (g)"
      />
      <GeomPoint size={3.2} color="#287271" alpha={0.68} />
    </GGPlot>
  </div>

  <section
    class="controls gg-demo-chrome"
    aria-labelledby="linked-controls-heading"
  >
    <div>
      <p class="eyebrow" id="linked-controls-heading">External controls</p>
      <p class="status">{status}</p>
    </div>
    <div class="button-row">
      <button type="button" onclick={() => selectSpecies("Gentoo")}
        >Select Gentoo</button
      >
      <button
        type="button"
        aria-pressed={emphasized.includes(emphasisTarget.id)}
        onclick={() => emphasize(emphasisTarget.id)}>Emphasize Adelie 2</button
      >
      <button type="button" onclick={replaceRows}>
        {showingAllRows ? "Remove Chinstrap rows" : "Restore all rows"}
      </button>
      <button type="button" onclick={clearAll}>Clear all</button>
    </div>
  </section>

  <div class="table-wrap gg-demo-chrome">
    <table>
      <caption>
        Linked semantic rows — {selected.length} selected, {emphasized.length} emphasized
      </caption>
      <thead>
        <tr
          ><th scope="col">Penguin</th><th scope="col">Species</th><th
            scope="col">Flipper</th
          ><th scope="col">Mass</th><th scope="col">Selection</th></tr
        >
      </thead>
      <tbody>
        {#each rows as row (row.id)}
          <tr
            aria-selected={selected.includes(row.id)}
            class:emphasized={emphasized.includes(row.id)}
          >
            <th scope="row">{row.id}</th>
            <td>{row.species}</td>
            <td>{row.flipper} mm</td>
            <td>{row.mass.toLocaleString()} g</td>
            <td>
              <button
                type="button"
                class="row-button"
                aria-pressed={selected.includes(row.id)}
                onmouseenter={() => emphasize(row.id)}
                onmouseleave={clearEmphasis}
                onfocus={() => emphasize(row.id)}
                onblur={clearEmphasis}
                onclick={() => interaction.toggleSelection(row.id, { scope })}
                >{selected.includes(row.id) ? "Selected" : "Select"}</button
              >
            </td>
          </tr>
        {/each}
      </tbody>
    </table>
  </div>
</div>

<style>
  .linked-demo {
    display: grid;
    gap: 1rem;
  }

  .plots {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 1rem;
  }

  .controls {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    padding: 1rem 1.1rem;
    border: 1px solid color-mix(in srgb, var(--fg, #17202a) 16%, transparent);
    border-radius: 0.7rem;
    background: color-mix(in srgb, var(--surface, white) 94%, #287271 6%);
  }

  .eyebrow,
  .status {
    margin: 0;
  }

  .eyebrow {
    color: var(--fg, #17202a);
    font: 650 0.78rem/1.2 var(--gg-font-family, system-ui, sans-serif);
    letter-spacing: 0.05em;
    text-transform: uppercase;
  }

  .status {
    margin-top: 0.25rem;
    color: var(--muted, #59636e);
    font: 0.84rem/1.35 var(--gg-font-family, system-ui, sans-serif);
  }

  .button-row {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: 0.45rem;
  }

  button {
    min-height: 2.25rem;
    padding: 0.42rem 0.72rem;
    border: 1px solid color-mix(in srgb, var(--fg, #17202a) 26%, transparent);
    border-radius: 0.42rem;
    background: var(--surface, white);
    color: var(--fg, #17202a);
    font: 600 0.78rem/1.2 var(--gg-font-family, system-ui, sans-serif);
    cursor: pointer;
  }

  button:hover,
  button:focus-visible,
  button[aria-pressed="true"] {
    border-color: #287271;
    background: color-mix(in srgb, #287271 13%, var(--surface, white));
    outline: none;
  }

  button:focus-visible {
    box-shadow: 0 0 0 3px color-mix(in srgb, #287271 28%, transparent);
  }

  .table-wrap {
    overflow-x: auto;
  }

  table {
    width: 100%;
    border-collapse: collapse;
    color: var(--fg, #17202a);
    font: 0.82rem/1.35 var(--gg-font-family, system-ui, sans-serif);
  }

  caption {
    padding: 0 0 0.55rem;
    font-weight: 650;
    text-align: left;
  }

  th,
  td {
    padding: 0.5rem 0.65rem;
    border-bottom: 1px solid
      color-mix(in srgb, var(--fg, #17202a) 12%, transparent);
    text-align: left;
  }

  thead th {
    color: var(--muted, #59636e);
    font-size: 0.72rem;
    letter-spacing: 0.04em;
    text-transform: uppercase;
  }

  tbody tr[aria-selected="true"] {
    background: color-mix(in srgb, #287271 12%, transparent);
  }

  tbody tr.emphasized {
    box-shadow: inset 3px 0 #e09f3e;
  }

  .row-button {
    min-height: 2rem;
    min-width: 5rem;
  }

  @media (max-width: 860px) {
    .plots {
      grid-template-columns: 1fr;
    }

    .controls {
      align-items: stretch;
      flex-direction: column;
    }

    .button-row {
      justify-content: flex-start;
    }
  }
</style>