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 } function relayout() { const n = nodes.length if (n === 0) { pos = {}; requestPaint(); 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; zoom = 1.0; panX = 0; panY = 0; 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 // небольшой джиттер чтобы не накладывались ровно const jitter = (Math.random() - 0.5) * 8 newPos[nodes[i].key] = { x: cx + rad * Math.cos(a) + jitter, y: cy + rad * Math.sin(a) + jitter } } 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 zoom = 1.0; panX = 0; panY = 0 requestPaint() } onNodesChanged: relayout() onWidthChanged: relayout() onHeightChanged: relayout() // пустое состояние 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.zoom = 1.0; g.panX = 0; g.panY = 0; g.relayout() } } }