Files
aars/org_chart_editor.html
T
admin 7f34b1fa09 Add SC Medical Group Org Chart Editor
- 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
2026-07-16 03:14:50 +08:00

1717 lines
73 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>