diff --git a/src/components/DataView.vue b/src/components/DataView.vue index 9d70f61..3350812 100644 --- a/src/components/DataView.vue +++ b/src/components/DataView.vue @@ -58,8 +58,9 @@ + + + + @@ -237,7 +246,9 @@ import { buildNodes, buildEdges, updateNodes, - updateEdges + updateEdges, + reduceNodes, + reduceEdges } from '@/lib/graphHelper' import Graph from 'graphology' import { circular, random, circlepack } from 'graphology-layout' @@ -277,7 +288,7 @@ export default { initOptions: Object, showViewSettings: Boolean }, - emits: ['update'], + emits: ['update', 'selectItem', 'deselectItem'], data() { return { graph: new Graph({ multi: true, allowSelfLoops: true }), @@ -300,7 +311,10 @@ export default { circlepack: CirclePackLayoutSettings, forceAtlas2: ForceAtlasLayoutSettings }), - + selectedNodeId: undefined, + hoveredNodeId: undefined, + selectedEdgeId: undefined, + hoveredEdgeId: undefined, settings: this.initOptions ? JSON.parse(JSON.stringify(this.initOptions)) : { @@ -312,6 +326,7 @@ export default { }, style: { backgroundColor: 'white', + highlightMode: 'node_and_neighbors', nodes: { size: { type: 'constant', @@ -352,7 +367,15 @@ export default { random: null, circlepack: null, forceAtlas2: null - } + }, + highlightModeOptions: markRaw([ + { label: 'Node alone', value: 'node_alone' }, + { label: 'Node and neighbors', value: 'node_and_neighbors' }, + { + label: 'Include edges between neighbors', + value: 'include_neighbor_edges' + } + ]) } }, computed: { @@ -379,6 +402,46 @@ export default { }, new Set()) return Array.from(keySet) + }, + neighborsOfSelectedNode() { + if (this.settings.style.highlightMode === 'node_alone') { + return undefined + } + return this.selectedNodeId + ? new Set(this.graph.neighbors(this.selectedNodeId)) + : undefined + }, + neighborsOfHoveredNode() { + if (this.settings.style.highlightMode === 'node_alone') { + return undefined + } + return this.hoveredNodeId + ? new Set(this.graph.neighbors(this.hoveredNodeId)) + : undefined + }, + hoveredEdgeExtremities() { + return this.hoveredEdgeId + ? this.graph.extremities(this.hoveredEdgeId) + : [] + }, + selectedEdgeExtremities() { + return this.selectedEdgeId + ? this.graph.extremities(this.selectedEdgeId) + : [] + }, + interactionState() { + return { + selectedNodeId: this.selectedNodeId, + hoveredNodeId: this.hoveredNodeId, + selectedEdgeId: this.selectedEdgeId, + hoveredEdgeId: this.hoveredEdgeId, + + neighborsOfSelectedNode: this.neighborsOfSelectedNode, + neighborsOfHoveredNode: this.neighborsOfHoveredNode, + + hoveredEdgeExtremities: this.hoveredEdgeExtremities, + selectedEdgeExtremities: this.selectedEdgeExtremities + } } }, watch: { @@ -423,6 +486,7 @@ export default { }, methods: { buildGraph() { + this.clearSelection() if (this.renderer) { this.renderer.kill() } @@ -440,12 +504,85 @@ export default { renderEdgeLabels: true, allowInvalidContainer: true, labelColor: { attribute: 'labelColor', color: '#444444' }, - edgeLabelColor: { attribute: 'labelColor', color: '#a2b1c6' } + edgeLabelColor: { attribute: 'labelColor', color: '#a2b1c6' }, + enableEdgeEvents: true, + zIndex: true, + nodeReducer: (node, data) => + reduceNodes(node, data, this.interactionState, this.settings), + edgeReducer: (edge, data) => + reduceEdges( + edge, + data, + this.interactionState, + this.settings, + this.graph + ) }) + this.renderer.on('clickNode', ({ node }) => { + this.selectedNodeId = node + this.selectedEdgeId = undefined + this.$emit('selectItem', this.graph.getNodeAttributes(node).data) + this.renderer.refresh({ + skipIndexation: true + }) + }) + this.renderer.on('clickEdge', ({ edge }) => { + this.selectedEdgeId = edge + this.selectedNodeId = undefined + this.$emit('selectItem', this.graph.getEdgeAttributes(edge).data) + this.renderer.refresh({ + skipIndexation: true + }) + }) + this.renderer.on('clickStage', () => { + this.clearSelection() + this.renderer.refresh({ + skipIndexation: true + }) + }) + this.renderer.on('enterNode', ({ node }) => { + this.hoveredNodeId = node + this.renderer.refresh({ + skipIndexation: true + }) + }) + this.renderer.on('enterEdge', ({ edge }) => { + this.hoveredEdgeId = edge + this.renderer.refresh({ + skipIndexation: true + }) + }) + this.renderer.on('leaveNode', () => { + this.hoveredNodeId = undefined + this.renderer.refresh({ + skipIndexation: true + }) + }) + this.renderer.on('leaveEdge', () => { + this.hoveredEdgeId = undefined + this.renderer.refresh({ + skipIndexation: true + }) + }) + if (this.settings.layout.type === 'forceAtlas2') { this.autoRunFA2Layout() } }, + clearSelection() { + this.selectedNodeId = undefined + this.selectedEdgeId = undefined + this.$emit('deselectItem') + }, + updateHighlightNodeMode(mode) { + this.settings.style.highlightMode = mode + + if (this.renderer) { + this.renderer.refresh({ + skipIndexation: true + }) + } + }, updateStructure(attributeName, value) { this.settings.structure[attributeName] = value }, diff --git a/src/components/Graph/index.vue b/src/components/Graph/index.vue index f2f8c6e..15619d8 100644 --- a/src/components/Graph/index.vue +++ b/src/components/Graph/index.vue @@ -31,14 +31,17 @@ :initOptions="initOptions" :showViewSettings="showViewSettings" @update="$emit('update')" + @selectItem="selectedItem = $event" + @deselectItem="selectedItem = null" /> @@ -75,7 +78,7 @@ export default { data() { return { resizeObserver: null, - selectedNode: {} + selectedItem: {} } }, computed: { diff --git a/src/components/ValueViewer.vue b/src/components/ValueViewer.vue index 39e109c..c39fae3 100644 --- a/src/components/ValueViewer.vue +++ b/src/components/ValueViewer.vue @@ -75,7 +75,11 @@ export default { props: { cellValue: [String, Number, Uint8Array], empty: Boolean, - emptyMessage: String + emptyMessage: String, + defaultFormat: { + type: String, + default: 'text' + } }, data() { return { @@ -83,7 +87,7 @@ export default { { text: 'Text', value: 'text' }, { text: 'JSON', value: 'json' } ], - currentFormat: 'text', + currentFormat: this.defaultFormat, lineWrapping: false, formattedJson: '', messages: [] @@ -121,17 +125,23 @@ export default { } }, watch: { - currentFormat() { - this.messages = [] - this.formattedJson = '' - if (this.currentFormat === 'json') { - this.formatJson(this.cellValue) + currentFormat: { + immediate: true, + handler() { + this.messages = [] + this.formattedJson = '' + if (this.currentFormat === 'json') { + this.formatJson(this.cellValue) + } } }, - cellValue() { - this.messages = [] - if (this.currentFormat === 'json') { - this.formatJson(this.cellValue) + cellValue: { + immediate: true, + handler() { + this.messages = [] + if (this.currentFormat === 'json') { + this.formatJson(this.cellValue) + } } } }, diff --git a/src/lib/graphHelper.js b/src/lib/graphHelper.js index e94dd75..cf1c896 100644 --- a/src/lib/graphHelper.js +++ b/src/lib/graphHelper.js @@ -127,6 +127,107 @@ export function updateEdges(graph, attributeUpdates) { }) } +export function reduceNodes(node, data, interactionState, settings) { + const { + selectedNodeId, + hoveredNodeId, + selectedEdgeId, + hoveredEdgeId, + neighborsOfSelectedNode, + neighborsOfHoveredNode, + selectedEdgeExtremities, + hoveredEdgeExtremities + } = interactionState + + const res = { ...data } + + if (selectedNodeId || hoveredNodeId || hoveredEdgeId || selectedEdgeId) { + res.zIndex = 2 + res.highlighted = node === selectedNodeId || node === hoveredNodeId + + const isInHoveredFamily = + node === hoveredNodeId || + neighborsOfHoveredNode?.has(node) || + hoveredEdgeExtremities.includes(node) + const isInSelectedFamily = + node === selectedNodeId || + neighborsOfSelectedNode?.has(node) || + selectedEdgeExtremities.includes(node) + if (isInSelectedFamily || isInHoveredFamily) { + res.forceLabel = true + } else { + res.color = getDiminishedColor(data.color, settings.style.backgroundColor) + res.label = '' + res.zIndex = 1 + } + } + + return res +} + +export function reduceEdges(edge, data, interactionState, settings, graph) { + const { + selectedNodeId, + hoveredNodeId, + selectedEdgeId, + hoveredEdgeId, + neighborsOfSelectedNode, + neighborsOfHoveredNode + } = interactionState + + const res = { ...data } + if (hoveredEdgeId || selectedEdgeId || selectedNodeId || hoveredNodeId) { + const extremities = graph.extremities(edge) + res.zIndex = 2 + const isHighlighted = hoveredEdgeId === edge || selectedEdgeId === edge + + let isVisible + if (settings.style.highlightMode === 'node_alone') { + isVisible = isHighlighted + } else if (settings.style.highlightMode === 'node_and_neighbors') { + isVisible = + isHighlighted || + (selectedNodeId && extremities.includes(selectedNodeId)) || + (hoveredNodeId && extremities.includes(hoveredNodeId)) + } else { + isVisible = + isHighlighted || + (selectedNodeId && + extremities.every( + n => n === selectedNodeId || neighborsOfSelectedNode.has(n) + )) || + (hoveredNodeId && + extremities.every( + n => n === hoveredNodeId || neighborsOfHoveredNode.has(n) + )) + } + if (isHighlighted) { + res.size = res.size * 2 + res.forceLabel = true + } else if (!isVisible) { + res.color = getDiminishedColor(data.color, settings.style.backgroundColor) + res.zIndex = 1 + res.label = '' + } + } + return res +} + +export function getDiminishedColor(color, bgColor) { + const colorObj = tinycolor(color) + const colorOpacity = colorObj.getAlpha() + colorObj.setAlpha(0.25 * colorOpacity) + + const fg = colorObj.toRgb() + const bg = tinycolor(bgColor).toRgb() + + const r = Math.round(fg.r * fg.a + bg.r * (1 - fg.a)) + const g = Math.round(fg.g * fg.a + bg.g * (1 - fg.a)) + const b = Math.round(fg.b * fg.a + bg.b * (1 - fg.a)) + + return tinycolor({ r, g, b, a: 1 }).toHexString() +} + function getUpdateLabelMethod(labelSettings) { const { source, color } = labelSettings return attributes => { diff --git a/src/lib/storedInquiries/_migrations.js b/src/lib/storedInquiries/_migrations.js index a1f92ea..b54f4bf 100644 --- a/src/lib/storedInquiries/_migrations.js +++ b/src/lib/storedInquiries/_migrations.js @@ -12,6 +12,7 @@ export default { inquiries.forEach(inquiry => { if (inquiry.viewType === 'graph') { inquiry.viewOptions.style.nodes.color.opacity = 100 + inquiry.viewOptions.style.highlightMode = 'node_and_neighbors' } }) } diff --git a/src/lib/storedInquiries/index.js b/src/lib/storedInquiries/index.js index 94b546e..bb102a4 100644 --- a/src/lib/storedInquiries/index.js +++ b/src/lib/storedInquiries/index.js @@ -69,6 +69,8 @@ export default { // Turn data into array if they are not inquiryList = !Array.isArray(inquiries) ? [inquiries] : inquiries inquiryList = migrate(1, inquiryList) + } else if (inquiries.version === 2) { + inquiryList = migrate(2, inquiries.inquiries) } else { inquiryList = inquiries.inquiries || [] } @@ -108,6 +110,8 @@ export default { if (!data.version) { return data.length > 0 ? migrate(1, data) : [] + } else if (data.version === 2) { + return migrate(2, data.inquiries) } else { return data.inquiries }