diff --git a/lib/clients/Histogram.ts b/lib/clients/Histogram.ts index fc1ce19..872204e 100644 --- a/lib/clients/Histogram.ts +++ b/lib/clients/Histogram.ts @@ -13,7 +13,7 @@ import type * as arrow from "apache-arrow"; import { CrossfilterHistogramPlot } from "../utils/CrossfilterHistogramPlot.ts"; -import type { Bin, Mark, Scale } from "../types.ts"; +import type { Mark } from "../types.ts"; import { assert } from "../utils/assert.ts"; /** An options bag for the Histogram Mosiac client. */ @@ -28,9 +28,10 @@ interface HistogramOptions { filterBy: Selection; } +type BinTable = arrow.Table<{ x1: arrow.Int; x2: arrow.Int; y: arrow.Int }>; + /** Represents a Cross-filtered Histogram */ export class Histogram extends MosaicClient implements Mark { - type = "rectY"; #source: { table: string; column: string; type: "number" | "date" }; #el: HTMLElement = document.createElement("div"); #select: { @@ -41,12 +42,8 @@ export class Histogram extends MosaicClient implements Mark { #interval: mplot.Interval1D | undefined = undefined; #initialized: boolean = false; #fieldInfo: FieldInfo | undefined; - svg: - | SVGSVGElement & { - scale: (type: string) => Scale; - update(bins: Bin[], opts: { nullCount: number }): void; - } - | undefined; + + svg: ReturnType | undefined; constructor(options: HistogramOptions) { super(options.filterBy); @@ -77,22 +74,12 @@ export class Histogram extends MosaicClient implements Mark { this.#fieldInfo = info[0]; return this; } - - /** - * Required by `mplot.bin` to get the field info. - */ - channelField(channel: string): FieldInfo { - assert(channel === "x"); - assert(this.#fieldInfo, "No field info yet"); - return this.#fieldInfo; - } - /** * Return a query specifying the data needed by this Mark client. * @param filter The filtering criteria to apply in the query. * @returns The client query */ - query(filter?: Array): Query { + query(filter: Array = []): Query { return Query .from({ source: this.#source.table }) .select(this.#select) @@ -102,11 +89,8 @@ export class Histogram extends MosaicClient implements Mark { /** * Provide query result data to the mark. - * @param {arrow.Table<{ x1: arrow.Int, x2: arrow.Int, y: arrow.Int }>} data */ - queryResult( - data: arrow.Table<{ x1: arrow.Int; x2: arrow.Int; y: arrow.Int }>, - ) { + queryResult(data: BinTable) { let bins = Array.from(data, (d) => ({ x0: d.x1, x1: d.x2, @@ -132,10 +116,17 @@ export class Histogram extends MosaicClient implements Mark { return this; } + /* Required by the Mark interface */ + type = "rectY"; + /** Required by `mplot.bin` to get the field info. */ + channelField(channel: string): FieldInfo { + assert(channel === "x"); + assert(this.#fieldInfo, "No field info yet"); + return this.#fieldInfo; + } get plot() { return { node: () => this.#el, - /** @param {string} _name */ getAttribute(_name: string) { return undefined; }, diff --git a/lib/clients/ValueCounts.ts b/lib/clients/ValueCounts.ts index fb79876..fcb1e7e 100644 --- a/lib/clients/ValueCounts.ts +++ b/lib/clients/ValueCounts.ts @@ -21,9 +21,11 @@ interface UniqueValuesOptions { /** The column to use for the histogram. */ column: string; /** A mosaic selection to filter the data. */ - filterBy?: Selection; + filterBy: Selection; } +type CountTable = arrow.Table<{ key: arrow.Utf8; total: arrow.Int }>; + export class ValueCounts extends MosaicClient { #table: string; #column: string; @@ -34,19 +36,27 @@ export class ValueCounts extends MosaicClient { super(options.filterBy); this.#table = options.table; this.#column = options.column; - } - - clause(value?: unknown) { - return clausePoint(this.#column, value, { source: this }); - } - reset() { - assert(this.#plot, "ValueCounts plot not initialized"); - this.#plot.selected.value = undefined; + // FIXME: There is some issue with the mosaic client or the query we + // are using here. Updates to the Selection (`filterBy`) seem to be + // missed by the coordinator, and query/queryResult are not called + // by the coordinator when the filterBy is updated. + // + // Here we manually listen for the changes to filterBy and update this + // client internally. It _should_ go through the coordinator. + options.filterBy.addEventListener("value", async () => { + let filters = options.filterBy.predicate(); + let query = this.query(filters); + if (this.#plot) { + let data = await this.coordinator.query(query); + this.#plot.data.value = data; + } + }); } - query(filter: Array): Query { - let valueCounts = Query + query(filter: Array = []): Query { + let counts = Query + .from({ source: this.#table }) .select({ value: sql`CASE WHEN ${column(this.#column)} IS NULL THEN '__quak_null__' @@ -54,11 +64,10 @@ export class ValueCounts extends MosaicClient { END`, count: count(), }) - .from(this.#table) - .where(filter) - .groupby("value"); + .groupby("value") + .where(filter); return Query - .with({ value_counts: valueCounts }) + .with({ counts }) .select( { key: sql`CASE @@ -68,24 +77,36 @@ export class ValueCounts extends MosaicClient { total: sum("count"), }, ) - .from("value_counts") + .from("counts") .groupby("key"); } - queryResult( - data: arrow.Table<{ key: arrow.Utf8; total: arrow.Int }>, // type comes from the query above - ): this { + queryResult(data: CountTable): this { if (!this.#plot) { - this.#plot = ValueCountsPlot(data); - this.#el.appendChild(this.#plot); + let plot = this.#plot = ValueCountsPlot(data); + this.#el.appendChild(plot); effect(() => { - let clause = this.clause(this.#plot!.selected.value); - this.filterBy?.update(clause); + let clause = this.clause(plot.selected.value); + this.filterBy!.update(clause); }); + } else { + this.#plot.data.value = data; } return this; } + clause(value?: T) { + let update = value === "__quak_null__" ? null : value; + return clausePoint(this.#column, update, { + source: this, + }); + } + + reset() { + assert(this.#plot, "ValueCounts plot not initialized"); + this.#plot.selected.value = undefined; + } + get plot() { return { node: () => this.#el, diff --git a/lib/deps/mosaic-core.d.ts b/lib/deps/mosaic-core.d.ts index ded73aa..c49faee 100644 --- a/lib/deps/mosaic-core.d.ts +++ b/lib/deps/mosaic-core.d.ts @@ -16,10 +16,11 @@ export interface Clause { } export class Selection { - predicate(client: MosaicClient): Array; + predicate(client?: MosaicClient): Array; static crossfilter(): Selection; clauses: Array>; update(clause: Clause): void; + activate(clause: Clause): void; addEventListener(event: "activate", listener: () => void): void; addEventListener( event: "value", @@ -144,5 +145,5 @@ type Logger = typeof console & { export declare function clausePoint( field: string, value: unknown, - options: { source: T }, + options: { source: T; clients?: Set }, ): Clause; diff --git a/lib/deps/mosaic-sql.d.ts b/lib/deps/mosaic-sql.d.ts index 9de0b61..d102007 100644 --- a/lib/deps/mosaic-sql.d.ts +++ b/lib/deps/mosaic-sql.d.ts @@ -91,6 +91,7 @@ export class Query { groupby(...exprs: unknown[]): Query; with(...exprs: unknown[]): Query; clone(): Query; + toString(): string; } export declare function desc(column: string): SQLExpression; diff --git a/lib/utils/ValueCountsPlot.ts b/lib/utils/ValueCountsPlot.ts index 6154ae6..ee7e3e1 100644 --- a/lib/utils/ValueCountsPlot.ts +++ b/lib/utils/ValueCountsPlot.ts @@ -63,6 +63,7 @@ export function ValueCountsPlot( let hovering = signal(undefined); let selected = signal(undefined); + let counts = signal(data); let hitArea = document.createElement("div"); Object.assign(hitArea.style, { @@ -87,13 +88,14 @@ export function ValueCountsPlot( effect(() => { text.textContent = bars.textFor(hovering.value ?? selected.value); - bars.highlight(hovering.value, selected.value); + bars.render(counts.value, hovering.value, selected.value); }); root.appendChild(container); root.appendChild(text); root.appendChild(hitArea); - return Object.assign(root, { selected }); + + return Object.assign(root, { selected, data: counts }); } function createBar(opts: { @@ -107,7 +109,11 @@ function createBar(opts: { let bar = document.createElement("div"); bar.title = title; Object.assign(bar.style, { - background: fillColor, + background: createSplitBarFill({ + color: fillColor, + bgColor: "var(--moon-gray)", + frac: 50, + }), width: `${width}px`, height: `${height}px`, borderColor: "white", @@ -163,16 +169,16 @@ function createBars(data: CountTableData, opts: { backgroundBarColor: string; nullFillColor: string; }) { - let { bins, uniqueCount, nullCount, total } = prepareData(data); + let source = prepareData(data); let x = d3.scaleLinear() - .domain([0, total]) + .domain([0, source.total]) .range([opts.marginLeft, opts.width - opts.marginRight]); // number of bars to show before virtualizing let thresh = 20; let bars: Array = []; - for (let d of bins.slice(0, thresh)) { + for (let d of source.bins.slice(0, thresh)) { let bar = createBar({ title: d.key, fillColor: opts.fillColor, @@ -188,8 +194,11 @@ function createBars(data: CountTableData, opts: { let hoverBar = createVirtualSelectionBar(opts); let selectBar = createVirtualSelectionBar(opts); let virtualBar: HTMLElement | undefined; - if (bins.length > thresh) { - let total = bins.slice(thresh).reduce((acc, d) => acc + d.total, 0); + if (source.bins.length > thresh) { + let total = source.bins.slice(thresh).reduce( + (acc, d) => acc + d.total, + 0, + ); virtualBar = Object.assign(document.createElement("div"), { title: "__quak_virtual__", }); @@ -212,42 +221,42 @@ function createBars(data: CountTableData, opts: { virtualBar.appendChild(hoverBar); virtualBar.appendChild(selectBar); Object.defineProperty(virtualBar, "data", { - value: bins.slice(thresh), + value: source.bins.slice(thresh), }); bars.push(virtualBar); } - if (uniqueCount) { + if (source.uniqueCount) { let bar = createBar({ title: "unique", fillColor: opts.backgroundBarColor, textColor: "var(--mid-gray)", - width: x(uniqueCount), + width: x(source.uniqueCount), height: opts.height, }); bar.title = "__quak_unique__"; Object.defineProperty(bar, "data", { value: { key: "__quak_unique__", - total: uniqueCount, + total: source.uniqueCount, }, }); bars.push(bar); } - if (nullCount) { + if (source.nullCount) { let bar = createBar({ title: "null", fillColor: opts.nullFillColor, textColor: "white", - width: x(nullCount), + width: x(source.nullCount), height: opts.height, }); bar.title = "__quak_null__"; Object.defineProperty(bar, "data", { value: { key: "__quak_null__", - total: nullCount, + total: source.nullCount, }, }); bars.push(bar); @@ -288,8 +297,20 @@ function createBars(data: CountTableData, opts: { // @ts-expect-error - we set this above let vbars: HTMLDivElement = bar.firstChild!; vbars.style.opacity = opactiy.toString(); + vbars.style.background = createVirtualBarRepeatingBackground({ + color: opts.fillColor, + }); } else { bar.style.opacity = opactiy.toString(); + bar.style.background = createSplitBarFill({ + color: bar.title === "__quak_unique__" + ? opts.backgroundBarColor + : bar.title === "__quak_null__" + ? opts.nullFillColor + : opts.fillColor, + bgColor: opts.backgroundBarColor, + frac: 1, + }); } bar.style.borderColor = "white"; bar.style.borderWidth = "0px 1px 0px 0px"; @@ -300,15 +321,15 @@ function createBars(data: CountTableData, opts: { selectBar.style.visibility = "hidden"; } - function hover(key: string) { + function hover(key: string, selected?: string) { let bar = bars.find((b) => b.title === key); if (bar) { bar.style.opacity = "1"; return; } let vbin = virtualBin(key); - hoverBar.style.opacity = "1"; hoverBar.title = vbin.key; + hoverBar.style.opacity = selected ? "0.25" : "1"; hoverBar.style.left = `${vbin.x}px`; hoverBar.style.visibility = "visible"; } @@ -327,6 +348,10 @@ function createBars(data: CountTableData, opts: { selectBar.style.visibility = "visible"; } + let counts: Record = Object.fromEntries( + Array.from(data.toArray(), (d) => [d.key, d.total]), + ); + return { elements: bars, nearestX(event: MouseEvent): string | undefined { @@ -343,9 +368,35 @@ function createBars(data: CountTableData, opts: { let idx = Math.floor((mouseX / rect.width) * data.length); return data[idx].key; }, - highlight(hovering?: string, selected?: string) { + render(data: CountTableData, hovering?: string, selected?: string) { reset(hovering || selected ? 0.4 : 1); - if (hovering) hover(hovering); + let update: Record = Object.fromEntries( + Array.from(data.toArray(), (d) => [d.key, d.total]), + ); + let total = Object.values(update).reduce((a, b) => a + b, 0); + for (let bar of bars) { + if (bar.title === "__quak_virtual__") { + let vbars = bar.firstChild as HTMLDivElement; + vbars.style.background = createVirtualBarRepeatingBackground({ + color: (total < source.total) || selected + ? opts.backgroundBarColor + : opts.fillColor, + }); + } else { + let frac = (update[bar.title] ?? 0) / counts[bar.title]; + if (selected) frac = bar.title === selected ? frac : 0; + bar.style.background = createSplitBarFill({ + color: bar.title === "__quak_unique__" + ? opts.backgroundBarColor + : bar.title === "__quak_null__" + ? opts.nullFillColor + : opts.fillColor, + bgColor: opts.backgroundBarColor, + frac: isNaN(frac) ? 0 : frac, + }); + } + } + if (hovering) hover(hovering, selected); if (selected) select(selected); }, textFor(key?: string): string { @@ -354,8 +405,8 @@ function createBars(data: CountTableData, opts: { return `${ncats.toLocaleString()} categor${ncats === 1 ? "y" : "ies"}`; } if (key === "__quak_unique__") { - return `${uniqueCount.toLocaleString()} unique value${ - uniqueCount === 1 ? "" : "s" + return `${source.uniqueCount.toLocaleString()} unique value${ + source.uniqueCount === 1 ? "" : "s" }`; } if (key === "__quak_null__") { @@ -405,3 +456,19 @@ function nearestX({ clientX }: MouseEvent, bars: Array) { } } } + +/** + * Creates a fill gradient that is filled x% with a color and the rest with a background color. + */ +function createSplitBarFill( + options: { color: string; bgColor: string; frac: number }, +) { + let { color, bgColor, frac } = options; + let p = frac * 100; + // deno-fmt-ignore + return `linear-gradient(to top, ${color} ${p}%, ${bgColor} ${p}%, ${bgColor} ${100 - p}%)`; +} + +function createVirtualBarRepeatingBackground({ color }: { color: string }) { + return `repeating-linear-gradient(to right, ${color} 0px, ${color} 1px, white 1px, white 2px)`; +}