Skip to content

Commit

Permalink
Refactored hover and select event value type
Browse files Browse the repository at this point in the history
  • Loading branch information
midlik committed May 29, 2024
1 parent 1436c98 commit 0cba2b6
Show file tree
Hide file tree
Showing 8 changed files with 74 additions and 55 deletions.
11 changes: 6 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ All notable changes to this project will be documented in this file, following t

## [Unreleased]

- Breaking change: Renamed item/items to datum/data in several places
- Breaking change: Default coloring is "everything gray", even for numeric values
- Breaking change: Color scales are created by `ColorScale.continuous`
- Breaking change: Default axis alignment for (`zoom`, `getZoom`, `events.zoom`) changed from center,center to left,top (call `setAlignment` to change)
- Breaking change: Changed value type for `hover` and `select` events -> `{ cell?: { datum?: TDatum, ... }, sourceEvent?: MouseEvent }`
- Breaking change: Dropped `formatDataItem` function
- `Heatmap.create` callable without `data` parameter
- Renamed item/items to datum/data in several places
- Can manipulate markers via `hm.extensions.marker?.drawMarkers({...})`
- Default coloring is "everything gray", even for numeric values
- Color scales are created by `ColorScale.continuous`
- Default axis alignment for (`zoom`, `getZoom`, `events.zoom`) changed from center,center to left,top (call `setAlignment` to change)
- Dropped `formatDataItem` function

## [0.9.0] - 2024-04-29

Expand Down
6 changes: 3 additions & 3 deletions src/heatmap-component/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ export function demo1(divElementOrId: HTMLDivElement | string): void {
setTimeout(() => heatmap.setFilter(undefined), 2000);
heatmap.setVisualParams({ xGapPixels: 0, yGapPixels: 0 });
heatmap.events.select.subscribe(e => {
if (!e) {
console.log('selecting nothing');
if (e.cell) {
console.log('selecting', e.cell.datum, e.cell.x, e.cell.y, e.cell.xIndex, e.cell.yIndex, e.sourceEvent);
} else {
console.log('selecting', e.datum, e.x, e.y, e.xIndex, e.yIndex, e.sourceEvent);
console.log('selecting nothing');
}
});
heatmap.events.zoom.subscribe(e => {
Expand Down
3 changes: 2 additions & 1 deletion src/heatmap-component/extensions/marker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ export class MarkerBehavior extends BehaviorBase<MarkerExtensionParams> {
super.register();
this.subscribe(this.state.events.hover, pointed => {
if (!this.params.freeze) {
this.drawMarkers(pointed);
const hasDatum = pointed.cell?.datum !== undefined;
this.drawMarkers(hasDatum ? pointed.cell : undefined);
}
});
this.subscribe(this.state.events.resize, () => {
Expand Down
23 changes: 13 additions & 10 deletions src/heatmap-component/extensions/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ export class TooltipBehavior<TX, TY, TDatum> extends BehaviorBase<TooltipExtensi

/** Add a div with tooltip or update position of existing tooltip, for the `pointed` grid cell.
* Remove existing tooltip, if `pointed` is `undefined`. */
private drawTooltip(pointed: CellEventValue<TX, TY, TDatum> | undefined): void {
private drawTooltip(pointed: CellEventValue<TX, TY, TDatum>): void {
if (!this.state.dom) return;
const thisTooltipPinned = pointed && this.pinnedTooltip && pointed.xIndex === Math.floor(this.pinnedTooltip.x) && pointed.yIndex === Math.floor(this.pinnedTooltip.y);
if (pointed && !thisTooltipPinned && this.params.tooltipProvider) {
const thisTooltipPinned = pointed.cell && this.pinnedTooltip && pointed.cell.xIndex === Math.floor(this.pinnedTooltip.x) && pointed.cell.yIndex === Math.floor(this.pinnedTooltip.y);
if ((pointed.cell?.datum !== undefined) && !thisTooltipPinned && this.params.tooltipProvider && pointed.sourceEvent) {
const tooltipPosition = this.getTooltipPosition(pointed.sourceEvent);
const tooltipText = this.params.tooltipProvider(pointed.datum, pointed.x, pointed.y, pointed.xIndex, pointed.yIndex);
const tooltipText = this.params.tooltipProvider(pointed.cell.datum, pointed.cell.x, pointed.cell.y, pointed.cell.xIndex, pointed.cell.yIndex);
let tooltip = this.state.dom.canvasDiv.selectAll<HTMLDivElement, any>('.' + Class.TooltipBox);
if (tooltip.empty()) {
// Create tooltip if doesn't exist
Expand All @@ -73,13 +73,16 @@ export class TooltipBehavior<TX, TY, TDatum> extends BehaviorBase<TooltipExtensi
* Remove existing pinned tooltip, if `pointed` is `undefined`.
* Pinned tooltip is shown when the user selects a cell by clicking,
* and stays visible until it is closed by close button or clicking elsewhere. */
private drawPinnedTooltip(pointed: CellEventValue<TX, TY, TDatum> | undefined): void {
private drawPinnedTooltip(pointed: CellEventValue<TX, TY, TDatum>): void {
if (!this.state.dom) return;
this.state.dom.canvasDiv.selectAll('.' + Class.PinnedTooltipBox).remove();
if (pointed && this.params.tooltipProvider && this.params.pinnable) {
this.pinnedTooltip = { x: this.state.scales.canvasToWorld.x(pointed.sourceEvent.offsetX), y: this.state.scales.canvasToWorld.y(pointed.sourceEvent.offsetY) };
if (pointed.cell?.datum !== undefined && this.params.tooltipProvider && this.params.pinnable && pointed.sourceEvent) {
this.pinnedTooltip = {
x: this.state.scales.canvasToWorld.x(pointed.sourceEvent.offsetX),
y: this.state.scales.canvasToWorld.y(pointed.sourceEvent.offsetY),
};
const tooltipPosition = this.getTooltipPosition(pointed.sourceEvent);
const tooltipText = this.params.tooltipProvider(pointed.datum, pointed.x, pointed.y, pointed.xIndex, pointed.yIndex);
const tooltipText = this.params.tooltipProvider(pointed.cell.datum, pointed.cell.x, pointed.cell.y, pointed.cell.xIndex, pointed.cell.yIndex);

const tooltip = attrd(this.state.dom.canvasDiv.append('div'), {
class: Class.PinnedTooltipBox,
Expand All @@ -92,7 +95,7 @@ export class TooltipBehavior<TX, TY, TDatum> extends BehaviorBase<TooltipExtensi

// Tooltip close button
attrd(tooltip.append('div'), { class: Class.PinnedTooltipClose })
.on('click', () => this.state.events.select.next(undefined))
.on('click.TooltipExtension', (e: MouseEvent) => this.state.events.select.next({ cell: undefined, sourceEvent: e }))
.append('svg')
.attr('viewBox', '0 0 24 24')
.attr('preserveAspectRatio', 'none')
Expand All @@ -107,7 +110,7 @@ export class TooltipBehavior<TX, TY, TDatum> extends BehaviorBase<TooltipExtensi
.attr('d', 'M0,100 L100,40 L60,0 Z');

// Remove any non-pinned tooltip
this.drawTooltip(undefined);
this.drawTooltip({ cell: undefined, sourceEvent: pointed.sourceEvent });
} else {
this.pinnedTooltip = undefined;
}
Expand Down
11 changes: 9 additions & 2 deletions src/heatmap-component/extensions/zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export class ZoomBehavior extends BehaviorBase<ZoomExtensionParams> {
// Remove any old behavior
this.zoomBehavior.on('zoom', null);
this.targetElement.on('.zoom', null);
this.targetElement.on('.customzoom', null);
this.zoomBehavior = undefined;
}
if (this.params.axis === 'none') return;
Expand All @@ -92,7 +93,10 @@ export class ZoomBehavior extends BehaviorBase<ZoomExtensionParams> {
const visWorld = this.zoomTransformToVisWorld(e.transform);
this.state.zoomVisWorldBox(visWorld, ZoomExtension.name, !this.suppressEmit);
if (e.sourceEvent) {
this.state.events.hover.next(this.state.getPointedCell(e.sourceEvent));
this.state.events.hover.next({
cell: this.state.getPointedCell(e.sourceEvent),
sourceEvent: e.sourceEvent,
});
}
}

Expand All @@ -114,7 +118,10 @@ export class ZoomBehavior extends BehaviorBase<ZoomExtensionParams> {
this.showScrollingMessage();
}
}
this.state.events.hover.next(this.state.getPointedCell(e));
this.state.events.hover.next({
cell: this.state.getPointedCell(e),
sourceEvent: e,
});
}

/** Magic to handle touchpad scrolling on Mac (when user lifts fingers from touchpad, but the browser is still getting wheel events) */
Expand Down
15 changes: 12 additions & 3 deletions src/heatmap-component/heatmap-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,18 @@ export class HeatmapCore<TX, TY, TDatum> {
this.state.dom = { rootDiv, mainDiv, canvasDiv, canvas, svg };

// Add event listeners
svg.on('mousemove.heatmapcore', (e: MouseEvent) => this.state.events.hover.next(this.state.getPointedCell(e)));
svg.on('mouseleave.heatmapcore', (e: MouseEvent) => this.state.events.hover.next(undefined));
svg.on('click.heatmapcore', (e: MouseEvent) => this.state.events.select.next(this.state.getPointedCell(e)));
svg.on('mousemove.heatmapcore', (e: MouseEvent) => this.state.events.hover.next({
cell: this.state.getPointedCell(e),
sourceEvent: e,
}));
svg.on('mouseleave.heatmapcore', (e: MouseEvent) => this.state.events.hover.next({
cell: undefined,
sourceEvent: e,
}));
svg.on('click.heatmapcore', (e: MouseEvent) => this.state.events.select.next({
cell: this.state.getPointedCell(e),
sourceEvent: e,
}));

this.state.events.render.next(undefined);
this.state.emitResize();
Expand Down
21 changes: 9 additions & 12 deletions src/heatmap-component/heatmap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,6 @@ import { HeatmapCore } from './heatmap-core';
import { XAlignmentMode, YAlignmentMode, ZoomEventValue } from './state';


// TODO: Should: docs
// TODO: Should: default alignment left-top to keep things simple
// TODO: Could: cell events -> { cell: {...}|undefined, sourceEvent: MouseEvent }
// TODO: Could: reorganize demos and index.html, github.io
// TODO: Could: various zoom modes (horizontal, vertical, both, none...)
// TODO: Could: DataDescription.toArray2D - use Float32Array/Int32Array/... (+ mask array) when possible instead of Array (might speed up initialization up to 4x)
// TODO: Would: try setting `downsamplingPixelsPerRect` dynamically, based on rendering times
// TODO: Would: Smoothen zooming and panning with mouse wheel?
// TODO: Would: Tooltip/marker only showing on click?
// TODO: Would: add Behavior.onUnregister, use to keep a list of currently register extensions?


/** Main class of the `heatmap-component` package.
* Extends `HeatmapCore` by registering essential extensions and implementing useful public methods. */
export class Heatmap<TX, TY, TDatum> extends HeatmapCore<TX, TY, TDatum> {
Expand Down Expand Up @@ -163,3 +151,12 @@ interface VisualParams {
minRectSizeForGaps: DrawExtensionParams<unknown, unknown, unknown>['minRectSizeForGaps'],
markerCornerRadius: MarkerExtensionParams['markerCornerRadius'],
}


// Possible TODOS for the future:
// TODO: Could: various zoom modes (horizontal, vertical, both, none...)
// TODO: Could: DataDescription.toArray2D - use Float32Array/Int32Array/... (+ mask array) when possible instead of Array (might speed up initialization up to 4x)
// TODO: Would: try setting `downsamplingPixelsPerRect` dynamically, based on rendering times
// TODO: Would: Smoothen zooming and panning with mouse wheel?
// TODO: Would: Tooltip/marker only showing on click?
// TODO: Would: add Behavior.onUnregister, use to keep a list of currently registered extensions?
39 changes: 20 additions & 19 deletions src/heatmap-component/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,21 @@ export const MIN_ZOOMED_DATAPOINTS_HARD = 1;

/** Emitted on data-cell-related events (hover, click...) */
export interface CellEventValue<TX, TY, TDatum> {
/** Datum stored in the data cell */
datum: TDatum,
/** X value ("column name") */
x: TX,
/** Y value ("row name") */
y: TY,
/** Column index */
xIndex: number,
/** Row index */
yIndex: number,
/** Pointed cell (can have a datum in it or can be empty) */
cell: {
/** Datum stored in the data cell, unless this is empty cell */
datum?: TDatum,
/** X value ("column name") */
x: TX,
/** Y value ("row name") */
y: TY,
/** Column index */
xIndex: number,
/** Row index */
yIndex: number,
} | undefined,
/** Original mouse event that triggered this */
sourceEvent: MouseEvent,
sourceEvent: MouseEvent | undefined,
}

/** Emitted on zoom event */
Expand Down Expand Up @@ -123,9 +126,9 @@ export class State<TX, TY, TDatum> {
/** Custom events fired by the heatmap component, all are RxJS `BehaviorSubject` */
readonly events = {
/** Fires when the user hovers over the component */
hover: new BehaviorSubject<CellEventValue<TX, TY, TDatum> | undefined>(undefined),
hover: new BehaviorSubject<CellEventValue<TX, TY, TDatum>>({ cell: undefined, sourceEvent: undefined }),
/** Fires when the user selects/deselects a cell (e.g. by clicking on it) */
select: new BehaviorSubject<CellEventValue<TX, TY, TDatum> | undefined>(undefined),
select: new BehaviorSubject<CellEventValue<TX, TY, TDatum>>({ cell: undefined, sourceEvent: undefined }),
/** Fires when the component is zoomed in or out, or panned (translated) */
zoom: new BehaviorSubject<ZoomEventValue<TX, TY> | undefined>(undefined),
/** Fires when the window is resized. Subject value is the size of the canvas in pixels. */
Expand Down Expand Up @@ -174,20 +177,18 @@ export class State<TX, TY, TDatum> {


/** Return the data cell that is being pointed by the mouse in `event`.
* Return `undefined` if there is no such cell. */
getPointedCell(event: MouseEvent | undefined): CellEventValue<TX, TY, TDatum> | undefined {
* Return `undefined` if there is no such cell.
* Return a cell with `datum: undefined` if there is a cell but it is empty. */
getPointedCell(event: MouseEvent | undefined): CellEventValue<TX, TY, TDatum>['cell'] {
if (!event) {
return undefined;
}
const xIndex = Math.floor(this.scales.canvasToWorld.x(event.offsetX));
const yIndex = Math.floor(this.scales.canvasToWorld.y(event.offsetY));
const datum = Array2D.get(this.dataArray, xIndex, yIndex);
if (!datum) {
return undefined;
}
const x = this.xDomain.values[xIndex];
const y = this.yDomain.values[yIndex];
return { datum, x, y, xIndex, yIndex, sourceEvent: event };
return { datum, x, y, xIndex, yIndex };
}

/** Emit a resize event, with the current size of canvas. */
Expand Down

0 comments on commit 0cba2b6

Please sign in to comment.