Add import/export, graph UX improvements, and contact management fixes.
Support vCard import and multi-format export, bulk delete with reliable local persistence, Ctrl+link relation creation, cluster-colored graph visualization, and right-click context menus for node details. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,12 @@
|
||||
/>
|
||||
|
||||
<div class="graph-view-toolbar">
|
||||
<p class="graph-link-hint text-muted">
|
||||
Ctrl+клик (⌘+клик) по двум узлам — создать связь.
|
||||
<span v-if="linkSelectionCount === 1">
|
||||
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
|
||||
</span>
|
||||
</p>
|
||||
<RelationTypeFilters
|
||||
:relation-types="allRelationTypes"
|
||||
:active-values="activeFilters"
|
||||
@@ -61,18 +67,59 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GraphNodeContextMenu
|
||||
:open="contextMenuOpen"
|
||||
:node="contextMenuNode"
|
||||
:x="contextMenuX"
|
||||
:y="contextMenuY"
|
||||
@close="closeContextMenu"
|
||||
@info="openNodeInfo"
|
||||
/>
|
||||
|
||||
<CreateRelationModal
|
||||
:open="relationModalOpen"
|
||||
:source="relationPair?.[0]"
|
||||
:target="relationPair?.[1]"
|
||||
@close="closeRelationModal"
|
||||
@created="onRelationCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Network, DataSet } from 'vis-network/standalone'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
||||
import { RELATION_COLORS } from '../lib/graph/relationColors'
|
||||
import { clusterColor } from '../lib/graph/clusterColors'
|
||||
import { computeClusterMap } from '../lib/graph/clusters'
|
||||
import { RELATION_TYPES } from '../domain/networkChoices'
|
||||
import { fetchGraphBundle } from '../composables/useGraphData'
|
||||
import { edgeFromRelation } from '../application/usecases/graph'
|
||||
import GraphHeaderPanel from '../components/GraphHeaderPanel.vue'
|
||||
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
|
||||
import CreateRelationModal from '../components/CreateRelationModal.vue'
|
||||
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
|
||||
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
||||
|
||||
let themeObserver = null
|
||||
let detachContextHandler = null
|
||||
|
||||
const {
|
||||
contextMenuOpen,
|
||||
contextMenuNode,
|
||||
contextMenuX,
|
||||
contextMenuY,
|
||||
closeContextMenu,
|
||||
attachNodeContextHandlers,
|
||||
} = useGraphNodeContextMenu()
|
||||
|
||||
function openNodeInfo(node) {
|
||||
selectedNode.value = node || null
|
||||
}
|
||||
|
||||
const store = useContactsStore()
|
||||
const graphContainer = ref(null)
|
||||
@@ -80,6 +127,20 @@ const loading = ref(true)
|
||||
const network = ref(null)
|
||||
const physicsEnabled = ref(true)
|
||||
const selectedNode = ref(null)
|
||||
const relationModalOpen = ref(false)
|
||||
const relationPair = ref(null)
|
||||
|
||||
const {
|
||||
linkSelection,
|
||||
linkSelectionCount,
|
||||
clearLinkSelection,
|
||||
handleCtrlPickNode,
|
||||
} = useCtrlLinkSelection({
|
||||
onPairSelected(pair) {
|
||||
relationPair.value = pair
|
||||
relationModalOpen.value = true
|
||||
},
|
||||
})
|
||||
|
||||
let nodesDS = null
|
||||
let edgesDS = null
|
||||
@@ -87,12 +148,14 @@ let initRetryCount = 0
|
||||
const INIT_RETRY_MAX = 40
|
||||
let initRetryTimer = null
|
||||
let resizeObserver = null
|
||||
let themeObserver = null
|
||||
|
||||
const nodes = ref([])
|
||||
const edges = ref([])
|
||||
const allRelationTypes = ref([])
|
||||
const activeFilters = ref([])
|
||||
const clusterMap = ref(new Map())
|
||||
|
||||
const relationTypeLabels = Object.fromEntries(RELATION_TYPES.map((r) => [r.value, r.label]))
|
||||
|
||||
const selectedContact = computed(() =>
|
||||
selectedNode.value ? store.contactById(selectedNode.value.id) : null
|
||||
@@ -107,6 +170,7 @@ function graphPalette() {
|
||||
return {
|
||||
nodeBackground: cssVar('--surface-alt', '#22263a'),
|
||||
nodeBorder: cssVar('--accent', '#5b8dee'),
|
||||
nodeLinkBorder: cssVar('--green', '#4ecca3'),
|
||||
nodeHighlightBackground: cssVar('--surface', '#1a1d27'),
|
||||
nodeHighlightBorder: cssVar('--accent-hover', '#7aa5f5'),
|
||||
nodeFont: cssVar('--text', '#e2e6f3'),
|
||||
@@ -114,6 +178,130 @@ function graphPalette() {
|
||||
}
|
||||
}
|
||||
|
||||
function recomputeClusters() {
|
||||
clusterMap.value = computeClusterMap(nodes.value, filteredEdges())
|
||||
}
|
||||
|
||||
function nodeDegree(id) {
|
||||
const sid = String(id)
|
||||
return filteredEdges().filter(
|
||||
(e) => String(e.from) === sid || String(e.to) === sid
|
||||
).length
|
||||
}
|
||||
|
||||
function isDarkTheme() {
|
||||
return document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
}
|
||||
|
||||
function nodeVisColor(nodeId, linkIds) {
|
||||
const sid = String(nodeId)
|
||||
const palette = graphPalette()
|
||||
if (linkIds.has(sid)) {
|
||||
return {
|
||||
background: palette.nodeBackground,
|
||||
border: palette.nodeLinkBorder,
|
||||
highlight: {
|
||||
background: palette.nodeHighlightBackground,
|
||||
border: palette.nodeHighlightBorder,
|
||||
},
|
||||
}
|
||||
}
|
||||
const cc = clusterColor(clusterMap.value.get(sid), isDarkTheme())
|
||||
return {
|
||||
background: cc.bg,
|
||||
border: cc.border,
|
||||
highlight: { background: cc.highlight, border: cc.border },
|
||||
}
|
||||
}
|
||||
|
||||
function mapGraphNodeToVis(n, linkIds = new Set()) {
|
||||
const palette = graphPalette()
|
||||
const degree = nodeDegree(n.id)
|
||||
return {
|
||||
id: String(n.id),
|
||||
label: n.label || String(n.id),
|
||||
title: n.title,
|
||||
color: nodeVisColor(n.id, linkIds),
|
||||
font: { color: palette.nodeFont, size: degree > 0 ? 13 : 11 },
|
||||
shape: 'dot',
|
||||
size: degree > 0 ? 12 + Math.min(degree, 6) * 2 : 9,
|
||||
borderWidth: linkIds.has(String(n.id)) ? 3 : 2,
|
||||
}
|
||||
}
|
||||
|
||||
function refreshNodeStyles() {
|
||||
if (!nodesDS) return
|
||||
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
||||
nodesDS.update(nodes.value.map((n) => mapGraphNodeToVis(n, linkIds)))
|
||||
network.value?.redraw()
|
||||
}
|
||||
|
||||
function applyLinkHighlights() {
|
||||
refreshNodeStyles()
|
||||
}
|
||||
|
||||
function mapGraphEdgeToVis(e) {
|
||||
const palette = graphPalette()
|
||||
const rc = RELATION_COLORS[e.relation_type] || RELATION_COLORS.other
|
||||
const typeLabel = relationTypeLabels[e.relation_type] || e.relation_type
|
||||
const tip = [typeLabel, e.title !== e.relation_type ? e.title : ''].filter(Boolean).join(' — ')
|
||||
return {
|
||||
id: String(e.id),
|
||||
from: String(e.from),
|
||||
to: String(e.to),
|
||||
title: tip || typeLabel,
|
||||
relation_type: e.relation_type,
|
||||
color: { color: rc.color, highlight: rc.highlight, opacity: 0.8 },
|
||||
width: e.interaction_intensity === 'sparse' ? 1 : 2,
|
||||
dashes: e.interaction_intensity === 'sparse' ? [6, 4] : false,
|
||||
font: { color: palette.edgeFont, size: 10, align: 'middle' },
|
||||
arrows: { to: { enabled: false } },
|
||||
smooth: { type: 'dynamic' },
|
||||
}
|
||||
}
|
||||
|
||||
function appendRelationEdge(relation) {
|
||||
if (!relation) return
|
||||
const edge = edgeFromRelation(relation)
|
||||
if (!edges.value.some((e) => String(e.id) === String(edge.id))) {
|
||||
edges.value.push(edge)
|
||||
}
|
||||
if (!edgesDS) return
|
||||
|
||||
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
||||
if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
|
||||
if (
|
||||
activeFilters.value.length < allRelationTypes.value.length &&
|
||||
!activeFilters.value.includes(edge.relation_type)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!edgesDS.get(String(edge.id))) {
|
||||
edgesDS.add(mapGraphEdgeToVis(edge))
|
||||
}
|
||||
recomputeClusters()
|
||||
refreshNodeStyles()
|
||||
}
|
||||
|
||||
function closeRelationModal() {
|
||||
relationModalOpen.value = false
|
||||
relationPair.value = null
|
||||
clearLinkSelection()
|
||||
applyLinkHighlights()
|
||||
}
|
||||
|
||||
function onRelationCreated(relation) {
|
||||
relationModalOpen.value = false
|
||||
relationPair.value = null
|
||||
clearLinkSelection()
|
||||
applyLinkHighlights()
|
||||
appendRelationEdge(relation)
|
||||
}
|
||||
|
||||
watch(linkSelection, () => {
|
||||
applyLinkHighlights()
|
||||
}, { deep: true })
|
||||
|
||||
async function loadGraph() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -127,6 +315,7 @@ async function loadGraph() {
|
||||
}
|
||||
// Контейнер #graph-container в DOM только когда loading=false и nodes.length > 0
|
||||
if (nodes.value.length > 0) {
|
||||
recomputeClusters()
|
||||
await nextTick()
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||
initNetwork()
|
||||
@@ -165,33 +354,9 @@ function initNetwork() {
|
||||
initRetryTimer = null
|
||||
}
|
||||
|
||||
const palette = graphPalette()
|
||||
const nodeList = nodes.value.map((n) => ({
|
||||
id: String(n.id),
|
||||
label: n.label || String(n.id),
|
||||
title: n.title,
|
||||
...(n.group != null && { group: n.group }),
|
||||
color: {
|
||||
background: palette.nodeBackground,
|
||||
border: palette.nodeBorder,
|
||||
highlight: { background: palette.nodeHighlightBackground, border: palette.nodeHighlightBorder },
|
||||
},
|
||||
font: { color: palette.nodeFont, size: 13 },
|
||||
shape: 'dot',
|
||||
size: 14,
|
||||
}))
|
||||
const edgeList = filteredEdges().map((e) => ({
|
||||
id: String(e.id),
|
||||
from: String(e.from),
|
||||
to: String(e.to),
|
||||
label: e.label,
|
||||
title: e.title,
|
||||
relation_type: e.relation_type,
|
||||
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
|
||||
font: { color: palette.edgeFont, size: 10, align: 'middle' },
|
||||
arrows: { to: { enabled: false } },
|
||||
smooth: { type: 'curvedCW', roundness: 0.1 },
|
||||
}))
|
||||
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
||||
const nodeList = nodes.value.map((n) => mapGraphNodeToVis(n, linkIds))
|
||||
const edgeList = filteredEdges().map(mapGraphEdgeToVis)
|
||||
nodesDS = new DataSet(nodeList)
|
||||
edgesDS = new DataSet(edgeList)
|
||||
|
||||
@@ -201,27 +366,43 @@ function initNetwork() {
|
||||
{
|
||||
physics: {
|
||||
enabled: true,
|
||||
stabilization: { iterations: 150 },
|
||||
barnesHut: { gravitationalConstant: -3000, springLength: 180 },
|
||||
stabilization: { iterations: 250, fit: true },
|
||||
barnesHut: {
|
||||
gravitationalConstant: -12000,
|
||||
centralGravity: 0.15,
|
||||
springLength: 220,
|
||||
springConstant: 0.035,
|
||||
damping: 0.12,
|
||||
avoidOverlap: 0.25,
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
tooltipDelay: 200,
|
||||
hover: true,
|
||||
hideEdgesOnDrag: true,
|
||||
zoomView: true,
|
||||
dragView: true,
|
||||
dragNodes: true,
|
||||
},
|
||||
nodes: { borderWidth: 1.5 },
|
||||
}
|
||||
)
|
||||
|
||||
network.value.on('click', (params) => {
|
||||
closeContextMenu()
|
||||
if (params.nodes.length > 0) {
|
||||
const id = params.nodes[0]
|
||||
const node = nodes.value.find((n) => String(n.id) === id)
|
||||
selectedNode.value = node || null
|
||||
const domEvent = params.event?.srcEvent || params.event
|
||||
if (domEvent && (domEvent.ctrlKey || domEvent.metaKey)) {
|
||||
handleCtrlPickNode(node, domEvent)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
detachContextHandler?.()
|
||||
detachContextHandler = attachNodeContextHandlers(network.value, () => nodes.value)
|
||||
|
||||
network.value.once('stabilizationIterationsDone', () => {
|
||||
network.value?.fit({ animation: { duration: 400 } })
|
||||
setTimeout(() => {
|
||||
@@ -250,34 +431,17 @@ function toggleFilter(type) {
|
||||
activeFilters.value.push(type)
|
||||
}
|
||||
if (edgesDS) {
|
||||
const palette = graphPalette()
|
||||
recomputeClusters()
|
||||
edgesDS.clear()
|
||||
edgesDS.add(
|
||||
filteredEdges().map((e) => ({
|
||||
...e,
|
||||
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
|
||||
font: { color: palette.edgeFont, size: 10, align: 'middle' },
|
||||
arrows: { to: { enabled: false } },
|
||||
smooth: { type: 'curvedCW', roundness: 0.1 },
|
||||
}))
|
||||
)
|
||||
edgesDS.add(filteredEdges().map(mapGraphEdgeToVis))
|
||||
refreshNodeStyles()
|
||||
}
|
||||
}
|
||||
|
||||
function applyThemeToNetwork() {
|
||||
if (!nodesDS || !edgesDS || !network.value) return
|
||||
refreshNodeStyles()
|
||||
const palette = graphPalette()
|
||||
nodesDS.update(
|
||||
nodes.value.map((n) => ({
|
||||
id: String(n.id),
|
||||
color: {
|
||||
background: palette.nodeBackground,
|
||||
border: palette.nodeBorder,
|
||||
highlight: { background: palette.nodeHighlightBackground, border: palette.nodeHighlightBorder },
|
||||
},
|
||||
font: { color: palette.nodeFont, size: 13 },
|
||||
}))
|
||||
)
|
||||
edgesDS.update(
|
||||
filteredEdges().map((e) => ({
|
||||
id: String(e.id),
|
||||
@@ -303,6 +467,8 @@ onMounted(() => {
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (initRetryTimer) clearTimeout(initRetryTimer)
|
||||
detachContextHandler?.()
|
||||
closeContextMenu()
|
||||
resizeObserver?.disconnect()
|
||||
themeObserver?.disconnect()
|
||||
network.value?.destroy()
|
||||
@@ -321,6 +487,10 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
padding: 0 28px 10px;
|
||||
}
|
||||
.graph-link-hint {
|
||||
font-size: 12px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.graph-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
Reference in New Issue
Block a user