| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552 |
- {% extends "layout.html" %}
- {% block title %}生物遗传图谱 - 家谱管理系统{% endblock %}
- {% block extra_css %}
- <style>
- #tree-container {
- width: 100%;
- height: 700px;
- background: white;
- border: 1px solid #e9ecef;
- border-radius: 8px;
- margin-top: 10px;
- box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
- position: relative;
- overflow: auto;
- scroll-behavior: smooth;
- }
-
- #tree-container::-webkit-scrollbar {
- width: 8px;
- height: 8px;
- }
-
- #tree-container::-webkit-scrollbar-track {
- background: #f1f1f1;
- border-radius: 4px;
- }
-
- #tree-container::-webkit-scrollbar-thumb {
- background: #c1c1c1;
- border-radius: 4px;
- }
-
- #tree-container::-webkit-scrollbar-thumb:hover {
- background: #a1a1a1;
- }
- .node rect, .node circle {
- stroke-width: 2px;
- filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1));
- }
- .node text { font: 13px 'Microsoft YaHei', sans-serif; }
- .node .node-name {
- font-size: 13px;
- font-weight: 500;
- fill: #334155;
- stroke: #fff;
- stroke-width: 4px;
- paint-order: stroke;
- stroke-linejoin: round;
- }
-
- /* 样图一致:较粗的细实线、浅灰蓝色,显得专业 */
- .link { fill: none; stroke: #94A3B8; stroke-width: 2px; stroke-linejoin: round; }
-
- /* 关系标签文字,取消白色粗描边,因为已有胶囊形背景 */
- .link-label { font-size: 11px; fill: #64748B; font-weight: 500; }
- .link-label-sibling { fill: #64748B; }
- /* 男女形状与颜色区分:男性方形(蓝),女性圆形(粉红),添加轻微圆角与投影 */
- .node-male rect { stroke: #3B82F6; fill: #EFF6FF; rx: 8px; ry: 8px; }
- .node-female circle { stroke: #EC4899; fill: #FDF2F8; }
-
- /* 未知性别默认 */
- .node-leaf circle, .node-internal circle { stroke: #94A3B8; fill: #F8FAFC; }
- .node-male circle { stroke: none; fill: none; } /* 清除可能的干扰 */
- /* 样图一致:全部细实线 */
- .link-parent-child { stroke: #333; stroke-dasharray: none; }
- .link-spouse { stroke: #333; stroke-dasharray: none; stroke-width: 1.2px; }
- .link-sibling { stroke: #333; stroke-dasharray: none; stroke-width: 1.2px; }
- /* 右键菜单样式 */
- .context-menu {
- position: absolute;
- display: none;
- background: white;
- border: 1px solid #ccc;
- box-shadow: 2px 2px 10px rgba(0,0,0,0.2);
- z-index: 1000;
- border-radius: 4px;
- padding: 5px 0;
- min-width: 120px;
- }
- .context-menu-item {
- padding: 8px 15px;
- cursor: pointer;
- font-size: 14px;
- color: #333;
- }
- .context-menu-item:hover {
- background-color: #f8f9fa;
- color: #0d6efd;
- }
- .context-menu-item i {
- margin-right: 8px;
- }
- </style>
- {% endblock %}
- {% block content %}
- <div class="d-flex justify-content-between align-items-center mb-3">
- <h2><i class="bi bi-diagram-3"></i> 家谱关系树状图</h2>
- <div>
- <a href="{{ url_for('tree_classic') }}" class="btn btn-outline-primary" target="_blank">
- <i class="bi bi-printer"></i> 导出传统吊线图
- </a>
- </div>
- </div>
- <div class="alert alert-light border small py-2">
- <i class="bi bi-info-circle me-1"></i> 提示:图中按生物遗传图谱格式展示。支持拖拽建立关系,右键点击成员可查看、编辑或新增。
- </div>
- <div id="tree-container">
- <!-- 右键菜单 -->
- <div id="contextMenu" class="context-menu">
- <div class="context-menu-item" onclick="menuAction('detail')"><i class="bi bi-eye"></i>查看成员</div>
- <div class="context-menu-item" onclick="menuAction('edit')"><i class="bi bi-pencil"></i>编辑成员</div>
- <div class="context-menu-item" onclick="menuAction('add')"><i class="bi bi-plus-lg"></i>新增成员</div>
- </div>
- </div>
- <!-- 关系选择弹窗 -->
- <div class="modal fade" id="relationModal" tabindex="-1">
- <div class="modal-dialog modal-sm">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title">建立关系</h5>
- <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
- </div>
- <div class="modal-body">
- <p id="relationInfo" class="small mb-3"></p>
- <input type="hidden" id="sourceMid">
- <input type="hidden" id="targetMid">
- <div class="mb-3">
- <label class="form-label small">关系类型</label>
- <select id="relType" class="form-select form-select-sm">
- <option value="1">是其 儿子/女儿</option>
- <option value="10">是其 妻子/丈夫</option>
- <option value="11">是其 兄弟</option>
- <option value="12">是其 姐妹</option>
- </select>
- </div>
- <div class="mb-3">
- <label class="form-label small">子类型</label>
- <select id="subRelType" class="form-select form-select-sm">
- <option value="0">亲生/正妻</option>
- <option value="1">养子/女</option>
- <option value="2">过继</option>
- </select>
- </div>
- </div>
- <div class="modal-footer">
- <button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">取消</button>
- <button type="button" class="btn btn-sm btn-primary" onclick="saveRelation()">保存关系</button>
- </div>
- </div>
- </div>
- </div>
- {% endblock %}
- {% block extra_js %}
- <script src="{{ url_for('static', filename='js/d3.min.js') }}"></script>
- <script>
- (function() {
- if (typeof d3 === 'undefined') {
- var container = document.getElementById('tree-container');
- if (container) container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-danger small">D3.js 未加载,请检查网络或稍后重试。</div>';
- }
- })();
- let currentData = null;
- let dragSource = null;
- let dragTarget = null;
- let selectedMid = null; // 当前选中的成员 ID
- const relationModal = new bootstrap.Modal(document.getElementById('relationModal'));
- const contextMenu = document.getElementById('contextMenu');
- // 隐藏右键菜单
- window.addEventListener('click', () => {
- contextMenu.style.display = 'none';
- });
- // 处理菜单点击
- function menuAction(type) {
- if (!selectedMid && type !== 'add') return;
-
- switch(type) {
- case 'detail':
- window.location.href = `/manager/member_detail/${selectedMid}`;
- break;
- case 'edit':
- window.location.href = `/manager/edit_member/${selectedMid}`;
- break;
- case 'add':
- window.location.href = '/manager/add_member';
- break;
- }
- }
- // 获取数据并渲染
- function loadTree() {
- if (typeof d3 === 'undefined') return;
- fetch('/manager/api/tree_data')
- .then(response => response.json())
- .then(data => {
- currentData = data;
- renderTree(data);
- });
- }
- if (typeof d3 !== 'undefined') loadTree();
- function renderTree(data) {
- const container = document.getElementById('tree-container');
- container.innerHTML = '';
- container.appendChild(contextMenu);
- try {
- const { members, relations } = data;
- if (!members || members.length === 0) {
- container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-muted">暂无成员数据,无法生成关系图。</div>';
- return;
- }
- const nodes = members.map(m => ({ id: m.id, name: m.name, simplified_name: m.simplified_name, sex: m.sex }));
- const hierarchicalLinks = relations.filter(r => r.relation_type === 1 || r.relation_type === 2)
- .map(r => ({ source: r.parent_mid, target: r.child_mid }));
- const spouseLinks = relations.filter(r => r.relation_type === 10);
- const otherLinks = relations.filter(r => r.relation_type >= 11);
- const childIds = new Set(hierarchicalLinks.map(l => l.target));
- const allSpouseIds = new Set(spouseLinks.map(l => l.child_mid));
- const roots = nodes.filter(n => !childIds.has(n.id) && !allSpouseIds.has(n.id));
- function buildHierarchy(nodeId, processedNodes = new Set()) {
- const node = nodes.find(n => n.id === nodeId);
- if (!node || processedNodes.has(nodeId)) return null;
- processedNodes.add(nodeId);
- const children = hierarchicalLinks.filter(l => l.source === nodeId)
- .map(l => buildHierarchy(l.target, processedNodes))
- .filter(c => c !== null);
-
- const spouses = spouseLinks.filter(l => l.parent_mid === nodeId)
- .map(l => {
- const spouseId = l.child_mid;
- if (!childIds.has(spouseId)) {
- const sNode = nodes.find(n => n.id === spouseId);
- if (sNode && !processedNodes.has(spouseId)) {
- processedNodes.add(spouseId);
- return { id: sNode.id, name: sNode.name, simplified_name: sNode.simplified_name, sex: sNode.sex, isSpouseNode: true, children: [] };
- }
- }
- return null;
- })
- .filter(s => s !== null);
- return { id: node.id, name: node.name, simplified_name: node.simplified_name, sex: node.sex, children: spouses.concat(children) };
- }
- let treeData;
- if (roots.length > 1) {
- treeData = { name: "家谱根源", children: roots.map(root => buildHierarchy(root.id)).filter(r => r !== null) };
- } else if (roots.length === 1) {
- treeData = buildHierarchy(roots[0].id);
- } else {
- treeData = buildHierarchy(nodes[0].id);
- }
- const margin = {top: 80, right: 60, bottom: 80, left: 60};
- const containerWidth = document.getElementById('tree-container').offsetWidth;
- let rootNode = d3.hierarchy(treeData, d => (d && d.children) || []);
-
- // 动态调整间距,保证节点绝对不重叠,长辈/同辈/配偶使用固定基础间距
- const nodeWidth = 90; // 基础宽度
- const nodeHeight = 260; // 基础高度,调大以避免上下层重叠
- const treemap = d3.tree().nodeSize([nodeWidth, nodeHeight]).separation((a, b) => {
- return a.parent === b.parent ? 1.4 : 2.0;
- });
-
- let nodesHier = treemap(rootNode);
- // 配偶固定在“下层半级”:纵向放到两代中间;横向围绕本人轻度展开
- const spouseHalfLevelOffsetY = Math.round(nodeHeight * 0.5); // 半级
- const spouseSpreadX = 110; // 多配偶时的横向间距
- nodesHier.descendants().forEach(parent => {
- const spouses = (parent.children || []).filter(c => c.data && c.data.isSpouseNode);
- if (spouses.length === 0) return;
- spouses.forEach((spouse, idx) => {
- const order = idx - (spouses.length - 1) / 2;
- spouse.x = parent.x + order * spouseSpreadX;
- spouse.y = parent.y + spouseHalfLevelOffsetY;
- });
- });
- // 计算边界以动态设置 SVG 宽高,实现自动滚动不挤压
- let x0 = Infinity;
- let x1 = -Infinity;
- let y1 = -Infinity;
- nodesHier.descendants().forEach(d => {
- if (d.x < x0) x0 = d.x;
- if (d.x > x1) x1 = d.x;
- if (d.y > y1) y1 = d.y;
- });
- // 增加更宽的边距以确保左侧不被切断
- const minWidth = containerWidth;
- const calculatedWidth = x1 - x0 + margin.left + margin.right + 400; // 增加额外的宽度缓冲
- const svgWidth = Math.max(minWidth, calculatedWidth);
- const svgHeight = Math.max(600, y1 + margin.top + margin.bottom);
-
- // 修正偏移量计算:确保最小的 x0 节点完全在可视区域内(加上足够的左边距)
- // 这样即便是负的很大,也会被完整平移到正数区域
- const extraLeftMargin = 200; // 增加更多左侧空间
- let offsetX = margin.left - x0 + extraLeftMargin; // 强制将最左侧节点右移确保文字不被截断
-
- // 确保offsetX至少为margin.left,防止内容被左侧菜单遮挡
- offsetX = Math.max(offsetX, margin.left + 50);
- const svg = d3.select("#tree-container").append("svg")
- .attr("width", svgWidth)
- .attr("height", svgHeight)
- .append("g")
- .attr("transform", `translate(${offsetX},${margin.top})`);
- // 节点圆圈半径(连线与节点共用),调大让图谱更清晰大气
- const circleR = 20;
- // 辅助函数:绘制精致的徽章式关系标签
- function addBadge(g, x, y, text) {
- const group = g.append("g").attr("transform", `translate(${x},${y})`);
- group.append("rect")
- .attr("x", -20).attr("y", -10)
- .attr("width", 40).attr("height", 20)
- .attr("rx", 10).attr("ry", 10) // 胶囊形状
- .attr("fill", "#fff")
- .attr("stroke", "#CBD5E1").attr("stroke-width", 1.2);
- group.append("text")
- .attr("class", "link-label")
- .attr("x", 0).attr("y", 0).attr("dy", "0.32em")
- .attr("text-anchor", "middle")
- .text(text);
- }
- // 连线:第二层连线设计(U型配偶线 + 亲子水平线)
- nodesHier.descendants().forEach(node => {
- if (!node.children || node.children.length === 0) return;
- const realChildren = (node.children || []).filter(c => c.data && !c.data.isSpouseNode);
- const spouses = (node.children || []).filter(c => c.data && c.data.isSpouseNode);
- // 配偶连线拐点:位于本人与配偶节点之间,匹配“下层半级”显示
- const hY = node.y + Math.round(spouseHalfLevelOffsetY * 0.55);
- const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
- // 夫妻关系:倒U型连线(主节点向下,然后横向连到配偶上面)
- if (spouses.length > 0) {
- const g = svg.append("g").attr("class", "link-group");
- spouses.forEach((spouse, idx) => {
- // 主节点向下,拐向配偶,再向下连接配偶顶部 (spouse.y - circleR)
- const pathD = `M${num(node.x)},${num(node.y + circleR)}
- L${num(node.x)},${num(hY)}
- L${num(spouse.x)},${num(hY)}
- L${num(spouse.x)},${num(spouse.y - circleR)}`;
- g.append("path").attr("class", "link link-spouse").attr("d", pathD);
-
- if (idx === 0) addBadge(g, (node.x + spouse.x) / 2, hY + 14, "配偶");
- });
- }
- if (realChildren.length === 0) return;
-
- const childrenY = realChildren[0].y;
- const minChildX = d3.min(realChildren, c => c.x);
- const maxChildX = d3.max(realChildren, c => c.x);
-
- // 如果有配偶,子代主线从配偶连线的中间引出,否则从自己直接引出
- const startX = spouses.length > 0 ? (node.x + spouses[0].x) / 2 : node.x;
- const startY = spouses.length > 0 ? hY : node.y + circleR;
-
- // 将子女横线也相应地下移一些,避免和配偶长名字重叠
- const sibsY = childrenY - 60;
- const g = svg.append("g").attr("class", "link-group");
-
- const hLineLeft = Math.min(minChildX, startX);
- const hLineRight = Math.max(maxChildX, startX);
- // 主线:从配偶U型线中点连到子女水平线
- let pathD = `M${num(startX)},${num(startY)} L${num(startX)},${num(sibsY)} M${num(hLineLeft)},${num(sibsY)} L${num(hLineRight)},${num(sibsY)}`;
-
- // 短竖线:连到每个子女顶部
- realChildren.forEach(child => {
- pathD += ` M${num(child.x)},${num(sibsY)} L${num(child.x)},${num(child.y) - circleR}`;
- });
- g.append("path").attr("class", "link link-parent-child").attr("d", pathD);
- // 去除家谱根源到第一层的关系展示;下层亲子关系标记使用徽章式标签放在短竖线上,一目了然
- const isRootToFirst = !node.parent || !(node.data && node.data.id);
- if (!isRootToFirst && node.data) {
- realChildren.forEach(child => {
- const pSex = node.data.sex;
- const cSex = child.data && child.data.sex;
- let label = "亲子";
- if (pSex === 1) label = cSex === 1 ? "父子" : "父女";
- else if (pSex === 2) label = cSex === 1 ? "母子" : "母女";
-
- const childLinkMidY = (sibsY + child.y - circleR) / 2;
- addBadge(g, child.x, childLinkMidY, label);
- });
- }
- });
- // 兄弟/姐妹:上方 U 型连线避免穿透节点
- const idToPos = {};
- nodesHier.descendants().forEach(d => { if (d.data.id) idToPos[d.data.id] = { x: d.x, y: d.y }; });
- otherLinks.forEach(rel => {
- const s = idToPos[rel.parent_mid], t = idToPos[rel.child_mid];
- if (s && t) {
- const x1 = Math.min(s.x, t.x), x2 = Math.max(s.x, t.x);
- const y = s.y - circleR - 25; // 兄弟线在节点上方
- const g = svg.append("g");
- const pathD = `M${x1},${s.y - circleR} L${x1},${y} L${x2},${y} L${x2},${t.y - circleR}`;
- g.append("path").attr("class", "link link-sibling").attr("d", pathD);
- addBadge(g, (x1 + x2) / 2, y, rel.relation_type === 11 ? "兄弟" : "姐妹");
- }
- });
- const node = svg.selectAll(".node")
- .data(nodesHier.descendants())
- .enter().append("g")
- .attr("class", d => {
- let cls = "node" + (d.children ? " node--internal" : " node--leaf");
- if (d.data.sex === 1) cls += " node-male";
- else if (d.data.sex === 2) cls += " node-female";
- return cls;
- })
- .attr("transform", d => `translate(${d.x},${d.y})`)
- .on("contextmenu", function(event, d) {
- if (!d.data.id) return;
- event.preventDefault();
- selectedMid = d.data.id;
- const containerRect = document.getElementById('tree-container').getBoundingClientRect();
- contextMenu.style.display = 'block';
- contextMenu.style.left = (event.clientX - containerRect.left) + 'px';
- contextMenu.style.top = (event.clientY - containerRect.top) + 'px';
- })
- .call(d3.drag()
- .on("start", dragstarted)
- .on("drag", dragged)
- .on("end", dragended));
- // 图形:男性方形,女性圆形,更符合生物遗传图谱
- node.each(function(d) {
- const el = d3.select(this);
- if (d.data.sex === 1) {
- el.append("rect")
- .attr("x", -circleR).attr("y", -circleR)
- .attr("width", circleR * 2).attr("height", circleR * 2)
- .style("cursor", "grab");
- } else {
- el.append("circle")
- .attr("r", circleR)
- .style("cursor", "grab");
- }
- });
-
- // 人名:往下移动更多,避免和长方形/圆形图形或者连线重叠
- const nameOffsetY = circleR + 25; // 增加间距
- const maxNameLen = 12; // 允许稍微长一点的文字
- function fullName(d) {
- if (!d.data) return '';
- return d.data.simplified_name ? `${d.data.name || ''} (${d.data.simplified_name})` : (d.data.name || '');
- }
-
- const nameGroup = node.append("g").attr("class", "node-name-wrap").attr("transform", "translate(0, " + nameOffsetY + ")");
- nameGroup.each(function(d) {
- const g = d3.select(this);
- const full = fullName(d);
- const disp = full.length <= maxNameLen ? full : full.slice(0, maxNameLen) + '…';
-
- // 姓名可能较长,我们这里做个简单的多行拆分显示或者让它有一个白色背景遮挡线
- // 这里为了简单不破坏原有结构,仅使用 text,但可以给一个白色的 stroke 做底或者调整 y 坐标
- const textNode = g.append("text")
- .attr("class", "node-name")
- .attr("x", 0).attr("y", 0)
- .attr("dy", "0.8em") // 使得文字基线往下靠,进一步远离图形
- .attr("text-anchor", "middle")
- .style("pointer-events", "all")
- .style("cursor", "default")
- .text(disp);
-
- if (full.length > maxNameLen) {
- textNode.append("title").text(full);
- }
- });
- function dragstarted(event, d) {
- if (!d.data.id) return;
- d3.select(this).raise().classed("active", true);
- d._currentX = d.x; d._currentY = d.y;
- dragSource = d.data;
- }
- function dragged(event, d) {
- d._currentX += event.dx; d._currentY += event.dy;
- d3.select(this).attr("transform", `translate(${d._currentX},${d._currentY})`);
- }
- function dragended(event, d) {
- d3.select(this).classed("active", false);
- const mouseX = event.sourceEvent.clientX, mouseY = event.sourceEvent.clientY;
- let foundNode = null;
- svg.selectAll(".node").each(function(nodeData) {
- if (!nodeData.data || nodeData.data.id === d.data.id || !nodeData.data.id) return;
- const rect = this.getBoundingClientRect();
- if (mouseX >= rect.left && mouseX <= rect.right && mouseY >= rect.top && mouseY <= rect.bottom) foundNode = nodeData.data;
- });
- if (foundNode && foundNode.id) {
- dragTarget = foundNode;
- document.getElementById('sourceMid').value = dragSource.id;
- document.getElementById('targetMid').value = dragTarget.id;
- document.getElementById('relationInfo').innerHTML = `确认将 <strong>${dragSource.name}</strong> 设定为 <strong>${dragTarget.name}</strong> 的关系人?`;
- relationModal.show();
- }
- setTimeout(() => { renderTree(currentData); }, 100);
- }
- } catch (err) {
- console.error('renderTree error:', err);
- container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-danger small">关系图渲染出错,请刷新重试。<br>' + (err.message || '') + '</div>';
- }
- }
- function saveRelation() {
- const payload = {
- source_mid: document.getElementById('sourceMid').value,
- target_mid: document.getElementById('targetMid').value,
- relation_type: document.getElementById('relType').value,
- sub_relation_type: document.getElementById('subRelType').value
- };
- fetch('/manager/api/save_relation', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload)
- }).then(res => res.json()).then(data => {
- if (data.success) { relationModal.hide(); loadTree(); }
- else { alert('保存失败: ' + data.message); }
- });
- }
- </script>
- {% endblock %}
|