1
0
mirror of https://github.com/lana-k/sqliteviz.git synced 2026-03-22 05:56:16 +08:00

#133 highlight nodes and edges

This commit is contained in:
lana-k
2026-02-07 21:18:49 +01:00
parent dd30e17ff5
commit 1e8c1761e6
7 changed files with 279 additions and 22 deletions

View File

@@ -58,8 +58,9 @@
</icon-button> </icon-button>
<icon-button <icon-button
ref="viewNodeValueBtn" v-if="mode === 'graph'"
tooltip="View node" ref="viewNodeOrEdgeBtn"
tooltip="View node or edge details"
tooltipPosition="top-left" tooltipPosition="top-left"
:active="viewValuePanelVisible" :active="viewValuePanelVisible"
@click="viewValuePanelVisible = !viewValuePanelVisible" @click="viewValuePanelVisible = !viewValuePanelVisible"

View File

@@ -67,6 +67,15 @@
@color-change="settings.style.backgroundColor = $event" @color-change="settings.style.backgroundColor = $event"
/> />
</Field> </Field>
<Field label="Highlight mode">
<Dropdown
:options="highlightModeOptions"
:value="settings.style.highlightMode"
className="test_highlight_mode_select"
@change="updateHighlightNodeMode"
/>
</Field>
</Fold> </Fold>
</Panel> </Panel>
<Panel group="Style" name="Nodes"> <Panel group="Style" name="Nodes">
@@ -237,7 +246,9 @@ import {
buildNodes, buildNodes,
buildEdges, buildEdges,
updateNodes, updateNodes,
updateEdges updateEdges,
reduceNodes,
reduceEdges
} from '@/lib/graphHelper' } from '@/lib/graphHelper'
import Graph from 'graphology' import Graph from 'graphology'
import { circular, random, circlepack } from 'graphology-layout' import { circular, random, circlepack } from 'graphology-layout'
@@ -277,7 +288,7 @@ export default {
initOptions: Object, initOptions: Object,
showViewSettings: Boolean showViewSettings: Boolean
}, },
emits: ['update'], emits: ['update', 'selectItem', 'deselectItem'],
data() { data() {
return { return {
graph: new Graph({ multi: true, allowSelfLoops: true }), graph: new Graph({ multi: true, allowSelfLoops: true }),
@@ -300,7 +311,10 @@ export default {
circlepack: CirclePackLayoutSettings, circlepack: CirclePackLayoutSettings,
forceAtlas2: ForceAtlasLayoutSettings forceAtlas2: ForceAtlasLayoutSettings
}), }),
selectedNodeId: undefined,
hoveredNodeId: undefined,
selectedEdgeId: undefined,
hoveredEdgeId: undefined,
settings: this.initOptions settings: this.initOptions
? JSON.parse(JSON.stringify(this.initOptions)) ? JSON.parse(JSON.stringify(this.initOptions))
: { : {
@@ -312,6 +326,7 @@ export default {
}, },
style: { style: {
backgroundColor: 'white', backgroundColor: 'white',
highlightMode: 'node_and_neighbors',
nodes: { nodes: {
size: { size: {
type: 'constant', type: 'constant',
@@ -352,7 +367,15 @@ export default {
random: null, random: null,
circlepack: null, circlepack: null,
forceAtlas2: 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: { computed: {
@@ -379,6 +402,46 @@ export default {
}, new Set()) }, new Set())
return Array.from(keySet) 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: { watch: {
@@ -423,6 +486,7 @@ export default {
}, },
methods: { methods: {
buildGraph() { buildGraph() {
this.clearSelection()
if (this.renderer) { if (this.renderer) {
this.renderer.kill() this.renderer.kill()
} }
@@ -440,12 +504,85 @@ export default {
renderEdgeLabels: true, renderEdgeLabels: true,
allowInvalidContainer: true, allowInvalidContainer: true,
labelColor: { attribute: 'labelColor', color: '#444444' }, 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') { if (this.settings.layout.type === 'forceAtlas2') {
this.autoRunFA2Layout() 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) { updateStructure(attributeName, value) {
this.settings.structure[attributeName] = value this.settings.structure[attributeName] = value
}, },

View File

@@ -31,14 +31,17 @@
:initOptions="initOptions" :initOptions="initOptions"
:showViewSettings="showViewSettings" :showViewSettings="showViewSettings"
@update="$emit('update')" @update="$emit('update')"
@selectItem="selectedItem = $event"
@deselectItem="selectedItem = null"
/> />
</div> </div>
</template> </template>
<template v-if="showValueViewer" #right-pane> <template v-if="showValueViewer" #right-pane>
<value-viewer <value-viewer
:empty="!selectedNode" :empty="!selectedItem"
empty-message="No node selected to view" empty-message="No node or edge selected to view"
:cellValue="'{}'" :cellValue="JSON.stringify(selectedItem)"
default-format="json"
/> />
</template> </template>
</splitpanes> </splitpanes>
@@ -75,7 +78,7 @@ export default {
data() { data() {
return { return {
resizeObserver: null, resizeObserver: null,
selectedNode: {} selectedItem: {}
} }
}, },
computed: { computed: {

View File

@@ -75,7 +75,11 @@ export default {
props: { props: {
cellValue: [String, Number, Uint8Array], cellValue: [String, Number, Uint8Array],
empty: Boolean, empty: Boolean,
emptyMessage: String emptyMessage: String,
defaultFormat: {
type: String,
default: 'text'
}
}, },
data() { data() {
return { return {
@@ -83,7 +87,7 @@ export default {
{ text: 'Text', value: 'text' }, { text: 'Text', value: 'text' },
{ text: 'JSON', value: 'json' } { text: 'JSON', value: 'json' }
], ],
currentFormat: 'text', currentFormat: this.defaultFormat,
lineWrapping: false, lineWrapping: false,
formattedJson: '', formattedJson: '',
messages: [] messages: []
@@ -121,17 +125,23 @@ export default {
} }
}, },
watch: { watch: {
currentFormat() { currentFormat: {
this.messages = [] immediate: true,
this.formattedJson = '' handler() {
if (this.currentFormat === 'json') { this.messages = []
this.formatJson(this.cellValue) this.formattedJson = ''
if (this.currentFormat === 'json') {
this.formatJson(this.cellValue)
}
} }
}, },
cellValue() { cellValue: {
this.messages = [] immediate: true,
if (this.currentFormat === 'json') { handler() {
this.formatJson(this.cellValue) this.messages = []
if (this.currentFormat === 'json') {
this.formatJson(this.cellValue)
}
} }
} }
}, },

View File

@@ -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) { function getUpdateLabelMethod(labelSettings) {
const { source, color } = labelSettings const { source, color } = labelSettings
return attributes => { return attributes => {

View File

@@ -12,6 +12,7 @@ export default {
inquiries.forEach(inquiry => { inquiries.forEach(inquiry => {
if (inquiry.viewType === 'graph') { if (inquiry.viewType === 'graph') {
inquiry.viewOptions.style.nodes.color.opacity = 100 inquiry.viewOptions.style.nodes.color.opacity = 100
inquiry.viewOptions.style.highlightMode = 'node_and_neighbors'
} }
}) })
} }

View File

@@ -69,6 +69,8 @@ export default {
// Turn data into array if they are not // Turn data into array if they are not
inquiryList = !Array.isArray(inquiries) ? [inquiries] : inquiries inquiryList = !Array.isArray(inquiries) ? [inquiries] : inquiries
inquiryList = migrate(1, inquiryList) inquiryList = migrate(1, inquiryList)
} else if (inquiries.version === 2) {
inquiryList = migrate(2, inquiries.inquiries)
} else { } else {
inquiryList = inquiries.inquiries || [] inquiryList = inquiries.inquiries || []
} }
@@ -108,6 +110,8 @@ export default {
if (!data.version) { if (!data.version) {
return data.length > 0 ? migrate(1, data) : [] return data.length > 0 ? migrate(1, data) : []
} else if (data.version === 2) {
return migrate(2, data.inquiries)
} else { } else {
return data.inquiries return data.inquiries
} }