From 228e41c943f2340582c0297fa04e30e56090099f Mon Sep 17 00:00:00 2001 From: Loic Huder Date: Thu, 18 Aug 2022 14:26:21 +0200 Subject: [PATCH] Added tooltip for TiledHeatmapMesh --- .../src/TiledHeatmapMesh.stories.tsx | 59 ++++++++++++++---- packages/lib/package.json | 3 +- packages/lib/src/vis/shared/TooltipMesh.tsx | 10 ++- packages/lib/src/vis/tiles/Tile.tsx | 19 +++++- .../lib/src/vis/tiles/TiledHeatmapMesh.tsx | 55 +++++++++------- packages/lib/src/vis/tiles/TiledLayer.tsx | 62 ++++++++++++------- .../lib/src/vis/tiles/TiledTooltipMesh.tsx | 35 +++++++++++ packages/lib/src/vis/tiles/api.ts | 8 +-- packages/lib/src/vis/tiles/hooks.ts | 50 +++++++++++++++ packages/lib/src/vis/tiles/models.ts | 5 +- packages/lib/src/vis/tiles/store.ts | 14 +++++ packages/lib/src/vis/tiles/utils.ts | 44 +++++++------ pnpm-lock.yaml | 2 + 13 files changed, 278 insertions(+), 88 deletions(-) create mode 100644 packages/lib/src/vis/tiles/TiledTooltipMesh.tsx create mode 100644 packages/lib/src/vis/tiles/hooks.ts create mode 100644 packages/lib/src/vis/tiles/store.ts diff --git a/apps/storybook/src/TiledHeatmapMesh.stories.tsx b/apps/storybook/src/TiledHeatmapMesh.stories.tsx index b19cf8458..be4aafb06 100644 --- a/apps/storybook/src/TiledHeatmapMesh.stories.tsx +++ b/apps/storybook/src/TiledHeatmapMesh.stories.tsx @@ -131,6 +131,26 @@ class MandelbrotTilesApi extends TilesApi { } } +class CheckboardTilesApi extends TilesApi { + public constructor(size: Size, tileSize: Size) { + super(tileSize, getLayerSizes(size, tileSize)); + } + + public get(layer: number, offset: Vector2): NdArray { + const layerSize = this.layerSizes[layer]; + // Clip slice to size of the level + const width = clamp(layerSize.width - offset.x, 0, this.tileSize.width); + const height = clamp(layerSize.height - offset.y, 0, this.tileSize.height); + + const value = Math.abs( + (Math.floor(offset.x / this.tileSize.width) % 2) - + (Math.floor(offset.y / this.tileSize.height) % 2) + ); + const arr = Uint8Array.from({ length: height * width }, () => value); + return ndarray(arr, [height, width]); + } +} + interface TiledHeatmapStoryProps extends TiledHeatmapMeshProps { abscissaConfig: AxisConfig; ordinateConfig: AxisConfig; @@ -152,11 +172,7 @@ const Template: Story = (args) => { - - - + ); }; @@ -167,6 +183,16 @@ const defaultApi = new MandelbrotTilesApi( [-2, 1], [-1.5, 1.5] ); +const halfMandelbrotApi = new MandelbrotTilesApi( + { width: 1e9, height: 5e8 }, + { width: 256, height: 128 }, + [-2, 1], + [0, 1.5] +); +const checkboardApi = new CheckboardTilesApi( + { width: 1e9, height: 1e9 }, + { width: 128, height: 128 } +); export const Default = Template.bind({}); Default.args = { @@ -183,13 +209,6 @@ Default.args = { }, }; -const halfMandelbrotApi = new MandelbrotTilesApi( - { width: 1e9, height: 5e8 }, - { width: 256, height: 128 }, - [-2, 1], - [0, 1.5] -); - export const AxisValues = Template.bind({}); AxisValues.args = { api: halfMandelbrotApi, @@ -292,6 +311,21 @@ WithTransforms.args = { }, }; +export const Checkboard = Template.bind({}); +Checkboard.args = { + api: checkboardApi, + abscissaConfig: { + visDomain: [0, checkboardApi.baseLayerSize.width], + isIndexAxis: true, + showGrid: false, + }, + ordinateConfig: { + visDomain: [0, checkboardApi.baseLayerSize.height], + isIndexAxis: true, + showGrid: false, + }, +}; + export default { title: 'Experimental/TiledHeatmapMesh', component: TiledHeatmapMesh, @@ -304,6 +338,7 @@ export default { scaleType: ScaleType.Linear, displayLowerResolutions: true, qualityFactor: 1, + showTooltip: true, }, argTypes: { scaleType: { diff --git a/packages/lib/package.json b/packages/lib/package.json index 320bad247..18f08a287 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -65,7 +65,8 @@ "react-measure": "2.5.2", "react-slider": "2.0.4", "react-use": "17.4.0", - "react-window": "1.8.7" + "react-window": "1.8.7", + "zustand": "4.1.1" }, "devDependencies": { "@h5web/shared": "workspace:*", diff --git a/packages/lib/src/vis/shared/TooltipMesh.tsx b/packages/lib/src/vis/shared/TooltipMesh.tsx index 7bf9834d1..bf8ad9924 100644 --- a/packages/lib/src/vis/shared/TooltipMesh.tsx +++ b/packages/lib/src/vis/shared/TooltipMesh.tsx @@ -4,18 +4,19 @@ import { useTooltip } from '@visx/tooltip'; import { useCallback } from 'react'; import type { ReactElement } from 'react'; -import type { Coords } from '../models'; +import type { Coords, Size } from '../models'; import { useAxisSystemContext } from './AxisSystemProvider'; import TooltipOverlay from './TooltipOverlay'; import VisMesh from './VisMesh'; interface Props { + size?: Size; guides?: 'horizontal' | 'vertical' | 'both'; renderTooltip: (x: number, y: number) => ReactElement | undefined; } function TooltipMesh(props: Props) { - const { guides, renderTooltip } = props; + const { guides, renderTooltip, size } = props; const { width, height } = useThree((state) => state.size); @@ -79,7 +80,10 @@ function TooltipMesh(props: Props) { return ( <> - + , array: TileArray) => void; } function Tile(props: Props) { - const { api, layer, x, y, magFilter, ...colorMapProps } = props; + const { api, layer, x, y, magFilter, onPointerMove, ...colorMapProps } = + props; const array = api.get(layer, new Vector2(x, y)); const [height, width] = array.shape; + const handlePointerMove = + onPointerMove && + throttle( + (e: ThreeEvent) => { + onPointerMove(e, array); + }, + 200, + { trailing: false } + ); + return ( ); diff --git a/packages/lib/src/vis/tiles/TiledHeatmapMesh.tsx b/packages/lib/src/vis/tiles/TiledHeatmapMesh.tsx index df6376d0e..2404f4cd4 100644 --- a/packages/lib/src/vis/tiles/TiledHeatmapMesh.tsx +++ b/packages/lib/src/vis/tiles/TiledHeatmapMesh.tsx @@ -8,13 +8,15 @@ import { useCameraState } from '../hooks'; import type { Size } from '../models'; import { useAxisSystemContext } from '../shared/AxisSystemProvider'; import TiledLayer from './TiledLayer'; +import TiledTooltipMesh from './TiledTooltipMesh'; import type { TilesApi } from './api'; +import { useLayerScales, useTooltipOnMoveHandler } from './hooks'; import type { ColorMapProps } from './models'; import { - getObject3DVisibleBox, - getObject3DPixelSize, getNdcToObject3DMatrix, - scaleToLayer, + getObject3DPixelSize, + getObject3DVisibleBox, + scaleBox, } from './utils'; interface Props extends ColorMapProps { @@ -22,6 +24,7 @@ interface Props extends ColorMapProps { displayLowerResolutions?: boolean; qualityFactor?: number; size?: Size; + showTooltip?: boolean; } function TiledHeatmapMesh(props: Props) { @@ -30,6 +33,7 @@ function TiledHeatmapMesh(props: Props) { displayLowerResolutions = true, qualityFactor = 1, // 0: Lower quality, less fetch; 1: Best quality size, + showTooltip, ...colorMapProps } = props; const { baseLayerIndex, baseLayerSize } = api; @@ -46,7 +50,8 @@ function TiledHeatmapMesh(props: Props) { ); const visibleBox = getObject3DVisibleBox(ndcToLocalMatrix); - const bounds = scaleToLayer(visibleBox, baseLayerSize, meshSize); + const { xScale, yScale } = useLayerScales(baseLayerSize, meshSize); + const bounds = scaleBox(visibleBox, xScale, yScale); let layers: number[] = []; if (!bounds.isEmpty()) { @@ -74,25 +79,31 @@ function TiledHeatmapMesh(props: Props) { const { colorMap, invertColorMap = false } = colorMapProps; + const onPointerMove = useTooltipOnMoveHandler(); + return ( - - - - - - {layers.map((layer) => ( - - ))} - + <> + + + + + + {layers.map((layer, i) => ( + + ))} + + {showTooltip && } + ); } diff --git a/packages/lib/src/vis/tiles/TiledLayer.tsx b/packages/lib/src/vis/tiles/TiledLayer.tsx index 05de0c7d2..f304f6bfe 100644 --- a/packages/lib/src/vis/tiles/TiledLayer.tsx +++ b/packages/lib/src/vis/tiles/TiledLayer.tsx @@ -1,31 +1,38 @@ +import type { ThreeEvent } from '@react-three/fiber'; import { Suspense } from 'react'; import { LinearFilter, NearestFilter, Vector2 } from 'three'; import type { Box3 } from 'three'; import type { Size } from '../models'; +import { useAxisSystemContext } from '../shared/AxisSystemProvider'; import Tile from './Tile'; import type { TilesApi } from './api'; -import type { ColorMapProps } from './models'; -import { getTileOffsets, scaleToLayer, sortTilesByDistanceTo } from './utils'; +import { useLayerScales } from './hooks'; +import type { ColorMapProps, TileArray } from './models'; +import { getTileOffsets, scaleBox, sortTilesByDistanceTo } from './utils'; interface Props extends ColorMapProps { api: TilesApi; layer: number; meshSize: Size; visibleBox: Box3; + onPointerMove?: (e: ThreeEvent, array: TileArray) => void; } function TiledLayer(props: Props) { - const { api, layer, meshSize, visibleBox, ...colorMapProps } = props; + const { api, layer, meshSize, visibleBox, onPointerMove, ...colorMapProps } = + props; const { baseLayerIndex, numLayers, tileSize } = api; const layerSize = api.layerSizes[layer]; + const { xScale, yScale } = useLayerScales(layerSize, meshSize); + const { abscissaConfig, ordinateConfig } = useAxisSystemContext(); if (visibleBox.isEmpty()) { return null; } - const bounds = scaleToLayer(visibleBox, layerSize, meshSize); + const bounds = scaleBox(visibleBox, xScale, yScale); const tileOffsets = getTileOffsets(bounds, tileSize); // Sort tiles from closest to vis center to farthest away sortTilesByDistanceTo(tileOffsets, tileSize, bounds.getCenter(new Vector2())); @@ -33,25 +40,36 @@ function TiledLayer(props: Props) { return ( // Transforms to use level of details layer array coordinates - {tileOffsets.map((offset) => ( - - - - ))} + + {tileOffsets.map((offset) => ( + + + + ))} + ); } diff --git a/packages/lib/src/vis/tiles/TiledTooltipMesh.tsx b/packages/lib/src/vis/tiles/TiledTooltipMesh.tsx new file mode 100644 index 000000000..c0997ebfb --- /dev/null +++ b/packages/lib/src/vis/tiles/TiledTooltipMesh.tsx @@ -0,0 +1,35 @@ +import { formatTooltipVal } from '@h5web/shared'; + +import type { Size } from '../models'; +import TooltipMesh from '../shared/TooltipMesh'; +import { useTooltipStore } from './store'; + +interface Props { + size: Size; +} + +function TiledTooltipMesh(props: Props) { + const { size } = props; + const val = useTooltipStore((state) => state.val); + + return ( + { + if (!val) { + return undefined; + } + + const { x, y, v } = val; + return ( +
+ {`x=${formatTooltipVal(x)}, y=${formatTooltipVal(y)}`} + {formatTooltipVal(v)} +
+ ); + }} + /> + ); +} + +export default TiledTooltipMesh; diff --git a/packages/lib/src/vis/tiles/api.ts b/packages/lib/src/vis/tiles/api.ts index 82061f26a..36796e310 100644 --- a/packages/lib/src/vis/tiles/api.ts +++ b/packages/lib/src/vis/tiles/api.ts @@ -1,8 +1,7 @@ -import type { NdArray } from 'ndarray'; import type { Vector2 } from 'three'; -import type { TextureSafeTypedArray } from '../heatmap/models'; import type { Size } from '../models'; +import type { TileArray } from './models'; export function getLayerSizes( baseLayerSize: Size, @@ -55,8 +54,5 @@ export abstract class TilesApi { return this.layerSizes.length; } - public abstract get( - layer: number, - offset: Vector2 - ): NdArray; // uint16 values are treated as half floats + public abstract get(layer: number, offset: Vector2): TileArray; } diff --git a/packages/lib/src/vis/tiles/hooks.ts b/packages/lib/src/vis/tiles/hooks.ts new file mode 100644 index 000000000..2bc07d6be --- /dev/null +++ b/packages/lib/src/vis/tiles/hooks.ts @@ -0,0 +1,50 @@ +import { ScaleType } from '@h5web/shared'; +import type { ThreeEvent } from '@react-three/fiber'; +import { useCallback } from 'react'; + +import type { Size } from '../models'; +import { useAxisSystemContext } from '../shared/AxisSystemProvider'; +import { createAxisScale } from '../utils'; +import type { TileArray } from './models'; +import { useTooltipStore } from './store'; + +export function useLayerScales(layerSize: Size, meshSize: Size) { + const { abscissaConfig, ordinateConfig } = useAxisSystemContext(); + + const xScale = createAxisScale(ScaleType.Linear, { + domain: [-meshSize.width / 2, meshSize.width / 2], + range: [0, layerSize.width], + reverse: abscissaConfig.flip, + clamp: true, + }); + + const yScale = createAxisScale(ScaleType.Linear, { + domain: [-meshSize.height / 2, meshSize.height / 2], + range: [0, layerSize.height], + reverse: ordinateConfig.flip, + clamp: true, + }); + + return { xScale, yScale }; +} + +export function useTooltipOnMoveHandler() { + const setTooltipValue = useTooltipStore((state) => state.setTooltipValue); + const { worldToData } = useAxisSystemContext(); + + return useCallback( + (evt: ThreeEvent, array: TileArray) => { + const { unprojectedPoint } = evt; + const localVec = evt.object.worldToLocal(unprojectedPoint.clone()); + const dataVec = worldToData(unprojectedPoint.clone()); + const [height, width] = array.shape; + const val = array.get( + Math.floor(localVec.y + height / 2), + Math.floor(localVec.x + width / 2) + ); + setTooltipValue(dataVec.x, dataVec.y, val); + evt.stopPropagation(); + }, + [setTooltipValue, worldToData] + ); +} diff --git a/packages/lib/src/vis/tiles/models.ts b/packages/lib/src/vis/tiles/models.ts index 23f6d3107..6758a5fdc 100644 --- a/packages/lib/src/vis/tiles/models.ts +++ b/packages/lib/src/vis/tiles/models.ts @@ -1,6 +1,7 @@ import type { Domain } from '@h5web/shared'; +import type { NdArray } from 'ndarray'; -import type { ColorMap } from '../heatmap/models'; +import type { ColorMap, TextureSafeTypedArray } from '../heatmap/models'; import type { VisScaleType } from '../models'; export interface ColorMapProps { @@ -9,3 +10,5 @@ export interface ColorMapProps { colorMap: ColorMap; invertColorMap?: boolean; } + +export type TileArray = NdArray; // uint16 values are treated as half floats diff --git a/packages/lib/src/vis/tiles/store.ts b/packages/lib/src/vis/tiles/store.ts new file mode 100644 index 000000000..6e90f2159 --- /dev/null +++ b/packages/lib/src/vis/tiles/store.ts @@ -0,0 +1,14 @@ +import create from 'zustand'; + +interface TooltipStore { + val: { x: number; y: number; v: number } | undefined; + setTooltipValue: (x: number, y: number, v: number) => void; +} + +export const useTooltipStore = create((set) => ({ + val: undefined, + setTooltipValue: (x: number, y: number, v: number) => + set(() => ({ + val: { x, y, v }, + })), +})); diff --git a/packages/lib/src/vis/tiles/utils.ts b/packages/lib/src/vis/tiles/utils.ts index 918e37d95..7e45c19bf 100644 --- a/packages/lib/src/vis/tiles/utils.ts +++ b/packages/lib/src/vis/tiles/utils.ts @@ -1,11 +1,9 @@ -import { ScaleType } from '@h5web/shared'; import type { Camera } from '@react-three/fiber'; import type { RefObject } from 'react'; +import type { Matrix4, Object3D } from 'three'; import { Box2, Box3, Vector2, Vector3 } from 'three'; -import type { Object3D, Matrix4 } from 'three'; -import type { Size } from '../models'; -import { createAxisScale } from '../utils'; +import type { AxisScale, Size } from '../models'; export function getTileOffsets(box: Box2, tileSize: Size): Vector2[] { const { width, height } = tileSize; @@ -88,25 +86,33 @@ export function getObject3DVisibleBox( return NDC_BOX.clone().applyMatrix4(ndcToObject3DMatrix); } -export function scaleToLayer(box: Box3, layerSize: Size, meshSize: Size): Box2 { +export function scaleBox( + box: Box3, + xScale: AxisScale, + yScale: AxisScale +): Box2 { if (box.isEmpty()) { return new Box2(); } - const xScale = createAxisScale(ScaleType.Linear, { - domain: [-meshSize.width / 2, meshSize.width / 2], - range: [0, layerSize.width], - clamp: true, - }); + const pt1 = new Vector2(xScale(box.min.x), yScale(box.min.y)); + const pt2 = new Vector2(xScale(box.max.x), yScale(box.max.y)); - const yScale = createAxisScale(ScaleType.Linear, { - domain: [-meshSize.height / 2, meshSize.height / 2], - range: [0, layerSize.height], - clamp: true, - }); + return new Box2().setFromPoints([pt1, pt2]); +} - return new Box2( - new Vector2(xScale(box.min.x), yScale(box.min.y)), - new Vector2(xScale(box.max.x), yScale(box.max.y)) - ); +export function scaleFromLayerToTile(layerCoord: Vector2, tileSize: Size) { + const tileOffsetX = + Math.floor(layerCoord.x / tileSize.width) * tileSize.width; + const tileOffsetY = + Math.floor(layerCoord.y / tileSize.height) * tileSize.height; + + const xInTile = layerCoord.x % tileSize.width; + const yInTile = layerCoord.y % tileSize.height; + + return { + offset: new Vector2(tileOffsetX, tileOffsetY), + x: xInTile, + y: yInTile, + }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c50bf54c8..8728e062f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -335,6 +335,7 @@ importers: three: 0.141.0 typescript: 4.8.3 vite: 3.1.3 + zustand: 4.1.1 dependencies: '@react-hookz/web': 14.2.2_sfoxds7t5ydpegc3knd667wn6m '@visx/axis': 2.12.2_react@17.0.2 @@ -358,6 +359,7 @@ importers: react-slider: 2.0.4_react@17.0.2 react-use: 17.4.0_sfoxds7t5ydpegc3knd667wn6m react-window: 1.8.7_sfoxds7t5ydpegc3knd667wn6m + zustand: 4.1.1_react@17.0.2 devDependencies: '@h5web/shared': link:../shared '@react-three/fiber': 7.0.26_weaphyuqmcotibvxgot4zxypyi