Files
Flora/qml/panels/GraphView.qml
T
grendervill 9c8215b707 Граф: не перестраивать при ресайзе окна
- onWidth/HeightChanged теперь только clampPositions()+requestPaint, без force-симуляции и без сброса зума/панорамы/позиций
- nodes/edges меняются парой -> дебаунс 120мс (requestRelayout), добавлен onEdgesChanged
- relayout больше не трогает zoom/pan (resetView только по двойному клику), убран Math.random в пользу детерминированного сдвига, guard на нулевой размер
2026-09-06 17:38:02 +03:00

251 lines
9.6 KiB
QML

import QtQuick
import Flora
Canvas {
id: g
property var nodes: []
property var edges: []
signal fileClicked(string path)
property var pos: ({})
property string dragKey: ""
property real zoom: 1.0
property real panX: 0
property real panY: 0
property real lastMouseX: 0
property real lastMouseY: 0
property bool isEmpty: nodes.length === 0
function nodeForKey(key) {
for (let i = 0; i < nodes.length; ++i)
if (nodes[i].key === key) return nodes[i]
return null
}
// Дебаунс перестройки на случай если nodes и edges меняются парой
Timer {
id: layoutDebounce
interval: 120
repeat: false
onTriggered: g.relayout()
}
function requestRelayout() { layoutDebounce.restart() }
// При ресайзе только подрезаем координаты и перерисовываем — без force-симуляции
function clampPositions() {
let changed = false
for (const k in pos) {
const p = pos[k]
const nx = Math.max(30, Math.min(width - 30, p.x))
const ny = Math.max(30, Math.min(height - 30, p.y))
if (nx !== p.x || ny !== p.y) {
pos[k] = { x: nx, y: ny }
changed = true
}
}
// присваивание нужно только если что-то вылезло за границы,
// requestPaint вызываем в любом случае из обработчиков размера
if (changed) pos = Object.assign({}, pos)
}
function relayout() {
const n = nodes.length
if (n === 0) { pos = {}; requestPaint(); return }
// Размер ещё не известен (создание компонента) — откладываем
if (width < 50 || height < 50) { layoutDebounce.restart(); return }
if (n > 600) {
// Слишком много узлов — показываем упрощённо без симуляции чтобы не заморозить UI
const cx = width / 2, cy = height / 2
const rad = Math.min(width, height) / 2 - 36
let newPos = {}
for (let i = 0; i < n; ++i) {
const a = (i / n) * 2 * Math.PI - Math.PI / 2
newPos[nodes[i].key] = { x: cx + rad * Math.cos(a), y: cy + rad * Math.sin(a) }
}
pos = newPos; requestPaint(); return
}
const cx = width / 2, cy = height / 2
const rad = Math.min(width, height) / 2 - 36
let newPos = {}
for (let i = 0; i < n; ++i) {
const a = (i / n) * 2 * Math.PI - Math.PI / 2
// детерминированный сдвиг вместо Math.random — раскладка не прыгает при каждом ребилде
const jitterX = ((i * 37) % 9) - 4
const jitterY = ((i * 53) % 9) - 4
newPos[nodes[i].key] = { x: cx + rad * Math.cos(a) + jitterX, y: cy + rad * Math.sin(a) + jitterY }
}
const iterCount = n > 200 ? 25 : (n > 100 ? 40 : 80)
// простая force-симуляция для разведения узлов (репульсия + притяжение по рёбрам)
for (let iter = 0; iter < iterCount; ++iter) {
// репульсия между всеми парами
for (let i = 0; i < n; ++i) {
for (let j = i + 1; j < n; ++j) {
const a = newPos[nodes[i].key]
const b = newPos[nodes[j].key]
let dx = a.x - b.x, dy = a.y - b.y
let dist = Math.sqrt(dx*dx + dy*dy)
if (dist < 1) dist = 1
if (dist < 70) {
const force = (70 - dist) * 0.08
const nx = dx / dist * force
const ny = dy / dist * force
a.x += nx; a.y += ny
b.x -= nx; b.y -= ny
}
}
}
// притяжение по рёбрам
for (let e = 0; e < edges.length; ++e) {
const f = newPos[edges[e].from]
const t = newPos[edges[e].to]
if (!f || !t) continue
let dx = t.x - f.x, dy = t.y - f.y
let dist = Math.sqrt(dx*dx + dy*dy)
if (dist < 1) continue
const target = 90
const force = (dist - target) * 0.02
const nx = dx / dist * force
const ny = dy / dist * force
f.x += nx; f.y += ny
t.x -= nx; t.y -= ny
}
// удержание в границах
for (let k in newPos) {
const p = newPos[k]
p.x = Math.max(30, Math.min(width - 30, p.x))
p.y = Math.max(30, Math.min(height - 30, p.y))
}
}
pos = newPos
requestPaint()
}
function resetView() { zoom = 1.0; panX = 0; panY = 0; requestPaint() }
onNodesChanged: requestRelayout()
onEdgesChanged: requestRelayout()
// Ресайз окна: только перерисовка, БЕЗ перестройки графа и БЕЗ сброса зума/панорамы
onWidthChanged: { clampPositions(); requestPaint() }
onHeightChanged: { clampPositions(); requestPaint() }
// пустое состояние
Text {
anchors.centerIn: parent
visible: g.isEmpty
text: qsTr("Нет связей\nДобавьте [[ссылки]] или #теги")
color: Theme.textMuted
horizontalAlignment: Text.AlignHCenter
font.pixelSize: 11
lineHeight: 1.3
}
function paintCanvas() {
const ctx = getContext("2d")
ctx.clearRect(0, 0, width, height)
if (nodes.length === 0) return
ctx.save()
ctx.translate(width / 2 + panX, height / 2 + panY)
ctx.scale(zoom, zoom)
ctx.translate(-width / 2, -height / 2)
ctx.lineWidth = 1 / zoom
ctx.strokeStyle = Theme.graphEdge
for (let i = 0; i < edges.length; ++i) {
const f = pos[edges[i].from]
const t = pos[edges[i].to]
if (!f || !t) continue
ctx.beginPath()
ctx.moveTo(f.x, f.y)
ctx.lineTo(t.x, t.y)
ctx.stroke()
}
ctx.textAlign = "center"
for (let i = 0; i < nodes.length; ++i) {
const nd = nodes[i]
const p = pos[nd.key]
if (!p) continue
const isFile = nd.type === "file"
const r = isFile ? 6 : 5
ctx.beginPath()
ctx.arc(p.x, p.y, r, 0, 2 * Math.PI)
if (isFile) {
ctx.fillStyle = (nd.key === g.dragKey) ? Theme.accentHover : Theme.graphNodeFile
} else {
ctx.fillStyle = Theme.graphNodeTag
}
ctx.fill()
ctx.strokeStyle = Theme.bg
ctx.lineWidth = 1.5
ctx.stroke()
ctx.fillStyle = Theme.graphLabel
ctx.font = (9 / zoom) + "px sans-serif"
const txt = nd.name.length > 14 ? nd.name.substring(0, 13) + "…" : nd.name
ctx.fillText(txt, p.x, p.y + r + 10 / zoom)
}
ctx.restore()
}
onPaint: paintCanvas()
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
hoverEnabled: true
cursorShape: g.dragKey.length > 0 ? Qt.ClosedHandCursor : Qt.ArrowCursor
onWheel: (w) => {
const factor = w.angleDelta.y > 0 ? 1.15 : 0.87
g.zoom = Math.max(0.2, Math.min(5.0, g.zoom * factor))
g.requestPaint()
}
onPressed: (m) => {
g.dragKey = ""
g.lastMouseX = m.x
g.lastMouseY = m.y
const gx = (m.x - width / 2 - panX) / zoom + width / 2
const gy = (m.y - height / 2 - panY) / zoom + height / 2
for (const key in g.pos) {
const p = g.pos[key]
const dx = gx - p.x, dy = gy - p.y
if (dx * dx + dy * dy <= 256) { g.dragKey = key; break }
}
}
onPositionChanged: (m) => {
if (!pressed) return
if (g.dragKey.length > 0 && g.pos[g.dragKey]) {
const gx = (m.x - width / 2 - panX) / zoom + width / 2
const gy = (m.y - height / 2 - panY) / zoom + height / 2
g.pos[g.dragKey] = { x: gx, y: gy }
} else if (pressed) {
g.panX += m.x - g.lastMouseX
g.panY += m.y - g.lastMouseY
}
g.lastMouseX = m.x
g.lastMouseY = m.y
g.requestPaint()
}
onReleased: (m) => {
const wasDrag = g.dragKey.length > 0
const gx = (m.x - width / 2 - panX) / zoom + width / 2
const gy = (m.y - height / 2 - panY) / zoom + height / 2
if (!wasDrag) {
for (const key in g.pos) {
const p = g.pos[key]
const dx = gx - p.x, dy = gy - p.y
if (dx * dx + dy * dy <= 144) {
const nd = g.nodeForKey(key)
if (nd && nd.type === "file") g.fileClicked(nd.key)
break
}
}
}
g.dragKey = ""
g.requestPaint()
}
onDoubleClicked: { g.resetView(); g.relayout() }
}
}