Add network map UX and theme support.

Introduce network map view/components with filtering and positioning, expand contacts/map backend fields and migrations, and add a persistent light/dark theme toggle with graph label color updates for readability.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-28 13:24:48 +03:00
co-authored by Cursor
parent 81ba6cd076
commit b668ee8507
49 changed files with 4679 additions and 129 deletions
+75 -58
View File
@@ -1,33 +1,19 @@
<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>
<GraphHeaderPanel
title="Граф связей"
:show-physics-toggle="true"
:physics-enabled="physicsEnabled"
@reset="resetView"
@toggle-physics="togglePhysics"
/>
<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>
<RelationTypeFilters
:relation-types="allRelationTypes"
:active-values="activeFilters"
@toggle="toggleFilter"
/>
</div>
<div class="graph-area">
@@ -82,8 +68,11 @@
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'
import { RELATION_COLORS } from '../lib/graph/relationColors'
import { fetchGraphBundle } from '../composables/useGraphData'
import GraphHeaderPanel from '../components/GraphHeaderPanel.vue'
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
const store = useContactsStore()
const graphContainer = ref(null)
@@ -98,6 +87,7 @@ let initRetryCount = 0
const INIT_RETRY_MAX = 40
let initRetryTimer = null
let resizeObserver = null
let themeObserver = null
const nodes = ref([])
const edges = ref([])
@@ -108,26 +98,30 @@ 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' },
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'),
nodeHighlightBackground: cssVar('--surface', '#1a1d27'),
nodeHighlightBorder: cssVar('--accent-hover', '#7aa5f5'),
nodeFont: cssVar('--text', '#e2e6f3'),
edgeFont: cssVar('--text-muted', '#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)
const bundle = await fetchGraphBundle('/graph/')
nodes.value = bundle.nodes
edges.value = bundle.edges
allRelationTypes.value = bundle.relationTypes
activeFilters.value = bundle.relationTypes.map((r) => r.value)
} finally {
loading.value = false
}
@@ -171,13 +165,18 @@ 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: '#1a1d27', border: '#5b8dee', highlight: { background: '#22263a', border: '#7aa5f5' } },
font: { color: '#e2e6f3', size: 13 },
color: {
background: palette.nodeBackground,
border: palette.nodeBorder,
highlight: { background: palette.nodeHighlightBackground, border: palette.nodeHighlightBorder },
},
font: { color: palette.nodeFont, size: 13 },
shape: 'dot',
size: 14,
}))
@@ -189,7 +188,7 @@ function initNetwork() {
title: e.title,
relation_type: e.relation_type,
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
font: { color: '#7b82a6', size: 10, align: 'middle' },
font: { color: palette.edgeFont, size: 10, align: 'middle' },
arrows: { to: { enabled: false } },
smooth: { type: 'curvedCW', roundness: 0.1 },
}))
@@ -251,12 +250,13 @@ function toggleFilter(type) {
activeFilters.value.push(type)
}
if (edgesDS) {
const palette = graphPalette()
edgesDS.clear()
edgesDS.add(
filteredEdges().map((e) => ({
...e,
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
font: { color: '#7b82a6', size: 10, align: 'middle' },
font: { color: palette.edgeFont, size: 10, align: 'middle' },
arrows: { to: { enabled: false } },
smooth: { type: 'curvedCW', roundness: 0.1 },
}))
@@ -264,6 +264,29 @@ function toggleFilter(type) {
}
}
function applyThemeToNetwork() {
if (!nodesDS || !edgesDS || !network.value) return
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),
font: { color: palette.edgeFont, size: 10, align: 'middle' },
}))
)
network.value.redraw()
}
function resetView() {
network.value?.fit({ animation: { duration: 500, easingFunction: 'easeInOutQuad' } })
}
@@ -273,10 +296,15 @@ function togglePhysics() {
network.value?.setOptions({ physics: { enabled: physicsEnabled.value } })
}
onMounted(loadGraph)
onMounted(() => {
loadGraph()
themeObserver = new MutationObserver(() => applyThemeToNetwork())
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
})
onUnmounted(() => {
if (initRetryTimer) clearTimeout(initRetryTimer)
resizeObserver?.disconnect()
themeObserver?.disconnect()
network.value?.destroy()
})
</script>
@@ -289,17 +317,6 @@ onUnmounted(() => {
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;