Improve graph/map UX and move contact editing to dedicated pages.

Add viewport controls, collapsible panels with persisted state, filters modal, and refined context menus; preserve map layout on navigation and let contacts be edited with relations on /contacts/:id instead of list modals.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gitrusprus
2026-07-06 15:15:45 +03:00
co-authored by Cursor
parent 10e7d65a00
commit 986d36ff51
22 changed files with 1638 additions and 623 deletions
+379 -150
View File
@@ -3,36 +3,29 @@
<GraphHeaderPanel
v-show="!chromeCollapsed"
title="Граф связей"
:show-reset="false"
:show-physics-toggle="true"
:physics-enabled="physicsEnabled"
@reset="resetView"
@toggle-physics="togglePhysics"
/>
<div v-show="!chromeCollapsed" class="graph-view-toolbar">
<p class="graph-link-hint text-muted">
Ctrl+клик (+клик) по двум узлам создать связь.
<span v-if="linkSelectionCount === 1">
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
</span>
</p>
<RelationTypeFilters
:relation-types="allRelationTypes"
:active-values="activeFilters"
@toggle="toggleFilter"
/>
<div v-if="graphToolbarActions.length" class="graph-plugin-actions">
<button
v-for="action in graphToolbarActions"
:key="action.id"
type="button"
class="btn btn-secondary btn-sm"
@click="runGraphToolbarAction(action)"
>
{{ action.label }}
>
<template #actions>
<button type="button" class="btn btn-secondary btn-sm" @click="filtersOpen = true">
Фильтры
</button>
</div>
</div>
</template>
</GraphHeaderPanel>
<GraphFiltersModal
:open="filtersOpen"
:relation-types="allRelationTypes"
:active-filters="activeFilters"
:link-selection-count="linkSelectionCount"
:link-selection="linkSelection"
:toolbar-actions="graphToolbarActions"
@close="filtersOpen = false"
@toggle="toggleFilter"
@toolbar-action="runGraphToolbarAction"
/>
<div class="graph-area" ref="graphArea" @contextmenu.prevent="onGraphAreaContextMenu">
<div class="graph-chrome-bar">
@@ -56,26 +49,6 @@
<span>{{ chromeCollapsed ? 'Показать панели' : 'Свернуть панели' }}</span>
</button>
</div>
<button
v-if="!loading && nodes.length > 0"
type="button"
class="graph-fullscreen-btn"
:title="isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'"
@click="toggleFullscreen"
>
<svg v-if="!isFullscreen" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M8 3H5a2 2 0 0 0-2 2v3"/>
<path d="M21 8V5a2 2 0 0 0-2-2h-3"/>
<path d="M3 16v3a2 2 0 0 0 2 2h3"/>
<path d="M16 21h3a2 2 0 0 0 2-2v-3"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 14h6v6"/>
<path d="M20 10h-6V4"/>
<path d="M14 10l7-7"/>
<path d="M3 21l7-7"/>
</svg>
</button>
<div v-if="loading" class="spinner"></div>
<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">
@@ -83,7 +56,41 @@
</svg>
<p>Нет контактов. <RouterLink to="/contacts">Добавьте контакты</RouterLink> или <RouterLink to="/import">импортируйте файл</RouterLink>.</p>
</div>
<div v-else id="graph-container" ref="graphContainer"></div>
<div v-else class="graph-stack" ref="graphStack">
<div class="graph-view-tools">
<button
type="button"
class="graph-fit-btn btn btn-secondary btn-sm"
title="По центру"
@click="fitView"
>
<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>
<button
type="button"
class="graph-fullscreen-btn"
:title="isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'"
@click="toggleFullscreen"
>
<svg v-if="!isFullscreen" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M8 3H5a2 2 0 0 0-2 2v3"/>
<path d="M21 8V5a2 2 0 0 0-2-2h-3"/>
<path d="M3 16v3a2 2 0 0 0 2 2h3"/>
<path d="M16 21h3a2 2 0 0 0 2-2v-3"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 14h6v6"/>
<path d="M20 10h-6V4"/>
<path d="M14 10l7-7"/>
<path d="M3 21l7-7"/>
</svg>
</button>
</div>
<div id="graph-container" ref="graphContainer"></div>
</div>
</div>
<!-- Node detail panel -->
@@ -127,7 +134,6 @@
:x="contextMenuX"
:y="contextMenuY"
@close="closeContextMenu"
@info="openNodeInfo"
/>
<GraphEdgeContextMenu
@@ -149,6 +155,9 @@
<CreateContactModal
:open="createContactOpen"
:show-relation-link="graphContactOptions.length > 0"
:link-to-options="graphContactOptions"
:initial-link-to-id="linkSelection[0]?.id"
@close="createContactOpen = false"
@created="onContactCreated"
/>
@@ -175,19 +184,19 @@
defineOptions({ name: 'Graph' })
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { RouterLink, useRouter, onBeforeRouteLeave } from 'vue-router'
import { Network, DataSet } from 'vis-network/standalone'
import { useContactsStore } from '../stores/contacts'
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
import { RELATION_COLORS } from '../lib/graph/relationColors'
import { clusterColor } from '../lib/graph/clusterColors'
import { computeClusterMap } from '../lib/graph/clusters'
import { computeClusterMap, updateClusterMapForNodes } from '../lib/graph/clusters'
import { computeGraphSeedPositions } from '../lib/graph/graphLayout'
import { readGraphLayoutCache, writeGraphLayoutCache } from '../lib/graph/graphLayoutCache'
import { applyIntensityToVisEdge } from '../lib/graph/relationIntensity'
import { buildGraphFromStore, edgeFromRelation } from '../application/usecases/graph'
import GraphHeaderPanel from '../components/GraphHeaderPanel.vue'
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
import GraphFiltersModal from '../components/GraphFiltersModal.vue'
import CreateRelationModal from '../components/CreateRelationModal.vue'
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
@@ -197,6 +206,7 @@ import EditRelationModal from '../components/EditRelationModal.vue'
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
import { useNetworkMapsStore } from '../stores/networkMaps'
import { getGraphToolbarActions } from '../core/pluginRegistry'
import { loadTopPanelCollapsed, saveTopPanelCollapsed } from '../lib/ui/topPanelCollapseStorage'
let themeObserver = null
let detachContextHandler = null
@@ -221,10 +231,6 @@ const {
const createContactOpen = ref(false)
function openNodeInfo(node) {
selectedNode.value = node || null
}
function openCreateContact() {
closeContextMenu()
createContactOpen.value = true
@@ -235,15 +241,29 @@ function onGraphAreaContextMenu(event) {
openCanvasContextMenu(event)
}
async function onContactCreated(data, mapIds, pluginPayload) {
async function onContactCreated(data, mapIds, pluginPayload, relationLink) {
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)
let relation = null
if (relationLink?.targetId && String(relationLink.targetId) !== String(created.id)) {
relation = await store.createRelation({
source: created.id,
target: relationLink.targetId,
relation_type: relationLink.type,
description: relationLink.description || '',
interaction_intensity: relationLink.intensity,
})
}
createContactOpen.value = false
clearLinkSelection()
await ensureGraphReady({ showSpinner: false })
if (relation) appendRelationEdge(relation)
}
const store = useContactsStore()
@@ -251,10 +271,12 @@ const mapsStore = useNetworkMapsStore()
const router = useRouter()
const graphToolbarActions = getGraphToolbarActions()
const graphArea = ref(null)
const graphStack = ref(null)
const graphContainer = ref(null)
const loading = ref(true)
const isFullscreen = ref(false)
const chromeCollapsed = ref(false)
const filtersOpen = ref(false)
const chromeCollapsed = ref(loadTopPanelCollapsed('graph'))
const network = ref(null)
const physicsEnabled = ref(true)
const selectedNode = ref(null)
@@ -282,6 +304,8 @@ const INIT_RETRY_MAX = 40
let initRetryTimer = null
let resizeObserver = null
let syncedRevision = -1
let graphViewActive = false
let layoutSnapshotOnLeave = null
let initialLayoutDone = false
const nodes = ref([])
@@ -289,11 +313,19 @@ const edges = ref([])
const allRelationTypes = ref([])
const activeFilters = ref([])
const clusterMap = ref(new Map())
let prevLinkSelectionIds = new Set()
const selectedContact = computed(() =>
selectedNode.value ? store.contactById(selectedNode.value.id) : null
)
const graphContactOptions = computed(() =>
store.contacts.map((c) => ({
value: String(c.id),
label: [c.name, c.organization].filter(Boolean).join(' · '),
}))
)
function cssVar(name, fallback) {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return value || fallback
@@ -315,6 +347,89 @@ function recomputeClusters() {
clusterMap.value = computeClusterMap(nodes.value, filteredEdges())
}
function updateClustersLocal(seedNodeIds) {
const map = clusterMap.value
const affected = updateClusterMapForNodes(
map,
nodes.value,
filteredEdges(),
seedNodeIds
)
clusterMap.value = map
return affected
}
function refreshNodeStylesForIds(nodeIds) {
if (!nodesDS || !nodeIds?.size) return
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
const livePositions = network.value?.getPositions() || {}
const idSet = new Set([...nodeIds].map(String))
const targets = nodes.value.filter((n) => idSet.has(String(n.id)))
if (!targets.length) return
nodesDS.update(
targets.map((n) => {
const id = String(n.id)
const vis = mapGraphNodeToVis(n, linkIds)
const pos = livePositions[id]
return pos ? { ...vis, x: pos.x, y: pos.y } : vis
})
)
network.value?.redraw()
}
function refreshNodeStyles() {
refreshNodeStylesForIds(new Set(nodes.value.map((n) => String(n.id))))
}
function withPhysicsPaused(fn) {
if (!network.value) {
fn()
return
}
const savedPositions = network.value.getPositions()
const savedView = {
position: network.value.getViewPosition(),
scale: network.value.getScale(),
}
const wasEnabled = physicsEnabled.value
if (wasEnabled) {
network.value.setOptions({ physics: physicsOptions(false) })
}
try {
fn()
} finally {
if (nodesDS && savedPositions) {
nodesDS.update(
Object.entries(savedPositions).map(([id, pos]) => ({ id, x: pos.x, y: pos.y }))
)
}
if (wasEnabled) {
network.value.setOptions({ physics: physicsOptions(true) })
}
if (savedView.position) {
network.value.moveTo({
position: savedView.position,
scale: savedView.scale || 1,
animation: false,
})
}
saveLayoutSnapshot()
}
}
function applyLocalEdgeChange(seedNodeIds, mutate) {
withPhysicsPaused(() => {
mutate()
const affected = updateClustersLocal(seedNodeIds)
const styleIds = new Set([...seedNodeIds].map(String))
affected.forEach((id) => styleIds.add(id))
refreshNodeStylesForIds(styleIds)
})
}
function nodeDegree(id) {
const sid = String(id)
return filteredEdges().filter(
@@ -363,15 +478,11 @@ function mapGraphNodeToVis(n, linkIds = new Set()) {
}
}
function refreshNodeStyles() {
if (!nodesDS) return
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
nodesDS.update(nodes.value.map((n) => mapGraphNodeToVis(n, linkIds)))
network.value?.redraw()
}
function applyLinkHighlights() {
refreshNodeStyles()
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
const affected = new Set([...linkIds, ...prevLinkSelectionIds])
prevLinkSelectionIds = linkIds
refreshNodeStylesForIds(affected)
}
function mapGraphEdgeToVis(e) {
@@ -443,48 +554,53 @@ function updateGraphEdge(relation) {
function onRelationUpdated(relation) {
closeEditRelation()
updateGraphEdge(relation)
syncedRevision = store.dataRevision
if (network.value && physicsEnabled.value) {
network.value.stabilize(80)
}
const edge = edgeFromRelation(relation)
const seedNodeIds = [edge.from, edge.to]
applyLocalEdgeChange(seedNodeIds, () => {
updateGraphEdge(relation)
})
}
function removeGraphEdge(relationId) {
const sid = String(relationId)
edges.value = edges.value.filter((e) => String(e.id) !== sid)
if (edgesDS?.get(sid)) edgesDS.remove(sid)
recomputeClusters()
refreshNodeStyles()
const removed = edges.value.find((e) => String(e.id) === sid)
const seedNodeIds = removed ? [removed.from, removed.to] : []
applyLocalEdgeChange(seedNodeIds, () => {
edges.value = edges.value.filter((e) => String(e.id) !== sid)
if (edgesDS?.get(sid)) edgesDS.remove(sid)
})
}
function onRelationDeleted(relationId) {
closeEditRelation()
removeGraphEdge(relationId)
syncedRevision = store.dataRevision
removeGraphEdge(relationId)
}
function appendRelationEdge(relation) {
if (!relation) return
const edge = edgeFromRelation(relation)
if (!edges.value.some((e) => String(e.id) === String(edge.id))) {
edges.value.push(edge)
}
if (!edgesDS) return
const seedNodeIds = [edge.from, edge.to]
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
if (
activeFilters.value.length < allRelationTypes.value.length &&
!activeFilters.value.includes(edge.relation_type)
) {
return
}
if (!edgesDS.get(String(edge.id))) {
edgesDS.add(mapGraphEdgeToVis(edge))
}
recomputeClusters()
refreshNodeStyles()
applyLocalEdgeChange(seedNodeIds, () => {
if (!edges.value.some((e) => String(e.id) === String(edge.id))) {
edges.value.push(edge)
}
if (!edgesDS) return
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
if (
activeFilters.value.length < allRelationTypes.value.length &&
!activeFilters.value.includes(edge.relation_type)
) {
return
}
if (!edgesDS.get(String(edge.id))) {
edgesDS.add(mapGraphEdgeToVis(edge))
}
})
}
function closeRelationModal() {
@@ -495,12 +611,12 @@ function closeRelationModal() {
}
function onRelationCreated(relation) {
syncedRevision = store.dataRevision
relationModalOpen.value = false
relationPair.value = null
clearLinkSelection()
applyLinkHighlights()
appendRelationEdge(relation)
syncedRevision = store.dataRevision
}
watch(linkSelection, () => {
@@ -552,6 +668,40 @@ function saveLayoutSnapshot() {
})
}
function captureLayoutSnapshot() {
if (!network.value) return null
const view = network.value.getViewPosition()
return {
positions: { ...network.value.getPositions() },
scale: network.value.getScale(),
view: view ? { x: view.x, y: view.y } : null,
}
}
function applyLayoutSnapshot(snapshot) {
if (!network.value || !nodesDS || !snapshot) return
const updates = Object.entries(snapshot.positions || {})
.filter(([id]) => nodesDS.get(id))
.map(([id, pos]) => ({ id, x: pos.x, y: pos.y }))
if (updates.length) nodesDS.update(updates)
if (snapshot.view) {
network.value.moveTo({
position: snapshot.view,
scale: snapshot.scale || 1,
animation: false,
})
}
writeGraphLayoutCache(snapshot)
}
function resizeNetworkCanvas() {
if (!network.value || !graphContainer.value) return
const { offsetWidth: w, offsetHeight: h } = graphContainer.value
if (w > 10 && h > 10) {
network.value.setSize(`${w}px`, `${h}px`)
}
}
function restoreViewport() {
if (!network.value) return
const cache = readGraphLayoutCache()
@@ -588,12 +738,45 @@ function teardownNetwork() {
edgesDS = null
}
function syncGraphMetadataOnly(lockedPositions) {
if (!network.value || !nodesDS || !edgesDS) return
const positions = lockedPositions || network.value.getPositions()
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
const nextNodeIds = new Set(nodes.value.map((n) => String(n.id)))
nodesDS.getIds().forEach((id) => {
if (!nextNodeIds.has(String(id))) nodesDS.remove(id)
})
nodes.value.forEach((n) => {
const id = String(n.id)
const vis = mapGraphNodeToVis(n, linkIds)
const pos = positions[id]
const payload = pos ? { ...vis, x: pos.x, y: pos.y } : vis
if (nodesDS.get(id)) nodesDS.update(payload)
else nodesDS.add(payload)
})
const nextEdges = filteredEdges().map(mapGraphEdgeToVis)
const nextEdgeIds = new Set(nextEdges.map((e) => String(e.id)))
edgesDS.getIds().forEach((id) => {
if (!nextEdgeIds.has(String(id))) edgesDS.remove(id)
})
nextEdges.forEach((edge) => {
if (edgesDS.get(edge.id)) edgesDS.update(edge)
else edgesDS.add(edge)
})
recomputeClusters()
}
function syncGraphToNetwork() {
if (!network.value || !nodesDS || !edgesDS) return
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
const livePositions = network.value.getPositions()
const cachedPositions = readGraphLayoutCache().positions || {}
const livePositions = network.value.getPositions()
const seeds = computeGraphSeedPositions(nodes.value, filteredEdges())
const nextNodeIds = new Set(nodes.value.map((n) => String(n.id)))
@@ -604,7 +787,7 @@ function syncGraphToNetwork() {
nodes.value.forEach((n) => {
const id = String(n.id)
const vis = mapGraphNodeToVis(n, linkIds)
const pos = livePositions[id] || cachedPositions[id] || seeds.get(id)
const pos = cachedPositions[id] || livePositions[id] || seeds.get(id)
const payload = pos ? { ...vis, x: pos.x, y: pos.y } : vis
if (nodesDS.get(id)) nodesDS.update(payload)
else nodesDS.add(payload)
@@ -623,6 +806,7 @@ function syncGraphToNetwork() {
recomputeClusters()
refreshNodeStyles()
syncedRevision = store.dataRevision
restoreViewport()
}
let ensureGraphReadyInFlight = null
@@ -820,8 +1004,8 @@ function applyThemeToNetwork() {
network.value.redraw()
}
function resetView() {
network.value?.fit({ animation: { duration: 500, easingFunction: 'easeInOutQuad' } })
function fitView() {
network.value?.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' }, padding: 56 })
}
function togglePhysics() {
@@ -830,7 +1014,7 @@ function togglePhysics() {
}
async function toggleFullscreen() {
const el = graphArea.value
const el = graphStack.value
if (!el) return
try {
if (document.fullscreenElement === el) {
@@ -844,7 +1028,7 @@ async function toggleFullscreen() {
}
function onFullscreenChange() {
isFullscreen.value = document.fullscreenElement === graphArea.value
isFullscreen.value = document.fullscreenElement === graphStack.value
nextTick(() => {
network.value?.redraw()
network.value?.fit({ animation: false })
@@ -853,6 +1037,7 @@ function onFullscreenChange() {
function toggleChrome() {
chromeCollapsed.value = !chromeCollapsed.value
saveTopPanelCollapsed('graph', chromeCollapsed.value)
nextTick(() => network.value?.redraw())
}
@@ -861,7 +1046,9 @@ function runGraphToolbarAction(action) {
}
watch(() => store.dataRevision, async (revision) => {
if (!network.value || revision === syncedRevision) return
if (!network.value || !graphViewActive) return
await nextTick()
if (revision === syncedRevision) return
await applyGraphDataFromStore()
if (nodes.value.length === 0) {
teardownNetwork()
@@ -880,20 +1067,58 @@ onMounted(() => {
})
})
onBeforeRouteLeave(() => {
if (network.value && physicsEnabled.value) {
network.value.setOptions({ physics: physicsOptions(false) })
}
layoutSnapshotOnLeave = captureLayoutSnapshot()
saveLayoutSnapshot()
})
onActivated(async () => {
graphViewActive = true
const snapshot = layoutSnapshotOnLeave
layoutSnapshotOnLeave = null
if (network.value) {
network.value.redraw()
network.value.setOptions({ physics: physicsOptions(false) })
await nextTick()
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
resizeNetworkCanvas()
const lockedPositions = snapshot?.positions
|| readGraphLayoutCache().positions
|| {}
if (syncedRevision !== store.dataRevision) {
await ensureGraphReady({ showSpinner: false })
await applyGraphDataFromStore()
syncGraphMetadataOnly(lockedPositions)
syncedRevision = store.dataRevision
}
if (snapshot) {
applyLayoutSnapshot(snapshot)
} else {
restoreViewport()
}
if (physicsEnabled.value) {
network.value.setOptions({ physics: physicsOptions(true) })
}
network.value.redraw()
return
}
await ensureGraphReady()
})
onDeactivated(() => {
graphViewActive = false
if (network.value && physicsEnabled.value) {
network.value.setOptions({ physics: physicsOptions(false) })
}
if (!layoutSnapshotOnLeave) {
layoutSnapshotOnLeave = captureLayoutSnapshot()
}
saveLayoutSnapshot()
})
@@ -901,7 +1126,7 @@ onUnmounted(() => {
saveLayoutSnapshot()
closeContextMenu()
document.removeEventListener('fullscreenchange', onFullscreenChange)
if (document.fullscreenElement === graphArea.value) {
if (document.fullscreenElement === graphStack.value) {
document.exitFullscreen().catch(() => {})
}
themeObserver?.disconnect()
@@ -917,15 +1142,6 @@ onUnmounted(() => {
min-height: 0;
overflow: hidden;
}
.graph-view-toolbar {
flex-shrink: 0;
padding: 0 28px 10px;
}
.graph-plugin-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.graph-chrome-bar {
display: flex;
justify-content: center;
@@ -959,10 +1175,6 @@ onUnmounted(() => {
.graph-view--chrome-collapsed .graph-area {
padding-top: 4px;
}
.graph-link-hint {
font-size: 12px;
margin: 0 0 8px;
}
.graph-area {
position: relative;
flex: 1;
@@ -971,41 +1183,8 @@ onUnmounted(() => {
flex-direction: column;
padding: 0 28px 20px;
}
.graph-area:fullscreen {
padding: 12px;
background: var(--bg);
}
.graph-area:fullscreen #graph-container {
min-height: 0;
}
.graph-fullscreen-btn {
position: absolute;
top: 8px;
right: 36px;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text-muted);
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.graph-fullscreen-btn:hover {
color: var(--text);
border-color: var(--accent);
background: var(--surface-alt);
}
.graph-area:fullscreen .graph-fullscreen-btn {
top: 20px;
right: 20px;
}
#graph-container {
.graph-stack {
position: relative;
flex: 1;
min-height: 300px;
width: 100%;
@@ -1014,4 +1193,54 @@ onUnmounted(() => {
border-radius: var(--radius);
overflow: hidden;
}
.graph-stack:fullscreen {
border-radius: 0;
border: none;
background: var(--bg);
}
.graph-view-tools {
position: absolute;
top: 10px;
right: 10px;
z-index: 3;
display: flex;
align-items: center;
gap: 8px;
}
.graph-fit-btn {
display: inline-flex;
align-items: center;
gap: 6px;
box-shadow: var(--shadow);
}
.graph-fullscreen-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text-muted);
cursor: pointer;
box-shadow: var(--shadow);
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.graph-fullscreen-btn:hover {
color: var(--text);
border-color: var(--accent);
background: var(--surface-alt);
}
.graph-stack:fullscreen .graph-view-tools {
top: 16px;
right: 16px;
}
#graph-container {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
</style>