import QtQuick // Небольшое полотно графа связей: узлы-файлы (синие) и теги (зелёные), // рёбра — связи; узлы можно перетаскивать, клик по файлу открывает его. Canvas { id: g property var nodes: [] // [{key,name,type}] property var edges: [] // [{from,to}] signal fileClicked(string path) property var pos: ({}) // key -> {x,y} property string dragKey: "" property real zoom: 1.0 property real panX: 0 property real panY: 0 property real lastMouseX: 0 property real lastMouseY: 0 function nodeForKey(key) { for (let i = 0; i < nodes.length; ++i) if (nodes[i].key === key) return nodes[i] return null } function relayout() { pos = {} const n = nodes.length if (n === 0) { requestPaint(); return } const cx = width / 2, cy = height / 2 const rad = Math.min(width, height) / 2 - 30 for (let i = 0; i < n; ++i) { const a = (i / n) * 2 * Math.PI - Math.PI / 2 pos[nodes[i].key] = { x: cx + rad * Math.cos(a), y: cy + rad * Math.sin(a) } } zoom = 1.0; panX = 0; panY = 0 requestPaint() } onNodesChanged: relayout() onWidthChanged: relayout() onHeightChanged: relayout() 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 = "#b9c6d6" 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) ctx.fillStyle = isFile ? (nd.key === g.dragKey ? "#1a6ce0" : "#3f7fd0") : "#3aa06a" ctx.fill() ctx.strokeStyle = "#ffffff" ctx.lineWidth = 1 ctx.stroke() ctx.fillStyle = "#5a6a7d" 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 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 (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 { g.panX += m.x - g.lastMouseX g.panY += m.y - g.lastMouseY } g.lastMouseX = m.x g.lastMouseY = m.y g.requestPaint() } onReleased: (m) => { if (g.dragKey.length === 0) { 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 <= 144) { const nd = g.nodeForKey(key) if (nd && nd.type === "file") g.fileClicked(nd.key) break } } } g.dragKey = "" } onDoubleClicked: { g.zoom = 1.0; g.panX = 0; g.panY = 0; g.relayout() } } }