|
<!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> | </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> | </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> | </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: </label><span>${p.types[d.type].label}</span><br/>` + |
|
`<label>Annotations: </label><span>${d.weight}</span><br/>` + |
|
`<label>${p.types[d.type].name}: </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> |