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:
Generated
+2412
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>Важность (1–5)</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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' },
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user