7f34b1fa09
- D3.js based org chart editor - Multi-select drag with Cmd/Ctrl+Click - Undo/Redo support (30 steps) - Version management (save/load) - Custom links, angle links, free draw links - Export PNG/SVG with transparent background - Word-wrap for long titles
1717 lines
73 KiB
HTML
1717 lines
73 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-Hant">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>SC Medical Group – Org Chart Editor</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
|
||
<style>
|
||
:root{--navy:#1F4E79;--blue:#2E75B6;--light:#D6E4F0;--white:#FFFFFF;--orange:#ED7D31;--green:#70AD47;--red:#FF4444;--text:#222;--border:#BFBFBF;--bg:#F0F4F8;}
|
||
*{box-sizing:border-box;margin:0;padding:0;}
|
||
html,body{height:100%;overflow:hidden;}
|
||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);font-size:14px;}
|
||
nav{background:var(--navy);color:white;padding:0 24px;display:flex;align-items:center;gap:16px;height:56px;position:sticky;top:0;z-index:100;box-shadow:0 2px 8px rgba(0,0,0,0.2);}
|
||
nav h1{font-size:16px;font-weight:700;flex:1;}
|
||
.badge{background:var(--orange);color:white;padding:4px 12px;border-radius:20px;font-size:12px;}
|
||
.layout{display:flex;height:calc(100vh - 56px);}
|
||
.sidebar{width:300px;background:white;border-right:2px solid var(--light);flex-shrink:0;display:flex;flex-direction:column;overflow:hidden;transition:width 0.3s;}
|
||
.sidebar.hidden{width:0;overflow:hidden;}
|
||
.sidebar-header{background:var(--navy);color:white;padding:12px 16px;font-weight:700;font-size:13px;white-space:nowrap;}
|
||
.sidebar-tabs{display:flex;border-bottom:2px solid var(--light);}
|
||
.stab{flex:1;padding:10px;text-align:center;cursor:pointer;font-weight:600;font-size:12px;color:#666;border-bottom:3px solid transparent;transition:all 0.2s;white-space:nowrap;}
|
||
.stab:hover{color:var(--navy);}
|
||
.stab.active{color:var(--navy);border-color:var(--navy);}
|
||
.sidebar-body{flex:1;overflow-y:auto;padding:12px;}
|
||
.panel{display:none;}
|
||
.panel.active{display:block;}
|
||
.card{background:white;border-radius:10px;padding:14px;box-shadow:0 1px 4px rgba(0,0,0,0.08);margin-bottom:10px;border-top:3px solid var(--blue);}
|
||
.card-title{font-size:12px;font-weight:700;color:var(--navy);text-transform:uppercase;margin-bottom:10px;}
|
||
.dept-item{border:1.5px solid var(--light);border-radius:8px;margin-bottom:6px;overflow:hidden;}
|
||
.dept-header{padding:8px 12px;display:flex;align-items:center;gap:8px;font-weight:700;font-size:12px;}
|
||
.dept-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0;}
|
||
.dept-name{flex:1;font-size:12px;}
|
||
.dept-count{font-size:11px;background:var(--light);padding:2px 6px;border-radius:10px;color:var(--navy);font-weight:600;}
|
||
.dept-sublist{padding:0 12px 8px 30px;}
|
||
.sub-p{padding:4px 0;font-size:12px;border-bottom:1px dashed #eee;display:flex;justify-content:space-between;align-items:center;}
|
||
.sub-p:last-child{border-bottom:none;}
|
||
.sub-name{font-weight:600;}
|
||
.sub-role{font-size:11px;color:#888;}
|
||
.sub-btns{display:flex;gap:4px;opacity:0;transition:opacity 0.2s;}
|
||
.dept-item:hover .sub-btns{opacity:1;}
|
||
.form-group{margin-bottom:8px;}
|
||
.form-group label{display:block;font-size:11px;font-weight:700;color:#666;margin-bottom:3px;}
|
||
.form-group input,.form-group select{width:100%;padding:7px 9px;border:1.5px solid var(--border);border-radius:6px;font-size:13px;outline:none;transition:border-color 0.2s;}
|
||
.form-group input:focus,.form-group select:focus{border-color:var(--blue);box-shadow:0 0 0 2px rgba(46,117,182,0.15);}
|
||
.checkbox-list{max-height:150px;overflow-y:auto;border:1.5px solid var(--border);border-radius:6px;padding:6px;background:#fafafa;}
|
||
.checkbox-item{display:flex;align-items:center;gap:8px;padding:4px 4px;cursor:pointer;font-size:13px;border-radius:4px;transition:background 0.15s;}
|
||
.checkbox-item:hover{background:var(--light);}
|
||
.checkbox-item input[type=checkbox]{width:15px;height:15px;cursor:pointer;accent-color:var(--navy);}
|
||
.checkbox-item span{flex:1;}
|
||
.check-color{width:10px;height:10px;border-radius:50%;flex-shrink:0;}
|
||
.btn{padding:7px 14px;border-radius:6px;border:none;cursor:pointer;font-size:13px;font-weight:600;transition:all 0.2s;white-space:nowrap;}
|
||
.btn-sm{padding:3px 8px;font-size:11px;}
|
||
.btn-primary{background:var(--navy);color:white;}
|
||
.btn-primary:hover{background:#163c60;}
|
||
.btn-secondary{background:var(--light);color:var(--navy);}
|
||
.btn-secondary:hover{background:#c0d8ed;}
|
||
.btn-green{background:var(--green);color:white;}
|
||
.btn-green:hover{background:#5a9040;}
|
||
.btn-red{background:var(--red);color:white;}
|
||
.btn-red:hover{background:#e03030;}
|
||
.btn-orange{background:var(--orange);color:white;}
|
||
.btn-orange:hover{background:#d06820;}
|
||
.btn-row{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px;}
|
||
.main{flex:1;overflow:hidden;padding:20px;background:var(--bg);display:flex;flex-direction:column;min-height:0;height:100%;}
|
||
.main-header{display:flex;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap;flex-shrink:0;}
|
||
.main-header h2{font-size:18px;color:var(--navy);flex:1;}
|
||
.chart-wrap{background:white;border-radius:12px;padding:16px;box-shadow:0 1px 4px rgba(0,0,0,0.08);flex:1;overflow:hidden;position:relative;display:flex;flex-direction:column;min-height:0;height:100%;box-sizing:border-box;}
|
||
#chartContainer{flex:1;overflow:auto;position:relative;min-height:0;height:100%;box-sizing:border-box;}
|
||
#chartSvg{display:block;}
|
||
.node-card{cursor:pointer;}
|
||
.node-rect{fill:white;stroke:#ccc;stroke-width:1.5;rx:8;ry:8;filter:drop-shadow(0 2px 4px rgba(0,0,0,0.1));transition:filter 0.15s;}
|
||
.node-rect:hover{stroke:#2E75B6;stroke-width:2;filter:drop-shadow(0 4px 12px rgba(46,117,182,0.25));}
|
||
.node-rect.sel{stroke:#ED7D31;stroke-width:3;}
|
||
.link{fill:none;stroke:#bbb;stroke-width:1.5;cursor:default;}
|
||
.link.sel{stroke:#ED7D31;stroke-width:2;}
|
||
.link-broken{stroke:#FF9944;stroke-width:2;}
|
||
.custom-link-path{fill:none;stroke:#aaa;stroke-width:1.2;cursor:pointer;}
|
||
.custom-link-path:hover{stroke:#888;stroke-width:1.5;}
|
||
.custom-link-hit{fill:none;stroke:transparent;stroke-width:16;cursor:pointer;}
|
||
.angle-link{fill:none;stroke:#aaa;stroke-width:1.2;cursor:pointer;}
|
||
.angle-link-hit{fill:none;stroke:transparent;stroke-width:16;cursor:pointer;}
|
||
.free-link{fill:none;stroke:#aaa;stroke-width:1.2;cursor:pointer;}
|
||
.free-link-hit{fill:none;stroke:transparent;stroke-width:16;cursor:pointer;}
|
||
.pending-line{fill:none;stroke:#ED7D31;stroke-width:1.5;pointer-events:none;}
|
||
.toast{position:fixed;bottom:24px;right:24px;background:var(--navy);color:white;padding:12px 20px;border-radius:8px;font-weight:600;box-shadow:0 4px 12px rgba(0,0,0,0.2);z-index:2000;opacity:0;transform:translateY(10px);transition:all 0.3s;}
|
||
.toast.show{opacity:1;transform:translateY(0);}
|
||
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;align-items:center;justify-content:center;padding:20px;}
|
||
.modal-overlay.show{display:flex;}
|
||
.modal{background:white;border-radius:16px;width:100%;max-width:500px;max-height:90vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,0.3);}
|
||
.modal-header{background:var(--navy);color:white;padding:16px 20px;display:flex;justify-content:space-between;align-items:center;border-radius:16px 16px 0 0;}
|
||
.modal-header h3{font-size:14px;}
|
||
.modal-close{background:none;border:none;color:white;font-size:22px;cursor:pointer;width:32px;height:32px;display:flex;align-items:center;justify-content:center;border-radius:50%;}
|
||
.modal-close:hover{background:rgba(255,255,255,0.2);}
|
||
.modal-body{padding:20px;}
|
||
.modal-footer{padding:12px 20px;background:#f5f5f5;border-radius:0 0 16px 16px;display:flex;gap:8px;flex-wrap:wrap;}
|
||
#floatingToggle{position:fixed;left:16px;top:72px;z-index:500;background:var(--navy);color:white;border:none;width:40px;height:40px;border-radius:50%;font-size:16px;cursor:pointer;box-shadow:0 2px 12px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;transition:background 0.2s;line-height:1;}
|
||
#floatingToggle:hover{background:#2E75B6;}
|
||
#modeBar{position:absolute;bottom:12px;left:50%;transform:translateX(-50%);background:rgba(31,78,121,0.92);color:white;padding:8px 16px;border-radius:20px;font-size:12px;font-weight:600;z-index:60;display:none;white-space:nowrap;}
|
||
#modeBar.show{display:block;}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<nav>
|
||
<h1>🏥 SC Medical Group – Org Chart Editor</h1>
|
||
<span class="badge" id="orgCount">0 人</span>
|
||
</nav>
|
||
|
||
<button id="floatingToggle" onclick="toggleSidebar()" title="側邊欄">☰</button>
|
||
|
||
<div class="layout">
|
||
<div class="sidebar" id="sidebar">
|
||
<div class="sidebar-header">📋 組織架構</div>
|
||
<div class="sidebar-tabs">
|
||
<div class="stab active" data-tab="depts">部門</div>
|
||
<div class="stab" data-tab="add">新增</div>
|
||
</div>
|
||
<div class="sidebar-body">
|
||
<div class="panel active" id="panel-depts"><div id="deptList"></div></div>
|
||
<div class="panel" id="panel-add">
|
||
<div class="card">
|
||
<div class="card-title">➕ 新增成員</div>
|
||
<div class="form-group"><label>姓名 *</label><input type="text" id="addName" placeholder="陳大明"></div>
|
||
<div class="form-group"><label>職位 *</label><input type="text" id="addRole" placeholder="醫生 / 護士長"></div>
|
||
<div class="form-group"><label>所屬部門(可選多個)</label><div class="checkbox-list" id="addDeptList"></div></div>
|
||
<div class="form-group"><label>匯報對象(可選多個)</label><div class="checkbox-list" id="addReportsList"></div></div>
|
||
<div class="btn-row">
|
||
<button class="btn btn-green" onclick="addPerson()">✅ 新增</button>
|
||
<button class="btn btn-secondary" onclick="clearAddForm()">🗑️</button>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:4px">
|
||
<button class="btn btn-primary" onclick="loadSampleData()" style="width:100%;margin-bottom:6px">📥 載入範本數據</button>
|
||
<button class="btn btn-secondary" onclick="exportJSON()" style="width:100%;margin-bottom:4px">💾 導出 JSON</button>
|
||
<input type="file" id="importFile" accept=".json" style="display:none" onchange="importJSON(event)">
|
||
<button class="btn btn-secondary" onclick="document.getElementById('importFile').click()" style="width:100%">📂 導入 JSON</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="main">
|
||
<div class="main-header">
|
||
<h2>🏢 組織架構圖</h2>
|
||
<button class="btn btn-secondary" onclick="zoomOut()">🔍-</button>
|
||
<button class="btn btn-secondary" onclick="fitToScreen()">⛶ 貼合</button>
|
||
<button class="btn btn-secondary" onclick="zoomIn()">🔍+</button>
|
||
<button class="btn btn-secondary" onclick="resetPositions()">🔄 重置</button>
|
||
<button class="btn btn-green" onclick="savePositions()">💾 儲存位置</button>
|
||
<button class="btn btn-secondary" onclick="clearPositions()">🗑️ 清除位置</button>
|
||
<button class="btn btn-secondary" onclick="openVersionModal()">📜 版本</button>
|
||
<button class="btn btn-secondary" onclick="undoAction()" id="undoBtn" disabled title="上一步">↩️ Undo</button>
|
||
<button class="btn btn-secondary" onclick="redoAction()" id="redoBtn" disabled title="下一步">↪️ Redo</button>
|
||
<button class="btn btn-secondary" onclick="showDebug()">🔍 Debug</button>
|
||
<button class="btn btn-secondary" onclick="exportSVG()">📐 SVG</button>
|
||
<button class="btn btn-secondary" onclick="exportPNG()">🖼️ PNG</button>
|
||
<button class="btn btn-secondary" onclick="window.print()">🖨️ 打印</button>
|
||
<span id="selInfo" style="font-size:12px;color:#888;display:none">已選 <b id="selNum">0</b> 人</span>
|
||
<button class="btn btn-orange" id="clearSelBtn" onclick="clearSelection()" style="display:none">✕ 清除選擇</button>
|
||
<button class="btn btn-secondary" id="drawBtn" onclick="toggleDrawMode()">✏️ 畫線</button>
|
||
<button class="btn btn-secondary" id="angleBtn" onclick="toggleAngleMode()">🔲 直角線</button>
|
||
<button class="btn btn-secondary" id="freeBtn" onclick="toggleFreeMode()">✍️ 自由畫</button>
|
||
<button class="btn btn-red" id="eraseBtn" onclick="toggleEraseMode()" style="display:none">🗑️ 刪線</button>
|
||
</div>
|
||
<div class="chart-wrap">
|
||
<div id="chartContainer">
|
||
<svg id="chartSvg"></svg>
|
||
</div>
|
||
<div id="modeBar"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- EDIT MODAL -->
|
||
<div class="modal-overlay" id="editModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h3 id="modalTitle">✏️ 編輯成員</h3>
|
||
<button class="modal-close" onclick="closeModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group"><label>姓名</label><input type="text" id="mName"></div>
|
||
<div class="form-group"><label>職位</label><input type="text" id="mRole"></div>
|
||
<div class="form-group"><label>所屬部門(可選多個)</label><div class="checkbox-list" id="mDeptList"></div></div>
|
||
<div class="form-group"><label>匯報對象(可選多個)</label><div class="checkbox-list" id="mReportsList"></div></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-primary" onclick="saveEdit()">💾 儲存</button>
|
||
<button class="btn btn-red" onclick="deletePerson()">🗑️ 刪除</button>
|
||
<button class="btn btn-secondary" onclick="closeModal()">取消</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- DEPT MODAL -->
|
||
<div class="modal-overlay" id="deptModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h3>🏷️ 編輯部門</h3>
|
||
<button class="modal-close" onclick="closeDeptModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div id="deptEditor"></div>
|
||
<div style="margin-top:12px;border-top:1px dashed #ddd;padding-top:12px">
|
||
<label style="font-size:11px;font-weight:700;color:#666;display:block;margin-bottom:6px">➕ 新增部門</label>
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<input type="color" id="newDeptColor" value="#2E75B6" style="width:36px;height:32px;border-radius:6px;border:1.5px solid var(--border);cursor:pointer;padding:0">
|
||
<input type="text" id="newDeptName" placeholder="部門名稱" style="flex:1;padding:7px 9px;border:1.5px solid var(--border);border-radius:6px;font-size:13px">
|
||
<button class="btn btn-green btn-sm" onclick="addDept()">新增</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-primary" onclick="saveDepts()">💾 儲存部門</button>
|
||
<button class="btn btn-secondary" onclick="closeDeptModal()">關閉</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- VERSION MODAL -->
|
||
<div class="modal-overlay" id="versionModal">
|
||
<div class="modal" style="max-width:450px">
|
||
<div class="modal-header">
|
||
<h3>📜 版本管理</h3>
|
||
<button class="modal-close" onclick="closeVersionModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div id="versionList" style="max-height:300px;overflow-y:auto;"></div>
|
||
<div style="margin-top:12px;padding-top:12px;border-top:1px dashed #ddd">
|
||
<div class="form-group">
|
||
<label>新版本名稱</label>
|
||
<input type="text" id="newVersionName" placeholder="例如:v1.0 完成版" style="width:100%;padding:7px 9px;border:1.5px solid var(--border);border-radius:6px;font-size:13px">
|
||
</div>
|
||
<button class="btn btn-green" onclick="saveVersion()" style="width:100%;margin-top:8px">💾 儲存新版本</button>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeVersionModal()">關閉</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
// ── STATE ────────────────────────────────────────────────────────────────────
|
||
let people = [];
|
||
let DEPTS = [
|
||
{id:'exec', name:'Executive Director', color:'#1F4E79'},
|
||
{id:'admin', name:'Administration', color:'#5B9BD5'},
|
||
{id:'hola', name:'HOLA Physiotherapy', color:'#70AD47'},
|
||
{id:'ihealth',name:'iHealth', color:'#4472C4'},
|
||
{id:'sch', name:'SC Health', color:'#ED7D31'},
|
||
{id:'schc', name:'SC Health Care', color:'#7030A0'},
|
||
{id:'scdpc', name:'SC Day Procedure Ctr', color:'#C55A11'},
|
||
{id:'zenith',name:'Zenith Health', color:'#FFC000'},
|
||
{id:'sccare',name:'SC Care', color:'#FF4444'},
|
||
];
|
||
let selectedId = null;
|
||
let _editId = null;
|
||
let selectedIds = new Set();
|
||
let tempDepts = [];
|
||
let _root = null;
|
||
let _g = null;
|
||
let _nodeMap = {};
|
||
let _svg = null;
|
||
let _zoom = null;
|
||
let _chartOX = 0, _chartOY = 0;
|
||
|
||
// Custom links: {id, fromId, toId}
|
||
let customLinks = [];
|
||
// Broken tree links: Set of "fromId→toId" strings
|
||
let brokenLinks = new Set();
|
||
// Angle links: {id, fromId, toId} — 90-degree grey lines
|
||
let angleLinks = [];
|
||
// Free links: {id, fromId, toId, points:[[x,y],...]} — freehand grey lines
|
||
let freeLinks = [];
|
||
// Draw/Erase/Angle mode
|
||
let _drawMode = false;
|
||
let _eraseMode = false;
|
||
let _angleMode = false;
|
||
let _freeMode = false;
|
||
let _linkFrom = null; // node id selected as start of pending link
|
||
let _pendingG = null; // SVG group for pending line being drawn
|
||
let _dragOff = {}; // offset map for multi-select drag
|
||
let _dragInitialized = false; // track if drag offsets have been initialized for current drag
|
||
|
||
const NODE_W = 180;
|
||
const NODE_H = 80;
|
||
const DEPT_ROW_H = 18;
|
||
const LEVEL_H = 130;
|
||
|
||
// ── TOAST ──────────────────────────────────────────────────────────────────
|
||
function toast(msg) {
|
||
const t = document.getElementById('toast');
|
||
t.textContent = msg;
|
||
t.classList.add('show');
|
||
setTimeout(() => t.classList.remove('show'), 2500);
|
||
}
|
||
|
||
function showDebug() {
|
||
const stored = JSON.parse(localStorage.getItem('sc_org_positions') || '[]');
|
||
const withPos = people.filter(p => p._x != null);
|
||
const title = document.createElement('div');
|
||
title.style.cssText = 'position:fixed;top:70px;right:16px;z-index:9999;width:380px;background:#1a1a2e;padding:14px;border-radius:10px;font-family:monospace;font-size:12px;color:#e0e0e0;max-height:400px;overflow:auto;box-shadow:0 4px 20px rgba(0,0,0,0.5)';
|
||
document.body.appendChild(title);
|
||
const lines = [
|
||
'<b style="color:#fff">🔍 Debug</b><br><br>',
|
||
'<b>people:</b> ' + people.length + ' 人',
|
||
'<b>有位置:</b> ' + withPos.length + ' 人',
|
||
'<b>localStorage:</b> ' + stored.length + ' 筆',
|
||
'<b>selectedIds:</b> ' + ([...selectedIds].join(', ') || '(無)'),
|
||
'<br>',
|
||
'<b>people._x/_y (前5):</b><br>' + people.slice(0,5).map(p => p.id + ' ' + p.name.substring(0,8) + ' x=' + p._x + ' y=' + p._y).join('<br>'),
|
||
'<br>',
|
||
'<b>localStorage (前5):</b><br>' + stored.slice(0,5).map(p => p.id + ' x=' + p._x + ' y=' + p._y).join('<br>'),
|
||
'<br><button onclick="this.parentElement.remove()" style="background:#444;color:#fff;border:none;padding:6px 14px;border-radius:6px;cursor:pointer">關閉</button>'
|
||
];
|
||
title.innerHTML = lines.join('');
|
||
}
|
||
|
||
// ── TABS ──────────────────────────────────────────────────────────────────
|
||
document.querySelectorAll('.stab').forEach(t => {
|
||
t.addEventListener('click', () => {
|
||
document.querySelectorAll('.stab').forEach(s => s.classList.remove('active'));
|
||
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
||
t.classList.add('active');
|
||
document.getElementById('panel-' + t.dataset.tab).classList.add('active');
|
||
});
|
||
});
|
||
|
||
// ── HELPERS ────────────────────────────────────────────────────────────────
|
||
function deptColor(id) {
|
||
if (Array.isArray(id)) id = id[0] || '';
|
||
const d = DEPTS.find(x => x.id === id);
|
||
return d ? d.color : '#888';
|
||
}
|
||
function deptName(id) {
|
||
if (Array.isArray(id)) id = id[0] || '';
|
||
const d = DEPTS.find(x => x.id === id);
|
||
return d ? d.name : id;
|
||
}
|
||
function cardH(node) {
|
||
// Accept both full node objects or just depts array (backward compat)
|
||
const depts = node.data ? node.data.depts : (Array.isArray(node) ? node : (node || []));
|
||
const roleText = node.data ? (node.data.role || '') : '';
|
||
const words = roleText.split(/\s+/);
|
||
const maxWidth = NODE_W - 16;
|
||
let lines = 1;
|
||
let currentLine = '';
|
||
words.forEach(word => {
|
||
const testLine = currentLine ? currentLine + ' ' + word : word;
|
||
if (testLine.length * 6 > maxWidth && currentLine) {
|
||
lines++;
|
||
currentLine = word;
|
||
} else {
|
||
currentLine = testLine;
|
||
}
|
||
});
|
||
const roleH = Math.max(0, lines - 1) * 14;
|
||
return NODE_H + Math.max(0, depts.length - 1) * DEPT_ROW_H + roleH;
|
||
}
|
||
function nodeY(d) { return d.y; }
|
||
|
||
// ── CHECKBOX ───────────────────────────────────────────────────────────────
|
||
function renderDeptCheckbox(containerId, selectedIdsArr) {
|
||
selectedIdsArr = Array.isArray(selectedIdsArr) ? selectedIdsArr : (selectedIdsArr ? [selectedIdsArr] : []);
|
||
const container = document.getElementById(containerId);
|
||
container.innerHTML = DEPTS.map(d => {
|
||
const checked = selectedIdsArr.includes(d.id) ? 'checked' : '';
|
||
return `<label class="checkbox-item">
|
||
<input type="checkbox" value="${d.id}" ${checked}>
|
||
<span class="check-color" style="background:${d.color}"></span>
|
||
<span>${d.name}</span>
|
||
</label>`;
|
||
}).join('');
|
||
}
|
||
function getCheckedDepts(containerId) {
|
||
return Array.from(document.querySelectorAll('#' + containerId + ' input[type=checkbox]:checked')).map(c => c.value);
|
||
}
|
||
function renderReportsCheckbox(containerId, selectedIdsArr, excludeId) {
|
||
selectedIdsArr = Array.isArray(selectedIdsArr) ? selectedIdsArr : (selectedIdsArr ? [selectedIdsArr] : []);
|
||
const others = people.filter(p => p.id !== excludeId);
|
||
const container = document.getElementById(containerId);
|
||
if (!others.length) { container.innerHTML = '<div style="font-size:12px;color:#aaa;padding:8px">(暫無其他人)</div>'; return; }
|
||
container.innerHTML = others.map(p => {
|
||
const checked = selectedIdsArr.includes(p.id) ? 'checked' : '';
|
||
return `<label class="checkbox-item">
|
||
<input type="checkbox" value="${p.id}" ${checked}>
|
||
<span class="check-color" style="background:${deptColor(p.depts && p.depts[0])}"></span>
|
||
<span>${p.name}</span>
|
||
<span style="font-size:11px;color:#888;margin-left:auto">${p.role}</span>
|
||
</label>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ── SELECTION ─────────────────────────────────────────────────────────────
|
||
function updateSelUI() {
|
||
const n = selectedIds.size;
|
||
document.getElementById('selInfo').style.display = n > 0 ? 'inline' : 'none';
|
||
document.getElementById('clearSelBtn').style.display = n > 0 ? 'inline' : 'none';
|
||
document.getElementById('selNum').textContent = n;
|
||
}
|
||
function clearSelection() { selectedIds.clear(); updateSelUI(); refreshChart(); }
|
||
|
||
// ── MODE BAR ─────────────────────────────────────────────────────────────
|
||
function showModeBar(msg, color) {
|
||
const bar = document.getElementById('modeBar');
|
||
bar.style.background = color || 'rgba(31,78,121,0.92)';
|
||
bar.innerHTML = msg;
|
||
bar.classList.add('show');
|
||
}
|
||
function hideModeBar() { document.getElementById('modeBar').classList.remove('show'); }
|
||
|
||
// ── DRAW / ERASE MODE ────────────────────────────────────────────────────
|
||
function toggleDrawMode() {
|
||
if (_drawMode) {
|
||
// Exit draw mode
|
||
_drawMode = false;
|
||
_linkFrom = null;
|
||
document.getElementById('drawBtn').classList.remove('btn-orange');
|
||
document.getElementById('drawBtn').classList.add('btn-secondary');
|
||
document.getElementById('eraseBtn').style.display = '';
|
||
hideModeBar();
|
||
if (_pendingG) { _pendingG.remove(); _pendingG = null; }
|
||
refreshChart();
|
||
} else {
|
||
_eraseMode = false;
|
||
document.getElementById('eraseBtn').style.display = '';
|
||
document.getElementById('eraseBtn').classList.remove('btn-orange');
|
||
document.getElementById('eraseBtn').classList.add('btn-red');
|
||
_drawMode = true;
|
||
_linkFrom = null;
|
||
document.getElementById('drawBtn').classList.add('btn-orange');
|
||
document.getElementById('drawBtn').classList.remove('btn-secondary');
|
||
document.getElementById('eraseBtn').style.display = '';
|
||
showModeBar('✏️ 畫線模式:點第一個 node 再點第二個 node(灰色虛線=直角線) <button onclick="exitLinkMode()" style="background:rgba(255,215,0,0.2);border:none;color:#FFD700;cursor:pointer;font-size:12px;padding:2px 8px;border-radius:10px;text-decoration:underline">取消</button>');
|
||
refreshChart();
|
||
}
|
||
}
|
||
|
||
function toggleEraseMode() {
|
||
if (_eraseMode) {
|
||
_eraseMode = false;
|
||
document.getElementById('eraseBtn').classList.remove('btn-orange');
|
||
document.getElementById('eraseBtn').classList.add('btn-red');
|
||
document.getElementById('drawBtn').style.display = '';
|
||
hideModeBar();
|
||
refreshChart();
|
||
} else {
|
||
_drawMode = false;
|
||
document.getElementById('drawBtn').classList.remove('btn-orange');
|
||
document.getElementById('drawBtn').classList.add('btn-secondary');
|
||
_eraseMode = true;
|
||
document.getElementById('eraseBtn').classList.add('btn-orange');
|
||
document.getElementById('eraseBtn').classList.remove('btn-red');
|
||
showModeBar('🗑️ 刪線:橙線·橙虛線·🔲灰線·✍️灰線 <button onclick="exitLinkMode()" style="background:rgba(255,215,0,0.2);border:none;color:#FFD700;cursor:pointer;font-size:12px;padding:2px 8px;border-radius:10px;text-decoration:underline">完成</button>');
|
||
refreshChart();
|
||
}
|
||
}
|
||
|
||
function toggleAngleMode() {
|
||
if (_angleMode) {
|
||
_angleMode = false;
|
||
_drawMode = false;
|
||
_linkFrom = null;
|
||
document.getElementById('angleBtn').classList.remove('btn-orange');
|
||
document.getElementById('angleBtn').classList.add('btn-secondary');
|
||
document.getElementById('drawBtn').style.display = '';
|
||
document.getElementById('eraseBtn').style.display = '';
|
||
hideModeBar();
|
||
refreshChart();
|
||
} else {
|
||
_angleMode = true;
|
||
_drawMode = true;
|
||
_linkFrom = null;
|
||
document.getElementById('angleBtn').classList.add('btn-orange');
|
||
document.getElementById('angleBtn').classList.remove('btn-secondary');
|
||
document.getElementById('drawBtn').style.display = 'none';
|
||
document.getElementById('eraseBtn').style.display = 'none';
|
||
showModeBar('🔲 直角線模式:點兩 node 繪製 <button onclick="exitLinkMode()" style="background:rgba(255,215,0,0.2);border:none;color:#FFD700;cursor:pointer;font-size:12px;padding:2px 8px;border-radius:10px;text-decoration:underline">完成</button>');
|
||
refreshChart();
|
||
}
|
||
}
|
||
window.toggleAngleMode = toggleAngleMode;
|
||
|
||
// Free draw state
|
||
let _freeDraw = null; // {fromId, points:[[x,y],...]}
|
||
|
||
function toggleFreeMode() {
|
||
if (_freeMode) {
|
||
_freeMode = false;
|
||
_freeDraw = null;
|
||
document.getElementById('freeBtn').classList.remove('btn-orange');
|
||
document.getElementById('freeBtn').classList.add('btn-secondary');
|
||
document.getElementById('drawBtn').style.display = '';
|
||
document.getElementById('angleBtn').style.display = '';
|
||
document.getElementById('eraseBtn').style.display = '';
|
||
hideModeBar();
|
||
_g && _g.selectAll('.free-drawing').remove();
|
||
refreshChart();
|
||
} else {
|
||
_freeMode = true;
|
||
_freeDraw = null;
|
||
document.getElementById('freeBtn').classList.add('btn-orange');
|
||
document.getElementById('freeBtn').classList.remove('btn-secondary');
|
||
document.getElementById('drawBtn').style.display = 'none';
|
||
document.getElementById('angleBtn').style.display = 'none';
|
||
document.getElementById('eraseBtn').style.display = 'none';
|
||
showModeBar('✍️ 自由畫:喺空白位拖拽起點→拖向終點放開 <button onclick="cancelFreeDraw()" style="background:rgba(255,215,0,0.2);border:none;color:#FFD700;cursor:pointer;font-size:12px;padding:2px 8px;border-radius:10px;text-decoration:underline">取消</button>');
|
||
refreshChart();
|
||
}
|
||
}
|
||
window.toggleFreeMode = toggleFreeMode;
|
||
|
||
function cancelFreeDraw() {
|
||
_freeDraw = null;
|
||
_g && _g.selectAll('.free-drawing').remove();
|
||
toggleFreeMode();
|
||
}
|
||
window.cancelFreeDraw = cancelFreeDraw;
|
||
|
||
function exitLinkMode() {
|
||
_drawMode = false;
|
||
_eraseMode = false;
|
||
_angleMode = false;
|
||
_freeMode = false;
|
||
_linkFrom = null;
|
||
_freeDraw = null;
|
||
_g && _g.selectAll('.free-drawing').remove();
|
||
_g && _g.style('cursor', '');
|
||
document.getElementById('angleBtn').classList.remove('btn-orange');
|
||
document.getElementById('angleBtn').classList.add('btn-secondary');
|
||
document.getElementById('drawBtn').style.display = '';
|
||
document.getElementById('angleBtn').style.display = '';
|
||
document.getElementById('freeBtn').classList.remove('btn-orange');
|
||
document.getElementById('freeBtn').classList.add('btn-secondary');
|
||
document.getElementById('freeBtn').style.display = '';
|
||
document.getElementById('eraseBtn').style.display = '';
|
||
document.getElementById('drawBtn').classList.remove('btn-orange');
|
||
document.getElementById('drawBtn').classList.add('btn-secondary');
|
||
document.getElementById('eraseBtn').classList.remove('btn-orange');
|
||
document.getElementById('eraseBtn').classList.add('btn-red');
|
||
hideModeBar();
|
||
if (_pendingG) { _pendingG.remove(); _pendingG = null; }
|
||
refreshChart();
|
||
}
|
||
window.exitLinkMode = exitLinkMode;
|
||
|
||
// ── DEPT LIST ──────────────────────────────────────────────────────────────
|
||
function renderDeptList() {
|
||
const container = document.getElementById('deptList');
|
||
let html = DEPTS.map(dept => {
|
||
const ms = people.filter(p => p.depts && p.depts.includes(dept.id));
|
||
return `<div class="dept-item">
|
||
<div class="dept-header">
|
||
<div class="dept-dot" style="background:${dept.color}"></div>
|
||
<span class="dept-name">${dept.name}</span>
|
||
<span class="dept-count">${ms.length}</span>
|
||
</div>
|
||
<div class="dept-sublist">
|
||
${ms.map(p => `<div class="sub-p">
|
||
<div><div class="sub-name">${p.name}</div><div class="sub-role">${p.role}</div></div>
|
||
<div class="sub-btns" style="opacity:1">
|
||
<button class="btn btn-sm btn-secondary" onclick="openEdit('${p.id}')" style="padding:2px 6px">✏️</button>
|
||
</div>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
html += `<div style="margin-top:12px"><button class="btn btn-secondary btn-sm" onclick="openDeptModal()" style="width:100%">⚙️ 編輯部門</button></div>`;
|
||
container.innerHTML = html;
|
||
document.getElementById('orgCount').textContent = people.length + ' 人';
|
||
}
|
||
|
||
// ── DEPT MODAL ────────────────────────────────────────────────────────────
|
||
function openDeptModal() { tempDepts = JSON.parse(JSON.stringify(DEPTS)); renderDeptEditor(); document.getElementById('deptModal').classList.add('show'); }
|
||
function closeDeptModal() { document.getElementById('deptModal').classList.remove('show'); }
|
||
function renderDeptEditor() {
|
||
document.getElementById('deptEditor').innerHTML = tempDepts.map((d, i) => `
|
||
<div style="display:flex;gap:8px;align-items:center;padding:6px 0;border-bottom:1px dashed #eee">
|
||
<input type="color" value="${d.color}" onchange="tempDepts[${i}].color=this.value" style="width:36px;height:32px;border-radius:6px;border:1.5px solid var(--border);cursor:pointer;padding:0">
|
||
<input type="text" value="${d.name}" oninput="tempDepts[${i}].name=this.value" style="flex:1;padding:6px 8px;border:1.5px solid var(--border);border-radius:6px;font-size:13px">
|
||
<button class="btn btn-red btn-sm" onclick="removeDept(${i})" style="padding:3px 8px">✕</button>
|
||
</div>`).join('');
|
||
}
|
||
function addDept() {
|
||
const n = document.getElementById('newDeptName').value.trim();
|
||
if (!n) { toast('⚠️ 請輸入部門名稱'); return; }
|
||
tempDepts.push({ id: 'd' + Date.now(), name: n, color: document.getElementById('newDeptColor').value });
|
||
document.getElementById('newDeptName').value = '';
|
||
renderDeptEditor();
|
||
}
|
||
function removeDept(i) {
|
||
const d = tempDepts[i];
|
||
if (people.some(p => p.depts && p.depts.includes(d.id))) { toast('⚠️ ' + d.name + ' 仍有人'); return; }
|
||
tempDepts.splice(i, 1); renderDeptEditor();
|
||
}
|
||
function saveDepts() { DEPTS = JSON.parse(JSON.stringify(tempDepts)); renderDeptList(); renderChart(); closeDeptModal(); toast('✅ 部門已儲存'); }
|
||
|
||
// ── D3 CHART ──────────────────────────────────────────────────────────────
|
||
function linkPath(d) {
|
||
const sx = d.source.x, sy = d.source.y + cardH(d.source.data.depts);
|
||
const tx = d.target.x, ty = d.target.y;
|
||
const my = (sy + ty) / 2;
|
||
return `M${sx},${sy} L${sx},${my} L${tx},${my} L${tx},${ty}`;
|
||
}
|
||
|
||
function renderChart() {
|
||
_svg = d3.select('#chartSvg');
|
||
_svg.selectAll('*').remove();
|
||
_pendingG = null;
|
||
_nodeMap = {};
|
||
if (!people.length) {
|
||
_svg.attr('width', 800).attr('height', 400);
|
||
_svg.append('text').attr('x', 400).attr('y', 200).attr('text-anchor', 'middle').attr('fill', '#aaa').attr('font-size', '16px').text('👈 點左邊「載入範本數據」或新增成員開始');
|
||
return;
|
||
}
|
||
people.forEach(p => { _nodeMap[p.id] = p; });
|
||
const dataCopy = people.map(p => ({ ...p, _reports: p.reports && p.reports.length ? p.reports[0] : null }));
|
||
const rootNodes = dataCopy.filter(p => !p._reports);
|
||
if (rootNodes.length > 1) {
|
||
dataCopy.push({ id: '__root', name: 'Root', role: '', depts: [], reports: [], _reports: null });
|
||
rootNodes.forEach(r => { r._reports = '__root'; });
|
||
}
|
||
_root = d3.stratify().id(d => d.id).parentId(d => d._reports || null)(dataCopy);
|
||
if (_root.data.id === '__root' && _root.children) {
|
||
_root.children.forEach(c => _promoteNode(c, 0));
|
||
}
|
||
d3.tree().nodeSize([NODE_W + 44, LEVEL_H]).separation((a, b) => a.parent === b.parent ? 1 : 1.2)(_root);
|
||
|
||
// Collect fixed positions from drag (fx/fy) or saved data (_x/_y)
|
||
const fixed = {};
|
||
_root.each(d => {
|
||
if (d.fx != null) {
|
||
fixed[d.data.id] = { fx: d.fx, fy: d.fy };
|
||
} else {
|
||
const p = people.find(x => x.id === d.data.id);
|
||
if (p && p._x != null && p._y != null) {
|
||
fixed[d.data.id] = { fx: p._x, fy: p._y };
|
||
}
|
||
}
|
||
});
|
||
|
||
// Apply fixed positions (override tree layout)
|
||
_root.each(d => {
|
||
if (fixed[d.data.id]) {
|
||
d.x = fixed[d.data.id].fx;
|
||
d.y = fixed[d.data.id].fy;
|
||
}
|
||
});
|
||
|
||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||
_root.each(d => {
|
||
const ch = cardH(d.data.depts);
|
||
minX = Math.min(minX, d.x - NODE_W / 2); maxX = Math.max(maxX, d.x + NODE_W / 2);
|
||
minY = Math.min(minY, d.y); maxY = Math.max(maxY, d.y + ch);
|
||
});
|
||
const pad = 60, W = maxX - minX + pad * 2, H = maxY - minY + pad * 2;
|
||
const ox = -minX + pad, oy = -minY + pad;
|
||
_chartOX = ox; _chartOY = oy;
|
||
_svg.attr('width', W).attr('height', H);
|
||
_g = _svg.append('g').attr('transform', `translate(${ox},${oy})`);
|
||
_g.append('g').attr('class', 'links-group');
|
||
_g.append('g').attr('class', 'custom-links-group');
|
||
_g.append('g').attr('class', 'angle-links-group');
|
||
_g.append('g').attr('class', 'free-links-group');
|
||
_g.append('g').attr('class', 'nodes-group'); // nodes on top of all links
|
||
|
||
// Transparent overlay for free draw on the SVG (not inside _g so it's not transformed)
|
||
// We draw free lines inside _g using the same coordinate system
|
||
setupFreeDrawEvents();
|
||
|
||
refreshChart();
|
||
|
||
_zoom = d3.zoom().scaleExtent([0.05, 4]).on('zoom', e => {
|
||
_g.attr('transform', `translate(${e.transform.x + _chartOX},${e.transform.y + _chartOY})`);
|
||
});
|
||
_svg.call(_zoom);
|
||
// Wheel zoom on chartContainer (not SVG, so it works over empty space too)
|
||
const cc = document.getElementById('chartContainer');
|
||
cc.addEventListener('wheel', function(e) {
|
||
e.preventDefault();
|
||
if (!_zoom) return;
|
||
const factor = e.deltaY < 0 ? 1.15 : 0.87;
|
||
_svg.transition().duration(150).call(_zoom.scaleBy, factor);
|
||
}, { passive: false });
|
||
// Keyboard zoom: +/- Pan: arrow keys
|
||
document.addEventListener('keydown', function(e) {
|
||
if (!_zoom) return;
|
||
const step = 1.3;
|
||
if (e.key === '+' || e.key === '=') { e.preventDefault(); _svg.transition().duration(200).call(_zoom.scaleBy, step); }
|
||
if (e.key === '-' || e.key === '_') { e.preventDefault(); _svg.transition().duration(200).call(_zoom.scaleBy, 1 / step); }
|
||
const pan = 60;
|
||
if (e.key === 'ArrowLeft') { e.preventDefault(); _svg.transition().duration(150).call(_zoom.translateBy, pan, 0); }
|
||
if (e.key === 'ArrowRight') { e.preventDefault(); _svg.transition().duration(150).call(_zoom.translateBy, -pan, 0); }
|
||
if (e.key === 'ArrowUp') { e.preventDefault(); _svg.transition().duration(150).call(_zoom.translateBy, 0, pan); }
|
||
if (e.key === 'ArrowDown') { e.preventDefault(); _svg.transition().duration(150).call(_zoom.translateBy, 0, -pan); }
|
||
if (e.key === '0') { e.preventDefault(); _svg.transition().duration(300).call(_zoom.transform, d3.zoomIdentity); }
|
||
});
|
||
}
|
||
|
||
function _promoteNode(node, depth) {
|
||
node.depth = depth;
|
||
node.y = depth * LEVEL_H;
|
||
if (node.children) node.children.forEach(c => _promoteNode(c, depth + 1));
|
||
}
|
||
|
||
function refreshChart() {
|
||
if (!_g || !_root) return;
|
||
const descendants = _root.descendants().filter(d => d.data.id !== '__root');
|
||
_root.descendants().forEach(d => { _nodeMap[d.data.id] = d.data; });
|
||
|
||
// ── Auto tree links ──
|
||
const allLinks = [];
|
||
people.forEach(p => {
|
||
if (!p.reports || !p.reports.length) return;
|
||
const td = descendants.find(d => d.data.id === p.id);
|
||
if (!td) return;
|
||
p.reports.forEach(rid => {
|
||
if (rid === '__root') return;
|
||
const sd = descendants.find(d => d.data.id === rid);
|
||
if (!sd) return;
|
||
allLinks.push({ source: sd, target: td });
|
||
});
|
||
});
|
||
|
||
_g.select('.links-group').selectAll('.link')
|
||
.data(allLinks)
|
||
.join('path')
|
||
.attr('class', d => 'link' + (brokenLinks.has(d.source.data.id + '→' + d.target.data.id) ? ' link-broken' : ''))
|
||
.attr('d', linkPath)
|
||
.attr('fill', 'none')
|
||
.attr('stroke', d => {
|
||
const key = d.source.data.id + '→' + d.target.data.id;
|
||
if (brokenLinks.has(key)) return '#FF9944';
|
||
return (selectedIds.has(d.source.data.id) || selectedIds.has(d.target.data.id)) ? '#ED7D31' : '#bbb';
|
||
})
|
||
.attr('stroke-width', d => {
|
||
const key = d.source.data.id + '→' + d.target.data.id;
|
||
if (brokenLinks.has(key)) return 2;
|
||
return (selectedIds.has(d.source.data.id) || selectedIds.has(d.target.data.id)) ? 2 : 1.5;
|
||
})
|
||
|
||
.attr('cursor', _eraseMode ? 'pointer' : 'default')
|
||
.on('click', function(e, d) {
|
||
if (!_eraseMode) return;
|
||
e.stopPropagation();
|
||
const key = d.source.data.id + '→' + d.target.data.id;
|
||
if (brokenLinks.has(key)) {
|
||
// Restore: remove from broken, re-add to reports
|
||
brokenLinks.delete(key);
|
||
// Re-add the reports relationship
|
||
const child = people.find(p => p.id === d.target.data.id);
|
||
if (child && !child.reports.includes(d.source.data.id)) {
|
||
child.reports.unshift(d.source.data.id);
|
||
}
|
||
toast('↩️ 連線已恢復');
|
||
} else {
|
||
// Break: add to broken, remove from reports
|
||
brokenLinks.add(key);
|
||
const child = people.find(p => p.id === d.target.data.id);
|
||
if (child) child.reports = child.reports.filter(r => r !== d.source.data.id);
|
||
toast('🗑️ 預設連線已移除(橙色虛線)');
|
||
}
|
||
refreshChart();
|
||
});
|
||
|
||
// ── Custom links ──
|
||
renderCustomLinks();
|
||
|
||
// ── Angle links (90-degree grey lines) ──
|
||
renderAngleLinks();
|
||
|
||
// ── Nodes ──
|
||
_g.select('.nodes-group').selectAll('.node-card').remove();
|
||
const nodeGs = _g.select('.nodes-group').selectAll('.node-card')
|
||
.data(descendants, d => d.data.id)
|
||
.enter().append('g')
|
||
.attr('class', d => 'node-card' + (selectedIds.has(d.data.id) ? ' sel' : ''))
|
||
.attr('transform', d => `translate(${d.x - NODE_W / 2},${d.y})`)
|
||
.on('click', function(e, d) {
|
||
e.stopPropagation();
|
||
if (_drawMode) { handleDrawClick(d.data.id); return; }
|
||
if (_freeMode) {
|
||
// In free draw mode, click snaps endpoint to node
|
||
if (_freeDraw && _freeDraw.points.length > 1) {
|
||
// Finalize: replace last point with node center
|
||
const nx = d.x, ny = d.y + cardH(d.data.depts) / 2;
|
||
_freeDraw.points.push([nx, ny]);
|
||
_freeDraw.toId = d.data.id;
|
||
freeLinks.push(_freeDraw);
|
||
toast('✍️ 自由線已繪製(可繼續畫)');
|
||
_freeDraw = null;
|
||
_g.selectAll('.free-drawing').remove();
|
||
refreshChart();
|
||
}
|
||
return;
|
||
}
|
||
if (_eraseMode) return;
|
||
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
||
if (selectedIds.has(d.data.id)) selectedIds.delete(d.data.id);
|
||
else selectedIds.add(d.data.id);
|
||
updateSelUI();
|
||
refreshChart();
|
||
} else if (!d._dragMoved) {
|
||
openEdit(d.data.id);
|
||
}
|
||
});
|
||
nodeGs.call(d3.drag()
|
||
.filter(event => !_drawMode && !_eraseMode && !_freeMode)
|
||
.on('start', dragStart)
|
||
.on('drag', dragging)
|
||
.on('end', dragEnd)
|
||
);
|
||
|
||
// Card background
|
||
nodeGs.append('rect').attr('class', d => 'node-rect' + (selectedIds.has(d.data.id) ? ' sel' : ''))
|
||
.attr('width', NODE_W)
|
||
.attr('height', d => cardH(d.data.depts))
|
||
.attr('rx', 8).attr('ry', 8).attr('fill', 'white')
|
||
.attr('stroke', d => selectedIds.has(d.data.id) ? '#ED7D31' : '#ccc')
|
||
.attr('stroke-width', d => selectedIds.has(d.data.id) ? 3 : 1.5);
|
||
|
||
// Dept rows
|
||
nodeGs.each(function(d) {
|
||
const g = d3.select(this);
|
||
const depts2 = d.data.depts || [];
|
||
depts2.forEach((deptId, i) => {
|
||
const rowY = i * DEPT_ROW_H;
|
||
const color = deptColor(deptId);
|
||
g.append('rect').attr('y', rowY).attr('width', NODE_W).attr('height', DEPT_ROW_H)
|
||
.attr('fill', color).attr('rx', i === 0 ? 8 : 0);
|
||
if (i === 0 && depts2.length > 1) g.append('rect').attr('y', DEPT_ROW_H - 6).attr('width', NODE_W).attr('height', 6).attr('fill', color);
|
||
g.append('text').attr('x', 6).attr('y', rowY + 12).attr('fill', 'white').attr('font-size', 9).attr('font-weight', 700)
|
||
.text(deptName(deptId).substring(0, 22));
|
||
});
|
||
const nameY = depts2.length * DEPT_ROW_H + 20;
|
||
// Name - single line, truncate if too long
|
||
const nameText = d.data.name || '';
|
||
const roleText = d.data.role || '';
|
||
g.append('text').attr('x', NODE_W / 2).attr('y', nameY).attr('text-anchor', 'middle').attr('font-size', 12).attr('font-weight', 700).attr('fill', '#1F4E79')
|
||
.text(nameText.length > 14 ? nameText.substring(0, 13) + '…' : nameText);
|
||
// Role - multi-line with tspan, word wrap by words
|
||
const maxWidth = NODE_W - 16; // padding
|
||
const words = roleText.split(/\s+/);
|
||
const roleLines = [];
|
||
let currentLine = '';
|
||
words.forEach(word => {
|
||
const testLine = currentLine ? currentLine + ' ' + word : word;
|
||
// Approximate width check (avg char width = 6px for font-size 10)
|
||
if (testLine.length * 6 > maxWidth && currentLine) {
|
||
roleLines.push(currentLine);
|
||
currentLine = word;
|
||
} else {
|
||
currentLine = testLine;
|
||
}
|
||
});
|
||
if (currentLine) roleLines.push(currentLine);
|
||
|
||
const roleTextEl = g.append('text').attr('x', NODE_W / 2).attr('y', nameY + 16).attr('text-anchor', 'middle').attr('font-size', 10).attr('fill', '#888');
|
||
roleLines.forEach((line, idx) => {
|
||
roleTextEl.append('tspan').attr('x', NODE_W / 2).attr('dy', idx === 0 ? 0 : 14).text(line);
|
||
});
|
||
});
|
||
|
||
nodeGs.on('dblclick.reset', doubleClickReset);
|
||
|
||
// Render free links
|
||
renderFreeLinks();
|
||
|
||
// Set up free draw SVG overlay (mousedown/move/up on _g for coordinate accuracy)
|
||
if (_freeMode) setupFreeDrawEvents();
|
||
|
||
// Always raise nodes-group to top so lines are always below nodes
|
||
if (_g) {
|
||
_g.select('.nodes-group').raise();
|
||
_g.selectAll('.free-drawing').raise();
|
||
_g.selectAll('.pending-group').raise();
|
||
}
|
||
}
|
||
|
||
// ── FREE DRAW ─────────────────────────────────────────────────────────────
|
||
function setupFreeDrawEvents() {
|
||
if (!_g || !_svg) return;
|
||
_g.style('cursor', 'crosshair');
|
||
|
||
_g.on('mousedown.free', function(event) {
|
||
if (!_freeMode) return;
|
||
event.stopPropagation();
|
||
const [mx, my] = d3.pointer(event, _g.node());
|
||
_freeDraw = { fromId: null, toId: null, points: [[mx, my]] };
|
||
_g.selectAll('.free-drawing').remove();
|
||
const pg = _g.append('g').attr('class', 'free-drawing');
|
||
pg.append('polyline').attr('class', 'free-line-preview')
|
||
.attr('fill', 'none').attr('stroke', '#888').attr('stroke-width', 1.5)
|
||
.attr('points', [[mx, my]]);
|
||
|
||
_g.on('mousemove.free', function(ev) {
|
||
if (!_freeDraw) return;
|
||
const [mx2, my2] = d3.pointer(ev, _g.node());
|
||
_freeDraw.points.push([mx2, my2]);
|
||
_g.select('.free-line-preview').attr('points', _freeDraw.points.map(p => p.join(',')).join(' '));
|
||
});
|
||
|
||
_g.on('mouseup.free', function(ev) {
|
||
if (!_freeDraw) return;
|
||
_g.selectAll('.free-drawing').remove();
|
||
_g.on('mousemove.free', null);
|
||
_g.on('mouseup.free', null);
|
||
const pts = _freeDraw.points;
|
||
if (pts.length < 3) { _freeDraw = null; return; }
|
||
// Simplify: reduce point count
|
||
const simplified = simplifyPoints(pts, 4);
|
||
freeLinks.push({ id: 'fl' + Date.now(), fromId: null, toId: null, points: simplified });
|
||
toast('✍️ 自由線已繪製');
|
||
_freeDraw = null;
|
||
refreshChart();
|
||
});
|
||
});
|
||
}
|
||
|
||
// Douglas-Peucker polyline simplification
|
||
function simplifyPoints(pts, tol) {
|
||
if (pts.length < 3) return pts;
|
||
let maxD = 0, idx = 0;
|
||
const end = pts.length - 1;
|
||
for (let i = 1; i < end; i++) {
|
||
const d = perpendicularDist(pts[i], pts[0], pts[end]);
|
||
if (d > maxD) { maxD = d; idx = i; }
|
||
}
|
||
if (maxD > tol) {
|
||
const left = simplifyPoints(pts.slice(0, idx + 1), tol);
|
||
const right = simplifyPoints(pts.slice(idx), tol);
|
||
return left.slice(0, -1).concat(right);
|
||
}
|
||
return [pts[0], pts[end]];
|
||
}
|
||
|
||
function perpendicularDist(p, a, b) {
|
||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
return Math.abs((dy * p[0] - dx * p[1] + b[0] * a[1] - b[1] * a[0]) / len);
|
||
}
|
||
|
||
function renderFreeLinks() {
|
||
if (!_g) return;
|
||
_g.selectAll('.free-links-group').remove();
|
||
if (!freeLinks.length) return;
|
||
const fg = _g.append('g').attr('class', 'free-links-group');
|
||
freeLinks.forEach(link => {
|
||
if (!link.points || link.points.length < 2) return;
|
||
const pts = link.points;
|
||
const g = fg.append('g').attr('class', 'free-link-wrap').datum(link)
|
||
.style('cursor', _eraseMode ? 'pointer' : 'default')
|
||
.on('click', function(e) {
|
||
e.stopPropagation();
|
||
if (!_eraseMode) return;
|
||
freeLinks = freeLinks.filter(l => l.id !== link.id);
|
||
refreshChart();
|
||
toast('🗑️ 自由線已刪除');
|
||
});
|
||
// Helper: build SVG path string from absolute pts
|
||
function buildPath(pts) {
|
||
return pts.map((p, i) => (i === 0 ? 'M' : 'L') + p[0] + ',' + p[1]).join(' ');
|
||
}
|
||
function redrawLine(pts) {
|
||
g.select('.free-link-hit').attr('d', buildPath(pts));
|
||
g.select('.free-link').attr('d', buildPath(pts));
|
||
}
|
||
// Hit area
|
||
g.append('path').attr('class', 'free-link-hit')
|
||
.attr('d', buildPath(pts)).attr('fill', 'none').attr('stroke', 'transparent').attr('stroke-width', 16);
|
||
// Visible line
|
||
g.append('path').attr('class', 'free-link')
|
||
.attr('d', buildPath(pts)).attr('fill', 'none').attr('stroke', '#777').attr('stroke-width', 1.8);
|
||
// Draggable invisible vertices
|
||
pts.forEach((pt, i) => {
|
||
g.append('circle')
|
||
.attr('cx', pt[0]).attr('cy', pt[1]).attr('r', 8)
|
||
.attr('fill', 'transparent')
|
||
.attr('stroke', 'transparent')
|
||
.style('cursor', _eraseMode ? 'default' : 'move')
|
||
.call(d3.drag()
|
||
.filter(e => !_eraseMode)
|
||
.on('drag', function(ev) {
|
||
const [mx, my] = d3.pointer(ev, _g.node());
|
||
const dx = mx - pts[i][0];
|
||
const dy = my - pts[i][1];
|
||
for (let j = i; j < pts.length; j++) {
|
||
pts[j][0] += dx;
|
||
pts[j][1] += dy;
|
||
}
|
||
link.points = JSON.parse(JSON.stringify(pts));
|
||
redrawLine(pts);
|
||
g.selectAll('circle').each(function(d, idx) {
|
||
d3.select(this).attr('cx', pts[idx][0]).attr('cy', pts[idx][1]);
|
||
});
|
||
})
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
function handleDrawClick(nodeId) {
|
||
if (!_linkFrom) {
|
||
_linkFrom = nodeId;
|
||
showModeBar('✏️ <b>' + (people.find(p => p.id === nodeId)?.name || '') + '</b> → 點第二個 node <button onclick="exitLinkMode()" style="background:rgba(255,215,0,0.2);border:none;color:#FFD700;cursor:pointer;font-size:12px;padding:2px 8px;border-radius:10px;text-decoration:underline">取消</button>');
|
||
// Create pending line starting at this node
|
||
_pendingG = _g.append('g').attr('class', 'pending-group');
|
||
refreshChart();
|
||
} else {
|
||
if (nodeId !== _linkFrom) {
|
||
// Check if this is a broken tree link — restore it as grey line
|
||
const k1 = _linkFrom + '→' + nodeId;
|
||
const k2 = nodeId + '→' + _linkFrom;
|
||
const wasBrokenFwd = brokenLinks.has(k1); // _linkFrom was parent, nodeId was child
|
||
const wasBrokenRev = brokenLinks.has(k2); // nodeId was parent, _linkFrom was child
|
||
if (wasBrokenFwd) {
|
||
brokenLinks.delete(k1);
|
||
const child = people.find(p => p.id === nodeId);
|
||
if (child && !child.reports.includes(_linkFrom)) child.reports.unshift(_linkFrom);
|
||
toast('↩️ 預設連線已恢復(灰色)');
|
||
} else if (wasBrokenRev) {
|
||
brokenLinks.delete(k2);
|
||
const child = people.find(p => p.id === _linkFrom);
|
||
if (child && !child.reports.includes(nodeId)) child.reports.unshift(nodeId);
|
||
toast('↩️ 預設連線已恢復(灰色)');
|
||
} else {
|
||
const exists = customLinks.some(l =>
|
||
(l.fromId === _linkFrom && l.toId === nodeId) ||
|
||
(l.fromId === nodeId && l.toId === _linkFrom)
|
||
);
|
||
if (!exists) {
|
||
if (_angleMode) {
|
||
angleLinks.push({ id: 'al' + Date.now(), fromId: _linkFrom, toId: nodeId });
|
||
toast('🔲 直角線已繪製(可繼續畫)');
|
||
showModeBar('🔲 繼續點 node 畫更多直角線 <button onclick="exitLinkMode()" style="background:rgba(255,215,0,0.2);border:none;color:#FFD700;cursor:pointer;font-size:12px;padding:2px 8px;border-radius:10px;text-decoration:underline">完成</button>');
|
||
} else {
|
||
customLinks.push({ id: 'cl' + Date.now(), fromId: _linkFrom, toId: nodeId });
|
||
toast('✅ 連線已繪製(可繼續畫)');
|
||
showModeBar('✅ 繼續點 node 畫更多連線 <button onclick="exitLinkMode()" style="background:rgba(255,215,0,0.2);border:none;color:#FFD700;cursor:pointer;font-size:12px;padding:2px 8px;border-radius:10px;text-decoration:underline">完成</button>');
|
||
}
|
||
} else {
|
||
toast('⚠️ 該連線已存在');
|
||
}
|
||
}
|
||
}
|
||
_linkFrom = null;
|
||
if (_pendingG) { _pendingG.remove(); _pendingG = null; }
|
||
refreshChart();
|
||
}
|
||
}
|
||
|
||
function renderCustomLinks() {
|
||
if (!_g || !_root) return;
|
||
|
||
// Pending line from _linkFrom node to mouse
|
||
if (_drawMode && _linkFrom && _root) {
|
||
_g.selectAll('.pending-group').remove();
|
||
const pendingG = _g.append('g').attr('class', 'pending-group');
|
||
const fromNd = _root.descendants().find(n => n.data.id === _linkFrom);
|
||
if (fromNd) {
|
||
const x1 = fromNd.x, y1 = fromNd.y + cardH(fromNd.data.depts) / 2;
|
||
// We'll use mousemove to update this
|
||
_pendingG = pendingG;
|
||
pendingG.append('line').attr('class', 'pending-line')
|
||
.attr('x1', x1).attr('y1', y1).attr('x2', x1).attr('y2', y1);
|
||
}
|
||
// Set up one-time mousemove
|
||
_svg.on('mousemove.pending', function(e) {
|
||
if (!_drawMode || !_linkFrom) return;
|
||
const [mx, my] = d3.pointer(e, _g.node());
|
||
_g.select('.pending-group line').attr('x2', mx).attr('y2', my);
|
||
});
|
||
} else {
|
||
_g.selectAll('.pending-group').remove();
|
||
_svg && _svg.on('mousemove.pending', null);
|
||
}
|
||
|
||
// Custom links
|
||
_g.select('.custom-links-group').selectAll('.custom-link-wrap').remove();
|
||
customLinks.forEach(link => {
|
||
const fromNd = _root.descendants().find(n => n.data.id === link.fromId);
|
||
const toNd = _root.descendants().find(n => n.data.id === link.toId);
|
||
if (!fromNd || !toNd) return;
|
||
|
||
const x1 = fromNd.x, y1 = fromNd.y + cardH(fromNd.data.depts) / 2;
|
||
const x2 = toNd.x, y2 = toNd.y;
|
||
const c1x = x1, c1y = y1 + 60, c2x = x2, c2y = y2 - 60;
|
||
const bezierD = `M${x1},${y1} C${c1x},${c1y} ${c2x},${c2y} ${x2},${y2}`;
|
||
|
||
const g = _g.select('.custom-links-group').append('g').attr('class', 'custom-link-wrap').datum(link);
|
||
|
||
// Wide invisible hit area
|
||
g.append('path').attr('d', bezierD).attr('class', 'custom-link-hit')
|
||
.on('click', function(e) {
|
||
if (!_eraseMode) return;
|
||
e.stopPropagation();
|
||
customLinks = customLinks.filter(l => l.id !== link.id);
|
||
refreshChart();
|
||
toast('🗑️ 連線已刪除');
|
||
});
|
||
|
||
// Visible dashed line
|
||
g.append('path').attr('d', bezierD).attr('class', 'custom-link-path')
|
||
.style('pointer-events', 'none');
|
||
|
||
// Arrow head
|
||
g.append('path')
|
||
.attr('d', `M${x2 - 7},${y2 - 7} L${x2},${y2} L${x2 - 7},${y2 + 7}`)
|
||
.attr('fill', 'none').attr('stroke', '#ED7D31').attr('stroke-width', 2.5)
|
||
.style('pointer-events', 'none');
|
||
});
|
||
}
|
||
|
||
// ── ANGLE LINKS (90-degree grey lines) ────────────────────────────────────
|
||
function renderAngleLinks() {
|
||
if (!_g || !_root) return;
|
||
_g.selectAll('.angle-links-group').remove();
|
||
if (!angleLinks.length) return;
|
||
const ag = _g.append('g').attr('class', 'angle-links-group');
|
||
angleLinks.forEach(link => {
|
||
const fromNd = _root.descendants().find(n => n.data.id === link.fromId);
|
||
const toNd = _root.descendants().find(n => n.data.id === link.toId);
|
||
if (!fromNd || !toNd) return;
|
||
let x1 = fromNd.x, y1 = fromNd.y + cardH(fromNd.data.depts) / 2;
|
||
let x2 = toNd.x, y2 = toNd.y;
|
||
// cx,cy stored in absolute coords (default midpoint)
|
||
let cx = link.cx != null ? link.cx : (x1 + x2) / 2;
|
||
let cy = link.cy != null ? link.cy : y1;
|
||
const pathD = `M${x1},${y1} L${cx},${cy} L${x2},${y2}`;
|
||
const g = ag.append('g').attr('class', 'angle-link-wrap').datum(link)
|
||
.on('click', function(e) {
|
||
e.stopPropagation();
|
||
if (_eraseMode) {
|
||
angleLinks = angleLinks.filter(l => l.id !== link.id);
|
||
refreshChart();
|
||
toast('🗑️ 直角線已刪除');
|
||
}
|
||
});
|
||
g.append('path').attr('d', pathD).attr('class', 'angle-link-hit')
|
||
.style('cursor', _eraseMode ? 'default' : 'move')
|
||
.call(d3.drag()
|
||
.filter(e => !_eraseMode)
|
||
.on('drag', function(ev) {
|
||
const [mx, my] = d3.pointer(ev, _g.node());
|
||
cx = mx; cy = my;
|
||
link.cx = cx; link.cy = cy;
|
||
const newD = `M${x1},${y1} L${cx},${cy} L${x2},${y2}`;
|
||
g.select('.angle-link-hit').attr('d', newD);
|
||
g.select('.angle-link').attr('d', newD);
|
||
})
|
||
);
|
||
g.append('path').attr('d', pathD).attr('class', 'angle-link');
|
||
});
|
||
}
|
||
|
||
// ── DRAG ──────────────────────────────────────────────────────────────────
|
||
// Fixed multi-select drag: properly initialize offsets once at drag start
|
||
function dragStart(event, d) {
|
||
// If Ctrl/Cmd or Shift is held, skip drag and let click handler do multi-select
|
||
if (event.sourceEvent.ctrlKey || event.sourceEvent.metaKey || event.sourceEvent.shiftKey) {
|
||
return;
|
||
}
|
||
|
||
// If this node is not in selection, make it the only selection
|
||
if (!selectedIds.has(d.data.id)) {
|
||
selectedIds.clear();
|
||
selectedIds.add(d.data.id);
|
||
updateSelUI();
|
||
}
|
||
|
||
// Get SVG coordinates from event (accounting for any zoom)
|
||
const svgPoint = d3.pointer(event, _g.node());
|
||
const svgX = svgPoint[0];
|
||
const svgY = svgPoint[1];
|
||
|
||
// Initialize drag offsets only once at the beginning of this drag operation
|
||
if (!_dragInitialized) {
|
||
_dragInitialized = true;
|
||
_dragOff = {};
|
||
selectedIds.forEach(id => {
|
||
const nd = _root.descendants().find(n => n.data.id === id);
|
||
if (nd) {
|
||
_dragOff[id] = { x: nd.x - svgX, y: nd.y - svgY };
|
||
}
|
||
});
|
||
}
|
||
|
||
// Suppress click that would fire after this drag
|
||
d._dragMoved = false;
|
||
}
|
||
function dragging(event, d) {
|
||
d._dragMoved = true;
|
||
|
||
// Get SVG coordinates from event (accounting for any zoom)
|
||
const svgPoint = d3.pointer(event, _g.node());
|
||
const svgX = svgPoint[0];
|
||
const svgY = svgPoint[1];
|
||
|
||
selectedIds.forEach(id => {
|
||
const nd = _root.descendants().find(n => n.data.id === id);
|
||
if (!nd) return;
|
||
const off = _dragOff[id] || { x: 0, y: 0 };
|
||
const newX = svgX + off.x;
|
||
const newY = svgY + off.y;
|
||
|
||
nd.x = newX;
|
||
nd.y = newY;
|
||
|
||
// Move the node visually
|
||
_g.select('.nodes-group').selectAll('.node-card')
|
||
.filter(n => n.data.id === id)
|
||
.attr('transform', `translate(${nd.x - NODE_W / 2},${nd.y})`);
|
||
});
|
||
|
||
// Update tree links
|
||
_g.select('.links-group').selectAll('.link').attr('d', linkPath);
|
||
|
||
// Update custom links
|
||
_g.select('.custom-links-group').selectAll('.custom-link-wrap').each(function(link) {
|
||
const fromNd = _root.descendants().find(n => n.data.id === link.fromId);
|
||
const toNd = _root.descendants().find(n => n.data.id === link.toId);
|
||
if (!fromNd || !toNd) return;
|
||
const x1 = fromNd.x, y1 = fromNd.y + cardH(fromNd.data.depts) / 2;
|
||
const x2 = toNd.x, y2 = toNd.y;
|
||
const c1x = x1, c1y = y1 + 60, c2x = x2, c2y = y2 - 60;
|
||
const d = `M${x1},${y1} C${c1x},${c1y} ${c2x},${c2y} ${x2},${y2}`;
|
||
d3.select(this).selectAll('path').attr('d', d);
|
||
});
|
||
|
||
// Update angle links
|
||
_g.select('.angle-links-group').selectAll('.angle-link-wrap').each(function() {
|
||
const link = d3.select(this).datum();
|
||
const fromNd = _root.descendants().find(n => n.data.id === link.fromId);
|
||
const toNd = _root.descendants().find(n => n.data.id === link.toId);
|
||
if (!fromNd || !toNd) return;
|
||
const x1 = fromNd.x, y1 = fromNd.y + cardH(fromNd.data.depts) / 2;
|
||
const x2 = toNd.x, y2 = toNd.y;
|
||
const cx = link.cx != null ? link.cx : (x1 + x2) / 2;
|
||
const cy = link.cy != null ? link.cy : y1;
|
||
const newD = `M${x1},${y1} L${cx},${cy} L${x2},${y2}`;
|
||
d3.select(this).selectAll('path').attr('d', newD);
|
||
d3.select(this).select('circle').attr('cx', cx).attr('cy', cy);
|
||
});
|
||
|
||
// Update free links
|
||
_g.select('.free-links-group').selectAll('.free-link-wrap').each(function() {
|
||
const link = d3.select(this).datum();
|
||
if (!link.points || link.points.length < 2) return;
|
||
const pts = link.points;
|
||
function buildPath(pts) {
|
||
return pts.map((p, i) => (i === 0 ? 'M' : 'L') + p[0] + ',' + p[1]).join(' ');
|
||
}
|
||
d3.select(this).selectAll('path').attr('d', buildPath(pts));
|
||
});
|
||
}
|
||
function dragEnd(event, d) {
|
||
// Save state for undo before changing positions
|
||
saveState();
|
||
|
||
// Save positions for all selected nodes
|
||
selectedIds.forEach(id => {
|
||
const nd = _root.descendants().find(n => n.data.id === id);
|
||
if (!nd) return;
|
||
nd.fx = nd.x;
|
||
nd.fy = nd.y;
|
||
|
||
// Keep in sync with people data (used by exportJSON)
|
||
const p = people.find(x => x.id === id);
|
||
if (p) { p._x = nd.x; p._y = nd.y; p.fx = nd.x; p.fy = nd.y; }
|
||
});
|
||
|
||
// Reset drag state
|
||
_dragInitialized = false;
|
||
_dragOff = {};
|
||
}
|
||
function doubleClickReset(event, d) {
|
||
d.fx = null; d.fy = null;
|
||
d3.tree().nodeSize([NODE_W + 44, LEVEL_H]).separation((a, b) => a.parent === b.parent ? 1 : 1.2)(_root);
|
||
refreshChart();
|
||
}
|
||
function resetPositions() {
|
||
if (!_root) return;
|
||
_root.each(n => { n.fx = null; n.fy = null; });
|
||
d3.tree().nodeSize([NODE_W + 44, LEVEL_H]).separation((a, b) => a.parent === b.parent ? 1 : 1.2)(_root);
|
||
refreshChart();
|
||
toast('✅ 位置已重置');
|
||
}
|
||
|
||
function savePositions() {
|
||
if (!_root) { toast('⚠️ 無圖表'); return; }
|
||
let saved = 0;
|
||
|
||
// Save ALL people with positions (not just those in tree)
|
||
people.forEach(p => {
|
||
// Find the node in tree
|
||
const nd = _root.descendants().find(n => n.data.id === p.id);
|
||
if (!nd) return;
|
||
|
||
// Save position if it exists
|
||
if (nd.fx != null || nd.fy != null || p._x != null || p._y != null) {
|
||
const x = nd.fx != null ? nd.fx : (p._x != null ? p._x : nd.x);
|
||
const y = nd.fy != null ? nd.fy : (p._y != null ? p._y : nd.y);
|
||
p._x = Math.round(x * 10) / 10;
|
||
p._y = Math.round(y * 10) / 10;
|
||
p.fx = p._x;
|
||
p.fy = p._y;
|
||
saved++;
|
||
}
|
||
});
|
||
|
||
const payload = people.filter(p => p._x != null).map(p => ({ id: p.id, _x: p._x, _y: p._y }));
|
||
localStorage.setItem('sc_org_positions', JSON.stringify(payload));
|
||
refreshChart();
|
||
toast('💾 已儲存 ' + saved + ' 人位置');
|
||
console.log('[SAVE] Saved', saved, 'positions:', payload);
|
||
}
|
||
|
||
// ── UNDO/REDO ─────────────────────────────────────────────────────────────
|
||
const MAX_HISTORY = 30;
|
||
let _history = [];
|
||
let _historyIndex = -1;
|
||
|
||
function saveState() {
|
||
// Remove any future states if we're not at the end
|
||
if (_historyIndex < _history.length - 1) {
|
||
_history = _history.slice(0, _historyIndex + 1);
|
||
}
|
||
// Clone current positions
|
||
const state = people.map(p => ({ id: p.id, _x: p._x, _y: p._y, fx: p.fx, fy: p.fy }));
|
||
_history.push(state);
|
||
if (_history.length > MAX_HISTORY) _history.shift();
|
||
_historyIndex = _history.length - 1;
|
||
updateUndoRedoButtons();
|
||
}
|
||
|
||
function updateUndoRedoButtons() {
|
||
const undoBtn = document.getElementById('undoBtn');
|
||
const redoBtn = document.getElementById('redoBtn');
|
||
if (undoBtn) undoBtn.disabled = _historyIndex <= 0;
|
||
if (redoBtn) redoBtn.disabled = _historyIndex >= _history.length - 1;
|
||
}
|
||
|
||
function undoAction() {
|
||
if (_historyIndex <= 0) return;
|
||
_historyIndex--;
|
||
restoreState(_history[_historyIndex]);
|
||
updateUndoRedoButtons();
|
||
toast('↩️ 上一步');
|
||
}
|
||
|
||
function redoAction() {
|
||
if (_historyIndex >= _history.length - 1) return;
|
||
_historyIndex++;
|
||
restoreState(_history[_historyIndex]);
|
||
updateUndoRedoButtons();
|
||
toast('↪️ 下一步');
|
||
}
|
||
|
||
function restoreState(state) {
|
||
state.forEach(s => {
|
||
const p = people.find(x => x.id === s.id);
|
||
if (p) {
|
||
p._x = s._x; p._y = s._y; p.fx = s.fx; p.fy = s.fy;
|
||
}
|
||
});
|
||
if (_root) {
|
||
_root.each(n => {
|
||
if (n.data.id === '__root') return;
|
||
const p = people.find(x => x.id === n.data.id);
|
||
if (p && p._x != null) {
|
||
n.x = p._x; n.y = p._y; n.fx = p.fx; n.fy = p.fy;
|
||
} else {
|
||
n.fx = null; n.fy = null;
|
||
}
|
||
});
|
||
}
|
||
renderChart();
|
||
}
|
||
|
||
function clearHistory() {
|
||
_history = [];
|
||
_historyIndex = -1;
|
||
updateUndoRedoButtons();
|
||
}
|
||
|
||
// Auto-save state before drag starts
|
||
const _origDragStart = null; // will be set in init
|
||
|
||
// ── VERSION MANAGEMENT ─────────────────────────────────────────────────────
|
||
let _versions = JSON.parse(localStorage.getItem('sc_org_versions') || '[]');
|
||
|
||
function openVersionModal() {
|
||
renderVersionList();
|
||
document.getElementById('versionModal').classList.add('show');
|
||
}
|
||
function closeVersionModal() { document.getElementById('versionModal').classList.remove('show'); }
|
||
|
||
function renderVersionList() {
|
||
const container = document.getElementById('versionList');
|
||
if (!_versions.length) {
|
||
container.innerHTML = '<div style="text-align:center;color:#888;padding:20px">暫無儲存版本<br><br>修改位置後,點「儲存新版本」保存</div>';
|
||
return;
|
||
}
|
||
const now = new Date();
|
||
container.innerHTML = _versions.map((v, i) => {
|
||
const d = new Date(v.time);
|
||
const timeStr = d.toLocaleString('zh-HK', { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' });
|
||
const posCount = v.positions ? v.positions.length : 0;
|
||
return `<div style="display:flex;align-items:center;gap:10px;padding:10px;border-bottom:1px solid #eee">
|
||
<div style="flex:1">
|
||
<div style="font-weight:600">${v.name}</div>
|
||
<div style="font-size:11px;color:#888">${timeStr} · ${posCount} 人位置</div>
|
||
</div>
|
||
<button class="btn btn-sm btn-primary" onclick="loadVersion(${i})" style="margin-right:4px">載入</button>
|
||
<button class="btn btn-sm btn-red" onclick="deleteVersion(${i})">✕</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function saveVersion() {
|
||
const name = document.getElementById('newVersionName').value.trim();
|
||
if (!name) { toast('⚠️ 請輸入版本名稱'); return; }
|
||
if (!_root) { toast('⚠️ 無圖表'); return; }
|
||
|
||
// Collect all positions
|
||
const positions = [];
|
||
people.forEach(p => {
|
||
const nd = _root.descendants().find(n => n.data.id === p.id);
|
||
if (!nd) return;
|
||
const x = nd.fx != null ? nd.fx : (p._x != null ? p._x : nd.x);
|
||
const y = nd.fy != null ? nd.fy : (p._y != null ? p._y : nd.y);
|
||
if (nd.fx != null || p._x != null) {
|
||
positions.push({ id: p.id, _x: Math.round(x * 10) / 10, _y: Math.round(y * 10) / 10 });
|
||
}
|
||
});
|
||
|
||
const version = {
|
||
name: name,
|
||
time: new Date().toISOString(),
|
||
positions: positions,
|
||
peopleCount: people.length
|
||
};
|
||
_versions.unshift(version); // Add to top
|
||
if (_versions.length > 20) _versions.pop(); // Keep max 20 versions
|
||
localStorage.setItem('sc_org_versions', JSON.stringify(_versions));
|
||
document.getElementById('newVersionName').value = '';
|
||
renderVersionList();
|
||
toast('✅ 版本「' + name + '」已儲存');
|
||
}
|
||
|
||
function loadVersion(index) {
|
||
if (!_versions[index]) return;
|
||
saveState(); // Save current state before loading
|
||
const v = _versions[index];
|
||
|
||
// Apply positions to people array
|
||
v.positions.forEach(pos => {
|
||
const p = people.find(x => x.id === pos.id);
|
||
if (p) {
|
||
p._x = pos._x;
|
||
p._y = pos._y;
|
||
p.fx = pos._x;
|
||
p.fy = pos._y;
|
||
}
|
||
});
|
||
|
||
// Rebuild tree and apply positions
|
||
_root = null;
|
||
renderChart();
|
||
closeVersionModal();
|
||
toast('✅ 已載入「' + v.name + '」');
|
||
}
|
||
|
||
function deleteVersion(index) {
|
||
if (!_versions[index]) return;
|
||
const name = _versions[index].name;
|
||
_versions.splice(index, 1);
|
||
localStorage.setItem('sc_org_versions', JSON.stringify(_versions));
|
||
renderVersionList();
|
||
toast('🗑️ 版本「' + name + '」已刪除');
|
||
}
|
||
|
||
function clearPositions() {
|
||
saveState();
|
||
people.forEach(p => { p._x = undefined; p._y = undefined; p.fx = undefined; p.fy = undefined; });
|
||
if (_root) _root.each(d => { d.fx = null; d.fy = null; });
|
||
localStorage.removeItem('sc_org_positions');
|
||
renderChart();
|
||
toast('🗑️ 已清除所有自訂位置');
|
||
}
|
||
|
||
// ── ZOOM ──────────────────────────────────────────────────────────────────
|
||
function zoomIn() { if (!_svg || !_zoom) return; _svg.transition().duration(300).call(_zoom.scaleBy, 1.4); }
|
||
function zoomOut() { if (!_svg || !_zoom) return; _svg.transition().duration(300).call(_zoom.scaleBy, 0.7); }
|
||
function fitToScreen() {
|
||
if (!_svg || !_zoom || !_root) return;
|
||
const cc = document.getElementById('chartContainer');
|
||
const cw = cc.clientWidth - 32;
|
||
const ch = cc.clientHeight - 32;
|
||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||
_root.each(d => {
|
||
minX = Math.min(minX, d.x - NODE_W / 2); maxX = Math.max(maxX, d.x + NODE_W / 2);
|
||
minY = Math.min(minY, d.y); maxY = Math.max(maxY, d.y + cardH(d.data.depts));
|
||
});
|
||
const pad = 60;
|
||
const chartW = maxX - minX + pad * 2;
|
||
const chartH = maxY - minY + pad * 2;
|
||
const scale = Math.min(cw / chartW, ch / chartH, 1);
|
||
const midX = (minX + maxX) / 2;
|
||
const midY = (minY + maxY) / 2;
|
||
const tx = cw / 2 - midX * scale;
|
||
const ty = ch / 2 - midY * scale;
|
||
_svg.transition().duration(500).call(_zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
|
||
}
|
||
|
||
// ── SIDEBAR ────────────────────────────────────────────────────────────────
|
||
function toggleSidebar() { document.getElementById('sidebar').classList.toggle('hidden'); }
|
||
|
||
// ── EDIT MODAL ─────────────────────────────────────────────────────────────
|
||
function openEdit(id) {
|
||
const p = people.find(x => x.id === id);
|
||
if (!p) return;
|
||
selectedId = id;
|
||
_editId = id;
|
||
document.getElementById('modalTitle').textContent = '✏️ ' + p.name;
|
||
document.getElementById('mName').value = p.name;
|
||
document.getElementById('mRole').value = p.role;
|
||
renderDeptCheckbox('mDeptList', p.depts || []);
|
||
renderReportsCheckbox('mReportsList', p.reports || [], id);
|
||
document.getElementById('editModal').classList.add('show');
|
||
}
|
||
function closeModal() { document.getElementById('editModal').classList.remove('show'); selectedId = null; _editId = null; refreshChart(); }
|
||
document.getElementById('editModal').addEventListener('click', e => { if (e.target === document.getElementById('editModal')) closeModal(); });
|
||
function saveEdit() {
|
||
if (!_editId) return;
|
||
const p = people.find(x => x.id === _editId);
|
||
if (!p) { closeModal(); return; }
|
||
p.name = document.getElementById('mName').value.trim() || p.name;
|
||
p.role = document.getElementById('mRole').value.trim() || p.role;
|
||
p.depts = getCheckedDepts('mDeptList');
|
||
p.reports = getCheckedDepts('mReportsList');
|
||
renderDeptList(); renderChart(); closeModal(); toast('✅ 已儲存');
|
||
}
|
||
function deletePerson() {
|
||
if (!_editId) return;
|
||
const p = people.find(x => x.id === _editId);
|
||
if (!p) { closeModal(); return; }
|
||
if (!confirm('刪除 ' + p.name + '?')) return;
|
||
people.forEach(pp => { if (pp.reports) pp.reports = pp.reports.filter(r => r !== _editId); });
|
||
people = people.filter(x => x.id !== _editId);
|
||
selectedIds.delete(_editId);
|
||
renderDeptList(); renderChart(); closeModal(); toast('🗑️ 已刪除');
|
||
}
|
||
|
||
// ── ADD ────────────────────────────────────────────────────────────────────
|
||
function addPerson() {
|
||
const name = document.getElementById('addName').value.trim();
|
||
const role = document.getElementById('addRole').value.trim();
|
||
const depts = getCheckedDepts('addDeptList');
|
||
const reports = getCheckedDepts('addReportsList');
|
||
if (!name || !role) { toast('⚠️ 請填寫姓名和職位'); return; }
|
||
if (!depts.length) { toast('⚠️ 請選擇至少一個部門'); return; }
|
||
people.push({ id: 'p' + Date.now(), name, role, depts, reports });
|
||
renderDeptList(); renderChart(); clearAddForm(); toast('✅ 已新增 ' + name);
|
||
}
|
||
function clearAddForm() {
|
||
document.getElementById('addName').value = '';
|
||
document.getElementById('addRole').value = '';
|
||
renderDeptCheckbox('addDeptList', []);
|
||
renderReportsCheckbox('addReportsList', [], null);
|
||
}
|
||
|
||
// ── EXPORT / IMPORT ─────────────────────────────────────────────────────────
|
||
function exportSVG() {
|
||
const svgEl = document.getElementById('chartSvg');
|
||
// Clone SVG for export
|
||
const clone = svgEl.cloneNode(true);
|
||
// Add transparent background rect
|
||
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||
bgRect.setAttribute('x', '0');
|
||
bgRect.setAttribute('y', '0');
|
||
bgRect.setAttribute('width', svgEl.clientWidth);
|
||
bgRect.setAttribute('height', svgEl.clientHeight);
|
||
bgRect.setAttribute('fill', 'none');
|
||
clone.insertBefore(bgRect, clone.firstChild);
|
||
// Apply inline styles for export
|
||
applyExportStyles(clone);
|
||
const serializer = new XMLSerializer();
|
||
const svgStr = serializer.serializeToString(clone);
|
||
const blob = new Blob([svgStr], { type: 'image/svg+xml' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a'); a.href = url; a.download = 'SC_Medical_Group_Org_Chart.svg'; a.click();
|
||
URL.revokeObjectURL(url); toast('✅ SVG 已下載');
|
||
}
|
||
function exportPNG() {
|
||
const svgEl = document.getElementById('chartSvg');
|
||
// Clone SVG for export
|
||
const clone = svgEl.cloneNode(true);
|
||
// Add transparent background rect
|
||
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||
bgRect.setAttribute('x', '0');
|
||
bgRect.setAttribute('y', '0');
|
||
bgRect.setAttribute('width', svgEl.clientWidth);
|
||
bgRect.setAttribute('height', svgEl.clientHeight);
|
||
bgRect.setAttribute('fill', 'none');
|
||
clone.insertBefore(bgRect, clone.firstChild);
|
||
// Apply inline styles for export
|
||
applyExportStyles(clone);
|
||
const serializer = new XMLSerializer();
|
||
const svgStr = serializer.serializeToString(clone);
|
||
const svgBlob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
|
||
const url = URL.createObjectURL(svgBlob);
|
||
const img = new Image();
|
||
img.onload = function() {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = svgEl.clientWidth * 2;
|
||
canvas.height = svgEl.clientHeight * 2;
|
||
const ctx = canvas.getContext('2d');
|
||
// Transparent background
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||
canvas.toBlob(function(blob) {
|
||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||
a.download = 'SC_Medical_Group_Org_Chart.png'; a.click();
|
||
URL.revokeObjectURL(url);
|
||
toast('✅ PNG 已下載 (透明背景)');
|
||
}, 'image/png');
|
||
};
|
||
img.src = url;
|
||
}
|
||
function applyExportStyles(clone) {
|
||
// CRITICAL: Set fill:none for ALL paths and lines (prevents black fill in exported image)
|
||
clone.querySelectorAll('path, line, polyline, polygon, circle, ellipse').forEach(el => {
|
||
el.setAttribute('fill', 'none');
|
||
});
|
||
// Set stroke for all link paths (custom links, angle links, free links, tree links)
|
||
clone.querySelectorAll('.custom-link-path, .angle-link, .free-link, .link, line').forEach(el => {
|
||
if (!el.getAttribute('stroke') || el.getAttribute('stroke') === 'none') {
|
||
el.setAttribute('stroke', '#888');
|
||
}
|
||
el.setAttribute('stroke-width', el.getAttribute('stroke-width') || '1.5');
|
||
});
|
||
// Ensure node rects are white
|
||
clone.querySelectorAll('.node-rect').forEach(el => {
|
||
el.setAttribute('fill', 'white');
|
||
el.setAttribute('stroke', el.getAttribute('stroke') || '#ccc');
|
||
});
|
||
}
|
||
|
||
function exportJSON() {
|
||
const data = {
|
||
people: people,
|
||
depts: DEPTS,
|
||
positions: people.filter(p => p._x != null).map(p => ({ id: p.id, _x: p._x, _y: p._y })),
|
||
customLinks: customLinks,
|
||
angleLinks: angleLinks,
|
||
freeLinks: freeLinks
|
||
};
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a'); a.href = url; a.download = 'org_chart_data.json'; a.click();
|
||
URL.revokeObjectURL(url); toast('✅ JSON 已導出');
|
||
}
|
||
function importJSON(event) {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = function(e) {
|
||
try {
|
||
const data = JSON.parse(e.target.result);
|
||
if (data.people) people = data.people;
|
||
if (data.depts) DEPTS = data.depts;
|
||
if (data.positions) {
|
||
data.positions.forEach(p => {
|
||
const person = people.find(x => x.id === p.id);
|
||
if (person) { person._x = p._x; person._y = p._y; person.fx = p._x; person.fy = p._y; }
|
||
});
|
||
}
|
||
if (data.customLinks) customLinks = data.customLinks;
|
||
if (data.angleLinks) angleLinks = data.angleLinks;
|
||
if (data.freeLinks) freeLinks = data.freeLinks;
|
||
renderDeptList(); renderChart(); toast('✅ 已導入');
|
||
clearHistory(); saveState();
|
||
} catch (err) { toast('⚠️ 導入失敗'); console.error(err); }
|
||
};
|
||
reader.readAsText(file);
|
||
event.target.value = '';
|
||
}
|
||
function loadSampleData() {
|
||
people = [
|
||
{id:'p1', name:'陳大明', role:'行政總監', depts:['exec'], reports:[]},
|
||
{id:'p2', name:'李思穎', role:'行政助理', depts:['admin'], reports:['p1']},
|
||
{id:'p3', name:'王健康', role:'物理治療師', depts:['hola'], reports:['p1']},
|
||
{id:'p4', name:'張小明', role:'iHealth 主管', depts:['ihealth'], reports:['p1']},
|
||
{id:'p5', name:'陳美美', role:'SC Health 醫生', depts:['sch'], reports:['p1']},
|
||
{id:'p6', name:'林偉', role:'SC Health Care 主管', depts:['schc'], reports:['p1']},
|
||
{id:'p7', name:'黃慧玲', role:'SC Day Procedure 護士', depts:['scdpc'], reports:['p1']},
|
||
{id:'p8', name:'周傑', role:'Zenith Health 主管', depts:['zenith'], reports:['p1']},
|
||
{id:'p9', name:'吳嘉欣', role:'SC Care 護士長', depts:['sccare'], reports:['p1']},
|
||
];
|
||
renderDeptList(); renderChart(); toast('✅ 範本數據已載入');
|
||
clearHistory();
|
||
saveState();
|
||
}
|
||
|
||
// Init
|
||
renderDeptList();
|
||
renderChart();
|
||
clearHistory(); // Initialize empty history
|
||
saveState(); // Save initial state as first history entry
|
||
</script>
|
||
</body>
|
||
</html>
|