Skip to content

Instantly share code, notes, and snippets.

@mhebrard
Last active January 24, 2018 07:24
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mhebrard/7d91f5a63daa1ae9d5fae53985fd807e to your computer and use it in GitHub Desktop.
Save mhebrard/7d91f5a63daa1ae9d5fae53985fd807e to your computer and use it in GitHub Desktop.
iCLiKVAL simple graph

iCLiKVAL links

This representation shows the content of iCLiKVAL database in a Force layout.

Notes:

  • The keyword use for search will be represented as a diamond
  • Each media will be represented as a square, its color refers to its type (see legend).
  • Each key will be represented as a triangle.
  • Each value will be represented as a circle.
  • Each annotation will be represented as a oval.
  • The size of each node is proportional (log scale) to the number of annotations of this node.
  • Hover on one node will display additional information in a tooltip.
  • Click on one node will open iCLiKVAL web site on the corresponding page.

Use case:

  1. Enter a keyword in the search field (top) and click on search button
  • The app request iCLiKVAL and draw a graph
  • The keyword is plotted at the center of the graph
  • Each media is connected to the keyword by one link
  1. More Media
  • By default the app request 10 media.
  • We can see the progression bar for media at the top left corner
  • Click on play button to request more media
  • Click on pause button to stop the request
  1. Select Mode: Keys using the drop down menu on top.
  • The app request iCLiKVAL for some annotations and draw another graph.
  • Media and keys are linked when an annotation describing the media, use the key
  1. More Annotations
  • By default the app request 25 annotations by media.
  • We can see the progression bar for annotations at the top left corner
  • Click on play button to request more annotations
  • Click on pause button to stop the request
  1. Select Mode: Values using the drop down menu at the bottom.
  • The app draw another graph.
  • Media and values are linked when an annotation describing the media, use the value
  1. Select Mode: Keys + Values using the drop down menu at the bottom.
  • The app draw another graph.
  • Media, keys and values are linked when an annotation describing the media, use the key and the value
  1. Select Mode: Annotations using the drop down menu at the bottom.
  • The app draw another graph.
  • Media and annotation are linked when an annotation describing the media, use the same key and value pair
  1. We can mouse over any item to display it's title
  2. If you modify the Filter parameter, the graph is redraw excluding the media, keys, values or annotations involves in less than "filter" annotations.
  3. If the graph is not stable, you can click on Fix graph button and the simulation will stop.
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<title>iCLiKVAL Links</title>
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro" rel="stylesheet">
<style>
body {
font-family: 'Source Sans Pro', sans-serif;
}
.header {
text-align: center;
font-weight: bold;
font-size: 18px;
margin: 10px;
}
.menu {
text-align: center;
font-weight: bold;
font-size: 18px;
margin: 10px;
justify-content: center;
}
/* Box */
.shadow {
border-radius: 3px;
border: 1px #000 solid;
box-shadow: 0 0 3px gray, inset 0 0 3px gray;
}
.content {
background-color: rgba(255,255,255,0.7);
padding: 5px;
}
.right {
position: absolute;
top:1%;
right:1%;
}
.left {
position: absolute;
top:1%;
left:1%;
}
.flex {
display: flex;
align-items: center;
}
/* Progress bar */
.progress-content {
width: 150px;
height: 12px;
border-radius: 3px;
margin: 2px 3px;
}
.progress-bar {
width: 0%;
height: 100%;
border-radius: 3px;
box-shadow: inset -3px 0 2px #80EAFF;
background: #22AFCA;
}
.progress-value {
float: left;
width: 100%;
text-align: center;
font-size: 10px;
cursor:pointer;
}
/* Tooltip */
#tip {
position:absolute;
z-index:3;
padding:10px;
pointer-events:none;
opacity:0;
background-color: rgba(255, 255, 255, 0.8);
}
#tip label {
font-weight: bold;
display: inline-block;
}
#txt-filter {
width:50px;
}
</style>
<script src="https://use.fontawesome.com/b6ac3d3b75.js"></script>
<script src="https://d3js.org/d3-array.v1.min.js"></script>
<script src="https://d3js.org/d3-collection.v1.min.js"></script>
<script src="https://d3js.org/d3-color.v1.min.js"></script>
<script src="https://d3js.org/d3-dispatch.v1.min.js"></script>
<script src="https://d3js.org/d3-drag.v1.min.js"></script>
<script src="https://d3js.org/d3-ease.v1.min.js"></script>
<script src="https://d3js.org/d3-force.v1.min.js"></script>
<script src="https://d3js.org/d3-interpolate.v1.min.js"></script>
<script src="https://d3js.org/d3-quadtree.v1.min.js"></script>
<script src="https://d3js.org/d3-request.v1.min.js"></script>
<script src="https://d3js.org/d3-scale.v1.min.js"></script>
<script src="https://d3js.org/d3-selection.v1.min.js"></script>
<script src="https://d3js.org/d3-timer.v1.min.js"></script>
<script src="https://d3js.org/d3-transition.v1.min.js"></script>
</head>
<body>
<div class='header flex' style="justify-content:space-between">
<div class='shadow content'>
<div id="progress-media" class="flex">
<span>Media:</span>
<div class="progress-content shadow">
<div class="progress-value">0</div>
<div class="progress-bar"></div>
</div>
<button type="button"><span class="fa fa-play"></span></button>
</div>
<div id="progress-annot" class="flex">
<span>Annot:</span>
<div class="progress-content shadow">
<div class="progress-value">0</div>
<div class="progress-bar"></div>
</div>
<button type="button"><span class="fa fa-play" style="cursor:pointer"></span></button>
</div>
</div>
<div style="font-size:30px">iCLiKVAL Links</div>
<div class='shadow content'>
<div id='log'></div>
</div>
</div>
<div class='menu content flex'>
<div>
<input type="text" id="txt-search"/>
<button type="button" id="btn-search">Search</button>
</div>
<div>&nbsp;|&nbsp;</div>
<div>
<label> Mode: </label>
<select id="btn-mode">
<option value='search' selected>Keyword</option>
<option value='keys'>Keys</option>
<option value='values'>Values</option>
<option value='keyval'>Keys + Values</option>
<option value='annot'>Annotations</option>
</select>
</div>
<div>&nbsp;|&nbsp;</div>
<div>
<label> Filter: </lable>
<input type="number" id="txt-filter" min="0" value="1" title="Hide nodes with no more that x links"/>
</div>
<div>&nbsp;|&nbsp;</div>
<div>
<button type="button" id="btn-stop">Fix graph</button>
</div>
</div>
<div id='chart' class='shadow'></div>
<div id='legend'></div>
<div id='tip' class='shadow'></div>
<script type="text/javascript">
// Global parameters
var p = {
radiusRange: [5, 15], // Radius range
types: { // Colors
audio: {label: 'Audio', name: 'Title', fg: '#ff7f00', bg: '#fdbf6f'},
dataset: {label: 'Dataset', name: 'Title', fg: '#33a02c', bg: '#b2df8a'},
image: {label: 'Image', name: 'Title', fg: '#6a3d9a', bg: '#cab2d6'},
journal_article: {label: 'Article', name: 'Title', fg: '#1f78b4', bg: '#a6cee3'},
video: {label: 'Video', name: 'Title', fg: '#e31a1c', bg: '#fb9a99'},
root: {label: 'Root', name: 'Term', fg: '#000', bg: '#ccc'},
key: {label: 'Key', name: 'Term', fg: '#990', bg: '#ff8'},
value: {label: 'Value', name: 'Term', fg: '#099', bg: '#8fa'},
annot: {label: 'Annotation', name: 'Key/Value', fg: '#909', bg: '#f8f'}
},
tipWidth: 200, // The tooltip div has a fixed width
loopMedia: false, // Loop on media request
loopAnnot: false, // Loop on annot request
};
var v = {
save: {search: {page: 0, annots: 0, annotsMax: 0}}, // Global save of the fetched data
scale: d3.scaleLog(), // Create a logarithmic scale for the node size
simulation: d3.forceSimulation(), // Global reference to simulation
win: [0, 0]
};
// RUN
// Initialize the page
init();
// Initialize the page
function init() {
// Scale SVG according to window size
var [w, h] = resizeSVG();
// Tooltip width
d3.select('#tip').style('width', `${p.tipWidth}px`);
// Add callback to search button and text field
// User enter a keyword
// App request Iclikval search with this keyword
// Then draw a network of media linked to a root node
d3.select('#btn-search').on('click', () => newsearch());
d3.select('#txt-search').on('change', () => newsearch());
// Add callback to link button
// User can select which type of network he want
// App draw the correspondig network from fetched data
d3.select('#btn-mode').on('change', () => {
return loopAnnot()
.catch(err => error('ERROR: Init - mode', err)); // Notify the error
});
// Add callback to log button
// User can stop and restart media request
// User can stop and restart annotation request
d3.select('#progress-media').select('button').on('click', () => loopSwitch('media'));
d3.select('#progress-annot').select('button').on('click', () => loopSwitch('annot'));
// Add callback to filter value
// Network is rebuild
d3.select('#txt-filter').on('change', () => {
return buildNetwork() // Build the network
.then(network => draw(network)) // Display the network
.catch(err => error('ERROR: change filter', err));
});
// Add callback to stop button
// Force the simulation to stop
d3.select('#btn-stop').on('click', () => {
v.simulation.stop();
});
// Add legend
var sel = d3.select('#legend').append('svg')
.attr('width', w)
.attr('height', (p.radiusRange[0] + 10) * 2)
.selectAll('g').data(Object.keys(p.types))
var add = sel.enter().append('g')
.attr('transform', (d, i) => `translate(${(i * 80) + p.radiusRange[0] + 40}, ${p.radiusRange[0] + 10})`)
add.append('path')
.attr('d', d => path(d))
.attr('fill', d => p.types[d].bg)
.attr('stroke-width', 2)
.attr('stroke', d => p.types[d].fg)
add.append('text')
.attr('x', (p.radiusRange[0] * 2) + 2)
.attr('y', p.radiusRange[0])
.text(d => p.types[d].label)
// Default example
d3.select('#txt-search').property("value", 'iclikval');
d3.select('#btn-search').on('click')();
// Loading message
log('fa-check','Initiated');
}
// Size SVG according to the window
function resizeSVG() {
// Get window size
v.win = getSize();
// SVG size
var width = v.win[0] * 0.95;
// win - header - margin (top + bottom)
var height = (v.win[1] * 0.95) - 140 - (v.win[1] * 0.04);
// Update chart div size
var sel = d3.select('#chart')
.style('width', `${width}px`)
.style('height', `${height}px`)
.style('margin', `${v.win[1] * 0.02}px ${v.win[0] * 0.02}px`)
.selectAll('svg')
.data([0]);
// If SVG not exist, create it
add = sel.enter().append('svg');
add.append('g').attr('class', 'links');
add.append('g').attr('class', 'nodes');
// Update SVG size
sel = add.merge(sel);
sel.attr('width', width)
.attr('height', height);
// Return SVG size
return [width, height];
}
// Get window size
function getSize() {
const w = window;
const d = document;
const e = d.documentElement;
const g = d.getElementsByTagName('body')[0];
const x = w.innerWidth || e.clientWidth || g.clientWidth;
const y = w.innerHeight || e.clientHeight || g.clientHeight;
return [x, y];
}
function onClickHandler(n) {
switch (n.type) {
case 'root':
case 'key':
case 'value': {
window.open(`https://iclikval.riken.jp/search?db=default&q="${n.name}"`, '_blank');
break;
}
case 'annot': {
var terms = n.id.split("|");
var qs = `https://iclikval.riken.jp/search?db=default&q={"bool":{"must":[{"term":{"key":"${terms[0]}"}},{"term":{"value":"${terms[1]}"}}]}}&term="Key=${terms[0]} & Value=${terms[1]}"`;
var url = encodeURI(qs);
window.open(qs, '_blank');
break;
}
default: {
window.open(`https://iclikval.riken.jp/review-media/${n.id}`, '_blank');
}
}
}
function newsearch() {
// Reset mode to search
d3.select('#btn-mode').property('value', 'search');
resetSave() // Reset data
.then(() => loopSearch()); // perform search
}
// Delete the previous search result
function resetSave() {
return new Promise(resolve => {
// Loading message
log('fa-spinner fa-spin', 'Reset...');
// Get the keyword and save it
v.save.search.keyword = d3.select('#txt-search').node().value;
// Reset the global storage
v.save = {
media: {}, // Media map
annotations: {}, // Annotations map
keys: {}, // Keys map
values: {}, // Value map
annots: {}, // Annot (key/value paire) map
search: {
page: 0, // Last page fetched from search API
pageMax: { // Total number of page from search API
journal_article: 1,
audio: 1,
video: 1,
image: 1,
dataset: 1
},
annots: 0, // Current annots fetched
annotsMax: 0, // Max annots to fetch
keyword: v.save.search.keyword // Current key word for search API
}
};
// Reset counts
progress('media', 0, 0);
progress('annot', 0, 0);
resolve();
});
}
// Request search in parallele
// Build network
// Loop search
function loopSearch() {
var types = v.save.search.pageMax;
var page = v.save.search.page;
// Prepare media request
var q = Object.keys(types)
.filter(k => types[k] > page)
.map(k => requestSearch(page + 1, k)); // QS + fetch + parse
// Test if need request
if (q.length > 0) {
return Promise.all(q) // Run the requests in parallele
.then(() => buildNetwork()) // Build the network
.then(network => draw(network)) // Display the network
.catch(err => error('ERROR: loopSearch', err)) // Notify the error
.then(() => p.loopMedia ? loopSearch() : 'end'); // Loop on Media Request
};
}
// Prepare and run the request to search API
function requestSearch(page, type) {
// Loading message
log('fa-spinner fa-spin','Request...');
// Setup the query
var querystring = `?db=default&page=${page}&media_type=${type}&q=${v.save.search.keyword}&term=${v.save.search.keyword}`;
// Send the request + parse
return querySearch(querystring)
.catch(err => error('ERROR: querySearch', err)) // Notify the error
.then(response => parseSearch(response)); // Parse response
}
// AJAX request to Iclikval search API
function querySearch(qs) {
return new Promise((resolve, reject) => {
d3.request('https://api.iclikval.riken.jp/search' + qs)
.header("Content-Type", "application/json")
.response(xhr => JSON.parse(xhr.responseText))
.get((err, res) => {
if (err) {
reject(err);
} else {
resolve(res);
}
});
});
};
// Parse response from Iclikval search API
function parseSearch(data) {
return new Promise((resolve, reject) => {
if (data.total_items === 0) {
reject();
} else {
// Loading message
log('fa-spinner fa-spin','Parsing...');
var annotsMax = v.save.search.annotsMax;
// Save media
data._embedded.media.forEach(m => {
// Manage wrong annotation count (work around bug in Iclikval)
// Clamp annotation count to 1
var annotCount = m.auto_annotation_count + m.user_annotation_count;
annotCount = annotCount < 1 ? 1 : annotCount;
// Create new media
if (v.save.media[m.id] === undefined) {
v.save.media[m.id] = {
id: m.id,
title: m.title,
type: m.media_type,
annot: {}, // annotations map link to this media
annotPage: 0, // last annotaton page fetched
annotPageCount: 1, // max annotation page for this media
annotCount // annotation count for this media
}
}
// Max annotation user need to fetch
annotsMax = Math.max(annotsMax, annotCount);
});
// PageMax
const pages = v.save.search.pageMax;
const counts = data.extra.media_count.media;
const size = data.page_size;
Object.keys(pages).forEach(k => {
pages[k] = counts[k] ? Math.ceil(counts[k] / size) : 0;
});
// Save search meta data
v.save.search = {...v.save.search,
annotsMax, // Max annotation count
page: data.page, // Current search page fetched
pageMax: pages, // Max number of page for current media
total: data.extra.media_count.total // Max media count
}
// Update media count
var count = Object.keys(v.save.media).length;
progress('media', count, v.save.search.total);
// Update annot count
progress('annot', v.save.search.annots, v.save.search.annotsMax);
resolve();
}
}).catch(err => error('No media', err)) // Notify the error
.catch(err => Promise.resolve()); // Continue the loop
}
// Request annot in parallele
// Build network
// Loop search
function loopAnnot() {
// Prepare annot request
var q = [];
// For each media, request next annotation page
Object.keys(v.save.media).forEach(mid => {
var page = v.save.media[mid].annotPage;
var count = v.save.media[mid].annotPageCount;
if (page < count) {
q.push(requestAnnot(page + 1, mid));
}
});
// Test if need request
if (q.length > 0) {
return Promise.all(q) // Run the requests in parallele
.then(counts => inferCount(counts)) // Capture the count of annotation fetched
.then(() => buildNetwork()) // Build the network
.then(network => draw(network)) // Display the network
.catch(err => error('ERROR: loopAnnot', err)) // Notify the error
.then(() => p.loopAnnot ? loopAnnot() : 'end'); // Loop on Media Request
}
// Else rebuild the network
return buildNetwork() // Build the network
.then(network => draw(network)) // Display the network
.catch(err => error('ERROR: loopAnnot', err)) // Notify the error
.then(() => p.loopAnnot ? loopAnnot() : 'end'); // Loop on Media Request
}
// Prepare the querystring
// Request to annotation API
// Parse the response
function requestAnnot(page, mid) {
// Loading message
log('fa-spinner fa-spin', 'Request...');
// Setup the query
var querystring = `?page=${page}&media=${mid}`;
// Send the request + parse
return queryAnnot(querystring)
.catch(err => error('ERROR: queryAnnot', err)) // Notify the error
.then(response => parseAnnot(response, mid)) // Add the respond to the previous one
.catch(err => error('Annot Request Failed', err)) // Notify the error
.catch(err => Promise.resolve()); // Continue the loop
}
// AJAX request to Iclikval annotation API
function queryAnnot(qs) {
return new Promise((resolve, reject) => {
d3.request('https://api.iclikval.riken.jp/annotation' + qs)
.header("Content-Type", "application/json")
.response(xhr => JSON.parse(xhr.responseText))
.get((err, res) => {
if (err) {
console.log('ERROR: queryAnnot', err);
reject(err);
} else {
resolve(res);
}
});
});
};
// Parse response from Iclikval annotation API
function parseAnnot(data, mid) {
return new Promise(resolve => {
// Loading message
log('fa-spinner fa-spin','Parsing...');
// Update media
var m = v.save.media[mid];
m.annotPage = data.page;
m.annotPageCount = data.page_count;
m.annotCount = data.total_items;
// Parse annot
data._embedded.annotation.forEach(a => {
// Annot
var id = `${a.key}|${a.value}`;
if (v.save.annots[id] === undefined) {
v.save.annots[id] = {media: {}, key: a.key, value: a.value, annotCount: 0};
}
v.save.annots[id].media[m.id] = true; // Media map, annot link to media
v.save.annots[id].annotCount++; // Count annotations involve
// Key
if (v.save.keys[a.key] === undefined) {
v.save.keys[a.key] = {media: {}, annotCount: 0}
}
v.save.keys[a.key].media[mid] = true; // Media map, key link to media
v.save.keys[a.key].annotCount++; // Count annotations involve
// Value
if (v.save.values[a.value] === undefined) {
v.save.values[a.value] = {media: {}, keys: {}, annotCount: 0}
}
v.save.values[a.value].media[mid] = true; // Media map, value is link to media
v.save.values[a.value].keys[a.key] = true; // Key map, value is link to key
v.save.values[a.value].annotCount++; // Count annotations involve
});
// Update annot count
v.save.search.annotsMax = Math.max(v.save.search.annotsMax, data.total_items);
// We need to catch the minimal annotation page fetched
// And infer the current annotation count
var count = 0;
if (data.page !== data.page_count) {
count = data.page * data.page_size;
}
resolve(count);
});
}
// Get the minimal count of annotations
function inferCount(counts) {
// Manage undefined
v.save.search.annots = Math.min(...counts.map(c => c === 0 ? v.save.search.annotsMax : c));
// Update annot count
progress('annot', v.save.search.annots, v.save.search.annotsMax);
return Promise.resolve();
}
// Build the network according to the mode selected in "link by" option
function buildNetwork() {
switch (d3.select('#btn-mode').node().value) {
case 'keys':
return networkKeys();
case 'values':
return networkValues();
case 'keyval':
return networkKeysValues();
case 'annot':
return networkAnnotations();
default: // search
return networkSearch();
};
}
// Build the network after search
// The keyword is the root node
// Each media is a node linked to the root
// The size of the node are proportional to the annotation count
function networkSearch() {
return new Promise(resolve => {
// Loading message
log('fa-spinner fa-spin','Network...');
// Store network
var network = {nodes: [], links: []};
// add a root (fixed at center)
network.nodes.push({id: 'ROOT', type:'root', name: v.save.search.keyword, weight: 1});
// link each media to root
Object.keys(v.save.media).forEach(k => {
var m = v.save.media[k];
network.nodes.push({id: m.id, type: m.type, name: m.title, weight: m.annotCount});
network.links.push({source: 'ROOT', target: m.id});
network.nodes[0].weight++;
});
resolve(network);
});
}
// Build the network linked by key
// Both media and key are nodes
// Media and key are linked by annotations
function networkKeys() {
return new Promise(resolve => {
// Loading message
log('fa-spinner fa-spin','Network...');
// Store network
var network = {nodes: [], links: []};
// Get filter
var filter = d3.select('#txt-filter').node().value;
// Create node for each media
Object.keys(v.save.media).forEach(k => {
var m = v.save.media[k];
if (m.annotCount > filter) {
network.nodes.push({id: m.id, type: m.type, name: m.title, weight: m.annotCount});
}
});
// Create node for each key and link with media
Object.keys(v.save.keys).forEach(k => {
var key = v.save.keys[k];
if (key.annotCount > filter) {
network.nodes.push({id: k, type: 'key', name: k, weight: key.annotCount});
Object.keys(key.media).forEach(m => {
if (v.save.media[m].annotCount > filter) {
network.links.push({source: m, target: k});
}
});
}
});
resolve(network);
});
}
// Build the network linked by value
// Both media and value are nodes
// Media and value are linked by annotations
function networkValues() {
return new Promise(resolve => {
// Loading message
log('fa-spinner fa-spin','Network...');
// Store network
var network = {nodes: [], links: []};
// Get filter
var filter = d3.select('#txt-filter').node().value;
// Create node for each media
Object.keys(v.save.media).forEach(k => {
var m = v.save.media[k];
if (m.annotCount > filter) {
network.nodes.push({id: m.id, type: m.type, name: m.title, weight: m.annotCount});
}
});
// Create node for each value and link with media
Object.keys(v.save.values).forEach(k => {
var val = v.save.values[k];
if (val.annotCount > filter) {
network.nodes.push({id: k, type: 'value', name: k, weight: val.annotCount});
Object.keys(val.media).forEach(m => {
if (v.save.media[m].annotCount > filter) {
network.links.push({source: m, target: k});
}
});
}
});
resolve(network);
});
}
// Build the network linked by key and value
// Both media key and value are nodes
// Media and key are linked
// Key and value are linked
function networkKeysValues() {
return new Promise(resolve => {
// Loading message
log('fa-spinner fa-spin','Network...');
// Store network
var network = {nodes: [], links: []};
// Get filter
var filter = d3.select('#txt-filter').node().value;
// Create node for each media
Object.keys(v.save.media).forEach(k => {
var m = v.save.media[k];
if (m.annotCount > filter) {
network.nodes.push({id: m.id, type: m.type, name: m.title, weight: m.annotCount});
}
});
// Create node for each key and link with media
Object.keys(v.save.keys).forEach(k => {
var key = v.save.keys[k];
if (key.annotCount > filter) {
network.nodes.push({id: k, type: 'key', name: k, weight: key.annotCount});
Object.keys(key.media).forEach(m => {
if (v.save.media[m].annotCount > filter) {
network.links.push({source: m, target: k});
}
});
}
});
// Create node for each value and link with key
Object.keys(v.save.values).forEach(k => {
var val = v.save.values[k];
if (val.annotCount > filter) {
network.nodes.push({id: k, type: 'value', name: k, weight: val.annotCount});
Object.keys(val.keys).forEach(m => {
if (v.save.keys[m].annotCount > filter) {
network.links.push({source: m, target: k});
}
});
}
});
resolve(network);
});
}
// Build the network linked by annotation
// One annotation represent a unique key value pair
// Both media and annots are nodes
// Media and annots are linked by annotations
function networkAnnotations() {
return new Promise(resolve => {
// Loading message
log('fa-spinner fa-spin','Network...');
// Store network
var network = {nodes: [], links: []};
// Get filter
var filter = d3.select('#txt-filter').node().value;
// Create node for each media
Object.keys(v.save.media).forEach(k => {
var m = v.save.media[k];
if (m.annotCount > filter) {
network.nodes.push({id: m.id, type: m.type, name: m.title, weight: m.annotCount});
}
});
// Create node for each annot and link with media
Object.keys(v.save.annots).forEach(k => {
var annot = v.save.annots[k];
if (annot.annotCount > filter) {
network.nodes.push({id: k, type: 'annot', name: `${annot.key} / ${annot.value}`, weight: annot.annotCount});
Object.keys(annot.media).forEach(m => {
if (v.save.media[m].annotCount > filter) {
network.links.push({source: m, target: k});
}
});
}
});
resolve(network);
});
}
// Update elements in SVG
function draw(data) {
return new Promise(resolve => {
// Loading message
log('fa-check','Done');
// Adjust SVG to window
var [w, h] = resizeSVG();
// Scale for radius
var weights = data.nodes.map(l => l.weight);
v.scale.domain([Math.min(...weights), Math.max(...weights)]) // input is min and max links weight
.range(p.radiusRange); // output is from defined distance to window shorter dimension.
// Create simulation
v.simulation = d3.forceSimulation()
.force('link', d3.forceLink().id(d => d.id)) // .distance(d => dist(d.weight)))
.force('charge', d3.forceManyBody().strength(-60).distanceMax(Math.min(w, h) / 3))
.force('collide', d3.forceCollide().radius(p.radiusRange[1]))
.force('center', d3.forceCenter(w / 2, h / 2));
var sel, add;
// JOIN, EXIT(t1), UPDATE old(t2), ENTER, MERGE, UPDATE all(t3)
// Nodes have different shape according to their type
// They also need to be managed globaly for their position
// Therefore we encapsulate the shape in a <g>
// Join
sel = d3.select('#chart').select('svg').select('.nodes')
.selectAll('.node').data(data.nodes, d => d.id);
// Exit
sel.exit().remove();
// Enter
add = sel.enter().append('g')
.attr('class', 'node')
.attr('transform', 'translate(0, 0)')
.attr('fill', d => p.types[d.type].bg)
.attr('stroke-width', 2)
.attr('stroke', d => p.types[d.type].fg)
.append('path')
// .attr('d', d => path(d.type, d))
.on('click', d => onClickHandler(d))
.on('mouseover', d => tip("show", d))
.on('mouseout', d => tip("hide"))
.on("mousemove", d => tip("move"));
// Update size
d3.select('#chart').select('svg').select('.nodes').selectAll('.node')
.selectAll('path').attr('d', d => path(d.type, d));
// reselect all nodes and manage (un)pin event
var node = d3.select('#chart').select('svg').select('.nodes').selectAll('.node')
.on('click', clicked)
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended)
);
// Update size
d3.select('#chart').select('svg').select('.nodes').selectAll('.node').selectAll('path')
.attr('d', d => path(d.type, d));
// Update Links
// Join
sel = d3.select('#chart').select('svg').select('.links')
.selectAll('line').data(data.links, d => `${d.source}_${d.target}`);
// Exit
sel.exit().remove();
// Enter
add = sel.enter().append('line')
.attr('stroke', 'rgba(100,100,100,0.5)')
.attr('stroke-width', 2);
// reselect all links
var link = d3.select('#chart').select('svg').select('.links').selectAll('line');
// Start simulation
v.simulation
.nodes(data.nodes)
.on('tick', ticked)
.alpha(0.7).restart();
v.simulation.force('link').links(data.links);
log('fa-check', 'Done');
resolve();
// Adjust position of each node for each force iteration
function ticked() {
// Force node inside window
node.attr('transform', d => {
var radius = p.radiusRange[1];
d.x = Math.max(radius, Math.min(w - radius, d.x));
d.y = Math.max(radius, Math.min(h - radius, d.y));
return `translate(${d.x}, ${d.y})`;
});
// Adjust link
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
}
// Drag and drop managment
function dragstarted(d) {
// console.log('dragstarted');
if (!d3.event.active) {
v.simulation.alphaTarget(0.3).restart();
}
d.fx = d.x;
d.fy = d.y;
}
function dragged(d) {
// console.log('dragged');
d.fx = d3.event.x;
d.fy = d3.event.y;
}
function dragended(d) {
// console.log('dragended');
if (!d3.event.active) {
v.simulation.alphaTarget(0);
}
// Reposition node at dragended
// d.fx = null;
// d.fy = null;
}
function clicked(d) {
// console.log('clicked', d);
// Reposition node on clicked
d.fx = null;
d.fy = null;
}
});
}
// Draw node shape according to its type
function path(type, d) {
var r = d ? v.scale(d.weight): p.radiusRange[0];
switch (type) {
case 'root': // Diamond
return `M0 ${-r} L${r} 0 L0 ${r} L${-r} 0 Z`;
break;
case 'key': // Triangle
return `M0 ${-r} L${r} ${r} L${-r} ${r} Z`;
break;
case 'value': // Circle
return `M0 ${-r} a ${r} ${r} 0 1 0 0.1 0 Z`;
break;
case 'annot': // Ellipse
return `M0 ${-r} a ${r * 2} ${r} 0 1 0 1 0 Z`;
break;
default: // Square
return `M${-r} ${-r} h${r * 2} v${r * 2} h${-r * 2} Z`;
}
}
function progress(mode, count, total) {
const percent = total === 0 ? 0 : Math.round(count * 100 * 100 / total) / 100;
switch (mode) {
case 'media':
var div = d3.select('#progress-media');
div.select('.progress-value').attr('title', `${count}/${total}`).text(`${percent}%`);
div.select('.progress-bar').style('width', `${percent}%`);
break;
case 'annot':
var div = d3.select('#progress-annot');
div.select('.progress-value').attr('title', `${count}/${total}`).text(`${percent}%`);
div.select('.progress-bar').style('width', `${percent}%`);
break;
default:
error('ERROR wrong progress mode');
}
}
function loopSwitch(mode) {
let bool;
let span;
let callback;
switch (mode) {
case 'media':
p.loopMedia = !p.loopMedia;
bool = p.loopMedia;
btn = d3.select('#progress-media').select('button');
callback = loopSearch;
break;
case 'annot':
p.loopAnnot = !p.loopAnnot;
bool = p.loopAnnot;
btn = d3.select('#progress-annot').select('button');
callback = loopAnnot;
default:
}
if (bool) {
btn.attr('title', 'Stop request').select('.fa').attr('class', 'fa fa-pause');
} else {
btn.attr('title', 'Restart request').select('.fa').attr('class', 'fa fa-play');
}
callback();
}
// Manage tooltip
function tip(mode, d) {
if(mode === "show") {
d3.select("#tip")
.datum(d)
.style("opacity", 1)
.html(d => `<label>Type:&nbsp;</label><span>${p.types[d.type].label}</span><br/>` +
`<label>Annotations:&nbsp;</label><span>${d.weight}</span><br/>` +
`<label>${p.types[d.type].name}:&nbsp;</label><span>${d.name}</span>`);
} else if(mode === "hide") {
d3.select("#tip").style("opacity",0)
} else { // move
// Tooltip X-axis
if (d3.event.pageX > v.win[0] - p.tipWidth) { // collide right border
d3.select("#tip").style("left", (d3.event.pageX - 10 - p.tipWidth) + "px");
} else {
d3.select("#tip").style("left", (d3.event.pageX + 10) + "px");
}
// Tooltip Y-axis
d3.select("#tip").style("top", (d3.event.pageY + 10) + "px");
}
}
// Manage Errors
function error(msg, err) {
// Loading message
log('fa-exclamation', msg);
return Promise.reject(msg);
}
// Manage Log
function log(icon, msg) {
d3.select('#log').html(`<span class="fa ${icon}"></span>${msg}`);
}
</script>
</body>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment