Cleanup: remove tracked pycache and db.sqlite3; add map membership backfill, new components, backups, docs
This commit is contained in:
@@ -83,4 +83,19 @@ describe('getGraphBundle', () => {
|
||||
expect(bundle.edges).toHaveLength(1)
|
||||
expect(bundle.edges[0].id).toBe('10')
|
||||
})
|
||||
|
||||
it('returns empty graph when map has no memberships', async () => {
|
||||
listContacts.mockResolvedValue([
|
||||
{ id: '1', name: 'Анна' },
|
||||
{ id: '2', name: 'Борис' },
|
||||
])
|
||||
listRelations.mockResolvedValue([
|
||||
{ id: '10', source: '1', target: '2', relation_type: 'friend' },
|
||||
])
|
||||
listMembershipsByMap.mockResolvedValue([])
|
||||
|
||||
const bundle = await getGraphBundle({ mapId: 'map-1' })
|
||||
expect(bundle.nodes).toHaveLength(0)
|
||||
expect(bundle.edges).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
const mapRepo = () => getNetworkMapRepository()
|
||||
const membershipRepo = () => getNetworkMapMembershipRepository()
|
||||
|
||||
export const DEFAULT_NETWORK_MAP_NAME = 'Основная карта'
|
||||
|
||||
export async function listNetworkMaps() {
|
||||
return mapRepo().list()
|
||||
}
|
||||
@@ -26,6 +28,19 @@ export async function createNetworkMap(payload) {
|
||||
return created
|
||||
}
|
||||
|
||||
export async function ensureDefaultNetworkMap() {
|
||||
const maps = await listNetworkMaps()
|
||||
if (maps.length) return maps[0]
|
||||
|
||||
const { getDefaultNetworkMapType } = await import('./networkMapTypes')
|
||||
const defaultType = await getDefaultNetworkMapType()
|
||||
return createNetworkMap({
|
||||
name: DEFAULT_NETWORK_MAP_NAME,
|
||||
description: '',
|
||||
mapTypeId: defaultType?.id,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateNetworkMap(id, payload) {
|
||||
const updated = await mapRepo().update(id, payload)
|
||||
await appendChange({
|
||||
|
||||
@@ -1,72 +1,257 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Добавить на карту</h3>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="$emit('close')">✕</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Контакт</label>
|
||||
<SearchableSelect
|
||||
v-model="selectedContactId"
|
||||
:options="contactOptions"
|
||||
placeholder="Выберите контакт..."
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">Отмена</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!selectedContactId"
|
||||
@click="onAdd"
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="modal-overlay add-contact-modal" @click.self="onClose">
|
||||
<div class="modal" role="dialog" aria-labelledby="add-contact-title">
|
||||
<div class="modal-header">
|
||||
<h3 id="add-contact-title">Добавить на карту</h3>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="onClose">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="add-contact-search">Поиск контакта</label>
|
||||
<input
|
||||
id="add-contact-search"
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="form-control"
|
||||
placeholder="Введите имя..."
|
||||
autocomplete="off"
|
||||
@keydown.enter.prevent="selectFirstResult"
|
||||
/>
|
||||
<p v-if="!searchQuery.trim()" class="field-hint text-muted">
|
||||
Начните вводить имя — появится список контактов.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="searchQuery.trim()" class="contact-results">
|
||||
<p v-if="searching" class="text-muted results-status">Поиск…</p>
|
||||
<p v-else-if="!searchResults.length" class="text-muted results-status">Ничего не найдено</p>
|
||||
<button
|
||||
v-for="contact in searchResults"
|
||||
:key="contact.id"
|
||||
type="button"
|
||||
class="contact-result"
|
||||
:class="{ 'is-selected': String(selectedContactId) === String(contact.id) }"
|
||||
@click="selectContact(contact)"
|
||||
>
|
||||
<span class="contact-result__name">{{ contact.name }}</span>
|
||||
<span v-if="contact.organization" class="contact-result__meta">{{ contact.organization }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="selectedContact" class="selected-summary">
|
||||
Выбран: <strong>{{ selectedContact.name }}</strong>
|
||||
</p>
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="onClose">Отмена</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!selectedContactId || saving"
|
||||
@click="submit"
|
||||
>
|
||||
{{ saving ? 'Добавление…' : 'Добавить' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import SearchableSelect from './SearchableSelect.vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { listContacts } from '../application/usecases/contacts'
|
||||
import { normalizeApiError } from '../lib/api/errors'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
contacts: { type: Array, default: () => [] },
|
||||
memberContactIds: { type: Array, default: () => [] },
|
||||
onAdd: { type: Function, required: true },
|
||||
})
|
||||
const emit = defineEmits(['close', 'add'])
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const searchInputRef = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref([])
|
||||
const selectedContactId = ref('')
|
||||
const searching = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
let searchTimer = null
|
||||
let searchRequestId = 0
|
||||
|
||||
const memberSet = computed(() => new Set(props.memberContactIds.map(String)))
|
||||
|
||||
const contactOptions = computed(() =>
|
||||
props.contacts
|
||||
.filter((c) => !memberSet.value.has(String(c.id)))
|
||||
.map((c) => ({ value: String(c.id), label: c.name }))
|
||||
const selectedContact = computed(() =>
|
||||
searchResults.value.find((c) => String(c.id) === String(selectedContactId.value)) || null
|
||||
)
|
||||
|
||||
function resetState() {
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
selectedContactId.value = ''
|
||||
searching.value = false
|
||||
saving.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function selectContact(contact) {
|
||||
selectedContactId.value = String(contact.id)
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function selectFirstResult() {
|
||||
const first = searchResults.value[0]
|
||||
if (first) selectContact(first)
|
||||
}
|
||||
|
||||
async function runSearch(query) {
|
||||
const requestId = ++searchRequestId
|
||||
searching.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const rows = await listContacts(query)
|
||||
if (requestId !== searchRequestId) return
|
||||
searchResults.value = rows.filter((c) => !memberSet.value.has(String(c.id)))
|
||||
if (
|
||||
selectedContactId.value
|
||||
&& !searchResults.value.some((c) => String(c.id) === String(selectedContactId.value))
|
||||
) {
|
||||
selectedContactId.value = ''
|
||||
}
|
||||
} catch (e) {
|
||||
if (requestId !== searchRequestId) return
|
||||
searchResults.value = []
|
||||
error.value = normalizeApiError(e).message
|
||||
} finally {
|
||||
if (requestId === searchRequestId) {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen) selectedContactId.value = ''
|
||||
async (isOpen) => {
|
||||
if (!isOpen) {
|
||||
resetState()
|
||||
return
|
||||
}
|
||||
resetState()
|
||||
await nextTick()
|
||||
searchInputRef.value?.focus()
|
||||
}
|
||||
)
|
||||
|
||||
function onAdd() {
|
||||
if (!selectedContactId.value) return
|
||||
emit('add', selectedContactId.value)
|
||||
watch(searchQuery, (value) => {
|
||||
clearTimeout(searchTimer)
|
||||
selectedContactId.value = ''
|
||||
error.value = ''
|
||||
|
||||
const query = value.trim()
|
||||
if (!query) {
|
||||
searchResults.value = []
|
||||
searching.value = false
|
||||
return
|
||||
}
|
||||
|
||||
searchTimer = setTimeout(() => {
|
||||
runSearch(query)
|
||||
}, 250)
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!selectedContactId.value || saving.value) return
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await props.onAdd(selectedContactId.value)
|
||||
} catch (e) {
|
||||
error.value = normalizeApiError(e).message
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.add-contact-modal {
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.field-hint,
|
||||
.results-status {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.contact-results {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.contact-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contact-result:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.contact-result:hover,
|
||||
.contact-result.is-selected {
|
||||
background: rgba(91, 141, 238, 0.12);
|
||||
}
|
||||
|
||||
.contact-result__name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.contact-result__meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selected-summary {
|
||||
margin: 14px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 12px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--red, #e74c3c);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal-overlay" @click.self="onCancel">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Новый контакт</h3>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="onCancel">✕</button>
|
||||
</div>
|
||||
<ContactForm
|
||||
:initial="{}"
|
||||
:initial-map-ids="initialMapIds"
|
||||
@submit="onSubmit"
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ContactForm from './ContactForm.vue'
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
initialMapIds: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'created'])
|
||||
|
||||
function onCancel() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onSubmit(contactData, mapIds, pluginPayload) {
|
||||
emit('created', contactData, mapIds, pluginPayload)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="graph-node-menu-overlay"
|
||||
@click="close"
|
||||
@contextmenu.prevent="close"
|
||||
/>
|
||||
<div
|
||||
v-if="open"
|
||||
class="graph-node-menu"
|
||||
:style="{ left: `${x}px`, top: `${y}px` }"
|
||||
role="menu"
|
||||
@click.stop
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onCreateContact">
|
||||
Добавить контакт
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
x: { type: Number, default: 0 },
|
||||
y: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'create-contact'])
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onCreateContact() {
|
||||
emit('create-contact')
|
||||
close()
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
if (event.key === 'Escape' && props.open) close()
|
||||
}
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen) window.addEventListener('keydown', onKeyDown)
|
||||
else window.removeEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.open) window.addEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.graph-node-menu-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 900;
|
||||
}
|
||||
.graph-node-menu {
|
||||
position: fixed;
|
||||
z-index: 901;
|
||||
min-width: 180px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 6px 0;
|
||||
}
|
||||
.graph-node-menu__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.graph-node-menu__item:hover {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,7 @@
|
||||
@blur="onBlur"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<ul v-if="open && filteredOptions.length" class="searchable-select__list" role="listbox">
|
||||
<ul v-if="open && query.trim() && filteredOptions.length" class="searchable-select__list" role="listbox">
|
||||
<li
|
||||
v-for="(opt, index) in filteredOptions"
|
||||
:key="String(opt.value)"
|
||||
@@ -32,6 +32,9 @@
|
||||
{{ opt.label }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="open && !query.trim()" class="searchable-select__empty">
|
||||
Начните вводить имя для поиска
|
||||
</p>
|
||||
<p v-else-if="open && query.trim() && !filteredOptions.length" class="searchable-select__empty">
|
||||
Ничего не найдено
|
||||
</p>
|
||||
@@ -62,7 +65,7 @@ const allOptions = computed(() =>
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase('ru')
|
||||
if (!q) return allOptions.value
|
||||
if (!q) return []
|
||||
return allOptions.value.filter((o) =>
|
||||
o.label.toLocaleLowerCase('ru').includes(q)
|
||||
)
|
||||
|
||||
@@ -9,11 +9,15 @@ export function useGraphNodeContextMenu() {
|
||||
const contextMenuEdge = ref(null)
|
||||
const edgeContextMenuX = ref(0)
|
||||
const edgeContextMenuY = ref(0)
|
||||
const canvasContextMenuOpen = ref(false)
|
||||
const canvasContextMenuX = ref(0)
|
||||
const canvasContextMenuY = ref(0)
|
||||
|
||||
function openContextMenu(node, event) {
|
||||
if (!node || !event) return
|
||||
edgeContextMenuOpen.value = false
|
||||
contextMenuEdge.value = null
|
||||
canvasContextMenuOpen.value = false
|
||||
contextMenuNode.value = node
|
||||
contextMenuX.value = event.clientX
|
||||
contextMenuY.value = event.clientY
|
||||
@@ -24,17 +28,30 @@ export function useGraphNodeContextMenu() {
|
||||
if (!edge || !event) return
|
||||
contextMenuOpen.value = false
|
||||
contextMenuNode.value = null
|
||||
canvasContextMenuOpen.value = false
|
||||
contextMenuEdge.value = edge
|
||||
edgeContextMenuX.value = event.clientX
|
||||
edgeContextMenuY.value = event.clientY
|
||||
edgeContextMenuOpen.value = true
|
||||
}
|
||||
|
||||
function openCanvasContextMenu(event) {
|
||||
if (!event) return
|
||||
contextMenuOpen.value = false
|
||||
contextMenuNode.value = null
|
||||
edgeContextMenuOpen.value = false
|
||||
contextMenuEdge.value = null
|
||||
canvasContextMenuX.value = event.clientX
|
||||
canvasContextMenuY.value = event.clientY
|
||||
canvasContextMenuOpen.value = true
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenuOpen.value = false
|
||||
contextMenuNode.value = null
|
||||
edgeContextMenuOpen.value = false
|
||||
contextMenuEdge.value = null
|
||||
canvasContextMenuOpen.value = false
|
||||
}
|
||||
|
||||
function closeEdgeContextMenu() {
|
||||
@@ -42,6 +59,10 @@ export function useGraphNodeContextMenu() {
|
||||
contextMenuEdge.value = null
|
||||
}
|
||||
|
||||
function closeCanvasContextMenu() {
|
||||
canvasContextMenuOpen.value = false
|
||||
}
|
||||
|
||||
function resolveEdgeAtPointer(network, domEvent, getEdges) {
|
||||
let edgeId = null
|
||||
if (domEvent && typeof network.getPointer === 'function' && typeof network.getEdgeAt === 'function') {
|
||||
@@ -82,7 +103,7 @@ export function useGraphNodeContextMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
closeContextMenu()
|
||||
openCanvasContextMenu(domEvent)
|
||||
}
|
||||
|
||||
network.on('oncontext', onContext)
|
||||
@@ -98,10 +119,15 @@ export function useGraphNodeContextMenu() {
|
||||
contextMenuEdge,
|
||||
edgeContextMenuX,
|
||||
edgeContextMenuY,
|
||||
canvasContextMenuOpen,
|
||||
canvasContextMenuX,
|
||||
canvasContextMenuY,
|
||||
openContextMenu,
|
||||
openEdgeContextMenu,
|
||||
openCanvasContextMenu,
|
||||
closeContextMenu,
|
||||
closeEdgeContextMenu,
|
||||
closeCanvasContextMenu,
|
||||
attachNodeContextHandlers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ export const remoteNetworkMapMembershipRepository = {
|
||||
return data
|
||||
},
|
||||
async create(mapId, payload) {
|
||||
const { data } = await api.post(`/network-maps/${mapId}/memberships/`, payload)
|
||||
const { data } = await api.post(`/network-maps/${mapId}/memberships/`, {
|
||||
map: mapId,
|
||||
...payload,
|
||||
})
|
||||
return data
|
||||
},
|
||||
async update(mapId, id, payload) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
radiusRatioFromInvolvement,
|
||||
involvementFromRadiusRatio,
|
||||
} from '../../domain/conflictology'
|
||||
import { hasStoredMapPlacement } from './positioning'
|
||||
|
||||
export { CONFLICT_CENTER_NODE_ID }
|
||||
|
||||
@@ -23,12 +24,14 @@ export function computeConflictPositions(nodes, layout) {
|
||||
}
|
||||
|
||||
export function conflictNodeXY(node, layout, posById) {
|
||||
const ratio = Number(node.map_radius_ratio)
|
||||
const storedAngle = Number(node.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) }
|
||||
if (hasStoredMapPlacement(node)) {
|
||||
const ratio = Number(node.map_radius_ratio)
|
||||
const storedAngle = Number(node.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(node.id)
|
||||
if (p) return p
|
||||
|
||||
@@ -53,6 +53,12 @@ export function normalizedCircle(n, geometry = defaultGeometry()) {
|
||||
return circleKeys[Math.floor(circleKeys.length / 2)] || circleKeys[0] || 'productivity'
|
||||
}
|
||||
|
||||
export function hasStoredMapPlacement(node) {
|
||||
const angle = node?.map_angle
|
||||
const ratio = node?.map_radius_ratio
|
||||
return angle != null && ratio != null && angle !== '' && ratio !== ''
|
||||
}
|
||||
|
||||
export function computePolarPositions(rawNodes, layout, geometry = defaultGeometry()) {
|
||||
const { sectorKeys } = geometry
|
||||
const groups = new Map()
|
||||
@@ -87,12 +93,14 @@ export function computePolarPositions(rawNodes, layout, geometry = defaultGeomet
|
||||
|
||||
export function nodeXY(n, layout, posById, geometry = defaultGeometry()) {
|
||||
const { sectorKeys } = geometry
|
||||
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) }
|
||||
if (hasStoredMapPlacement(n)) {
|
||||
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
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
sphereByAngle,
|
||||
ringByRadius,
|
||||
nodeXY,
|
||||
computePolarPositions,
|
||||
defaultGeometry,
|
||||
} from './positioning'
|
||||
|
||||
@@ -40,6 +41,19 @@ describe('map positioning', () => {
|
||||
expect(p.y).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('ignores null persisted coordinates and spreads nodes in sector', () => {
|
||||
const layout = { rInner: 10, rMid: 20, rOuter: 100, cx: 0, cy: 0 }
|
||||
const nodes = [
|
||||
{ id: 1, life_sphere: 'other', network_circle: 'productivity', map_angle: null, map_radius_ratio: null },
|
||||
{ id: 2, life_sphere: 'other', network_circle: 'productivity', map_angle: null, map_radius_ratio: null },
|
||||
]
|
||||
const posById = computePolarPositions(nodes, layout)
|
||||
const p1 = nodeXY(nodes[0], layout, posById)
|
||||
const p2 = nodeXY(nodes[1], layout, posById)
|
||||
expect(p1.x).not.toBeCloseTo(p2.x)
|
||||
expect(Math.hypot(p1.x, p1.y)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('defaultGeometry matches SPHERE_ORDER', () => {
|
||||
expect(defaultGeometry().sectorKeys).toEqual(SPHERE_ORDER)
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="graph-area" ref="graphArea">
|
||||
<div class="graph-area" ref="graphArea" @contextmenu.prevent="onGraphAreaContextMenu">
|
||||
<div class="graph-chrome-bar">
|
||||
<button
|
||||
type="button"
|
||||
@@ -77,7 +77,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="loading" class="spinner"></div>
|
||||
<div v-else-if="nodes.length === 0" class="empty-state card">
|
||||
<div v-else-if="nodes.length === 0" class="empty-state card" @contextmenu.prevent="onGraphAreaContextMenu">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="5" cy="12" r="3"/><circle cx="19" cy="5" r="3"/><circle cx="19" cy="19" r="3"/>
|
||||
</svg>
|
||||
@@ -139,6 +139,20 @@
|
||||
@edit="openEditRelation"
|
||||
/>
|
||||
|
||||
<GraphCanvasContextMenu
|
||||
:open="canvasContextMenuOpen"
|
||||
:x="canvasContextMenuX"
|
||||
:y="canvasContextMenuY"
|
||||
@close="closeContextMenu"
|
||||
@create-contact="openCreateContact"
|
||||
/>
|
||||
|
||||
<CreateContactModal
|
||||
:open="createContactOpen"
|
||||
@close="createContactOpen = false"
|
||||
@created="onContactCreated"
|
||||
/>
|
||||
|
||||
<EditRelationModal
|
||||
:open="editRelationOpen"
|
||||
:relation="editRelationTarget"
|
||||
@@ -177,8 +191,11 @@ import RelationTypeFilters from '../components/RelationTypeFilters.vue'
|
||||
import CreateRelationModal from '../components/CreateRelationModal.vue'
|
||||
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
|
||||
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
|
||||
import GraphCanvasContextMenu from '../components/GraphCanvasContextMenu.vue'
|
||||
import CreateContactModal from '../components/CreateContactModal.vue'
|
||||
import EditRelationModal from '../components/EditRelationModal.vue'
|
||||
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
import { getGraphToolbarActions } from '../core/pluginRegistry'
|
||||
|
||||
let themeObserver = null
|
||||
@@ -193,16 +210,44 @@ const {
|
||||
contextMenuEdge,
|
||||
edgeContextMenuX,
|
||||
edgeContextMenuY,
|
||||
canvasContextMenuOpen,
|
||||
canvasContextMenuX,
|
||||
canvasContextMenuY,
|
||||
openCanvasContextMenu,
|
||||
closeContextMenu,
|
||||
closeEdgeContextMenu,
|
||||
attachNodeContextHandlers,
|
||||
} = useGraphNodeContextMenu()
|
||||
|
||||
const createContactOpen = ref(false)
|
||||
|
||||
function openNodeInfo(node) {
|
||||
selectedNode.value = node || null
|
||||
}
|
||||
|
||||
function openCreateContact() {
|
||||
closeContextMenu()
|
||||
createContactOpen.value = true
|
||||
}
|
||||
|
||||
function onGraphAreaContextMenu(event) {
|
||||
if (nodes.value.length > 0) return
|
||||
openCanvasContextMenu(event)
|
||||
}
|
||||
|
||||
async function onContactCreated(data, mapIds, pluginPayload) {
|
||||
const created = await store.createContact(data)
|
||||
if (mapIds?.length) {
|
||||
await mapsStore.setContactMapMemberships(created.id, mapIds)
|
||||
}
|
||||
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
||||
await saveContactPluginData(created.id, pluginPayload)
|
||||
createContactOpen.value = false
|
||||
await ensureGraphReady({ showSpinner: false })
|
||||
}
|
||||
|
||||
const store = useContactsStore()
|
||||
const mapsStore = useNetworkMapsStore()
|
||||
const router = useRouter()
|
||||
const graphToolbarActions = getGraphToolbarActions()
|
||||
const graphArea = ref(null)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ensureDefaultNetworkMap } from '../application/usecases/networkMaps'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -14,12 +15,16 @@ const mapsStore = useNetworkMapsStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await mapsStore.fetchMaps()
|
||||
const id = mapsStore.activeMapId || mapsStore.maps[0]?.id
|
||||
if (id) {
|
||||
router.replace({ name: 'NetworkMap', params: { mapId: id } })
|
||||
} else {
|
||||
router.replace({ name: 'Contacts' })
|
||||
const existing = mapsStore.maps.find(
|
||||
(m) => String(m.id) === String(mapsStore.activeMapId),
|
||||
) || mapsStore.maps[0]
|
||||
|
||||
const map = existing || await ensureDefaultNetworkMap()
|
||||
if (!existing) {
|
||||
await mapsStore.fetchMaps()
|
||||
}
|
||||
mapsStore.setActiveMapId(map.id)
|
||||
router.replace({ name: 'NetworkMap', params: { mapId: map.id } })
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
@create="openCreateMap"
|
||||
@manage="openEditMap"
|
||||
/>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="showAddContact = true">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="openAddContact">
|
||||
+ Участник
|
||||
</button>
|
||||
</template>
|
||||
@@ -42,15 +42,18 @@
|
||||
|
||||
</NetworkMapTopPanel>
|
||||
|
||||
<div class="network-map-body">
|
||||
<div class="network-map-body" @contextmenu.prevent="onMapBodyContextMenu">
|
||||
<div v-if="loading" class="spinner"></div>
|
||||
<div v-else-if="nodes.length === 0" class="empty-state card">
|
||||
<p>
|
||||
На карте «{{ activeMap?.name || 'сети' }}» никого нет.
|
||||
<button type="button" class="btn btn-link" @click="showAddContact = true">Добавьте участников</button>
|
||||
из общего списка контактов или создайте новых в
|
||||
<RouterLink to="/contacts">карточках контактов</RouterLink>.
|
||||
</p>
|
||||
<div class="empty-state-actions">
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="openAddContact">
|
||||
+ Добавить участника
|
||||
</button>
|
||||
<span class="text-muted">или создайте контакт в <RouterLink to="/contacts">карточках контактов</RouterLink></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="map-stack" ref="mapStack">
|
||||
<div id="network-map-container" ref="graphContainer" class="map-vis"></div>
|
||||
@@ -112,6 +115,21 @@
|
||||
@edit="openEditRelation"
|
||||
/>
|
||||
|
||||
<GraphCanvasContextMenu
|
||||
:open="canvasContextMenuOpen"
|
||||
:x="canvasContextMenuX"
|
||||
:y="canvasContextMenuY"
|
||||
@close="closeContextMenu"
|
||||
@create-contact="openCreateContact"
|
||||
/>
|
||||
|
||||
<CreateContactModal
|
||||
:open="createContactOpen"
|
||||
:initial-map-ids="createContactMapIds"
|
||||
@close="createContactOpen = false"
|
||||
@created="onContactCreated"
|
||||
/>
|
||||
|
||||
<EditRelationModal
|
||||
:open="editRelationOpen"
|
||||
:relation="editRelationTarget"
|
||||
@@ -143,10 +161,9 @@
|
||||
|
||||
<AddContactToMapModal
|
||||
:open="showAddContact"
|
||||
:contacts="store.contacts"
|
||||
:member-contact-ids="memberContactIds"
|
||||
:on-add="onAddContactToMap"
|
||||
@close="showAddContact = false"
|
||||
@add="onAddContactToMap"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -174,6 +191,7 @@ import {
|
||||
involvementNodeSize,
|
||||
} from '../domain/conflictology'
|
||||
import { fetchGraphBundle } from '../composables/useGraphData'
|
||||
import { ensureDefaultNetworkMap } from '../application/usecases/networkMaps'
|
||||
import { edgeFromRelation } from '../application/usecases/graph'
|
||||
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
|
||||
import NetworkMapSwitcher from '../components/NetworkMapSwitcher.vue'
|
||||
@@ -182,6 +200,8 @@ import AddContactToMapModal from '../components/AddContactToMapModal.vue'
|
||||
import CreateRelationModal from '../components/CreateRelationModal.vue'
|
||||
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
|
||||
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
|
||||
import GraphCanvasContextMenu from '../components/GraphCanvasContextMenu.vue'
|
||||
import CreateContactModal from '../components/CreateContactModal.vue'
|
||||
import EditRelationModal from '../components/EditRelationModal.vue'
|
||||
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
@@ -200,16 +220,46 @@ const {
|
||||
contextMenuEdge,
|
||||
edgeContextMenuX,
|
||||
edgeContextMenuY,
|
||||
canvasContextMenuOpen,
|
||||
canvasContextMenuX,
|
||||
canvasContextMenuY,
|
||||
openCanvasContextMenu,
|
||||
closeContextMenu,
|
||||
closeEdgeContextMenu,
|
||||
attachNodeContextHandlers,
|
||||
} = useGraphNodeContextMenu()
|
||||
|
||||
const createContactOpen = ref(false)
|
||||
|
||||
function openNodeInfo(node) {
|
||||
selectedNode.value = node || null
|
||||
selectedInvolvement.value = Number(node?.conflict_involvement) || 3
|
||||
}
|
||||
|
||||
function openCreateContact() {
|
||||
closeContextMenu()
|
||||
createContactOpen.value = true
|
||||
}
|
||||
|
||||
function onMapBodyContextMenu(event) {
|
||||
if (loading.value || nodes.value.length > 0) return
|
||||
openCanvasContextMenu(event)
|
||||
}
|
||||
|
||||
async function onContactCreated(data, mapIds, pluginPayload) {
|
||||
const created = await store.createContact(data)
|
||||
const targetMapIds = mapIds?.length
|
||||
? mapIds
|
||||
: (mapId.value ? [String(mapId.value)] : [])
|
||||
if (targetMapIds.length) {
|
||||
await mapsStore.setContactMapMemberships(created.id, targetMapIds)
|
||||
}
|
||||
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
||||
await saveContactPluginData(created.id, pluginPayload)
|
||||
createContactOpen.value = false
|
||||
await load()
|
||||
}
|
||||
|
||||
async function saveInvolvement() {
|
||||
const node = selectedNode.value
|
||||
if (!node?.membership_id) return
|
||||
@@ -334,6 +384,9 @@ const mapsStore = useNetworkMapsStore()
|
||||
const typesStore = useNetworkMapTypesStore()
|
||||
|
||||
const mapId = computed(() => String(route.params.mapId || ''))
|
||||
const createContactMapIds = computed(() => (
|
||||
mapId.value ? [String(mapId.value)] : []
|
||||
))
|
||||
const activeMap = computed(() => mapsStore.maps.find((m) => String(m.id) === mapId.value) || null)
|
||||
const activeMapType = computed(() => {
|
||||
const typeId = activeMap.value?.mapTypeId
|
||||
@@ -691,7 +744,12 @@ function buildVisNodes() {
|
||||
}
|
||||
|
||||
function initNetwork() {
|
||||
if (!graphContainer.value) return
|
||||
if (!graphContainer.value) {
|
||||
if (initRetryCount >= INIT_RETRY_MAX) return
|
||||
initRetryCount += 1
|
||||
initRetryTimer = setTimeout(initNetwork, 80)
|
||||
return
|
||||
}
|
||||
measureLayout()
|
||||
const el = graphContainer.value
|
||||
let { w, h } = layout.value
|
||||
@@ -877,8 +935,9 @@ async function load() {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
await store.fetchContacts()
|
||||
if (nodes.value.length > 0) {
|
||||
await store.fetchContacts()
|
||||
initRetryCount = 0
|
||||
await nextTick()
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||
initNetwork()
|
||||
@@ -890,6 +949,10 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openAddContact() {
|
||||
showAddContact.value = true
|
||||
}
|
||||
|
||||
function switchMap(nextId) {
|
||||
if (!nextId || String(nextId) === mapId.value) return
|
||||
router.push({ name: 'NetworkMap', params: { mapId: nextId } })
|
||||
@@ -930,8 +993,13 @@ async function onMapDelete() {
|
||||
closeMapForm()
|
||||
if (String(mapId.value) === String(deletingId)) {
|
||||
const nextId = mapsStore.maps[0]?.id
|
||||
if (nextId) router.replace({ name: 'NetworkMap', params: { mapId: nextId } })
|
||||
else router.replace({ name: 'Contacts' })
|
||||
if (nextId) {
|
||||
router.replace({ name: 'NetworkMap', params: { mapId: nextId } })
|
||||
return
|
||||
}
|
||||
const created = await ensureDefaultNetworkMap()
|
||||
await mapsStore.fetchMaps()
|
||||
router.replace({ name: 'NetworkMap', params: { mapId: created.id } })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -941,6 +1009,13 @@ async function onAddContactToMap(contactId) {
|
||||
await load()
|
||||
}
|
||||
|
||||
watch(graphContainer, (el) => {
|
||||
if (el && nodes.value.length > 0 && !network.value) {
|
||||
initRetryCount = 0
|
||||
initNetwork()
|
||||
}
|
||||
})
|
||||
|
||||
watch(mapId, async (next, prev) => {
|
||||
if (!next || next === prev) return
|
||||
network.value?.destroy()
|
||||
@@ -1039,4 +1114,11 @@ onUnmounted(() => {
|
||||
.legend-tension { color: #f1c40f; }
|
||||
.legend-alliance { color: #2ecc71; }
|
||||
.legend-neutral { color: #95a5a6; }
|
||||
.empty-state-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user