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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user