Customize tooltips for sampled distributions.

* Add the ability to normalize ranges for distributions to make comparisons easier.

Test: Tested locally.
Change-Id: Ib76f57162fed262b3db2a76cd9e169b897a93bb9
diff --git a/development/plot-benchmarks/src/lib/Chart.svelte b/development/plot-benchmarks/src/lib/Chart.svelte
index de2616e..175da99 100644
--- a/development/plot-benchmarks/src/lib/Chart.svelte
+++ b/development/plot-benchmarks/src/lib/Chart.svelte
@@ -1,13 +1,14 @@
 <script lang="ts">
-  import type { ChartType, LegendItem } from "chart.js";
+  import type { ChartType, LegendItem, Point, TooltipItem } from "chart.js";
   import { Chart } from "chart.js/auto";
   import { createEventDispatcher, onMount } from "svelte";
   import { writable, type Writable } from "svelte/store";
-  import type { Data } from "../types/chart.js";
-  import { LegendPlugin } from "../plugins.js";
-  import Legend from "./Legend.svelte";
   import { saveToClipboard as save } from "../clipboard.js";
+  import { LegendPlugin } from "../plugins.js";
+  import type { Data } from "../types/chart.js";
   import type { Controls, ControlsEvent } from "../types/events.js";
+  import Legend from "./Legend.svelte";
+  import { isSampled } from "../transforms/standard-mappers.js";
 
   export let data: Data;
   export let chartType: ChartType = "line";
@@ -37,6 +38,24 @@
       $items = legend.labels.generateLabels(chart);
     };
     const plugins = {
+      tooltip: {
+        callbacks: {
+          label: (context: TooltipItem<typeof chartType>): string | null => {
+            // TODO: Configure Tooltips
+            // https://www.chartjs.org/docs/latest/configuration/tooltip.html
+            const label = context.dataset.label;
+            const rp = context.raw as Point;
+            const frequency = context.parsed.y;
+            if (isSampled(label)) {
+              const fx = rp.x.toFixed(2);
+              return `${label}: ${fx} F(${frequency})`;
+            } else {
+              // Fallback to default behavior
+              return undefined;
+            }
+          },
+        },
+      },
       legend: {
         display: false,
       },
diff --git a/development/plot-benchmarks/src/lib/Session.svelte b/development/plot-benchmarks/src/lib/Session.svelte
index a099cf8..d4307ee 100644
--- a/development/plot-benchmarks/src/lib/Session.svelte
+++ b/development/plot-benchmarks/src/lib/Session.svelte
@@ -2,32 +2,32 @@
   import type { Remote } from "comlink";
   import { createEventDispatcher } from "svelte";
   import {
-    derived,
-    writable,
-    type Readable,
-    type Writable,
+      derived,
+      writable,
+      type Readable,
+      type Writable,
   } from "svelte/store";
   import { readBenchmarks } from "../files.js";
   import {
-    ChartDataTransforms,
-    type Mapper,
+      ChartDataTransforms,
+      type Mapper,
   } from "../transforms/data-transforms.js";
   import { Transforms } from "../transforms/metric-transforms.js";
+  import { buildMapper } from "../transforms/standard-mappers.js";
   import type { Data, Series } from "../types/chart.js";
   import type { Metrics } from "../types/data.js";
   import type {
-    Controls,
-    DatasetSelection,
-    FileMetadataEvent,
-    MetricSelection,
-    StatInfo,
+      Controls,
+      DatasetSelection,
+      FileMetadataEvent,
+      MetricSelection,
+      StatInfo,
   } from "../types/events.js";
   import type { FileMetadata } from "../types/files.js";
   import type { StatService } from "../workers/service.js";
   import { Session, type IndexedWrapper } from "../wrappers/session.js";
   import Chart from "./Chart.svelte";
   import Group from "./Group.svelte";
-  import { buildMapper } from "../transforms/standard-mappers.js";
 
   export let fileEntries: FileMetadata[];
   export let service: Remote<StatService>;
@@ -40,12 +40,13 @@
   let series: Series[];
   let chartData: Data;
   let classGroups: Record<string, IndexedWrapper[]>;
-  let showHistogramControls: boolean;
+  let showControls: boolean;
   let size: number;
   let activeSeries: Promise<Series[]>;
 
   // Stores
   let buckets: Writable<number> = writable(100);
+  let normalizeMetrics: Writable<boolean> = writable(false);
   let activeDragDrop: Writable<boolean> = writable(false);
   let suppressed: Writable<Set<string>> = writable(new Set());
   let suppressedMetrics: Writable<Set<string>> = writable(new Set());
@@ -113,9 +114,13 @@
     session = new Session(fileEntries);
     mapper = buildMapper($buckets);
     metrics = Transforms.buildMetrics(session, $suppressed, $suppressedMetrics);
-    showHistogramControls = metrics.sampled && metrics.sampled.length > 0;
+    showControls = metrics.sampled && metrics.sampled.length > 0;
     activeSeries = service.pSeries(metrics, $active);
-    series = ChartDataTransforms.mapToSeries(metrics, mapper);
+    series = ChartDataTransforms.mapToSeries(
+      metrics,
+      mapper,
+      $normalizeMetrics
+    );
     chartData = ChartDataTransforms.mapToDataset(series);
     classGroups = session.classGroups;
     size = session.fileNames.size;
@@ -183,6 +188,24 @@
     on:dragover={onDragOver}
     on:dragleave={onDragLeave}
   >
+    {#if showControls}
+      <div class="toolbar">
+        <div class="control">
+          <label for="normalize">
+            <input
+              type="checkbox"
+              id="normalize"
+              name="normalize"
+              data-tooltip="Normalize Metrics"
+              on:change={(_) => {
+                $normalizeMetrics = !$normalizeMetrics;
+              }}
+            />
+            ≃
+          </label>
+        </div>
+      </div>
+    {/if}
     <h5>Benchmarks</h5>
     {#each Object.entries(classGroups) as [className, wrappers]}
       <Group
@@ -199,7 +222,7 @@
   {#if series.length > 0}
     <Chart
       data={chartData}
-      {showHistogramControls}
+      showHistogramControls={showControls}
       on:controls={controlsHandler}
     />
   {/if}
@@ -217,6 +240,13 @@
 {/if}
 
 <style>
+  .toolbar {
+    padding: 0;
+    margin: 2rem;
+    display: flex;
+    flex-direction: row;
+    justify-content: flex-end;
+  }
   .active {
     outline: beige;
     outline-style: dashed;
diff --git a/development/plot-benchmarks/src/transforms/data-transforms.ts b/development/plot-benchmarks/src/transforms/data-transforms.ts
index 41ef149..52b2926 100644
--- a/development/plot-benchmarks/src/transforms/data-transforms.ts
+++ b/development/plot-benchmarks/src/transforms/data-transforms.ts
@@ -14,12 +14,15 @@
  */
 export class ChartDataTransforms {
 
-  static mapToSeries(metrics: Metrics<number>, mapper: Mapper<number>): Series[] {
+  static mapToSeries(metrics: Metrics<number>, mapper: Mapper<number>, normalize: boolean = false): Series[] {
     const series: Series[] = [];
     const standard = metrics.standard;
     const sampled = metrics.sampled;
     // Builds ranges for distribution.
-    const ranges = mapper.sampledRanges(metrics);
+    let ranges: Record<string, Range> = {};
+    if (normalize) {
+      ranges = mapper.sampledRanges(metrics);
+    }
     // Builds series.
     if (standard) {
       for (let i = 0; i < standard.length; i += 1) {
@@ -54,7 +57,7 @@
 
   private static chartDataset<T extends ChartType>(series: Series): ChartDataset {
     return {
-      label: series.label,
+      label: series.descriptiveLabel,
       type: series.type,
       data: series.data,
       ...series.options
diff --git a/development/plot-benchmarks/src/transforms/standard-mappers.ts b/development/plot-benchmarks/src/transforms/standard-mappers.ts
index 9db0cd1..df74b8f9 100644
--- a/development/plot-benchmarks/src/transforms/standard-mappers.ts
+++ b/development/plot-benchmarks/src/transforms/standard-mappers.ts
@@ -3,6 +3,8 @@
 import type { ChartData, Metric, Metrics, Range } from "../types/data.js";
 import type { Mapper } from "./data-transforms.js";
 
+const SAMPLED_SUFFIX = '(S)';
+
 function sampledRanges(metrics: Metrics<number>): Record<string, Range> {
   const ranges: Record<string, Range> = {};
   const sampled = metrics.sampled;
@@ -43,10 +45,10 @@
   const entries = Object.entries(data);
   for (let i = 0; i < entries.length; i += 1) {
     const [source, chartData] = entries[i];
-    const label = labelFor(metric, source);
+    const label = labelFor(metric, source, true);
     const [points, _, __] = histogramPoints(chartData.values, buckets, /* target */ undefined, range);
     series.push({
-      label: label,
+      descriptiveLabel: label,
       type: "line",
       data: points,
       options: {
@@ -63,10 +65,10 @@
   const entries = Object.entries(data);
   for (let i = 0; i < entries.length; i += 1) {
     const [source, chartData] = entries[i];
-    const label = labelFor(metric, source);
+    const label = labelFor(metric, source, false);
     const points = singlePoints(chartData.values);
     series.push({
-      label: label,
+      descriptiveLabel: label,
       type: "line",
       data: points,
       options: {
@@ -104,9 +106,13 @@
   let pMin: number = 0;
   let pMax: number = 0;
   let maxFreq: number = 0;
-  const histogram = new Array(buckets).fill(0);
+  const histogram: Point[] = new Array(buckets).fill(null);
   // The actual number of slots in the histogram
   const slots = buckets - 1;
+  for (let i = 0; i < buckets; i += 1) {
+    const interpolated = interpolate(i / slots, min, max);
+    histogram[i] = { x: interpolated, y: 0 };
+  }
   for (let i = 0; i < flattened.length; i += 1) {
     const value = flattened[i];
     if (target && value < target) {
@@ -117,9 +123,9 @@
     }
     const n = normalize(value, min, max);
     const index = Math.ceil(n * slots);
-    histogram[index] = histogram[index] + 1;
-    if (maxFreq < histogram[index]) {
-      maxFreq = histogram[index];
+    histogram[index].y = histogram[index].y + 1;
+    if (maxFreq < histogram[index].y) {
+      maxFreq = histogram[index].y;
     }
   }
   if (target) {
@@ -129,7 +135,7 @@
   }
   // Pay attention to both sides of the normal distribution.
   let p = Math.min(pMin / flattened.length, pMax / flattened.length);
-  return [singlePoints(histogram), targetPoints, p];
+  return [histogram, targetPoints, p];
 }
 
 function selectPoints(buckets: number, index: number, target: number) {
@@ -168,7 +174,7 @@
   return (n - min) / ((max - min) + 1e-9);
 }
 
-function interpolate(normalized: number, min: number, max: number) {
+function interpolate(normalized: number, min: number, max: number): number {
   const range = max - min;
   const value = normalized * range;
   return value + min;
@@ -177,8 +183,9 @@
 /**
  * Generates a series label.
  */
-function labelFor<T>(metric: Metric<T>, source: string): string {
-  return `${source} {${metric.class} ${metric.benchmark}} - ${metric.label}`;
+function labelFor<T>(metric: Metric<T>, source: string, sampled: boolean): string {
+  const suffix = sampled ? SAMPLED_SUFFIX : '';
+  return `${source} {${metric.class} ${metric.benchmark}} - ${metric.label} ${suffix}`;
 }
 
 export function datasetName(metric: Metric<any>): string {
@@ -190,7 +197,7 @@
  * comparing equal distributions.
  */
 function rangeLabel(metric: Metric<unknown>): string {
-  return `${metric.benchmark}>${metric.label}`;
+  return `${metric.label}`;
 }
 
 /**
@@ -223,3 +230,7 @@
 export function buildMapper(buckets: number): Mapper<number> {
   return new StandardMapper(buckets);
 }
+
+export function isSampled(label: string | null | undefined): boolean {
+  return label && label.indexOf(SAMPLED_SUFFIX) >= 0;
+}
diff --git a/development/plot-benchmarks/src/types/chart.ts b/development/plot-benchmarks/src/types/chart.ts
index e90f8d2..c5f8cb5 100644
--- a/development/plot-benchmarks/src/types/chart.ts
+++ b/development/plot-benchmarks/src/types/chart.ts
@@ -18,7 +18,7 @@
  * Used by a Mapper for data transformations.
  */
 export interface Series {
-  label: string;
+  descriptiveLabel: string;
   type: ChartType;
   data: Point[];
   // Additional series options
diff --git a/development/plot-benchmarks/src/workers/service.ts b/development/plot-benchmarks/src/workers/service.ts
index 33c52f2..3373353 100644
--- a/development/plot-benchmarks/src/workers/service.ts
+++ b/development/plot-benchmarks/src/workers/service.ts
@@ -29,7 +29,7 @@
               const [delta, distribution] = this.buildDistribution(reference, target);
               const [points, pPlots, p] = histogramPoints([distribution], /* buckets */ 100, /* target */ delta);
               series.push({
-                label: `${name} { ${metric.label} } - Likelihood`,
+                descriptiveLabel: `${name} { ${metric.label} } - Likelihood`,
                 type: "line",
                 data: points,
                 options: {
@@ -38,7 +38,7 @@
               });
               if (pPlots && pPlots.length > 0) {
                 series.push({
-                  label: `${name} { ${metric.label} } - { P = ${p} }`,
+                  descriptiveLabel: `${name} { ${metric.label} } - { P = ${p} }`,
                   type: "bar",
                   data: pPlots,
                   options: {
@@ -69,7 +69,7 @@
               const [delta, distribution] = this.buildStandardDistribution(reference, target);
               const [points, pPlots, p] = histogramPoints([distribution], /* buckets */ 100, /* target */ delta);
               series.push({
-                label: `${name} { ${metric.label} } - Likelihood`,
+                descriptiveLabel: `${name} { ${metric.label} } - Likelihood`,
                 type: "line",
                 data: points,
                 options: {
@@ -78,7 +78,7 @@
               });
               if (pPlots && pPlots.length > 0) {
                 series.push({
-                  label: `${name} { ${metric.label} } - { P = ${p} }`,
+                  descriptiveLabel: `${name} { ${metric.label} } - { P = ${p} }`,
                   type: "bar",
                   data: pPlots,
                   options: {