WIP: local changes
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<div class="graph-view">
|
||||
<div class="graph-view-header">
|
||||
<h2>Граф связей</h2>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary btn-sm" @click="resetView">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
</svg>
|
||||
Сбросить вид
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="togglePhysics">
|
||||
{{ physicsEnabled ? 'Заморозить' : 'Оживить' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="graph-view-toolbar">
|
||||
<div class="flex gap-2" style="flex-wrap:wrap;">
|
||||
<button
|
||||
v-for="rt in allRelationTypes"
|
||||
:key="rt.value"
|
||||
class="btn btn-sm"
|
||||
:class="activeFilters.includes(rt.value) ? 'btn-primary' : 'btn-secondary'"
|
||||
@click="toggleFilter(rt.value)"
|
||||
>
|
||||
{{ rt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="graph-area">
|
||||
<div v-if="loading" class="spinner"></div>
|
||||
<div v-else-if="nodes.length === 0" class="empty-state card">
|
||||
<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 id="graph-container" ref="graphContainer"></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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Network, DataSet } from 'vis-network/standalone'
|
||||
import api from '../api'
|
||||
import { useContactsStore } from '../stores/contacts'
|
||||
|
||||
const store = useContactsStore()
|
||||
const graphContainer = ref(null)
|
||||
const loading = ref(true)
|
||||
const network = ref(null)
|
||||
const physicsEnabled = ref(true)
|
||||
const selectedNode = ref(null)
|
||||
|
||||
let nodesDS = null
|
||||
let edgesDS = null
|
||||
let initRetryCount = 0
|
||||
const INIT_RETRY_MAX = 40
|
||||
let initRetryTimer = null
|
||||
let resizeObserver = null
|
||||
|
||||
const nodes = ref([])
|
||||
const edges = ref([])
|
||||
const allRelationTypes = ref([])
|
||||
const activeFilters = ref([])
|
||||
|
||||
const selectedContact = computed(() =>
|
||||
selectedNode.value ? store.contactById(selectedNode.value.id) : null
|
||||
)
|
||||
|
||||
const RELATION_COLORS = {
|
||||
colleague: { color: '#4facfe', highlight: '#7ac8ff' },
|
||||
friend: { color: '#4ecca3', highlight: '#7edfc0' },
|
||||
family: { color: '#f4a261', highlight: '#f7bb8a' },
|
||||
business: { color: '#5b8dee', highlight: '#7aa5f5' },
|
||||
acquaintance: { color: '#7b82a6', highlight: '#9ba3c5' },
|
||||
other: { color: '#555d7a', highlight: '#7b82a6' },
|
||||
}
|
||||
|
||||
async function loadGraph() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [gRes, rtRes] = await Promise.all([
|
||||
api.get('/graph/'),
|
||||
api.get('/relation-types/'),
|
||||
])
|
||||
nodes.value = gRes.data.nodes
|
||||
edges.value = gRes.data.edges
|
||||
allRelationTypes.value = rtRes.data
|
||||
activeFilters.value = rtRes.data.map((r) => r.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
// Контейнер #graph-container в DOM только когда loading=false и nodes.length > 0
|
||||
if (nodes.value.length > 0) {
|
||||
await nextTick()
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||
initNetwork()
|
||||
}
|
||||
}
|
||||
|
||||
function filteredEdges() {
|
||||
const nodeIds = new Set(nodes.value.map((n) => n.id))
|
||||
let list = edges.value.filter((e) => nodeIds.has(e.from) && nodeIds.has(e.to))
|
||||
if (activeFilters.value.length < allRelationTypes.value.length) {
|
||||
list = list.filter((e) => activeFilters.value.includes(e.relation_type))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
function initNetwork() {
|
||||
if (!graphContainer.value) 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 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: '#1a1d27', border: '#5b8dee', highlight: { background: '#22263a', border: '#7aa5f5' } },
|
||||
font: { color: '#e2e6f3', 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: '#7b82a6', size: 10, align: 'middle' },
|
||||
arrows: { to: { enabled: false } },
|
||||
smooth: { type: 'curvedCW', roundness: 0.1 },
|
||||
}))
|
||||
nodesDS = new DataSet(nodeList)
|
||||
edgesDS = new DataSet(edgeList)
|
||||
|
||||
network.value = new Network(
|
||||
el,
|
||||
{ nodes: nodesDS, edges: edgesDS },
|
||||
{
|
||||
physics: {
|
||||
enabled: true,
|
||||
stabilization: { iterations: 150 },
|
||||
barnesHut: { gravitationalConstant: -3000, springLength: 180 },
|
||||
},
|
||||
interaction: {
|
||||
tooltipDelay: 200,
|
||||
hover: true,
|
||||
hideEdgesOnDrag: true,
|
||||
zoomView: true,
|
||||
},
|
||||
nodes: { borderWidth: 1.5 },
|
||||
}
|
||||
)
|
||||
|
||||
network.value.on('click', (params) => {
|
||||
if (params.nodes.length > 0) {
|
||||
const id = params.nodes[0]
|
||||
const node = nodes.value.find((n) => String(n.id) === id)
|
||||
selectedNode.value = node || null
|
||||
}
|
||||
})
|
||||
|
||||
network.value.once('stabilizationIterationsDone', () => {
|
||||
network.value?.fit({ animation: { duration: 400 } })
|
||||
setTimeout(() => {
|
||||
network.value?.fit({ animation: false })
|
||||
network.value?.redraw()
|
||||
}, 100)
|
||||
})
|
||||
setTimeout(() => {
|
||||
if (network.value) {
|
||||
network.value.fit({ animation: false })
|
||||
network.value.redraw()
|
||||
}
|
||||
}, 800)
|
||||
|
||||
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) {
|
||||
edgesDS.clear()
|
||||
edgesDS.add(
|
||||
filteredEdges().map((e) => ({
|
||||
...e,
|
||||
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
|
||||
font: { color: '#7b82a6', size: 10, align: 'middle' },
|
||||
arrows: { to: { enabled: false } },
|
||||
smooth: { type: 'curvedCW', roundness: 0.1 },
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
network.value?.fit({ animation: { duration: 500, easingFunction: 'easeInOutQuad' } })
|
||||
}
|
||||
|
||||
function togglePhysics() {
|
||||
physicsEnabled.value = !physicsEnabled.value
|
||||
network.value?.setOptions({ physics: { enabled: physicsEnabled.value } })
|
||||
}
|
||||
|
||||
onMounted(loadGraph)
|
||||
onUnmounted(() => {
|
||||
if (initRetryTimer) clearTimeout(initRetryTimer)
|
||||
resizeObserver?.disconnect()
|
||||
network.value?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.graph-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.graph-view-header {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 28px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.graph-view-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.graph-view-toolbar {
|
||||
flex-shrink: 0;
|
||||
padding: 0 28px 10px;
|
||||
}
|
||||
.graph-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 28px 20px;
|
||||
}
|
||||
#graph-container {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user