From 2de48856edd2a5f2326153c34ae97c53ec00821d Mon Sep 17 00:00:00 2001 From: Ruben van de Ven Date: Sat, 17 Apr 2021 21:15:03 +0200 Subject: [PATCH] Refactored version with working filters --- README.md | 26 ++ www/graph.css | 15 +- www/graph.js | 1066 ++++++++++++++++++++++++------------------------ www/index.html | 23 +- 4 files changed, 582 insertions(+), 548 deletions(-) diff --git a/README.md b/README.md index b1eab0b..2849454 100644 --- a/README.md +++ b/README.md @@ -15,4 +15,30 @@ wget https://d3js.org/d3.v6.min.js ``` python wiki_relations.py +``` + +## Data + +_Ask_ SMW with the following query: + +``` +[[Category:Deployments||Institution]] OR [[Category:Technologies]] [[Developed by (institutions)::+]] OR [[Category:Technologies]] [[-Software Deployed::+]] OR [[Category:City]] +``` + +``` +?Category +?Geolocation +?City +?City.Has Coordinates=City Coordinates +?City.Is in Country=City Country +?City.Is in Country.Has Coordinates=Country Coordinates +?Clients +?Managed by +?Used by +?Funded by +?Provided by +?Software Deployed +?Software Deployed.Developped by (institutions)=Software Developer +?Datasets Used +?Datasets Used.Developed by Institution=Dataset Developer ``` \ No newline at end of file diff --git a/www/graph.css b/www/graph.css index c2fa212..2b06c50 100644 --- a/www/graph.css +++ b/www/graph.css @@ -19,7 +19,8 @@ body { margin: 0; overflow: hidden; - background: linear-gradient(to top, #040308, #AD4A28, #DD723C, #fc7001, #dcb697, #9ba5ae, #3e5879, #020b1a); + /* background: linear-gradient(to top, #040308, #AD4A28, #DD723C, #fc7001, #dcb697, #9ba5ae, #3e5879, #020b1a); */ + background:linear-gradient(to top, #414141, #99a6b8); font-family: sans-serif; min-height: 100vh; } @@ -33,9 +34,10 @@ svg.dragging { cursor: grabbing; } -svg .links line{ - stroke: darkgray; - stroke-width: 1; +svg .links line,svg .links path{ + stroke: #f3722c; + stroke-width: 3; + fill:none; } .links text{ @@ -47,6 +49,7 @@ svg .links line{ .node text{ text-anchor: middle; + font-size:3pt; } .node circle{ @@ -150,10 +153,10 @@ a:hover{ text-decoration: underline; } -#filters{ +#filters,#menu{ position: fixed; left: 0; top: 0; background: white; padding: 10px; -} \ No newline at end of file +} diff --git a/www/graph.js b/www/graph.js index c0266a4..709e31b 100644 --- a/www/graph.js +++ b/www/graph.js @@ -2,7 +2,7 @@ const CONFIG = { 'nodeSize': 8, 'baseUrl': 'https://www.securityvision.io/wiki/index.php/', - 'dataUrl': '../semantic_data.json', + 'dataUrl': 'result.json', 'preSimulate': false, // run simulation before starting, so we don't start with lines jumping around 'labels': { 'rotate': true, @@ -14,46 +14,35 @@ const CONFIG = { 'center': [11, 47], }, "filters": ["Institution", "Deployments", "Technology", "Dataset"], + + "link_properties": [ + "Clients", + "Managed by", + "Used by", + "Funded by", + "Provided by", + "Software Deployed", + "Software Developer", + "Dataset Developer", + ], + + "geo_properties": [ + "Geolocation", + "City Coordinates", + "Country Coordinates", + ], + + "geo_property_map": { // used to work around a bug in SMW + "City Coordinates": "City", + "Country Coordinates": "City Country", + } }; - -// thanks to https://bl.ocks.org/denisemauldin/cdd667cbaf7b45d600a634c8ae32fae5 -var graph, store; +// let width = window.innerWidth; +// let height = window.innerHeight; -var filteredCategories = [];//= ["Institution"]; -CONFIG.filters.forEach(f => { - let labelEl = document.createElement('label') - let inputEl = document.createElement('input') - let textEl = document.createElement('span'); - inputEl.type = "checkbox"; - textEl.innerText = f; - labelEl.appendChild(inputEl); - labelEl.appendChild(textEl); - - if(!filteredCategories.includes(f)) { - inputEl.checked = true; - } - - inputEl.addEventListener('change', function(e){ - if(e.target.checked) { - filteredCategories.forEach((d, i) => { - if(d == f) { - filteredCategories.splice(i, 1); - } - }); - } else { - if(!filteredCategories.includes(f)) { - filteredCategories.push(f); - } - } - filter(); - update(); - }) - - document.getElementById('filters').appendChild(labelEl); -}) function getSizeForNode(node) { @@ -134,94 +123,536 @@ function splitText(text) { }; function getTitle(obj) { - if (obj.parent) { - return "sub of " + obj.parent.split('#', 1)[0].replace(/_/g, " ") - } - return obj['@id'].split('#', 1)[0].replace(/_/g, " ") + return obj.fulltext; } function getCategories(obj) { - if (!obj._INST) { - return []; - } - return obj['_INST'].map(classId => classId.split('#', 1)[0]); + // console.log(obj); + return obj.printouts['Category'].map(n => n.fulltext.split(':')[1]); } function getClasses(obj) { - if (!obj._INST) - return 'node'; const classes = getCategories(obj); return 'node ' + classes.join(' '); } -function getUrl(obj) { - return CONFIG.baseUrl + obj['@id'].split('#', 1)[0]; + + + +class NodeMap { + constructor(parent) { + this.root = d3.select(parent); + this.resizeEvent = window.addEventListener('resize', this.resize.bind(this)); + } + + resize() { + this.width = window.innerWidth; + this.height = window.innerHeight; + this.vbWidth = 2000; + this.vbHeight = 2000; + this.svg + .attr("viewBox", [0, 0, this.vbWidth, this.vbHeight]) + .attr("width", this.width) + .attr("height", this.height); + } + + + reset() { + this.root.select('svg').remove(); + this.render(); + } + + render() { + this.svg = this.root.append('svg') + this.svg.append('defs').html(''); + this.resize(); + + this.projection = d3.geoHill() + .rotate([-12, 0, 0]) + .translate([this.vbWidth / 2, this.vbHeight * 1.5]) + .scale(this.vbHeight * 1.5); + + this.nodeSize = this.vbHeight / 200; + + this.proj = d3.geoPath().projection(this.projection); + const graticule = d3.geoGraticule10(); + const euCenter = this.projection(CONFIG.eu.center); + + const container = this.svg.append("g").attr("id", "container"); + // container.append("circle").attr("cx", euCenter[0]).attr("cy", euCenter[1]).attr("r", 500).attr("fill","red") + + this.g_countries = container.append("g").attr("id", "countries"); + this.g_borders = container.append("g").attr("id", "borders"); + this.g_graticule = container.append("g").attr('id', 'graticule') + .append('path') + .attr("class", "graticule") + .attr("fill", "none") + .attr('d', this.proj(graticule)) + .attr("stroke-width", "!px") + .attr("stroke", (n) => { + return "lightgray"; + }); + ; + + + const c = this.g_countries.selectAll("path") + .data(this.countries) + .enter() + .append("path") + .attr("class", "countries") + .attr("d", this.proj) + .attr("fill", (n) => { + if (CONFIG.countries.indexOf(n.properties.name) !== -1) { + return ''; + } + return "rgba(200,200,200,.7)"; + }); + + this.g_borders + .append("path") + .attr("class", "borders") + .attr("d", this.proj(this.borders)) + .attr("fill", "none") + .attr("stroke-width", "2px") + .attr("stroke", (n) => { + return "white"; + }); + + this.svg.call(d3.zoom().scaleExtent([0.3, 8]).on("start", () => { + this.svg.node().classList.add("dragging"); + }).on("end", () => { + this.svg.node().classList.remove("dragging"); + }).on("zoom", ({ transform }) => { + container.attr("transform", transform); + })); + + this.node = container.append("g") + .attr('class', 'nodes') + .selectAll(".node"); + this.link = container.append("g") + .attr('class', 'links') + .selectAll(".link"); + + + this.graph.nodes.forEach((d) => { + for (const prop of CONFIG.geo_properties) { + + // console.log(this,d.printouts, prop) + if (d.printouts[prop].length) { + // console.log("fix node", d); + var p = this.projection([d.printouts[prop][0].lon, d.printouts[prop][0].lat]); + d.x = p[0]; + d.y = p[1]; + // d.targetX = p[0]; + // d.targetY = p[1]; + d.fx = p[0]; + d.fy = p[1]; + // d.targetLat = d.printouts[prop][0].lat; + // d.targetLon = d.printouts[prop][0].lon; + break + } + } + }) + + + + // this.nodeMap = Object.fromEntries(this.graph.nodes.map(d => [d['id'], d])); + // this.links = this.graph.links.filter(l => this.nodeMap[l.source] && nodeMap[l.target]).map(d => Object.create(d)); + // console.log(this.nodeMap, this.graph.links); + + + this.simulation = d3.forceSimulation() + .force("link", d3.forceLink() + .id(d => d['@id']) + .iterations(2) // increase to make more rigid + .distance((l) => { + // if (getCategories(l.source).indexOf('City') || getCategories(l.target).indexOf('City')) { + // // if(l.source.lat || l.target.lat) { + // return 2; + // } + return this.nodeSize * 5; + }) + ) + // .force("charge", d3.forceManyBody() + // .strength(-10) + // ) + // .force("center", d3.forceCenter(width / 2, height / 2)) + .force("collision", d3.forceCollide(this.nodeSize)) + // TODO look at simpler labels https://github.com/d3fc/d3fc/tree/master/packages/d3fc-label-layout + // TODO look at rects https://github.com/emeeks/d3-bboxCollide + .force("posX", d3.forceX(n => n.targetX || 0).strength(n => n.targetX ? 1 : 0)) // TODO: should not be or 0 + .force("posY", d3.forceY(n => n.targetY || 0).strength(n => n.targetY ? 1 : 0)) + ; + + this.update(); + } + + getSizeForNode(node) { + return this.nodeSize; + } + + update() { + console.log(this.graph) + // see also: https://www.createwithdata.com/enter-exit-with-d3-join/ + this.node = this.node.data(this.graph.nodes, d => d.id) + .join((enter) => { + let group = enter.append("g").attr("class", getClasses); + // group.call(drag(simulation)); + group.on("click", (evt, n) => selectNode(evt, n, node)); + group.append('circle').attr("r", this.nodeSize); + var nodeTitle = group.append('text').attr("class", "nodeTitle").attr("y", "5"); + nodeTitle + .each(function (node, nodes) { + var textLength = void 0; + const self = d3.select(this); + const titleText = getTitle(node); + var titleTexts = false; + if (titleText.length > 20) { + titleTexts = splitText(titleText); + } + if (titleTexts !== false) { + const tspan1 = self.append("tspan").text(titleTexts[0]).attr("y", "-10").attr("x", "0"); + const tspan = self.append("tspan").text(titleTexts[1]).attr("y", "10").attr("x", "0"); + const textLength1 = tspan.node().getComputedTextLength(); + const textLength2 = tspan.node().getComputedTextLength(); + textLength = Math.max(textLength1, textLength2); + } else { + self.text(titleText); + textLength = self.node().getComputedTextLength(); + } + // scale according to text length: + if (textLength > this.nodeSize * 2) { + self.attr('transform', 'scale(' + this.nodeSize * 2 / textLength / 1.05 + ')'); + } + }); + return group; + }); + + this.link = this.link + .data(this.graph.links) + .join( + enter => { + console.log(enter); + let group = enter.append("g").attr("class", "link"); + group.append("path").attr("marker-end", "url(#arrowHead)").attr('id', (d, i) => 'linkid_' + i); + group.filter((l) => l.name != "City").append("text").attr("class", "labelText").text(function (l) { + return l.name; + }); + // group.append("text") + // .attr("class", "labelText") + // .attr("dx", 20) + // .attr("dy", 0) + // .style("fill", "red") + // .append("textPath") + // .attr("xlink:href", function (d, i) { return "#linkid_" + i; }) + // .attr("startOffset","50%") + // .text((d,i) => d.name ); + return group; + } + ) + ; + + + this.simulation.nodes(this.graph.nodes); + this.simulation.force("link") + .links(this.graph.links); + + this.simulation.on("tick", () => { + + // console.log('t', link._groups[0].length); + const _mapGraph = this; + this.link.each(function (l) { + let sourceX, targetX, midX, dx, dy, angle; + + // This mess makes the arrows exactly perfect. + // thanks to http://bl.ocks.org/curran/9b73eb564c1c8a3d8f3ab207de364bf4 + if (l.source.x < l.target.x) { + sourceX = l.source.x; + targetX = l.target.x; + } else if (l.target.x < l.source.x) { + targetX = l.target.x; + sourceX = l.source.x; + } else if (l.target.isCircle) { + targetX = sourceX = l.target.x; + } else if (l.source.isCircle) { + targetX = sourceX = l.source.x; + } else { + midX = (l.source.x + l.target.x) / 2; + if (midX > l.target.x) { + midX = l.target.x; + } else if (midX > l.source.x) { + midX = l.source.x; + } else if (midX < l.target.x) { + midX = l.target.x; + } else if (midX < l.source.x) { + midX = l.source.x; + } + targetX = sourceX = midX; + } + + dx = targetX - sourceX; + dy = l.target.y - l.source.y; + angle = Math.atan2(dx, dy); + + /* DISABLED + srcSize = (typeof nodePositions[l.source.index] != 'undefined') ? selectedNodeSize : nodeSize; + tgtSize = (typeof nodePositions[l.target.index] != 'undefined') ? selectedNodeSize : nodeSize; + */ + var srcSize = _mapGraph.getSizeForNode(l.source); + var tgtSize = _mapGraph.getSizeForNode(l.target); + + // Compute the line endpoint such that the arrow + // is touching the edge of the node rectangle perfectly. + l.sourceX = sourceX + Math.sin(angle) * srcSize; + l.targetX = targetX - Math.sin(angle) * tgtSize; + l.sourceY = l.source.y + Math.cos(angle) * srcSize; + l.targetY = l.target.y - Math.cos(angle) * tgtSize; + + // const coor_source = _mapGraph.projection.invert([l.source.x, l.source.y]); + // const coor_target = _mapGraph.projection.invert([l.target.x, l.target.y]); + // const middleCoor = [coor_source[0] * .5 + coor_target[0] * .5, coor_source[1] * .5 + coor_target[1] * .5]; + // const middlePoint = _mapGraph.projection(middleCoor); + + const dr = Math.sqrt(dx * dx + dy * dy); + + // "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y + let rel = d3.select(this); + rel.select("path") //${middlePoint[0]},${middlePoint[1]} + // .attr('d', `M ${l.sourceX},${l.sourceY} L ${l.targetX},${l.targetY}`) + .attr('d', `M ${l.sourceX},${l.sourceY} A ${dr},${dr} 0 0,1 ${l.targetX},${l.targetY}`) + // .attr("x1", l.sourceX) + // .attr("y1", l.sourceY) + // .attr("x2", l.targetX) + // .attr("y2", l.targetY) + + rel.select('text') + .attr("transform", function (d) { + const dx = (l.target.x - l.source.x) / 2; + const dy = (l.target.y - l.source.y) / 2; + const x = l.source.x + dx; + const y = l.source.y + dy; + const deg = Math.atan(dy / dx) * 180 / Math.PI; + // if dx/dy == 0/0 -> deg == NaN + if (isNaN(deg)) { + return ""; + } + // return ""; + return "translate(" + x + " " + y + ") rotate(" + (CONFIG.labels.rotate ? deg : 0) + ")"; + }); + + + }) + + this.node + .attr("transform", d => `translate(${d.x}, ${d.y})`); + }); + + + this.simulation.alpha = 0; + this.simulation.restart(); + + } + + setWorld(world) { + this.borders = topojson.mesh(world, world.objects.countries, (a, b) => a !== b) + this.countries = topojson.feature(world, world.objects.countries).features; + } + + setStore(store) { + this.store = store; + this.graph = this.store.graph; + store.registerMap(this); + } + + // alias for update (redraw is used in dc) + redraw() { + this.update() + } } +var mapGraph = new NodeMap('#map') -// see also: http://bl.ocks.org/dwtkns/4973620 -let width = window.innerWidth; -let height = window.innerHeight; +JsonToGraph = function (data) { + let nodes = []; + let links = []; -const svg = d3.select("svg") + let smwBugFixLocationMaps = {}; + console.log(data) + for (const node_id in data.results) { + if (Object.hasOwnProperty.call(data.results, node_id)) { + let node = data.results[node_id]; + node.id = node.fulltext; //node_id; + nodes.push(node); + // console.log(node_id, node); + // work around SMW bug in Ask. 1/2 + for (const idx of Object.keys(CONFIG.geo_property_map)) { + if (node.printouts[idx].length) { + const srcProp = CONFIG.geo_property_map[idx]; + const loc = node.printouts[srcProp][0].fulltext; + smwBugFixLocationMaps[loc] = node.printouts[idx]; + // console.debug("Set location for", loc, node.printouts[idx]) + } + } -// SET UP MAP: -const projection = d3.geoHill() - .rotate([-12, 0, 0]) - .translate([width / 2, height * 1.5]) - .scale(height * 1.5); + for (const prop of CONFIG.link_properties) { + if (!node.printouts.hasOwnProperty(prop)) { + continue; + } + for (const target_node of node.printouts[prop]) { + links.push({ + "source": node_id, + "target": target_node.fulltext, + "name": prop + }) + } + } + } + } -const proj = d3.geoPath().projection(projection); -const graticule = d3.geoGraticule10(); -const euCenter = projection(CONFIG.eu.center); + let fixes = 0; + nodes.forEach((node) => { + // work around SMW bug in Ask. 2/2 + for (const idx of Object.keys(CONFIG.geo_property_map)) { + if (!node.printouts[idx].length) { + const srcProp = CONFIG.geo_property_map[idx]; + // only retrievable if we know the name of the location (which for some reason _is_ often there) + if (!node.printouts[srcProp].length) { + continue; + } + const loc = node.printouts[srcProp][0].fulltext; + if (!smwBugFixLocationMaps.hasOwnProperty(loc)) { + continue; + } + node.printouts[idx] = smwBugFixLocationMaps[loc] + fixes++; + } + } + }) + + console.debug(`Fixed location for ${fixes} nodes`); -const container = svg.append("g").attr("id", "container"); -// container.append("circle").attr("cx", euCenter[0]).attr("cy", euCenter[1]).attr("r", 500).attr("fill","red") + console.log(links.length); -const g_countries = container.append("g").attr("id", "countries"); -const g_borders = container.append("g").attr("id", "borders"); -const g_graticule = container.append("g") - .append('path') - .attr("class", "graticule") - .attr("fill", "none") - .attr('d', proj(graticule)) - .attr("stroke-width", "!px") - .attr("stroke", (n) => { - return "lightgray"; + nodeMap = Object.fromEntries(nodes.map(d => [d['id'], d])); + links = links.filter(l => nodeMap[l.source] && nodeMap[l.target]).map(l => { + l.source = nodeMap[l.source]; + l.target = nodeMap[l.target]; + return l; }); -; -function sizeWindow() { - width = window.innerWidth; - height = window.innerHeight; - svg - .attr("viewBox", [0, 0, width, height]) - .attr("width", width) - .attr("height", height); - // - // - // update(); + return { nodes, links } } -sizeWindow() -window.addEventListener('resize', sizeWindow); - -svg.call(d3.zoom().scaleExtent([0.3, 8]).on("start", function () { - svg.node().classList.add("dragging"); -}).on("end", function () { - svg.node().classList.remove("dragging"); -}).on("zoom", function ({ transform }) { - container.attr("transform", transform); -})); -var node = container.append("g") - .attr('class', 'nodes') - .selectAll(".node"); -var link = container.append("g") - .attr('class', 'links') - .selectAll(".link"); -// let linkLines = link.selectAll('line'); +var typeFilterList = [ + // 'Deployments' +] +class Store { + constructor(graph, parent) { + this.nodes = graph.nodes; + this.links = graph.links; + this.graph = { + 'nodes': [], + 'links': [] + } + + this.root = document.querySelector(parent); + + this._maps = []; + + this.filters = { + 'categories': [], + } + + this.filter(); + + } + + registerMap(map) { + this._maps.push(map); + return this; + } + + update() { + this._maps.forEach(m => { + m.update(); + }); + } + + filter() { + // add and remove nodes from data based on type filters + this.nodes.forEach((n) => { + if (!this.filters.categories.includes(n.printouts['Category'][0].fulltext.split(':')[1])) { + if (n.filtered || typeof n.filtered === 'undefined') { + n.filtered = false; + this.graph.nodes.push(n); + } + } else if (!n.filtered) { + n.filtered = true; + this.graph.nodes.forEach((d, i) => { + if (n.id === d.id) { + this.graph.nodes.splice(i, 1); + return; + } + }); + } + }); + + // add and remove links from data based on availability of nodes + this.links.forEach((l) => { + if (this.graph.nodes.includes(l.source) && this.graph.nodes.includes(l.target)) { + if (l.filtered || typeof l.filtered === 'undefined') + this.graph.links.push(l); + l.filtered = false; + } else { + if (l.filtered === false) { + this.graph.links.forEach((d, i) => { + if (l.id === d.id) { + this.graph.links.splice(i, 1); + } + }); + } + l.filtered = true; + } + }); + } + + render() { + CONFIG.filters.forEach(f => { + let labelEl = document.createElement('label') + let inputEl = document.createElement('input') + let textEl = document.createElement('span'); + inputEl.type = "checkbox"; + textEl.innerText = f; + labelEl.appendChild(inputEl); + labelEl.appendChild(textEl); + + if (!this.filters.categories.includes(f)) { + inputEl.checked = true; + } + + inputEl.addEventListener('change', (e) => { + if (e.target.checked) { + this.filters.categories.forEach((d, i) => { + if (d == f) { + this.filters.categories.splice(i, 1); + } + }); + } else { + if (!this.filters.categories.includes(f)) { + this.filters.categories.push(f); + } + } + this.filter(); + this.update(); + }) + + this.root.appendChild(labelEl); + }) + + } +} // REQUEST ATLAS & GRAPH @@ -232,434 +663,15 @@ Promise.all([fetch(req_data), fetch(req_world)]) return Promise.all([res_data.json(), res_world.json()]); }) .then(([data, world]) => { - buildGraph(data, world); + var graph = JsonToGraph(data); + var store = new Store(graph, '#filters'); + mapGraph.setWorld(world); + mapGraph.setStore(store); + store.render() + + mapGraph.render() + }).catch(error => { console.error(error); }); ; - -let linkCounts = []; -const simulation = d3.forceSimulation() - .force("link", d3.forceLink() - .id(d => d['@id']) - .iterations(2) // increase to make more rigid - .distance((l) => { - // if (getCategories(l.source).indexOf('City') || getCategories(l.target).indexOf('City')) { - // // if(l.source.lat || l.target.lat) { - // return 2; - // } - return 10; - }) - .strength((l) => { - if (linkCounts.length < 1) { - // replicate from d3-force/src/link.js so we have access to this in our own strength function - linkCounts = new Array(store.nodes.length) - for (let i = 0; i < store.links.length; ++i) { - let link = store.links[i]; - linkCounts[link.source.index] = (linkCounts[link.source.index] || 0) + 1; - linkCounts[link.target.index] = (linkCounts[link.target.index] || 0) + 1; - } - } - - if (store.nodesBorderingEu.indexOf(l.source) !== -1 || store.nodesBorderingEu.indexOf(l.target) !== -1) { - // console.log('outside', l.target) - return 0.0001; - } - // original: - return 1 / Math.min(linkCounts[l.source.index], linkCounts[l.target.index]); - }) - ) - // .force("charge", d3.forceManyBody() - // .strength(-10) - // ) - // .force("center", d3.forceCenter(width / 2, height / 2)) - .force("collision", d3.forceCollide(function (d) { - return getSizeForNode(d) * 1.5; // avoid overlapping nodes - })) - .force("outsideEu", d3.forceRadial(height/2.7, euCenter[0], euCenter[1]) - .strength(function (node, idx) { - // return 1; - if (store.nodesBorderingEu.indexOf(node) !== -1) { - // console.log(node, store.nodesBorderingEu.indexOf(node) !== -1); - return 1; - } - return 0; - }) - ); - -function buildGraph(data, world) { - // land = topojson.feature(world, world.objects.land) - const borders = topojson.mesh(world, world.objects.countries, (a, b) => a !== b) - const countries = topojson.feature(world, world.objects.countries).features; - - - const nodes = data.nodes.filter(n => n._INST || n.parent).map(d => Object.create(d)); - console.log('all nodes', nodes); - const nodeMap = Object.fromEntries(nodes.map(d => [d['@id'], d])); - const links = data.links.filter(l => nodeMap[l.source] && nodeMap[l.target]).map(d => Object.create(d)); - console.log(nodeMap, links); - - graph = { - "nodes": [], - "links": [], - } - store = { - "nodes": [...nodes], - "links": [...links], - "nodesBorderingEu": links.filter(l => l.name == 'City' && (nodeMap[l.target].lon < CONFIG.eu.lonMin || nodeMap[l.target].lon > CONFIG.eu.lonMax)).map(l => nodeMap[l.source]), - } - - filter(); - - // the .source and .target attributes are still ID's. Only after initialisation of the force are they replaced with their representative objects - - const nodesEastOfEu = links.filter(l => l.name == 'City' && (nodeMap[l.target].lon < CONFIG.eu.lonMin)).map(l => nodeMap[l.source]); - - - var c = g_countries.selectAll("path") - .data(countries) - .enter() - .append("path") - .attr("class", "countries") - .attr("d", proj) - .attr("fill", (n) => { - if (CONFIG.countries.indexOf(n.properties.name) !== -1) { - return ''; - } - return "rgba(200,200,200,.7)"; - }); - - g_borders - .append("path") - .attr("class", "borders") - .attr("d", proj(borders)) - .attr("fill", "none") - .attr("stroke-width", "2px") - .attr("stroke", (n) => { - return "white"; - }); - - nodes.forEach(function (d) { - d.x = euCenter[0]; - d.y = euCenter[1]; - - if (store.nodesBorderingEu.indexOf(d) !== -1) { - if (nodesEastOfEu.indexOf(d) !== -1) { - d.x = 466.5836692678423; - d.y = 466.3493609705728; - } else { - d.x = 1406.608195836305; - d.y = 807.9332721328062; - } - - } - - if (d.lon && d.lat) { - // console.log("fix node", d); - var p = projection([d.lon, d.lat]); - d.x = p[0]; - d.y = p[1]; - d.fx = p[0]; - d.fy = p[1]; - } - }) - - update(); - - - return svg.node(); -} - -function update() { - - - // see also: https://www.createwithdata.com/enter-exit-with-d3-join/ - node = node.data(graph.nodes, d => d.id) - .join(function (enter) { - let group = enter.append("g").attr("class", getClasses); - group.call(drag(simulation)); - group.on("click", (evt, n) => selectNode(evt, n, node)); - group.append('circle').attr("r", getSizeForNode); - var nodeTitle = group.append('text').attr("class", "nodeTitle").attr("y", "5"); - nodeTitle - .each(function (node, nodes) { - var textLength = void 0; - const self = d3.select(this); - const titleText = getTitle(node); - var titleTexts = false; - if (titleText.length > 20) { - titleTexts = splitText(titleText); - } - if (titleTexts !== false) { - const tspan1 = self.append("tspan").text(titleTexts[0]).attr("y", "-10").attr("x", "0"); - const tspan = self.append("tspan").text(titleTexts[1]).attr("y", "10").attr("x", "0"); - const textLength1 = tspan.node().getComputedTextLength(); - const textLength2 = tspan.node().getComputedTextLength(); - textLength = Math.max(textLength1, textLength2); - } else { - self.text(titleText); - textLength = self.node().getComputedTextLength(); - } - // scale according to text length: - if (textLength > getSizeForNode(node) * 2) { - self.attr('transform', 'scale(' + getSizeForNode(node) * 2 / textLength / 1.05 + ')'); - } - }); - return group; - }); - - // let linkText; - // let linkLine; - link = link - .data(graph.links) - .join( - enter => { - console.log(enter); - let group = enter.append("g").attr("class", "link"); - group.append("line").attr("marker-end", "url(#arrowHead)").attr('id', (d,i) => 'linkid_' + i); - group.filter((l) => l.name != "City").append("text").attr("class", "labelText").text(function (l) { - return l.name; - }); - // group.append("text") - // .attr("class", "labelText") - // .attr("dx", 20) - // .attr("dy", 0) - // .style("fill", "red") - // .append("textPath") - // .attr("xlink:href", function (d, i) { return "#linkid_" + i; }) - // .attr("startOffset","50%") - // .text((d,i) => d.name ); - return group; - } - ) - ; - // let linkEnter = link.enter() - // let linkLine = link.selectAll('line'); - // let newLinks = link.enter().append("g").attr("class","link"); - // newLinks.append("line").attr("marker-end", "url(#arrowHead)"); - // newLinks.filter((l) => l.name != "City").append("text").text(function (l) { - // return l.name; - // }); - - // link.exit().remove(); - - // let linkLine = link.selectAll('line'); - // let linkText = link.selectAll('text'); - // // }) function(update){ return update }, function(exit) { - // // exit.remove(); - // // }) - - // ; - - // console.log(link, linkText, linkLine) - - simulation.nodes(graph.nodes); - simulation.on("tick", () => { - - // console.log('t', link._groups[0].length); - - link.each(function (d) { - let sourceX, targetX, midX, dx, dy, angle; - - // This mess makes the arrows exactly perfect. - // thanks to http://bl.ocks.org/curran/9b73eb564c1c8a3d8f3ab207de364bf4 - if (d.source.x < d.target.x) { - sourceX = d.source.x; - targetX = d.target.x; - } else if (d.target.x < d.source.x) { - targetX = d.target.x; - sourceX = d.source.x; - } else if (d.target.isCircle) { - targetX = sourceX = d.target.x; - } else if (d.source.isCircle) { - targetX = sourceX = d.source.x; - } else { - midX = (d.source.x + d.target.x) / 2; - if (midX > d.target.x) { - midX = d.target.x; - } else if (midX > d.source.x) { - midX = d.source.x; - } else if (midX < d.target.x) { - midX = d.target.x; - } else if (midX < d.source.x) { - midX = d.source.x; - } - targetX = sourceX = midX; - } - - dx = targetX - sourceX; - dy = d.target.y - d.source.y; - angle = Math.atan2(dx, dy); - - /* DISABLED - srcSize = (typeof nodePositions[d.source.index] != 'undefined') ? selectedNodeSize : nodeSize; - tgtSize = (typeof nodePositions[d.target.index] != 'undefined') ? selectedNodeSize : nodeSize; - */ - var srcSize = getSizeForNode(d.source); - var tgtSize = getSizeForNode(d.target); - - // Compute the line endpoint such that the arrow - // is touching the edge of the node rectangle perfectly. - d.sourceX = sourceX + Math.sin(angle) * srcSize; - d.targetX = targetX - Math.sin(angle) * tgtSize; - d.sourceY = d.source.y + Math.cos(angle) * srcSize; - d.targetY = d.target.y - Math.cos(angle) * tgtSize; - - let rel = d3.select(this); - rel.select('line') - .attr("x1", d.sourceX) - .attr("y1", d.sourceY) - .attr("x2", d.targetX) - .attr("y2", d.targetY) - - rel.select('text') - .attr("transform", function (d) { - const dx = (d.target.x - d.source.x) / 2; - const dy = (d.target.y - d.source.y) / 2; - const x = d.source.x + dx; - const y = d.source.y + dy; - const deg = Math.atan(dy / dx) * 180 / Math.PI; - // if dx/dy == 0/0 -> deg == NaN - if (isNaN(deg)) { - return ""; - } - // return ""; - return "translate(" + x + " " + y + ") rotate(" + (CONFIG.labels.rotate ? deg : 0) + ")"; - }); - - - })/*.attr("x1", function (d) { - return d.sourceX; - }).attr("y1", function (d) { - return d.sourceY; - }).attr("x2", function (d) { - return d.targetX; - }).attr("y2", function (d) { - return d.targetY; - });*/ - // linkText - - node - .attr("transform", d => `translate(${d.x}, ${d.y})`); - }); - - simulation.force("link") - .links(graph.links); - - - // simulate the first bit without drawing, so we don't have the 'jumping' graph in the beginning - if (CONFIG.preSimulate) { - for (var i = 0, n = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())); i < n; ++i) { - simulation.tick(); - } - } - -} - -// filter function -function filter() { - // add and remove nodes from data based on type filters - store.nodes.forEach(function (n) { - const cats = getCategories(n); - if (cats.every(c => filteredCategories.includes(c))) { - // hide - graph.nodes.forEach(function (d, i) { - if (n['@id'] === d['@id']) { - graph.nodes.splice(i, 1); - } - }); - } else { - // add back - if (!graph.nodes.includes(n)) { - graph.nodes.push(n); - } - } - - }); - - // add and remove links from data based on availability of nodes - let checkOn; - if(typeof store.links[0].source !== 'object') { - checkOn = graph.nodes.map(n => n['@id']) - } else { - checkOn = graph.nodes - } - - store.links.forEach(function (l) { - - if (!checkOn.includes(l.source) || !checkOn.includes(l.target)) { - // hide - console.log('hide!') - graph.links.forEach(function (d, i) { - if (l === d) { - graph.links.splice(i, 1); - } - }); - - } else if (!graph.links.includes(l)) { - graph.links[graph.links.length] = l; - // console.log('add', l, graph.links) - } - - }); - // console.log(graph.links) - -} - -const drag = simulation => { - - let ignoreDrag = false; - function dragstarted(event) { - if (event.subject.fx) ignoreDrag = true; - if (!event.active) simulation.alphaTarget(0.3).restart(); - event.subject.fx = event.subject.x; - event.subject.fy = event.subject.y; - } - - function dragged(event) { - if (ignoreDrag) return; - event.subject.fx = event.x; - event.subject.fy = event.y; - } - - function dragended(event) { - if (ignoreDrag) { - ignoreDrag = false; - return; - } - if (!event.active) simulation.alphaTarget(0); - event.subject.fx = null; - event.subject.fy = null; - } - - return d3.drag() - .on("start", dragstarted) - .on("drag", dragged) - .on("end", dragended); -}; - -function selectNode(evt, node, d3Node) { - console.log(evt, node, d3Node); - document.querySelectorAll('svg .node').forEach(n => n.classList.remove('selected')); - d3Node._groups[0][node.index].classList.add('selected'); - - infoEl = document.getElementById('nodeInfo'); - infoEl.classList.remove('hidden'); - - const url = getUrl(node); - const hrefEl = infoEl.querySelector('.nodeHref'); - hrefEl.textContent = getTitle(node); - hrefEl.setAttribute('href', url); - infoEl.querySelector('.nodeContents').src = url; - -} - -const closeEl = document.getElementById('closeInfo'); -if(closeEl) { - - document.getElementById('closeInfo').addEventListener('click', (evt) => { - document.querySelectorAll('svg .node').forEach(n => n.classList.remove('selected')); - document.getElementById('nodeInfo').classList.add('hidden'); - }) - -} \ No newline at end of file diff --git a/www/index.html b/www/index.html index 757806e..a2e7a7e 100644 --- a/www/index.html +++ b/www/index.html @@ -1,35 +1,28 @@ + + - - - - - - - - - - +
+ + - + + + \ No newline at end of file