Skip to content

Commit

Permalink
Merge pull request #28 from cosmograph-org/f/point-hover-events
Browse files Browse the repository at this point in the history
Mouse events and visual highlight of a node when hovering and clicking
  • Loading branch information
rokotyan authored Feb 3, 2023
2 parents c5ace56 + d13ce6c commit 1af423d
Show file tree
Hide file tree
Showing 13 changed files with 461 additions and 52 deletions.
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ graph.setData(nodes, links)
| nodeGreyoutOpacity | Greyed out node opacity value when the selection is active | `0.1`
| nodeSize | Node size accessor function or value in pixels | `4`
| nodeSizeScale | Scale factor for the node size | `1`
| renderHighlightedNodeRing | Turns the node highlight on hover on / off | `true`
| highlightedNodeRingColor | Highlighted node ring color | `undefined`
| renderLinks | Turns link rendering on / off | `true`
| linkColor | Link color accessor function or hex value | `#666666`
| linkGreyoutOpacity | Greyed out link opacity value when the selection is active | `0.1`
Expand All @@ -78,7 +80,10 @@ graph.setData(nodes, links)
| linkVisibilityMinTransparency | The transparency value that the link will have when its length reaches the maximum link distance value from `linkVisibilityDistanceRange`. | `0.25`
| useQuadtree | Use the classic [quadtree algorithm](https://en.wikipedia.org/wiki/Barnes%E2%80%93Hut_simulation) for the Many-Body force. This property will be applied only on component initialization and it can't be changed using the `setConfig` method. <br /><br /> ⚠ The algorithm might not work on some GPUs (e.g. Nvidia) and on Windows (unless you disable ANGLE in the browser settings). | `false`
| simulation | Simulation parameters and event listeners | See [Simulation configuration](#simulation_configuration) table for more details
| events.onClick | Callback function that will be called on every canvas click. If clicked on a node, its data will be passed as a first argument, index as a second argument, position as a third argument and the corresponding mouse event as a forth argument: <code>(node: Node &vert; undefined, index: number &vert; undefined, nodePosition: [number, number] &vert; undefined, event: MouseEvent) => void</code> | `undefined`
| events.onClick | Callback function that will be called on every canvas click. If clicked on a node, its data will be passed as the first argument, index as the second argument, position as the third argument and the corresponding mouse event as the forth argument: <code>(node: Node &vert; undefined, index: number &vert; undefined, nodePosition: [number, number] &vert; undefined, event: MouseEvent) => void</code> | `undefined`
| events.onMouseMove | Callback function that will be called when mouse movement happens. If the mouse moves over a node, its data will be passed as the first argument, index as the second argument, position as the third argument and the corresponding mouse event as the forth argument: <code>(node: Node &vert; undefined, index: number &vert; undefined, nodePosition: [number, number] &vert; undefined, event: MouseEvent) => void</code> | `undefined`
| events.onNodeMouseOver | Callback function that will be called when a node appears under the mouse as a result of a mouse event, zooming and panning, or movement of nodes. The node data will be passed as the first argument, index as the second argument, position as the third argument and the corresponding mouse event or D3's zoom event as the forth argument: <code>(node: Node, index: number, nodePosition: [number, number], event: MouseEvent &vert; D3ZoomEvent &vert; undefined) => void</code> | `undefined`
| events.onNodeMouseOut | Callback function that will be called when node is no longer underneath the mouse pointer because of a mouse event, zoom/pan event, or movement of nodes. The corresponding mouse event or D3's zoom event will be passed as the first argument: <code>(event: MouseEvent &vert; D3ZoomEvent &vert; undefined) => void</code> | `undefined`
| events.onZoomStart | Callback function that will be called when zooming or panning starts. First argument is a D3 Zoom Event and second indicates whether the event has been initiated by a user interaction (e.g. a mouse event): <code>(event: D3ZoomEvent, userDriven: boolean) => void</code> | `undefined`
| events.onZoom | Callback function that will be called continuously during zooming or panning. First argument is a D3 Zoom Event and second indicates whether the event has been initiated by a user interaction (e.g. a mouse event): <code>(event: D3ZoomEvent, userDriven: boolean) => void</code> | `undefined`
| events.onZoomEnd | Callback function that will be called when zooming or panning ends. First argument is a D3 Zoom Event and second indicates whether the event has been initiated by a user interaction (e.g. a mouse event): <code>(event: D3ZoomEvent, userDriven: boolean) => void</code> | `undefined`
Expand Down Expand Up @@ -178,6 +183,14 @@ Get an array of currently selected nodes.

Get nodes that are adjacent to a specific node by its <i>id</i>.

<a name="get_node_radius_by_index" href="#get_node_radius_by_index">#</a> graph.<b>getNodeRadiusByIndex</b>(<i>index</i>)

Get node radius by its <i>index</i>.

<a name="get_node_radius_by_id" href="#get_node_radius_by_id">#</a> graph.<b>getNodeRadiusById</b>(<i>id</i>)

Get node radius by its <i>id</i>.

<a name="start" href="#start">#</a> graph.<b>start</b>([<i>alpha</i>])

Start the simulation. The <i>alpha</i> value can be from 0 to 1 (1 by default). The higher the value, the more initial energy the simulation will get.
Expand Down Expand Up @@ -215,6 +228,14 @@ Get a `Map` object with node coordinates, where keys are the _ids_ of the nodes

Get an array of `[number, number]` arrays corresponding to the X and Y coordinates of the nodes.

<a name="space_to_screen_position" href="#space_to_screen_position">#</a> graph.<b>spaceToScreenPosition</b>(<i>coordinates</i>)

Converts the X and Y node <i>coordinates</i> in the `[number, number]` format from the space coordinate system to the screen coordinate system.

<a name="space_to_screen_radius" href="#space_to_screen_radius">#</a> graph.<b>spaceToScreenRadius</b>(<i>radius</i>)

Converts the node <i>radius</i> value from the space coordinate system to the screen coordinate system.

<a name="is_simulation_running" href="#is_simulation_running">#</a> graph.<b>isSimulationRunning</b>

A boolean value showing whether the simulation is active or not.
Expand Down
58 changes: 54 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,46 @@ export type ColorAccessor<Datum> = ((d: Datum, i?: number, ...rest: unknown[]) =
export interface Events <N extends InputNode> {
/**
* Callback function that will be called on every canvas click.
* If clicked on a node, its data will be passed as a first argument,
* index as a second argument, position as a third argument
* and the corresponding mouse event as a forth argument:
* If clicked on a node, its data will be passed as the first argument,
* index as the second argument, position as the third argument
* and the corresponding mouse event as the forth argument:
* `(node: Node | undefined, index: number | undefined, nodePosition: [number, number] | undefined, event: MouseEvent) => void`.
* Default value: `undefined`
*/
onClick?: (clickedNode: N | undefined, index: number | undefined, nodePosition: [number, number] | undefined, event: MouseEvent) => void;
onClick?: (
clickedNode: N | undefined, index: number | undefined, nodePosition: [number, number] | undefined, event: MouseEvent
) => void;
/**
* Callback function that will be called when mouse movement happens.
* If the mouse moves over a node, its data will be passed as the first argument,
* index as the second argument, position as the third argument
* and the corresponding mouse event as the forth argument:
* `(node: Node | undefined, index: number | undefined, nodePosition: [number, number] | undefined, event: MouseEvent) => void`.
* Default value: `undefined`
*/
onMouseMove?: (
hoveredNode: N | undefined, index: number | undefined, nodePosition: [number, number] | undefined, event: MouseEvent
) => void;
/**
* Callback function that will be called when a node appears under the mouse
* as a result of a mouse event, zooming and panning, or movement of nodes.
* The node data will be passed as the first argument,
* index as the second argument, position as the third argument
* and the corresponding mouse event or D3's zoom event as the forth argument:
* `(node: Node, index: number, nodePosition: [number, number], event: MouseEvent | D3ZoomEvent<HTMLCanvasElement, undefined) => void`.
* Default value: `undefined`
*/
onNodeMouseOver?: (
hoveredNode: N, index: number, nodePosition: [number, number], event: MouseEvent | D3ZoomEvent<HTMLCanvasElement, undefined> | undefined
) => void;
/**
* Callback function that will be called when a node is no longer underneath
* the mouse pointer because of a mouse event, zoom/pan event, or movement of nodes.
* The corresponding mouse event or D3's zoom event will be passed as the first argument:
* `(event: MouseEvent | D3ZoomEvent<HTMLCanvasElement, undefined) => void`.
* Default value: `undefined`
*/
onNodeMouseOut?: (event: MouseEvent | D3ZoomEvent<HTMLCanvasElement, undefined> | undefined) => void;
/**
* Callback function that will be called when zooming or panning starts.
* First argument is a D3 Zoom Event and second indicates whether
Expand Down Expand Up @@ -171,6 +204,18 @@ export interface GraphConfigInterface<N extends InputNode, L extends InputLink>
*/
nodeSizeScale?: number;

/**
* Turns the node highlight on hover on / off.
* Default value: `true`
*/
renderHighlightedNodeRing?: boolean;

/**
* Highlighted node ring color hex value.
* Default value: undefined
*/
highlightedNodeRingColor?: string;

/**
* Turns link rendering on / off.
* Default value: `true`
Expand Down Expand Up @@ -269,6 +314,8 @@ export class GraphConfig<N extends InputNode, L extends InputLink> implements Gr
public nodeGreyoutOpacity = defaultGreyoutNodeOpacity
public nodeSize = defaultNodeSize
public nodeSizeScale = defaultConfigValues.nodeSizeScale
public renderHighlightedNodeRing = true
public highlightedNodeRingColor = undefined
public linkColor = defaultLinkColor
public linkGreyoutOpacity = defaultGreyoutLinkOpacity
public linkWidth = defaultLinkWidth
Expand Down Expand Up @@ -301,6 +348,9 @@ export class GraphConfig<N extends InputNode, L extends InputLink> implements Gr

public events: Events<N> = {
onClick: undefined,
onMouseMove: undefined,
onNodeMouseOver: undefined,
onNodeMouseOut: undefined,
onZoomStart: undefined,
onZoom: undefined,
onZoomEnd: undefined,
Expand Down
142 changes: 124 additions & 18 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { select, Selection } from 'd3-selection'
import 'd3-transition'
import { easeQuadIn, easeQuadOut, easeQuadInOut } from 'd3-ease'
import { D3ZoomEvent } from 'd3-zoom'
import regl from 'regl'
import { GraphConfig, GraphConfigInterface } from '@/graph/config'
import { getRgbaColor, readPixels } from '@/graph/helper'
Expand Down Expand Up @@ -28,7 +29,7 @@ export class Graph<N extends InputNode, L extends InputLink> {
private isRightClickMouse = false

private graph = new GraphData<N, L>()
private store = new Store()
private store = new Store<N>()
private points: Points<N, L>
private lines: Lines<N, L>
private forceGravity: ForceGravity<N, L>
Expand All @@ -40,6 +41,7 @@ export class Graph<N extends InputNode, L extends InputLink> {
private zoomInstance = new Zoom(this.store, this.config)
private fpsMonitor: FPSMonitor | undefined
private hasBeenRecentlyDestroyed = false
private currentEvent: D3ZoomEvent<HTMLCanvasElement, undefined> | MouseEvent | undefined

public constructor (canvas: HTMLCanvasElement, config?: GraphConfigInterface<N, L>) {
if (config) this.config.init(config)
Expand All @@ -61,6 +63,14 @@ export class Graph<N extends InputNode, L extends InputLink> {

this.canvas = canvas
this.canvasD3Selection = select<HTMLCanvasElement, undefined>(canvas)
this.zoomInstance.behavior
.on('start.detect', (e: D3ZoomEvent<HTMLCanvasElement, undefined>) => { this.currentEvent = e })
.on('zoom.detect', (e: D3ZoomEvent<HTMLCanvasElement, undefined>) => {
const userDriven = !!e.sourceEvent
if (userDriven) this.updateMousePosition(e.sourceEvent)
this.currentEvent = e
})
.on('end.detect', (e: D3ZoomEvent<HTMLCanvasElement, undefined>) => { this.currentEvent = e })
this.canvasD3Selection
.call(this.zoomInstance.behavior)
.on('click', this.onClick.bind(this))
Expand Down Expand Up @@ -92,6 +102,7 @@ export class Graph<N extends InputNode, L extends InputLink> {
this.forceMouse = new ForceMouse(this.reglInstance, this.config, this.store, this.graph, this.points)

this.store.backgroundColor = getRgbaColor(this.config.backgroundColor)
if (this.config.highlightedNodeRingColor) this.store.setHighlightedNodeRingColor(this.config.highlightedNodeRingColor)

if (this.config.showFPSMonitor) this.fpsMonitor = new FPSMonitor(this.canvas)

Expand Down Expand Up @@ -129,6 +140,9 @@ export class Graph<N extends InputNode, L extends InputLink> {
if (prevConfig.nodeSize !== this.config.nodeSize) this.points.updateSize()
if (prevConfig.linkWidth !== this.config.linkWidth) this.lines.updateWidth()
if (prevConfig.backgroundColor !== this.config.backgroundColor) this.store.backgroundColor = getRgbaColor(this.config.backgroundColor)
if (prevConfig.highlightedNodeRingColor !== this.config.highlightedNodeRingColor) {
this.store.setHighlightedNodeRingColor(this.config.highlightedNodeRingColor)
}
if (prevConfig.spaceSize !== this.config.spaceSize ||
prevConfig.simulation.repulsionQuadtreeLevels !== this.config.simulation.repulsionQuadtreeLevels) this.update(this.store.isSimulationRunning)
if (prevConfig.showFPSMonitor !== this.config.showFPSMonitor) {
Expand Down Expand Up @@ -397,6 +411,45 @@ export class Graph<N extends InputNode, L extends InputLink> {
return this.graph.getAdjacentNodes(id)
}

/**
* Converts the X and Y node coordinates from the space coordinate system to the screen coordinate system.
* @param spacePosition Array of x and y coordinates in the space coordinate system.
* @returns Array of x and y coordinates in the screen coordinate system.
*/

public spaceToScreenPosition (spacePosition: [number, number]): [number, number] {
return this.zoomInstance.convertSpaceToScreenPosition(spacePosition)
}

/**
* Converts the node radius value from the space coordinate system to the screen coordinate system.
* @param spaceRadius Radius of Node in the space coordinate system.
* @returns Radius of Node in the screen coordinate system.
*/
public spaceToScreenRadius (spaceRadius: number): number {
return this.zoomInstance.convertSpaceToScreenRadius(spaceRadius)
}

/**
* Get node radius by its index.
* @param index Index of the node.
* @returns Radius of the node.
*/
public getNodeRadiusByIndex (index: number): number | undefined {
const node = this.graph.getNodeByIndex(index)
return node && this.points.getNodeRadius(node)
}

/**
* Get node radius by its id.
* @param id Id of the node.
* @returns Radius of the node.
*/
public getNodeRadiusById (id: string): number | undefined {
const node = this.graph.getNodeById(id)
return node && this.points.getNodeRadius(node)
}

/**
* Start the simulation.
* @param alpha Value from 0 to 1. The higher the value, the more initial energy the simulation will get.
Expand Down Expand Up @@ -497,6 +550,7 @@ export class Graph<N extends InputNode, L extends InputLink> {
this.requestAnimationFrameId = window.requestAnimationFrame((now) => {
this.fpsMonitor?.begin()
this.resizeCanvas()
this.findHoveredPoint()

if (this.isRightClickMouse) {
if (!isSimulationRunning) this.start(0.1)
Expand Down Expand Up @@ -543,6 +597,7 @@ export class Graph<N extends InputNode, L extends InputLink> {
this.points.draw()
this.fpsMonitor?.end(now)

this.currentEvent = undefined
this.frame()
})
}
Expand All @@ -558,23 +613,21 @@ export class Graph<N extends InputNode, L extends InputLink> {
}

private onClick (event: MouseEvent): void {
this.points.findPointsOnMouseClick()
const pixels = readPixels(this.reglInstance, this.points.selectedFbo as regl.Framebuffer2D)
let position: [number, number] | undefined
const pixelsInSelectedArea = pixels
.map((pixel, i) => {
if (i % 4 === 0 && pixel !== 0) {
position = [pixels[i + 2] as number, this.config.spaceSize - (pixels[i + 3] as number)]
return i / 4
} else return -1
})
.filter(d => d !== -1)
const clickedIndex = this.graph.getInputIndexBySortedIndex(pixelsInSelectedArea[pixelsInSelectedArea.length - 1] as number)
const clickedParticle = (pixelsInSelectedArea.length && clickedIndex !== undefined) ? this.graph.nodes[clickedIndex] : undefined
this.config.events.onClick?.(clickedParticle, clickedIndex, position, event)
}

private onMouseMove (event: MouseEvent): void {
this.store.setClickedNode()
this.config.events.onClick?.(
this.store.clickedNode.node as N | undefined,
this.store.clickedNode.node?.id !== undefined
? this.graph.getInputIndexBySortedIndex(
this.graph.getSortedIndexById(this.store.clickedNode.node.id) as number
) as number
: undefined,
this.store.clickedNode.position,
event
)
}

private updateMousePosition (event: MouseEvent): void {
if (!event || event.offsetX === undefined || event.offsetY === undefined) return
const { x, y, k } = this.zoomInstance.eventTransform
const h = this.canvas.clientHeight
const mouseX = event.offsetX
Expand All @@ -585,7 +638,22 @@ export class Graph<N extends InputNode, L extends InputLink> {
this.store.mousePosition[0] -= (this.store.screenSize[0] - this.config.spaceSize) / 2
this.store.mousePosition[1] -= (this.store.screenSize[1] - this.config.spaceSize) / 2
this.store.screenMousePosition = [mouseX, (this.store.screenSize[1] - mouseY)]
}

private onMouseMove (event: MouseEvent): void {
this.currentEvent = event
this.updateMousePosition(event)
this.isRightClickMouse = event.which === 3
this.config.events.onMouseMove?.(
this.store.hoveredNode.node as N | undefined,
this.store.hoveredNode.node?.id !== undefined
? this.graph.getInputIndexBySortedIndex(
this.graph.getSortedIndexById(this.store.hoveredNode.node.id) as number
) as number
: undefined,
this.store.hoveredNode.position,
this.currentEvent
)
}

private onRightClickMouse (event: MouseEvent): void {
Expand Down Expand Up @@ -642,6 +710,44 @@ export class Graph<N extends InputNode, L extends InputLink> {
.call(this.zoomInstance.behavior.transform, transform)
}
}

private findHoveredPoint (): void {
this.points.findHoveredPoint()
let isMouseover = false
let isMouseout = false
const pixels = readPixels(this.reglInstance, this.points.hoveredFbo as regl.Framebuffer2D)
const nodeSize = pixels[1] as number
if (nodeSize) {
const index = pixels[0] as number
const i = index % this.store.pointsTextureSize
const j = Math.floor(index / this.store.pointsTextureSize)
const inputIndex = this.graph.getInputIndexBySortedIndex(index)
const hovered = inputIndex ? this.graph.getNodeByIndex(inputIndex) : undefined
if (this.store.hoveredNode.node !== hovered) isMouseover = true
this.store.hoveredNode.node = hovered
this.store.hoveredNode.indicesFromFbo = [i, j]
const pointX = pixels[2] as number
const pointY = pixels[3] as number
this.store.hoveredNode.position = [pointX, pointY]
} else {
if (this.store.hoveredNode.node) isMouseout = true
this.store.hoveredNode.node = undefined
this.store.hoveredNode.indicesFromFbo = [-1, -1]
this.store.hoveredNode.position = undefined
}

if (isMouseover && this.store.hoveredNode.node) {
this.config.events.onNodeMouseOver?.(
this.store.hoveredNode.node as N,
this.graph.getInputIndexBySortedIndex(
this.graph.getSortedIndexById(this.store.hoveredNode.node.id) as number
) as number,
this.store.hoveredNode.position,
this.currentEvent
)
}
if (isMouseout) this.config.events.onNodeMouseOut?.(this.currentEvent)
}
}

export type { InputLink, InputNode } from './types'
Expand Down
Loading

0 comments on commit 1af423d

Please sign in to comment.