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
+2412
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -5,7 +5,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"vue": "^3.4.0",
@@ -16,7 +18,10 @@
"vis-data": "^7.1.9"
},
"devDependencies": {
"@vue/test-utils": "^2.4.6",
"@vitejs/plugin-vue": "^5.0.3",
"vite": "^5.1.0"
"jsdom": "^25.0.1",
"vite": "^5.1.0",
"vitest": "^2.1.9"
}
}
+101 -9
View File
@@ -1,7 +1,7 @@
<template>
<div class="layout">
<!-- Sidebar -->
<aside class="sidebar">
<aside class="sidebar sidebar-collapsible">
<div class="sidebar-logo">
<h1>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" style="display:inline;vertical-align:-3px;margin-right:6px;">
@@ -12,9 +12,9 @@
<line x1="8" y1="12" x2="16" y2="17" stroke="#5b8dee" stroke-width="1.5"/>
<line x1="16" y1="7" x2="16" y2="17" stroke="#7b82a6" stroke-width="1.5" stroke-dasharray="3,2"/>
</svg>
Social Graph
<span class="sidebar-title">Social Graph</span>
</h1>
<span>Построитель социального графа</span>
<span class="sidebar-subtitle">Построитель социального графа</span>
</div>
<nav>
<RouterLink to="/graph" class="nav-link" active-class="active">
@@ -22,7 +22,15 @@
<circle cx="5" cy="12" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="19" cy="19" r="2"/>
<line x1="7" y1="11.5" x2="17" y2="6.5"/><line x1="7" y1="12.5" x2="17" y2="17.5"/>
</svg>
Граф
<span class="nav-label">Граф</span>
</RouterLink>
<RouterLink to="/map" class="nav-link" active-class="active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="9"/>
<circle cx="12" cy="12" r="5" opacity="0.6"/>
<circle cx="12" cy="12" r="2" opacity="0.8"/>
</svg>
<span class="nav-label">Карта сети</span>
</RouterLink>
<RouterLink to="/contacts" class="nav-link" active-class="active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -30,7 +38,7 @@
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Контакты
<span class="nav-label">Контакты</span>
</RouterLink>
<RouterLink to="/import" class="nav-link" active-class="active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -38,12 +46,28 @@
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Импорт
<span class="nav-label">Импорт</span>
</RouterLink>
</nav>
<button
class="nav-link nav-theme-btn"
type="button"
@click="toggleTheme"
:title="isLightTheme ? 'Переключить на темную тему' : 'Переключить на светлую тему'"
>
<svg v-if="isLightTheme" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<span class="nav-label">{{ isLightTheme ? 'Темная тема' : 'Светлая тема' }}</span>
</button>
<div class="sidebar-stats">
<div class="stat">Контактов: <strong>{{ store.totalContacts }}</strong></div>
<div class="stat">Связей: <strong>{{ store.totalRelations }}</strong></div>
<div class="stat"><span class="nav-label">Контактов: </span><strong>{{ store.totalContacts }}</strong></div>
<div class="stat"><span class="nav-label">Связей: </span><strong>{{ store.totalRelations }}</strong></div>
</div>
</aside>
@@ -55,13 +79,81 @@
</template>
<script setup>
import { onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { RouterLink, RouterView } from 'vue-router'
import { useContactsStore } from './stores/contacts'
const store = useContactsStore()
const THEME_KEY = 'ui-theme'
const currentTheme = ref('dark')
const isLightTheme = computed(() => currentTheme.value === 'light')
function applyTheme(theme) {
currentTheme.value = theme
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem(THEME_KEY, theme)
}
function toggleTheme() {
applyTheme(isLightTheme.value ? 'dark' : 'light')
}
onMounted(async () => {
const savedTheme = localStorage.getItem(THEME_KEY)
applyTheme(savedTheme === 'light' ? 'light' : 'dark')
await store.fetchContacts()
await store.fetchRelations()
})
</script>
<style scoped>
.sidebar-collapsible {
width: 60px;
transition: width 0.2s ease;
overflow: hidden;
}
.sidebar-collapsible:hover {
width: 220px;
}
.sidebar-collapsible .sidebar-logo {
padding-left: 18px;
padding-right: 18px;
}
.sidebar-collapsible .sidebar-title,
.sidebar-collapsible .sidebar-subtitle,
.sidebar-collapsible .nav-label {
display: none;
white-space: nowrap;
}
.sidebar-collapsible:hover .sidebar-title,
.sidebar-collapsible:hover .sidebar-subtitle,
.sidebar-collapsible:hover .nav-label {
display: inline;
}
.sidebar-collapsible .nav-link {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
.sidebar-collapsible .nav-theme-btn {
width: 100%;
border: none;
background: transparent;
}
.sidebar-collapsible:hover .nav-link {
justify-content: flex-start;
padding-left: 20px;
padding-right: 20px;
}
.sidebar-collapsible .sidebar-stats {
padding-left: 10px;
padding-right: 10px;
text-align: center;
}
.sidebar-collapsible:hover .sidebar-stats {
padding-left: 20px;
padding-right: 20px;
text-align: left;
}
</style>
+7
View File
@@ -1,8 +1,15 @@
import axios from 'axios'
import { normalizeApiError } from './lib/api/errors'
const api = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
timeout: 12000,
})
api.interceptors.response.use(
(response) => response,
(error) => Promise.reject(normalizeApiError(error))
)
export default api
+52 -1
View File
@@ -2,6 +2,7 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
color-scheme: dark;
--bg: #0f1117;
--surface: #1a1d27;
--surface-alt: #22263a;
@@ -21,6 +22,24 @@
--font: 'Inter', system-ui, -apple-system, sans-serif;
}
:root[data-theme='light'] {
color-scheme: light;
--bg: #f5f7fb;
--surface: #ffffff;
--surface-alt: #f0f3fa;
--border: #d7deef;
--accent: #2f6feb;
--accent-hover: #1f5bd2;
--accent-dim: rgba(47, 111, 235, 0.12);
--text: #1a2238;
--text-muted: #5e6b8a;
--text-dim: #8b96b0;
--red: #cc3d52;
--green: #1d9c77;
--orange: #cb7a2d;
--shadow: 0 10px 30px rgba(17, 24, 39, 0.08);
}
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--font); font-size: 14px; }
#app { display: flex; flex-direction: column; height: 100%; }
@@ -79,6 +98,27 @@ a:hover { color: var(--accent-hover); }
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.page-header__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
}
.page-header__title h2 {
font-size: 18px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.page-header__actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
}
.page-header h2 { font-size: 18px; font-weight: 600; }
.page-content { padding: 20px 28px; flex: 1; }
@@ -136,6 +176,12 @@ textarea.form-control { resize: vertical; min-height: 80px; }
.table th { color: var(--text-muted); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; }
.table tr:hover td { background: var(--surface-alt); cursor: pointer; }
.table tr:last-child td { border-bottom: none; }
.table-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
/* ===== Badge ===== */
.badge {
@@ -155,8 +201,10 @@ textarea.form-control { resize: vertical; min-height: 80px; }
/* ===== Modal ===== */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
display: flex; align-items: flex-start; justify-content: center;
z-index: 1000;
overflow-y: auto;
padding: 24px 16px;
}
.modal {
background: var(--surface);
@@ -165,7 +213,10 @@ textarea.form-control { resize: vertical; min-height: 80px; }
padding: 28px;
width: 460px;
max-width: 95vw;
max-height: calc(100vh - 48px);
overflow-y: auto;
box-shadow: var(--shadow);
margin: auto 0;
}
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.modal-header h3 { font-size: 16px; font-weight: 600; }
@@ -0,0 +1,25 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ContactForm from './ContactForm.vue'
vi.mock('../api', () => ({
default: {
get: vi.fn(async () => ({
data: {
life_spheres: [{ value: 'work', label: 'Работа' }],
network_circles: [{ value: 'support', label: 'Круг поддержки' }],
},
})),
},
}))
describe('ContactForm', () => {
it('emits submit with normalized payload', async () => {
const wrapper = mount(ContactForm, { props: { initial: { name: 'Иван' } } })
await wrapper.get('form').trigger('submit.prevent')
const payload = wrapper.emitted('submit')[0][0]
expect(payload.name).toBe('Иван')
expect(payload.importance).toBeDefined()
expect(payload.include_on_network_map).toBe(false)
})
})
+115 -2
View File
@@ -24,6 +24,35 @@
<input v-model="form.position" class="form-control" placeholder="Директор" />
</div>
</div>
<div class="contact-form-map-fields">
<div class="form-group">
<label>Сфера жизни</label>
<SearchableSelect
v-model="form.life_sphere"
:options="lifeSpheres"
placeholder="Сфера жизни..."
/>
</div>
<div class="form-group">
<label>Круг сети</label>
<SearchableSelect
v-model="form.network_circle"
:options="networkCircles"
placeholder="Круг сети..."
/>
</div>
<div class="form-group">
<label>Важность (15)</label>
<input v-model.number="form.importance" type="number" min="1" max="5" class="form-control" />
</div>
</div>
<div class="form-group checkbox-row">
<label class="checkbox-label">
<input v-model="form.include_on_network_map" type="checkbox" />
Показывать на карте сети
</label>
<p class="checkbox-hint">На странице «Граф» контакт виден всегда; на «Карте сети» только с этой отметкой.</p>
</div>
<div class="form-group">
<label>Заметки</label>
<textarea v-model="form.notes" class="form-control" placeholder="Дополнительная информация..." rows="3"></textarea>
@@ -36,11 +65,30 @@
</template>
<script setup>
import { reactive, watch } from 'vue'
import { reactive, watch, ref, onMounted } from 'vue'
import api from '../api'
import SearchableSelect from './SearchableSelect.vue'
const FALLBACK_SPHERES = [
{ value: 'work', label: 'Работа' },
{ value: 'study', label: 'Учёба' },
{ value: 'hobby', label: 'Хобби' },
{ value: 'family', label: 'Семья' },
{ value: 'health', label: 'Здоровье' },
{ value: 'other', label: 'Другое' },
]
const FALLBACK_CIRCLES = [
{ value: 'support', label: 'Круг поддержки' },
{ value: 'productivity', label: 'Круг продуктивности' },
{ value: 'development', label: 'Круг развития' },
]
const props = defineProps({ initial: { type: Object, default: () => ({}) } })
const emit = defineEmits(['submit', 'cancel'])
const lifeSpheres = ref([...FALLBACK_SPHERES])
const networkCircles = ref([...FALLBACK_CIRCLES])
const form = reactive({
name: props.initial.name || '',
email: props.initial.email || '',
@@ -48,13 +96,78 @@ const form = reactive({
organization: props.initial.organization || '',
position: props.initial.position || '',
notes: props.initial.notes || '',
life_sphere: props.initial.life_sphere || 'other',
network_circle: props.initial.network_circle || 'productivity',
importance: props.initial.importance ?? 3,
include_on_network_map: Boolean(props.initial.include_on_network_map),
})
watch(() => props.initial, (v) => {
if (v) Object.assign(form, v)
if (!v || !Object.keys(v).length) {
Object.assign(form, {
name: '',
email: '',
phone: '',
organization: '',
position: '',
notes: '',
life_sphere: 'other',
network_circle: 'productivity',
importance: 3,
include_on_network_map: false,
})
return
}
Object.assign(form, {
name: v.name || '',
email: v.email || '',
phone: v.phone || '',
organization: v.organization || '',
position: v.position || '',
notes: v.notes || '',
life_sphere: v.life_sphere || 'other',
network_circle: v.network_circle || 'productivity',
importance: v.importance ?? 3,
include_on_network_map: Boolean(v.include_on_network_map),
})
}, { deep: true })
onMounted(async () => {
try {
const { data } = await api.get('/network-map-choices/')
if (data.life_spheres?.length) lifeSpheres.value = data.life_spheres
if (data.network_circles?.length) networkCircles.value = data.network_circles
} catch {
/* оставляем FALLBACK_* */
}
})
function onSubmit() {
emit('submit', { ...form })
}
</script>
<style scoped>
.contact-form-map-fields {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.checkbox-row {
margin-top: 4px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
cursor: pointer;
}
.checkbox-hint {
margin: 6px 0 0;
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
}
</style>
@@ -0,0 +1,43 @@
<template>
<div class="graph-view-header">
<h2>{{ title }}</h2>
<div class="flex gap-2">
<button class="btn btn-secondary btn-sm" @click="$emit('reset')">
<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>
{{ resetLabel }}
</button>
<button v-if="showPhysicsToggle" class="btn btn-secondary btn-sm" @click="$emit('toggle-physics')">
{{ physicsEnabled ? physicsOnLabel : physicsOffLabel }}
</button>
</div>
</div>
</template>
<script setup>
defineProps({
title: { type: String, default: 'Граф связей' },
resetLabel: { type: String, default: 'Сбросить вид' },
showPhysicsToggle: { type: Boolean, default: false },
physicsEnabled: { type: Boolean, default: true },
physicsOnLabel: { type: String, default: 'Заморозить' },
physicsOffLabel: { type: String, default: 'Оживить' },
})
defineEmits(['reset', 'toggle-physics'])
</script>
<style scoped>
.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;
}
</style>
@@ -0,0 +1,87 @@
<template>
<div class="network-map-legend card">
<div class="legend-row">
<span><strong class="legend-strong"></strong> интенсивные</span>
<span><strong class="legend-strong"></strong> редкие</span>
<span class="legend-hint">{{ hint }}</span>
</div>
<div class="legend-rings">
<span class="legend-ring-inner">внутреннее кольцо поддержка</span>
<span class="legend-ring-mid">среднее продуктивность</span>
<span class="legend-ring-outer">внешнее развитие</span>
</div>
</div>
</template>
<script setup>
defineProps({
hint: {
type: String,
default: 'Имя — в подсказке при наведении; клик — карточка. Масштаб и панорама — мышью; точки закреплены в секторах.',
},
})
</script>
<style scoped>
.network-map-legend {
margin: 0 28px 10px;
padding: 10px 14px;
font-size: 12px;
color: var(--text-muted);
}
.legend-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
}
.legend-strong {
color: var(--text);
}
.legend-hint {
flex: 1 1 200px;
font-size: 11px;
opacity: 0.9;
}
.legend-rings {
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.legend-ring-inner::before {
content: '';
display: inline-block;
width: 10px;
height: 10px;
border: 1px solid var(--border);
border-radius: 50%;
margin-right: 6px;
vertical-align: -1px;
opacity: 0.85;
}
.legend-ring-mid::before {
content: '';
display: inline-block;
width: 14px;
height: 14px;
border: 1px solid var(--border);
border-radius: 50%;
margin-right: 6px;
vertical-align: -2px;
opacity: 0.85;
}
.legend-ring-outer::before {
content: '';
display: inline-block;
width: 18px;
height: 18px;
border: 1px solid var(--border);
border-radius: 50%;
margin-right: 6px;
vertical-align: -3px;
opacity: 0.85;
}
</style>
@@ -0,0 +1,103 @@
<template>
<div class="network-map-top-panel" :class="{ collapsed }">
<button
class="panel-toggle-btn"
@click="$emit('toggle-collapse')"
:title="collapsed ? 'Развернуть панель' : 'Свернуть панель'"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline v-if="!collapsed" points="18 15 12 9 6 15" />
<polyline v-else points="6 9 12 15 18 9" />
</svg>
</button>
<div class="network-map-header" v-show="!collapsed">
<div>
<h2>{{ title }}</h2>
<p class="network-map-sub">{{ subtitle }}</p>
</div>
<div class="flex gap-2">
<button class="btn btn-secondary btn-sm" @click="$emit('fit')">
<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>
</div>
</div>
<div class="network-map-toolbar" v-show="!collapsed">
<slot name="filters" />
</div>
<div v-show="!collapsed">
<slot name="legend" />
</div>
</div>
</template>
<script setup>
defineProps({
collapsed: { type: Boolean, default: false },
title: { type: String, default: 'Карта сети' },
subtitle: {
type: String,
default:
'Три круга — поддержка, продуктивность, развитие. Секторы — сферы жизни. Толстая линия — частые контакты, пунктир — редкие. Стрелка — от инициатора связи.',
},
})
defineEmits(['toggle-collapse', 'fit'])
</script>
<style scoped>
.network-map-top-panel {
position: relative;
border-bottom: 1px solid var(--border);
}
.network-map-top-panel.collapsed {
height: 26px;
}
.panel-toggle-btn {
position: absolute;
top: 4px;
right: 8px;
z-index: 3;
width: 24px;
height: 18px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-alt);
color: var(--text-muted);
display: grid;
place-items: center;
cursor: pointer;
}
.panel-toggle-btn:hover {
color: var(--text);
border-color: var(--accent);
}
.network-map-header {
flex-shrink: 0;
padding: 12px 28px 8px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.network-map-header h2 {
font-size: 18px;
font-weight: 600;
margin: 0 0 6px;
}
.network-map-sub {
margin: 0;
font-size: 13px;
color: var(--text-muted);
max-width: 640px;
line-height: 1.45;
}
.network-map-toolbar {
flex-shrink: 0;
padding: 0 28px 10px;
}
</style>
@@ -0,0 +1,29 @@
<template>
<div class="relation-filters">
<button
v-for="rt in relationTypes"
:key="rt.value"
class="btn btn-sm"
:class="activeValues.includes(rt.value) ? 'btn-primary' : 'btn-secondary'"
@click="$emit('toggle', rt.value)"
>
{{ rt.label }}
</button>
</div>
</template>
<script setup>
defineProps({
relationTypes: { type: Array, default: () => [] },
activeValues: { type: Array, default: () => [] },
})
defineEmits(['toggle'])
</script>
<style scoped>
.relation-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import SearchableSelect from './SearchableSelect.vue'
const options = [
{ value: 1, label: 'Иван Петров' },
{ value: 2, label: 'Мария Сидорова' },
]
describe('SearchableSelect', () => {
it('filters options while typing', async () => {
const wrapper = mount(SearchableSelect, {
props: { modelValue: '', options, placeholder: 'Поиск' },
})
const input = wrapper.get('input')
await input.setValue('мария')
await input.trigger('focus')
const items = wrapper.findAll('.searchable-select__option')
expect(items).toHaveLength(1)
expect(items[0].text()).toBe('Мария Сидорова')
})
it('emits selected value on option click', async () => {
const wrapper = mount(SearchableSelect, {
props: { modelValue: '', options },
})
await wrapper.get('input').setValue('иван')
await wrapper.get('input').trigger('focus')
await wrapper.find('.searchable-select__option').trigger('mousedown')
expect(wrapper.emitted('update:modelValue')).toEqual([[1]])
})
it('shows label for current model value', async () => {
const wrapper = mount(SearchableSelect, {
props: { modelValue: 2, options },
})
expect(wrapper.get('input').element.value).toBe('Мария Сидорова')
})
it('keeps input empty when no value is selected', async () => {
const wrapper = mount(SearchableSelect, {
props: { modelValue: '', options, placeholder: 'Введите имя' },
})
expect(wrapper.get('input').element.value).toBe('')
expect(wrapper.get('input').attributes('placeholder')).toBe('Введите имя')
})
})
@@ -0,0 +1,250 @@
<template>
<div class="searchable-select" ref="rootRef">
<input
ref="inputRef"
type="text"
class="form-control searchable-select__input"
:placeholder="placeholder"
:disabled="disabled"
:value="query"
autocomplete="off"
role="combobox"
:aria-expanded="open"
aria-autocomplete="list"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
@keydown="onKeydown"
/>
<ul v-if="open && filteredOptions.length" class="searchable-select__list" role="listbox">
<li
v-for="(opt, index) in filteredOptions"
:key="String(opt.value)"
role="option"
:aria-selected="isSelected(opt)"
class="searchable-select__option"
:class="{
'is-active': index === highlightedIndex,
'is-selected': isSelected(opt),
}"
@mousedown.prevent="selectOption(opt)"
>
{{ opt.label }}
</li>
</ul>
<p v-else-if="open && query.trim() && !filteredOptions.length" class="searchable-select__empty">
Ничего не найдено
</p>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: 'Начните вводить...' },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
const rootRef = ref(null)
const inputRef = ref(null)
const query = ref('')
const open = ref(false)
const highlightedIndex = ref(0)
const allOptions = computed(() =>
props.options.map((o) => ({ value: o.value, label: o.label }))
)
const filteredOptions = computed(() => {
const q = query.value.trim().toLocaleLowerCase('ru')
if (!q) return allOptions.value
return allOptions.value.filter((o) =>
o.label.toLocaleLowerCase('ru').includes(q)
)
})
function valuesEqual(a, b) {
if (a === '' || a === null || a === undefined) {
return b === '' || b === null || b === undefined
}
return String(a) === String(b)
}
function isEmptyValue(value) {
return value === '' || value === null || value === undefined
}
function labelForValue(value) {
if (isEmptyValue(value)) return ''
const opt = allOptions.value.find((o) => valuesEqual(o.value, value))
return opt?.label ?? ''
}
function isSelected(opt) {
return valuesEqual(opt.value, props.modelValue)
}
function syncQueryFromModel() {
query.value = labelForValue(props.modelValue)
}
function onInput(e) {
query.value = e.target.value
open.value = true
highlightedIndex.value = 0
}
function onFocus() {
open.value = true
if (isEmptyValue(props.modelValue)) {
query.value = ''
highlightedIndex.value = 0
return
}
query.value = labelForValue(props.modelValue)
highlightedIndex.value = Math.max(
0,
filteredOptions.value.findIndex((o) => isSelected(o))
)
nextTick(() => inputRef.value?.select())
}
function onBlur() {
setTimeout(() => {
open.value = false
syncQueryFromModel()
}, 120)
}
function selectOption(opt) {
emit('update:modelValue', opt.value)
query.value = isEmptyValue(opt.value) ? '' : opt.label
open.value = false
highlightedIndex.value = 0
}
function onKeydown(e) {
if (!open.value && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
open.value = true
e.preventDefault()
return
}
if (e.key === 'Escape') {
open.value = false
syncQueryFromModel()
inputRef.value?.blur()
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
if (!filteredOptions.value.length) return
highlightedIndex.value = Math.min(
highlightedIndex.value + 1,
filteredOptions.value.length - 1
)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
if (!filteredOptions.value.length) return
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
return
}
if (e.key === 'Enter') {
e.preventDefault()
const opt = filteredOptions.value[highlightedIndex.value]
if (opt) selectOption(opt)
}
}
watch(
() => props.modelValue,
() => {
if (!open.value) syncQueryFromModel()
},
{ immediate: true }
)
watch(
() => props.options,
() => {
if (!open.value) syncQueryFromModel()
},
{ deep: true }
)
watch(filteredOptions, (list) => {
if (highlightedIndex.value >= list.length) {
highlightedIndex.value = Math.max(0, list.length - 1)
}
})
</script>
<style scoped>
.searchable-select {
position: relative;
}
.searchable-select__input {
width: 100%;
}
.searchable-select__list {
position: absolute;
z-index: 50;
left: 0;
right: 0;
top: calc(100% + 4px);
max-height: 220px;
overflow-y: auto;
margin: 0;
padding: 4px 0;
list-style: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
}
.searchable-select__option {
padding: 8px 12px;
font-size: 13px;
cursor: pointer;
color: var(--text);
}
.searchable-select__option:hover,
.searchable-select__option.is-active {
background: var(--surface-alt);
color: var(--accent);
}
.searchable-select__option.is-selected {
font-weight: 500;
}
.searchable-select__empty {
position: absolute;
z-index: 50;
left: 0;
right: 0;
top: calc(100% + 4px);
margin: 0;
padding: 10px 12px;
font-size: 12px;
color: var(--text-muted);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
}
</style>
+18
View File
@@ -0,0 +1,18 @@
import api from '../api'
export async function fetchGraphBundle(graphEndpoint = '/graph/') {
const [gRes, rtRes] = await Promise.all([
api.get(graphEndpoint),
api.get('/relation-types/'),
])
return {
nodes: gRes.data.nodes || [],
edges: gRes.data.edges || [],
relationTypes: rtRes.data || [],
}
}
export async function fetchMapChoices() {
const { data } = await api.get('/network-map-choices/')
return data
}
+19
View File
@@ -0,0 +1,19 @@
export function normalizeApiError(error) {
const status = error?.response?.status ?? null
const data = error?.response?.data
let message = 'Произошла ошибка запроса.'
if (typeof data === 'string' && data.trim()) {
message = data
} else if (typeof data?.error === 'string' && data.error.trim()) {
message = data.error
} else if (typeof error?.message === 'string' && error.message.trim()) {
message = error.message
}
const normalized = new Error(message)
normalized.status = status
normalized.data = data
normalized.original = error
return normalized
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest'
import { normalizeApiError } from './errors'
describe('normalizeApiError', () => {
it('extracts API message from response payload', () => {
const err = normalizeApiError({ response: { status: 400, data: { error: 'Bad request' } } })
expect(err.message).toBe('Bad request')
expect(err.status).toBe(400)
})
it('falls back to generic message', () => {
const err = normalizeApiError({})
expect(err.message.length).toBeGreaterThan(0)
})
})
+19
View File
@@ -0,0 +1,19 @@
/**
* Loads all items from a DRF paginated list endpoint (page query param).
* If the response is a plain array, returns it as-is.
*/
export async function fetchAllPages(requestPage) {
const all = []
let page = 1
while (true) {
const { data } = await requestPage(page)
if (Array.isArray(data)) return data
all.push(...(data.results ?? []))
if (!data.next) break
page += 1
}
return all
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect, vi } from 'vitest'
import { fetchAllPages } from './pagination'
describe('fetchAllPages', () => {
it('returns a plain array response unchanged', async () => {
const requestPage = vi.fn().mockResolvedValue({ data: [{ id: 1 }] })
await expect(fetchAllPages(requestPage)).resolves.toEqual([{ id: 1 }])
expect(requestPage).toHaveBeenCalledOnce()
})
it('concatenates all paginated pages', async () => {
const requestPage = vi
.fn()
.mockResolvedValueOnce({
data: { results: [{ id: 1 }], next: 'http://example/api/?page=2' },
})
.mockResolvedValueOnce({
data: { results: [{ id: 2 }], next: null },
})
await expect(fetchAllPages(requestPage)).resolves.toEqual([{ id: 1 }, { id: 2 }])
expect(requestPage).toHaveBeenCalledTimes(2)
expect(requestPage).toHaveBeenNthCalledWith(1, 1)
expect(requestPage).toHaveBeenNthCalledWith(2, 2)
})
})
+8
View File
@@ -0,0 +1,8 @@
export 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' },
}
+81
View File
@@ -0,0 +1,81 @@
export const SPHERE_ORDER = ['work', 'study', 'hobby', 'family', 'health', 'other']
export function ringRadius(networkCircle, layout) {
if (networkCircle === 'support') return layout.rInner
if (networkCircle === 'productivity') return layout.rMid
return layout.rOuter
}
export function ringByRadius(r, L) {
const t1 = (L.rInner + L.rMid) / 2
const t2 = (L.rMid + L.rOuter) / 2
if (r <= t1) return 'support'
if (r <= t2) return 'productivity'
return 'development'
}
export function sphereByAngle(angle) {
const full = 2 * Math.PI
const norm = ((angle + Math.PI / 2) % full + full) % full
const sectorW = full / SPHERE_ORDER.length
const idx = Math.floor(norm / sectorW)
return SPHERE_ORDER[Math.max(0, Math.min(SPHERE_ORDER.length - 1, idx))]
}
export function normalizedSphere(n) {
const s = n.life_sphere
return SPHERE_ORDER.includes(s) ? s : 'other'
}
export function normalizedCircle(n) {
const c = n.network_circle
return c === 'support' || c === 'productivity' || c === 'development' ? c : 'productivity'
}
export function computePolarPositions(rawNodes, L) {
const groups = new Map()
for (const n of rawNodes) {
const key = `${normalizedSphere(n)}|${normalizedCircle(n)}`
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(n)
}
const posById = new Map()
const nSectors = SPHERE_ORDER.length
for (const [, group] of groups) {
group.sort((a, b) => Number(a.id) - Number(b.id))
const sample = group[0]
const sphere = normalizedSphere(sample)
const circle = normalizedCircle(sample)
const idx = SPHERE_ORDER.indexOf(sphere)
const sectorStart = (idx / nSectors) * 2 * Math.PI - Math.PI / 2
const sectorW = (2 * Math.PI) / nSectors
const pad = sectorW * 0.07
const usable = Math.max(sectorW - 2 * pad, sectorW * 0.2)
const rBase = ringRadius(circle, L)
const k = group.length
group.forEach((n, i) => {
const angle = k === 1
? sectorStart + sectorW / 2
: sectorStart + pad + ((i + 0.5) / k) * usable
posById.set(n.id, { x: rBase * Math.cos(angle), y: rBase * Math.sin(angle) })
})
}
return posById
}
export function nodeXY(n, layout, posById) {
const ratio = Number(n.map_radius_ratio)
const storedAngle = Number(n.map_angle)
if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) {
const safeRatio = Math.max(0, Math.min(1, ratio))
const r = safeRatio * layout.rOuter
return { x: r * Math.cos(storedAngle), y: r * Math.sin(storedAngle) }
}
const p = posById.get(n.id)
if (p) return p
const idx = SPHERE_ORDER.indexOf(normalizedSphere(n))
const sectorStart = (Math.max(0, idx) / SPHERE_ORDER.length) * 2 * Math.PI - Math.PI / 2
const r = ringRadius(normalizedCircle(n), layout)
const angle = sectorStart + (2 * Math.PI) / SPHERE_ORDER.length / 2
return { x: r * Math.cos(angle), y: r * Math.sin(angle) }
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest'
import { sphereByAngle, ringByRadius, nodeXY } from './positioning'
describe('map positioning', () => {
it('maps angle to sphere deterministically', () => {
expect(sphereByAngle(-Math.PI / 2)).toBe('work')
expect(sphereByAngle(0)).toBe('study')
})
it('maps radius to ring', () => {
const L = { rInner: 10, rMid: 20, rOuter: 30 }
expect(ringByRadius(5, L)).toBe('support')
expect(ringByRadius(16, L)).toBe('productivity')
expect(ringByRadius(28, L)).toBe('development')
})
it('prefers persisted coordinates', () => {
const L = { rInner: 10, rMid: 20, rOuter: 100 }
const p = nodeXY({ map_radius_ratio: 0.5, map_angle: 0 }, L, new Map())
expect(p.x).toBeCloseTo(50)
expect(p.y).toBeCloseTo(0)
})
})
+5
View File
@@ -10,6 +10,11 @@ const routes = [
name: 'Graph',
component: () => import('../views/GraphView.vue'),
},
{
path: '/map',
name: 'NetworkMap',
component: () => import('../views/NetworkMapView.vue'),
},
{
path: '/contacts',
name: 'Contacts',
+93 -23
View File
@@ -1,11 +1,17 @@
import { defineStore } from 'pinia'
import api from '../api'
import { fetchAllPages } from '../lib/api/pagination'
export const useContactsStore = defineStore('contacts', {
state: () => ({
contacts: [],
relations: [],
relationTypes: [],
mapChoices: null,
loading: false,
contactsLoading: false,
relationsLoading: false,
mapLoading: false,
error: null,
}),
@@ -16,52 +22,116 @@ export const useContactsStore = defineStore('contacts', {
},
actions: {
async fetchContacts(search = '') {
this.loading = true
setError(error) {
this.error = error?.message || String(error)
},
async withLoading(flagName, fn) {
this[flagName] = true
this.loading = this.contactsLoading || this.relationsLoading || this.mapLoading
this.error = null
try {
const params = search ? { search } : {}
const { data } = await api.get('/contacts/', { params })
this.contacts = data.results ?? data
return await fn()
} catch (e) {
this.error = e.message
this.setError(e)
throw e
} finally {
this.loading = false
this[flagName] = false
this.loading = this.contactsLoading || this.relationsLoading || this.mapLoading
}
},
async fetchContacts(search = '') {
return this.withLoading('contactsLoading', async () => {
const params = search ? { search } : {}
this.contacts = await fetchAllPages((page) =>
api.get('/contacts/', { params: { ...params, page } })
)
})
},
async fetchContactById(id) {
return this.withLoading('contactsLoading', async () => {
const { data } = await api.get(`/contacts/${id}/`)
const idx = this.contacts.findIndex((c) => c.id === id)
if (idx !== -1) this.contacts[idx] = data
return data
})
},
async fetchRelations() {
const { data } = await api.get('/relations/')
this.relations = data.results ?? data
return this.withLoading('relationsLoading', async () => {
this.relations = await fetchAllPages((page) =>
api.get('/relations/', { params: { page } })
)
})
},
async fetchRelationTypes() {
return this.withLoading('mapLoading', async () => {
const { data } = await api.get('/relation-types/')
this.relationTypes = data
return data
})
},
async fetchNetworkMapChoices() {
return this.withLoading('mapLoading', async () => {
const { data } = await api.get('/network-map-choices/')
this.mapChoices = data
return data
})
},
async createContact(payload) {
const { data } = await api.post('/contacts/', payload)
this.contacts.push(data)
return data
return this.withLoading('contactsLoading', async () => {
const { data } = await api.post('/contacts/', payload)
this.contacts.push(data)
return data
})
},
async updateContact(id, payload) {
const { data } = await api.patch(`/contacts/${id}/`, payload)
const idx = this.contacts.findIndex((c) => c.id === id)
if (idx !== -1) this.contacts[idx] = data
return data
return this.withLoading('contactsLoading', async () => {
const { data } = await api.patch(`/contacts/${id}/`, payload)
const idx = this.contacts.findIndex((c) => c.id === id)
if (idx !== -1) this.contacts[idx] = data
return data
})
},
async deleteContact(id) {
await api.delete(`/contacts/${id}/`)
this.contacts = this.contacts.filter((c) => c.id !== id)
return this.withLoading('contactsLoading', async () => {
await api.delete(`/contacts/${id}/`)
this.contacts = this.contacts.filter((c) => c.id !== id)
})
},
async createRelation(payload) {
const { data } = await api.post('/relations/', payload)
this.relations.push(data)
return data
return this.withLoading('relationsLoading', async () => {
const { data } = await api.post('/relations/', payload)
this.relations.push(data)
return data
})
},
async deleteRelation(id) {
await api.delete(`/relations/${id}/`)
this.relations = this.relations.filter((r) => r.id !== id)
return this.withLoading('relationsLoading', async () => {
await api.delete(`/relations/${id}/`)
this.relations = this.relations.filter((r) => r.id !== id)
})
},
async importContacts(file) {
return this.withLoading('mapLoading', async () => {
const fd = new FormData()
fd.append('file', file)
const { data } = await api.post('/import/', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
await this.fetchContacts()
return data
})
},
},
})
+83 -17
View File
@@ -1,11 +1,13 @@
<template>
<div>
<div class="page-header">
<div class="flex items-center gap-3">
<div class="page-header__title">
<button class="btn btn-secondary btn-sm" @click="$router.back()"> Назад</button>
<h2>{{ contact?.name || 'Загрузка...' }}</h2>
</div>
<button v-if="contact" class="btn btn-primary btn-sm" @click="editing = true">Редактировать</button>
<div v-if="contact" class="page-header__actions">
<button class="btn btn-primary btn-sm" @click="editing = true">Редактировать</button>
</div>
</div>
<div class="page-content" v-if="contact">
@@ -33,6 +35,17 @@
<label>Заметки</label>
<div style="white-space:pre-wrap;">{{ contact.notes || '—' }}</div>
</div>
<div class="form-group">
<label>Сфера жизни / круг / важность</label>
<div>
{{ sphereLabel(contact.life_sphere) }} · {{ circleLabel(contact.network_circle) }}
· важность {{ contact.importance ?? '—' }}/5
</div>
</div>
<div class="form-group">
<label>Карта сети</label>
<div>{{ contact.include_on_network_map ? 'Показывается на карте' : 'Не на карте (только в общем графе)' }}</div>
</div>
</div>
<!-- Relations card -->
@@ -54,6 +67,7 @@
<div>
<span style="font-weight:500;">{{ rel.source === contact.id ? rel.target_name : rel.source_name }}</span>
<span :class="`badge badge-${rel.relation_type}`" style="margin-left:8px;">{{ relLabel(rel.relation_type) }}</span>
<span class="text-muted" style="margin-left:8px;font-size:12px;">{{ intensityLabel(rel.interaction_intensity) }}</span>
<div class="text-muted mt-1">{{ rel.description }}</div>
</div>
<button class="btn btn-danger btn-sm" @click="removeRelation(rel.id)"></button>
@@ -84,21 +98,35 @@
<div v-if="relError" class="alert alert-error">{{ relError }}</div>
<div class="form-group">
<label>С кем связать</label>
<select v-model="newRel.targetId" class="form-control">
<option value=""> Выберите контакт </option>
<option v-for="c in otherContacts" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<SearchableSelect
v-model="newRel.targetId"
:options="contactSelectOptions"
placeholder="Введите имя контакта..."
/>
</div>
<div class="form-group">
<label>Тип связи</label>
<select v-model="newRel.type" class="form-control">
<option v-for="rt in relationTypes" :key="rt.value" :value="rt.value">{{ rt.label }}</option>
</select>
<SearchableSelect
v-model="newRel.type"
:options="relationTypes"
placeholder="Тип связи..."
/>
</div>
<div class="form-group">
<label>Интенсивность общения</label>
<SearchableSelect
v-model="newRel.interaction_intensity"
:options="interactionIntensities"
placeholder="Интенсивность..."
/>
</div>
<div class="form-group">
<label>Описание (необязательно)</label>
<input v-model="newRel.description" class="form-control" placeholder="Например: знакомы с 2018 года" />
</div>
<p class="text-muted" style="font-size:12px;margin:0 0 8px;">
Стрелка на карте сети идёт от вас к выбранному контакту: вы указаны как источник связи.
</p>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showAddRelation = false">Отмена</button>
<button class="btn btn-primary" :disabled="!newRel.targetId" @click="addRelation">Создать связь</button>
@@ -111,9 +139,9 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import api from '../api'
import { useContactsStore } from '../stores/contacts'
import ContactForm from '../components/ContactForm.vue'
import SearchableSelect from '../components/SearchableSelect.vue'
const route = useRoute()
const store = useContactsStore()
@@ -121,8 +149,16 @@ const contact = ref(null)
const editing = ref(false)
const showAddRelation = ref(false)
const relationTypes = ref([])
const lifeSpheres = ref([])
const networkCircles = ref([])
const interactionIntensities = ref([])
const relError = ref('')
const newRel = ref({ targetId: '', type: 'acquaintance', description: '' })
const newRel = ref({
targetId: '',
type: 'acquaintance',
description: '',
interaction_intensity: 'intense',
})
const contactId = computed(() => Number(route.params.id))
@@ -133,16 +169,34 @@ const contactRelations = computed(() =>
)
const otherContacts = computed(() =>
store.contacts.filter((c) => c.id !== contactId.value)
store.contacts
.filter((c) => c.id !== contactId.value)
.slice()
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
)
const contactSelectOptions = computed(() =>
otherContacts.value.map((c) => ({ value: c.id, label: c.name }))
)
function relLabel(type) {
return relationTypes.value.find((r) => r.value === type)?.label || type
}
function sphereLabel(v) {
return lifeSpheres.value.find((x) => x.value === v)?.label || v || '—'
}
function circleLabel(v) {
return networkCircles.value.find((x) => x.value === v)?.label || v || '—'
}
function intensityLabel(v) {
return interactionIntensities.value.find((x) => x.value === v)?.label || v || ''
}
async function loadContact() {
const { data } = await api.get(`/contacts/${contactId.value}/`)
contact.value = data
contact.value = await store.fetchContactById(contactId.value)
}
async function onUpdate(data) {
@@ -159,9 +213,15 @@ async function addRelation() {
target: newRel.value.targetId,
relation_type: newRel.value.type,
description: newRel.value.description,
interaction_intensity: newRel.value.interaction_intensity,
})
showAddRelation.value = false
newRel.value = { targetId: '', type: 'acquaintance', description: '' }
newRel.value = {
targetId: '',
type: 'acquaintance',
description: '',
interaction_intensity: 'intense',
}
} catch (e) {
const msg = e.response?.data
relError.value = typeof msg === 'object' ? JSON.stringify(msg) : String(msg)
@@ -175,7 +235,13 @@ async function removeRelation(id) {
onMounted(async () => {
await loadContact()
await Promise.all([store.fetchContacts(), store.fetchRelations()])
const { data } = await api.get('/relation-types/')
relationTypes.value = data
const [rt, mapChoices] = await Promise.all([
store.fetchRelationTypes(),
store.fetchNetworkMapChoices(),
])
relationTypes.value = rt
lifeSpheres.value = mapChoices.life_spheres
networkCircles.value = mapChoices.network_circles
interactionIntensities.value = mapChoices.interaction_intensities
})
</script>
+24 -2
View File
@@ -40,7 +40,7 @@
<th>Организация</th>
<th>Email</th>
<th>Связей</th>
<th style="width:80px;"></th>
<th style="width:180px;"></th>
</tr>
</thead>
<tbody>
@@ -54,7 +54,8 @@
<td>
<span class="badge badge-colleague">{{ c.relations_count }}</span>
</td>
<td @click.stop>
<td @click.stop class="table-actions">
<button class="btn btn-secondary btn-sm" @click="openEdit(c)">Редактировать</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(c)">Удалить</button>
</td>
</tr>
@@ -74,6 +75,17 @@
</div>
</div>
<!-- Edit modal -->
<div v-if="editTarget" class="modal-overlay" @click.self="editTarget = null">
<div class="modal">
<div class="modal-header">
<h3>Редактировать контакт</h3>
<button class="btn btn-secondary btn-sm" @click="editTarget = null"></button>
</div>
<ContactForm :initial="editTarget" @submit="onUpdate" @cancel="editTarget = null" />
</div>
</div>
<!-- Delete confirm -->
<div v-if="deleteTarget" class="modal-overlay" @click.self="deleteTarget = null">
<div class="modal">
@@ -100,6 +112,7 @@ const store = useContactsStore()
const router = useRouter()
const search = ref('')
const showCreate = ref(false)
const editTarget = ref(null)
const deleteTarget = ref(null)
let searchTimer = null
@@ -115,6 +128,15 @@ async function onCreate(data) {
showCreate.value = false
}
function openEdit(c) {
editTarget.value = { ...c }
}
async function onUpdate(data) {
await store.updateContact(editTarget.value.id, data)
editTarget.value = null
}
function confirmDelete(c) { deleteTarget.value = c }
async function doDelete() {
+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;
+15 -12
View File
@@ -3,10 +3,10 @@
<div class="page-header">
<h2>Импорт контактов</h2>
</div>
<div class="page-content" style="max-width:640px;">
<div class="page-content content-narrow">
<div class="card">
<h3 style="font-size:14px;margin-bottom:6px;">Загрузить файл</h3>
<p class="text-muted" style="margin-bottom:18px;">
<h3 class="section-title">Загрузить файл</h3>
<p class="text-muted section-subtitle">
Поддерживаются форматы <strong style="color:var(--text)">CSV</strong> и <strong style="color:var(--text)">JSON</strong>.
</p>
@@ -73,7 +73,6 @@
<script setup>
import { ref } from 'vue'
import api from '../api'
import { useContactsStore } from '../stores/contacts'
const store = useContactsStore()
@@ -99,15 +98,9 @@ async function doImport() {
importing.value = true
result.value = null
try {
const fd = new FormData()
fd.append('file', selectedFile.value)
const { data } = await api.post('/import/', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
result.value = data
await store.fetchContacts()
result.value = await store.importContacts(selectedFile.value)
} catch (e) {
result.value = { error: e.response?.data?.error || e.message }
result.value = { error: e.message }
} finally {
importing.value = false
}
@@ -121,6 +114,16 @@ function reset() {
</script>
<style scoped>
.content-narrow {
max-width: 640px;
}
.section-title {
font-size: 14px;
margin-bottom: 6px;
}
.section-subtitle {
margin-bottom: 18px;
}
.drop-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
+608
View File
@@ -0,0 +1,608 @@
<template>
<div class="network-map-view">
<NetworkMapTopPanel
:collapsed="topPanelCollapsed"
@toggle-collapse="topPanelCollapsed = !topPanelCollapsed"
@fit="fitView"
>
<template #filters>
<RelationTypeFilters
:relation-types="allRelationTypes"
:active-values="activeFilters"
@toggle="toggleFilter"
/>
</template>
<template #legend>
<MapLegendPanel />
</template>
</NetworkMapTopPanel>
<div class="network-map-body">
<div v-if="loading" class="spinner"></div>
<div v-else-if="nodes.length === 0" class="empty-state card">
<p>
На карте сети никого нет. Отметьте «Показывать на карте сети» в
<RouterLink to="/contacts">карточках контактов</RouterLink>
или добавьте новых.
</p>
</div>
<div v-else class="map-stack" ref="mapStack">
<div id="network-map-container" ref="graphContainer" class="map-vis"></div>
</div>
</div>
<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>Сфера / круг / важность</label>
<div>
{{ sphereLabels[selectedContact.life_sphere] || selectedContact.life_sphere }}
· {{ circleLabels[selectedContact.network_circle] || selectedContact.network_circle }}
· {{ selectedContact.importance }}/5
</div>
</div>
<div class="form-group">
<label>Организация</label>
<div>{{ [selectedContact.organization, selectedContact.position].filter(Boolean).join(' · ') || '—' }}</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 { useContactsStore } from '../stores/contacts'
import { RELATION_COLORS } from '../lib/graph/relationColors'
import {
SPHERE_ORDER,
ringRadius,
ringByRadius,
sphereByAngle,
computePolarPositions,
nodeXY,
} from '../lib/map/positioning'
import { fetchGraphBundle, fetchMapChoices } from '../composables/useGraphData'
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
import MapLegendPanel from '../components/MapLegendPanel.vue'
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
const GRID_SCALE = 5
function nodeById(id) {
return nodes.value.find((n) => String(n.id) === String(id))
}
async function persistNodePlacement(nodeId, canvasX, canvasY) {
const node = nodeById(nodeId)
if (!node) return
const L = layout.value
const dx = canvasX - L.cx
const dy = canvasY - L.cy
const r = Math.sqrt(dx * dx + dy * dy)
const angle = Math.atan2(dy, dx)
const ratio = Math.max(0, Math.min(1, r / (L.rOuter || 1)))
const nextSphere = sphereByAngle(angle)
const nextCircle = ringByRadius(r, L)
if (
node.life_sphere === nextSphere &&
node.network_circle === nextCircle &&
Math.abs((Number(node.map_angle) || 0) - angle) < 1e-6 &&
Math.abs((Number(node.map_radius_ratio) || 0) - ratio) < 1e-6
) {
refreshPositions()
return
}
node.life_sphere = nextSphere
node.network_circle = nextCircle
node.map_angle = angle
node.map_radius_ratio = ratio
refreshPositions()
try {
await store.updateContact(Number(node.id), {
life_sphere: nextSphere,
network_circle: nextCircle,
map_angle: angle,
map_radius_ratio: ratio,
})
} catch (e) {
// Если PATCH не прошел — откатываем карту к данным из API.
await store.fetchContacts()
const actual = store.contactById(Number(node.id))
if (actual) {
node.life_sphere = actual.life_sphere
node.network_circle = actual.network_circle
node.map_angle = actual.map_angle
node.map_radius_ratio = actual.map_radius_ratio
refreshPositions()
}
}
}
function importanceSize(importance) {
// Защита от "грязных" данных в БД: размер точки всегда в разумном диапазоне.
const raw = Number(importance)
const i = Number.isFinite(raw) ? Math.max(1, Math.min(5, raw)) : 3
return 8 + (i - 1) * 3
}
function initials(name) {
return String(name || '')
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() || '')
.join('')
}
function computeLabelBySpace(name, minScreenDistance) {
if (minScreenDistance >= 120) return name
if (minScreenDistance >= 64) return initials(name)
return ''
}
function buildLabelMap(posById, scale = 1) {
const map = new Map()
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1
const arranged = nodes.value.map((n) => ({
id: n.id,
name: n.label || String(n.id),
pos: posById.get(n.id),
})).filter((x) => x.pos)
for (const item of arranged) {
let minDist = Number.POSITIVE_INFINITY
for (const other of arranged) {
if (other.id === item.id) continue
const dx = item.pos.x - other.pos.x
const dy = item.pos.y - other.pos.y
const d = Math.sqrt(dx * dx + dy * dy)
if (d < minDist) minDist = d
}
const minScreen = Number.isFinite(minDist) ? minDist * safeScale : 9999
map.set(item.id, computeLabelBySpace(item.name, minScreen))
}
return map
}
const store = useContactsStore()
const mapStack = ref(null)
const graphContainer = ref(null)
const loading = ref(true)
const network = ref(null)
const selectedNode = ref(null)
const nodes = ref([])
const edges = ref([])
const allRelationTypes = ref([])
const activeFilters = ref([])
const layout = ref({ w: 0, h: 0, cx: 0, cy: 0, rInner: 0, rMid: 0, rOuter: 0 })
const sphereLabels = ref({})
const circleLabels = ref({})
let nodesDS = null
let edgesDS = null
let resizeObserver = null
let initRetryTimer = null
let initRetryCount = 0
const INIT_RETRY_MAX = 40
let themeObserver = null
const selectedContact = computed(() =>
selectedNode.value ? store.contactById(selectedNode.value.id) : null
)
const topPanelCollapsed = ref(false)
function cssVar(name, fallback) {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return value || fallback
}
function mapPalette() {
return {
nodeBackground: cssVar('--surface-alt', '#22263a'),
nodeBorder: cssVar('--accent', '#5b8dee'),
nodeHighlightBackground: cssVar('--surface', '#1a1d27'),
nodeHighlightBorder: cssVar('--accent-hover', '#7aa5f5'),
nodeFont: cssVar('--text', '#e2e6f3'),
guideText: cssVar('--text-muted', '#7b82a6'),
}
}
/** Сетка в тех же мировых координатах, что и узлы (см. vis-network beforeDrawing после translate+scale). */
function drawPolarGuide(ctx) {
const palette = mapPalette()
const L = layout.value
const net = network.value
if (!L.rOuter || !net) return
const { cx, cy, rInner, rMid, rOuter } = L
const n = SPHERE_ORDER.length
// Полупрозрачная заливка колец (от внешнего к внутреннему)
ctx.beginPath()
ctx.arc(cx, cy, rOuter, 0, 2 * Math.PI)
ctx.arc(cx, cy, rMid, 0, 2 * Math.PI, true)
ctx.closePath()
ctx.fillStyle = 'rgba(91, 141, 238, 0.08)'
ctx.fill()
ctx.beginPath()
ctx.arc(cx, cy, rMid, 0, 2 * Math.PI)
ctx.arc(cx, cy, rInner, 0, 2 * Math.PI, true)
ctx.closePath()
ctx.fillStyle = 'rgba(78, 204, 163, 0.08)'
ctx.fill()
ctx.beginPath()
ctx.arc(cx, cy, rInner, 0, 2 * Math.PI)
ctx.closePath()
ctx.fillStyle = 'rgba(244, 162, 97, 0.09)'
ctx.fill()
ctx.strokeStyle = 'rgba(123, 130, 166, 0.55)'
ctx.lineWidth = 1
for (const r of [rInner, rMid, rOuter]) {
ctx.beginPath()
ctx.arc(cx, cy, r, 0, 2 * Math.PI)
ctx.stroke()
}
ctx.strokeStyle = 'rgba(123, 130, 166, 0.4)'
ctx.lineWidth = 1
for (let i = 0; i <= n; i += 1) {
const a = (i / n) * 2 * Math.PI - Math.PI / 2
ctx.beginPath()
ctx.moveTo(cx, cy)
ctx.lineTo(cx + rOuter * Math.cos(a), cy + rOuter * Math.sin(a))
ctx.stroke()
}
ctx.fillStyle = palette.guideText
ctx.font = '12px system-ui, -apple-system, sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const labelR = rOuter + 36
for (let i = 0; i < n; i += 1) {
const mid = ((i + 0.5) / n) * 2 * Math.PI - Math.PI / 2
const key = SPHERE_ORDER[i]
const text = sphereLabels.value[key] || key
ctx.fillText(text, cx + labelR * Math.cos(mid), cy + labelR * Math.sin(mid))
}
// Подписи кругов
const supportR = rInner * 0.55
const productivityR = (rInner + rMid) / 2
const developmentR = (rMid + rOuter) / 2
ctx.fillStyle = palette.guideText
ctx.font = '13px system-ui, -apple-system, sans-serif'
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.fillText('Круг поддержки', cx + 12, cy - supportR)
ctx.fillText('Круг продуктивности', cx + 12, cy - productivityR)
ctx.fillText('Круг развития', cx + 12, cy - developmentR)
}
function measureLayout() {
const el = graphContainer.value
if (!el) return
const w = el.offsetWidth
const h = el.offsetHeight
if (w < 10 || h < 10) return
const cx = w / 2
const cy = h / 2
const maxR = Math.min(w, h) * 0.36 * GRID_SCALE
layout.value = {
w,
h,
cx,
cy,
rInner: maxR * 0.33,
rMid: maxR * 0.62,
rOuter: maxR * 0.92,
}
}
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 mapEdgeToVis(e) {
const intense = e.interaction_intensity !== 'sparse'
const tip = [e.label, e.title].filter(Boolean).join(' — ') || 'Связь'
return {
id: String(e.id),
from: String(e.from),
to: String(e.to),
title: tip,
relation_type: e.relation_type,
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
width: intense ? 2.2 : 1,
dashes: intense ? false : [8, 6],
arrows: { to: { enabled: true, scaleFactor: 0.65 } },
smooth: false,
}
}
function buildVisNodes() {
const L = layout.value
const posById = computePolarPositions(nodes.value, L)
const labelMap = buildLabelMap(posById, network.value?.getScale?.() || 1)
const palette = mapPalette()
return nodes.value.map((n) => {
const { x, y } = nodeXY(n, L, posById)
const name = n.label || String(n.id)
return {
id: String(n.id),
label: labelMap.get(n.id) || '',
title: [name, n.title].filter(Boolean).join('\n'),
x: L.cx + x,
y: L.cy + y,
fixed: false,
color: {
background: palette.nodeBackground,
border: palette.nodeBorder,
highlight: { background: palette.nodeHighlightBackground, border: palette.nodeHighlightBorder },
},
font: { color: palette.nodeFont, size: 11 },
shape: 'dot',
size: importanceSize(n.importance),
label: labelMap.get(n.id) || '',
}
})
}
function initNetwork() {
if (!graphContainer.value) return
measureLayout()
const el = graphContainer.value
let { w, h } = layout.value
if (w < 10 || h < 10) {
if (initRetryCount >= INIT_RETRY_MAX) {
el.style.minHeight = '400px'
measureLayout()
w = layout.value.w
h = layout.value.h
} else {
initRetryCount += 1
initRetryTimer = setTimeout(initNetwork, 80)
return
}
}
initRetryCount = 0
if (initRetryTimer) {
clearTimeout(initRetryTimer)
initRetryTimer = null
}
network.value?.destroy()
network.value = null
const nodeList = buildVisNodes()
const edgeList = filteredEdges().map(mapEdgeToVis)
nodesDS = new DataSet(nodeList)
edgesDS = new DataSet(edgeList)
network.value = new Network(
el,
{ nodes: nodesDS, edges: edgesDS },
{
physics: false,
interaction: {
tooltipDelay: 150,
hover: true,
zoomView: true,
dragView: true,
dragNodes: true,
selectable: true,
},
nodes: { borderWidth: 1.5 },
edges: { font: { size: 0 } },
}
)
network.value.on('beforeDrawing', drawPolarGuide)
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.on('dragEnd', async (params) => {
if (!params.nodes?.length) return
const id = params.nodes[0]
const p = network.value?.getPositions([id])?.[id]
if (!p) return
await persistNodePlacement(id, p.x, p.y)
})
network.value.on('zoom', () => {
refreshLabelsByZoom()
})
nextTick(() => {
network.value?.fit({ animation: false, padding: 56 })
})
}
function refreshPositions() {
measureLayout()
if (!nodesDS || !network.value) return
const L = layout.value
const posById = computePolarPositions(nodes.value, L)
const labelMap = buildLabelMap(posById, network.value?.getScale?.() || 1)
const updates = nodes.value.map((n) => {
const { x, y } = nodeXY(n, L, posById)
return {
id: String(n.id),
x: L.cx + x,
y: L.cy + y,
fixed: false,
size: importanceSize(n.importance),
label: labelMap.get(n.id) || '',
}
})
nodesDS.update(updates)
network.value.redraw()
}
function refreshLabelsByZoom() {
if (!nodesDS || !network.value) return
const L = layout.value
const posById = computePolarPositions(nodes.value, L)
const labelMap = buildLabelMap(posById, network.value.getScale())
const palette = mapPalette()
const updates = nodes.value.map((n) => ({
id: String(n.id),
label: labelMap.get(n.id) || '',
font: { color: palette.nodeFont, size: 11 },
}))
nodesDS.update(updates)
network.value.redraw()
}
function applyThemeToNetwork() {
if (!nodesDS || !network.value) return
const palette = mapPalette()
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: 11 },
}))
)
network.value.redraw()
}
function refreshEdges() {
if (!edgesDS) return
edgesDS.clear()
edgesDS.add(filteredEdges().map(mapEdgeToVis))
}
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)
}
refreshEdges()
}
function fitView() {
network.value?.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' }, padding: 56 })
}
async function load() {
loading.value = true
try {
const [bundle, mapChoices] = await Promise.all([
fetchGraphBundle('/network-map-graph/'),
fetchMapChoices(),
])
nodes.value = bundle.nodes
edges.value = bundle.edges
allRelationTypes.value = bundle.relationTypes
activeFilters.value = bundle.relationTypes.map((r) => r.value)
const sm = {}
for (const o of mapChoices.life_spheres) sm[o.value] = o.label
sphereLabels.value = sm
const cm = {}
for (const o of mapChoices.network_circles) cm[o.value] = o.label
circleLabels.value = cm
} finally {
loading.value = false
}
if (nodes.value.length > 0) {
await store.fetchContacts()
await nextTick()
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
initNetwork()
}
}
onMounted(async () => {
await load()
await nextTick()
const stack = mapStack.value
if (stack) {
resizeObserver = new ResizeObserver(() => {
refreshPositions()
network.value?.redraw()
})
resizeObserver.observe(stack)
}
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>
<style scoped>
.network-map-view {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.network-map-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0 28px 20px;
position: relative;
}
.map-stack {
position: relative;
flex: 1;
min-height: 320px;
width: 100%;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.map-vis {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
include: ['src/**/*.test.js'],
},
})