|
|
@@ -0,0 +1,1011 @@
|
|
|
+{% extends "layout.html" %}
|
|
|
+
|
|
|
+{% block title %}世代分层家谱树 - 家谱管理系统{% endblock %}
|
|
|
+
|
|
|
+{% block extra_css %}
|
|
|
+<style>
|
|
|
+/* ── 容器 ── */
|
|
|
+#tree-gen-container {
|
|
|
+ width: 100%;
|
|
|
+ height: calc(100vh - 160px);
|
|
|
+ min-height: 500px;
|
|
|
+ background: #fafbfd;
|
|
|
+ border: 1px solid #e9ecef;
|
|
|
+ border-radius: 8px;
|
|
|
+ position: relative;
|
|
|
+ overflow: hidden;
|
|
|
+ margin-top: 6px;
|
|
|
+ box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.075);
|
|
|
+}
|
|
|
+
|
|
|
+/* ── 左侧世代标签栏 ── */
|
|
|
+#gen-sidebar {
|
|
|
+ position: absolute;
|
|
|
+ left: 0; top: 0;
|
|
|
+ width: 148px;
|
|
|
+ height: 100%;
|
|
|
+ z-index: 12;
|
|
|
+ background: #f1f5f9;
|
|
|
+ border-right: 1.5px solid #cbd5e1;
|
|
|
+ overflow: hidden;
|
|
|
+ /* pointer-events 由子元素各自控制 */
|
|
|
+}
|
|
|
+#gen-sidebar-title {
|
|
|
+ position: absolute;
|
|
|
+ top: 0; left: 0; right: 0;
|
|
|
+ height: 38px;
|
|
|
+ background: #e2e8f0;
|
|
|
+ border-bottom: 1px solid #cbd5e1;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #475569;
|
|
|
+ font-family: 'Microsoft YaHei', sans-serif;
|
|
|
+ z-index: 1;
|
|
|
+ pointer-events: none;
|
|
|
+}
|
|
|
+
|
|
|
+/* 世代标签行(不可交互,仅展示) */
|
|
|
+.gen-label-row {
|
|
|
+ position: absolute;
|
|
|
+ left: 0; right: 0;
|
|
|
+ text-align: center;
|
|
|
+ transform: translateY(-50%);
|
|
|
+ padding: 4px 6px;
|
|
|
+ line-height: 1.5;
|
|
|
+ pointer-events: none;
|
|
|
+ user-select: none;
|
|
|
+}
|
|
|
+.gen-label-num {
|
|
|
+ display: block;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #334155;
|
|
|
+ font-family: 'Microsoft YaHei', sans-serif;
|
|
|
+ letter-spacing: 0.5px;
|
|
|
+}
|
|
|
+.gen-label-lineage {
|
|
|
+ display: block;
|
|
|
+ font-size: 10px;
|
|
|
+ color: #94a3b8;
|
|
|
+ font-family: 'Microsoft YaHei', sans-serif;
|
|
|
+ margin-top: 1px;
|
|
|
+}
|
|
|
+
|
|
|
+/* ── 侧边栏折叠手柄 ── */
|
|
|
+.gen-collapse-handle {
|
|
|
+ position: absolute;
|
|
|
+ left: 0; right: 0;
|
|
|
+ height: 20px;
|
|
|
+ transform: translateY(-50%);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ pointer-events: auto;
|
|
|
+ cursor: pointer;
|
|
|
+ opacity: 0;
|
|
|
+ transition: opacity 0.2s;
|
|
|
+ z-index: 3;
|
|
|
+}
|
|
|
+#gen-sidebar:hover .gen-collapse-handle { opacity: 1; }
|
|
|
+.gen-collapse-handle:hover { opacity: 1 !important; }
|
|
|
+.gen-collapse-handle .h-line {
|
|
|
+ position: absolute;
|
|
|
+ left: 8px; right: 8px;
|
|
|
+ height: 1.5px;
|
|
|
+ background: #fbbf24;
|
|
|
+ border-radius: 1px;
|
|
|
+}
|
|
|
+.gen-collapse-handle .h-btn {
|
|
|
+ position: absolute;
|
|
|
+ right: 6px;
|
|
|
+ width: 16px; height: 16px;
|
|
|
+ background: #fbbf24;
|
|
|
+ border-radius: 50%;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-size: 9px;
|
|
|
+ color: white;
|
|
|
+ font-weight: 900;
|
|
|
+ line-height: 1;
|
|
|
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
|
|
+}
|
|
|
+.gen-collapse-handle:hover .h-line { background: #f59e0b; height: 2px; }
|
|
|
+.gen-collapse-handle:hover .h-btn { background: #f59e0b; transform: scale(1.15); }
|
|
|
+/* 第一次点击后高亮 */
|
|
|
+.gen-collapse-handle.first-selected .h-line { background: #ef4444; height: 2px; }
|
|
|
+.gen-collapse-handle.first-selected .h-btn { background: #ef4444; }
|
|
|
+
|
|
|
+/* ── 已折叠世代行 ── */
|
|
|
+.gen-collapsed-row {
|
|
|
+ position: absolute;
|
|
|
+ left: 4px; right: 4px;
|
|
|
+ transform: translateY(-50%);
|
|
|
+ background: #fffbeb;
|
|
|
+ border: 1.5px dashed #fbbf24;
|
|
|
+ border-radius: 5px;
|
|
|
+ padding: 4px 4px;
|
|
|
+ text-align: center;
|
|
|
+ pointer-events: auto;
|
|
|
+ cursor: pointer;
|
|
|
+ font-family: 'Microsoft YaHei', sans-serif;
|
|
|
+}
|
|
|
+.gen-collapsed-row:hover { background: #fef3c7; border-color: #f59e0b; }
|
|
|
+.gen-collapsed-row .cr-title {
|
|
|
+ display: block;
|
|
|
+ font-size: 11px;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #92400e;
|
|
|
+ line-height: 1.4;
|
|
|
+}
|
|
|
+.gen-collapsed-row .cr-hint {
|
|
|
+ display: block;
|
|
|
+ font-size: 9px;
|
|
|
+ color: #b45309;
|
|
|
+ margin-top: 1px;
|
|
|
+}
|
|
|
+
|
|
|
+/* ── SVG 节点 ── */
|
|
|
+.gen-node rect, .gen-node circle {
|
|
|
+ stroke-width: 2px;
|
|
|
+ filter: drop-shadow(0 2px 3px rgba(0,0,0,0.12));
|
|
|
+}
|
|
|
+.node-male rect { stroke: #3B82F6; fill: #EFF6FF; }
|
|
|
+.node-female circle { stroke: #EC4899; fill: #FDF2F8; }
|
|
|
+.node-unknown circle, .node-unknown rect { stroke: #94A3B8; fill: #F8FAFC; }
|
|
|
+.node-name-text {
|
|
|
+ font-family: 'Microsoft YaHei', sans-serif;
|
|
|
+ font-size: 12px;
|
|
|
+ fill: #334155;
|
|
|
+ stroke: white;
|
|
|
+ stroke-width: 3.5px;
|
|
|
+ paint-order: stroke;
|
|
|
+ stroke-linejoin: round;
|
|
|
+ pointer-events: none;
|
|
|
+}
|
|
|
+/* 高亮 */
|
|
|
+.node-highlight rect { stroke: #ef4444 !important; stroke-width: 3px !important; }
|
|
|
+.node-highlight circle { stroke: #ef4444 !important; stroke-width: 3px !important; }
|
|
|
+/* 入继节点:虚线边框 */
|
|
|
+.node-adopted-in rect { stroke-dasharray: 5,3; stroke: #f59e0b !important; }
|
|
|
+.node-adopted-in circle { stroke-dasharray: 5,3; stroke: #f59e0b !important; }
|
|
|
+.node-adopt-label {
|
|
|
+ font-family: 'Microsoft YaHei', sans-serif;
|
|
|
+ font-size: 9px;
|
|
|
+ fill: #f59e0b;
|
|
|
+ stroke: white;
|
|
|
+ stroke-width: 2px;
|
|
|
+ paint-order: stroke;
|
|
|
+ pointer-events: none;
|
|
|
+}
|
|
|
+
|
|
|
+/* ── 连线 ── */
|
|
|
+.link-parent { fill: none; stroke: #94a3b8; stroke-width: 1.6px; stroke-linejoin: round; }
|
|
|
+.link-spouse { fill: none; stroke: #f59e0b; stroke-width: 1.4px; stroke-dasharray: 5,3; }
|
|
|
+/* 折叠穿透虚线 */
|
|
|
+.link-collapse { fill: none; stroke: #f59e0b; stroke-width: 2px; stroke-dasharray: 8,4; }
|
|
|
+
|
|
|
+/* ── 折叠区间背景 ── */
|
|
|
+.collapse-stripe { fill: #fef3c7; opacity: 0.55; }
|
|
|
+
|
|
|
+/* ── 缩放控件 ── */
|
|
|
+.zoom-controls {
|
|
|
+ position: absolute;
|
|
|
+ top: 10px; right: 10px;
|
|
|
+ z-index: 100;
|
|
|
+ background: white;
|
|
|
+ border-radius: 6px;
|
|
|
+ box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
|
|
+ padding: 4px;
|
|
|
+}
|
|
|
+.zoom-btn {
|
|
|
+ display: flex;
|
|
|
+ width: 28px; height: 28px;
|
|
|
+ margin: 3px 0;
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ border-radius: 4px;
|
|
|
+ background: white; cursor: pointer;
|
|
|
+ align-items: center; justify-content: center;
|
|
|
+ font-size: 14px; color: #475569;
|
|
|
+}
|
|
|
+.zoom-btn:hover { background: #f1f5f9; color: #0f172a; }
|
|
|
+
|
|
|
+/* ── 右键菜单 ── */
|
|
|
+.context-menu {
|
|
|
+ position: absolute;
|
|
|
+ display: none;
|
|
|
+ background: white;
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
|
|
+ z-index: 200;
|
|
|
+ border-radius: 6px;
|
|
|
+ padding: 4px 0;
|
|
|
+ min-width: 130px;
|
|
|
+}
|
|
|
+.context-menu-item {
|
|
|
+ padding: 8px 14px;
|
|
|
+ cursor: pointer;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #334155;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+.context-menu-item:hover { background: #f1f5f9; color: #0d6efd; }
|
|
|
+
|
|
|
+/* ── 操作提示浮层 ── */
|
|
|
+#collapse-hint-float {
|
|
|
+ position: absolute;
|
|
|
+ bottom: 12px; left: 50%;
|
|
|
+ transform: translateX(-50%);
|
|
|
+ background: rgba(245,158,11,0.92);
|
|
|
+ color: white;
|
|
|
+ padding: 6px 14px;
|
|
|
+ border-radius: 20px;
|
|
|
+ font-size: 12px;
|
|
|
+ font-family: 'Microsoft YaHei', sans-serif;
|
|
|
+ z-index: 300;
|
|
|
+ pointer-events: none;
|
|
|
+ display: none;
|
|
|
+ white-space: nowrap;
|
|
|
+ box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
|
|
+}
|
|
|
+</style>
|
|
|
+{% endblock %}
|
|
|
+
|
|
|
+{% block content %}
|
|
|
+<div class="d-flex justify-content-between align-items-center mb-2">
|
|
|
+ <h2><i class="bi bi-layout-three-columns me-2"></i>世代分层家谱树</h2>
|
|
|
+ <div class="d-flex gap-2">
|
|
|
+ <div class="input-group" style="width:260px">
|
|
|
+ <input id="genSearch" type="text" class="form-control form-control-sm" placeholder="输入成员名搜索定位">
|
|
|
+ <button class="btn btn-sm btn-primary" onclick="searchMember()"><i class="bi bi-search"></i></button>
|
|
|
+ </div>
|
|
|
+ <a href="{{ url_for('tree') }}" class="btn btn-outline-secondary btn-sm">
|
|
|
+ <i class="bi bi-diagram-3 me-1"></i>标准树状图
|
|
|
+ </a>
|
|
|
+ <a href="{{ url_for('tree_classic') }}" class="btn btn-outline-secondary btn-sm">
|
|
|
+ <i class="bi bi-printer me-1"></i>传统吊线图
|
|
|
+ </a>
|
|
|
+ </div>
|
|
|
+</div>
|
|
|
+
|
|
|
+<!-- ── 向上收紧工具栏 ── -->
|
|
|
+<div class="d-flex align-items-center gap-2 mb-2 p-2 rounded border bg-warning bg-opacity-10">
|
|
|
+ <i class="bi bi-arrows-collapse text-warning"></i>
|
|
|
+ <span class="small fw-semibold text-warning-emphasis">向上收紧:</span>
|
|
|
+ <span class="small text-secondary">折叠第</span>
|
|
|
+ <input id="collapseFrom" type="number" class="form-control form-control-sm text-center"
|
|
|
+ style="width:62px" min="1" placeholder="起">
|
|
|
+ <span class="small text-secondary">至</span>
|
|
|
+ <input id="collapseTo" type="number" class="form-control form-control-sm text-center"
|
|
|
+ style="width:62px" min="1" placeholder="止">
|
|
|
+ <span class="small text-secondary">世(区间)</span>
|
|
|
+ <button class="btn btn-sm btn-warning" onclick="applyCollapseFromInput()">收起</button>
|
|
|
+ <button class="btn btn-sm btn-outline-secondary" onclick="clearAllCollapse()">展开全部</button>
|
|
|
+ <span class="small text-muted ms-1">或在左侧标签栏点击两次
|
|
|
+ <span style="display:inline-block;width:14px;height:14px;background:#fbbf24;border-radius:50%;vertical-align:middle;font-size:9px;line-height:14px;text-align:center;color:#fff;font-weight:900">─</span>
|
|
|
+ 手柄折叠
|
|
|
+ </span>
|
|
|
+</div>
|
|
|
+
|
|
|
+<div class="alert alert-light border small py-1 mb-2">
|
|
|
+ <i class="bi bi-info-circle me-1"></i>
|
|
|
+ 每行 = 同一世代。<strong>向上收紧</strong>:将中间世代压缩为虚线,保留两端节点。
|
|
|
+ 男性方形<span style="display:inline-block;width:12px;height:12px;background:#EFF6FF;border:2px solid #3B82F6;border-radius:2px;vertical-align:middle;margin:0 3px"></span>,
|
|
|
+ 女性圆形<span style="display:inline-block;width:12px;height:12px;background:#FDF2F8;border:2px solid #EC4899;border-radius:50%;vertical-align:middle;margin:0 3px"></span>,
|
|
|
+ 橙色虚线=折叠穿透。右键可查看/编辑。
|
|
|
+</div>
|
|
|
+
|
|
|
+<div id="tree-gen-container">
|
|
|
+ <!-- 左侧世代标签栏 -->
|
|
|
+ <div id="gen-sidebar">
|
|
|
+ <div id="gen-sidebar-title">世代</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 缩放控件 -->
|
|
|
+ <div class="zoom-controls">
|
|
|
+ <button class="zoom-btn" onclick="zoomIn()" title="放大"><i class="bi bi-plus"></i></button>
|
|
|
+ <button class="zoom-btn" onclick="zoomOut()" title="缩小"><i class="bi bi-dash"></i></button>
|
|
|
+ <button class="zoom-btn" onclick="zoomReset()" title="重置"><i class="bi bi-arrow-counterclockwise"></i></button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右键菜单 -->
|
|
|
+ <div id="contextMenuGen" 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 id="collapse-hint-float"></div>
|
|
|
+
|
|
|
+ <!-- 加载提示 -->
|
|
|
+ <div id="gen-loading" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:#94a3b8;z-index:5">
|
|
|
+ <div class="spinner-border spinner-border-sm mb-2" role="status"></div>
|
|
|
+ <div style="font-size:13px">加载中...</div>
|
|
|
+ </div>
|
|
|
+</div>
|
|
|
+{% endblock %}
|
|
|
+
|
|
|
+{% block extra_js %}
|
|
|
+<script src="{{ url_for('static', filename='js/d3.min.js') }}"></script>
|
|
|
+<script>
|
|
|
+if (typeof d3 === 'undefined') {
|
|
|
+ var _ds = document.createElement('script');
|
|
|
+ _ds.src = 'https://cdn.jsdelivr.net/npm/d3@7.8.5/dist/d3.min.js';
|
|
|
+ _ds.onload = startInit;
|
|
|
+ _ds.onerror = () => {
|
|
|
+ document.getElementById('gen-loading').innerHTML =
|
|
|
+ '<div class="text-danger small">D3.js 未加载,请检查网络。</div>';
|
|
|
+ };
|
|
|
+ document.head.appendChild(_ds);
|
|
|
+} else {
|
|
|
+ startInit();
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+// 配置常量
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+const CFG = {
|
|
|
+ SIDEBAR_W: 150, // 侧边栏宽度
|
|
|
+ ROW_H: 160, // 正常行高
|
|
|
+ COLLAPSED_H: 80, // 折叠区间虚拟行高
|
|
|
+ NODE_SPC: 100, // 水平节点间距
|
|
|
+ NODE_R: 22, // 节点半径
|
|
|
+ TOP_PAD: 60, // 顶部留白
|
|
|
+ LEFT_PAD: 30, // 侧边栏右侧额外留白
|
|
|
+ MAX_NAME: 6, // 节点显示最大字符数
|
|
|
+};
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+// 全局状态
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+let _rawData = null;
|
|
|
+let _mMap = null;
|
|
|
+let _minGen = 1;
|
|
|
+let _maxGen = 1;
|
|
|
+let _lineageRef = null;
|
|
|
+let _zoomBhv = null;
|
|
|
+let _svgEl = null;
|
|
|
+let _mainG = null;
|
|
|
+let _selMid = null;
|
|
|
+let _collapsedRanges = []; // [{start, end}, ...] 当前所有折叠区间
|
|
|
+let _collapseFirst = null; // 侧边栏手柄:第一次点击的 betweenGen 值
|
|
|
+
|
|
|
+const _ctxMenu = document.getElementById('contextMenuGen');
|
|
|
+window.addEventListener('click', () => { _ctxMenu.style.display = 'none'; });
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+// 中文数字工具
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+const _CN_D = ['零','一','二','三','四','五','六','七','八','九'];
|
|
|
+const _CN_U = ['','十','百','千','万'];
|
|
|
+
|
|
|
+function numToCN(n) {
|
|
|
+ if (n <= 0) return '零';
|
|
|
+ if (n <= 10) return n === 10 ? '十' : _CN_D[n];
|
|
|
+ if (n < 20) return '十' + (n % 10 === 0 ? '' : _CN_D[n % 10]);
|
|
|
+ let r = '', s = String(n);
|
|
|
+ for (let i = 0; i < s.length; i++) {
|
|
|
+ const d = +s[i], u = s.length - 1 - i;
|
|
|
+ if (d === 0) { if (r && !r.endsWith('零') && i < s.length-1) r += '零'; }
|
|
|
+ else r += _CN_D[d] + _CN_U[u];
|
|
|
+ }
|
|
|
+ return r.replace(/零+$/, '');
|
|
|
+}
|
|
|
+function cnToNum(s) {
|
|
|
+ if (!s) return NaN;
|
|
|
+ const dm = {'零':0,'一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9};
|
|
|
+ const um = {'十':10,'百':100,'千':1000,'万':10000};
|
|
|
+ let r = 0, t = 0;
|
|
|
+ for (const ch of s.trim()) {
|
|
|
+ if (dm[ch] !== undefined) t = dm[ch];
|
|
|
+ else if (um[ch] !== undefined) { if (!t && um[ch]===10) t=1; r += t*um[ch]; t=0; }
|
|
|
+ }
|
|
|
+ return (r+t) || NaN;
|
|
|
+}
|
|
|
+function extractGen(str) {
|
|
|
+ if (!str) return null;
|
|
|
+ const m = String(str).match(/\d+/);
|
|
|
+ return m ? parseInt(m[0]) : null;
|
|
|
+}
|
|
|
+function parseLineage(str) {
|
|
|
+ if (!str) return [];
|
|
|
+ return str.split(';').filter(s => s.trim()).map(item => {
|
|
|
+ item = item.trim();
|
|
|
+ const m = item.match(/^(.+?)第(.+?)代$/);
|
|
|
+ if (m) return { place: m[1], num: cnToNum(m[2]) };
|
|
|
+ return { place: '', num: NaN };
|
|
|
+ });
|
|
|
+}
|
|
|
+function lineageLbl(refLineage, refGen, targetGen) {
|
|
|
+ if (!refLineage || !refLineage.length) return [];
|
|
|
+ const diff = targetGen - refGen;
|
|
|
+ return refLineage.map(lg => {
|
|
|
+ const n = lg.num + diff;
|
|
|
+ if (isNaN(n) || n <= 0) return null;
|
|
|
+ return (lg.place ? lg.place + '第' : '第') + numToCN(n) + '代';
|
|
|
+ }).filter(Boolean);
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+// 折叠区间工具函数
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+/** 计算 gen 在当前折叠状态下的 Y 坐标(内容坐标系) */
|
|
|
+function computeY(gen) {
|
|
|
+ let y = CFG.TOP_PAD;
|
|
|
+ let g = _minGen;
|
|
|
+ while (g < gen) {
|
|
|
+ const cr = _collapsedRanges.find(r => r.start <= g && g <= r.end);
|
|
|
+ if (cr) {
|
|
|
+ y += CFG.COLLAPSED_H; // 整个折叠区间只占 COLLAPSED_H
|
|
|
+ g = cr.end + 1; // 跳过区间
|
|
|
+ } else {
|
|
|
+ y += CFG.ROW_H;
|
|
|
+ g++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return y;
|
|
|
+}
|
|
|
+
|
|
|
+/** gen 是否在某个折叠区间内 */
|
|
|
+function isCollapsedGen(gen) {
|
|
|
+ return _collapsedRanges.some(r => r.start <= gen && gen <= r.end);
|
|
|
+}
|
|
|
+
|
|
|
+/** 获取包含 gen 的折叠区间(没有则返回 null) */
|
|
|
+function getCollapseRange(gen) {
|
|
|
+ return _collapsedRanges.find(r => r.start <= gen && gen <= r.end) || null;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * BFS:从 entryNode 的孩子出发,穿越折叠区间 cr,
|
|
|
+ * 找到所有 gen > cr.end 的"出口节点"
|
|
|
+ */
|
|
|
+function findExitNodes(entryNode, cr) {
|
|
|
+ const exits = [];
|
|
|
+ const visited = new Set([entryNode.id]);
|
|
|
+ const queue = entryNode.children.filter(c => c.gen >= cr.start && c.gen <= cr.end);
|
|
|
+ while (queue.length) {
|
|
|
+ const cur = queue.shift();
|
|
|
+ if (visited.has(cur.id)) continue;
|
|
|
+ visited.add(cur.id);
|
|
|
+ if (cur.gen > cr.end) {
|
|
|
+ if (!exits.find(e => e.id === cur.id)) exits.push(cur);
|
|
|
+ } else {
|
|
|
+ cur.children.forEach(c => { if (!visited.has(c.id)) queue.push(c); });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return exits;
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+// 折叠操作
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+function applyCollapse(start, end) {
|
|
|
+ if (start >= end) { alert('折叠区间无效,起始世代须小于结束世代'); return; }
|
|
|
+ // 移除重叠的旧区间
|
|
|
+ _collapsedRanges = _collapsedRanges.filter(r => r.end < start || r.start > end);
|
|
|
+ _collapsedRanges.push({ start, end });
|
|
|
+ renderGenTree(_rawData);
|
|
|
+}
|
|
|
+
|
|
|
+function expandRange(start, end) {
|
|
|
+ _collapsedRanges = _collapsedRanges.filter(r => !(r.start === start && r.end === end));
|
|
|
+ renderGenTree(_rawData);
|
|
|
+}
|
|
|
+
|
|
|
+function applyCollapseFromInput() {
|
|
|
+ const from = parseInt(document.getElementById('collapseFrom').value);
|
|
|
+ const to = parseInt(document.getElementById('collapseTo').value);
|
|
|
+ if (isNaN(from) || isNaN(to)) { alert('请输入有效的起始和结束世代数字'); return; }
|
|
|
+ applyCollapse(Math.min(from, to), Math.max(from, to));
|
|
|
+}
|
|
|
+
|
|
|
+function clearAllCollapse() {
|
|
|
+ _collapsedRanges = [];
|
|
|
+ renderGenTree(_rawData);
|
|
|
+}
|
|
|
+
|
|
|
+// ── 侧边栏手柄点击逻辑 ──
|
|
|
+function handleCollapseClick(betweenGen, el) {
|
|
|
+ if (_collapseFirst === null) {
|
|
|
+ // 第一次点击
|
|
|
+ _collapseFirst = betweenGen;
|
|
|
+ document.querySelectorAll('.gen-collapse-handle').forEach(h => h.classList.remove('first-selected'));
|
|
|
+ el.classList.add('first-selected');
|
|
|
+ _showHint('✓ 已标记折叠起点(第' + numToCN(betweenGen) + '/'+ numToCN(betweenGen+1) +'世之间),再点一个位置完成收紧');
|
|
|
+ } else {
|
|
|
+ // 第二次点击
|
|
|
+ const first = _collapseFirst;
|
|
|
+ const second = betweenGen;
|
|
|
+ _collapseFirst = null;
|
|
|
+ document.querySelectorAll('.gen-collapse-handle').forEach(h => h.classList.remove('first-selected'));
|
|
|
+ _hideHint();
|
|
|
+
|
|
|
+ const lo = Math.min(first, second) + 1;
|
|
|
+ const hi = Math.max(first, second);
|
|
|
+ if (lo > hi) { alert('请选择间距至少 1 代的两个位置'); return; }
|
|
|
+ applyCollapse(lo, hi);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function _showHint(msg) {
|
|
|
+ const el = document.getElementById('collapse-hint-float');
|
|
|
+ el.textContent = msg;
|
|
|
+ el.style.display = 'block';
|
|
|
+}
|
|
|
+function _hideHint() {
|
|
|
+ document.getElementById('collapse-hint-float').style.display = 'none';
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+// 入口
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+function startInit() {
|
|
|
+ fetch('/manager/api/tree_data')
|
|
|
+ .then(r => r.json())
|
|
|
+ .then(data => {
|
|
|
+ _rawData = data;
|
|
|
+ document.getElementById('gen-loading').style.display = 'none';
|
|
|
+ renderGenTree(data);
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ document.getElementById('gen-loading').innerHTML =
|
|
|
+ '<div class="text-danger small">加载失败,请刷新重试。</div>';
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+// 主渲染函数
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+function renderGenTree(data) {
|
|
|
+ const container = document.getElementById('tree-gen-container');
|
|
|
+ d3.select('#tree-gen-container svg').remove();
|
|
|
+ document.querySelectorAll('#gen-sidebar .gen-label-row, #gen-sidebar .gen-collapse-handle, #gen-sidebar .gen-collapsed-row').forEach(el => el.remove());
|
|
|
+
|
|
|
+ const { members, relations } = data;
|
|
|
+ if (!members || !members.length) {
|
|
|
+ container.insertAdjacentHTML('beforeend',
|
|
|
+ '<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#94a3b8">暂无成员数据。</div>');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 1. 构建成员 Map ──
|
|
|
+ const mMap = {};
|
|
|
+ members.forEach(m => {
|
|
|
+ mMap[m.id] = {
|
|
|
+ id: m.id, name: m.name || '', sname: m.simplified_name || '',
|
|
|
+ sex: m.sex, family_rank: m.family_rank, nwg: m.name_word_generation,
|
|
|
+ gen: extractGen(m.family_rank),
|
|
|
+ children: [], spouses: [], _pCount: 0, _spouseOf: null,
|
|
|
+ x: 0, y: 0,
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ // ── 2. 处理关系 ──
|
|
|
+ const pcRels = relations.filter(r => r.relation_type === 1 || r.relation_type === 2);
|
|
|
+ const spRels = relations.filter(r => r.relation_type === 10);
|
|
|
+ const hasFather = new Set(relations.filter(r => r.relation_type === 1).map(r => r.child_mid));
|
|
|
+ // 找出有"入继"养父母的子女 ID 集合(这些人应显示在养父母名下,不显示在生父母名下)
|
|
|
+ const hasAdoptiveParent = new Set(
|
|
|
+ pcRels.filter(r => r.sub_relation_type === 3).map(r => r.child_mid)
|
|
|
+ );
|
|
|
+
|
|
|
+ pcRels.forEach(r => {
|
|
|
+ if (r.relation_type === 2 && hasFather.has(r.child_mid)) return;
|
|
|
+ // 出继子女(生父母侧,sub_relation_type=2)且已有养父母记录 → 跳过,不显示在生父母名下
|
|
|
+ if (r.sub_relation_type === 2 && hasAdoptiveParent.has(r.child_mid)) return;
|
|
|
+ const p = mMap[r.parent_mid], c = mMap[r.child_mid];
|
|
|
+ if (!p || !c) return;
|
|
|
+ if (!p.children.find(x => x.id === c.id)) p.children.push(c);
|
|
|
+ c._pCount++;
|
|
|
+ // 标记入继节点(养父母侧)
|
|
|
+ if (r.sub_relation_type === 3) c._isAdoptedIn = true;
|
|
|
+ });
|
|
|
+ // 配偶关系不再在树中展示(spRels 不处理)
|
|
|
+
|
|
|
+ // ── 3. 找根节点 ──
|
|
|
+ let roots = Object.values(mMap).filter(m => !m._pCount);
|
|
|
+ if (!roots.length) roots = [Object.values(mMap)[0]];
|
|
|
+
|
|
|
+ // ── 4. 填充缺失世代 ──
|
|
|
+ const gVis = new Set();
|
|
|
+ function fillGen(node, pg) {
|
|
|
+ if (gVis.has(node.id)) return;
|
|
|
+ gVis.add(node.id);
|
|
|
+ if (node.gen == null || isNaN(node.gen)) node.gen = pg;
|
|
|
+ node.children.forEach(c => fillGen(c, node.gen + 1));
|
|
|
+ }
|
|
|
+ roots.sort((a, b) => ((a.gen || 999) - (b.gen || 999)));
|
|
|
+ roots.forEach(r => fillGen(r, r.gen || 1));
|
|
|
+
|
|
|
+ // ── 5. 后序遍历分配 X 坐标 ──
|
|
|
+ const xVis = new Set();
|
|
|
+ let xCursor = 0;
|
|
|
+ function assignX(node) {
|
|
|
+ if (xVis.has(node.id)) return;
|
|
|
+ xVis.add(node.id);
|
|
|
+ if (!node.children.length) {
|
|
|
+ node.x = xCursor + CFG.NODE_SPC / 2;
|
|
|
+ xCursor += CFG.NODE_SPC;
|
|
|
+ } else {
|
|
|
+ node.children.forEach(c => assignX(c));
|
|
|
+ const xs = node.children.map(c => c.x);
|
|
|
+ node.x = (Math.min(...xs) + Math.max(...xs)) / 2;
|
|
|
+ const needed = Math.max(...xs) + CFG.NODE_SPC * 0.5;
|
|
|
+ if (needed > xCursor) xCursor = needed;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ roots.forEach(r => { assignX(r); xCursor += CFG.NODE_SPC * 1.5; });
|
|
|
+
|
|
|
+ // ── 6. 依世代计算 Y 坐标(考虑折叠区间) ──
|
|
|
+ const allGens = Object.values(mMap).map(m => m.gen).filter(g => g != null && !isNaN(g));
|
|
|
+ const minGen = allGens.length ? Math.min(...allGens) : 1;
|
|
|
+ const maxGen = allGens.length ? Math.max(...allGens) : 1;
|
|
|
+ _minGen = minGen; _maxGen = maxGen;
|
|
|
+
|
|
|
+ const offsetX = CFG.SIDEBAR_W + CFG.LEFT_PAD;
|
|
|
+ Object.values(mMap).forEach(m => {
|
|
|
+ const g = (!isNaN(m.gen) && m.gen != null) ? m.gen : minGen;
|
|
|
+ m.y = computeY(g); // 使用折叠感知的 Y 计算
|
|
|
+ m.x = m.x + offsetX;
|
|
|
+ });
|
|
|
+
|
|
|
+ // ── 8. 找世系参考点 ──
|
|
|
+ let lineageRef = null;
|
|
|
+ Object.values(mMap).find(m => {
|
|
|
+ if (!m.nwg) return false;
|
|
|
+ const parsed = parseLineage(m.nwg);
|
|
|
+ if (parsed.length && !isNaN(parsed[0].num)) { lineageRef = { gen: m.gen, lineage: parsed }; return true; }
|
|
|
+ return false;
|
|
|
+ });
|
|
|
+ _lineageRef = lineageRef; _mMap = mMap;
|
|
|
+
|
|
|
+ // ── 9. 创建 SVG ──
|
|
|
+ const allX = Object.values(mMap).map(m => m.x);
|
|
|
+ const allY = Object.values(mMap).map(m => m.y);
|
|
|
+ const maxX = allX.length ? Math.max(...allX) : 800;
|
|
|
+ const maxY = allY.length ? Math.max(...allY) : 400;
|
|
|
+ const svgW = Math.max(container.offsetWidth || 800, maxX + 150);
|
|
|
+ const svgH = Math.max(container.offsetHeight || 600, maxY + 120);
|
|
|
+
|
|
|
+ _zoomBhv = d3.zoom().scaleExtent([0.04, 5]).on('zoom', e => {
|
|
|
+ _mainG.attr('transform', e.transform);
|
|
|
+ _updateSidebar(e.transform);
|
|
|
+ });
|
|
|
+ _svgEl = d3.select('#tree-gen-container')
|
|
|
+ .append('svg')
|
|
|
+ .attr('width', svgW).attr('height', svgH)
|
|
|
+ .style('position', 'absolute').style('top', '0').style('left', '0').style('z-index', '2')
|
|
|
+ .call(_zoomBhv);
|
|
|
+ _mainG = _svgEl.append('g').attr('class', 'main-g');
|
|
|
+
|
|
|
+ // ── 10. 行背景条纹 ──
|
|
|
+ const stripeG = _mainG.append('g').attr('class', 'stripes');
|
|
|
+ const fullW = Math.max(svgW, maxX + 300);
|
|
|
+
|
|
|
+ {
|
|
|
+ let g = minGen;
|
|
|
+ let idx = 0;
|
|
|
+ while (g <= maxGen) {
|
|
|
+ const cr = _collapsedRanges.find(r => r.start === g);
|
|
|
+ if (cr) {
|
|
|
+ // 折叠区间:特殊背景
|
|
|
+ const rowY = computeY(cr.start);
|
|
|
+ stripeG.append('rect')
|
|
|
+ .attr('x', offsetX - CFG.LEFT_PAD * 0.5)
|
|
|
+ .attr('y', rowY - CFG.COLLAPSED_H * 0.45)
|
|
|
+ .attr('width', fullW).attr('height', CFG.COLLAPSED_H * 0.9)
|
|
|
+ .attr('class', 'collapse-stripe');
|
|
|
+ // 上下边界虚线
|
|
|
+ [-1, 1].forEach(sign => {
|
|
|
+ stripeG.append('line')
|
|
|
+ .attr('x1', offsetX - CFG.LEFT_PAD * 0.5)
|
|
|
+ .attr('y1', rowY + sign * CFG.COLLAPSED_H * 0.45)
|
|
|
+ .attr('x2', fullW)
|
|
|
+ .attr('y2', rowY + sign * CFG.COLLAPSED_H * 0.45)
|
|
|
+ .attr('stroke', '#fbbf24').attr('stroke-width', 0.8)
|
|
|
+ .attr('stroke-dasharray', '4,3');
|
|
|
+ });
|
|
|
+ g = cr.end + 1; idx++;
|
|
|
+ } else {
|
|
|
+ const rowY = computeY(g);
|
|
|
+ if (idx % 2 === 0) {
|
|
|
+ stripeG.append('rect')
|
|
|
+ .attr('x', offsetX - CFG.LEFT_PAD * 0.5)
|
|
|
+ .attr('y', rowY - CFG.ROW_H * 0.46)
|
|
|
+ .attr('width', fullW).attr('height', CFG.ROW_H * 0.92)
|
|
|
+ .attr('fill', '#eef2ff').attr('opacity', 0.45);
|
|
|
+ }
|
|
|
+ if (g > minGen) {
|
|
|
+ stripeG.append('line')
|
|
|
+ .attr('x1', offsetX - CFG.LEFT_PAD * 0.5).attr('y1', rowY - CFG.ROW_H / 2)
|
|
|
+ .attr('x2', fullW).attr('y2', rowY - CFG.ROW_H / 2)
|
|
|
+ .attr('stroke', '#e2e8f0').attr('stroke-width', 0.8)
|
|
|
+ .attr('stroke-dasharray', '6,5');
|
|
|
+ }
|
|
|
+ g++; idx++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 11. 正常亲子连线(跳过折叠区间中的节点) ──
|
|
|
+ const linkG = _mainG.append('g').attr('class', 'links');
|
|
|
+ Object.values(mMap).forEach(node => {
|
|
|
+ if (isCollapsedGen(node.gen)) return; // 折叠区内的节点不画线
|
|
|
+ const visChildren = node.children.filter(c => !isCollapsedGen(c.gen));
|
|
|
+ if (!visChildren.length) return;
|
|
|
+
|
|
|
+ const px = node.x, py = node.y;
|
|
|
+ const midY = py + CFG.ROW_H * 0.40;
|
|
|
+ const xs = visChildren.map(c => c.x);
|
|
|
+ const minCX = Math.min(...xs), maxCX = Math.max(...xs);
|
|
|
+
|
|
|
+ linkG.append('line').attr('x1', px).attr('y1', py + CFG.NODE_R + 2).attr('x2', px).attr('y2', midY).attr('class', 'link-parent');
|
|
|
+ if (xs.length > 1) {
|
|
|
+ linkG.append('line').attr('x1', minCX).attr('y1', midY).attr('x2', maxCX).attr('y2', midY).attr('class', 'link-parent');
|
|
|
+ }
|
|
|
+ visChildren.forEach(child => {
|
|
|
+ linkG.append('line').attr('x1', child.x).attr('y1', midY).attr('x2', child.x).attr('y2', child.y - CFG.NODE_R - 2).attr('class', 'link-parent');
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ // ── 11b. 折叠穿透虚线(Collapse connectors) ──
|
|
|
+ const colConnG = _mainG.append('g').attr('class', 'collapse-conns');
|
|
|
+ _collapsedRanges.forEach(cr => {
|
|
|
+ Object.values(mMap).forEach(entryNode => {
|
|
|
+ if (isCollapsedGen(entryNode.gen)) return;
|
|
|
+ if (entryNode.gen >= cr.start) return;
|
|
|
+ const hasChildInRange = entryNode.children.some(c => c.gen >= cr.start && c.gen <= cr.end);
|
|
|
+ if (!hasChildInRange) return;
|
|
|
+
|
|
|
+ const exits = findExitNodes(entryNode, cr);
|
|
|
+ if (!exits.length) return;
|
|
|
+
|
|
|
+ const px = entryNode.x, py = entryNode.y;
|
|
|
+ const exitYs = exits.map(e => e.y);
|
|
|
+ const exitY = Math.min(...exitYs); // 最近的出口行 Y
|
|
|
+ const midY = py + (exitY - py) * 0.50; // 虚线中点
|
|
|
+
|
|
|
+ const exitXs = exits.map(e => e.x);
|
|
|
+ const allXs = [px, ...exitXs];
|
|
|
+ const minBX = Math.min(...allXs), maxBX = Math.max(...allXs);
|
|
|
+
|
|
|
+ // 父节点向下
|
|
|
+ colConnG.append('line')
|
|
|
+ .attr('x1', px).attr('y1', py + CFG.NODE_R + 2)
|
|
|
+ .attr('x2', px).attr('y2', midY)
|
|
|
+ .attr('class', 'link-collapse');
|
|
|
+ // 横向汇聚线
|
|
|
+ if (exits.length > 1 || Math.abs(px - exits[0].x) > 2) {
|
|
|
+ colConnG.append('line')
|
|
|
+ .attr('x1', minBX).attr('y1', midY)
|
|
|
+ .attr('x2', maxBX).attr('y2', midY)
|
|
|
+ .attr('class', 'link-collapse');
|
|
|
+ }
|
|
|
+ // 到各出口节点
|
|
|
+ exits.forEach(exit => {
|
|
|
+ colConnG.append('line')
|
|
|
+ .attr('x1', exit.x).attr('y1', midY)
|
|
|
+ .attr('x2', exit.x).attr('y2', exit.y - CFG.NODE_R - 2)
|
|
|
+ .attr('class', 'link-collapse');
|
|
|
+ });
|
|
|
+
|
|
|
+ // 角标:× N代
|
|
|
+ const count = cr.end - cr.start + 1;
|
|
|
+ const bdgX = (minBX + maxBX) / 2;
|
|
|
+ const bdgY = midY;
|
|
|
+ const label = `↑收紧 ${count}代`;
|
|
|
+ colConnG.append('rect')
|
|
|
+ .attr('x', bdgX - 30).attr('y', bdgY - 11)
|
|
|
+ .attr('width', 60).attr('height', 22)
|
|
|
+ .attr('rx', 11).attr('fill', '#fffbeb')
|
|
|
+ .attr('stroke', '#fbbf24').attr('stroke-width', 1.2);
|
|
|
+ colConnG.append('text')
|
|
|
+ .attr('x', bdgX).attr('y', bdgY + 1)
|
|
|
+ .attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
|
|
|
+ .attr('font-size', '10px').attr('font-weight', '600')
|
|
|
+ .attr('fill', '#d97706').attr('font-family', '"Microsoft YaHei", sans-serif')
|
|
|
+ .text(label);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ // ── 12. 配偶连线已移除 ──
|
|
|
+
|
|
|
+ // ── 13. 节点(跳过折叠区内) ──
|
|
|
+ const nodeG = _mainG.append('g').attr('class', 'nodes');
|
|
|
+ Object.values(mMap).forEach(m => {
|
|
|
+ if (isCollapsedGen(m.gen)) return; // 跳过折叠区内节点
|
|
|
+
|
|
|
+ const sexCls = m.sex === 1 ? 'node-male' : m.sex === 2 ? 'node-female' : 'node-unknown';
|
|
|
+ const adoptedCls = m._isAdoptedIn ? ' node-adopted-in' : '';
|
|
|
+ const ng = nodeG.append('g')
|
|
|
+ .attr('class', `gen-node ${sexCls}${adoptedCls}`)
|
|
|
+ .attr('data-mid', m.id)
|
|
|
+ .attr('transform', `translate(${m.x},${m.y})`)
|
|
|
+ .style('cursor', 'pointer')
|
|
|
+ .on('contextmenu', event => {
|
|
|
+ event.preventDefault();
|
|
|
+ _selMid = m.id;
|
|
|
+ const cr = container.getBoundingClientRect();
|
|
|
+ _ctxMenu.style.display = 'block';
|
|
|
+ _ctxMenu.style.left = (event.clientX - cr.left) + 'px';
|
|
|
+ _ctxMenu.style.top = (event.clientY - cr.top) + 'px';
|
|
|
+ })
|
|
|
+ .on('dblclick', () => { if (m.id) window.location.href = `/manager/member_detail/${m.id}`; });
|
|
|
+
|
|
|
+ const R = CFG.NODE_R;
|
|
|
+ if (m.sex === 1) {
|
|
|
+ ng.append('rect').attr('x', -R).attr('y', -R).attr('width', R*2).attr('height', R*2).attr('rx', 4).attr('ry', 4);
|
|
|
+ } else {
|
|
|
+ ng.append('circle').attr('r', R);
|
|
|
+ }
|
|
|
+ // 入继标签(节点正上方)
|
|
|
+ if (m._isAdoptedIn) {
|
|
|
+ ng.append('text').attr('class', 'node-adopt-label')
|
|
|
+ .attr('x', 0).attr('y', -R - 3).attr('dy', '-0.2em')
|
|
|
+ .attr('text-anchor', 'middle').text('入继');
|
|
|
+ }
|
|
|
+ const disp = m.sname || m.name;
|
|
|
+ const short = disp.length > CFG.MAX_NAME ? disp.slice(0, CFG.MAX_NAME) + '…' : disp;
|
|
|
+ const txt = ng.append('text').attr('class', 'node-name-text')
|
|
|
+ .attr('x', 0).attr('y', R + 16).attr('dy', '0.15em').attr('text-anchor', 'middle').text(short);
|
|
|
+ if (disp.length > CFG.MAX_NAME || (m.name && m.sname && m.name !== m.sname)) {
|
|
|
+ const tip = m.name + (m.sname && m.name !== m.sname ? ` (${m.sname})` : '');
|
|
|
+ txt.append('title').text(tip);
|
|
|
+ ng.append('title').text(tip);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // ── 14. 侧边栏 ──
|
|
|
+ _buildSidebar(minGen, maxGen, lineageRef);
|
|
|
+ _updateSidebar(d3.zoomIdentity);
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+// 侧边栏构建
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+function _buildSidebar(minGen, maxGen, lineageRef) {
|
|
|
+ const sidebar = document.getElementById('gen-sidebar');
|
|
|
+
|
|
|
+ let g = minGen;
|
|
|
+ while (g <= maxGen) {
|
|
|
+ // 检查是否是折叠区间的起点
|
|
|
+ const cr = _collapsedRanges.find(r => r.start === g);
|
|
|
+ if (cr) {
|
|
|
+ // 折叠行(点击展开)
|
|
|
+ const count = cr.end - cr.start + 1;
|
|
|
+ const div = document.createElement('div');
|
|
|
+ div.className = 'gen-collapsed-row';
|
|
|
+ div.dataset.start = cr.start;
|
|
|
+ div.dataset.end = cr.end;
|
|
|
+ div.innerHTML =
|
|
|
+ `<span class="cr-title">⇕ 第${numToCN(cr.start)}~${numToCN(cr.end)}世</span>` +
|
|
|
+ `<span class="cr-hint">(${count}代已收紧,点击展开)</span>`;
|
|
|
+ div.onclick = () => expandRange(cr.start, cr.end);
|
|
|
+ sidebar.appendChild(div);
|
|
|
+ g = cr.end + 1;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 正常世代标签行
|
|
|
+ const div = document.createElement('div');
|
|
|
+ div.className = 'gen-label-row';
|
|
|
+ div.dataset.gen = g;
|
|
|
+ const numSpan = document.createElement('span');
|
|
|
+ numSpan.className = 'gen-label-num';
|
|
|
+ numSpan.textContent = `第${numToCN(g)}世`;
|
|
|
+ div.appendChild(numSpan);
|
|
|
+ if (lineageRef) {
|
|
|
+ lineageLbl(lineageRef.lineage, lineageRef.gen, g).forEach(lbl => {
|
|
|
+ const sp = document.createElement('span');
|
|
|
+ sp.className = 'gen-label-lineage';
|
|
|
+ sp.textContent = lbl;
|
|
|
+ div.appendChild(sp);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ sidebar.appendChild(div);
|
|
|
+
|
|
|
+ // 手柄:在当前代和下一代之间(如果下一代存在且未到 maxGen)
|
|
|
+ if (g < maxGen) {
|
|
|
+ const handle = document.createElement('div');
|
|
|
+ handle.className = 'gen-collapse-handle';
|
|
|
+ handle.dataset.between = g;
|
|
|
+ handle.title = `点击标记折叠边界(第${numToCN(g)}~${numToCN(g+1)}世之间)`;
|
|
|
+ handle.innerHTML = '<div class="h-line"><div class="h-btn">─</div></div>';
|
|
|
+ handle.addEventListener('click', function(e) {
|
|
|
+ e.stopPropagation();
|
|
|
+ handleCollapseClick(parseInt(this.dataset.between), this);
|
|
|
+ });
|
|
|
+ sidebar.appendChild(handle);
|
|
|
+ }
|
|
|
+
|
|
|
+ g++;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+// 侧边栏位置同步
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+function _updateSidebar(transform) {
|
|
|
+ const k = transform.k, ty = transform.y;
|
|
|
+
|
|
|
+ // 世代标签行
|
|
|
+ document.querySelectorAll('#gen-sidebar .gen-label-row').forEach(el => {
|
|
|
+ const g = parseInt(el.dataset.gen);
|
|
|
+ el.style.top = (computeY(g) * k + ty) + 'px';
|
|
|
+ });
|
|
|
+
|
|
|
+ // 折叠手柄(在 gen g 和 g+1 之间的中点)
|
|
|
+ document.querySelectorAll('#gen-sidebar .gen-collapse-handle').forEach(el => {
|
|
|
+ const bg = parseInt(el.dataset.between);
|
|
|
+ const y = (computeY(bg) + computeY(bg + 1)) / 2;
|
|
|
+ el.style.top = (y * k + ty) + 'px';
|
|
|
+ });
|
|
|
+
|
|
|
+ // 折叠区间行(居中于折叠区间虚拟行)
|
|
|
+ document.querySelectorAll('#gen-sidebar .gen-collapsed-row').forEach(el => {
|
|
|
+ const start = parseInt(el.dataset.start);
|
|
|
+ // 折叠区间起始的内容 Y + 半个虚拟行高
|
|
|
+ const y = computeY(start) + CFG.COLLAPSED_H / 2;
|
|
|
+ el.style.top = (y * k + ty) + 'px';
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+// 缩放控件
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+function zoomIn() { if (_svgEl) _svgEl.transition().duration(280).call(_zoomBhv.scaleBy, 1.35); }
|
|
|
+function zoomOut() { if (_svgEl) _svgEl.transition().duration(280).call(_zoomBhv.scaleBy, 0.75); }
|
|
|
+function zoomReset() { if (_svgEl) _svgEl.transition().duration(300).call(_zoomBhv.transform, d3.zoomIdentity); }
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+// 右键菜单
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+function menuAction(type) {
|
|
|
+ switch(type) {
|
|
|
+ case 'detail': if (_selMid) window.location.href = `/manager/member_detail/${_selMid}`; break;
|
|
|
+ case 'edit': if (_selMid) window.location.href = `/manager/edit_member/${_selMid}`; break;
|
|
|
+ case 'add': window.location.href = '/manager/add_member'; break;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+// 搜索
|
|
|
+// ═══════════════════════════════════════════════════════════
|
|
|
+function searchMember() {
|
|
|
+ const term = document.getElementById('genSearch').value.trim().toLowerCase();
|
|
|
+ if (!term) return;
|
|
|
+ if (!_rawData || !_rawData.members) { alert('数据未加载完成,请稍候再试'); return; }
|
|
|
+ const matches = _rawData.members.filter(m =>
|
|
|
+ (m.name || '').toLowerCase().includes(term) ||
|
|
|
+ (m.simplified_name || '').toLowerCase().includes(term)
|
|
|
+ );
|
|
|
+ if (!matches.length) { alert('未找到匹配成员'); return; }
|
|
|
+ const target = matches[0];
|
|
|
+ const node = _mMap && _mMap[target.id];
|
|
|
+ if (!node || !_svgEl) return;
|
|
|
+ const cont = document.getElementById('tree-gen-container');
|
|
|
+ const scale = 1.6;
|
|
|
+ _svgEl.transition().duration(800).call(
|
|
|
+ _zoomBhv.transform,
|
|
|
+ d3.zoomIdentity.translate(cont.clientWidth/2 - node.x*scale, cont.clientHeight/2 - node.y*scale).scale(scale)
|
|
|
+ );
|
|
|
+ _mainG.selectAll('.gen-node').filter(function() {
|
|
|
+ return d3.select(this).attr('data-mid') == target.id;
|
|
|
+ }).classed('node-highlight', true);
|
|
|
+ setTimeout(() => { _mainG.selectAll('.gen-node').classed('node-highlight', false); }, 2000);
|
|
|
+}
|
|
|
+
|
|
|
+document.getElementById('genSearch').addEventListener('keypress', e => {
|
|
|
+ if (e.key === 'Enter') searchMember();
|
|
|
+});
|
|
|
+</script>
|
|
|
+{% endblock %}
|