166 lines
5.3 KiB
JavaScript
166 lines
5.3 KiB
JavaScript
/** @odoo-module **/
|
||
import { Component, onMounted, onWillUpdateProps } from "@odoo/owl";
|
||
|
||
export class MindmapRenderer extends Component {
|
||
setup() {
|
||
super.setup();
|
||
}
|
||
|
||
get treeData() {
|
||
return this.props.treeData;
|
||
}
|
||
|
||
onMounted() {
|
||
this._renderTree();
|
||
}
|
||
|
||
onWillUpdateProps() {
|
||
// 等待 owl 完成 DOM patch 后再重绘 d3,避免直接操作 DOM 与 owl 冲突
|
||
Promise.resolve().then(() => this._renderTree());
|
||
}
|
||
|
||
_renderTree() {
|
||
const d3 = window.d3;
|
||
if (!d3) {
|
||
console.error("[Mindmap] D3.js not loaded");
|
||
return;
|
||
}
|
||
const container = this.el?.querySelector?.(".mindmap-renderer-root");
|
||
if (!container || !this.treeData) return;
|
||
|
||
const width = Math.max(container.clientWidth, 900);
|
||
const height = 560;
|
||
|
||
// 清空后重绘
|
||
container.innerHTML = "";
|
||
|
||
const svg = d3
|
||
.select(container)
|
||
.append("svg")
|
||
.attr("width", width)
|
||
.attr("height", height)
|
||
.attr("class", "mindmap-svg");
|
||
|
||
const g = svg.append("g").attr("class", "mindmap-zoom-group");
|
||
|
||
const zoom = d3
|
||
.zoom()
|
||
.scaleExtent([0.15, 4])
|
||
.on("zoom", (event) => {
|
||
g.attr("transform", event.transform);
|
||
});
|
||
svg.call(zoom);
|
||
|
||
const root = d3.hierarchy(this.treeData);
|
||
root.descendants().forEach((d) => {
|
||
d.dataId = d.data.id;
|
||
});
|
||
|
||
const treeLayout = d3.tree().nodeSize([50, 220]);
|
||
treeLayout(root);
|
||
|
||
// 竖直居中
|
||
const x0 = d3.min(root.descendants(), (d) => d.x) || 0;
|
||
const x1 = d3.max(root.descendants(), (d) => d.x) || 0;
|
||
const initialTransform = d3.zoomIdentity.translate(
|
||
80,
|
||
(height - (x1 + x0)) / 2
|
||
);
|
||
svg.call(zoom.transform, initialTransform);
|
||
|
||
// 连接线
|
||
g.selectAll(".mindmap-link")
|
||
.data(root.links())
|
||
.enter()
|
||
.append("path")
|
||
.attr("class", "mindmap-link")
|
||
.attr("fill", "none")
|
||
.attr("stroke", "#90A4AE")
|
||
.attr("stroke-width", 1.5)
|
||
.attr("opacity", 0.6)
|
||
.attr("d", (d) => {
|
||
return `M${d.source.y},${d.source.x}
|
||
C${(d.source.y + d.target.y) / 2},${d.source.x}
|
||
${(d.source.y + d.target.y) / 2},${d.target.x}
|
||
${d.target.y},${d.target.x}`;
|
||
});
|
||
|
||
// 节点
|
||
const nodes = g
|
||
.selectAll(".mindmap-node")
|
||
.data(root.descendants())
|
||
.enter()
|
||
.append("g")
|
||
.attr("class", "mindmap-node")
|
||
.attr("transform", (d) => `translate(${d.y},${d.x})`)
|
||
.style("cursor", "pointer");
|
||
|
||
nodes
|
||
.append("rect")
|
||
.attr("rx", 6)
|
||
.attr("ry", 6)
|
||
.attr("width", 160)
|
||
.attr("height", 36)
|
||
.attr("x", -80)
|
||
.attr("y", -18)
|
||
.attr("fill", (d) => (d.children ? "#1E88E5" : "#43A047"))
|
||
.attr("stroke", (d) => (d.children ? "#1565C0" : "#2E7D32"))
|
||
.attr("stroke-width", 1.5)
|
||
.style("filter", "drop-shadow(1px 2px 3px rgba(0,0,0,0.2))")
|
||
.on("mouseenter", function () {
|
||
d3.select(this)
|
||
.transition()
|
||
.duration(150)
|
||
.attr("stroke-width", 2.5)
|
||
.style("filter", "drop-shadow(2px 4px 6px rgba(0,0,0,0.35))");
|
||
})
|
||
.on("mouseleave", function () {
|
||
d3.select(this)
|
||
.transition()
|
||
.duration(150)
|
||
.attr("stroke-width", 1.5)
|
||
.style("filter", "drop-shadow(1px 2px 3px rgba(0,0,0,0.2))");
|
||
});
|
||
|
||
nodes
|
||
.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dominant-baseline", "central")
|
||
.attr("fill", "#fff")
|
||
.style("font-size", "13px")
|
||
.style("font-weight", "500")
|
||
.style("pointer-events", "none")
|
||
.text((d) => {
|
||
const name = d.data.name || "";
|
||
return name.length > 14 ? name.substring(0, 13) + "…" : name;
|
||
});
|
||
|
||
// 进度环(有进度的叶子任务)
|
||
nodes
|
||
.filter((d) => !d.children && d.data.progress && d.data.progress > 0)
|
||
.append("circle")
|
||
.attr("cx", 66)
|
||
.attr("cy", -14)
|
||
.attr("r", 10)
|
||
.attr("fill", "none")
|
||
.attr("stroke", "#FF9800")
|
||
.attr("stroke-width", 2.5)
|
||
.each(function (d) {
|
||
const r = 10;
|
||
const circ = 2 * Math.PI * r;
|
||
const p = d.data.progress / 100;
|
||
d3.select(this)
|
||
.attr("stroke-dasharray", circ)
|
||
.attr("stroke-dashoffset", circ * (1 - p));
|
||
});
|
||
|
||
// 点击 → 冒泡事件给 Controller
|
||
nodes.on("click", (event, d) => {
|
||
if (d.data.id) {
|
||
this.trigger("node-clicked", { id: d.data.id });
|
||
}
|
||
});
|
||
}
|
||
}
|
||
MindmapRenderer.template = "project_md.MindmapRenderer";
|