Skip to content

Commit fa61ea3

Browse files
authored
feat: Show value counts and highlight bar on histogram hover (#44)
* add percentFormatter * Prop drill arrow field * Support highlighting and value count on hover * update histogram bin highlight/row-count hover to only work on axes hover * align lint with base repo * fix tabs * Update lib/utils/formatting.ts
1 parent 8ce3d4f commit fa61ea3

File tree

5 files changed

+103
-6
lines changed

5 files changed

+103
-6
lines changed

lib/clients/DataTable.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ export class DataTable extends MosaicClient {
287287
vis = new Histogram({
288288
table: this.#meta.table,
289289
column: field.name,
290+
field: field,
290291
type: info.type,
291292
filterBy: this.filterBy!,
292293
});

lib/clients/Histogram.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { assert } from "../utils/assert.ts";
2020
interface HistogramOptions {
2121
/** The table to query. */
2222
table: string;
23+
/** An arrow Field containing the column info to use for the histogram. */
24+
field: arrow.Field;
2325
/** The column to use for the histogram. */
2426
column: string;
2527
/** The type of the column. Must be "number" or "date". */
@@ -32,7 +34,12 @@ type BinTable = arrow.Table<{ x1: arrow.Int; x2: arrow.Int; y: arrow.Int }>;
3234

3335
/** Represents a Cross-filtered Histogram */
3436
export class Histogram extends MosaicClient implements Mark {
35-
#source: { table: string; column: string; type: "number" | "date" };
37+
#source: {
38+
table: string;
39+
column: string;
40+
field: arrow.Field;
41+
type: "number" | "date";
42+
};
3643
#el: HTMLElement = document.createElement("div");
3744
#select: {
3845
x1: ColumnField;
@@ -47,7 +54,12 @@ export class Histogram extends MosaicClient implements Mark {
4754

4855
constructor(options: HistogramOptions) {
4956
super(options.filterBy);
50-
this.#source = options;
57+
this.#source = {
58+
table: options.table,
59+
column: options.field.name,
60+
field: options.field,
61+
type: options.type,
62+
};
5163
// calls this.channelField internally
5264
let bin = mplot.bin(options.column)(this, "x");
5365
this.#select = { x1: bin.x1, x2: bin.x2, y: count() };
@@ -102,7 +114,7 @@ export class Histogram extends MosaicClient implements Mark {
102114
bins.splice(nullBinIndex, 1);
103115
}
104116
if (!this.#initialized) {
105-
this.svg = CrossfilterHistogramPlot(bins, {
117+
this.svg = CrossfilterHistogramPlot(bins, this.#source.field, {
106118
nullCount,
107119
type: this.#source.type,
108120
});

lib/utils/CrossfilterHistogramPlot.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import * as d3 from "d3";
44
import { assert } from "../utils/assert.ts";
55
import { tickFormatterForBins } from "./tick-formatter-for-bins.ts";
66
import type { Bin, Scale } from "../types.ts";
7+
import type * as arrow from "apache-arrow";
8+
import { formatDataType, percentFormatter } from "./formatting.ts";
79

810
interface HistogramOptions {
911
type: "number" | "date";
@@ -27,6 +29,7 @@ interface HistogramOptions {
2729
*/
2830
export function CrossfilterHistogramPlot(
2931
bins: Array<Bin>,
32+
field: arrow.Field,
3033
{
3134
type = "number",
3235
width = 125,
@@ -44,7 +47,10 @@ export function CrossfilterHistogramPlot(
4447
scale: (type: string) => Scale<number, number>;
4548
update(bins: Array<Bin>, opts: { nullCount: number }): void;
4649
} {
50+
const fieldType = formatDataType(field.type);
51+
const total = bins.reduce((sum, bin) => sum + bin.length, 0);
4752
let hovered = signal<number | Date | undefined>(undefined);
53+
let countLabel = signal<string>(fieldType);
4854
let nullBinWidth = nullCount === 0 ? 0 : 5;
4955
let spacing = nullBinWidth ? 4 : 0;
5056
let extent = /** @type {const} */ ([
@@ -148,6 +154,13 @@ export function CrossfilterHistogramPlot(
148154
)
149155
.attr("width", bbox.width + 5)
150156
.attr("height", bbox.height + 5);
157+
158+
const labelElement = svg
159+
.node()
160+
?.parentElement?.parentElement?.querySelector(".gray");
161+
if (labelElement) {
162+
labelElement.textContent = countLabel.value;
163+
}
151164
});
152165

153166
/** @type {typeof foregroundBarGroup | undefined} */
@@ -215,7 +228,8 @@ export function CrossfilterHistogramPlot(
215228
.attr("x", (d) => x(d.x0) + 1.5)
216229
.attr("width", (d) => x(d.x1) - x(d.x0) - 1.5)
217230
.attr("y", (d) => y(d.length))
218-
.attr("height", (d) => y(0) - y(d.length));
231+
.attr("height", (d) => y(0) - y(d.length))
232+
.attr("opacity", 1);
219233
foregroundNullGroup
220234
?.select("rect")
221235
.attr("y", y(nullCount))
@@ -237,10 +251,64 @@ export function CrossfilterHistogramPlot(
237251
let node = svg.node();
238252
assert(node, "Infallable");
239253

254+
// Function to find the closest rect to a given x-coordinate
255+
function findClosestRect(x: number): SVGRectElement | null {
256+
let closestRect: SVGRectElement | null = null;
257+
let minDistance = Infinity;
258+
259+
foregroundBarGroup.selectAll("rect").each(function () {
260+
const rect = d3.select(this);
261+
const rectX = parseFloat(rect.attr("x"));
262+
const rectWidth = parseFloat(rect.attr("width"));
263+
const rectCenter = rectX + rectWidth / 2;
264+
const distance = Math.abs(x - rectCenter);
265+
266+
if (distance < minDistance) {
267+
minDistance = distance;
268+
closestRect = this as SVGRectElement;
269+
}
270+
});
271+
272+
return closestRect;
273+
}
274+
275+
axes.on("mousemove", (event) => {
276+
const relativeX = event.clientX - node.getBoundingClientRect().left;
277+
const hoveredX = x.invert(relativeX);
278+
hovered.value = clamp(hoveredX, xmin, xmax);
279+
280+
const closestRect = findClosestRect(relativeX);
281+
282+
foregroundBarGroup.selectAll("rect").attr("opacity", function () {
283+
return this === closestRect ? 1 : 0.3;
284+
});
285+
286+
const hoveredValue = hovered.value;
287+
288+
const hoveredBin = hoveredValue !== undefined
289+
? bins.find((bin) => hoveredValue >= bin.x0 && hoveredValue < bin.x1)
290+
: undefined;
291+
const hoveredValueCount = hoveredBin?.length;
292+
293+
countLabel.value =
294+
hoveredValue !== undefined && hoveredValueCount !== undefined
295+
? `${hoveredValueCount} row${hoveredValueCount === 1 ? "" : "s"} (${
296+
percentFormatter(hoveredValueCount / total)
297+
})`
298+
: fieldType;
299+
});
300+
240301
node.addEventListener("mousemove", (event) => {
241302
const relativeX = event.clientX - node.getBoundingClientRect().left;
242303
hovered.value = clamp(x.invert(relativeX), xmin, xmax);
243304
});
305+
306+
axes.on("mouseleave", () => {
307+
hovered.value = undefined;
308+
foregroundBarGroup.selectAll("rect").attr("opacity", 1);
309+
countLabel.value = fieldType;
310+
});
311+
244312
node.addEventListener("mouseleave", () => {
245313
hovered.value = undefined;
246314
});

lib/utils/ValueCountsPlot.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type * as arrow from "apache-arrow";
33
// @ts-types="npm:@types/d3"
44
import * as d3 from "d3";
55
import { assert } from "./assert.ts";
6-
import { formatDataType } from "./formatting.ts";
6+
import { formatDataType, percentFormatter } from "./formatting.ts";
77

88
type CountTableData = arrow.Table<{
99
key: arrow.Utf8;
@@ -98,7 +98,7 @@ export function ValueCountsPlot(
9898
countLabel.value =
9999
hoveredValue !== undefined && hoveredValueCount !== undefined
100100
? `${hoveredValueCount} row${hoveredValueCount === 1 ? "" : "s"} (${
101-
d3.format(".1%")(hoveredValueCount / total)
101+
percentFormatter(hoveredValueCount / total)
102102
})`
103103
: fieldType;
104104
});

lib/utils/formatting.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Temporal } from "@js-temporal/polyfill";
22
import * as arrow from "apache-arrow";
3+
import { format } from "d3";
34

45
/**
56
* A utility function to create a formatter for a given data type.
@@ -223,3 +224,18 @@ function durationFromTimeUnit(value: number | bigint, unit: arrow.TimeUnit) {
223224
}
224225
throw new Error("Invalid TimeUnit");
225226
}
227+
228+
/**
229+
* Formats a number as a percentage string with varying precision based on the value's magnitude.
230+
* @param {number} value - The value to be formatted as a percentage.
231+
* @returns {string} A formatted percentage string.
232+
*/
233+
export function percentFormatter(value: number): string {
234+
if (value >= 0.1) {
235+
return format(".0%")(value);
236+
} else if (value >= 0.01) {
237+
return format(".1%")(value);
238+
} else {
239+
return format(".2%")(value);
240+
}
241+
}

0 commit comments

Comments
 (0)