Gallery / Interaction

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.
Link plots, controls, and a table

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" }}
      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" }}
      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>

Meta

Details

Family
Interaction
Source
interaction/linked-views
Rendering
SVG
Techniques

interaction · controller · linked-views · select · emphasis · table · svelte