|
<!DOCTYPE html> |
|
<head> |
|
<meta charset="utf-8"> |
|
<title>iCLiKVAL 3D Graph</title> |
|
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro" rel="stylesheet"> |
|
<style> |
|
body { |
|
font-family: 'Source Sans Pro', sans-serif; |
|
color: #eee; |
|
margin: 0; |
|
padding: 0; |
|
overflow: hidden; |
|
} |
|
.header { |
|
position: absolute; |
|
top: 0px; |
|
padding: 5px; |
|
width: 100%; |
|
text-align: center; |
|
display: flex; |
|
justify-content: center; |
|
} |
|
.left { |
|
position: absolute; |
|
top: 5px; |
|
left: 5px; |
|
} |
|
.right { |
|
position: absolute; |
|
top: 5px; |
|
right: 5px; |
|
} |
|
.footer { |
|
position: absolute; |
|
bottom: 15px; |
|
padding: 5px; |
|
width:100%; |
|
text-align: center; |
|
display: flex; |
|
justify-content: center; |
|
} |
|
.content { |
|
background-color: rgba(0,0,0,0.7); |
|
padding: 5px; |
|
} |
|
.tronbox { |
|
border-radius: 3px; |
|
border: 1px #80EAFF solid; |
|
box-shadow: 0 0 3px #22AFCA, inset 0 0 3px #22AFCA; |
|
} |
|
.flex { |
|
display: flex; |
|
align-items: center; |
|
} |
|
.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; |
|
} |
|
#txt-filter { |
|
width:50px; |
|
} |
|
/* Tooltip */ |
|
#tip { |
|
position:absolute; |
|
z-index:3; |
|
padding:10px; |
|
pointer-events:none; |
|
opacity:0; |
|
max-width: 30%; |
|
} |
|
#tip label { |
|
font-weight: bold; |
|
} |
|
</style> |
|
<!-- D3js v4 --> |
|
<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-interpolate.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> |
|
<!-- Other --> |
|
<script src="https://unpkg.com/3d-force-graph"></script> |
|
<script src="https://unpkg.com/d3-octree"></script> |
|
<script src="https://unpkg.com/d3-force-3d"></script> |
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/89/three.min.js"></script> |
|
<script src="https://use.fontawesome.com/b6ac3d3b75.js"></script> |
|
|
|
</head> |
|
<body> |
|
<div class="header"> |
|
<div class="content tronbox"> |
|
<h1>iCLiKVAL 3D Graph</h1> |
|
</div> |
|
</div> |
|
<div class="left content tronbox"> |
|
<div id="progress-media" class="flex"> |
|
<span>Media:</span> |
|
<div class="progress-content tronbox"> |
|
<div class="progress-value">0</div> |
|
<div class="progress-bar"></div> |
|
</div> |
|
<span class="fa" style="cursor:pointer"></span> |
|
</div> |
|
<div id="progress-annot" class="flex"> |
|
<span>Annot:</span> |
|
<div class="progress-content tronbox"> |
|
<div class="progress-value">0</div> |
|
<div class="progress-bar"></div> |
|
</div> |
|
<span class="fa fa-caret-right" style="cursor:pointer"></span> |
|
</div> |
|
<div id="log"></div> |
|
<div> |
|
<span>Legend</span> |
|
<span id="btn-legend" class="fa" style="cursor:pointer"></span> |
|
<ul id="txt-legend" class="fa-ul"></ul> |
|
</div> |
|
</div> |
|
<div id='tip' class='right content tronbox'></div> |
|
<div class="footer"> |
|
<div class="content tronbox 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 id="chart"></div> |
|
<script type="text/javascript"> |
|
// Global parameters |
|
var p = { |
|
scaleRange: [1, 5], // geometry size scale |
|
types: { // Colors |
|
root: {label: 'Root', name: 'Term', fg: '#000', bg: '#ccc', geometry: 'octahedron'}, |
|
journal_article: {label: 'Article', name: 'Title', fg: '#1f78b4', bg: '#a6cee3', geometry: 'box'}, |
|
audio: {label: 'Audio', name: 'Title', fg: '#ff7f00', bg: '#fdbf6f', geometry: 'box'}, |
|
dataset: {label: 'Dataset', name: 'Title', fg: '#33a02c', bg: '#b2df8a', geometry: 'box'}, |
|
image: {label: 'Image', name: 'Title', fg: '#6a3d9a', bg: '#cab2d6', geometry: 'box'}, |
|
video: {label: 'Video', name: 'Title', fg: '#e31a1c', bg: '#fb9a99', geometry: 'box'}, |
|
key: {label: 'Key', name: 'Term', fg: '#990', bg: '#ff8', geometry: 'cone'}, |
|
value: {label: 'Value', name: 'Term', fg: '#099', bg: '#8fa', geometry: 'sphere'}, |
|
annot: {label: 'Annotation', name: 'Key/Value', fg: '#909', bg: '#f8f', geometry: 'cylinder'} |
|
}, |
|
tipWidth: 200, // The tooltip div has a fixed width |
|
numDim: 3, // Number of dimensions |
|
loopMedia: false, // Loop on media request |
|
loopAnnot: true, // Loop on annot request |
|
maxLabel: 25 // Max lenght of node label (ellipsis) |
|
}; |
|
// Global variables |
|
var v = { |
|
save: {search: {}}, |
|
graph: ForceGraph3D()(document.getElementById("chart")), |
|
scale: d3.scaleLog().range(p.scaleRange), |
|
weights: [1, 1] // Domain of weights |
|
}; |
|
|
|
// RUN |
|
init(); |
|
|
|
function init() { |
|
// 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').on('click', () => loopSwitch('media')); |
|
d3.select('#progress-annot').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)); // Notify the error |
|
}); |
|
|
|
// Add callback to legend button |
|
// Click switch txt-legend between display:none/block |
|
d3.select('#btn-legend').on('click', () => { |
|
const status = d3.select('#btn-legend').classed('fa-caret-right'); |
|
if (status) { // Switch to display |
|
d3.select('#btn-legend').attr('class', 'fa fa-caret-down'); |
|
d3.select('#txt-legend').style('display', 'block'); |
|
} else { // Switch to hide |
|
d3.select('#btn-legend').attr('class', 'fa fa-caret-right'); |
|
d3.select('#txt-legend').style('display', 'none'); |
|
} |
|
}) |
|
|
|
// Add Legend |
|
var li = d3.select('#txt-legend').selectAll('li') |
|
.data(Object.keys(p.types)) |
|
.enter().append('li') |
|
li.append('span') |
|
.attr('class', 'fa fa-li fa-circle') |
|
.style('color', d => { |
|
if (d === 'root' || d === 'key' || d === 'value' || d === 'annot') { |
|
return p.types[d].bg; |
|
} |
|
return p.types[d].fg; |
|
}); |
|
li.append('label').text(d => p.types[d].label); |
|
|
|
// Graph config |
|
v.graph |
|
// .valField(n => v.scale(n.weight)) |
|
.lineOpacity(0.5) |
|
.nodeThreeObject(n => objectHandler(n)) |
|
.onNodeHover(n => onHoverHandler(n)) |
|
.onNodeClick(n => onClickHandler(n)) |
|
.nodeLabel(n => (n.name.length > p.maxLabel) ? n.name.substr(0, p.maxLabel-1) + '...' : n.name) |
|
.d3Force('collide', d3.forceCollide().radius(p.scaleRange[1] * 2)); |
|
// .forceEngine('ngraph'); |
|
// console.log(v.graph.forceEngine()); |
|
|
|
// Default example |
|
d3.select('#txt-search').property("value", 'iclikval'); |
|
d3.select('#btn-search').on('click')(); |
|
d3.select('#progress-media').on('click')(); |
|
d3.select('#progress-annot').on('click')(); |
|
d3.select('#btn-legend').on('click')(); |
|
// Loading message |
|
log('check', 'Initiated'); |
|
} |
|
|
|
function objectHandler(n) { |
|
var geometry = ''; |
|
var weight = v.scale(n.weight); |
|
switch (p.types[n.type].geometry) { |
|
case 'octahedron': |
|
geometry = new THREE.OctahedronGeometry(2 * weight); |
|
break; |
|
case 'box' : |
|
geometry = new THREE.BoxGeometry(4 * weight, 4 * weight, 4 * weight); |
|
break; |
|
case 'cone': |
|
geometry = new THREE.ConeGeometry(2 * weight, 4 * weight); |
|
break; |
|
case 'cylinder': |
|
geometry = new THREE.CylinderGeometry(2 * weight, 2 * weight, 6 * weight); |
|
break; |
|
default: |
|
geometry = new THREE.SphereGeometry(2 * weight); |
|
} |
|
|
|
var color = '#ccc'; |
|
switch (n.type) { |
|
case 'root': |
|
case 'key': |
|
case 'value': |
|
case 'annot': |
|
color = p.types[n.type].bg; |
|
break; |
|
default: |
|
color = p.types[n.type].fg; |
|
} |
|
return new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({color, transparent: true, opacity: 0.75})); |
|
} |
|
|
|
function onHoverHandler(n, prev) { |
|
if (n !== null) { |
|
// Highlight n |
|
tip("show", n); |
|
} else { |
|
// No hover |
|
tip("hide"); |
|
} |
|
// if (prev !== null) { |
|
// Reset prev |
|
// } |
|
} |
|
|
|
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('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 the querystring |
|
// Request to search API |
|
// Parse the response |
|
function requestSearch(page, type) { |
|
// Loading message |
|
log('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('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('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('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('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: 0}); |
|
// link each media to root |
|
Object.keys(v.save.media).forEach((k, i) => { |
|
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('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 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('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 value |
|
// Both media and value are nodes |
|
// Media and value are linked by annotations |
|
function networkValues() { |
|
return new Promise(resolve => { |
|
// Loading message |
|
log('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 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('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); |
|
}); |
|
} |
|
|
|
// Set graph dimension and data |
|
function draw(data) { |
|
return new Promise(resolve => { |
|
// scaleRange |
|
var weights = data.nodes.map(n => n.weight); |
|
v.scale.domain([Math.min(...weights), Math.max(...weights)]); |
|
console.log('scale', Math.min(...weights), Math.max(...weights), " -> ", p.scaleRange); |
|
|
|
v.graph.numDimensions(p.numDim).graphData(data); |
|
log('check', 'Done'); |
|
resolve(); |
|
}); |
|
} |
|
|
|
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; |
|
span = d3.select('#progress-media').select('.fa'); |
|
callback = loopSearch; |
|
break; |
|
case 'annot': |
|
p.loopAnnot = !p.loopAnnot; |
|
bool = p.loopAnnot; |
|
span = d3.select('#progress-annot').select('.fa'); |
|
callback = loopAnnot; |
|
default: |
|
} |
|
if (bool) { |
|
span.attr('class', 'fa fa-pause').attr('title', 'Stop request'); |
|
} else { |
|
span.attr('class', 'fa fa-play').attr('title', 'Restart request'); |
|
} |
|
|
|
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) |
|
} |
|
} |
|
|
|
// Manage Errors |
|
function error(msg, err) { |
|
// Loading message |
|
log('exclamation', msg); |
|
return Promise.reject(msg); |
|
} |
|
|
|
// Manage Log |
|
function log(icon, msg) { |
|
d3.select('#log').html(`<span class="fa fa-${icon}"></span>${msg}`); |
|
} |
|
|
|
// Menu |
|
function toggleDimensions(num) { |
|
// Save dimensions |
|
p.numDim = num; |
|
// Reset coords |
|
if (num < 2) { |
|
v.graph.graphData().nodes.forEach(n => { |
|
n.y = 0; |
|
n.vy = 0; |
|
n.z = 0; |
|
n.vz = 0; |
|
}); |
|
} else if (num < 3) { |
|
v.graph.graphData().nodes.forEach(n => { |
|
n.z = 0; |
|
n.vz = 0; |
|
}); |
|
} |
|
} |
|
|
|
</script> |
|
</html> |