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>