Add input bar, add node menu

This commit is contained in:
simon 2019-05-21 12:33:10 -04:00
parent 7ba2dfbdff
commit 0e428548a5
10 changed files with 363 additions and 80 deletions

View File

@ -5942,7 +5942,8 @@
},
"js-yaml": {
"version": "3.7.0",
"resolved": "",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.7.0.tgz",
"integrity": "sha1-XJZ93YN6m/3KXy3oQlOr6KHAO4A=",
"dev": true,
"requires": {
"argparse": "^1.0.7",
@ -6172,8 +6173,7 @@
"lodash": {
"version": "4.17.11",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz",
"integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==",
"dev": true
"integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg=="
},
"lodash.camelcase": {
"version": "4.3.0",

View File

@ -15,6 +15,7 @@
"d3-force": "^2.0.1",
"d3-path": "^1.0.7",
"element-ui": "^2.7.2",
"lodash": "^4.17.11",
"vue": "^2.6.10",
"vue-resource": "^1.5.1",
"vue-router": "^3.0.2"

View File

@ -1,4 +1,5 @@
import * as d3 from 'd3'
import icons from './icons'
export const nodeUtils = {
getNodeType: function (labels) {
@ -13,6 +14,21 @@ export const nodeUtils = {
}
}
// TODO: export somewhere else
const arc = function (radius, itemNumber, itemCount, width) {
itemNumber = itemNumber - 1
const startAngle = ((2 * Math.PI) / itemCount) * itemNumber
const endAngle = startAngle + (2 * Math.PI) / itemCount
const innerRadius = Math.max(radius + 8, 20)
return d3.arc()
.innerRadius(innerRadius)
.outerRadius(innerRadius + width)
.startAngle(startAngle)
.endAngle(endAngle)
.padAngle(0.09)
}
export function MusicGraph(data) {
const width = window.innerWidth - 7
const height = window.innerHeight - 7
@ -24,7 +40,7 @@ export function MusicGraph(data) {
this.links = []
this._originSet = false
this.svg = d3.select('body')
this.svg = d3.select('#mm')
.append('svg')
.attr('width', width)
.attr('height', height)
@ -33,6 +49,20 @@ export function MusicGraph(data) {
this.container.attr('transform', d3.event.transform)
}
this.dismiss = () => {
this.menu.remove()
this.nodes.forEach(d => {
d.menu = null
})
}
this.svg.append('rect')
.attr('width', width)
.attr('height', height)
.classed('dismiss-rect', true)
.style('fill', 'none')
.on('mousedown', this.dismiss)
this.svg.append('rect')
.attr('width', width)
.attr('height', height)
@ -50,6 +80,8 @@ export function MusicGraph(data) {
.attr('id', 'nodes')
this.container.append('g')
.attr('id', 'labels')
this.container.append('g')
.attr('id', 'menu')
this.dragStarted = (d) => {
if (!d3.event.active) {
@ -116,12 +148,62 @@ export function MusicGraph(data) {
}
this.nodeDbClick = (d) => {
if (this.expandedNodes.has(d.id)) {
if (d.menu) {
return
}
this.expandedNodes.add(d.id)
this.expandArtist(d.mbid)
this.svg.classed('menu-mode', true)
d.menu = true
// TODO: Move this somewhere else V V V
d.fx = d.x
d.fy = d.y
const items = [
{idx: 0, icon: icons.expand},
{idx: 1, icon: icons.release},
{idx: 2, icon: icons.hash},
{idx: 3, icon: icons.guitar}
]
const tr = `translate(${d.x},${d.y})`
this.menu = this.container.select('#menu')
.selectAll('g')
.data(items)
.enter()
.append('g')
.classed('menu-item', true)
.attr('transform', tr)
this.menu
.append('path')
.attr('d', item => arc(35, item.idx, items.length, 1)())
.on('mouseover', d => {
this.menu.classed('hover', item => item.idx === d.idx)
})
.on('mouseout', () => this.menu.classed('hover', false))
.transition()
.duration(200)
.attr('d', item => arc(35, item.idx, items.length, 30)())
const angleOffset = items.length === 3 ? 3.72 : 3.927 // Don't ask
this.menu
.append('g')
.html(d => d.icon)
.classed('menu-icon', true)
.attr('transform', d =>
`translate(${40 * Math.cos(2 * Math.PI * d.idx / items.length + angleOffset) - 10},
${40 * Math.sin(2 * Math.PI * d.idx / items.length + angleOffset) - 10})`
)
.transition()
.duration(250)
.attr('transform', d =>
`translate(
${57 * Math.cos(2 * Math.PI * d.idx / items.length + angleOffset) - 10},
${57 * Math.sin(2 * Math.PI * d.idx / items.length + angleOffset) - 10})`
)
// ^ ^ ^
d3.event.preventDefault()
}
this.simulation = d3.forceSimulation()
@ -147,9 +229,35 @@ export function MusicGraph(data) {
.attr('y', d => d.y)
})
/**
* Add nodes to the graph
*/
this.addNode = function (newNode, relations) {
// Convert {id, id} relation to {node, node}
if (this.nodeById.has(newNode.id)) {
return
}
this.nodeById.set(newNode.id, newNode)
newNode.x = width / 2
newNode.y = height / 2
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
for (const {source, target} of linksToAdd) {
source.sourceLinks.add(target.id)
target.targetLinks.add(source.id)
}
this.nodes.push(newNode)
this.links.push(...linksToAdd)
this._update()
}
this.addNodes = function (newNodes, relations, originId) {
// Update node map, ignore existing nodes
let nodesToAdd = []
@ -191,7 +299,7 @@ export function MusicGraph(data) {
this.nodes.push(...nodesToAdd)
this.links.push(...linksToAdd)
if (!this._originSet) {
if (!this._originSet && originId) {
this._setOrigin()
this._originSet = true
}
@ -238,8 +346,8 @@ export function MusicGraph(data) {
(1.2 / d.weight) * (94 * this.expandedNodes.size))
)
)
this.simulation
.restart()
this.simulation.alphaTarget(0.01).restart()
// Add new links
this.link = this.container.select('#links')
@ -269,6 +377,7 @@ export function MusicGraph(data) {
.on('mouseover', this.nodeHover)
.on('mouseout', this.nodeOut)
.on('dblclick', this.nodeDbClick)
.on('contextmenu', this.nodeDbClick)
this.node = nodeEnter.merge(this.node)
// Add new labels
@ -323,7 +432,7 @@ export function MusicGraph(data) {
this.expandArtist = function (mbid) {
// todo use http client
d3.json('https://mm.simon987.net/api/artist/related/' + mbid)
d3.json('http://localhost:3030/artist/related/' + mbid)
.then((r) => {
this.originArtist = r.artists.find(a => a.mbid === mbid)
@ -343,6 +452,24 @@ export function MusicGraph(data) {
})
}
this.addArtistByName = function (name) {
// todo use http client
d3.json('http://localhost:3030/artist/related_by_name/' + name)
.then((r) => {
const node = r.artists.find(a => a.name === name)
this.addNode({
id: node.id,
mbid: node.mbid,
name: node.name,
listeners: node.listeners,
type: nodeUtils.getNodeType(node.labels),
sourceLinks: new Set(),
targetLinks: new Set()
}, r.relations)
})
}
this._update()
this.setupKeyBindings()
}

View File

@ -0,0 +1,5 @@
export default function MusicGraphApi() {
let a = 0
console.log(a)
}

View File

@ -0,0 +1,63 @@
<template>
<div>
<figure
v-for="release in releases"
v-show="current === release.mbid"
v-bind:key="release.mbid"
>
<figure>
<img
alt=""
width="128"
height="128"
v-bind:src="'http://localhost:3030/cover/' + release.mbid"
>
<figcaption>{{release.name}} ({{release.year}})</figcaption>
</figure>
</figure>
</div>
</template>
<script>
let data = {
current: '',
index: 0
}
export default {
name: 'AlbumCarousel',
props: ['releases', 'interval'],
data() {
return data
},
mounted() {
setInterval(() => {
this.tick()
}, Number(this.interval))
},
watch: {
releases: () => {
data.index = 0
}
},
methods: {
tick() {
if (data.index === this.releases.length - 1) {
data.index = 0
}
data.index += 1
data.current = this.releases[data.index].mbid
}
}
}
</script>
<style scoped>
figure {
text-align: center;
margin: 0 0 10px 0;
max-width: 300px;
}
</style>

View File

@ -4,38 +4,38 @@
<span>{{artist.name}}</span>
</div>
<div>
<ImageCarousel
v-bind:sources="artistInfo.covers"
<AlbumCarousel
style="float: left"
v-bind:releases="artistInfo.releases"
interval="750" />
<span>Listeners: {{artist.listeners}}</span>
</div>
</el-card>
</template>
<script>
import Vue from 'vue'
import ImageCarousel from './ImageCarousel'
import AlbumCarousel from './AlbumCarousel'
let data = {
artistInfo: {
releases: [],
covers: []
releases: []
}
}
function reloadInfo(artist) {
Vue.http.get('https://mm.simon987.net/api/artist/details/' + artist.mbid)
Vue.http.get('http://localhost:3030/artist/details/' + artist.mbid)
.then(response => {
response.json().then(info => {
info.covers = info.releases.map(mbid => 'https://mm.simon987.net/api/cover/' + mbid)
data.artistInfo = info
data.artistInfo.releases = data.artistInfo.releases.slice(0, 2)
})
})
}
export default {
name: 'ArtistInfo',
components: {ImageCarousel},
components: {AlbumCarousel},
props: ['artist'],
watch: {
artist: reloadInfo

View File

@ -1,5 +1,7 @@
<template>
<div>
<div id="mm"></div>
<InputBar v-on:query="onQuery($event)"></InputBar>
<ArtistInfo v-bind:artist="hoverArtist"/>
</div>
</template>
@ -7,18 +9,27 @@
<script>
import ArtistInfo from './ArtistInfo'
import {MusicGraph} from '../MusicGraph'
import InputBar from './InputBar'
let data = {
hoverArtist: undefined
hoverArtist: undefined,
mm: undefined
}
let mm = new MusicGraph(data)
mm.expandArtist('66fc5bf8-daa4-4241-b378-9bc9077939d2')
export default {
components: {ArtistInfo},
components: {InputBar, ArtistInfo},
data() {
return data
},
methods: {
onQuery: function (e) {
console.log(e)
this.mm.addArtistByName(e)
}
},
mounted() {
this.mm = new MusicGraph(data)
this.mm.addArtistByName('Tool')
}
}
</script>
@ -34,6 +45,8 @@ export default {
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
position: fixed;
top: 0;
}
/* Pan mode */
@ -49,6 +62,10 @@ export default {
pointer-events: all;
}
svg.menu-mode .dismiss-rect {
pointer-events: all;
}
svg.pan-mode {
box-sizing: border-box;
border: 5px red solid;
@ -92,7 +109,6 @@ export default {
/* Label */
svg .label {
text-anchor: middle;
pointer-events: none;
}
svg.hover .label:not(.selected) {
@ -102,4 +118,34 @@ export default {
body {
background: #E7EDEB;
}
/* test */
#menu .menu-item {
cursor: pointer;
fill: orange;
}
#menu .menu-item.hover {
fill: darkorange;
}
#menu .menu-item.hover text {
font-weight: bold;
}
#menu .menu-item text {
text-anchor: middle;
fill: white;
}
#menu .menu-icon {
fill: black;
stroke: black;
pointer-events: none;
}
text {
pointer-events: none;
}
</style>

View File

@ -1,51 +0,0 @@
<template>
<div>
<img
alt=""
width="96"
height="96"
v-show="currentSrc === url"
v-bind:src="url"
v-bind:key="url"
v-for="url in sources"
>
</div>
</template>
<script>
let data = {
currentSrc: '',
index: 0
}
export default {
name: 'ImageCarousel',
props: ['sources', 'interval'],
data() {
return data
},
mounted() {
setInterval(() => {
this.tick()
}, Number(this.interval))
},
watch: {
sources: () => {
data.index = 0
}
},
methods: {
tick() {
if (data.index === this.sources.length - 1) {
data.index = 0
}
data.index += 1
data.currentSrc = this.sources[data.index]
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,45 @@
<template>
<div class="bar-wrapper">
<el-input
v-model="query"
@keyup.enter.native="onSubmit"
placeholder="Add nodes"
></el-input>
</div>
</template>
<script>
import * as _ from 'lodash'
export default {
name: 'InputBar',
data: () => {
return {
query: ''
}
},
watch: {
query: _.debounce(function () {
// todo: autocomplete
}, 125)
},
methods: {
onSubmit: function () {
this.$emit('query', this.query)
this.query = ''
}
}
}
</script>
<style scoped>
.bar-wrapper {
text-align: center;
padding: 2em 0;
}
.el-input {
width: 50%;
}
</style>

47
music_graph/src/icons.js Normal file
View File

@ -0,0 +1,47 @@
const icons = {
expand: '<svg viewBox="0 0 55 55" width="30px" height="30px" transform="translate(-7,0)">\n' +
'<path d="M49,0c-3.309,0-6,2.691-6,6c0,1.035,0.263,2.009,0.726,2.86l-9.829,9.829C32.542,17.634,30.846,17,29,17\n' +
'\ts-3.542,0.634-4.898,1.688l-7.669-7.669C16.785,10.424,17,9.74,17,9c0-2.206-1.794-4-4-4S9,6.794,9,9s1.794,4,4,4\n' +
'\tc0.74,0,1.424-0.215,2.019-0.567l7.669,7.669C21.634,21.458,21,23.154,21,25s0.634,3.542,1.688,4.897L10.024,42.562\n' +
'\tC8.958,41.595,7.549,41,6,41c-3.309,0-6,2.691-6,6s2.691,6,6,6s6-2.691,6-6c0-1.035-0.263-2.009-0.726-2.86l12.829-12.829\n' +
'\tc1.106,0.86,2.44,1.436,3.898,1.619v10.16c-2.833,0.478-5,2.942-5,5.91c0,3.309,2.691,6,6,6s6-2.691,6-6c0-2.967-2.167-5.431-5-5.91\n' +
'\tv-10.16c1.458-0.183,2.792-0.759,3.898-1.619l7.669,7.669C41.215,39.576,41,40.26,41,41c0,2.206,1.794,4,4,4s4-1.794,4-4\n' +
'\ts-1.794-4-4-4c-0.74,0-1.424,0.215-2.019,0.567l-7.669-7.669C36.366,28.542,37,26.846,37,25s-0.634-3.542-1.688-4.897l9.665-9.665\n' +
'\tC46.042,11.405,47.451,12,49,12c3.309,0,6-2.691,6-6S52.309,0,49,0z M11,9c0-1.103,0.897-2,2-2s2,0.897,2,2s-0.897,2-2,2\n' +
'\tS11,10.103,11,9z M6,51c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S8.206,51,6,51z M33,49c0,2.206-1.794,4-4,4s-4-1.794-4-4\n' +
'\ts1.794-4,4-4S33,46.794,33,49z M29,31c-3.309,0-6-2.691-6-6s2.691-6,6-6s6,2.691,6,6S32.309,31,29,31z M47,41c0,1.103-0.897,2-2,2\n' +
'\ts-2-0.897-2-2s0.897-2,2-2S47,39.897,47,41z M49,10c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S51.206,10,49,10z"/>\n' +
'</svg>',
release: '<svg viewBox="0 0 217.465 217.465" width="30px" height="30px" transform="translate(-1,-5)">\n' +
'<path d="M108.732,152.869c-24.337,0-44.137-19.8-44.137-44.137c0-24.337,19.8-44.137,44.137-44.137\n' +
'\tc24.337,0,44.137,19.8,44.137,44.137C152.869,133.07,133.07,152.869,108.732,152.869z M108.732,75.505\n' +
'\tc-18.322,0-33.227,14.906-33.227,33.228c0,18.322,14.906,33.227,33.227,33.227c18.322,0,33.227-14.906,33.227-33.227\n' +
'\tC141.96,90.411,127.054,75.505,108.732,75.505z M108.732,129.388c-11.408,0-20.655-9.248-20.655-20.656\n' +
'\tc0-11.408,9.248-20.655,20.655-20.655c11.408,0,20.655,9.248,20.655,20.655C129.388,120.14,120.14,129.388,108.732,129.388z\n' +
'\t M108.732,0C48.777,0,0,48.778,0,108.732s48.777,108.732,108.732,108.732s108.732-48.777,108.732-108.732S168.687,0,108.732,0z\n' +
'\t M108.732,46.79c-34.155,0-61.942,27.787-61.942,61.942c0,2.762-2.239,5-5,5s-5-2.238-5-5c0-39.669,32.273-71.942,71.942-71.942\n' +
'\tc2.761,0,5,2.238,5,5S111.494,46.79,108.732,46.79z M108.732,26.306c-45.45,0-82.427,36.977-82.427,82.427c0,2.762-2.239,5-5,5\n' +
'\ts-5-2.238-5-5c0-50.964,41.462-92.427,92.427-92.427c2.761,0,5,2.238,5,5S111.494,26.306,108.732,26.306z"/>\n' +
'</svg>',
guitar: '<svg width="30px" height="30px" viewBox="0 0 481.684 481.683" transform="translate(-3,-3)">' +
'<path d="M469.783,14.968c0.312-0.693,0.412-1.196,0.292-1.491c-2.701-6.424-4.66,2.016-24.221-8.874\n' +
'\t\t\tc-19.548-10.893-24.63,0.966-24.63,0.966s-19.411,36.914-39.833,51.327c-20.435,14.413-21.428,12.381-22.586,24.95\n' +
'\t\t\tc-1.167,12.565-5.438,16.646-5.438,16.646l0.309,0.331l-105.384,97.529c-0.1-6.39-6.159-6.073-6.159-6.073\n' +
'\t\t\tc-24.964-11.68-19.266-18.578-6.664-46.466c12.595-27.89-4.482-33.897-4.482-33.897s-33.654,44.781-56.961,44.24\n' +
'\t\t\tc-23.309-0.545-23.898,7.456-23.898,7.456s17.294,56.719-40.287,66.014c-21.516,3.468-37.612,8.636-49.333,13.74\n' +
'\t\t\tc-2.761,1.146-5.464,2.433-8.109,3.851c-13.604,7.062-18.905,13.101-18.905,13.101l0.167,0.156c-1.515,1.379-3.005,2.797-4.45,4.3\n' +
'\t\t\tc-43.687,45.3-35.432,124.143,18.456,176.11c53.884,51.965,132.977,57.362,176.664,12.066c3.65-3.787,6.869-7.786,9.744-11.941\n' +
'\t\t\tl0.024,0.031c15.018-16.967,19.787-55.803,19.787-55.803s4.685-49.344,35.374-43.112c30.688,6.243,30.824-13.168,30.824-13.168\n' +
'\t\t\ts-1.639-36.853,12.748-46.338c14.391-9.481,16.562-23.104-2.22-15.485c-18.791,7.622-30.068,14.375-47.557,0.377\n' +
'\t\t\tc-10.251-8.211-12.295-20.478-12.251-29.224l113.555-109.09c2.513-1.28,6.816-2.831,13.785-3.422\n' +
'\t\t\tc12.58-1.07,10.548-2.082,25.115-22.406c14.559-20.324,51.623-39.455,51.623-39.455s11.891-4.993,1.143-24.627\n' +
'\t\t\tC467.719,22.125,470.849,17.533,469.783,14.968z"/></svg>',
hash: '<svg viewBox="0 0 281.465 281.465" width="28px" height="28px" transform="translate(-4,0)">\n' +
'<path d="M273.661,114.318V67.035h-45.558L236.886,0h-47.69l-8.783,67.035h-60.084L129.113,0H81.425L72.64,67.035H7.804v47.283\n' +
'\th58.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' +
'\tv-47.289h-58.647l6.901-52.791H273.661z M167.326,167.109h-60.084l6.9-52.791h60.082L167.326,167.109z"/>\n' +
'</svg>'
}
export default icons