Appearance
SPARQL to JSON
In this demo it is shown how to use the Wikidata SPARQL endpoint to fetch data about Movies. The results of these queries are manually transformed into Ogma's RawGraph format. The loading can be slow at first due to a double query run and wikidata availability.
ts
import Ogma, { NodeId } from '@linkurious/ogma';
import {
SPARQLParser,
MovieType,
DirectorType,
ActorType,
NodeType
} from './parser';
const ogma = new Ogma({
container: 'graph-container'
});
// some UI logic
const toggleLoading = () => {
const el = document.querySelector<HTMLDivElement>('#info')!;
const enable = el.style.display;
el.style.display = enable === 'block' ? 'none' : 'block';
};
const updateSPARQLQuery = (query: string) => {
const codeBlock = document.querySelector<HTMLElement>('#query-text')!;
codeBlock.innerHTML = query;
// @ts-ignore
Prism.highlightElement(codeBlock);
};
// Query templates
const getStartTemplate = () =>
`SELECT DISTINCT ?movie ?movieLabel WHERE {
?movie wdt:P31 wd:Q11424.
?movie wdt:P1476 "The Matrix"@en.
SERVICE wikibase:label { bd:serviceParam wikibase:language "[AUTO_LANGUAGE],en". }
}
LIMIT 1`;
const getMovieExpandQuery = (id: NodeId) =>
`SELECT DISTINCT ?actor ?actorLabel ?director ?directorLabel WHERE {
wd:${id} wdt:P161 ?actor.
wd:${id} wdt:P57 ?director.
SERVICE wikibase:label { bd:serviceParam wikibase:language "[AUTO_LANGUAGE],en". }
}`;
const getPersonExpandQuery = (id: NodeId) =>
`SELECT DISTINCT ?movie ?movieLabel WHERE {
?movie wdt:P161|wdt:P57 wd:${id}.
SERVICE wikibase:label { bd:serviceParam wikibase:language "[AUTO_LANGUAGE],en". }
}`;
const getExpandQuery = (id: NodeId, type: NodeType) => {
if (type === MovieType) return getMovieExpandQuery(id);
return getPersonExpandQuery(id);
};
// Fetch data from Wikidata via the SPARQL endpoint
const sendQuery = (id?: NodeId, type?: NodeType): Promise<unknown> => {
const query = id && type ? getExpandQuery(id, type) : getStartTemplate();
const parser = new SPARQLParser(ogma);
toggleLoading();
updateSPARQLQuery(query);
return fetch(
'https://query.wikidata.org/sparql?query=' + encodeURIComponent(query),
{
headers: {
Accept: 'application/sparql-results+json'
}
}
)
.then(response => response.json())
.then(result => parser.parse(result, id, type))
.then(graph => {
toggleLoading();
return ogma.addGraph(graph);
})
.then(() => {
// if it's the first query run another expand
if (ogma.getNodes().size === 1)
return sendQuery(ogma.getNodes().get(0).getId(), MovieType);
return layout();
});
};
const layout = () =>
ogma.layouts.force({
gpu: true,
locate: { padding: 80 }
});
// Define what to use as node and edge captions.
ogma.styles.addEdgeRule({
text: {
content: e => e.getData('type')
},
color: e => {
const type = e.getData('type');
if (type === 'ACTED_IN') return '#22b455';
if (type === 'DIRECTED') return '#80ce87';
}
});
ogma.styles.addNodeRule({
text: {
content: n => {
if (n.getData('type') === MovieType) return n.getData('title');
if (n.getData('type') === DirectorType || n.getData('type') === ActorType)
return n.getData('name');
}
},
icon: {
content: n => {
if (n.getData('type') === MovieType) return '\uf008';
if (n.getData('type') === DirectorType || n.getData('type') === ActorType)
return '\uf007';
},
font: 'Font Awesome 5 Free',
color: 'black',
minVisibleSize: 0
},
outerStroke: {
color: '#204829',
width: 2
},
color: 'white'
});
// Expand on double click
ogma.events.on('doubleclick', evt => {
if (evt.target && evt.target.isNode) {
sendQuery(evt.target.getId(), evt.target.getData('type'));
}
});
document.querySelector('#layout')!.addEventListener('click', evt => {
evt.preventDefault();
layout();
});
sendQuery();
html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<link
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.11.2/css/solid.min.css"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.19.0/themes/prism.min.css"
integrity="sha256-cuvic28gVvjQIo3Q4hnRpQSNB0aMw3C+kjkR0i+hrWg="
crossorigin="anonymous"
/>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.19.0/prism.min.js"
integrity="sha256-YZQM6/hLBZYkb01VYf17isoQM4qpaOP+aX96hhYrWhg="
crossorigin="anonymous"
></script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.19.0/plugins/autoloader/prism-autoloader.min.js"
integrity="sha256-WIuEtgHNTdrDT2obGtHYz/emxxAj04sJBdMhRjDXd8I="
crossorigin="anonymous"
></script>
<link type="text/css" rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="graph-container"></div>
<div class="toolbar">
<h3>Last wikidata SPARQL query</h3>
<pre><code class="language-sparql" id="query-text"></code></pre>
<div class="controls">
<button id="layout" class="btn menu">Layout</button>
</div>
<img
src="./img/wikidata-logo.png"
alt="powered by Wikidata"
width="100"
/>
</div>
<div id="info">loading...</div>
<script type="module" src="index.ts"></script>
</body>
</html>
css
#graph-container {
top: 0;
bottom: 0;
left: 0;
right: 0;
position: absolute;
margin: 0;
overflow: hidden;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-o-user-select: none;
user-select: none;
}
#info {
position: absolute;
color: #fff;
background: #141229;
font-size: 12px;
font-family: monospace;
padding: 5px;
top: 0;
left: 0;
white-space: pre;
}
.toolbar {
display: block;
position: absolute;
top: 20px;
right: 20px;
padding: 10px;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
border-radius: 4px;
background: #ffffff;
color: #222222;
font-weight: 300;
max-width: 350px;
}
.toolbar .section {
position: relative;
display: block;
}
.toolbar h3 {
display: block;
font-weight: 300;
border-bottom: 1px solid #ddd;
color: #606060;
font-size: 1rem;
font-family: Arial, Helvetica, sans-serif;
}
.toolbar img {
margin-top: 15px;
float: right;
}
.controls {
text-align: center;
margin-top: 5px;
}
.btn {
padding: 6px 8px;
background-color: white;
cursor: pointer;
font-size: 18px;
border: none;
border-radius: 5px;
outline: none;
}
.btn:hover {
color: #333;
background-color: #e6e6e6;
}
.menu {
border: 1px solid #ddd;
width: 80%;
font-size: 14px;
margin-top: 10px;
}
ts
import Ogma, { NodeId, RawGraph, RawNode } from '@linkurious/ogma';
// type constants
export const MovieType = 'Movie';
export const DirectorType = 'Director';
export const ActorType = 'Actor';
export const DirectedType = 'DIRECTED';
export const ActedInType = 'ACTED_IN';
// dummy result shape type
export interface SparqlResult {
head: {
vars: string[];
};
results: {
bindings: {
[key: string]: {
type: string;
value: string;
};
}[];
};
}
// node data types
export interface ActorData {
type: typeof ActorType;
name: string;
}
export interface DirectorData {
type: typeof DirectorType;
name: string;
}
export interface MovieData {
type: typeof MovieType;
title: string;
}
export type NodeData = ActorData | DirectorData | MovieData;
export type NodeType =
| typeof MovieType
| typeof DirectorType
| typeof ActorType;
export type EdgeType = typeof DirectedType | typeof ActedInType;
const ENITITY_RE = /https?\:\/\/www\.wikidata\.org\/entity\//;
const getCleanId = (uri: string) => uri.replace(ENITITY_RE, '');
const getEdgeType = (sourceType: NodeType, targetType: NodeType): EdgeType => {
if (sourceType === MovieType)
return targetType === DirectorType ? DirectedType : ActedInType;
return sourceType === DirectorType ? DirectedType : ActedInType;
};
export class SPARQLParser {
constructor(private ogma: Ogma) {}
// Parsing logic
parseMovieResults = (data: SparqlResult): RawNode<MovieData>[] => {
// the triple format is [movie: id, movieLabel: title]
const entities = data.results.bindings;
return entities.map(node => ({
id: getCleanId(node.movie.value),
data: { type: MovieType, title: node.movieLabel.value }
}));
};
parsePersonResults = (
data: SparqlResult
): RawNode<ActorData | DirectorData>[] => {
const nodes: RawNode<ActorData | DirectorData>[] = [];
// the triple format is: [actorId, actor, actorGenre, directorId, director, directorGenre]
const entities = data.results.bindings;
// each record contains more than one node information
entities.forEach(node => {
nodes.push({
id: getCleanId(node.actor.value),
data: { type: ActorType, name: node.actorLabel.value }
});
nodes.push({
id: getCleanId(node.director.value),
data: { type: DirectorType, name: node.directorLabel.value }
});
});
return nodes;
};
parse = (data: SparqlResult, source?: NodeId, type?: NodeType) => {
// initial query or we're expanding a person node, so result will be of movie type
const isMovieNodesResults = !type || type !== MovieType;
const graph: RawGraph<NodeData, { type: EdgeType }> = {
nodes: isMovieNodesResults
? this.parseMovieResults(data)
: this.parsePersonResults(data),
edges: []
};
// first query has no source
if (source) {
const sourceType = this.ogma.getNode(source)!.getData('type');
graph.edges = graph.nodes.map(target => {
const type = getEdgeType(sourceType, target.data!.type);
return {
id: source + '-' + target.id,
source,
target: target.id!,
data: { type }
};
});
}
return graph;
};
}