/** @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";