Add settings page and configurable network map types.
Let users define sector/circle labels and geometry per map type, choose a type when creating maps, and sync types through local backup and API migrations. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -48,6 +48,13 @@
|
||||
</svg>
|
||||
<span class="nav-label">Импорт</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/settings" 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="3"/>
|
||||
<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">Настройки</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-for="item in pluginNavItems"
|
||||
:key="item.to"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { listContacts, createContact } from './contacts'
|
||||
import { listRelations } from './relations'
|
||||
import { localDb, DEFAULT_MAP_NAME } from '../../infrastructure/db/localDb'
|
||||
import { generateId } from '../../lib/uuid'
|
||||
import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults'
|
||||
import { parseVcf } from '../../lib/import/vcard'
|
||||
import { serializeContactsExport } from '../../lib/export/contacts'
|
||||
|
||||
@@ -216,15 +217,41 @@ function normalizeDumpRelation(relation) {
|
||||
}
|
||||
|
||||
function migrateV1Dump(dump) {
|
||||
if ((dump.version || 1) >= 2) {
|
||||
return {
|
||||
...dump,
|
||||
networkMaps: dump.networkMaps || [],
|
||||
networkMapMemberships: dump.networkMapMemberships || [],
|
||||
contacts: (dump.contacts || []).map(stripLegacyContactFields),
|
||||
}
|
||||
let next = dump
|
||||
if ((dump.version || 1) < 2) {
|
||||
next = migrateV1ToV2(dump)
|
||||
}
|
||||
return migrateV2Dump(next)
|
||||
}
|
||||
|
||||
function migrateV2Dump(dump) {
|
||||
const base = {
|
||||
...dump,
|
||||
networkMaps: dump.networkMaps || [],
|
||||
networkMapMemberships: dump.networkMapMemberships || [],
|
||||
networkMapTypes: dump.networkMapTypes || [],
|
||||
contacts: (dump.contacts || []).map(stripLegacyContactFields),
|
||||
}
|
||||
|
||||
if (base.networkMapTypes.length) {
|
||||
return base
|
||||
}
|
||||
|
||||
const ts = new Date().toISOString()
|
||||
const defaultType = createDefaultMapTypeRecord('default-map-type', ts)
|
||||
const maps = base.networkMaps.map((map) => ({
|
||||
...map,
|
||||
mapTypeId: map.mapTypeId || defaultType.id,
|
||||
}))
|
||||
|
||||
return {
|
||||
...base,
|
||||
networkMapTypes: [defaultType],
|
||||
networkMaps: maps,
|
||||
}
|
||||
}
|
||||
|
||||
function migrateV1ToV2(dump) {
|
||||
const ts = new Date().toISOString()
|
||||
const mapId = generateId()
|
||||
const networkMaps = [{
|
||||
@@ -278,6 +305,7 @@ export async function exportLocalData({ passphrase = '' } = {}) {
|
||||
relations: await listRelations(),
|
||||
networkMaps: await localDb.networkMaps.toArray(),
|
||||
networkMapMemberships: await localDb.networkMapMemberships.toArray(),
|
||||
networkMapTypes: await localDb.networkMapTypes.toArray(),
|
||||
changes: await localDb.changelog.toArray(),
|
||||
}
|
||||
|
||||
@@ -331,8 +359,14 @@ export async function importLocalDump(file, passphrase = '') {
|
||||
localDb.relations,
|
||||
localDb.networkMaps,
|
||||
localDb.networkMapMemberships,
|
||||
localDb.networkMapTypes,
|
||||
localDb.changelog,
|
||||
async () => {
|
||||
if (Array.isArray(dump.networkMapTypes)) {
|
||||
for (const mapType of dump.networkMapTypes) {
|
||||
await localDb.networkMapTypes.put(mapType)
|
||||
}
|
||||
}
|
||||
for (const contact of dump.contacts) {
|
||||
await localDb.contacts.put(normalizeDumpContact(contact))
|
||||
}
|
||||
@@ -362,5 +396,6 @@ export async function importLocalDump(file, passphrase = '') {
|
||||
importedRelations: dump.relations.length,
|
||||
importedMaps: dump.networkMaps?.length || 0,
|
||||
importedMemberships: dump.networkMapMemberships?.length || 0,
|
||||
importedMapTypes: dump.networkMapTypes?.length || 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { appendChange } from '../../infrastructure/sync/changeLogRepository'
|
||||
import { getNetworkMapTypeRepository } from '../../infrastructure/repositories/repositoryFactory'
|
||||
|
||||
const typeRepo = () => getNetworkMapTypeRepository()
|
||||
|
||||
export async function listNetworkMapTypes() {
|
||||
return typeRepo().list()
|
||||
}
|
||||
|
||||
export async function getNetworkMapTypeById(id) {
|
||||
return typeRepo().getById(id)
|
||||
}
|
||||
|
||||
export async function getDefaultNetworkMapType() {
|
||||
return typeRepo().getDefault()
|
||||
}
|
||||
|
||||
export async function createNetworkMapType(payload) {
|
||||
const created = await typeRepo().create(payload)
|
||||
await appendChange({
|
||||
entityType: 'networkMapType',
|
||||
entityId: created.id,
|
||||
op: 'created',
|
||||
payloadPatch: created,
|
||||
})
|
||||
return created
|
||||
}
|
||||
|
||||
export async function updateNetworkMapType(id, payload) {
|
||||
const updated = await typeRepo().update(id, payload)
|
||||
await appendChange({
|
||||
entityType: 'networkMapType',
|
||||
entityId: id,
|
||||
op: 'updated',
|
||||
payloadPatch: payload,
|
||||
})
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function deleteNetworkMapType(id) {
|
||||
await typeRepo().remove(id)
|
||||
await appendChange({
|
||||
entityType: 'networkMapType',
|
||||
entityId: id,
|
||||
op: 'deleted',
|
||||
payloadPatch: {},
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,14 @@
|
||||
<label>Название *</label>
|
||||
<input v-model="form.name" class="form-control" required placeholder="Например: Коллектив А" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Тип карты *</label>
|
||||
<select v-model="form.mapTypeId" class="form-control" required>
|
||||
<option v-for="type in mapTypes" :key="type.id" :value="String(type.id)">
|
||||
{{ type.name }}{{ type.isDefault ? ' (по умолчанию)' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Описание</label>
|
||||
<textarea
|
||||
@@ -45,6 +53,8 @@ const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
initial: { type: Object, default: () => ({}) },
|
||||
deletable: { type: Boolean, default: false },
|
||||
mapTypes: { type: Array, default: () => [] },
|
||||
defaultMapTypeId: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['close', 'submit', 'delete'])
|
||||
|
||||
@@ -53,20 +63,28 @@ const isEdit = computed(() => Boolean(props.initial?.id))
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
mapTypeId: '',
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.open, props.initial],
|
||||
() => [props.open, props.initial, props.defaultMapTypeId, props.mapTypes],
|
||||
() => {
|
||||
if (!props.open) return
|
||||
form.name = props.initial?.name || ''
|
||||
form.description = props.initial?.description || ''
|
||||
form.mapTypeId = String(
|
||||
props.initial?.mapTypeId || props.defaultMapTypeId || props.mapTypes[0]?.id || ''
|
||||
)
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
function onSubmit() {
|
||||
emit('submit', { name: form.name.trim(), description: form.description.trim() })
|
||||
emit('submit', {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
mapTypeId: form.mapTypeId,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="modal modal-wide">
|
||||
<div class="modal-header">
|
||||
<h3>{{ isEdit ? 'Редактировать тип карты' : 'Новый тип карты' }}</h3>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="$emit('close')">✕</button>
|
||||
</div>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="form-group">
|
||||
<label>Название типа *</label>
|
||||
<input v-model="form.name" class="form-control" required placeholder="Например: Корпоративная" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Секторы (подписи по кругу)</label>
|
||||
<div v-for="(sector, index) in form.sectors" :key="sector._id" class="dynamic-row">
|
||||
<input
|
||||
v-model="sector.label"
|
||||
class="form-control"
|
||||
required
|
||||
:placeholder="`Сектор ${index + 1}`"
|
||||
/>
|
||||
<button
|
||||
v-if="form.sectors.length > 1"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="removeSector(index)"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="addSector">+ Добавить сектор</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Концентрические круги (от центра к краю)</label>
|
||||
<div v-for="(circle, index) in form.circles" :key="circle._id" class="dynamic-row">
|
||||
<input
|
||||
v-model="circle.label"
|
||||
class="form-control"
|
||||
required
|
||||
:placeholder="`Круг ${index + 1}`"
|
||||
/>
|
||||
<button
|
||||
v-if="form.circles.length > 1"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="removeCircle(index)"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="addCircle">+ Добавить круг</button>
|
||||
</div>
|
||||
|
||||
<p v-if="formError" class="text-danger" style="font-size:13px;">{{ formError }}</p>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
v-if="isEdit && !initial?.isDefault"
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="$emit('delete')"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
<div class="modal-footer-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch, computed } from 'vue'
|
||||
import { assignKeysToItems, validateMapTypePayload } from '../domain/mapTypeDefaults'
|
||||
|
||||
let rowId = 0
|
||||
function nextRowId() {
|
||||
rowId += 1
|
||||
return rowId
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
initial: { type: Object, default: () => ({}) },
|
||||
})
|
||||
const emit = defineEmits(['close', 'submit', 'delete'])
|
||||
|
||||
const isEdit = computed(() => Boolean(props.initial?.id))
|
||||
const formError = ref('')
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
sectors: [],
|
||||
circles: [],
|
||||
})
|
||||
|
||||
function blankSector(label = '') {
|
||||
return { _id: nextRowId(), label, key: '' }
|
||||
}
|
||||
|
||||
function blankCircle(label = '') {
|
||||
return { _id: nextRowId(), label, key: '' }
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.name = props.initial?.name || ''
|
||||
form.sectors = (props.initial?.sectors || [{ label: 'Сектор 1', key: 'sector_1' }]).map((s) => ({
|
||||
_id: nextRowId(),
|
||||
label: s.label,
|
||||
key: s.key,
|
||||
}))
|
||||
form.circles = (props.initial?.circles || [{ label: 'Круг 1', key: 'circle_1' }]).map((c) => ({
|
||||
_id: nextRowId(),
|
||||
label: c.label,
|
||||
key: c.key,
|
||||
}))
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.open, props.initial],
|
||||
() => {
|
||||
if (!props.open) return
|
||||
resetForm()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
function addSector() {
|
||||
form.sectors.push(blankSector())
|
||||
}
|
||||
|
||||
function removeSector(index) {
|
||||
form.sectors.splice(index, 1)
|
||||
}
|
||||
|
||||
function addCircle() {
|
||||
form.circles.push(blankCircle())
|
||||
}
|
||||
|
||||
function removeCircle(index) {
|
||||
form.circles.splice(index, 1)
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
const sectors = assignKeysToItems(
|
||||
form.sectors.map((s) => ({ label: s.label.trim(), key: s.key || undefined }))
|
||||
)
|
||||
const circles = assignKeysToItems(
|
||||
form.circles.map((c) => ({ label: c.label.trim(), key: c.key || undefined }))
|
||||
)
|
||||
const errors = validateMapTypePayload({
|
||||
name: form.name.trim(),
|
||||
sectors,
|
||||
circles,
|
||||
})
|
||||
if (Object.keys(errors).length) {
|
||||
formError.value = Object.values(errors)[0]
|
||||
return
|
||||
}
|
||||
formError.value = ''
|
||||
emit('submit', {
|
||||
name: form.name.trim(),
|
||||
sectors,
|
||||
circles,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-wide {
|
||||
max-width: 520px;
|
||||
}
|
||||
.dynamic-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.dynamic-row .form-control {
|
||||
flex: 1;
|
||||
}
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.modal-footer-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.text-danger {
|
||||
color: var(--red, #e74c3c);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
import { LIFE_SPHERES, NETWORK_CIRCLES } from './networkChoices'
|
||||
|
||||
export const DEFAULT_MAP_TYPE_NAME = 'Стандартная'
|
||||
|
||||
export const DEFAULT_MAP_TYPE_SECTORS = LIFE_SPHERES.map(({ value, label }) => ({
|
||||
key: value,
|
||||
label,
|
||||
}))
|
||||
|
||||
export const DEFAULT_MAP_TYPE_CIRCLES = NETWORK_CIRCLES.map(({ value, label }) => ({
|
||||
key: value,
|
||||
label,
|
||||
}))
|
||||
|
||||
const CYRILLIC_MAP = {
|
||||
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z',
|
||||
и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r',
|
||||
с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch',
|
||||
ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
|
||||
}
|
||||
|
||||
export function slugifyKey(label, existingKeys = []) {
|
||||
const lower = String(label || '').trim().toLowerCase()
|
||||
let base = lower
|
||||
.split('')
|
||||
.map((ch) => CYRILLIC_MAP[ch] ?? ch)
|
||||
.join('')
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 32)
|
||||
|
||||
if (!base) base = 'item'
|
||||
let key = base
|
||||
let i = 2
|
||||
const used = new Set(existingKeys)
|
||||
while (used.has(key)) {
|
||||
key = `${base}_${i}`
|
||||
i += 1
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
export function assignKeysToItems(items) {
|
||||
const keys = []
|
||||
return items.map((item) => {
|
||||
const label = String(item.label || '').trim()
|
||||
const key = item.key?.trim() || slugifyKey(label, keys)
|
||||
keys.push(key)
|
||||
return { key, label }
|
||||
})
|
||||
}
|
||||
|
||||
export function validateMapTypePayload({ name, sectors, circles }) {
|
||||
const errors = {}
|
||||
if (!String(name || '').trim()) errors.name = 'Укажите название типа.'
|
||||
if (!sectors?.length) errors.sectors = 'Нужен хотя бы один сектор.'
|
||||
if (!circles?.length) errors.circles = 'Нужен хотя бы один круг.'
|
||||
return errors
|
||||
}
|
||||
|
||||
export function geometryFromMapType(mapType) {
|
||||
const sectorKeys = (mapType?.sectors || DEFAULT_MAP_TYPE_SECTORS).map((s) => s.key)
|
||||
const circleKeys = (mapType?.circles || DEFAULT_MAP_TYPE_CIRCLES).map((c) => c.key)
|
||||
return { sectorKeys, circleKeys }
|
||||
}
|
||||
|
||||
export function labelsFromMapType(mapType) {
|
||||
const sectors = mapType?.sectors || DEFAULT_MAP_TYPE_SECTORS
|
||||
const circles = mapType?.circles || DEFAULT_MAP_TYPE_CIRCLES
|
||||
const sphereLabels = {}
|
||||
const circleLabels = {}
|
||||
for (const s of sectors) sphereLabels[s.key] = s.label
|
||||
for (const c of circles) circleLabels[c.key] = c.label
|
||||
return { sphereLabels, circleLabels }
|
||||
}
|
||||
|
||||
export function createDefaultMapTypeRecord(id, ts = new Date().toISOString()) {
|
||||
return {
|
||||
id,
|
||||
name: DEFAULT_MAP_TYPE_NAME,
|
||||
sectors: DEFAULT_MAP_TYPE_SECTORS,
|
||||
circles: DEFAULT_MAP_TYPE_CIRCLES,
|
||||
isDefault: true,
|
||||
workspaceId: 'personal',
|
||||
version: 1,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
deletedAt: null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
slugifyKey,
|
||||
assignKeysToItems,
|
||||
validateMapTypePayload,
|
||||
} from './mapTypeDefaults'
|
||||
|
||||
describe('mapTypeDefaults', () => {
|
||||
it('slugifyKey transliterates and deduplicates', () => {
|
||||
expect(slugifyKey('Работа')).toBe('rabota')
|
||||
expect(slugifyKey('Работа', ['rabota'])).toBe('rabota_2')
|
||||
})
|
||||
|
||||
it('assignKeysToItems preserves explicit keys', () => {
|
||||
const items = assignKeysToItems([
|
||||
{ label: 'A', key: 'custom' },
|
||||
{ label: 'B' },
|
||||
])
|
||||
expect(items[0].key).toBe('custom')
|
||||
expect(items[1].key).toBe('b')
|
||||
})
|
||||
|
||||
it('validateMapTypePayload requires sectors and circles', () => {
|
||||
expect(validateMapTypePayload({ name: 'T', sectors: [], circles: [{ key: 'a', label: 'A' }] }))
|
||||
.toHaveProperty('sectors')
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,20 @@
|
||||
import Dexie from 'dexie'
|
||||
import { generateId } from '../../lib/uuid'
|
||||
import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults'
|
||||
|
||||
const DEFAULT_MAP_NAME = 'Основная карта'
|
||||
|
||||
const BASE_STORES_V2 = {
|
||||
contacts: 'id, name, updatedAt, deletedAt, workspaceId',
|
||||
relations: 'id, source, target, updatedAt, deletedAt, workspaceId',
|
||||
networkMaps: 'id, name, updatedAt, deletedAt, workspaceId',
|
||||
networkMapMemberships: 'id, mapId, contactId, updatedAt, deletedAt, [mapId+contactId]',
|
||||
meta: 'key',
|
||||
changelog: 'id, ts, entityType, entityId, syncStatus, workspaceId',
|
||||
}
|
||||
|
||||
const CONTACT_TAGS_STORE = 'id, contactId, label, updatedAt, deletedAt, workspaceId'
|
||||
|
||||
class SocialGraphDb extends Dexie {
|
||||
constructor() {
|
||||
super('socialGraphDb')
|
||||
@@ -13,12 +25,7 @@ class SocialGraphDb extends Dexie {
|
||||
changelog: 'id, ts, entityType, entityId, syncStatus, workspaceId',
|
||||
})
|
||||
this.version(2).stores({
|
||||
contacts: 'id, name, updatedAt, deletedAt, workspaceId',
|
||||
relations: 'id, source, target, updatedAt, deletedAt, workspaceId',
|
||||
networkMaps: 'id, name, updatedAt, deletedAt, workspaceId',
|
||||
networkMapMemberships: 'id, mapId, contactId, updatedAt, deletedAt, [mapId+contactId]',
|
||||
meta: 'key',
|
||||
changelog: 'id, ts, entityType, entityId, syncStatus, workspaceId',
|
||||
...BASE_STORES_V2,
|
||||
}).upgrade(async (tx) => {
|
||||
const contacts = await tx.table('contacts').toArray()
|
||||
const ts = new Date().toISOString()
|
||||
@@ -67,4 +74,43 @@ class SocialGraphDb extends Dexie {
|
||||
}
|
||||
|
||||
export const localDb = new SocialGraphDb()
|
||||
|
||||
/**
|
||||
* Core schema v4: network map types (runs after plugin Dexie upgraders).
|
||||
*/
|
||||
export function applyCoreDbUpgrades(db) {
|
||||
if (db.verno >= 4) return
|
||||
|
||||
const stores = {
|
||||
...BASE_STORES_V2,
|
||||
networkMapTypes: 'id, name, updatedAt, deletedAt, workspaceId',
|
||||
}
|
||||
if (db.verno >= 3) {
|
||||
stores.contactTags = CONTACT_TAGS_STORE
|
||||
}
|
||||
|
||||
db.version(4).stores(stores).upgrade(async (tx) => {
|
||||
const ts = new Date().toISOString()
|
||||
const types = await tx.table('networkMapTypes').toArray()
|
||||
const activeTypes = types.filter((t) => !t.deletedAt)
|
||||
let defaultType = activeTypes.find((t) => t.isDefault)
|
||||
|
||||
if (!defaultType) {
|
||||
const typeId = generateId()
|
||||
defaultType = createDefaultMapTypeRecord(typeId, ts)
|
||||
await tx.table('networkMapTypes').add(defaultType)
|
||||
}
|
||||
|
||||
const maps = await tx.table('networkMaps').toArray()
|
||||
for (const map of maps) {
|
||||
if (!map.deletedAt && !map.mapTypeId) {
|
||||
await tx.table('networkMaps').update(map.id, {
|
||||
mapTypeId: defaultType.id,
|
||||
updatedAt: ts,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export { DEFAULT_MAP_NAME }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { localDb, DEFAULT_MAP_NAME } from '../db/localDb'
|
||||
import { generateId } from '../../lib/uuid'
|
||||
import { localNetworkMapTypeRepository } from './networkMapTypeRepository.local'
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString()
|
||||
@@ -9,6 +10,7 @@ function withDefaults(payload = {}) {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
mapTypeId: payload.mapTypeId || null,
|
||||
workspaceId: 'personal',
|
||||
...payload,
|
||||
}
|
||||
@@ -40,12 +42,14 @@ async function ensureDefaultMap() {
|
||||
const active = all.filter((m) => !m.deletedAt)
|
||||
if (active.length) return active.sort((a, b) => a.name.localeCompare(b.name, 'ru'))[0]
|
||||
|
||||
const defaultType = await localNetworkMapTypeRepository.getDefault()
|
||||
const ts = nowIso()
|
||||
const id = generateId()
|
||||
await localDb.networkMaps.put({
|
||||
id,
|
||||
name: DEFAULT_MAP_NAME,
|
||||
description: '',
|
||||
mapTypeId: defaultType?.id || null,
|
||||
workspaceId: 'personal',
|
||||
version: 1,
|
||||
createdAt: ts,
|
||||
@@ -75,8 +79,10 @@ export const localNetworkMapRepository = {
|
||||
},
|
||||
|
||||
async create(payload) {
|
||||
const defaultType = await localNetworkMapTypeRepository.getDefault()
|
||||
const ts = nowIso()
|
||||
const record = withDefaults(payload)
|
||||
if (!record.mapTypeId) record.mapTypeId = defaultType?.id || null
|
||||
const id = generateId()
|
||||
await localDb.networkMaps.put({
|
||||
...record,
|
||||
|
||||
@@ -3,19 +3,29 @@ import { fetchAllPages } from '../../lib/api/pagination'
|
||||
|
||||
export const remoteNetworkMapRepository = {
|
||||
async list() {
|
||||
return fetchAllPages((page) => api.get('/network-maps/', { params: { page } }))
|
||||
const pages = await fetchAllPages((page) => api.get('/network-maps/', { params: { page } }))
|
||||
return pages.map((m) => ({ ...m, mapTypeId: m.map_type }))
|
||||
},
|
||||
async getById(id) {
|
||||
const { data } = await api.get(`/network-maps/${id}/`)
|
||||
return data
|
||||
return { ...data, mapTypeId: data.map_type }
|
||||
},
|
||||
async create(payload) {
|
||||
const { data } = await api.post('/network-maps/', payload)
|
||||
return data
|
||||
const { data } = await api.post('/network-maps/', {
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
map_type: payload.mapTypeId || payload.map_type,
|
||||
})
|
||||
return { ...data, mapTypeId: data.map_type }
|
||||
},
|
||||
async update(id, payload) {
|
||||
const { data } = await api.patch(`/network-maps/${id}/`, payload)
|
||||
return data
|
||||
const body = { ...payload }
|
||||
if (body.mapTypeId !== undefined) {
|
||||
body.map_type = body.mapTypeId
|
||||
delete body.mapTypeId
|
||||
}
|
||||
const { data } = await api.patch(`/network-maps/${id}/`, body)
|
||||
return { ...data, mapTypeId: data.map_type }
|
||||
},
|
||||
async remove(id) {
|
||||
await api.delete(`/network-maps/${id}/`)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { localDb } from '../db/localDb'
|
||||
import { generateId } from '../../lib/uuid'
|
||||
import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults'
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
function normalizeType(record) {
|
||||
if (!record || record.deletedAt) return null
|
||||
return {
|
||||
...record,
|
||||
isDefault: Boolean(record.isDefault),
|
||||
}
|
||||
}
|
||||
|
||||
async function findActiveType(id) {
|
||||
const direct = await localDb.networkMapTypes.get(id)
|
||||
if (direct && !direct.deletedAt) return direct
|
||||
const sid = String(id)
|
||||
const all = await localDb.networkMapTypes.toArray()
|
||||
return all.find((t) => !t.deletedAt && String(t.id) === sid) || null
|
||||
}
|
||||
|
||||
async function ensureDefaultType() {
|
||||
const all = await localDb.networkMapTypes.toArray()
|
||||
const active = all.filter((t) => !t.deletedAt)
|
||||
const existing = active.find((t) => t.isDefault)
|
||||
if (existing) return existing
|
||||
|
||||
const ts = nowIso()
|
||||
const id = generateId()
|
||||
const record = createDefaultMapTypeRecord(id, ts)
|
||||
await localDb.networkMapTypes.put(record)
|
||||
return record
|
||||
}
|
||||
|
||||
export const localNetworkMapTypeRepository = {
|
||||
async list() {
|
||||
await ensureDefaultType()
|
||||
const all = await localDb.networkMapTypes.toArray()
|
||||
return all
|
||||
.filter((t) => !t.deletedAt)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
||||
.map(normalizeType)
|
||||
.filter(Boolean)
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const type = await findActiveType(id)
|
||||
return normalizeType(type)
|
||||
},
|
||||
|
||||
async getDefault() {
|
||||
await ensureDefaultType()
|
||||
const all = await localDb.networkMapTypes.toArray()
|
||||
const active = all.filter((t) => !t.deletedAt)
|
||||
return normalizeType(active.find((t) => t.isDefault) || active[0])
|
||||
},
|
||||
|
||||
async create(payload) {
|
||||
const ts = nowIso()
|
||||
const id = generateId()
|
||||
await localDb.networkMapTypes.put({
|
||||
id,
|
||||
name: payload.name || '',
|
||||
sectors: payload.sectors || [],
|
||||
circles: payload.circles || [],
|
||||
isDefault: false,
|
||||
workspaceId: 'personal',
|
||||
version: 1,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
deletedAt: null,
|
||||
})
|
||||
return this.getById(id)
|
||||
},
|
||||
|
||||
async update(id, payload) {
|
||||
const existing = await findActiveType(id)
|
||||
if (!existing) throw new Error('Тип карты не найден')
|
||||
if (existing.isDefault && payload.isDefault === false) {
|
||||
throw new Error('Нельзя снять флаг типа по умолчанию')
|
||||
}
|
||||
await localDb.networkMapTypes.update(existing.id, {
|
||||
...payload,
|
||||
updatedAt: nowIso(),
|
||||
version: Number(existing.version || 1) + 1,
|
||||
})
|
||||
return this.getById(existing.id)
|
||||
},
|
||||
|
||||
async remove(id) {
|
||||
const type = await findActiveType(id)
|
||||
if (!type) throw new Error('Тип карты не найден')
|
||||
if (type.isDefault) throw new Error('Нельзя удалить тип карты по умолчанию')
|
||||
|
||||
const maps = await localDb.networkMaps.toArray()
|
||||
const inUse = maps.some((m) => !m.deletedAt && String(m.mapTypeId) === String(type.id))
|
||||
if (inUse) throw new Error('Тип используется картами сети')
|
||||
|
||||
const ts = nowIso()
|
||||
await localDb.networkMapTypes.update(type.id, {
|
||||
deletedAt: ts,
|
||||
updatedAt: ts,
|
||||
version: Number(type.version || 1) + 1,
|
||||
})
|
||||
},
|
||||
|
||||
async countMapsUsing(id) {
|
||||
const maps = await localDb.networkMaps.toArray()
|
||||
return maps.filter((m) => !m.deletedAt && String(m.mapTypeId) === String(id)).length
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import api from '../../api'
|
||||
import { fetchAllPages } from '../../lib/api/pagination'
|
||||
|
||||
export const remoteNetworkMapTypeRepository = {
|
||||
async list() {
|
||||
const rows = await fetchAllPages((page) => api.get('/network-map-types/', { params: { page } }))
|
||||
return rows.map((t) => ({ ...t, isDefault: t.is_default }))
|
||||
},
|
||||
async getById(id) {
|
||||
const { data } = await api.get(`/network-map-types/${id}/`)
|
||||
return { ...data, isDefault: data.is_default }
|
||||
},
|
||||
async getDefault() {
|
||||
const list = await this.list()
|
||||
return list.find((t) => t.isDefault) || list[0] || null
|
||||
},
|
||||
async create(payload) {
|
||||
const { data } = await api.post('/network-map-types/', {
|
||||
name: payload.name,
|
||||
sectors: payload.sectors,
|
||||
circles: payload.circles,
|
||||
})
|
||||
return { ...data, isDefault: data.is_default }
|
||||
},
|
||||
async update(id, payload) {
|
||||
const { data } = await api.patch(`/network-map-types/${id}/`, {
|
||||
name: payload.name,
|
||||
sectors: payload.sectors,
|
||||
circles: payload.circles,
|
||||
})
|
||||
return { ...data, isDefault: data.is_default }
|
||||
},
|
||||
async remove(id) {
|
||||
await api.delete(`/network-map-types/${id}/`)
|
||||
},
|
||||
async countMapsUsing() {
|
||||
return 0
|
||||
},
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import { localContactRepository } from './contactRepository.local'
|
||||
import { localRelationRepository } from './relationRepository.local'
|
||||
import { localNetworkMapRepository } from './networkMapRepository.local'
|
||||
import { localNetworkMapMembershipRepository } from './networkMapMembershipRepository.local'
|
||||
import { localNetworkMapTypeRepository } from './networkMapTypeRepository.local'
|
||||
import { remoteContactRepository } from './contactRepository.remote'
|
||||
import { remoteRelationRepository } from './relationRepository.remote'
|
||||
import { remoteNetworkMapRepository } from './networkMapRepository.remote'
|
||||
import { remoteNetworkMapMembershipRepository } from './networkMapMembershipRepository.remote'
|
||||
import { remoteNetworkMapTypeRepository } from './networkMapTypeRepository.remote'
|
||||
|
||||
function mode() {
|
||||
return getDataMode()
|
||||
@@ -30,6 +32,10 @@ export function getNetworkMapMembershipRepository() {
|
||||
return mode() === 'remote' ? remoteNetworkMapMembershipRepository : localNetworkMapMembershipRepository
|
||||
}
|
||||
|
||||
export function getNetworkMapTypeRepository() {
|
||||
return mode() === 'remote' ? remoteNetworkMapTypeRepository : localNetworkMapTypeRepository
|
||||
}
|
||||
|
||||
export async function getRelationTypes() {
|
||||
if (mode() === 'remote') {
|
||||
const { data } = await api.get('/meta/choices/')
|
||||
|
||||
@@ -1,57 +1,79 @@
|
||||
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
|
||||
const DEFAULT_CIRCLE_KEYS = ['support', 'productivity', 'development']
|
||||
|
||||
export function defaultGeometry() {
|
||||
return {
|
||||
sectorKeys: [...SPHERE_ORDER],
|
||||
circleKeys: [...DEFAULT_CIRCLE_KEYS],
|
||||
}
|
||||
}
|
||||
|
||||
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 ringRadius(networkCircle, layout, geometry = defaultGeometry()) {
|
||||
const { circleKeys } = geometry
|
||||
const idx = circleKeys.indexOf(networkCircle)
|
||||
if (idx < 0) return layout.rOuter * 0.5
|
||||
const n = circleKeys.length
|
||||
return layout.rOuter * ((idx + 1) / n) * 0.95
|
||||
}
|
||||
|
||||
export function sphereByAngle(angle) {
|
||||
export function ringByRadius(r, layout, geometry = defaultGeometry()) {
|
||||
const { circleKeys } = geometry
|
||||
const n = circleKeys.length
|
||||
if (!n) return 'productivity'
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const threshold = layout.rOuter * (i + 0.5) / n
|
||||
if (r <= threshold) return circleKeys[i]
|
||||
}
|
||||
return circleKeys[n - 1]
|
||||
}
|
||||
|
||||
export function sphereByAngle(angle, geometry = defaultGeometry()) {
|
||||
const { sectorKeys } = geometry
|
||||
const n = sectorKeys.length
|
||||
if (!n) return 'other'
|
||||
const full = 2 * Math.PI
|
||||
const norm = ((angle + Math.PI / 2) % full + full) % full
|
||||
const sectorW = full / SPHERE_ORDER.length
|
||||
const sectorW = full / n
|
||||
const idx = Math.floor(norm / sectorW)
|
||||
return SPHERE_ORDER[Math.max(0, Math.min(SPHERE_ORDER.length - 1, idx))]
|
||||
return sectorKeys[Math.max(0, Math.min(n - 1, idx))]
|
||||
}
|
||||
|
||||
export function normalizedSphere(n) {
|
||||
export function normalizedSphere(n, geometry = defaultGeometry()) {
|
||||
const { sectorKeys } = geometry
|
||||
const s = n.life_sphere
|
||||
return SPHERE_ORDER.includes(s) ? s : 'other'
|
||||
if (sectorKeys.includes(s)) return s
|
||||
return sectorKeys[sectorKeys.length - 1] || sectorKeys[0] || 'other'
|
||||
}
|
||||
|
||||
export function normalizedCircle(n) {
|
||||
export function normalizedCircle(n, geometry = defaultGeometry()) {
|
||||
const { circleKeys } = geometry
|
||||
const c = n.network_circle
|
||||
return c === 'support' || c === 'productivity' || c === 'development' ? c : 'productivity'
|
||||
if (circleKeys.includes(c)) return c
|
||||
return circleKeys[Math.floor(circleKeys.length / 2)] || circleKeys[0] || 'productivity'
|
||||
}
|
||||
|
||||
export function computePolarPositions(rawNodes, L) {
|
||||
export function computePolarPositions(rawNodes, layout, geometry = defaultGeometry()) {
|
||||
const { sectorKeys } = geometry
|
||||
const groups = new Map()
|
||||
for (const n of rawNodes) {
|
||||
const key = `${normalizedSphere(n)}|${normalizedCircle(n)}`
|
||||
const key = `${normalizedSphere(n, geometry)}|${normalizedCircle(n, geometry)}`
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key).push(n)
|
||||
}
|
||||
const posById = new Map()
|
||||
const nSectors = SPHERE_ORDER.length
|
||||
const nSectors = sectorKeys.length || 1
|
||||
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 sphere = normalizedSphere(sample, geometry)
|
||||
const circle = normalizedCircle(sample, geometry)
|
||||
const idx = sectorKeys.indexOf(sphere)
|
||||
const sectorStart = (Math.max(0, 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 rBase = ringRadius(circle, layout, geometry)
|
||||
const k = group.length
|
||||
group.forEach((n, i) => {
|
||||
const angle = k === 1
|
||||
@@ -63,7 +85,8 @@ export function computePolarPositions(rawNodes, L) {
|
||||
return posById
|
||||
}
|
||||
|
||||
export function nodeXY(n, layout, posById) {
|
||||
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)) {
|
||||
@@ -73,9 +96,19 @@ export function nodeXY(n, layout, posById) {
|
||||
}
|
||||
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
|
||||
const idx = sectorKeys.indexOf(normalizedSphere(n, geometry))
|
||||
const nSectors = sectorKeys.length || 1
|
||||
const sectorStart = (Math.max(0, idx) / nSectors) * 2 * Math.PI - Math.PI / 2
|
||||
const r = ringRadius(normalizedCircle(n, geometry), layout, geometry)
|
||||
const angle = sectorStart + (2 * Math.PI) / nSectors / 2
|
||||
return { x: r * Math.cos(angle), y: r * Math.sin(angle) }
|
||||
}
|
||||
|
||||
export function circleLabelPositions(layout, geometry = defaultGeometry()) {
|
||||
const { circleKeys } = geometry
|
||||
const n = circleKeys.length
|
||||
return circleKeys.map((key, i) => ({
|
||||
key,
|
||||
y: layout.cy - layout.rOuter * ((i + 1) / n) * 0.95,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,23 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { sphereByAngle, ringByRadius, nodeXY } from './positioning'
|
||||
import {
|
||||
SPHERE_ORDER,
|
||||
sphereByAngle,
|
||||
ringByRadius,
|
||||
nodeXY,
|
||||
defaultGeometry,
|
||||
} from './positioning'
|
||||
|
||||
describe('map positioning', () => {
|
||||
it('maps angle to sphere deterministically', () => {
|
||||
const L = { rInner: 10, rMid: 20, rOuter: 30, cx: 0, cy: 0 }
|
||||
|
||||
it('maps angle to sphere deterministically with default geometry', () => {
|
||||
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 }
|
||||
it('maps angle with custom sector count', () => {
|
||||
const geometry = { sectorKeys: ['a', 'b', 'c', 'd', 'e'], circleKeys: ['x'] }
|
||||
expect(sphereByAngle(-Math.PI / 2, geometry)).toBe('a')
|
||||
expect(sphereByAngle(Math.PI / 2, geometry)).toBe('c')
|
||||
})
|
||||
|
||||
it('maps radius to ring with default geometry', () => {
|
||||
expect(ringByRadius(5, L)).toBe('support')
|
||||
expect(ringByRadius(16, L)).toBe('productivity')
|
||||
expect(ringByRadius(28, L)).toBe('development')
|
||||
})
|
||||
|
||||
it('maps radius with two circles', () => {
|
||||
const geometry = { sectorKeys: ['a'], circleKeys: ['inner', 'outer'] }
|
||||
expect(ringByRadius(5, L, geometry)).toBe('inner')
|
||||
expect(ringByRadius(20, L, geometry)).toBe('outer')
|
||||
})
|
||||
|
||||
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())
|
||||
const layout = { rInner: 10, rMid: 20, rOuter: 100, cx: 0, cy: 0 }
|
||||
const p = nodeXY({ map_radius_ratio: 0.5, map_angle: 0 }, layout, new Map())
|
||||
expect(p.x).toBeCloseTo(50)
|
||||
expect(p.y).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('defaultGeometry matches SPHERE_ORDER', () => {
|
||||
expect(defaultGeometry().sectorKeys).toEqual(SPHERE_ORDER)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,13 +3,14 @@ import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import { bootstrapPlugins } from './core/bootstrapPlugins'
|
||||
import { applyDexiePluginUpgrades } from './core/pluginRegistry'
|
||||
import { localDb } from './infrastructure/db/localDb'
|
||||
import { localDb, applyCoreDbUpgrades } from './infrastructure/db/localDb'
|
||||
import { initRouter } from './router'
|
||||
import './assets/style.css'
|
||||
|
||||
async function bootstrap() {
|
||||
await bootstrapPlugins()
|
||||
applyDexiePluginUpgrades(localDb)
|
||||
applyCoreDbUpgrades(localDb)
|
||||
const router = await initRouter()
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -36,6 +36,11 @@ const coreRoutes = [
|
||||
name: 'Import',
|
||||
component: () => import('../views/ImportView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
component: () => import('../views/SettingsView.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
let router = null
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
listNetworkMapTypes,
|
||||
getNetworkMapTypeById,
|
||||
getDefaultNetworkMapType,
|
||||
createNetworkMapType,
|
||||
updateNetworkMapType,
|
||||
deleteNetworkMapType,
|
||||
} from '../application/usecases/networkMapTypes'
|
||||
import { syncPendingChanges } from '../application/usecases/sync'
|
||||
|
||||
export const useNetworkMapTypesStore = defineStore('networkMapTypes', {
|
||||
state: () => ({
|
||||
types: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
defaultType(state) {
|
||||
return state.types.find((t) => t.isDefault) || state.types[0] || null
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
setError(error) {
|
||||
this.error = error?.message || String(error)
|
||||
},
|
||||
|
||||
async fetchTypes() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
this.types = await listNetworkMapTypes()
|
||||
return this.types
|
||||
} catch (e) {
|
||||
this.setError(e)
|
||||
throw e
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
getById(id) {
|
||||
return this.types.find((t) => String(t.id) === String(id)) || null
|
||||
},
|
||||
|
||||
async fetchTypeById(id) {
|
||||
const data = await getNetworkMapTypeById(id)
|
||||
const idx = this.types.findIndex((t) => String(t.id) === String(id))
|
||||
if (idx !== -1) this.types[idx] = data
|
||||
else if (data) this.types.push(data)
|
||||
return data
|
||||
},
|
||||
|
||||
async fetchDefaultType() {
|
||||
const data = await getDefaultNetworkMapType()
|
||||
if (data) {
|
||||
const idx = this.types.findIndex((t) => String(t.id) === String(data.id))
|
||||
if (idx !== -1) this.types[idx] = data
|
||||
else this.types.push(data)
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
async createType(payload) {
|
||||
const data = await createNetworkMapType(payload)
|
||||
this.types.push(data)
|
||||
await syncPendingChanges()
|
||||
return data
|
||||
},
|
||||
|
||||
async updateType(id, payload) {
|
||||
const data = await updateNetworkMapType(id, payload)
|
||||
const idx = this.types.findIndex((t) => String(t.id) === String(id))
|
||||
if (idx !== -1) this.types[idx] = data
|
||||
await syncPendingChanges()
|
||||
return data
|
||||
},
|
||||
|
||||
async deleteType(id) {
|
||||
await deleteNetworkMapType(id)
|
||||
this.types = this.types.filter((t) => String(t.id) !== String(id))
|
||||
await syncPendingChanges()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -114,6 +114,8 @@
|
||||
:open="mapFormOpen"
|
||||
:initial="mapFormTarget"
|
||||
:deletable="Boolean(mapFormTarget?.id)"
|
||||
:map-types="typesStore.types"
|
||||
:default-map-type-id="String(typesStore.defaultType?.id || '')"
|
||||
@close="closeMapForm"
|
||||
@submit="onMapFormSubmit"
|
||||
@delete="onMapDelete"
|
||||
@@ -138,14 +140,15 @@ import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
||||
import { RELATION_COLORS } from '../lib/graph/relationColors'
|
||||
import { applyIntensityToVisEdge } from '../lib/graph/relationIntensity'
|
||||
import {
|
||||
SPHERE_ORDER,
|
||||
ringRadius,
|
||||
ringByRadius,
|
||||
sphereByAngle,
|
||||
computePolarPositions,
|
||||
nodeXY,
|
||||
defaultGeometry,
|
||||
circleLabelPositions,
|
||||
} from '../lib/map/positioning'
|
||||
import { fetchGraphBundle, fetchMapChoices } from '../composables/useGraphData'
|
||||
import { geometryFromMapType, labelsFromMapType } from '../domain/mapTypeDefaults'
|
||||
import { fetchGraphBundle } from '../composables/useGraphData'
|
||||
import { edgeFromRelation } from '../application/usecases/graph'
|
||||
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
|
||||
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
|
||||
@@ -158,6 +161,7 @@ import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
|
||||
import EditRelationModal from '../components/EditRelationModal.vue'
|
||||
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
import { useNetworkMapTypesStore } from '../stores/networkMapTypes'
|
||||
|
||||
defineOptions({ name: 'NetworkMap' })
|
||||
let themeObserver = null
|
||||
@@ -196,8 +200,8 @@ async function persistNodePlacement(nodeId, canvasX, canvasY) {
|
||||
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)
|
||||
const nextSphere = sphereByAngle(angle, mapGeometry.value)
|
||||
const nextCircle = ringByRadius(r, L, mapGeometry.value)
|
||||
|
||||
if (
|
||||
node.life_sphere === nextSphere &&
|
||||
@@ -279,6 +283,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useContactsStore()
|
||||
const mapsStore = useNetworkMapsStore()
|
||||
const typesStore = useNetworkMapTypesStore()
|
||||
|
||||
const mapId = computed(() => String(route.params.mapId || ''))
|
||||
const activeMap = computed(() => mapsStore.maps.find((m) => String(m.id) === mapId.value) || null)
|
||||
@@ -316,6 +321,15 @@ 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({})
|
||||
const mapGeometry = ref(defaultGeometry())
|
||||
|
||||
const RING_FILL_COLORS = [
|
||||
'rgba(91, 141, 238, 0.08)',
|
||||
'rgba(78, 204, 163, 0.08)',
|
||||
'rgba(244, 162, 97, 0.09)',
|
||||
'rgba(155, 89, 182, 0.08)',
|
||||
'rgba(52, 152, 219, 0.08)',
|
||||
]
|
||||
|
||||
let nodesDS = null
|
||||
let edgesDS = null
|
||||
@@ -477,33 +491,25 @@ function drawPolarGuide(ctx) {
|
||||
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
|
||||
const { cx, cy, rOuter } = L
|
||||
const { sectorKeys, circleKeys } = mapGeometry.value
|
||||
const n = sectorKeys.length
|
||||
const nCircles = circleKeys.length
|
||||
const radii = circleKeys.map((_, i) => rOuter * (i + 1) / nCircles)
|
||||
|
||||
// Полупрозрачная заливка колец (от внешнего к внутреннему)
|
||||
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()
|
||||
for (let i = nCircles - 1; i >= 0; i -= 1) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, radii[i], 0, 2 * Math.PI)
|
||||
if (i > 0) ctx.arc(cx, cy, radii[i - 1], 0, 2 * Math.PI, true)
|
||||
else ctx.arc(cx, cy, 0, 0, 2 * Math.PI, true)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = RING_FILL_COLORS[i % RING_FILL_COLORS.length]
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
ctx.strokeStyle = 'rgba(123, 130, 166, 0.55)'
|
||||
ctx.lineWidth = 1
|
||||
for (const r of [rInner, rMid, rOuter]) {
|
||||
for (const r of radii) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, r, 0, 2 * Math.PI)
|
||||
ctx.stroke()
|
||||
@@ -526,22 +532,18 @@ function drawPolarGuide(ctx) {
|
||||
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 key = sectorKeys[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)
|
||||
for (const { key, y } of circleLabelPositions(L, mapGeometry.value)) {
|
||||
ctx.fillText(circleLabels.value[key] || key, cx + 12, y)
|
||||
}
|
||||
}
|
||||
|
||||
function measureLayout() {
|
||||
@@ -591,11 +593,11 @@ function mapEdgeToVis(e) {
|
||||
|
||||
function buildVisNodes() {
|
||||
const L = layout.value
|
||||
const posById = computePolarPositions(nodes.value, L)
|
||||
const posById = computePolarPositions(nodes.value, L, mapGeometry.value)
|
||||
const labelMap = buildLabelMap(posById, network.value?.getScale?.() || 1)
|
||||
const palette = mapPalette()
|
||||
return nodes.value.map((n) => {
|
||||
const { x, y } = nodeXY(n, L, posById)
|
||||
const { x, y } = nodeXY(n, L, posById, mapGeometry.value)
|
||||
const name = n.label || String(n.id)
|
||||
return {
|
||||
id: String(n.id),
|
||||
@@ -709,10 +711,10 @@ function refreshPositions() {
|
||||
measureLayout()
|
||||
if (!nodesDS || !network.value) return
|
||||
const L = layout.value
|
||||
const posById = computePolarPositions(nodes.value, L)
|
||||
const posById = computePolarPositions(nodes.value, L, mapGeometry.value)
|
||||
const labelMap = buildLabelMap(posById, network.value?.getScale?.() || 1)
|
||||
const updates = nodes.value.map((n) => {
|
||||
const { x, y } = nodeXY(n, L, posById)
|
||||
const { x, y } = nodeXY(n, L, posById, mapGeometry.value)
|
||||
return {
|
||||
id: String(n.id),
|
||||
x: L.cx + x,
|
||||
@@ -731,7 +733,7 @@ function refreshPositions() {
|
||||
function refreshLabelsByZoom() {
|
||||
if (!nodesDS || !network.value) return
|
||||
const L = layout.value
|
||||
const posById = computePolarPositions(nodes.value, L)
|
||||
const posById = computePolarPositions(nodes.value, L, mapGeometry.value)
|
||||
const labelMap = buildLabelMap(posById, network.value.getScale())
|
||||
const palette = mapPalette()
|
||||
const updates = nodes.value.map((n) => ({
|
||||
@@ -776,25 +778,33 @@ function fitView() {
|
||||
network.value?.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' }, padding: 56 })
|
||||
}
|
||||
|
||||
async function resolveMapTypeLabels() {
|
||||
if (!typesStore.types.length) {
|
||||
await typesStore.fetchTypes()
|
||||
}
|
||||
const mapTypeId = activeMap.value?.mapTypeId
|
||||
let mapType = mapTypeId ? typesStore.getById(mapTypeId) : null
|
||||
if (!mapType) {
|
||||
mapType = typesStore.defaultType || await typesStore.fetchDefaultType()
|
||||
}
|
||||
mapGeometry.value = geometryFromMapType(mapType)
|
||||
const labels = labelsFromMapType(mapType)
|
||||
sphereLabels.value = labels.sphereLabels
|
||||
circleLabels.value = labels.circleLabels
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!mapId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
if (!mapsStore.maps.length) await mapsStore.fetchMaps()
|
||||
mapsStore.setActiveMapId(mapId.value)
|
||||
const [bundle, mapChoices] = await Promise.all([
|
||||
fetchGraphBundle({ mapId: mapId.value }),
|
||||
fetchMapChoices(),
|
||||
])
|
||||
await resolveMapTypeLabels()
|
||||
const bundle = await fetchGraphBundle({ mapId: mapId.value })
|
||||
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
|
||||
}
|
||||
@@ -817,6 +827,7 @@ function switchMap(nextId) {
|
||||
}
|
||||
|
||||
function openCreateMap() {
|
||||
typesStore.fetchTypes().catch(() => {})
|
||||
mapFormTarget.value = {}
|
||||
mapFormOpen.value = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Настройки</h2>
|
||||
</div>
|
||||
<div class="page-content content-narrow">
|
||||
<div class="card">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">Типы карты сети</h3>
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="openCreate">+ Создать тип</button>
|
||||
</div>
|
||||
<p class="text-muted section-subtitle">
|
||||
Тип задаёт подписи секторов и концентрических кругов. При создании карты сети выбирается один из типов.
|
||||
</p>
|
||||
|
||||
<div v-if="typesStore.loading" class="spinner" style="margin: 24px auto;"></div>
|
||||
<div v-else-if="!typesStore.types.length" class="empty-state">
|
||||
<p>Типов карт пока нет.</p>
|
||||
</div>
|
||||
<div v-else class="type-list">
|
||||
<div v-for="type in typesStore.types" :key="type.id" class="type-card">
|
||||
<div>
|
||||
<strong>{{ type.name }}</strong>
|
||||
<span v-if="type.isDefault" class="badge">По умолчанию</span>
|
||||
<div class="text-muted type-meta">
|
||||
{{ type.sectors?.length || 0 }} секторов · {{ type.circles?.length || 0 }} кругов
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="openEdit(type)">
|
||||
Редактировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="actionError" class="alert alert-error" style="margin-top: 16px;">{{ actionError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NetworkMapTypeForm
|
||||
:open="formOpen"
|
||||
:initial="formTarget"
|
||||
@close="closeForm"
|
||||
@submit="onFormSubmit"
|
||||
@delete="onFormDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import NetworkMapTypeForm from '../components/NetworkMapTypeForm.vue'
|
||||
import { useNetworkMapTypesStore } from '../stores/networkMapTypes'
|
||||
|
||||
defineOptions({ name: 'Settings' })
|
||||
|
||||
const typesStore = useNetworkMapTypesStore()
|
||||
const formOpen = ref(false)
|
||||
const formTarget = ref({})
|
||||
const actionError = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
typesStore.fetchTypes().catch((e) => {
|
||||
actionError.value = e?.message || String(e)
|
||||
})
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
formTarget.value = {}
|
||||
formOpen.value = true
|
||||
actionError.value = ''
|
||||
}
|
||||
|
||||
function openEdit(type) {
|
||||
formTarget.value = { ...type }
|
||||
formOpen.value = true
|
||||
actionError.value = ''
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
formOpen.value = false
|
||||
formTarget.value = {}
|
||||
}
|
||||
|
||||
async function onFormSubmit(payload) {
|
||||
try {
|
||||
if (formTarget.value?.id) {
|
||||
await typesStore.updateType(formTarget.value.id, payload)
|
||||
} else {
|
||||
await typesStore.createType(payload)
|
||||
}
|
||||
closeForm()
|
||||
} catch (e) {
|
||||
actionError.value = e?.message || String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function onFormDelete() {
|
||||
if (!formTarget.value?.id) return
|
||||
try {
|
||||
await typesStore.deleteType(formTarget.value.id)
|
||||
closeForm()
|
||||
} catch (e) {
|
||||
actionError.value = e?.message || String(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.type-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.type-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
.type-meta {
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-muted, rgba(91, 141, 238, 0.15));
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user