Improve graph/map UX and move contact editing to dedicated pages.
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>
This commit is contained in:
+379
-150
@@ -3,36 +3,29 @@
|
||||
<GraphHeaderPanel
|
||||
v-show="!chromeCollapsed"
|
||||
title="Граф связей"
|
||||
:show-reset="false"
|
||||
:show-physics-toggle="true"
|
||||
:physics-enabled="physicsEnabled"
|
||||
@reset="resetView"
|
||||
@toggle-physics="togglePhysics"
|
||||
/>
|
||||
|
||||
<div v-show="!chromeCollapsed" 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"
|
||||
@toggle="toggleFilter"
|
||||
/>
|
||||
<div v-if="graphToolbarActions.length" class="graph-plugin-actions">
|
||||
<button
|
||||
v-for="action in graphToolbarActions"
|
||||
:key="action.id"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="runGraphToolbarAction(action)"
|
||||
>
|
||||
{{ action.label }}
|
||||
>
|
||||
<template #actions>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="filtersOpen = true">
|
||||
Фильтры
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
@@ -56,26 +49,6 @@
|
||||
<span>{{ chromeCollapsed ? 'Показать панели' : 'Свернуть панели' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="!loading && nodes.length > 0"
|
||||
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 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">
|
||||
@@ -83,7 +56,41 @@
|
||||
</svg>
|
||||
<p>Нет контактов. <RouterLink to="/contacts">Добавьте контакты</RouterLink> или <RouterLink to="/import">импортируйте файл</RouterLink>.</p>
|
||||
</div>
|
||||
<div v-else id="graph-container" ref="graphContainer"></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 -->
|
||||
@@ -127,7 +134,6 @@
|
||||
:x="contextMenuX"
|
||||
:y="contextMenuY"
|
||||
@close="closeContextMenu"
|
||||
@info="openNodeInfo"
|
||||
/>
|
||||
|
||||
<GraphEdgeContextMenu
|
||||
@@ -149,6 +155,9 @@
|
||||
|
||||
<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"
|
||||
/>
|
||||
@@ -175,19 +184,19 @@
|
||||
defineOptions({ name: 'Graph' })
|
||||
|
||||
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
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 } from '../lib/graph/clusters'
|
||||
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 RelationTypeFilters from '../components/RelationTypeFilters.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'
|
||||
@@ -197,6 +206,7 @@ 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
|
||||
@@ -221,10 +231,6 @@ const {
|
||||
|
||||
const createContactOpen = ref(false)
|
||||
|
||||
function openNodeInfo(node) {
|
||||
selectedNode.value = node || null
|
||||
}
|
||||
|
||||
function openCreateContact() {
|
||||
closeContextMenu()
|
||||
createContactOpen.value = true
|
||||
@@ -235,15 +241,29 @@ function onGraphAreaContextMenu(event) {
|
||||
openCanvasContextMenu(event)
|
||||
}
|
||||
|
||||
async function onContactCreated(data, mapIds, pluginPayload) {
|
||||
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()
|
||||
@@ -251,10 +271,12 @@ 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 chromeCollapsed = ref(false)
|
||||
const filtersOpen = ref(false)
|
||||
const chromeCollapsed = ref(loadTopPanelCollapsed('graph'))
|
||||
const network = ref(null)
|
||||
const physicsEnabled = ref(true)
|
||||
const selectedNode = ref(null)
|
||||
@@ -282,6 +304,8 @@ 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([])
|
||||
@@ -289,11 +313,19 @@ 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
|
||||
@@ -315,6 +347,89 @@ 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(
|
||||
@@ -363,15 +478,11 @@ function mapGraphNodeToVis(n, linkIds = new Set()) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
|
||||
const affected = new Set([...linkIds, ...prevLinkSelectionIds])
|
||||
prevLinkSelectionIds = linkIds
|
||||
refreshNodeStylesForIds(affected)
|
||||
}
|
||||
|
||||
function mapGraphEdgeToVis(e) {
|
||||
@@ -443,48 +554,53 @@ function updateGraphEdge(relation) {
|
||||
|
||||
function onRelationUpdated(relation) {
|
||||
closeEditRelation()
|
||||
updateGraphEdge(relation)
|
||||
syncedRevision = store.dataRevision
|
||||
if (network.value && physicsEnabled.value) {
|
||||
network.value.stabilize(80)
|
||||
}
|
||||
const edge = edgeFromRelation(relation)
|
||||
const seedNodeIds = [edge.from, edge.to]
|
||||
applyLocalEdgeChange(seedNodeIds, () => {
|
||||
updateGraphEdge(relation)
|
||||
})
|
||||
}
|
||||
|
||||
function removeGraphEdge(relationId) {
|
||||
const sid = String(relationId)
|
||||
edges.value = edges.value.filter((e) => String(e.id) !== sid)
|
||||
if (edgesDS?.get(sid)) edgesDS.remove(sid)
|
||||
recomputeClusters()
|
||||
refreshNodeStyles()
|
||||
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()
|
||||
removeGraphEdge(relationId)
|
||||
syncedRevision = store.dataRevision
|
||||
removeGraphEdge(relationId)
|
||||
}
|
||||
|
||||
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 seedNodeIds = [edge.from, edge.to]
|
||||
|
||||
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()
|
||||
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() {
|
||||
@@ -495,12 +611,12 @@ function closeRelationModal() {
|
||||
}
|
||||
|
||||
function onRelationCreated(relation) {
|
||||
syncedRevision = store.dataRevision
|
||||
relationModalOpen.value = false
|
||||
relationPair.value = null
|
||||
clearLinkSelection()
|
||||
applyLinkHighlights()
|
||||
appendRelationEdge(relation)
|
||||
syncedRevision = store.dataRevision
|
||||
}
|
||||
|
||||
watch(linkSelection, () => {
|
||||
@@ -552,6 +668,40 @@ function saveLayoutSnapshot() {
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -588,12 +738,45 @@ function teardownNetwork() {
|
||||
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 livePositions = network.value.getPositions()
|
||||
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)))
|
||||
@@ -604,7 +787,7 @@ function syncGraphToNetwork() {
|
||||
nodes.value.forEach((n) => {
|
||||
const id = String(n.id)
|
||||
const vis = mapGraphNodeToVis(n, linkIds)
|
||||
const pos = livePositions[id] || cachedPositions[id] || seeds.get(id)
|
||||
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)
|
||||
@@ -623,6 +806,7 @@ function syncGraphToNetwork() {
|
||||
recomputeClusters()
|
||||
refreshNodeStyles()
|
||||
syncedRevision = store.dataRevision
|
||||
restoreViewport()
|
||||
}
|
||||
|
||||
let ensureGraphReadyInFlight = null
|
||||
@@ -820,8 +1004,8 @@ function applyThemeToNetwork() {
|
||||
network.value.redraw()
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
network.value?.fit({ animation: { duration: 500, easingFunction: 'easeInOutQuad' } })
|
||||
function fitView() {
|
||||
network.value?.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' }, padding: 56 })
|
||||
}
|
||||
|
||||
function togglePhysics() {
|
||||
@@ -830,7 +1014,7 @@ function togglePhysics() {
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
const el = graphArea.value
|
||||
const el = graphStack.value
|
||||
if (!el) return
|
||||
try {
|
||||
if (document.fullscreenElement === el) {
|
||||
@@ -844,7 +1028,7 @@ async function toggleFullscreen() {
|
||||
}
|
||||
|
||||
function onFullscreenChange() {
|
||||
isFullscreen.value = document.fullscreenElement === graphArea.value
|
||||
isFullscreen.value = document.fullscreenElement === graphStack.value
|
||||
nextTick(() => {
|
||||
network.value?.redraw()
|
||||
network.value?.fit({ animation: false })
|
||||
@@ -853,6 +1037,7 @@ function onFullscreenChange() {
|
||||
|
||||
function toggleChrome() {
|
||||
chromeCollapsed.value = !chromeCollapsed.value
|
||||
saveTopPanelCollapsed('graph', chromeCollapsed.value)
|
||||
nextTick(() => network.value?.redraw())
|
||||
}
|
||||
|
||||
@@ -861,7 +1046,9 @@ function runGraphToolbarAction(action) {
|
||||
}
|
||||
|
||||
watch(() => store.dataRevision, async (revision) => {
|
||||
if (!network.value || revision === syncedRevision) return
|
||||
if (!network.value || !graphViewActive) return
|
||||
await nextTick()
|
||||
if (revision === syncedRevision) return
|
||||
await applyGraphDataFromStore()
|
||||
if (nodes.value.length === 0) {
|
||||
teardownNetwork()
|
||||
@@ -880,20 +1067,58 @@ onMounted(() => {
|
||||
})
|
||||
})
|
||||
|
||||
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.redraw()
|
||||
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 ensureGraphReady({ showSpinner: false })
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -901,7 +1126,7 @@ onUnmounted(() => {
|
||||
saveLayoutSnapshot()
|
||||
closeContextMenu()
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
if (document.fullscreenElement === graphArea.value) {
|
||||
if (document.fullscreenElement === graphStack.value) {
|
||||
document.exitFullscreen().catch(() => {})
|
||||
}
|
||||
themeObserver?.disconnect()
|
||||
@@ -917,15 +1142,6 @@ onUnmounted(() => {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.graph-view-toolbar {
|
||||
flex-shrink: 0;
|
||||
padding: 0 28px 10px;
|
||||
}
|
||||
.graph-plugin-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.graph-chrome-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -959,10 +1175,6 @@ onUnmounted(() => {
|
||||
.graph-view--chrome-collapsed .graph-area {
|
||||
padding-top: 4px;
|
||||
}
|
||||
.graph-link-hint {
|
||||
font-size: 12px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.graph-area {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
@@ -971,41 +1183,8 @@ onUnmounted(() => {
|
||||
flex-direction: column;
|
||||
padding: 0 28px 20px;
|
||||
}
|
||||
.graph-area:fullscreen {
|
||||
padding: 12px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.graph-area:fullscreen #graph-container {
|
||||
min-height: 0;
|
||||
}
|
||||
.graph-fullscreen-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 36px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
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-area:fullscreen .graph-fullscreen-btn {
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
#graph-container {
|
||||
.graph-stack {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
width: 100%;
|
||||
@@ -1014,4 +1193,54 @@ onUnmounted(() => {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user