Refactor Plot benchmarks.

* Faster rendering.
* Flexible mappers for custom series generation.
* Make it easier to visually inspect and find benchmark data sources.

Change-Id: I37634549e0204fff3c6059717d12566d0abdf630
Test: `npm run-script dev`.
diff --git a/development/plot-benchmarks/src/lib/App.svelte b/development/plot-benchmarks/src/lib/App.svelte
index e50908c..ffa3e57 100644
--- a/development/plot-benchmarks/src/lib/App.svelte
+++ b/development/plot-benchmarks/src/lib/App.svelte
@@ -1,23 +1,14 @@
 <script lang="ts">
   import type { Writable } from "svelte/store";
-  import { derived, writable } from "svelte/store";
-  import type { FileMetadata } from "../files.js";
-  import type { Benchmarks } from "../schema.js";
-  import Chart from "./Chart.svelte";
-  import Files from "./Files.svelte";
+  import { writable } from "svelte/store";
+  import type { FileMetadata } from "../types/files.js";
+  import Session from "./Session.svelte";
 
-  let entries: Writable<Array<FileMetadata>> = writable([]);
-  let containers = derived(entries, ($entries) => {
-    const containers: Array<Benchmarks> = [];
-    for (let i = 0; i < $entries.length; i += 1) {
-      let entry = $entries[i];
-      containers.push(entry.benchmarks);
-    }
-    return containers;
-  });
+  // Stores
+  let entries: Writable<FileMetadata[]> = writable([]);
 
-  function onFileMetadataEvent(event) {
-    const detail: Array<FileMetadata> = event.detail;
+  function onFilesChanged(event) {
+    const detail: FileMetadata[] = event.detail;
     if (detail) {
       const enabled = detail.filter((metadata) => metadata.enabled === true);
       $entries = [...enabled];
@@ -31,8 +22,5 @@
 </details>
 
 <div class="container">
-  <Files on:entries={onFileMetadataEvent} />
-  {#if $containers.length > 0}
-    <Chart {containers} />
-  {/if}
+  <Session fileEntries={$entries} on:entries={onFilesChanged} />
 </div>
diff --git a/development/plot-benchmarks/src/lib/Chart.svelte b/development/plot-benchmarks/src/lib/Chart.svelte
index 257f228..7a063a8 100644
--- a/development/plot-benchmarks/src/lib/Chart.svelte
+++ b/development/plot-benchmarks/src/lib/Chart.svelte
@@ -1,42 +1,33 @@
 <script lang="ts">
-  import type { LegendItem } from "chart.js";
+  import type { ChartType, LegendItem } from "chart.js";
   import { Chart } from "chart.js/auto";
   import { onMount } from "svelte";
-  import type { Readable, Writable } from "svelte/store";
-  import { derived, writable } from "svelte/store";
-  import { chartData } from "../chart-transforms.js";
-  import { saveToClipboard } from "../clipboard.js";
+  import { writable, type Writable } from "svelte/store";
+  import type { Data } from "../types/chart.js";
   import { LegendPlugin } from "../plugins.js";
-  import { expressionFilter } from "../regexp.js";
-  import type { Benchmarks } from "../schema.js";
-  import { benchmarksDataset } from "../transforms.js";
-  type FilterFn = (label: string) => boolean;
+  import Legend from "./Legend.svelte";
+  import { saveToClipboard as save } from "../clipboard.js";
 
-  export let containers: Readable<Array<Benchmarks>>;
+  export let data: Data;
+  export let chartType: ChartType = "line";
 
-  let canvas: HTMLCanvasElement;
-  let filter: Writable<FilterFn> = writable((_: string) => true);
-
-  let data = derived([containers, filter], ([$containers, $filter]) => {
-    return chartData(benchmarksDataset($containers, $filter));
-  });
-
-  let chart: Writable<Chart | undefined> = writable(null);
-  let legendLabels: Writable<Array<LegendItem> | undefined> = writable(null);
-
-  data.subscribe(($data) => {
+  $: {
     if ($chart) {
-      $chart.data = $data;
+      $chart.data = data;
       $chart.update();
     }
-  });
+  }
 
+  // State
+  let element: HTMLCanvasElement;
+  let chart: Writable<Chart | null> = writable(null);
+  let items: Writable<LegendItem[] | null> = writable(null);
+
+  // Effects
   onMount(() => {
-    const onUpdate = (updated: Chart) => {
-      chart.set(updated);
-      legendLabels.set(
-        updated.options.plugins.legend.labels.generateLabels(updated)
-      );
+    const onUpdate = (chart: Chart) => {
+      $chart = chart;
+      $items = chart.options.plugins.legend.labels.generateLabels(chart);
     };
     const plugins = {
       legend: {
@@ -46,119 +37,56 @@
         onUpdate: onUpdate,
       },
     };
-    chart.set(
-      new Chart(canvas, {
-        type: "line",
-        data: $data,
-        plugins: [LegendPlugin],
-        options: {
-          plugins: plugins,
-        },
-      })
-    );
+    $chart = new Chart(element, {
+      data: data,
+      type: chartType,
+      plugins: [LegendPlugin],
+      options: {
+        plugins: plugins,
+      },
+    });
   });
 
-  function onItemClick(item: LegendItem) {
-    return (_: Event) => {
-      // https://www.chartjs.org/docs/latest/samples/legend/html.html
-      $chart.setDatasetVisibility(
-        item.datasetIndex,
-        !$chart.isDatasetVisible(item.datasetIndex)
-      );
-      // Update chart
-      $chart.update();
-    };
-  }
-
-  function onFilterChanged(event: Event) {
-    const target = event.currentTarget as HTMLInputElement;
-    const value = target.value;
-    $filter = expressionFilter(value);
-  }
-
-  async function onCopy(_: Event) {
+  // Copy to clip board
+  async function copy(event: Event) {
     if ($chart) {
-      await saveToClipboard($chart);
+      await save($chart);
     }
   }
 </script>
 
 <article>
-  <button
-    class="copy outline"
-    data-tooltip="Copy chart to clipboard."
-    on:click={onCopy}
-  >
-    ⎘
-  </button>
-  <canvas id="chart" class="chart" bind:this={canvas} />
+  <div class="toolbar">
+    <button
+      class="btn outline"
+      data-tooltip="Copy chart to clipboard."
+      on:click={copy}
+    >
+      ⎘
+    </button>
+  </div>
+  <canvas id="chart" class="chart" bind:this={element} />
 </article>
 
-{#if $legendLabels && $legendLabels.length >= 0}
-  <article>
-    <div class="filter">
-      <label for="metricFilter">
-        <input
-          type="text"
-          id="metricFilter"
-          name="metricFilter"
-          placeholder="Filter metrics (regular expressions)"
-          autocomplete="off"
-          on:input={onFilterChanged}
-        />
-      </label>
-    </div>
-    <div class="legend">
-      {#each $legendLabels as label, index}
-        <div
-          class="item"
-          on:dblclick={onItemClick(label)}
-          aria-label="legend"
-          role="listitem"
-        >
-          <span
-            class="box"
-            style="background: {label.fillStyle}; border-color: {label.strokeStyle}; border-width: {label.lineWidth}px;"
-          />
-          <span
-            class="label"
-            style="text-decoration: {label.hidden ? 'line-through' : ''};"
-          >
-            {label.text}
-          </span>
-        </div>
-      {/each}
-    </div>
-  </article>
+{#if $items}
+  <Legend chart={$chart} items={$items} />
 {/if}
 
 <style>
-  .copy {
-    width: 4%;
-    position: relative;
-    padding: 0;
-    border: none;
-    /* Looked okay on my machine. */
-    right: -52rem;
-    top: -3rem;
-  }
   .chart {
     width: 100%;
   }
-  .legend {
-    display: flex;
-    flex-direction: column;
-    row-gap: 3px;
-  }
-  .item {
+  .toolbar {
+    padding: 0;
     display: flex;
     flex-direction: row;
-    column-gap: 10px;
-    align-items: center;
+    justify-content: flex-end;
   }
-  .item .box {
-    display: inline-block;
-    width: 20px;
-    height: 20px;
+
+  .toolbar .btn {
+    width: auto;
+    height: auto;
+    border: none;
+    padding: 5px;
   }
 </style>
diff --git a/development/plot-benchmarks/src/lib/Dataset.svelte b/development/plot-benchmarks/src/lib/Dataset.svelte
new file mode 100644
index 0000000..ce37d45
--- /dev/null
+++ b/development/plot-benchmarks/src/lib/Dataset.svelte
@@ -0,0 +1,68 @@
+<script lang="ts">
+  import { Session, type IndexedWrapper } from "../wrappers/session.js";
+
+  export let name: string;
+  export let datasetGroup: IndexedWrapper[];
+
+  let sources: Set<string>;
+  let sampledMetrics: Set<string>;
+  let metrics: Set<string>;
+
+  $: {
+    sources = Session.sources(datasetGroup);
+
+    let labels = datasetGroup
+      .map((indexed) => indexed.value.metricLabels())
+      .flat();
+
+    let sampled = datasetGroup
+      .map((indexed) => indexed.value.sampledLabels())
+      .flat();
+
+    metrics = new Set<string>(labels);
+    sampledMetrics = new Set<string>(sampled);
+  }
+</script>
+
+<div class="dataset">
+  <hgroup>
+    <div>{name}</div>
+    <div class="details">
+      <div class="sources">
+        {#each sources as source (source)}
+          <div>📁 <small>{source}</small></div>
+        {/each}
+      </div>
+      {#if metrics.size > 0}
+        <div class="metrics">
+          {#each metrics as metric}
+            <div>📏 <small>{metric}</small></div>
+          {/each}
+        </div>
+      {/if}
+      {#if sampledMetrics.size > 0}
+        <div class="sampled">
+          {#each sampledMetrics as metric}
+            <div>📏 <small>{metric}</small></div>
+          {/each}
+        </div>
+      {/if}
+    </div>
+  </hgroup>
+</div>
+
+<style>
+  .dataset {
+    margin: 0.5rem;
+    padding-top: 1rem;
+    padding-left: 1rem;
+    border: 1px solid #b3cee5;
+  }
+  .details {
+    padding-left: 1rem;
+  }
+
+  .details > div {
+    margin-top: 0.25rem;
+  }
+</style>
diff --git a/development/plot-benchmarks/src/lib/Files.svelte b/development/plot-benchmarks/src/lib/Files.svelte
deleted file mode 100644
index ffe09d6..0000000
--- a/development/plot-benchmarks/src/lib/Files.svelte
+++ /dev/null
@@ -1,124 +0,0 @@
-<script lang="ts">
-  import { createEventDispatcher } from "svelte";
-  import type { Writable } from "svelte/store";
-  import { writable } from "svelte/store";
-  import type { FileMetadata, FileMetadataEvent } from "../files.js";
-  import { readBenchmarks } from "../files.js";
-
-  let active: Writable<boolean> = writable(false);
-  let entries: Writable<Array<FileMetadata>> = writable([]);
-  let dispatcher = createEventDispatcher<FileMetadataEvent>();
-
-  entries.subscribe(($entries) => {
-    // Dispatch an event anytime files change.
-    dispatcher("entries", [...$entries]);
-  });
-
-  function onDropFile(event: DragEvent) {
-    handleDropFile(event); // async
-    active.set(false);
-    event.preventDefault();
-  }
-
-  async function handleDropFile(event: DragEvent) {
-    const items = [...event.dataTransfer.items];
-    const metadata = [];
-    if (items) {
-      for (let i = 0; i < items.length; i += 1) {
-        if (items[i].kind === "file") {
-          const file = items[i].getAsFile();
-          if (file.name.endsWith(".json")) {
-            const benchmarks = await readBenchmarks(file);
-            const entry: FileMetadata = {
-              enabled: true,
-              file: file,
-              benchmarks: benchmarks,
-            };
-            metadata.push(entry);
-          }
-        }
-      }
-      // Deep copy
-      $entries = [...$entries, ...metadata];
-    }
-  }
-
-  function onDragOver(event: DragEvent) {
-    active.set(true);
-    event.preventDefault();
-  }
-
-  function onDragLeave(event: DragEvent) {
-    active.set(false);
-    event.preventDefault();
-  }
-
-  function onChecked(entries: Array<FileMetadata>, index: number) {
-    return (event: Event) => {
-      // Deep copy
-      const copied = [...entries];
-      copied[index].enabled = !copied[index].enabled;
-      $entries = copied;
-    };
-  }
-</script>
-
-<article
-  id="drop"
-  class="drop"
-  class:active={$active}
-  on:drop={onDropFile}
-  on:dragover={onDragOver}
-  on:dragleave={onDragLeave}
->
-  {#if $entries.length > 0}
-    <div class="files">
-      {#each $entries as entry, index}
-        <div class="file">
-          <input
-            type="checkbox"
-            checked={entry.enabled}
-            on:change={onChecked($entries, index)}
-          />
-          <div class="index">
-            <span>{index + 1}</span>
-          </div>
-          <span class="label">{entry.file.name}</span>
-        </div>
-      {/each}
-    </div>
-  {:else}
-    <h5>Drag and drop benchmark results to get started.</h5>
-  {/if}
-</article>
-
-<style>
-  .active {
-    background-color: #00bfff;
-    outline: gray;
-    outline-style: groove;
-  }
-
-  .files {
-    display: flex;
-    flex-direction: column;
-    row-gap: 3px;
-  }
-
-  .file {
-    display: flex;
-    flex-direction: row;
-    column-gap: 10px;
-    align-items: center;
-  }
-
-  .index {
-    width: 2rem;
-    height: 2rem;
-    display: flex;
-    flex-direction: row;
-    align-items: center;
-    justify-content: center;
-    border: 2px solid sandybrown;
-  }
-</style>
diff --git a/development/plot-benchmarks/src/lib/Group.svelte b/development/plot-benchmarks/src/lib/Group.svelte
new file mode 100644
index 0000000..328e606
--- /dev/null
+++ b/development/plot-benchmarks/src/lib/Group.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+  import { Session, type IndexedWrapper } from "../wrappers/session.js";
+  import Dataset from "./Dataset.svelte";
+
+  export let className: string;
+  export let datasetGroup: IndexedWrapper[];
+  let datasetNames: Set<string>;
+  $: {
+    datasetNames = Session.datasetNames(datasetGroup);
+  }
+</script>
+
+<details open>
+  <summary>{className}</summary>
+  <div class="details">
+    {#each datasetNames as name (name)}
+      <Dataset {datasetGroup} {name} />
+    {/each}
+  </div>
+</details>
diff --git a/development/plot-benchmarks/src/lib/Legend.svelte b/development/plot-benchmarks/src/lib/Legend.svelte
new file mode 100644
index 0000000..ef239f5
--- /dev/null
+++ b/development/plot-benchmarks/src/lib/Legend.svelte
@@ -0,0 +1,60 @@
+<script lang="ts">
+  import type { Chart, LegendItem } from "chart.js";
+  export let chart: Chart;
+  export let items: LegendItem[];
+
+  const handlerFactory = (item: LegendItem) => {
+    return (event: Event) => {
+      // https://www.chartjs.org/docs/latest/samples/legend/html.html
+      chart.setDatasetVisibility(
+        item.datasetIndex,
+        !chart.isDatasetVisible(item.datasetIndex)
+      );
+      chart.update();
+    };
+  };
+</script>
+
+<div class="legend">
+  <h6>Legend</h6>
+  {#each items as item (item)}
+    <div
+      class="item"
+      on:dblclick={handlerFactory(item)}
+      aria-label="legend"
+      role="listitem"
+    >
+      <span
+        class="box"
+        style="background: {item.fillStyle}; border-color: {item.strokeStyle}; border-width: {item.lineWidth}px;"
+      />
+      <span
+        class="label"
+        style="text-decoration: {item.hidden ? 'line-through' : ''};"
+      >
+        {item.text}
+      </span>
+    </div>
+  {/each}
+</div>
+
+<style>
+  .legend {
+    padding: 4px;
+    display: flex;
+    flex-direction: column;
+    row-gap: 3px;
+  }
+
+  .item {
+    display: flex;
+    flex-direction: row;
+    column-gap: 10px;
+    align-items: center;
+  }
+  .item .box {
+    display: inline-block;
+    width: 20px;
+    height: 20px;
+  }
+</style>
diff --git a/development/plot-benchmarks/src/lib/Session.svelte b/development/plot-benchmarks/src/lib/Session.svelte
new file mode 100644
index 0000000..4b0cf57
--- /dev/null
+++ b/development/plot-benchmarks/src/lib/Session.svelte
@@ -0,0 +1,117 @@
+<script lang="ts">
+  import { createEventDispatcher } from "svelte";
+  import type { FileMetadata } from "../types/files.js";
+  import { Session, type IndexedWrapper } from "../wrappers/session.js";
+  import Group from "./Group.svelte";
+  import type { FileMetadataEvent } from "../types/events.js";
+  import { writable, type Writable } from "svelte/store";
+  import { readBenchmarks } from "../files.js";
+  import type { Metrics } from "../types/data.js";
+  import { Transforms } from "../transforms/metric-transforms.js";
+  import type { Data, Series } from "../types/chart.js";
+  import { ChartDataTransforms } from "../transforms/data-transforms.js";
+  import { STANDARD_MAPPER } from "../transforms/standard-mappers.js";
+  import Chart from "./Chart.svelte";
+
+  export let fileEntries: FileMetadata[];
+
+  // State
+  let eventDispatcher = createEventDispatcher<FileMetadataEvent>();
+  let session: Session;
+  let metrics: Metrics<number>;
+  let series: Series[];
+  let chartData: Data;
+  let classGroups: Record<string, IndexedWrapper[]>;
+  let size: number;
+
+  // Stores
+  let activeDragDrop: Writable<boolean> = writable(false);
+
+  $: {
+    session = new Session(fileEntries);
+    metrics = Transforms.buildMetrics(session);
+    series = ChartDataTransforms.mapToSeries(metrics, STANDARD_MAPPER);
+    chartData = ChartDataTransforms.mapToDataset(series);
+    classGroups = session.classGroups;
+    size = session.fileNames.size;
+  }
+
+  // Helpers
+
+  function onDropFile(event: DragEvent) {
+    handleFileDragDrop(event); // async
+    $activeDragDrop = false;
+    event.preventDefault();
+  }
+
+  function onDragOver(event: DragEvent) {
+    $activeDragDrop = true;
+    event.preventDefault();
+  }
+
+  function onDragLeave(event: DragEvent) {
+    $activeDragDrop = false;
+    event.preventDefault();
+  }
+
+  async function handleFileDragDrop(event: DragEvent) {
+    const items = [...event.dataTransfer.items];
+    const newFiles: FileMetadata[] = [];
+    if (items) {
+      for (let i = 0; i < items.length; i += 1) {
+        if (items[i].kind === "file") {
+          const file = items[i].getAsFile();
+          if (file.name.endsWith(".json")) {
+            const benchmarks = await readBenchmarks(file);
+            const entry: FileMetadata = {
+              enabled: true,
+              file: file,
+              container: benchmarks,
+            };
+            newFiles.push(entry);
+          }
+        }
+      }
+      // Deep copy & notify
+      eventDispatcher("entries", [...fileEntries, ...newFiles]);
+    }
+  }
+</script>
+
+{#if size <= 0}
+  <article
+    id="drop"
+    class="drop"
+    class:active={$activeDragDrop}
+    on:drop={onDropFile}
+    on:dragover={onDragOver}
+    on:dragleave={onDragLeave}
+  >
+    <h5>Drag and drop benchmark results to get started.</h5>
+  </article>
+{:else}
+  <article
+    id="drop"
+    class="drop"
+    class:active={$activeDragDrop}
+    on:drop={onDropFile}
+    on:dragover={onDragOver}
+    on:dragleave={onDragLeave}
+  >
+    <h5>Benchmarks</h5>
+    {#each Object.entries(classGroups) as [className, wrappers]}
+      <Group {className} datasetGroup={wrappers} />
+    {/each}
+  </article>
+
+  {#if series.length > 0}
+    <Chart data={chartData} />
+  {/if}
+{/if}
+
+<style>
+  .active {
+    outline: beige;
+    outline-style: dashed;
+  }
+</style>