diff --git a/music_graph/index.html b/music_graph/index.html index 47e6598..a173540 100644 --- a/music_graph/index.html +++ b/music_graph/index.html @@ -3,10 +3,9 @@ - music_graph + music-graph v1.0
- diff --git a/music_graph/package-lock.json b/music_graph/package-lock.json index bec02d9..9ba7623 100644 --- a/music_graph/package-lock.json +++ b/music_graph/package-lock.json @@ -3372,9 +3372,9 @@ "dev": true }, "element-ui": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/element-ui/-/element-ui-2.7.2.tgz", - "integrity": "sha512-Exh9QTkm9gwMMPzg1TyaTlBKyr3k4K9XcC5vl0A/mneDvJX//RsURGuOWsCNDVQMdhh5h9e+W5icosh+pKfbCg==", + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/element-ui/-/element-ui-2.9.2.tgz", + "integrity": "sha512-HU5DDKivv2UFZghVWiP3I74IbTzSW8VXc070fM787i1X/u9f43olDuu3S6Pe9z87Z1oNjXt06ZmBlLYtySJMCw==", "requires": { "async-validator": "~1.8.1", "babel-helper-vue-jsx-merge-props": "^2.0.0", diff --git a/music_graph/package.json b/music_graph/package.json index 770641f..0141df6 100644 --- a/music_graph/package.json +++ b/music_graph/package.json @@ -14,7 +14,7 @@ "d3": "^5.9.2", "d3-force": "^2.0.1", "d3-path": "^1.0.7", - "element-ui": "^2.7.2", + "element-ui": "^2.9.2", "lodash": "^4.17.11", "vue": "^2.6.10", "vue-resource": "^1.5.1", diff --git a/music_graph/src/MusicGraph.js b/music_graph/src/MusicGraph.js index ee5dd96..6789338 100644 --- a/music_graph/src/MusicGraph.js +++ b/music_graph/src/MusicGraph.js @@ -33,12 +33,13 @@ export function MusicGraph(data) { this.simulation = d3.forceSimulation() .force('charge', d3.forceManyBody()) .force('collide', d3.forceCollide() - .radius(40) + .radius(35) .strength(1)) .force('center', d3.forceCenter(width / 2, height / 2)) this.zoomed = () => { this.container.attr('transform', d3.event.transform) + this.dismiss() } this.dismiss = () => { @@ -157,7 +158,7 @@ export function MusicGraph(data) { this.makeMenu = function (d) { let items = [] let i = 0 - if (d.type === 'Group' && !d.membersExpanded) { + if ((d.type === 'Group' || d.type === 'Artist')) { items.push({ idx: i++, icon: icons.guitar, @@ -165,13 +166,12 @@ export function MusicGraph(data) { fn: (d) => { this.api.getGroupMembers(d.mbid, d.id) .then(data => { - d.membersExpanded = true this.addNodes(data.newNodes, data.relations, d.id) }) } }) } - if ((d.type === 'Group' || d.type === 'Artist') && !d.relatedExpanded) { + if ((d.type === 'Group' || d.type === 'Artist')) { items.push({ idx: i++, icon: icons.expand, @@ -183,34 +183,43 @@ export function MusicGraph(data) { this.expandedNodes.add(d.id) this.addNodes(data.newNodes, data.relations, d.id) } - d.relatedExpanded = true }) } }) } - if ((d.type === 'Artist' || d.type === 'Group') && !d.releasesExpanded) { + if (d.type === 'Tag') { items.push({ idx: i++, - icon: icons.release, - title: 'Releases', + icon: icons.label, + title: 'Related', fn: (d) => { - this.api.getArtistReleases(d.mbid, d.id) + this.api.getRelatedTags(d.id) .then(data => { this.addNodes(data.newNodes, data.relations, d.id) - d.releasesExpanded = true }) } }) } - if ((d.type === 'Album' || d.type === 'EP' || d.type === 'Single' || d.type === 'Group' || d.type === 'Artist') && - !d.tagsExpanded) { + if ((d.type === 'Artist' || d.type === 'Group')) { + items.push({ + idx: i++, + icon: icons.label, + title: 'Label', + fn: (d) => { + this.api.getArtistLabels(d.mbid, d.id) + .then(data => { + this.addNodes(data.newNodes, data.relations, d.id) + }) + } + }) + } + if (d.type === 'Album' || d.type === 'EP' || d.type === 'Single' || d.type === 'Group' || d.type === 'Artist') { let fn if (d.type === 'Group' || d.type === 'Artist') { fn = (d) => { this.api.getArtistTags(d.mbid, d.id) .then(data => { this.addNodes(data.newNodes, data.relations, d.id) - d.tagsExpanded = true }) } } else if (d.type === 'Album' || d.type === 'EP' || d.type === 'Single') { @@ -218,7 +227,6 @@ export function MusicGraph(data) { this.api.getReleaseDetails(d.mbid, d.id) .then(data => { this.addNodes(data.newNodes, data.relations, d.id) - d.tagsExpanded = true }) } } @@ -230,12 +238,28 @@ export function MusicGraph(data) { fn: fn }) } + if (d.type === 'Tag') { + items.push({ + idx: i++, + icon: icons.expand, + title: 'Related', + fn: (d) => { + this.api.getRelatedByTag(d.id) + .then(data => { + this.addNodes(data.newNodes, data.relations, d.id) + }) + } + }) + } items.push({ idx: i, icon: icons.delete, title: 'Remove from graph', fn: (d) => { this.removeNodes([d.id]) + if (this._data.hoverArtist && d.id === this._data.hoverArtist.id) { + this._data.hoverArtist = undefined + } } }) @@ -295,17 +319,29 @@ export function MusicGraph(data) { this.nodeClick = (d) => { if (d.type === 'Group' || d.type === 'Artist') { // Toggle artistInfo + this.nodes.forEach(x => { + x.hover = false + }) if (this._data.hoverArtist === d) { this._data.hoverArtist = undefined } else { this._data.hoverArtist = d + d.hover = true } + this._update() } } this.addNode = function (newNode, relations) { // Convert {id, id} relation to {node, node} if (this.nodeById.has(newNode.id)) { + // Node already exists, select it + this.nodes.forEach(x => { + x.hover = false + }) + this._data.hoverArtist = this.nodeById.get(newNode.id) + this._data.hoverArtist.hover = true + this._update() return } this.nodeById.set(newNode.id, newNode) @@ -358,11 +394,13 @@ export function MusicGraph(data) { }) // Convert {id, id} relation to {node, node} - let linksToAdd = relations.map(({weight, source, target}) => ({ - source: this.nodeById.get(source), - target: this.nodeById.get(target), - weight: weight - })) + let linksToAdd = relations + .filter(rel => this.nodeById.has(rel.source) && this.nodeById.has(rel.target)) + .map(({weight, source, target}) => ({ + source: this.nodeById.get(source), + target: this.nodeById.get(target), + weight: weight + })) // Update source/targetLinks, avoid bidirectional links for (const {source, target} of linksToAdd) { @@ -430,7 +468,7 @@ export function MusicGraph(data) { .force('link', d3.forceLink(this.links) .id(d => d.id) .strength(l => l.weight) - .distance(d => (1.15 / d.weight) * (82 * (this.expandedNodes.size + 1))) + .distance(d => (1.12 / d.weight) * 80 * (this.expandedNodes.size + 1)) ) this.simulation.alphaTarget(0.03).restart() @@ -456,6 +494,7 @@ export function MusicGraph(data) { .enter() .append('circle') .merge(this.node) + this.node .classed('node', true) .attr('r', d => d.radius) .attr('stroke', d => this._getNodeColor(d)) @@ -485,11 +524,13 @@ export function MusicGraph(data) { this.setupKeyBindings = function () { document.body.onkeydown = (e) => { - let isPanMode = this.svg.classed('pan-mode') + if (e.ctrlKey) { + this.svg.classed('pan-mode', true) + } + } - if (e.key === 'q') { - this.svg.classed('pan-mode', !isPanMode) - } else if (e.key === 'Escape') { + document.body.onkeyup = (e) => { + if (e.key === 'Control') { this.svg.classed('pan-mode', false) } } @@ -511,6 +552,9 @@ export function MusicGraph(data) { } this._getNodeColor = function (node) { + if (node.hover) { + return '#FF0000' + } if (this.expandedNodes.has(node.id)) { return '#1cb3c8' } @@ -524,7 +568,7 @@ export function MusicGraph(data) { }) } - this.addTagById = function(tagid) { + this.addTagById = function (tagid) { if (this.nodeById.has(tagid)) { return } @@ -546,7 +590,7 @@ export function MusicGraph(data) { .attr('cx', d => d.x) .attr('cy', d => d.y) this.label - .attr('x', d => Math.round(d.node.x)) + .attr('x', d => d.node.x) .attr('y', d => d.node.y + d.baseline) }) diff --git a/music_graph/src/MusicGraphApi.js b/music_graph/src/MusicGraphApi.js index a44abdc..7b01170 100644 --- a/music_graph/src/MusicGraphApi.js +++ b/music_graph/src/MusicGraphApi.js @@ -63,6 +63,10 @@ export function MusicGraphApi() { return d3.json(this.url + '/artist/details/' + mbid) } + /** + * Works in both directions + * @returns {Promise<{newNodes: *, relations: *} | never>} + */ this.getGroupMembers = function (mbid, originId) { return d3.json(this.url + '/artist/members/' + mbid) .then((r) => { @@ -70,8 +74,8 @@ export function MusicGraphApi() { newNodes: r.artists.map(nodeUtils.fromRawDict), relations: r.artists.map(a => { return { - source: a.id, - target: originId, + source: originId, + target: a.id, weight: 0.8 } }) @@ -99,6 +103,29 @@ export function MusicGraphApi() { }) } + this.getArtistLabels = function (mbid, originId) { + return d3.json(this.url + '/artist/details/' + mbid) + .then((r) => { + const newNodes = r.labels + .map(l => { + l.labels = ['Label'] + return l + }) + .map(nodeUtils.fromRawDict) + + return { + newNodes: newNodes, + relations: newNodes.map(t => { + return { + source: originId, + target: t.id, + weight: 0.8 + } + }) + } + }) + } + this._filterTags = function (tags) { if (ONLY_GENRE_TAGS) { return tags.filter(tag => genres.has(tag.name)) @@ -133,6 +160,30 @@ export function MusicGraphApi() { }) } + this.getRelatedTags = function (tagId) { + return d3.json(this.url + '/tag/tag/' + tagId) + .then((r) => { + const tags = this._filterTags(r.tags) + let directedRelations = r.relations.map(rel => { + // Make new nodes children of the expanded nodes, no matter the original direction + if (rel.source === tagId) { + return rel + } else { + return { + source: rel.target, + target: rel.source, + weight: rel.weight + } + } + }) + + return { + newNodes: this._addTagLabel(tags).map(nodeUtils.fromRawDict), + relations: directedRelations + } + }) + } + this.getRelatedByMbid = function (mbid) { return d3.json(this.url + '/artist/related/' + mbid) .then((r) => { @@ -168,7 +219,14 @@ export function MusicGraphApi() { name: r.tag.name }), newNodes: r.artists.map(nodeUtils.fromRawDict), - relations: r.relations + relations: r.relations.map(rel => { + // Invert relation direction + return { + source: rel.target, + target: rel.source, + weight: rel.weight + } + }) } }) } @@ -195,6 +253,6 @@ export function MusicGraphApi() { prefix = prefix.replace(/[^\w.\-!?& ]/g, '_').toUpperCase() prefix = prefix.replace(/ /g, '+') - return d3.json(this.url + '/artist/autocomplete/' + prefix) + return d3.json(this.url + '/autocomplete/' + prefix) } } diff --git a/music_graph/src/components/AboutPage.vue b/music_graph/src/components/AboutPage.vue new file mode 100644 index 0000000..0cb96c0 --- /dev/null +++ b/music_graph/src/components/AboutPage.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/music_graph/src/components/AlbumCarousel.vue b/music_graph/src/components/AlbumCarousel.vue index 210fea4..5aab18a 100644 --- a/music_graph/src/components/AlbumCarousel.vue +++ b/music_graph/src/components/AlbumCarousel.vue @@ -7,14 +7,18 @@ style="float: right" >
- +
+ +
+
{{release.name}} ({{release.year}})
@@ -62,12 +66,25 @@ export default { } - diff --git a/music_graph/src/components/ArtistInfo.vue b/music_graph/src/components/ArtistInfo.vue index b01f8b6..b00f732 100644 --- a/music_graph/src/components/ArtistInfo.vue +++ b/music_graph/src/components/ArtistInfo.vue @@ -58,15 +58,16 @@ export default { this.api.getArtistDetails(artist.mbid) .then(info => { this.artistInfo = info - this.artistInfo.releases = this.artistInfo.releases.filter(r => - r.labels.indexOf('Album') !== -1 || r.labels.indexOf('EP') !== -1) + this.artistInfo.releases = this.artistInfo.releases + .sort((a, b) => a.year - b.year) + .filter(r => r.labels.indexOf('Album') !== -1 || r.labels.indexOf('EP') !== -1) this.artistInfo.tags = info.tags.sort((a, b) => b.weight - a.weight).splice(0, 6).map(t => { t.type = genres.has(t.name) ? '' : 'info' return t }) }) }, - onTagClick: function(tag) { + onTagClick: function (tag) { this.$emit('addTag', tag) } } diff --git a/music_graph/src/components/HelloWorld.vue b/music_graph/src/components/HelloWorld.vue index c557095..671ee2d 100644 --- a/music_graph/src/components/HelloWorld.vue +++ b/music_graph/src/components/HelloWorld.vue @@ -1,17 +1,19 @@ @@ -63,7 +72,7 @@ export default { cursor: move; } - svg.pan-mode .pan-rect { + .pan-rect { pointer-events: all; } @@ -71,11 +80,6 @@ export default { pointer-events: all; } - svg.pan-mode { - box-sizing: border-box; - border: 5px red solid; - } - /* Link */ svg .link.selected { stroke-width: 2; @@ -123,7 +127,7 @@ export default { } svg .label.tag { - fill: darkgrey; + fill: #409EFF; } svg.hover .label:not(.selected) { diff --git a/music_graph/src/components/InputBar.vue b/music_graph/src/components/InputBar.vue index 17c6991..3e394ce 100644 --- a/music_graph/src/components/InputBar.vue +++ b/music_graph/src/components/InputBar.vue @@ -9,8 +9,10 @@ @select="onSubmit" > @@ -18,7 +20,6 @@ + + diff --git a/music_graph/src/icons.js b/music_graph/src/icons.js index 31d9285..40d4eb6 100644 --- a/music_graph/src/icons.js +++ b/music_graph/src/icons.js @@ -35,7 +35,15 @@ const icons = { 'h58.649l-6.904,52.791H7.804v47.289h45.559l-8.784,67.066h47.687l8.787-67.066h60.083l-8.786,67.066h47.691l8.783-67.066h64.836\n' + 'v-47.289h-58.647l6.901-52.791H273.661z M167.326,167.109h-60.084l6.9-52.791h60.082L167.326,167.109z"/>', delete: '' + 'L255,290.7l-91.8,91.8l-35.7-35.7l91.8-91.8l-91.8-91.8l35.7-35.7l91.8,91.8l91.8-91.8l35.7,35.7L290.7,255L382.5,346.8z"/>', + label: '' } diff --git a/music_graph/src/router/index.js b/music_graph/src/router/index.js index c34d76d..3a8bfbd 100644 --- a/music_graph/src/router/index.js +++ b/music_graph/src/router/index.js @@ -1,15 +1,22 @@ import Vue from 'vue' import Router from 'vue-router' -import HelloWorld from '@/components/HelloWorld' +import HelloWorld from '../components/HelloWorld' +import AboutPage from '../components/AboutPage' Vue.use(Router) export default new Router({ + mode: 'history', routes: [ { path: '/', name: 'HelloWorld', component: HelloWorld + }, + { + path: '/about', + name: 'AboutPage', + component: AboutPage } ] }) diff --git a/music_graph/static/album.png b/music_graph/static/album.png deleted file mode 100644 index 80eed43..0000000 Binary files a/music_graph/static/album.png and /dev/null differ diff --git a/music_graph/static/diagram.png b/music_graph/static/diagram.png new file mode 100644 index 0000000..a0df1e8 Binary files /dev/null and b/music_graph/static/diagram.png differ