Last active
April 4, 2023 15:43
-
-
Save tonilastre/71f6759621886ae8f307656cb0b9ea32 to your computer and use it in GitHub Desktop.
Run Cypher queries from browser and visualize graph results with Memgraph Orb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8" /> | |
<title>Javascript Browser Example | Memgraph</title> | |
<script src="https://cdn.jsdelivr.net/npm/neo4j-driver"></script> | |
<script src="https://unpkg.com/@memgraph/orb/dist/browser/orb.min.js"></script> | |
<link rel="preconnect" href="https://fonts.googleapis.com"> | |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
<link href="https://fonts.googleapis.com/css2?family=Roboto&family=Roboto+Mono&display=swap" rel="stylesheet"> | |
<style> | |
body { | |
font-size: 1rem; | |
font-family: 'Roboto', sans-serif; | |
font-weight: 400; | |
line-height: 1.5; | |
} | |
header { | |
padding: 0.5rem; | |
display: flex; | |
flex-direction: column; | |
gap: 0.25rem; | |
background-color: #ddd; | |
} | |
main { | |
padding: 0.5rem; | |
flex-grow: 1; | |
} | |
input { | |
width: 100%; | |
padding: 0.5rem 1.5rem 0.5rem 0.5rem; | |
border: 1px solid #e6e6e6; | |
border-radius: 5px; | |
background-color: #fff; | |
font-size: 1rem; | |
font-family: 'Roboto Mono', monospace; | |
color: #231f20; | |
} | |
input:focus { | |
outline: none; | |
} | |
button { | |
width: 5rem; | |
background: #fb6e00; | |
color: white; | |
border: 0; | |
border-radius: 5px; | |
} | |
button:hover { | |
background: red; | |
cursor: pointer; | |
} | |
.container { | |
display: flex; | |
flex-direction: column; | |
position: absolute; | |
inset: 0; | |
} | |
.connection-icon { | |
height: 10px; | |
} | |
.connection-closed { | |
color: red; | |
} | |
.connection-opened { | |
color: green; | |
} | |
.connection[data-is-opened="true"] .connection-icon { | |
fill: green; | |
} | |
.connection[data-is-opened="false"] .connection-icon { | |
fill: red; | |
} | |
.connection[data-is-opened="true"] .connection-closed { | |
display: none; | |
} | |
.connection[data-is-opened="false"] .connection-closed { | |
display: inline; | |
} | |
.connection[data-is-opened="true"] .connection-opened { | |
display: inline; | |
} | |
.connection[data-is-opened="false"] .connection-opened { | |
display: none; | |
} | |
.setup { | |
display: flex; | |
gap: 0.5rem; | |
} | |
.hidden { | |
display: none; | |
} | |
#error { | |
color: red; | |
} | |
#info { | |
padding: 0.5rem; | |
font-family: 'Roboto Mono', monospace; | |
z-index: 9999; | |
position: absolute; | |
inset: auto 0 0 0; | |
background-color: white; | |
border-top: 1px solid #e6e6e6; | |
word-wrap: break-word; | |
} | |
#info > strong { | |
color: #fb6e00; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<header> | |
<div class="connection" data-is-opened="false"> | |
<svg class="connection-icon" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"> | |
<circle cx="50" cy="50" r="50" /> | |
</svg> | |
<span class="connection-closed">Disconnected from <span class="bolt-url">localhost:7687</span>. Please check that your Memgraph is running and listening on <span class="bolt-url">localhost:7687</span></span> | |
<span class="connection-opened">Connected to <span class="bolt-url">localhost:7687</span></span> | |
</div> | |
<div class="setup"> | |
<input type="text" value="MATCH (n)-[e]->(m) RETURN n, e, m;" placeholder="Add your Cypher query here and click run..." /> | |
<button>Run</button> | |
</div> | |
</header> | |
<main> | |
<div id="info" class="hidden"></div> | |
<div id="error" class="hidden"></div> | |
<div id="graph"></div> | |
</main> | |
</div> | |
<script> | |
const inputElem = document.querySelector("input"); | |
const buttonElem = document.querySelector("button"); | |
const graphElem = document.getElementById("graph"); | |
const errorElem = document.getElementById("error"); | |
const infoElem = document.getElementById("info"); | |
const connectionElem = document.querySelector(".connection"); | |
const BOLT_HOSTNAME = 'localhost:7687'; | |
const orb = new Orb.Orb(graphElem); | |
const driver = neo4j.driver(`bolt://${BOLT_HOSTNAME}`, neo4j.auth.basic("", "")); | |
// Using Orb to render nodes and edges | |
const renderGraph = ({ nodes, edges }) => { | |
orb.data.setDefaultStyle(getGraphStyling({ nodes, edges })); | |
orb.data.setup({ nodes, edges }); | |
orb.view.render(() => { | |
orb.view.recenter(); | |
}); | |
orb.events.on(Orb.OrbEventType.NODE_CLICK, (event) => { | |
infoElem.innerHTML = `<strong>Node clicked: </strong>` + JSON.stringify(event.node.data) | |
infoElem.classList.remove('hidden'); | |
}); | |
orb.events.on(Orb.OrbEventType.EDGE_CLICK, (event) => { | |
infoElem.innerHTML = `<strong>Edge clicked: </strong>` + JSON.stringify(event.edge.data) | |
infoElem.classList.remove('hidden'); | |
}); | |
orb.events.on(Orb.OrbEventType.MOUSE_CLICK, (event) => { | |
if (!event.subject) { | |
infoElem.classList.add('hidden'); | |
} | |
}); | |
}; | |
// Using Orb to get a default node/edge styling | |
const getGraphStyling = ({ nodes, edges }) => { | |
const colorByLabels = {}; | |
nodes.forEach((node) => { | |
const labels = node.labels.join(':'); | |
if (!colorByLabels[labels]) { | |
colorByLabels[labels] = Orb.Color.getRandomColor(); | |
} | |
}); | |
return { | |
getNodeStyle(node) { | |
const labels = node.data.labels.join(':'); | |
const name = node.data.properties?.title ?? node.data.properties?.name ?? labels; | |
return { | |
size: 5, | |
color: colorByLabels[labels] ?? Orb.Color.getRandomColor(), | |
label: name, | |
}; | |
}, | |
getEdgeStyle(edge) { | |
return { | |
width: 0.3, | |
color: '#ababab', | |
label: edges.length < 50 ? edge.type : '', | |
}; | |
}, | |
}; | |
}; | |
const extractGraphFromMgResult = (mgResult) => { | |
const nodeById = {}; | |
const edgeById = {}; | |
mgResult.records.forEach((record) => { | |
Object.values(record).forEach((value) => { | |
if (_isMemgraphNode(value)) { | |
nodeById[value.id] = value; | |
} | |
if (_isMemgraphEdge(value)) { | |
edgeById[value.id] = value; | |
} | |
if (_isMemgraphPath(value)) { | |
value.nodes.forEach((node) => nodeById[node.id] = node); | |
value.relationships.forEach((edge) => edgeById[edge.id] = edge); | |
} | |
}); | |
}); | |
return { nodes: Object.values(nodeById), edges: Object.values(edgeById) }; | |
}; | |
// Parsing functions to handle Neo4j/Memgraph result objects | |
const _isNumber = (value) => typeof value === 'number'; | |
const _isString = (value) => typeof value === 'string'; | |
const _isObject = (value) => typeof value === 'object' && value !== null; | |
const _isArray = (value) => Array.isArray(value); | |
const _MG_NODE = 'node'; | |
const _MG_EDGE = 'relationship'; | |
const _MG_PATH = 'path'; | |
const _isMemgraphNode = (field) => { | |
return ( | |
_isObject(field) && | |
_isNumber(field.id) && | |
field.type === _MG_NODE | |
); | |
}; | |
const _isMemgraphEdge = (field) => { | |
return ( | |
_isObject(field) && | |
_isNumber(field.id) && | |
_isNumber(field.start) && | |
_isNumber(field.end) && | |
field.type === _MG_EDGE | |
); | |
}; | |
const _isMemgraphPath = (field) => { | |
return ( | |
_isObject(field) && | |
_isArray(field.nodes) && | |
field.nodes.every((node) => _isMemgraphNode(node)) && | |
_isArray(field.relationships) && | |
field.relationships.every((edge) => _isMemgraphEdge(edge)) && | |
field.type === _MG_PATH | |
); | |
}; | |
const _toMemgraphNode = (neo4jNode) => { | |
return { | |
id: parseNeo4jField(neo4jNode.identity), | |
labels: parseNeo4jField(neo4jNode.labels), | |
properties: parseNeo4jField(neo4jNode.properties), | |
type: _MG_NODE, | |
}; | |
}; | |
const _toMemgraphEdge = (neo4jEdge) => { | |
return { | |
id: parseNeo4jField(neo4jEdge.identity), | |
start: parseNeo4jField(neo4jEdge.start), | |
end: parseNeo4jField(neo4jEdge.end), | |
label: parseNeo4jField(neo4jEdge.type), | |
properties: parseNeo4jField(neo4jEdge.properties), | |
type: _MG_EDGE, | |
}; | |
}; | |
const _toMemgraphPath = (neo4jPath) => { | |
const nodeById = {}; | |
const edgeById = {}; | |
(neo4jPath.segments ?? []).forEach((segment) => { | |
if (_isNeo4jNode(segment.start)) { | |
const node = _toMemgraphNode(segment.start); | |
nodeById[node.id] = node; | |
} | |
if (_isNeo4jNode(segment.end)) { | |
const node = _toMemgraphNode(segment.end); | |
nodeById[node.id] = node; | |
} | |
if (_isNeo4jEdge(segment.relationship)) { | |
const edge = _toMemgraphEdge(segment.relationship); | |
edgeById[edge.id] = edge; | |
} | |
}); | |
return { | |
nodes: Object.values(nodeById), | |
relationships: Object.values(edgeById), | |
type: _MG_PATH, | |
}; | |
}; | |
const _isNeo4jNumber = (field) => { | |
return ( | |
_isObject(field) && | |
Object.keys(field).length === 2 && | |
_isNumber(field.low) && | |
_isNumber(field.high) | |
); | |
}; | |
const _isNeo4jNode = (field) => { | |
return ( | |
_isObject(field) && | |
_isNeo4jNumber(field.identity) && | |
_isArray(field.labels) && | |
field.labels.every((label) => _isString(label)) && | |
_isObject(field.properties) | |
); | |
}; | |
const _isNeo4jEdge = (field) => { | |
return ( | |
_isObject(field) && | |
_isNeo4jNumber(field.identity) && | |
_isNeo4jNumber(field.start) && | |
_isNeo4jNumber(field.end) && | |
_isString(field.type) && | |
_isObject(field.properties) | |
); | |
}; | |
const _isNeo4jPath = (field) => { | |
return ( | |
_isObject(field) && | |
_isNeo4jNode(field.start) && | |
_isNeo4jNode(field.end) && | |
_isArray(field.segments) && | |
field.segments.every((segment) => { | |
return ( | |
_isObject(segment) && | |
_isNeo4jNode(segment.start) && | |
_isNeo4jEdge(segment.relationship) && | |
_isNeo4jNode(segment.end) | |
); | |
}) | |
); | |
} | |
const parseNeo4jField = (field) => { | |
if (field === undefined || field === null) { | |
return null; | |
} | |
if (_isArray(field)) { | |
return field.map((item) => parseNeo4jField(item)); | |
} | |
if (_isNeo4jNumber(field)) { | |
return field.toNumber(); | |
} | |
if (_isNeo4jNode(field)) { | |
return _toMemgraphNode(field); | |
} | |
if (_isNeo4jEdge(field)) { | |
return _toMemgraphEdge(field); | |
} | |
if (_isNeo4jPath(field)) { | |
return _toMemgraphPath(field); | |
} | |
if (_isObject(field)) { | |
const newObject = {}; | |
Object.keys(field).forEach((key) => { | |
if (field.hasOwnProperty(key)) { | |
newObject[key] = parseNeo4jField(field[key]); | |
} | |
}); | |
return newObject; | |
} | |
return field; | |
} | |
const parseNeo4jRecord = (record) => { | |
if (!_isObject(record)) { | |
return {}; | |
} | |
const newRecord = {}; | |
record.keys.forEach((key) => { | |
newRecord[key] = parseNeo4jField(record.get(key)); | |
}); | |
return newRecord; | |
} | |
const parseNeo4jResult = (result) => { | |
if (!_isObject(result)) { | |
return { records: [] }; | |
} | |
return { | |
records: (result.records ?? []).map((r) => parseNeo4jRecord(r)), | |
summary: result.summary, | |
}; | |
} | |
const runCypherQuery = async (query) => { | |
const session = driver.session(); | |
try { | |
const neo4jResult = await session.run(query); | |
return parseNeo4jResult(neo4jResult); | |
} catch (error) { | |
throw error; | |
} finally { | |
session.close(); | |
} | |
} | |
// Handling UI elements and results | |
const runQueryFromInputValue = async () => { | |
const query = inputElem.value ?? ''; | |
try { | |
const mgResult = await runCypherQuery(query); | |
const graph = extractGraphFromMgResult(mgResult); | |
if (graph.nodes.length === 0 && graph.edges.length === 0) { | |
throw new Error(`Query was successful, but the graph can't be shown because there are no nodes and edges in the response.`); | |
} | |
hideError(); | |
renderGraph(graph); | |
} catch (error) { | |
console.error(error); | |
showError(error); | |
} | |
}; | |
const showError = (error) => { | |
errorElem.innerHTML = error.message; | |
errorElem.classList.remove('hidden'); | |
graphElem.classList.add('hidden'); | |
if (error.message.includes('WebSocket connection failure')) { | |
setIsDisconnected(); | |
} | |
}; | |
const hideError = () => { | |
setIsConnected(); | |
errorElem.classList.add('hidden'); | |
graphElem.classList.remove('hidden'); | |
}; | |
const setIsConnected = () => connectionElem.setAttribute('data-is-opened', 'true'); | |
const setIsDisconnected = () => connectionElem.setAttribute('data-is-opened', 'false'); | |
// Event handlers for running a cypher query | |
buttonElem.addEventListener('click', () => runQueryFromInputValue()); | |
inputElem.addEventListener('keyup', (e) => { | |
if (e.key === 'Enter' || e.keyCode === 13) { | |
runQueryFromInputValue(); | |
} | |
}); | |
// Setting up the final bolt endpoint to the UI | |
document.querySelectorAll('.bolt-url').forEach((spanElem) => spanElem.innerHTML = BOLT_HOSTNAME); | |
// Running a test query to check connection | |
runCypherQuery('MATCH (n) RETURN n LIMIT 1').then(() => setIsConnected()); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment