Add viewport controls, collapsible panels with persisted state, filters modal, and refined context menus; preserve map layout on navigation and let contacts be edited with relations on /contacts/:id instead of list modals. Co-authored-by: Cursor <cursoragent@cursor.com>
1247 lines
36 KiB
Vue
1247 lines
36 KiB
Vue
<template>
|
|
<div class="graph-view" :class="{ 'graph-view--chrome-collapsed': chromeCollapsed }">
|
|
<GraphHeaderPanel
|
|
v-show="!chromeCollapsed"
|
|
title="Граф связей"
|
|
:show-reset="false"
|
|
:show-physics-toggle="true"
|
|
:physics-enabled="physicsEnabled"
|
|
@toggle-physics="togglePhysics"
|
|
>
|
|
<template #actions>
|
|
<button type="button" class="btn btn-secondary btn-sm" @click="filtersOpen = true">
|
|
Фильтры
|
|
</button>
|
|
</template>
|
|
</GraphHeaderPanel>
|
|
|
|
<GraphFiltersModal
|
|
:open="filtersOpen"
|
|
:relation-types="allRelationTypes"
|
|
:active-filters="activeFilters"
|
|
:link-selection-count="linkSelectionCount"
|
|
:link-selection="linkSelection"
|
|
:toolbar-actions="graphToolbarActions"
|
|
@close="filtersOpen = false"
|
|
@toggle="toggleFilter"
|
|
@toolbar-action="runGraphToolbarAction"
|
|
/>
|
|
|
|
<div class="graph-area" ref="graphArea" @contextmenu.prevent="onGraphAreaContextMenu">
|
|
<div class="graph-chrome-bar">
|
|
<button
|
|
type="button"
|
|
class="graph-chrome-toggle"
|
|
:title="chromeCollapsed ? 'Показать панели' : 'Свернуть панели'"
|
|
@click="toggleChrome"
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
:class="{ 'graph-chrome-toggle__icon--collapsed': chromeCollapsed }"
|
|
>
|
|
<polyline points="18 15 12 9 6 15"/>
|
|
</svg>
|
|
<span>{{ chromeCollapsed ? 'Показать панели' : 'Свернуть панели' }}</span>
|
|
</button>
|
|
</div>
|
|
<div v-if="loading" class="spinner"></div>
|
|
<div v-else-if="nodes.length === 0" class="empty-state card" @contextmenu.prevent="onGraphAreaContextMenu">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<circle cx="5" cy="12" r="3"/><circle cx="19" cy="5" r="3"/><circle cx="19" cy="19" r="3"/>
|
|
</svg>
|
|
<p>Нет контактов. <RouterLink to="/contacts">Добавьте контакты</RouterLink> или <RouterLink to="/import">импортируйте файл</RouterLink>.</p>
|
|
</div>
|
|
<div v-else class="graph-stack" ref="graphStack">
|
|
<div class="graph-view-tools">
|
|
<button
|
|
type="button"
|
|
class="graph-fit-btn btn btn-secondary btn-sm"
|
|
title="По центру"
|
|
@click="fitView"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
|
|
</svg>
|
|
По центру
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="graph-fullscreen-btn"
|
|
:title="isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'"
|
|
@click="toggleFullscreen"
|
|
>
|
|
<svg v-if="!isFullscreen" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M8 3H5a2 2 0 0 0-2 2v3"/>
|
|
<path d="M21 8V5a2 2 0 0 0-2-2h-3"/>
|
|
<path d="M3 16v3a2 2 0 0 0 2 2h3"/>
|
|
<path d="M16 21h3a2 2 0 0 0 2-2v-3"/>
|
|
</svg>
|
|
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M4 14h6v6"/>
|
|
<path d="M20 10h-6V4"/>
|
|
<path d="M14 10l7-7"/>
|
|
<path d="M3 21l7-7"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div id="graph-container" ref="graphContainer"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Node detail panel -->
|
|
<div v-if="selectedNode" class="modal-overlay" @click.self="selectedNode = null">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h3>{{ selectedNode.label }}</h3>
|
|
<button class="btn btn-secondary btn-sm" @click="selectedNode = null">✕</button>
|
|
</div>
|
|
<div v-if="selectedContact">
|
|
<div class="form-group">
|
|
<label>Email</label>
|
|
<div>{{ selectedContact.email || '—' }}</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Телефон</label>
|
|
<div>{{ selectedContact.phone || '—' }}</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Организация / Должность</label>
|
|
<div>{{ [selectedContact.organization, selectedContact.position].filter(Boolean).join(' · ') || '—' }}</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Заметки</label>
|
|
<div>{{ selectedContact.notes || '—' }}</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Связей</label>
|
|
<div>{{ selectedContact.relations_count }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<RouterLink :to="`/contacts/${selectedNode.id}`" class="btn btn-primary btn-sm">Открыть</RouterLink>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<GraphNodeContextMenu
|
|
:open="contextMenuOpen"
|
|
:node="contextMenuNode"
|
|
:x="contextMenuX"
|
|
:y="contextMenuY"
|
|
@close="closeContextMenu"
|
|
/>
|
|
|
|
<GraphEdgeContextMenu
|
|
:open="edgeContextMenuOpen"
|
|
:edge="contextMenuEdge"
|
|
:x="edgeContextMenuX"
|
|
:y="edgeContextMenuY"
|
|
@close="closeEdgeContextMenu"
|
|
@edit="openEditRelation"
|
|
/>
|
|
|
|
<GraphCanvasContextMenu
|
|
:open="canvasContextMenuOpen"
|
|
:x="canvasContextMenuX"
|
|
:y="canvasContextMenuY"
|
|
@close="closeContextMenu"
|
|
@create-contact="openCreateContact"
|
|
/>
|
|
|
|
<CreateContactModal
|
|
:open="createContactOpen"
|
|
:show-relation-link="graphContactOptions.length > 0"
|
|
:link-to-options="graphContactOptions"
|
|
:initial-link-to-id="linkSelection[0]?.id"
|
|
@close="createContactOpen = false"
|
|
@created="onContactCreated"
|
|
/>
|
|
|
|
<EditRelationModal
|
|
:open="editRelationOpen"
|
|
:relation="editRelationTarget"
|
|
@close="closeEditRelation"
|
|
@updated="onRelationUpdated"
|
|
@deleted="onRelationDeleted"
|
|
/>
|
|
|
|
<CreateRelationModal
|
|
:open="relationModalOpen"
|
|
:source="relationPair?.[0]"
|
|
:target="relationPair?.[1]"
|
|
@close="closeRelationModal"
|
|
@created="onRelationCreated"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
defineOptions({ name: 'Graph' })
|
|
|
|
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch } from 'vue'
|
|
import { RouterLink, useRouter, onBeforeRouteLeave } 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, updateClusterMapForNodes } from '../lib/graph/clusters'
|
|
import { computeGraphSeedPositions } from '../lib/graph/graphLayout'
|
|
import { readGraphLayoutCache, writeGraphLayoutCache } from '../lib/graph/graphLayoutCache'
|
|
import { applyIntensityToVisEdge } from '../lib/graph/relationIntensity'
|
|
import { buildGraphFromStore, edgeFromRelation } from '../application/usecases/graph'
|
|
import GraphHeaderPanel from '../components/GraphHeaderPanel.vue'
|
|
import GraphFiltersModal from '../components/GraphFiltersModal.vue'
|
|
import CreateRelationModal from '../components/CreateRelationModal.vue'
|
|
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
|
|
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
|
|
import GraphCanvasContextMenu from '../components/GraphCanvasContextMenu.vue'
|
|
import CreateContactModal from '../components/CreateContactModal.vue'
|
|
import EditRelationModal from '../components/EditRelationModal.vue'
|
|
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
|
import { useNetworkMapsStore } from '../stores/networkMaps'
|
|
import { getGraphToolbarActions } from '../core/pluginRegistry'
|
|
import { loadTopPanelCollapsed, saveTopPanelCollapsed } from '../lib/ui/topPanelCollapseStorage'
|
|
|
|
let themeObserver = null
|
|
let detachContextHandler = null
|
|
|
|
const {
|
|
contextMenuOpen,
|
|
contextMenuNode,
|
|
contextMenuX,
|
|
contextMenuY,
|
|
edgeContextMenuOpen,
|
|
contextMenuEdge,
|
|
edgeContextMenuX,
|
|
edgeContextMenuY,
|
|
canvasContextMenuOpen,
|
|
canvasContextMenuX,
|
|
canvasContextMenuY,
|
|
openCanvasContextMenu,
|
|
closeContextMenu,
|
|
closeEdgeContextMenu,
|
|
attachNodeContextHandlers,
|
|
} = useGraphNodeContextMenu()
|
|
|
|
const createContactOpen = ref(false)
|
|
|
|
function openCreateContact() {
|
|
closeContextMenu()
|
|
createContactOpen.value = true
|
|
}
|
|
|
|
function onGraphAreaContextMenu(event) {
|
|
if (nodes.value.length > 0) return
|
|
openCanvasContextMenu(event)
|
|
}
|
|
|
|
async function onContactCreated(data, mapIds, pluginPayload, relationLink) {
|
|
const created = await store.createContact(data)
|
|
if (mapIds?.length) {
|
|
await mapsStore.setContactMapMemberships(created.id, mapIds)
|
|
}
|
|
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
|
await saveContactPluginData(created.id, pluginPayload)
|
|
|
|
let relation = null
|
|
if (relationLink?.targetId && String(relationLink.targetId) !== String(created.id)) {
|
|
relation = await store.createRelation({
|
|
source: created.id,
|
|
target: relationLink.targetId,
|
|
relation_type: relationLink.type,
|
|
description: relationLink.description || '',
|
|
interaction_intensity: relationLink.intensity,
|
|
})
|
|
}
|
|
|
|
createContactOpen.value = false
|
|
clearLinkSelection()
|
|
await ensureGraphReady({ showSpinner: false })
|
|
if (relation) appendRelationEdge(relation)
|
|
}
|
|
|
|
const store = useContactsStore()
|
|
const mapsStore = useNetworkMapsStore()
|
|
const router = useRouter()
|
|
const graphToolbarActions = getGraphToolbarActions()
|
|
const graphArea = ref(null)
|
|
const graphStack = ref(null)
|
|
const graphContainer = ref(null)
|
|
const loading = ref(true)
|
|
const isFullscreen = ref(false)
|
|
const filtersOpen = ref(false)
|
|
const chromeCollapsed = ref(loadTopPanelCollapsed('graph'))
|
|
const network = ref(null)
|
|
const physicsEnabled = ref(true)
|
|
const selectedNode = ref(null)
|
|
const relationModalOpen = ref(false)
|
|
const relationPair = ref(null)
|
|
const editRelationOpen = ref(false)
|
|
const editRelationTarget = ref(null)
|
|
|
|
const {
|
|
linkSelection,
|
|
linkSelectionCount,
|
|
clearLinkSelection,
|
|
handleCtrlPickNode,
|
|
} = useCtrlLinkSelection({
|
|
onPairSelected(pair) {
|
|
relationPair.value = pair
|
|
relationModalOpen.value = true
|
|
},
|
|
})
|
|
|
|
let nodesDS = null
|
|
let edgesDS = null
|
|
let initRetryCount = 0
|
|
const INIT_RETRY_MAX = 40
|
|
let initRetryTimer = null
|
|
let resizeObserver = null
|
|
let syncedRevision = -1
|
|
let graphViewActive = false
|
|
let layoutSnapshotOnLeave = null
|
|
let initialLayoutDone = false
|
|
|
|
const nodes = ref([])
|
|
const edges = ref([])
|
|
const allRelationTypes = ref([])
|
|
const activeFilters = ref([])
|
|
const clusterMap = ref(new Map())
|
|
let prevLinkSelectionIds = new Set()
|
|
|
|
const selectedContact = computed(() =>
|
|
selectedNode.value ? store.contactById(selectedNode.value.id) : null
|
|
)
|
|
|
|
const graphContactOptions = computed(() =>
|
|
store.contacts.map((c) => ({
|
|
value: String(c.id),
|
|
label: [c.name, c.organization].filter(Boolean).join(' · '),
|
|
}))
|
|
)
|
|
|
|
function cssVar(name, fallback) {
|
|
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
|
return value || fallback
|
|
}
|
|
|
|
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'),
|
|
edgeFont: cssVar('--text-muted', '#7b82a6'),
|
|
}
|
|
}
|
|
|
|
function recomputeClusters() {
|
|
clusterMap.value = computeClusterMap(nodes.value, filteredEdges())
|
|
}
|
|
|
|
function updateClustersLocal(seedNodeIds) {
|
|
const map = clusterMap.value
|
|
const affected = updateClusterMapForNodes(
|
|
map,
|
|
nodes.value,
|
|
filteredEdges(),
|
|
seedNodeIds
|
|
)
|
|
clusterMap.value = map
|
|
return affected
|
|
}
|
|
|
|
function refreshNodeStylesForIds(nodeIds) {
|
|
if (!nodesDS || !nodeIds?.size) return
|
|
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
|
const livePositions = network.value?.getPositions() || {}
|
|
const idSet = new Set([...nodeIds].map(String))
|
|
const targets = nodes.value.filter((n) => idSet.has(String(n.id)))
|
|
if (!targets.length) return
|
|
nodesDS.update(
|
|
targets.map((n) => {
|
|
const id = String(n.id)
|
|
const vis = mapGraphNodeToVis(n, linkIds)
|
|
const pos = livePositions[id]
|
|
return pos ? { ...vis, x: pos.x, y: pos.y } : vis
|
|
})
|
|
)
|
|
network.value?.redraw()
|
|
}
|
|
|
|
function refreshNodeStyles() {
|
|
refreshNodeStylesForIds(new Set(nodes.value.map((n) => String(n.id))))
|
|
}
|
|
|
|
function withPhysicsPaused(fn) {
|
|
if (!network.value) {
|
|
fn()
|
|
return
|
|
}
|
|
|
|
const savedPositions = network.value.getPositions()
|
|
const savedView = {
|
|
position: network.value.getViewPosition(),
|
|
scale: network.value.getScale(),
|
|
}
|
|
const wasEnabled = physicsEnabled.value
|
|
|
|
if (wasEnabled) {
|
|
network.value.setOptions({ physics: physicsOptions(false) })
|
|
}
|
|
|
|
try {
|
|
fn()
|
|
} finally {
|
|
if (nodesDS && savedPositions) {
|
|
nodesDS.update(
|
|
Object.entries(savedPositions).map(([id, pos]) => ({ id, x: pos.x, y: pos.y }))
|
|
)
|
|
}
|
|
if (wasEnabled) {
|
|
network.value.setOptions({ physics: physicsOptions(true) })
|
|
}
|
|
if (savedView.position) {
|
|
network.value.moveTo({
|
|
position: savedView.position,
|
|
scale: savedView.scale || 1,
|
|
animation: false,
|
|
})
|
|
}
|
|
saveLayoutSnapshot()
|
|
}
|
|
}
|
|
|
|
function applyLocalEdgeChange(seedNodeIds, mutate) {
|
|
withPhysicsPaused(() => {
|
|
mutate()
|
|
const affected = updateClustersLocal(seedNodeIds)
|
|
const styleIds = new Set([...seedNodeIds].map(String))
|
|
affected.forEach((id) => styleIds.add(id))
|
|
refreshNodeStylesForIds(styleIds)
|
|
})
|
|
}
|
|
|
|
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)
|
|
const label = degree <= 12 ? (n.label || String(n.id)) : ''
|
|
return {
|
|
id: String(n.id),
|
|
label,
|
|
title: [n.label, n.title].filter(Boolean).join('\n'),
|
|
color: nodeVisColor(n.id, linkIds),
|
|
font: { color: palette.nodeFont, size: degree > 0 ? 12 : 11 },
|
|
shape: 'dot',
|
|
size: degree > 0 ? 10 + Math.min(degree, 4) * 1.5 : 8,
|
|
borderWidth: linkIds.has(String(n.id)) ? 3 : 2,
|
|
}
|
|
}
|
|
|
|
function applyLinkHighlights() {
|
|
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
|
const affected = new Set([...linkIds, ...prevLinkSelectionIds])
|
|
prevLinkSelectionIds = linkIds
|
|
refreshNodeStylesForIds(affected)
|
|
}
|
|
|
|
function mapGraphEdgeToVis(e) {
|
|
const palette = graphPalette()
|
|
const rc = RELATION_COLORS[e.relation_type] || RELATION_COLORS.other
|
|
const intensity = applyIntensityToVisEdge(e, { color: rc.color, highlight: rc.highlight })
|
|
return {
|
|
id: String(e.id),
|
|
from: String(e.from),
|
|
to: String(e.to),
|
|
relation_type: e.relation_type,
|
|
interaction_intensity: e.interaction_intensity,
|
|
...intensity,
|
|
hoverWidth: intensity.width + 8,
|
|
selectionWidth: intensity.width + 12,
|
|
font: { color: palette.edgeFont, size: 10, align: 'middle' },
|
|
arrows: { to: { enabled: false } },
|
|
smooth: false,
|
|
}
|
|
}
|
|
|
|
function resolveRelationForEdit(edge) {
|
|
const fromStore = store.relations.find((r) => String(r.id) === String(edge.id))
|
|
if (fromStore) return fromStore
|
|
const fromNode = nodes.value.find((n) => String(n.id) === String(edge.from))
|
|
const toNode = nodes.value.find((n) => String(n.id) === String(edge.to))
|
|
return {
|
|
id: edge.id,
|
|
source: edge.from,
|
|
target: edge.to,
|
|
relation_type: edge.relation_type,
|
|
description: edge.title && edge.title !== edge.relation_type ? edge.title : '',
|
|
interaction_intensity: edge.interaction_intensity || 'intense',
|
|
source_name: fromNode?.label || String(edge.from),
|
|
target_name: toNode?.label || String(edge.to),
|
|
}
|
|
}
|
|
|
|
function openEditRelation(edge) {
|
|
editRelationTarget.value = resolveRelationForEdit(edge)
|
|
editRelationOpen.value = true
|
|
}
|
|
|
|
function closeEditRelation() {
|
|
editRelationOpen.value = false
|
|
editRelationTarget.value = null
|
|
}
|
|
|
|
function updateGraphEdge(relation) {
|
|
const edge = edgeFromRelation(relation)
|
|
const idx = edges.value.findIndex((e) => String(e.id) === String(edge.id))
|
|
if (idx !== -1) edges.value[idx] = edge
|
|
else 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
|
|
const visible =
|
|
activeFilters.value.length === allRelationTypes.value.length ||
|
|
activeFilters.value.includes(edge.relation_type)
|
|
if (!visible) {
|
|
if (edgesDS.get(String(edge.id))) edgesDS.remove(String(edge.id))
|
|
return
|
|
}
|
|
const visEdge = mapGraphEdgeToVis(edge)
|
|
if (edgesDS.get(String(edge.id))) edgesDS.update(visEdge)
|
|
else edgesDS.add(visEdge)
|
|
}
|
|
|
|
function onRelationUpdated(relation) {
|
|
closeEditRelation()
|
|
syncedRevision = store.dataRevision
|
|
const edge = edgeFromRelation(relation)
|
|
const seedNodeIds = [edge.from, edge.to]
|
|
applyLocalEdgeChange(seedNodeIds, () => {
|
|
updateGraphEdge(relation)
|
|
})
|
|
}
|
|
|
|
function removeGraphEdge(relationId) {
|
|
const sid = String(relationId)
|
|
const removed = edges.value.find((e) => String(e.id) === sid)
|
|
const seedNodeIds = removed ? [removed.from, removed.to] : []
|
|
applyLocalEdgeChange(seedNodeIds, () => {
|
|
edges.value = edges.value.filter((e) => String(e.id) !== sid)
|
|
if (edgesDS?.get(sid)) edgesDS.remove(sid)
|
|
})
|
|
}
|
|
|
|
function onRelationDeleted(relationId) {
|
|
closeEditRelation()
|
|
syncedRevision = store.dataRevision
|
|
removeGraphEdge(relationId)
|
|
}
|
|
|
|
function appendRelationEdge(relation) {
|
|
if (!relation) return
|
|
const edge = edgeFromRelation(relation)
|
|
const seedNodeIds = [edge.from, edge.to]
|
|
|
|
applyLocalEdgeChange(seedNodeIds, () => {
|
|
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))
|
|
}
|
|
})
|
|
}
|
|
|
|
function closeRelationModal() {
|
|
relationModalOpen.value = false
|
|
relationPair.value = null
|
|
clearLinkSelection()
|
|
applyLinkHighlights()
|
|
}
|
|
|
|
function onRelationCreated(relation) {
|
|
syncedRevision = store.dataRevision
|
|
relationModalOpen.value = false
|
|
relationPair.value = null
|
|
clearLinkSelection()
|
|
applyLinkHighlights()
|
|
appendRelationEdge(relation)
|
|
}
|
|
|
|
watch(linkSelection, () => {
|
|
applyLinkHighlights()
|
|
}, { deep: true })
|
|
|
|
function physicsOptions(enabled) {
|
|
return {
|
|
enabled,
|
|
solver: 'forceAtlas2Based',
|
|
forceAtlas2Based: {
|
|
gravitationalConstant: -120,
|
|
centralGravity: 0.002,
|
|
springLength: 200,
|
|
springConstant: 0.035,
|
|
damping: 0.5,
|
|
avoidOverlap: 1,
|
|
},
|
|
stabilization: enabled
|
|
? { iterations: 200, fit: !initialLayoutDone, updateInterval: 25 }
|
|
: undefined,
|
|
maxVelocity: 20,
|
|
}
|
|
}
|
|
|
|
async function applyGraphDataFromStore() {
|
|
if (!store.contacts.length) await store.fetchContacts()
|
|
if (!store.relations.length) await store.fetchRelations()
|
|
if (!store.relationTypes.length) {
|
|
allRelationTypes.value = await store.fetchRelationTypes()
|
|
} else {
|
|
allRelationTypes.value = store.relationTypes
|
|
}
|
|
const bundle = buildGraphFromStore(store.contacts, store.relations, allRelationTypes.value)
|
|
nodes.value = bundle.nodes
|
|
edges.value = bundle.edges
|
|
if (!activeFilters.value.length && allRelationTypes.value.length) {
|
|
activeFilters.value = allRelationTypes.value.map((r) => r.value)
|
|
}
|
|
recomputeClusters()
|
|
}
|
|
|
|
function saveLayoutSnapshot() {
|
|
if (!network.value) return
|
|
writeGraphLayoutCache({
|
|
positions: network.value.getPositions(),
|
|
scale: network.value.getScale(),
|
|
view: network.value.getViewPosition(),
|
|
})
|
|
}
|
|
|
|
function captureLayoutSnapshot() {
|
|
if (!network.value) return null
|
|
const view = network.value.getViewPosition()
|
|
return {
|
|
positions: { ...network.value.getPositions() },
|
|
scale: network.value.getScale(),
|
|
view: view ? { x: view.x, y: view.y } : null,
|
|
}
|
|
}
|
|
|
|
function applyLayoutSnapshot(snapshot) {
|
|
if (!network.value || !nodesDS || !snapshot) return
|
|
const updates = Object.entries(snapshot.positions || {})
|
|
.filter(([id]) => nodesDS.get(id))
|
|
.map(([id, pos]) => ({ id, x: pos.x, y: pos.y }))
|
|
if (updates.length) nodesDS.update(updates)
|
|
if (snapshot.view) {
|
|
network.value.moveTo({
|
|
position: snapshot.view,
|
|
scale: snapshot.scale || 1,
|
|
animation: false,
|
|
})
|
|
}
|
|
writeGraphLayoutCache(snapshot)
|
|
}
|
|
|
|
function resizeNetworkCanvas() {
|
|
if (!network.value || !graphContainer.value) return
|
|
const { offsetWidth: w, offsetHeight: h } = graphContainer.value
|
|
if (w > 10 && h > 10) {
|
|
network.value.setSize(`${w}px`, `${h}px`)
|
|
}
|
|
}
|
|
|
|
function restoreViewport() {
|
|
if (!network.value) return
|
|
const cache = readGraphLayoutCache()
|
|
if (cache.view) {
|
|
network.value.moveTo({
|
|
position: cache.view,
|
|
scale: cache.scale || 1,
|
|
animation: false,
|
|
})
|
|
}
|
|
}
|
|
|
|
function finishInitialLayout({ fitView = true } = {}) {
|
|
saveLayoutSnapshot()
|
|
initialLayoutDone = true
|
|
if (fitView) {
|
|
network.value?.fit({ animation: false })
|
|
}
|
|
network.value?.redraw()
|
|
}
|
|
|
|
function teardownNetwork() {
|
|
if (initRetryTimer) {
|
|
clearTimeout(initRetryTimer)
|
|
initRetryTimer = null
|
|
}
|
|
detachContextHandler?.()
|
|
detachContextHandler = null
|
|
resizeObserver?.disconnect()
|
|
resizeObserver = null
|
|
network.value?.destroy()
|
|
network.value = null
|
|
nodesDS = null
|
|
edgesDS = null
|
|
}
|
|
|
|
function syncGraphMetadataOnly(lockedPositions) {
|
|
if (!network.value || !nodesDS || !edgesDS) return
|
|
|
|
const positions = lockedPositions || network.value.getPositions()
|
|
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
|
|
|
const nextNodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
|
nodesDS.getIds().forEach((id) => {
|
|
if (!nextNodeIds.has(String(id))) nodesDS.remove(id)
|
|
})
|
|
|
|
nodes.value.forEach((n) => {
|
|
const id = String(n.id)
|
|
const vis = mapGraphNodeToVis(n, linkIds)
|
|
const pos = positions[id]
|
|
const payload = pos ? { ...vis, x: pos.x, y: pos.y } : vis
|
|
if (nodesDS.get(id)) nodesDS.update(payload)
|
|
else nodesDS.add(payload)
|
|
})
|
|
|
|
const nextEdges = filteredEdges().map(mapGraphEdgeToVis)
|
|
const nextEdgeIds = new Set(nextEdges.map((e) => String(e.id)))
|
|
edgesDS.getIds().forEach((id) => {
|
|
if (!nextEdgeIds.has(String(id))) edgesDS.remove(id)
|
|
})
|
|
nextEdges.forEach((edge) => {
|
|
if (edgesDS.get(edge.id)) edgesDS.update(edge)
|
|
else edgesDS.add(edge)
|
|
})
|
|
|
|
recomputeClusters()
|
|
}
|
|
|
|
function syncGraphToNetwork() {
|
|
if (!network.value || !nodesDS || !edgesDS) return
|
|
|
|
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
|
const cachedPositions = readGraphLayoutCache().positions || {}
|
|
const livePositions = network.value.getPositions()
|
|
const seeds = computeGraphSeedPositions(nodes.value, filteredEdges())
|
|
|
|
const nextNodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
|
nodesDS.getIds().forEach((id) => {
|
|
if (!nextNodeIds.has(String(id))) nodesDS.remove(id)
|
|
})
|
|
|
|
nodes.value.forEach((n) => {
|
|
const id = String(n.id)
|
|
const vis = mapGraphNodeToVis(n, linkIds)
|
|
const pos = cachedPositions[id] || livePositions[id] || seeds.get(id)
|
|
const payload = pos ? { ...vis, x: pos.x, y: pos.y } : vis
|
|
if (nodesDS.get(id)) nodesDS.update(payload)
|
|
else nodesDS.add(payload)
|
|
})
|
|
|
|
const nextEdges = filteredEdges().map(mapGraphEdgeToVis)
|
|
const nextEdgeIds = new Set(nextEdges.map((e) => String(e.id)))
|
|
edgesDS.getIds().forEach((id) => {
|
|
if (!nextEdgeIds.has(String(id))) edgesDS.remove(id)
|
|
})
|
|
nextEdges.forEach((edge) => {
|
|
if (edgesDS.get(edge.id)) edgesDS.update(edge)
|
|
else edgesDS.add(edge)
|
|
})
|
|
|
|
recomputeClusters()
|
|
refreshNodeStyles()
|
|
syncedRevision = store.dataRevision
|
|
restoreViewport()
|
|
}
|
|
|
|
let ensureGraphReadyInFlight = null
|
|
|
|
async function ensureGraphReady({ showSpinner = true } = {}) {
|
|
if (ensureGraphReadyInFlight) return ensureGraphReadyInFlight
|
|
ensureGraphReadyInFlight = (async () => {
|
|
const reconnecting = Boolean(network.value)
|
|
if (showSpinner && !reconnecting) loading.value = true
|
|
try {
|
|
await applyGraphDataFromStore()
|
|
} finally {
|
|
if (showSpinner && !reconnecting) loading.value = false
|
|
}
|
|
|
|
if (nodes.value.length === 0) {
|
|
teardownNetwork()
|
|
syncedRevision = store.dataRevision
|
|
return
|
|
}
|
|
|
|
await nextTick()
|
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
|
|
|
if (!network.value) {
|
|
initNetwork()
|
|
syncedRevision = store.dataRevision
|
|
return
|
|
}
|
|
|
|
if (syncedRevision !== store.dataRevision) {
|
|
syncGraphToNetwork()
|
|
}
|
|
})().finally(() => {
|
|
ensureGraphReadyInFlight = null
|
|
})
|
|
return ensureGraphReadyInFlight
|
|
}
|
|
|
|
function filteredEdges() {
|
|
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
|
|
let list = edges.value.filter(
|
|
(e) => nodeIds.has(String(e.from)) && nodeIds.has(String(e.to)),
|
|
)
|
|
if (activeFilters.value.length < allRelationTypes.value.length) {
|
|
list = list.filter((e) => activeFilters.value.includes(e.relation_type))
|
|
}
|
|
return list
|
|
}
|
|
|
|
function initNetwork() {
|
|
if (network.value) return
|
|
if (!graphContainer.value) {
|
|
if (initRetryCount >= INIT_RETRY_MAX) return
|
|
initRetryCount += 1
|
|
initRetryTimer = setTimeout(initNetwork, 80)
|
|
return
|
|
}
|
|
const el = graphContainer.value
|
|
let width = el.offsetWidth
|
|
let height = el.offsetHeight
|
|
if (width < 10 || height < 10) {
|
|
if (initRetryCount >= INIT_RETRY_MAX) {
|
|
el.style.height = '400px'
|
|
el.style.width = '100%'
|
|
width = el.offsetWidth
|
|
height = el.offsetHeight
|
|
} else {
|
|
initRetryCount += 1
|
|
initRetryTimer = setTimeout(initNetwork, 80)
|
|
return
|
|
}
|
|
}
|
|
initRetryCount = 0
|
|
if (initRetryTimer) {
|
|
clearTimeout(initRetryTimer)
|
|
initRetryTimer = null
|
|
}
|
|
|
|
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
|
const cache = readGraphLayoutCache()
|
|
const cachedPositions = cache.positions || {}
|
|
const nodeIds = nodes.value.map((n) => String(n.id))
|
|
const cachedCount = nodeIds.filter((id) => cachedPositions[id]).length
|
|
const useCachedLayout = cachedCount > 0 && cachedCount >= nodeIds.length * 0.8
|
|
|
|
const seedPositions = computeGraphSeedPositions(nodes.value, filteredEdges())
|
|
const nodeList = nodes.value.map((n) => {
|
|
const vis = mapGraphNodeToVis(n, linkIds)
|
|
const id = String(n.id)
|
|
const pos = cachedPositions[id] || seedPositions.get(id)
|
|
return pos ? { ...vis, x: pos.x, y: pos.y } : vis
|
|
})
|
|
const edgeList = filteredEdges().map(mapGraphEdgeToVis)
|
|
|
|
nodesDS = new DataSet(nodeList)
|
|
edgesDS = new DataSet(edgeList)
|
|
|
|
if (useCachedLayout) {
|
|
initialLayoutDone = true
|
|
}
|
|
|
|
network.value = new Network(
|
|
el,
|
|
{ nodes: nodesDS, edges: edgesDS },
|
|
{
|
|
layout: { improvedLayout: false },
|
|
physics: physicsOptions(physicsEnabled.value),
|
|
interaction: {
|
|
tooltipDelay: 200,
|
|
hover: true,
|
|
hideEdgesOnDrag: true,
|
|
selectConnectedEdges: false,
|
|
zoomView: true,
|
|
dragView: true,
|
|
dragNodes: true,
|
|
},
|
|
edges: {
|
|
chosen: { label: false },
|
|
},
|
|
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)
|
|
const domEvent = params.event?.srcEvent || params.event
|
|
if (domEvent && (domEvent.ctrlKey || domEvent.metaKey)) {
|
|
handleCtrlPickNode(node, domEvent)
|
|
}
|
|
}
|
|
})
|
|
|
|
network.value.on('dragEnd', () => {
|
|
saveLayoutSnapshot()
|
|
})
|
|
|
|
detachContextHandler?.()
|
|
detachContextHandler = attachNodeContextHandlers(
|
|
network.value,
|
|
() => nodes.value,
|
|
() => edges.value
|
|
)
|
|
|
|
if (useCachedLayout) {
|
|
restoreViewport()
|
|
network.value.redraw()
|
|
finishInitialLayout({ fitView: false })
|
|
} else {
|
|
let layoutFinished = false
|
|
const completeLayout = () => {
|
|
if (layoutFinished) return
|
|
layoutFinished = true
|
|
finishInitialLayout({ fitView: true })
|
|
}
|
|
network.value.once('stabilizationIterationsDone', completeLayout)
|
|
network.value.once('stabilized', completeLayout)
|
|
setTimeout(completeLayout, 2500)
|
|
}
|
|
|
|
resizeObserver = new ResizeObserver(() => {
|
|
network.value?.redraw()
|
|
})
|
|
resizeObserver.observe(el)
|
|
}
|
|
|
|
function toggleFilter(type) {
|
|
if (activeFilters.value.includes(type)) {
|
|
if (activeFilters.value.length === 1) return
|
|
activeFilters.value = activeFilters.value.filter((f) => f !== type)
|
|
} else {
|
|
activeFilters.value.push(type)
|
|
}
|
|
if (edgesDS) {
|
|
recomputeClusters()
|
|
edgesDS.clear()
|
|
edgesDS.add(filteredEdges().map(mapGraphEdgeToVis))
|
|
refreshNodeStyles()
|
|
}
|
|
}
|
|
|
|
function applyThemeToNetwork() {
|
|
if (!nodesDS || !edgesDS || !network.value) return
|
|
refreshNodeStyles()
|
|
const palette = graphPalette()
|
|
edgesDS.update(
|
|
filteredEdges().map((e) => ({
|
|
id: String(e.id),
|
|
font: { color: palette.edgeFont, size: 10, align: 'middle' },
|
|
}))
|
|
)
|
|
network.value.redraw()
|
|
}
|
|
|
|
function fitView() {
|
|
network.value?.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' }, padding: 56 })
|
|
}
|
|
|
|
function togglePhysics() {
|
|
physicsEnabled.value = !physicsEnabled.value
|
|
network.value?.setOptions({ physics: physicsOptions(physicsEnabled.value) })
|
|
}
|
|
|
|
async function toggleFullscreen() {
|
|
const el = graphStack.value
|
|
if (!el) return
|
|
try {
|
|
if (document.fullscreenElement === el) {
|
|
await document.exitFullscreen()
|
|
} else {
|
|
await el.requestFullscreen()
|
|
}
|
|
} catch {
|
|
// Browser may block fullscreen without user gesture or in unsupported context.
|
|
}
|
|
}
|
|
|
|
function onFullscreenChange() {
|
|
isFullscreen.value = document.fullscreenElement === graphStack.value
|
|
nextTick(() => {
|
|
network.value?.redraw()
|
|
network.value?.fit({ animation: false })
|
|
})
|
|
}
|
|
|
|
function toggleChrome() {
|
|
chromeCollapsed.value = !chromeCollapsed.value
|
|
saveTopPanelCollapsed('graph', chromeCollapsed.value)
|
|
nextTick(() => network.value?.redraw())
|
|
}
|
|
|
|
function runGraphToolbarAction(action) {
|
|
action.onClick?.({ router })
|
|
}
|
|
|
|
watch(() => store.dataRevision, async (revision) => {
|
|
if (!network.value || !graphViewActive) return
|
|
await nextTick()
|
|
if (revision === syncedRevision) return
|
|
await applyGraphDataFromStore()
|
|
if (nodes.value.length === 0) {
|
|
teardownNetwork()
|
|
syncedRevision = revision
|
|
return
|
|
}
|
|
syncGraphToNetwork()
|
|
})
|
|
|
|
onMounted(() => {
|
|
themeObserver = new MutationObserver(() => applyThemeToNetwork())
|
|
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
|
document.addEventListener('fullscreenchange', onFullscreenChange)
|
|
nextTick(() => {
|
|
if (!network.value) ensureGraphReady()
|
|
})
|
|
})
|
|
|
|
onBeforeRouteLeave(() => {
|
|
if (network.value && physicsEnabled.value) {
|
|
network.value.setOptions({ physics: physicsOptions(false) })
|
|
}
|
|
layoutSnapshotOnLeave = captureLayoutSnapshot()
|
|
saveLayoutSnapshot()
|
|
})
|
|
|
|
onActivated(async () => {
|
|
graphViewActive = true
|
|
const snapshot = layoutSnapshotOnLeave
|
|
layoutSnapshotOnLeave = null
|
|
|
|
if (network.value) {
|
|
network.value.setOptions({ physics: physicsOptions(false) })
|
|
await nextTick()
|
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
|
resizeNetworkCanvas()
|
|
|
|
const lockedPositions = snapshot?.positions
|
|
|| readGraphLayoutCache().positions
|
|
|| {}
|
|
|
|
if (syncedRevision !== store.dataRevision) {
|
|
await applyGraphDataFromStore()
|
|
syncGraphMetadataOnly(lockedPositions)
|
|
syncedRevision = store.dataRevision
|
|
}
|
|
|
|
if (snapshot) {
|
|
applyLayoutSnapshot(snapshot)
|
|
} else {
|
|
restoreViewport()
|
|
}
|
|
|
|
if (physicsEnabled.value) {
|
|
network.value.setOptions({ physics: physicsOptions(true) })
|
|
}
|
|
network.value.redraw()
|
|
return
|
|
}
|
|
await ensureGraphReady()
|
|
})
|
|
|
|
onDeactivated(() => {
|
|
graphViewActive = false
|
|
if (network.value && physicsEnabled.value) {
|
|
network.value.setOptions({ physics: physicsOptions(false) })
|
|
}
|
|
if (!layoutSnapshotOnLeave) {
|
|
layoutSnapshotOnLeave = captureLayoutSnapshot()
|
|
}
|
|
saveLayoutSnapshot()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
saveLayoutSnapshot()
|
|
closeContextMenu()
|
|
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
|
if (document.fullscreenElement === graphStack.value) {
|
|
document.exitFullscreen().catch(() => {})
|
|
}
|
|
themeObserver?.disconnect()
|
|
teardownNetwork()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.graph-view {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
.graph-chrome-bar {
|
|
display: flex;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
margin-bottom: 6px;
|
|
}
|
|
.graph-chrome-toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 3px 12px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 999px;
|
|
background: var(--surface);
|
|
color: var(--text-muted);
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
|
}
|
|
.graph-chrome-toggle:hover {
|
|
color: var(--text);
|
|
border-color: var(--accent);
|
|
background: var(--surface-alt);
|
|
}
|
|
.graph-chrome-toggle svg {
|
|
transition: transform 0.2s ease;
|
|
}
|
|
.graph-chrome-toggle__icon--collapsed {
|
|
transform: rotate(180deg);
|
|
}
|
|
.graph-view--chrome-collapsed .graph-area {
|
|
padding-top: 4px;
|
|
}
|
|
.graph-area {
|
|
position: relative;
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0 28px 20px;
|
|
}
|
|
.graph-stack {
|
|
position: relative;
|
|
flex: 1;
|
|
min-height: 300px;
|
|
width: 100%;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
overflow: hidden;
|
|
}
|
|
.graph-stack:fullscreen {
|
|
border-radius: 0;
|
|
border: none;
|
|
background: var(--bg);
|
|
}
|
|
.graph-view-tools {
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
z-index: 3;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.graph-fit-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
.graph-fullscreen-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
padding: 0;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--surface);
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
box-shadow: var(--shadow);
|
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
|
}
|
|
.graph-fullscreen-btn:hover {
|
|
color: var(--text);
|
|
border-color: var(--accent);
|
|
background: var(--surface-alt);
|
|
}
|
|
.graph-stack:fullscreen .graph-view-tools {
|
|
top: 16px;
|
|
right: 16px;
|
|
}
|
|
#graph-container {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
</style>
|