diff --git a/src/app.js b/src/app.js
index 8124bfd..7403643 100644
--- a/src/app.js
+++ b/src/app.js
@@ -154,7 +154,6 @@ class App extends Component {
if (!this.props.sourceNode.id && !this.props.targetNode.id)
return;
const title =
- 'hetmech · ' +
(this.props.sourceNode.name || '___') +
' → ' +
(this.props.targetNode.name || '___');
diff --git a/src/node-results.js b/src/node-results.js
index e36c740..9d74aec 100644
--- a/src/node-results.js
+++ b/src/node-results.js
@@ -9,6 +9,8 @@ import { Tooltip } from './tooltip.js';
import { TextButton } from './buttons.js';
import { DynamicField } from './dynamic-field.js';
import { CollapsibleSection } from './collapsible-section.js';
+import { shortenUrl } from './util.js';
+import { sortCustom } from './util.js';
// node results section component
// details about source/target nodes
@@ -68,38 +70,26 @@ class TableFull extends Component {
// display row entries
rows() {
- // explicitly specify and order primary fields
- let fields = ['name', 'metanode', 'identifier', 'source'];
-
- if (this.state.showMore) {
- // get 'extra fields' from node 'data' field
- let extraFields = Object.keys(this.props.node.data);
- // remove unnecessary fields
- extraFields.splice(extraFields.indexOf('source'), 1);
- extraFields.splice(extraFields.indexOf('url'), 1);
- // sort extra fields alphabetically
- extraFields = extraFields.sort();
- // add 'id' to beginning of extra fields
- extraFields.unshift('id');
- // append 'extraFields' to primary 'fields'
- fields = fields.concat(extraFields);
+ // helper text when user hovers over given field
+ let tooltipText = {};
+ if (this.props.hetioDefinitions.properties) {
+ tooltipText = {
+ ...tooltipText,
+ ...this.props.hetioDefinitions.properties.common,
+ ...this.props.hetioDefinitions.properties.nodes
+ };
}
+ tooltipText = { ...tooltipText, ...this.props.hetmechDefinitions };
- // determine contents of first and second column for each row entry
- return fields.map((field, index) => {
- // set first col to field name
- const firstCol = field;
- // default second col to field value in node
- let secondCol = this.props.node[field];
- if (secondCol === undefined)
- secondCol = this.props.node.data[field];
- if (secondCol === undefined)
- secondCol = '';
-
+ // get primary fields from top level of node
+ let primaryFields = ['name', 'metanode', 'source', 'identifier', 'id'];
+ // get first/second column text (key/value) for each field
+ primaryFields = primaryFields.map((field) => {
// handle special field cases
+ let specialSecondCol;
if (field === 'metanode') {
// make text with metanode chip
- secondCol = (
+ specialSecondCol = (
<>
{this.props.node[field]}
@@ -110,32 +100,70 @@ class TableFull extends Component {
const linkUrl = this.props.node.url || this.props.node.data.url || '';
let linkText = this.props.node.data.source || linkUrl;
linkText = shortenUrl(linkText);
- secondCol = (
+ specialSecondCol = (
{linkText}
);
}
+ // get first/second column text (key/value) for each field
+ return {
+ firstCol: field,
+ secondCol: specialSecondCol || String(this.props.node[field]),
+ tooltipText: tooltipText[field]
+ };
+ });
+ // remove id and identifier if table not expanded
+ if (!this.state.showMore) {
+ primaryFields.splice(
+ primaryFields.findIndex((field) => field.firstCol === 'id'),
+ 1
+ );
+ primaryFields.splice(
+ primaryFields.findIndex((field) => field.firstCol === 'identifier'),
+ 1
+ );
+ }
- // helper text when user hovers over given field
- let tooltipText = {};
- if (this.props.hetioDefinitions.properties) {
- tooltipText = {
- ...tooltipText,
- ...this.props.hetioDefinitions.properties.common,
- ...this.props.hetioDefinitions.properties.nodes
- };
- }
- tooltipText = { ...tooltipText, ...this.props.hetmechDefinitions };
+ // get 'extra fields' from node 'data' field
+ let extraFields = [];
+ if (this.state.showMore) {
+ extraFields = Object.keys(this.props.node.data);
+ // remove source and url, since they are combined and added to
+ // primary fields above
+ extraFields.splice(extraFields.indexOf('source'), 1);
+ extraFields.splice(extraFields.indexOf('url'), 1);
+ // get first/second column text (key/value) for each field
+ extraFields = extraFields.map((field) => ({
+ firstCol: field,
+ secondCol: String(this.props.node.data[field]),
+ tooltipText: tooltipText[field]
+ }));
+ }
- // return row entry
+ // combine primary and extra fields
+ let fields = primaryFields.concat(extraFields);
+
+ // display fields in custom order
+ const order = [
+ 'name',
+ 'metanode',
+ 'source',
+ 'description',
+ 'identifier',
+ 'id'
+ ];
+ fields = sortCustom(fields, order, 'firstCol');
+
+ // make rows from fields
+ return fields.map((field, index) => {
return (
-
- {firstCol}
+ |
+ {field.firstCol}
|
-
+
|
);
@@ -186,12 +214,3 @@ class TableEmpty extends Component {
);
}
}
-
-// remove unnecessary preceding 'www.' and etc from url
-function shortenUrl(url) {
- const remove = ['http://', 'https://', 'www.'];
- for (const str of remove)
- url = url.replace(str, '');
-
- return url;
-}
diff --git a/src/path-graph.css b/src/path-graph.css
index d642b8f..426ee56 100644
--- a/src/path-graph.css
+++ b/src/path-graph.css
@@ -6,12 +6,28 @@
border: solid #e0e0e0 1px;
position: absolute;
}
-#graph * {
- user-select: none;
-}
.graph_dimensions {
margin: 0 10px;
}
.graph_expand_collapse_button {
margin: 0 !important;
}
+#graph_overlay {
+ pointer-events: none;
+}
+#graph_info {
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ font-size: 8px;
+}
+.graph_info_header {
+ margin-top: 10px;
+ font-weight: 500;
+}
+#graph_info_table {
+ margin-top: 5px;
+}
+#graph_info_table td {
+ height: 20px;
+}
diff --git a/src/path-graph.js b/src/path-graph.js
index bfab5da..d575b48 100644
--- a/src/path-graph.js
+++ b/src/path-graph.js
@@ -15,6 +15,8 @@ import { CollapsibleSection } from './collapsible-section.js';
import { NumberBox } from './number-box.js';
import { TextButton } from './buttons.js';
import { downloadSvg } from './util.js';
+import { Tooltip } from './tooltip.js';
+import { sortCustom } from './util.js';
import './path-graph.css';
// graph settings
@@ -36,7 +38,7 @@ const edgeSpreadDistance = 20;
const edgeSpreadAngle = (45 / 360) * 2 * Math.PI;
const inkColor = '#424242';
const backgroundColor = '#fafafa';
-const highlightColor = '#02b3e4';
+const highlightColor = '#B3E8F7';
// path graph section component
export class PathGraph extends Component {
@@ -49,6 +51,7 @@ export class PathGraph extends Component {
this.state.height = maxHeight;
this.state.nodeCount = 0;
this.state.edgeCount = 0;
+ this.state.selectedElement = null;
this.graph = React.createRef();
@@ -58,6 +61,7 @@ export class PathGraph extends Component {
this.setWidth = this.setWidth.bind(this);
this.setHeight = this.setHeight.bind(this);
this.setGraphCounts = this.setGraphCounts.bind(this);
+ this.setSelectedElement = this.setSelectedElement.bind(this);
}
// when component mounts
@@ -140,8 +144,48 @@ export class PathGraph extends Component {
});
}
+ // sets the selected node/edge
+ setSelectedElement(element) {
+ this.setState({ selectedElement: element });
+ }
+
// display component
render() {
+ let info = '';
+ if (this.state.selectedElement) {
+ if (this.state.selectedElement.elementType === 'node') {
+ info = (
+ <>
+ Selected Node
+
+ >
+ );
+ }
+ if (this.state.selectedElement.elementType === 'edge') {
+ info = (
+ <>
+ Selected Edge
+
+ >
+ );
+ }
+ }
return (
{this.state.nodeCount} nodes, {this.state.edgeCount} edges
@@ -230,11 +274,13 @@ export class PathGraph extends Component {
+ {info}
);
@@ -253,8 +299,10 @@ export class Graph extends Component {
this.fitView = this.fitView.bind(this);
this.resetView = this.resetView.bind(this);
this.onSimulationTick = this.onSimulationTick.bind(this);
+ this.onNodeEdgeClick = this.onNodeEdgeClick.bind(this);
this.onNodeDragStart = this.onNodeDragStart.bind(this);
this.onNodeDragEnd = this.onNodeDragEnd.bind(this);
+ this.onViewClick = this.onViewClick.bind(this);
}
// when component mounts
@@ -275,7 +323,7 @@ export class Graph extends Component {
// positions/velocities/etc
for (const newNode of this.state.data.nodes) {
for (const oldNode of prevState.data.nodes) {
- if (newNode.id === oldNode.id) {
+ if (newNode.neo4j_id === oldNode.neo4j_id) {
newNode.x = oldNode.x;
newNode.y = oldNode.y;
newNode.fx = oldNode.fx;
@@ -320,7 +368,7 @@ export class Graph extends Component {
d3
.forceLink()
.distance(nodeDistance)
- .id((d) => d.id)
+ .id((d) => d.neo4j_id)
)
.force(
'collide',
@@ -340,6 +388,9 @@ export class Graph extends Component {
.on('zoom', this.onViewZoom);
svg.call(viewZoomHandler);
+ // handle clicks on background
+ svg.on('click', this.onViewClick);
+
// create handler for dragging nodes
const nodeDragHandler = d3
.drag()
@@ -388,8 +439,8 @@ export class Graph extends Component {
const angle = Math.atan2(y2 - y1, x2 - x1);
// get radius of source/target nodes
- const sourceRadius = nodeRadius - 1;
- let targetRadius = nodeRadius - 1;
+ const sourceRadius = nodeRadius - 0.25;
+ let targetRadius = nodeRadius - 0.25;
// increase target node radius to bring tip of arrowhead out of circle
if (d.directed)
targetRadius += edgeArrowSize / 4;
@@ -448,8 +499,8 @@ export class Graph extends Component {
let angle = Math.atan2(y2 - y1, x2 - x1);
// get radius of source/target nodes
- const sourceRadius = nodeRadius - 1;
- let targetRadius = nodeRadius - 1;
+ const sourceRadius = nodeRadius - 0.25;
+ let targetRadius = nodeRadius - 0.25;
// increase target node radius to bring tip of arrowhead out of circle
if (d.directed)
targetRadius += edgeArrowSize / 4;
@@ -585,6 +636,30 @@ export class Graph extends Component {
});
}
+ // when node or edge clicked by user
+ onNodeEdgeClick(d) {
+ d3.event.stopPropagation();
+
+ if (!d.selected) {
+ this.deselectAll();
+ d.selected = true;
+ } else
+ this.deselectAll();
+
+ this.updateNodeCircles();
+ this.updateEdgeLines();
+
+ this.props.setSelectedElement(d);
+ }
+
+ // deselect all elements
+ deselectAll() {
+ for (const node of this.state.data.nodes)
+ node.selected = undefined;
+ for (const edge of this.state.data.edges)
+ edge.selected = undefined;
+ }
+
// when node dragged by user
onNodeDragStart() {
this.state.simulation.alphaTarget(1).restart();
@@ -601,6 +676,14 @@ export class Graph extends Component {
this.state.simulation.alphaTarget(0).restart();
}
+ // when view/background is clicked by user
+ onViewClick() {
+ this.deselectAll();
+ this.updateNodeCircles();
+ this.updateEdgeLines();
+ this.props.setSelectedElement(null);
+ }
+
// when view panned or zoomed by user
onViewZoom() {
d3.select('#graph_view').attr('transform', d3.event.transform);
@@ -631,8 +714,7 @@ export class Graph extends Component {
.attr('fill', 'none')
.attr('stroke', highlightColor)
.attr('stroke-width', edgeArrowSize)
- .attr('stroke-linecap', 'square')
- .attr('opacity', 0.35);
+ .attr('stroke-linecap', 'square');
edgeLineHighlights.exit().remove();
}
@@ -647,6 +729,7 @@ export class Graph extends Component {
edgeLines
.enter()
.append('path')
+ .on('click', this.onNodeEdgeClick)
.merge(edgeLines)
.attr('class', 'graph_edge_line')
.attr('marker-end', (d) => {
@@ -657,7 +740,14 @@ export class Graph extends Component {
})
.attr('fill', 'none')
.attr('stroke', inkColor)
- .attr('stroke-width', edgeThickness);
+ .attr('stroke-width', edgeThickness)
+ .style('stroke-dasharray', (d) => {
+ if (d.selected)
+ return edgeThickness * 2 + ' ' + edgeThickness;
+ else
+ return 'none';
+ })
+ .style('cursor', 'pointer');
edgeLines.exit().remove();
}
@@ -672,13 +762,15 @@ export class Graph extends Component {
edgeLabels
.enter()
.append('text')
+ .on('click', this.onNodeEdgeClick)
.merge(edgeLabels)
.attr('class', 'graph_edge_label')
.attr('font-size', edgeFontSize)
.attr('font-weight', 500)
.attr('text-anchor', 'middle')
- .attr('pointer-events', 'none')
+ .attr('user-select', 'none')
.attr('fill', inkColor)
+ .style('cursor', 'pointer')
.text((d) => d.kind);
edgeLabels.exit().remove();
@@ -701,8 +793,7 @@ export class Graph extends Component {
.attr('r', nodeRadius)
.attr('fill', 'none')
.attr('stroke', highlightColor)
- .attr('stroke-width', edgeArrowSize)
- .attr('opacity', 0.35);
+ .attr('stroke-width', edgeArrowSize);
nodeCircleHighlights.exit().remove();
}
@@ -717,12 +808,21 @@ export class Graph extends Component {
nodeCircles
.enter()
.append('circle')
+ .call(this.state.nodeDragHandler)
+ .on('click', this.onNodeEdgeClick)
.merge(nodeCircles)
.attr('class', 'graph_node_circle')
.attr('r', nodeRadius)
.attr('fill', (d) => this.getNodeFillColor(d.metanode))
- .style('cursor', 'pointer')
- .call(this.state.nodeDragHandler);
+ .attr('stroke', (d) => {
+ if (d.selected)
+ return inkColor;
+ else
+ return 'none';
+ })
+ .attr('stroke-width', edgeThickness)
+ .style('stroke-dasharray', edgeThickness * 2 + ' ' + edgeThickness)
+ .style('cursor', 'pointer');
nodeCircles.exit().remove();
}
@@ -763,10 +863,10 @@ export class Graph extends Component {
.style('color', (d) => this.getNodeTextColor(d.metanode))
.style('word-break', 'break-word')
.html((d) => {
- if (d.name.length > nodeCharLimit)
- return d.name.substr(0, nodeCharLimit - 3) + '...';
+ if (d.data.name.length > nodeCharLimit)
+ return d.data.name.substr(0, nodeCharLimit - 3) + '...';
else
- return d.name;
+ return d.data.name;
});
nodeLabels.exit().remove();
@@ -789,13 +889,13 @@ export class Graph extends Component {
const data = this.state.data;
data.nodes.forEach((node) => {
- if (node.id === data.source) {
+ if (node.neo4j_id === data.source_neo4j_id) {
if (!node.x && !node.fx)
node.fx = -nodeDistance * 2;
if (!node.y && !node.fy)
node.fy = 0;
}
- if (node.id === data.target) {
+ if (node.neo4j_id === data.target_neo4j_id) {
if (!node.x && !node.fx)
node.fx = nodeDistance * 2;
if (!node.y && !node.fy)
@@ -825,7 +925,12 @@ export class Graph extends Component {
// construct graph object with relevant properties for each node/edge
assembleGraph(pathQueries) {
// empty graph object
- const graph = { source: null, target: null, nodes: [], edges: [] };
+ const graph = {
+ source_neo4j_id: null,
+ target_neo4j_id: null,
+ nodes: [],
+ edges: []
+ };
// if null explicitly provided as argument, return empty graph object
if (pathQueries === null)
@@ -841,8 +946,8 @@ export class Graph extends Component {
// get source/target nodes from first path in pathQueries
const firstPath = pathQueries[0].paths[0];
- graph.source = firstPath.node_ids[0];
- graph.target = firstPath.node_ids[firstPath.node_ids.length - 1];
+ graph.source_neo4j_id = firstPath.node_ids[0];
+ graph.target_neo4j_id = firstPath.node_ids[firstPath.node_ids.length - 1];
// loop through all paths in pathQueries
for (const pathQuery of pathQueries) {
@@ -853,14 +958,19 @@ export class Graph extends Component {
// loop through nodes in path
for (const nodeId of path.node_ids) {
- const existingNode = graph.nodes.find((node) => node.id === nodeId);
+ const node = pathQuery.nodes[nodeId];
+ const existingNode = graph.nodes.find(
+ (existing) => existing.neo4j_id === node.neo4j_id
+ );
if (!existingNode) {
// if node hasn't been added to graph yet, add it
graph.nodes.push({
- id: nodeId,
- metanode: pathQuery.nodes[nodeId].metanode,
- name: pathQuery.nodes[nodeId].data.name,
- highlighted: path.highlighted
+ // copy all properties of node
+ ...node,
+ // add highlight property
+ highlighted: path.highlighted,
+ // mark as node
+ elementType: 'node'
});
} else if (path.highlighted)
// if node already in graph, still update highlight status
@@ -869,21 +979,27 @@ export class Graph extends Component {
// loop through edges in path
for (const relId of path.rel_ids) {
+ const edge = pathQuery.relationships[relId];
const existingEdge = graph.edges.find(
- (edge) =>
- edge.source === pathQuery.relationships[relId].source_neo4j_id &&
- edge.target === pathQuery.relationships[relId].target_neo4j_id &&
- edge.kind === pathQuery.relationships[relId].kind &&
- edge.directed === pathQuery.relationships[relId].directed
+ (existing) =>
+ existing.source_neo4j_id === edge.source_neo4j_id &&
+ existing.target_neo4j_id === edge.target_neo4j_id &&
+ existing.kind === edge.kind &&
+ existing.directed === edge.directed
);
if (!existingEdge) {
// if edge hasn't been added to graph yet, add it
graph.edges.push({
- source: pathQuery.relationships[relId].source_neo4j_id,
- target: pathQuery.relationships[relId].target_neo4j_id,
- kind: pathQuery.relationships[relId].kind,
- directed: pathQuery.relationships[relId].directed,
- highlighted: path.highlighted
+ // copy all properties of edge
+ ...edge,
+ // set duplicate properties "source" and "target" because d3
+ // needs them (with those names) to create links between nodes
+ source: edge.source_neo4j_id,
+ target: edge.target_neo4j_id,
+ // add highlight property
+ highlighted: path.highlighted,
+ // mark as edge
+ elementType: 'edge'
});
} else if (path.highlighted)
// if edge already in graph, still update highlight status
@@ -904,8 +1020,10 @@ export class Graph extends Component {
for (const edgeBin of edgeBins) {
const match = edgeBin.find(
(edgeB) =>
- (edgeA.source === edgeB.source && edgeA.target === edgeB.target) ||
- (edgeA.source === edgeB.target && edgeA.target === edgeB.source)
+ (edgeA.source_neo4j_id === edgeB.source_neo4j_id &&
+ edgeA.target_neo4j_id === edgeB.target_neo4j_id) ||
+ (edgeA.source_neo4j_id === edgeB.target_neo4j_id &&
+ edgeA.target_neo4j_id === edgeB.source_neo4j_id)
);
// if matching bin found, add edge to it
if (match) {
@@ -924,7 +1042,7 @@ export class Graph extends Component {
// for each edge in bin, assign coincident "offset", a value between
// -1 and 1 used for drawing, where 0 is straight line, negative is curve
// on one side, and positive is curve on other side
- const firstSource = edgeBin[0].source;
+ const firstSource = edgeBin[0].source_neo4j_id;
for (let index = 0; index < edgeBin.length; index++) {
// default offset to 0
let offset = 0;
@@ -932,34 +1050,20 @@ export class Graph extends Component {
offset = -0.5 + index / (edgeBin.length - 1);
// if edge source/target order in reverse order as rest of bin,
// invert offset
- if (edgeBin[index].source !== firstSource)
+ if (edgeBin[index].source_neo4j_id !== firstSource)
offset *= -1;
edgeBin[index].coincidentOffset = offset;
}
}
- // sort by key === true last
- function compareBoolean(a, b, key) {
- if (a[key] === true && b[key] !== true)
- return 1;
- else if (a[key] !== true && b[key] === true)
- return -1;
- else
- return 0;
- }
-
- // sort lists by highlighted last to ensure higher z-index
- graph.nodes.sort((a, b) => compareBoolean(a, b, 'highlighted'));
- graph.edges.sort((a, b) => compareBoolean(a, b, 'highlighted'));
-
// put source and target node at end of list to ensure highest z-index
const sourceNodeIndex = graph.nodes.findIndex(
- (node) => node.id === graph.source
+ (node) => node.neo4j_id === graph.source_neo4j_id
);
if (sourceNodeIndex !== -1)
graph.nodes.push(graph.nodes.splice(sourceNodeIndex, 1)[0]);
const targetNodeIndex = graph.nodes.findIndex(
- (node) => node.id === graph.target
+ (node) => node.neo4j_id === graph.target_neo4j_id
);
if (targetNodeIndex !== -1)
graph.nodes.push(graph.nodes.splice(targetNodeIndex, 1)[0]);
@@ -979,6 +1083,33 @@ export class Graph extends Component {
left = minLeft;
}
+ // title text
+ const title =
+ (this.props.sourceNode.name || '___') +
+ ' → ' +
+ (this.props.targetNode.name || '___');
+
+ // description text
+ const description = [
+ 'Graph visualization of the connectivity between ',
+ this.props.sourceNode.name || '___',
+ ' (',
+ this.props.sourceNode.metanode || '___',
+ ') and ',
+ this.props.targetNode.name || '___',
+ ' (',
+ this.props.targetNode.metanode || '___',
+ '). ',
+ '\n\n',
+ 'Created at ',
+ window.location.href,
+ '\n\n',
+ 'This subgraph of Hetionet v1.0 was created from paths between the ',
+ 'specified source/target nodes that occurred more than expected ',
+ 'by chance. ',
+ 'See https://het.io for more information. '
+ ].join('');
+
return (