diff --git a/music_graph/src/MusicGraph.js b/music_graph/src/MusicGraph.js index 3272f45..77bc1db 100644 --- a/music_graph/src/MusicGraph.js +++ b/music_graph/src/MusicGraph.js @@ -1,6 +1,7 @@ import * as d3 from 'd3' import icons from './icons' import {MusicGraphApi} from './MusicGraphApi' +import {fitCaptionIntoCircle} from './graphGeometry' // TODO: export somewhere else const arc = function (radius, itemNumber, itemCount, width) { @@ -32,7 +33,7 @@ export function MusicGraph(data) { this.simulation = d3.forceSimulation() .force('charge', d3.forceManyBody()) .force('collide', d3.forceCollide() - .radius(50) + .radius(40) .strength(1)) .force('center', d3.forceCenter(width / 2, height / 2)) @@ -138,9 +139,9 @@ export function MusicGraph(data) { n.targetLinks.has(d.id)) this.label.classed('selected', n => - n.sourceLinks.has(d.id) || - n.targetLinks.has(d.id) || - n.id === d.id) + n.node.sourceLinks.has(d.id) || + n.node.targetLinks.has(d.id) || + n.node.id === d.id) this.node.classed('hover', n => n.id === d.id) @@ -182,8 +183,8 @@ export function MusicGraph(data) { fn: (d) => { this.api.getRelatedByMbid(d.mbid) .then(data => { - this.addNodes(data.newNodes, data.relations, d.id) this.expandedNodes.add(d.id) + this.addNodes(data.newNodes, data.relations, d.id) d.relatedExpanded = true }) } @@ -408,12 +409,10 @@ export function MusicGraph(data) { .force('link', d3.forceLink(this.links) .id(d => d.id) .strength(l => l.weight) - .distance(d => Math.min( - (1.2 / d.weight) * (94 * (this.expandedNodes.size + 1))) - ) + .distance(d => (1.15 / d.weight) * (82 * (this.expandedNodes.size + 1))) ) - this.simulation.alphaTarget(0.01).restart() + this.simulation.alphaTarget(0.03).restart() // Add new links this.link = this.container.select('#links') @@ -451,14 +450,16 @@ export function MusicGraph(data) { // Add new labels this.label = this.container.select('#labels') .selectAll('.label') - .data(this.nodes) + .data([].concat(...this.nodes.map(d => fitCaptionIntoCircle(d.name, d)))) this.label.exit().remove() this.label = this.label .enter() .append('text') .merge(this.label) - .text(d => d.name) .classed('label', true) + .classed('release', d => d.node.type === 'Album' || d.node.type === 'EP' || d.node.type === 'Single') + .classed('tag', d => d.node.type === 'Tag') + .text(d => d.text) } this.setupKeyBindings = function () { @@ -514,8 +515,8 @@ export function MusicGraph(data) { .attr('cx', d => d.x) .attr('cy', d => d.y) this.label - .attr('x', d => d.x) - .attr('y', d => d.y + 5) + .attr('x', d => Math.round(d.node.x)) + .attr('y', d => d.node.y + d.baseline) }) this._update() diff --git a/music_graph/src/MusicGraphApi.js b/music_graph/src/MusicGraphApi.js index 209a47e..c385a96 100644 --- a/music_graph/src/MusicGraphApi.js +++ b/music_graph/src/MusicGraphApi.js @@ -48,17 +48,7 @@ const nodeUtils = { } }, radius: function (type) { - if (type === 'Group') { - return 35 - } else if (type === 'Artist') { - return 25 - } else if (type === 'Tag') { - return 20 - } else if (type === 'Album') { - return 20 - } else if (type === 'EP' || type === 'Single') { - return 15 - } + return 35 } } diff --git a/music_graph/src/components/HelloWorld.vue b/music_graph/src/components/HelloWorld.vue index 363d518..22f7f5a 100644 --- a/music_graph/src/components/HelloWorld.vue +++ b/music_graph/src/components/HelloWorld.vue @@ -3,6 +3,7 @@
+ @@ -74,13 +75,12 @@ export default { /* Link */ svg .link.selected { stroke-width: 2; - stroke-opacity: 1; } svg .link { - stroke: orange; + stroke: #FFE082; pointer-events: none; - stroke-opacity: 1; + stroke-opacity: 0.3; stroke-width: 1; } @@ -91,11 +91,11 @@ export default { /* Node */ svg .node.selected { - stroke: red; + stroke: #7C4DFF; } svg .node.hover { - stroke: red; + stroke: #7C4DFF; } svg.hover .node:not(.selected):not(.hover) { @@ -104,11 +104,22 @@ export default { svg .node { fill: transparent; + stroke-width: 2; } /* Label */ svg .label { text-anchor: middle; + font-family: Tahoma, sans-serif; + font-size: 11px; + } + + svg .label.release { + fill: darkgrey; + } + + svg .label.tag { + fill: darkgrey; } svg.hover .label:not(.selected) { @@ -116,27 +127,18 @@ export default { } body { - background: #E7EDEB; + background: #ffffff; } /* test */ #menu .menu-item { cursor: pointer; - fill: orange; + fill: #FFB300; } #menu .menu-item.hover { - fill: darkorange; - } - - #menu .menu-item.hover text { - font-weight: bold; - } - - #menu .menu-item text { - text-anchor: middle; - fill: white; + fill: #FF8F00; } #menu .menu-icon { diff --git a/music_graph/src/graphGeometry.js b/music_graph/src/graphGeometry.js new file mode 100644 index 0000000..123cffd --- /dev/null +++ b/music_graph/src/graphGeometry.js @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2002-2019 "Neo4j," + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import measureText from './textMeasurement' + +let addShortenedNextWord = (line, word, measure) => { + const result = [] + while (!(word.length <= 2)) { + word = word.substr(0, word.length - 2) + '\u2026' + if (measure(word) < line.remainingWidth) { + line.text += ` ${word}` + break + } else { + result.push(undefined) + } + } + return result +} + +let noEmptyLines = function (lines) { + for (let line of Array.from(lines)) { + if (line.text.length === 0) { + return false + } + } + return true +} + +export let fitCaptionIntoCircle = function (captionText, node) { + const fontFamily = 'Tahoma' + const fontSize = 11 + const lineHeight = fontSize + const measure = text => measureText(text, fontFamily, fontSize) + + const words = captionText.split(' ') + + const emptyLine = function (lineCount, iLine) { + let baseline = (1 + iLine - lineCount / 2) * lineHeight + const containingHeight = iLine < lineCount / 2 ? baseline - lineHeight : baseline + const lineWidth = + Math.sqrt(node.radius * node.radius - containingHeight * containingHeight) * 2 + return { + node, + text: '', + baseline, + remainingWidth: lineWidth + } + } + + const fitOnFixedNumberOfLines = function (lineCount) { + const lines = [] + let iWord = 0 + for ( + let iLine = 0, end = lineCount - 1, asc = end >= 0; + asc ? iLine <= end : iLine >= end; + asc ? iLine++ : iLine-- + ) { + const line = emptyLine(lineCount, iLine) + while ( + iWord < words.length && + measure(` ${words[iWord]}`) < line.remainingWidth) { + line.text += ` ${words[iWord]}` + line.remainingWidth -= measure(` ${words[iWord]}`) + iWord++ + } + lines.push(line) + } + if (iWord < words.length) { + addShortenedNextWord(lines[lineCount - 1], words[iWord], measure) + } + return [lines, iWord] + } + + let consumedWords = 0 + const maxLines = (node.radius * 2) / fontSize + + let lines = [emptyLine(1, 0)] + for ( + let lineCount = 1, end = maxLines, asc = end >= 1; + asc ? lineCount <= end : lineCount >= end; + asc ? lineCount++ : lineCount-- + ) { + const [candidateLines, candidateWords] = Array.from( + fitOnFixedNumberOfLines(lineCount) + ) + if (noEmptyLines(candidateLines)) { + [lines, consumedWords] = Array.from([candidateLines, candidateWords]) + } + if (consumedWords >= words.length) { + return lines + } + } + return lines +} diff --git a/music_graph/src/icons.js b/music_graph/src/icons.js index 55d58b7..31d9285 100644 --- a/music_graph/src/icons.js +++ b/music_graph/src/icons.js @@ -1,50 +1,41 @@ const icons = { - expand: '\n' + - '\n' + - '', - release: '\n' + - '\n' + - '', - guitar: '' + - '', - hash: '\n' + - '\n' + - '', - delete: '\n' + - '\t\t\n' + - '' + expand: '', + release: '', + guitar: '', + hash: '', + delete: '' } diff --git a/music_graph/src/textMeasurement.js b/music_graph/src/textMeasurement.js new file mode 100644 index 0000000..b4532f3 --- /dev/null +++ b/music_graph/src/textMeasurement.js @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2002-2019 "Neo4j," + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import * as d3 from 'd3' + +const measureUsingCanvas = function (text, font) { + const canvasSelection = d3.select('canvas#textMeasurementCanvas').data([this]) + canvasSelection + .enter() + .append('canvas') + .attr('id', 'textMeasurementCanvas') + .style('display', 'none') + + const canvas = canvasSelection.node() + const context = canvas.getContext('2d') + context.font = font + return context.measureText(text).width +} + +export default function (text, fontFamily, fontSize) { + const font = `normal normal normal ${fontSize}px/normal ${fontFamily}` + return measureUsingCanvas(text, font) +}