diff --git a/.gitignore b/.gitignore index 5b801d214..4359f546f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ dist/tiles test-results/ playwright-report/ dist/tiles/* + +tiles +tmp.csv +*.feather diff --git a/package-lock.json b/package-lock.json index 1d0020146..a8fb90be9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "deepscatter", - "version": "2.3.3", + "version": "2.4.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "deepscatter", - "version": "2.3.3", + "version": "2.4.1", "license": "MIT", "dependencies": { "apache-arrow": "^9.0.0", @@ -14,6 +14,7 @@ "d3-array": "^3.2.0", "d3-color": "^3.1.0", "d3-contour": "^4.0.0", + "d3-drag": "^3.0.0", "d3-ease": "^3.0.1", "d3-fetch": "^3.0.1", "d3-format": "^3.1.0", @@ -36,6 +37,7 @@ "devDependencies": { "@playwright/test": "^1.25.0", "@types/d3": "^7.4.0", + "@types/d3-drag": "^3.0.1", "@types/d3-geo": "^3.0.2", "@types/d3-selection": "^3.0.3", "@types/lodash.merge": "^4.6.7", @@ -352,8 +354,9 @@ }, "node_modules/@types/d3-drag": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.1.tgz", + "integrity": "sha512-o1Va7bLwwk6h03+nSM8dpaGEYnoIG19P0lKqlic8Un36ymh9NSkNFX1yiXMKNMx8rJ0Kfnn2eovuFaL6Jvj0zA==", "dev": true, - "license": "MIT", "dependencies": { "@types/d3-selection": "*" } @@ -1160,7 +1163,8 @@ }, "node_modules/d3-drag": { "version": "3.0.0", - "license": "ISC", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" @@ -4874,6 +4878,8 @@ }, "@types/d3-drag": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.1.tgz", + "integrity": "sha512-o1Va7bLwwk6h03+nSM8dpaGEYnoIG19P0lKqlic8Un36ymh9NSkNFX1yiXMKNMx8rJ0Kfnn2eovuFaL6Jvj0zA==", "dev": true, "requires": { "@types/d3-selection": "*" @@ -5419,6 +5425,8 @@ }, "d3-drag": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", "requires": { "d3-dispatch": "1 - 3", "d3-selection": "3" diff --git a/package.json b/package.json index ee34e2a76..73de57550 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "d3-array": "^3.2.0", "d3-color": "^3.1.0", "d3-contour": "^4.0.0", + "d3-drag": "^3.0.0", "d3-ease": "^3.0.1", "d3-fetch": "^3.0.1", "d3-format": "^3.1.0", @@ -64,6 +65,7 @@ "devDependencies": { "@playwright/test": "^1.25.0", "@types/d3": "^7.4.0", + "@types/d3-drag": "^3.0.1", "@types/d3-geo": "^3.0.2", "@types/d3-selection": "^3.0.3", "@types/lodash.merge": "^4.6.7", diff --git a/src/Dataset.ts b/src/Dataset.ts index 5bca681c0..403615255 100644 --- a/src/Dataset.ts +++ b/src/Dataset.ts @@ -11,7 +11,8 @@ import TileWorker from './tileworker.worker.js?worker&inline'; import { APICall } from './types'; import Scatterplot from './deepscatter'; -import { StructRowProxy, Table } from 'apache-arrow'; +import { Float32, makeVector, StructRowProxy, Table, Vector } from 'apache-arrow'; +import { assert } from './util'; type Key = string; export abstract class Dataset { @@ -85,6 +86,7 @@ export abstract class Dataset { } } } + /** * * @param ix The index of the point to get. @@ -105,7 +107,6 @@ export abstract class Dataset { return matches; } - get tileWorker() { const NUM_WORKERS = 4; if (this._tileworkers.length > 0) { @@ -248,4 +249,4 @@ function check_overlap(tile : Tile, bbox : Rectangle) : number { return disqualify; } return area(intersection) / area(bbox); -} \ No newline at end of file +} diff --git a/src/deepscatter.ts b/src/deepscatter.ts index 9edc52b4c..22c3db0da 100644 --- a/src/deepscatter.ts +++ b/src/deepscatter.ts @@ -41,6 +41,7 @@ export default class Scatterplot { public prefs : APICall; ready : Promise; public click_handler : ClickFunction; + public drag_handler : DragFunction; public tooltip_handler : TooltipHTML; constructor(selector : string, width : number, height: number) { @@ -53,6 +54,7 @@ export default class Scatterplot { // Unresolvable. this.ready = Promise.resolve(); this.click_handler = new ClickFunction(this); + this.drag_handler = new DragFunction(this); this.tooltip_handler = new TooltipHTML(this); this.prefs = { zoom_balance: 0.35, @@ -260,19 +262,34 @@ export default class Scatterplot { /* PUBLIC see set tooltip_html */ return this.tooltip_handler.f; } + set click_function(func) { this.click_handler.f = func; } + get click_function() { /* PUBLIC see set click_function */ return this.click_handler.f; } + set drag_function(func) { + this.drag_handler.f = func; + } + + get drag_function() { + return this.drag_handler.f; + } + async plotAPI(prefs : APICall) { if (prefs.click_function) { this.click_function = Function('datum', prefs.click_function); } + + if (prefs.drag_function) { + this.drag_function = Function('datum', prefs.drag_function); + } + if (prefs.tooltip_html) { this.tooltip_html = Function('datum', prefs.tooltip_html); } @@ -448,6 +465,13 @@ class ClickFunction extends SettableFunction { } } +class DragFunction extends SettableFunction { + //@ts-ignore bc https://github.com/microsoft/TypeScript/issues/48125 + default(datum : StructRowProxy) { + console.log({ ...datum }); + } +} + class TooltipHTML extends SettableFunction { //@ts-ignore bc https://github.com/microsoft/TypeScript/issues/48125 default(point : StructRowProxy) { @@ -469,4 +493,4 @@ class TooltipHTML extends SettableFunction { } return `${output}\n`; } -} \ No newline at end of file +} diff --git a/src/interaction.ts b/src/interaction.ts index 7d73b3784..533af6b45 100644 --- a/src/interaction.ts +++ b/src/interaction.ts @@ -3,6 +3,7 @@ import { select } from 'd3-selection'; import { timer } from 'd3-timer'; import { zoom, zoomIdentity } from 'd3-zoom'; import { mean } from 'd3-array'; +import { D3DragEvent, drag } from 'd3-drag'; import { ScaleLinear, scaleLinear } from 'd3-scale'; import { APICall, Encoding } from './types'; // import { annotation, annotationLabel } from 'd3-svg-annotation'; @@ -10,7 +11,9 @@ import type { Renderer } from './rendering'; import type QuadtreeRoot from './tile'; import { ReglRenderer } from './regl_rendering'; import Scatterplot from './deepscatter'; -import { StructRow } from 'apache-arrow'; +import { Float32, makeData, StructRow, StructRowProxy } from 'apache-arrow'; +import type { Dataset } from './Dataset'; +import type { QuadTile, Tile } from './tile'; export default class Zoom { @@ -19,7 +22,7 @@ export default class Zoom { public width : number; public height : number; public renderers : Map; - public tileSet? : QuadtreeRoot; + public tileSet? : Dataset; public _timer : d3.Timer; public _scales : Record>; public zoomer : d3.ZoomBehavior; @@ -43,7 +46,7 @@ export default class Zoom { this.renderers = new Map(); } - attach_tiles(tiles : QuadtreeRoot) { + attach_tiles(tiles : Dataset) { this.tileSet = tiles; this.tileSet._zoom = this; return this; @@ -146,6 +149,9 @@ export default class Zoom { add_mouseover() { let last_fired = 0; + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + //@ts-ignore Not sure how to guarantee this formally. const renderer : ReglRenderer = this.renderers.get('regl'); const x_aes = renderer.aes.dim('x').current; @@ -162,6 +168,7 @@ export default class Zoom { const d = data[0]; + type CircleDragEvent = D3DragEvent; type Annotation = { x: number, y: number, @@ -180,6 +187,7 @@ export default class Zoom { ] : []; const { x_, y_ } = this.scales(); + const { scatterplot } = this; this.html_annotation(annotations); @@ -187,20 +195,62 @@ export default class Zoom { .selectAll('circle.label') .data(data, (d_) => d_.ix) .join( + + // Enter (enter) => enter .append('circle') + .call(drag() + // Drag Start + .on('start', + function on_drag_start(this: SVGCircleElement, event: CircleDragEvent, datum) { + select(this) + .attr('cursor', 'grabbing') + .raise(); + }) + + // Dragging + .on('drag', function on_dragged(this: SVGCircleElement, event: CircleDragEvent, datum: StructRowProxy) { + let { dx, dy } = event; + if (dx === 0 && dy === 0) return; + + // Map (dx, dy) from screen to data space. + dx = x_.invert(dx) - x_.invert(0); + dy = y_.invert(dy) - y_.invert(0); + + datum.x += dx; + datum.y += dy; + + select(this) + .attr('cx', x_(datum.x)) + .attr('cy', y_(datum.y)); + + const tile = self.scatterplot._root.root_tile as QuadTile; + tile.visit_at_point(datum.ix, tile => tile.needs_rerendering('x', 'y')); + }) + + // Drag end + .on('end', function on_drag_end(this: SVGCircleElement, event: CircleDragEvent, datum: StructRowProxy) { + select(this).attr('cursor', 'grab'); + renderer.render_all(renderer.props); + scatterplot.drag_function(datum); + })) + .attr('cursor', 'grab') .attr('class', 'label') .attr('stroke', '#110022') .attr('r', 12) - .attr('fill', (dd) => this.renderers.get('regl').aes.dim('color').current.apply(dd)) + .attr('fill', (dd) => renderer.aes.dim('color').current.apply(dd)) .attr('cx', (datum) => x_(x_aes.value_for(datum))) .attr('cy', (datum) => y_(y_aes.value_for(datum))), + + // Update (update) => update - .attr('fill', (dd) => this.renderers.get('regl').aes.dim('color').current.apply(dd)), + .attr('fill', (dd) => renderer.aes.dim('color').current.apply(dd)), + + // Exit (exit) => exit.call((e) => e.remove()) ) .on('click', (ev, dd) => { - this.scatterplot.click_function(dd); + scatterplot.click_function(dd); }); }); } @@ -254,7 +304,7 @@ export default class Zoom { return this._timer; } - data(dataset) { + data(dataset: Dataset) { if (dataset === undefined) { return this.tileSet; } @@ -399,4 +449,4 @@ export function window_transform(x_scale : ScaleLinear, y_scale) { ] */ return m1; -} \ No newline at end of file +} diff --git a/src/rendering.ts b/src/rendering.ts index 886ba587f..22e2031f8 100644 --- a/src/rendering.ts +++ b/src/rendering.ts @@ -2,11 +2,12 @@ import { select } from 'd3-selection'; import { min } from 'd3-array'; import type Scatterplot from './deepscatter'; -import type { Tileset } from './tile'; +import type { Tile, Tileset } from './tile'; import type { APICall } from './types'; import type Zoom from './interaction'; import type { AestheticSet } from './AestheticSet'; import { timer, Timer } from 'd3-timer'; +import type { Dataset } from './Dataset'; abstract class PlotSetting { abstract start: number; @@ -119,7 +120,7 @@ export class Renderer { public _zoom : Zoom; public _initializations : Promise[]; public render_props : RenderProps; - constructor(selector, tileSet, scatterplot) { + constructor(selector: string, tileSet: Dataset, scatterplot: Scatterplot) { this.scatterplot = scatterplot; this.holder = select(selector); this.canvas = select(this.holder.node().firstElementChild); @@ -190,13 +191,13 @@ export class Renderer { return max_points * k * k / point_size_adjust / point_size_adjust; } - visible_tiles() { + visible_tiles(): Tile[] { // yield the currently visible tiles based on the zoom state // and a maximum index passed manually. const { max_ix } = this; const { tileSet } = this; // Materialize using a tileset method. - let all_tiles; + let all_tiles: Tile[]; const natural_display = this.aes.dim('x').current.field == 'x' && this.aes.dim('y').current.field == 'y' && this.aes.dim('x').last.field == 'x' && @@ -222,4 +223,4 @@ export class Renderer { await this._initializations; this.zoom.restart_timer(500_000); } -} \ No newline at end of file +} diff --git a/src/tile.ts b/src/tile.ts index 2f12f0fcb..9be47f356 100644 --- a/src/tile.ts +++ b/src/tile.ts @@ -2,11 +2,13 @@ import { extent } from 'd3-array'; -import { tableFromIPC, Table, RecordBatch } from 'apache-arrow'; +import { tableFromIPC, Table, RecordBatch, StructRowProxy } from 'apache-arrow'; import TileWorker from './tileworker.worker.js?worker&inline'; import type { Dataset, QuadtileSet } from './Dataset'; import Scatterplot from './deepscatter'; +import { assert } from './util'; +import type { Buffer } from 'regl'; type MinMax = [number, number]; export type Rectangle = { @@ -40,6 +42,12 @@ export abstract class Tile { local_dictionary_lookups? : Map; public _extent? : { 'x' : MinMax, 'y': MinMax }; + _regl_elements: Map | undefined; + constructor(dataset : QuadtileSet) { // Accepts prefs only for the case of the root tile. this.promise = Promise.resolve(); @@ -282,6 +290,27 @@ export abstract class Tile { } return this.parent.root_extent; } + + /** + * Flush cached render buffers. Buffers will be re-built on next render, + * allowing changes to this tile to be displayed on-screen. + * + * @param keys The name of the buffer to flush. If not specified, all buffers + * will be flushed. + * + * @returns + */ + public needs_rerendering(...keys: string[]) { + if (!this._regl_elements) return; + + if (keys.length === 0) { + this._regl_elements = undefined; + } else { + for (const key of keys) { + this._regl_elements.delete(key); + } + } + } } export class QuadTile extends Tile { @@ -291,6 +320,8 @@ export class QuadTile extends Tile { codes : [number, number, number]; _already_called = false; public child_locations : string[] = []; + _table: Table | undefined; + constructor(base_url : string, key : string, parent : null | this, dataset : QuadtileSet) { super(dataset); this.url = base_url; @@ -330,7 +361,8 @@ export class QuadTile extends Tile { // metadata is passed separately b/c I dont know // how to fix it on the table in javascript, just python. this._table_buffer = buffer; - this._batch = tableFromIPC(buffer).batches[0]; + this._table = tableFromIPC(buffer); + this._batch = this._table.batches[0]; this._extent = JSON.parse(metadata.get('extent')); this.child_locations = JSON.parse(metadata.get('children')); const ixes = this.record_batch.getChild('ix'); @@ -366,8 +398,6 @@ export class QuadTile extends Tile { this._children.push(new this.class(this.url, key, this, this.dataset)); } } - // } - // } return this._children; } @@ -388,6 +418,74 @@ export class QuadTile extends Tile { }; } + /** + * Find the tile that contains a desired point. + * + * @param ix The index of the point to find + * @param callback A function to call when the tile is found. + * + * @returns `true` if the tile was found and {@link callback} was invoked, + * `false` otherwise. + */ + public visit_at_point( + ix: number, + callback: (tile: this, point: StructRowProxy) => void + ): boolean { + + // Tile hasn't been downloaded yet. + if (this.download_state !== 'Complete' || !this._table) return false; + + const toVisit: this[] = [this]; + // Breadth first search. + while(toVisit.length > 0) { + const current = toVisit.pop(); + assert(current); + + // Check if the current tile contains the point + if (ix >= current.min_ix && ix <= current.max_ix) { + const ixes = current._table?.getChild('ix'); + assert(ixes); + const rowIndex = ixes.indexOf(ix); + if (rowIndex !== -1) { + const point = current._table?.get(rowIndex); + assert(point); + callback(current, point); + return true; + } + } + + // If we didn't find the point in this tile, we need to visit its children. + if(current._children && current._children.length > 0) { + console.log('toVisit:', toVisit.length); + console.log('children:', current._children.length); + // toVisit.push(...this._children); + toVisit.push(...current._children); + } + } + + return false; + } + + public find_point(ix: number): StructRowProxy | null { + let point: StructRowProxy | null = null; + this.visit_at_point(ix, (_, p) => { + point = p; + }); + + return point; + } + + public update_point(ix: number, point: StructRowProxy): boolean { + let did_update = false; + + this.visit_at_point(ix, tile => { + const rowIndex: number = point[Symbol.for('rowIndex')] + tile._table?.set(rowIndex, point); + did_update = true; + }); + + return did_update; + } } @@ -436,4 +534,4 @@ export class ArrowTile extends Tile { // Arrow tables are always ready. return true; } -} \ No newline at end of file +} diff --git a/src/util.ts b/src/util.ts index a65ad0783..c2ecc3a78 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,28 +1,34 @@ -import { extent } from 'd3-array'; +// import { extent } from 'd3-array'; +export function assert(cond: unknown, message?: string): asserts cond { + if (!cond) { + throw new Error(message ?? 'Assertion failed'); + } +} +// TODO: This function is unused and appears broken. Commenting out for now. // eslint-disable-next-line import/prefer-default-export -export function encodeFloatsRGBArange(values, array) { - // Rescale a number into the range [0, 1e32] - // so that it can be passed to the four components of a texture. - values = values.flat(); +// export function encodeFloatsRGBArange(values, array) { +// // Rescale a number into the range [0, 1e32] +// // so that it can be passed to the four components of a texture. +// values = values.flat(); - const [min, max] = extent(values); +// const [min, max] = extent(values); - if (array === undefined) { - array = new Uint32Array(values.length); - } +// if (array === undefined) { +// array = new Uint32Array(values.length); +// } - const scale_size = (2 ** 32) / (max - min); +// const scale_size = (2 ** 32) / (max - min); - let i = 0; +// let i = 0; - for (const value of values) { - array[i] = (value - min) * scale_size; - i += 1; - } +// for (const value of values) { +// array[i] = (value - min) * scale_size; +// i += 1; +// } - return { - extent: [min, max], - array: new Uint8Array(array.buffer), - }; -} +// return { +// extent: [min, max], +// array: new Uint8Array(array.buffer), +// }; +// }