Skip to content

Commit

Permalink
Support double-clicking canvas creates new node (#7)
Browse files Browse the repository at this point in the history
* Support double-clicking canvas creates new node

* Double clicking can make a node on canvas, although not at clicking position

* New nodes takes the max ID automatically

* add label in order to have node name displayed

* Node can be persisted into DB with simple config

* add better error handling and documentations

* add tests & documentation

* Self-review
  • Loading branch information
QubitPi authored Oct 5, 2023
1 parent aeb4ab6 commit c25b0e8
Show file tree
Hide file tree
Showing 10 changed files with 490 additions and 6 deletions.
1 change: 1 addition & 0 deletions docs/modules/ROOT/content-nav.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@
* xref:internals/index.adoc[Internals]
** xref:internals/graph-modelling.adoc[]
** xref:internals/graph-interactions.adoc[]
253 changes: 253 additions & 0 deletions docs/modules/ROOT/images/neo4j-browser.drawio

Large diffs are not rendered by default.

Binary file modified docs/modules/ROOT/images/neo4j-browser.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
130 changes: 130 additions & 0 deletions docs/modules/ROOT/pages/internals/graph-interactions.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
:description: How graph interations take effect on displayed graph and backing database


[[user-interactions]]
= How Graph Interations Take Effect on Displayed Graph and Backing Database

We define *graph interactions* as any https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent[mouse] and
https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent[keyboard] events on the
link:../../visual-tour/index.html#frame-views[_Graph_ frame view]

There are basically 3 components involved in handling users' interactions:

1. *GraphEventHandlerModel* contains implementations on how to operate on the displayed graph for various interactions
2. *Visualization* triggers the implementations via D3 callbacks
3. *VisualizationView* is involved if a certain type of user interaction involves database changes

[NOTE]
====
In the case 3. above, _VisualizationView_
https://react.dev/learn/passing-data-deeply-with-context#the-problem-with-passing-props[prop-drilling] a callback
called **GraphInteractionCallBack** all they way to the _GraphEventHandlerModel_. Since database operations should not
be defined in neo4j-arc module, _GraphEventHandlerModel_ can simply pass any argument to the callback function and let
the upper level component (recall the component diagram in link:../index.html[overview section]) _VisualizationView_ handle
the database connection, query issuing, and response handling, etc.
====

== How to Implement a User Interaction

1. Implement an event handler function in GraphEventHandlerModel
2. Bind the handler function in `GraphEventHandlerModel.bindEventHandlers()`
3. Trigger the handler function in `Visualization`
4. If the interaction involves database changes, add the corresponding logic to `GraphInteractionCallBack`, and
5. trigger the `GraphInteractionCallBack` in the handler function

For example, let's say we'd like to support easy on-graph editing by allowing us to create a new node when we double
click on canvas. We will follow the steps above by first defining the event handler function:

[source,typescript]
----
onCanvasDblClicked(): void {
this.graph.addNodes([ new NodeModel(...) ])
this.visualization.update({ updateNodes: true, updateRelationships: true })
this.graphModelChanged()
this.onGraphInteraction(NODE_ON_CANVAS_CREATE, { id: newNodeId, name: 'New Node', labels: ['Undefined'] })
}
----

[NOTE]
====
When we add a new node to the graph, it is important to update the visual of the graph using `visualization.update()`
and the stats panel with `graphModelChanged()`
We would also like to persist the new node to database so that refreshing the page or re-login shall still render this
node. Therefore we invoke `onGraphInteraction()` above. The details of this method will be disucssed below
====

Next, we bind the function so that `Visualization.ts` component can delegte any canvas double click callbacks to this
function

[source,typescript]
----
bindEventHandlers(): void {
this.visualization
...
.on('canvasDblClicked', this.onCanvasDblClicked.bind(this))
...
this.onItemMouseOut()
}
----

When `Visualization.ts` recrives a canvas double click from user, it invoke the event handler function via
https://www.d3indepth.com/selections/#event-handling[D3 event handlers]:

[source,typescript]
----
this.rect = this.baseGroup
.append('rect')
...
.on('dblclick', () => {
if (!this.draw) {
return this.trigger('canvasDblClicked')
}
})
----

This is how we trigger the handler function in `Visualization`.

As we've mentioned earlier, we would like to persist this new node to database. They way to do that is by modifying
*VisualizationView.onGraphInteraction()* method:

[source,typescript]
----
onGraphInteraction: GraphInteractionCallBack = (event, properties) => {
if (event == NODE_ON_CANVAS_CREATE) {
if (properties == null) {
throw new Error(
'A property map with id, name, and labels keys are required'
)
}
const id = properties['id']
const name = properties['name']
const variableName = `node${id}`
const labels = (properties['labels'] as string[]).map(label => `\`${label}\``).join(':')
const query = `CREATE (${variableName}:${labels} { id: ${id}, name: "${name}" });`
this.props.bus.self(
CYPHER_REQUEST,
{
query,
params: { labels, id, name },
queryType: NEO4J_BROWSER_USER_ACTION_QUERY
},
(response: any) => {
if (!response.success) {
throw new Error(response.error)
}
}
)
}
}
----

The complete implementation is in https://github.com/QubitPi/neo4j-browser/pull/7[this PR] as a reference
14 changes: 9 additions & 5 deletions docs/modules/ROOT/pages/internals/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,23 @@ The Neo4J Browser is logically composed of 2 parts:
2. A user-interface that combines the graph rendering (supported by the graphing module), database, and user
interaction together

The graphing is based on D3 but pretty much developed from scratch by building everything on top of D3 and implementing
its own layout, coloring, and link drawing. For example, the calculation of arrow (e.g. node links) between nodes
uses some very complicated math along with very prelimiary MDN standard basic shap specification.
The graphing is based on D3 and implements its own layout, coloring, and link drawing. For example, the calculation of
arrow, i.e. links, between nodes uses some
https://github.com/QubitPi/neo4j-browser/blob/master/src/neo4j-arc/graph-visualization/utils/ArcArrow.ts[very complicated math]
along with very prelimiary MDN standard basic shap specification.

[WARNING]
====
We will not have any TypeDoc documentation, because the TypeScript version used in Neo4J Browser is not supprted by it
We will not have any TypeDoc documentation, because the TypeScript version used in Neo4J Browser is not supprted by the
TypeDoc
====

== Component Diagram (WIP)

image:neo4j-browser.png[width=300]
image:neo4j-browser.png[width=900]

* The orange triangle labled with "On Canvas Interaction" is discussed in detail in
link:internals/graph-interactions[graph interactions] section
* Sentry.io is initialized in the top index.tsx
* AppInit.tsx is responsible for several initializations:
+
Expand Down
18 changes: 18 additions & 0 deletions e2e_tests/integration/viz.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,4 +241,22 @@ describe('Viz rendering', () => {
cy.get('#svg-vis').trigger('wheel', { deltaY: 3000, shiftKey: true })
cy.get(`[aria-label="zoom-out"]`).should('be.disabled')
})

it('can create a new node by double clicking the canvas', () => {
cy.executeCommand(':clear')
cy.executeCommand(`CREATE (a:TestLabel {name: 'testNode'}) RETURN a`, {
parseSpecialCharSequences: false
})

cy.get('[data-testid="graphCanvas"]')
.trigger('click', 200, 200, { force: true })
.trigger('dblclick', 200, 200, { force: true })

cy.get('[data-testid="nodeGroups"]', { timeout: 5000 }).contains('New Node')
cy.get('[data-testid="vizInspector"]', { timeout: 5000 }).contains(
'Undefined'
)

cy.executeCommand('MATCH (n) DETACH DELETE n')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ import { withBus } from 'react-suber'
import { Action, Dispatch } from 'redux'
import { Bus } from 'suber'

import { GraphModel, GraphVisualizer } from 'neo4j-arc/graph-visualization'
import {
GraphInteractionCallBack,
GraphModel,
GraphVisualizer,
NODE_ON_CANVAS_CREATE
} from 'neo4j-arc/graph-visualization'

import { StyledVisContainer } from './VisualizationView.styled'
import { resultHasTruncatedFields } from 'browser/modules/Stream/CypherFrame/helpers'
Expand Down Expand Up @@ -271,6 +276,39 @@ LIMIT ${maxNewNeighbours}`
this.autoCompleteRelationships([], this.graph.nodes(), true)
}

onGraphInteraction: GraphInteractionCallBack = (event, properties) => {
if (event == NODE_ON_CANVAS_CREATE) {
if (properties == null) {
throw new Error(
'A property map with id, name, and labels keys are required'
)
}

const id = properties['id']
const name = properties['name']
const variableName = `node${id}`
const labels = (properties['labels'] as string[])
.map(label => `\`${label}\``)
.join(':')

const query = `CREATE (${variableName}:${labels} { id: ${id}, name: "${name}" });`

this.props.bus.self(
CYPHER_REQUEST,
{
query,
params: { labels, id, name },
queryType: NEO4J_BROWSER_USER_ACTION_QUERY
},
(response: any) => {
if (!response.success) {
throw new Error(response.error)
}
}
)
}
}

render(): React.ReactNode {
if (!this.state.nodes.length) return null

Expand Down Expand Up @@ -309,6 +347,7 @@ LIMIT ${maxNewNeighbours}`
OverviewPaneOverride={OverviewPane}
useGeneratedDefaultColors={false}
initialZoomToFit
onGraphInteraction={this.onGraphInteraction}
/>
</StyledVisContainer>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@ import {
} from '../../utils/mapper'
import { Visualization } from './visualization/Visualization'

export const NODE_ON_CANVAS_CREATE = 'NODE_ON_CANVAS_CREATE'

export type GraphInteraction =
| 'NODE_EXPAND'
| 'NODE_UNPINNED'
| 'NODE_DISMISSED'
| 'NODE_ON_CANVAS_CREATE'
| typeof NODE_ON_CANVAS_CREATE

export type GraphInteractionCallBack = (
event: GraphInteraction,
Expand Down Expand Up @@ -225,6 +229,30 @@ export class GraphEventHandlerModel {
this.deselectItem()
}

onCanvasDblClicked(): void {
const maxId: number = Math.max(
...this.graph.nodes().map(node => parseInt(node.id))
)
const newId = maxId + 1

this.graph.addNodes([
new NodeModel(
newId.toString(),
['Undefined'],
{ name: 'New Node' },
{ name: 'string' }
)
])
this.visualization.update({ updateNodes: true, updateRelationships: true })
this.graphModelChanged()

this.onGraphInteraction(NODE_ON_CANVAS_CREATE, {
id: newId,
name: 'New Node',
labels: ['Undefined']
})
}

onItemMouseOut(): void {
this.onItemMouseOver({
type: 'canvas',
Expand All @@ -245,6 +273,7 @@ export class GraphEventHandlerModel {
.on('relMouseOut', this.onItemMouseOut.bind(this))
.on('relationshipClicked', this.onRelationshipClicked.bind(this))
.on('canvasClicked', this.onCanvasClicked.bind(this))
.on('canvasDblClicked', this.onCanvasDblClicked.bind(this))
.on('nodeClose', this.nodeClose.bind(this))
.on('nodeClicked', this.nodeClicked.bind(this))
.on('nodeDblClicked', this.nodeDblClicked.bind(this))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,19 @@ export class Visualization {
.attr('width', '100%')
.attr('height', '100%')
.attr('transform', 'scale(1)')
.attr('data-testid', 'graphCanvas')
// Background click event
// Check if panning is ongoing
.on('click', () => {
if (!this.draw) {
return this.trigger('canvasClicked')
}
})
.on('dblclick', () => {
if (!this.draw) {
return this.trigger('canvasDblClicked')
}
})

this.container = this.baseGroup.append('g')
this.geometry = new GraphGeometryModel(style)
Expand Down Expand Up @@ -166,6 +172,7 @@ export class Visualization {
const nodeGroups = this.container
.selectAll<SVGGElement, NodeModel>('g.node')
.attr('transform', d => `translate(${d.x},${d.y})`)
.attr('data-testid', 'nodeGroups')

nodeRenderer.forEach(renderer => nodeGroups.call(renderer.onTick, this))

Expand Down
3 changes: 3 additions & 0 deletions src/neo4j-arc/graph-visualization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,6 @@ export { measureText } from './utils/textMeasurement'
export { GraphVisualizer } from './GraphVisualizer/GraphVisualizer'
export type { DetailsPaneProps } from './GraphVisualizer/DefaultPanelContent/DefaultDetailsPane'
export type { OverviewPaneProps } from './GraphVisualizer/DefaultPanelContent/DefaultOverviewPane'

export { NODE_ON_CANVAS_CREATE } from './GraphVisualizer/Graph/GraphEventHandlerModel'
export type { GraphInteractionCallBack } from './GraphVisualizer/Graph/GraphEventHandlerModel'

0 comments on commit c25b0e8

Please sign in to comment.