Files
FamilyBook/templates/kin/tree.php
T

544 lines
26 KiB
PHP
Raw Normal View History

<?php
/**
* 我的家谱:以我为中心,上一代 / 这一代 / 下一代**所有有关系的人**。
*
* ============================ 布局算法(v16 重写) ============================
*
* 历史:v13/v14 用第三方库 family-chart → 数据一直不全(实测 20 人只画出 9 人,
* 因为它只画「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
* **横向亲戚与姻亲旁支永远进不来**)。v15 改成自己排版,人数终于对了,
* 但布局是"每代按固定格子摊开",于是**线是乱的**:
* · 同代卡片**会重叠**(一对夫妻宽 254px,却按 200px 一格摆);
* · 夫妻横线画在卡片**内部**(y = 卡中心 + 半个卡高 − 6),所以线从卡上穿过去;
* · 子女不落在父母下方,横梁跨得老远、线与线互相交叉。
*
* v16 换成**整齐树(tidy tree)**——这是家谱/组织图的标准排法,规则只有三条:
* ① 后序给每棵子树算「轮廓」(每一行被占用的 x 区间),按轮廓从左往右放,
* 保证**任何两张卡都不重叠**(同代不同代的都不会);
* ② 父(户)落在**其子女整体跨度的中点**上 —— 这样"子女从父母下方连出来"
* 天然成立,横梁最短、最不容易交叉;
* ③ 同代按长幼排序(长子左、次子右),兄弟姊妹相对次序稳定。
*
* 连线画法(用户点名的要求,一字不改):
* · 一对夫妻之间一条短横线;
* · 他们的子女**一律从这条横线正中间垂下来**(单亲户从本人卡正下方),
* 再经横梁分到每个孩子 —— 与"谁登录""孩子登记了几位父母"都无关。
*
* 拖拽 / 滚轮缩放保留(d3.zoom);另给「看全部 / 回到我」两个按钮。
*/
$graph = $graph ?? \App\Kinship::graph((int)\App\Auth::familyId());
$people = $people ?? [];
// ---- ① 以我为中心,检索上一代 / 这一代 / 下一代的**所有人** ----
$map = \App\Kinship::familyMap((int)\App\Auth::familyId(), (int)$meId, $people, 2, 2);
$gen = $map['gen'];
$units = $map['units'];
$unitOf = $map['unit_of'];
// ---- ② 户 → 子女:子女挂在**户**上(不是挂在某一个人身上)----
$unitKids = \App\Kinship::householdChildren($graph, $unitOf);
// ---- ③ 同一代里的户按长幼排(长子左、次子右)----
$rankKey = static function (int $id) use ($people): string {
return \App\Kinship::rankValueFor($people, $id);
};
// ---- ④ 打成前端要的数据包(代数 → 户 → 人)----
$payloadUnits = [];
foreach ($units as $i => $u) {
$sorted = array_map('intval', $u['ids']);
usort($sorted, static function (int $a, int $b) use ($rankKey): int {
return strcmp($rankKey($a), $rankKey($b)) ?: ($a <=> $b);
});
$cards = [];
foreach ($sorted as $id) {
if (!isset($people[$id]) || !is_array($people[$id])) {
continue;
}
$p = $people[$id];
$g = strtolower((string)($p['gender'] ?? ''));
$cards[] = [
'id' => $id,
'name' => \App\Nickname::display((int)$meId, $p) ?: (string)($p['name'] ?? ''),
'label' => \App\Kinship::label($graph, (int)$meId, $id, $people),
'avatar' => avatar_url($p['avatar'] ?? null),
'me' => $id === (int)$meId,
'ph' => (int)($p['is_placeholder'] ?? 0) === 1,
'gender' => $g === 'm' ? 'M' : ($g === 'f' ? 'F' : 'U'),
];
}
if (!$cards) {
continue;
}
// ⚠️ 户内部:**血亲在前、配偶在后**(配偶排右手边)。
// 只按长幼排会让"妻子排到丈夫左边",夫妻横线看着别扭。
$head = null;
foreach ($cards as $c) {
// 血亲 = 这个户里"有父母在本图上"的那一位;都没有就取第一位
if ($head === null) {
$head = $c;
}
foreach ($graph['parents'][(int)$c['id']] ?? [] as $par) {
if (isset($unitOf[(int)$par]) && (int)$unitOf[(int)$par] !== $i) {
$head = $c;
break 2;
}
}
}
if ($head !== null) {
$rest = [];
foreach ($cards as $c) {
if ((int)$c['id'] !== (int)$head['id']) {
$rest[] = $c;
}
}
$cards = array_merge([$head], $rest);
}
$kids = [];
foreach ($unitKids[$i] ?? [] as $cid) {
if (isset($people[$cid])) {
$kids[] = (int)$cid;
}
}
usort($kids, static fn(int $a, int $b): int => strcmp($rankKey($a), $rankKey($b)) ?: ($a <=> $b));
$payloadUnits[] = ['gen' => (int)$u['gen'], 'cards' => $cards, 'kids' => $kids];
}
// 同代之间按「主位长幼」排序,保证顺序稳定
usort($payloadUnits, static function (array $a, array $b) use ($rankKey): int {
return ($a['gen'] <=> $b['gen'])
?: strcmp($rankKey((int)$a['cards'][0]['id']), $rankKey((int)$b['cards'][0]['id']))
?: ((int)$a['cards'][0]['id'] <=> (int)$b['cards'][0]['id']);
});
$totalPeople = 0;
foreach ($payloadUnits as $u) {
$totalPeople += count($u['cards']);
}
$canChart = $totalPeople > 1;
$meName = (string)($people[$meId]['name'] ?? '自己');
$payloadJson = json_encode(
['units' => $payloadUnits, 'me' => (int)$meId],
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
);
?>
<div class="page-head">
<h1>我的家谱</h1>
<span class="sub">以我为中心 · 上一代 / 这一代 / 下一代所有人 · 子女从夫妻横线中点连出</span>
</div>
<?php if (!$canChart): ?>
<div class="empty">
<b>还没连上任何亲属关系</b>
到「<?= e($meName) ?>」的资料页,把父母、配偶、子女填进去,
这张家谱就会自动长出来。兄弟姐妹不用单独填 —— 只要你们有同一对父母,就会自动认出来。
</div>
<?php else: ?>
<div class="kin-tools">
<button type="button" class="btn btn-ghost btn-sm" id="kinFit">看全部</button>
<button type="button" class="btn btn-ghost btn-sm" id="kinMe">回到我</button>
</div>
<div class="kin-chart" id="kinChart"
data-kin="<?= e($payloadJson) ?>" data-me="<?= e((string)$meId) ?>"></div>
<p class="tree-legend">
每代一行,一代里按「户」排开;<b>一对夫妻之间有一条短横线</b>,
<b>他们的子女一律从这条横线的正中间垂下来</b>(不管是谁登录,孩子都挂在这对夫妻上),
再由横梁分到每个孩子。
「待确认」= 只填了称呼、还没对应到真实的人(虚线卡片)。
可以**拖拽平移**、**滚轮缩放**;点「回到我」把镜头挪回你自己,点卡片进对应成员页。
</p>
<script src="<?= e(asset('/assets/vendor/family-chart/d3.min.js')) ?>"></script>
<script>
(function () {
var root = document.getElementById('kinChart');
if (!root || typeof d3 === 'undefined') { return; }
var data;
try {
data = JSON.parse(root.getAttribute('data-kin') || '{}');
} catch (err) { data = {}; }
var units = (data.units || []);
if (!units.length) { return; }
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ---- 版式常量 ----
var CW = 118; // 卡片宽
var CH = 92; // 卡片高
var HGAP = 20; // 同代相邻「户」之间的水平间隙
var CGAP = 14; // 夫妻两张卡之间的缝(短横线画在这一段上)
var PGAP = 16; // 夫妻两张卡与"夹在中间的竖线"之间的额外余量
var YS = 150; // 代与代之间的行距
var PAD = 64; // 画布留白
// ================= ========= 布局:整齐树 =================
//
// ⚠️ 为什么不用"每代按固定格子摊开"(v15 的做法,线就是那样乱起来的):
// ① 一对夫妻占 2×CW + CGAP 宽(≈250px),单人也占 CW;按同一个固定格距摆,
// 宽的那户会**压到隔壁**(实测:相邻两户重叠了 52px);
// ② 子女不落在父母正下方,横梁要跨很远,线与线互相交叉。
// 整齐树的做法:先按「轮廓」把子树从左往右摆开(保证不重叠),
// 再让父(户)落在**其子女跨度的中点**——于是一层一层都是居中于下一层,
// 线自然是最短的、几乎不交叉。
// ---- 0. 户的父子关系(哪个户是哪一户的孩子)----
var unitOf = {}; // personId -> unitIndex
units.forEach(function (u, ui) {
u._i = ui;
u.cards.forEach(function (c) { unitOf[c.id] = ui; });
});
units.forEach(function (u) { u._kids = []; });
// 从「户 → 子女」反推「户 → 子户」:某户的孩子落在了别的户里,那个户就是子户
var childUnits = {}; // ui -> [子户 ui,...](去重、保持顺序)
var parentUnits = {}; // 子户 ui -> [父户 ui,...](**可以有多个**)
units.forEach(function (u, ui) {
(u.kids || []).forEach(function (kid) {
var ku = unitOf[kid];
if (ku === undefined || ku === ui) { return; }
childUnits[ui] = childUnits[ui] || [];
if (childUnits[ui].indexOf(ku) < 0) {
childUnits[ui].push(ku);
// ⚠️ **必须允许一个户有多个父户**:我这一户同时是「爸妈的孩子」和
// 「岳父母的孩子」(因为妻子与我同户,而她是岳父母的孩子)。
// 只留第一个父户会让另一支(我那一支)失去父子关系 → 后代被排到别处。
parentUnits[ku] = parentUnits[ku] || [];
if (parentUnits[ku].indexOf(ui) < 0) { parentUnits[ku].push(ui); }
}
});
});
// 「主父户」= 排序时用哪一个父户。多个父户时取**代数最高的那个**
// (即最靠上的祖先那一支)—— 这样整个本家血脉会以同一支祖先为主轴排开,
// 姻亲(岳父母)只是"挂在旁边",不会把主轴拆成两半。
var parentUnitOf = {};
Object.keys(parentUnits).forEach(function (ku) {
var ps = parentUnits[ku].slice().sort(function (a, b) {
return units[a].gen - units[b].gen || a - b;
});
parentUnitOf[ku] = ps[0];
});
// ---- 2. 排序:同代内按「父户的位置」从左到右摆 ----
//
// ⚠️ v17 换了思路。v16 试过「树递归 + 轮廓合并」,但家谱不是一棵纯树:
// **姻亲**(岳父母)不是本家的后代,没有父户 → 会被当成"第二棵独立的树"
// 排到一侧,于是它的后代(妻子/我/姐姐/孩子)全被推远,
// 横梁要跨过别人的卡片(实测 D 项失败:266→838 穿过爸爸/岳父母)。
// 正解:**按代分行;同代内按"父辈位置"排序后从左往右摆**;
// 同一父户的子女排成紧挨的一整块,块的中心对到父户中心 ——
// 这样每户的横梁只覆盖自己那几个孩子。
// ---- 2a. rank 迭代:同代内按「父户的 rank」排序,几轮收敛 ----
var byGen = {};
units.forEach(function (u) { (byGen[u.gen] = byGen[u.gen] || []).push(u._i); });
var rankOf = {};
// 初值:数据顺序(服务端已按「血亲在前、长幼次序」排过)
units.forEach(function (u, ui) { rankOf[ui] = ui; });
for (var round = 0; round < 6; round++) {
Object.keys(byGen).forEach(function (g) {
var row = byGen[g].slice();
row.sort(function (a, b) {
var pa = parentUnitOf[a], pb = parentUnitOf[b];
var ka = (pa === undefined) ? -1 : rankOf[pa];
var kb = (pb === undefined) ? -1 : rankOf[pb];
if (ka !== kb) { return ka - kb; }
return rankOf[a] - rankOf[b];
});
row.forEach(function (ui, i) { rankOf[ui] = i; });
});
}
// ---- 2c. 横向落位:同代按 rank 从左往右摆,留 HGAP 间隙 ----
//
// 每个户的宽度:双人户 = 2×CW + CGAP + PGAP;单人户 = CW。
// 摆完一轮后,用「子户的中心 = 其父户的中心」做一次微调(自上而下传播),
// 让子女尽量落在父母正下方(减少横梁跨度)。
function unitWidthW(u) {
return u.cards.length >= 2 ? (2 * CW + CGAP + PGAP) : CW;
}
units.forEach(function (u) { u._w = unitWidthW(u); });
var cxOf = {};
Object.keys(byGen).forEach(function (g) {
var row = byGen[g].slice().sort(function (a, b) { return rankOf[a] - rankOf[b]; });
var x = 0;
row.forEach(function (ui) {
if (x > 0) { x += HGAP; } // 与上一个户之间留缝
cxOf[ui] = x + units[ui]._w / 2;
x += units[ui]._w;
});
});
// ---- 2d. 自上而下:子女往父母中心靠(但**同父的一组必须左对齐成块**)----
//
// ⚠️ v17:v16 只做了"整行防重叠 + 各自往父中心靠",结果**同一父户的多个孩子
// 会被推得七零八落**:孩子的横梁要从父户中心一路横跨到最远那个孩子,
// 中间就压过了别人的卡片(实测 D 项:横梁 801→1115 从「姐夫」身上过去)。
// 正解:**同一父户的子女必须紧挨成一整块**(块内按长幼),
// 块的中心 = 父户的中心。这样每户的横梁只覆盖自己那几个孩子,绝不越界。
var gensAsc = Object.keys(byGen).map(Number).sort(function (a, b) { return a - b; });
for (var pass = 0; pass < 3; pass++) {
gensAsc.forEach(function (g) {
// ① 分组:把这一代按「父户」分组(没有父户的各自成组)
//
// ⚠️ 分组键要用**全部父户**拼成:户 2(我+妻子)同时属于户 0(爸妈)
// 和户 1(岳父母),但**只能出现在一组里**(否则它会摆两次)。
// 取「主父户」(见上)当分组键,多个父户只在**定位**时参与平均。
var row = byGen[g].slice().sort(function (a, b) { return cxOf[a] - cxOf[b]; });
var groups = [], seen = {};
row.forEach(function (ui) {
var pu = parentUnitOf[ui];
var key = (pu === undefined) ? ('solo' + ui) : ('u' + pu);
if (!seen[key]) {
seen[key] = { key: key, parents: [], kids: [] };
groups.push(seen[key]);
}
seen[key].kids.push(ui);
// 这一组的所有父户都记下来(定位时取平均)
var allp = parentUnits[ui] || (pu === undefined ? [] : [pu]);
allp.forEach(function (p) {
if (seen[key].parents.indexOf(p) < 0) { seen[key].parents.push(p); }
});
});
// ② 每组算「块宽」,并按父户中心定位;同组内按长幼(rankOf)排
var blocks = groups.map(function (gp) {
gp.kids.sort(function (a, b) { return rankOf[a] - rankOf[b]; });
var w = 0;
gp.kids.forEach(function (k, i) { w += units[k]._w + (i ? HGAP : 0); });
// 定位目标:多父户时取**所有父户中心的平均**(姻亲与本家都在时居中,
// 看着最自然);没有父户(顶层祖先/纯姻亲)就跟随前一块往右排。
var center = null;
if (gp.parents && gp.parents.length) {
var sum = 0;
gp.parents.forEach(function (p) { sum += cxOf[p]; });
center = sum / gp.parents.length;
}
return { gp: gp, w: w, center: center,
order: (center === null) ? cxOf[gp.kids[0]] : center };
});
blocks.sort(function (a, b) { return a.order - b.order; });
// ③ 依次摆放(块之间也留 HGAP),父户中心能对上就对上,对不上就顺延
//
// ⚠️⚠️ `cur` 必须从 **0** 起,不能写 `-Infinity`:
// 顶层(最早那一代)**所有户都没有父户** → 每块都走 `cur + ...` 这一支,
// `-Infinity + 宽度` 仍是 `-Infinity` → 全部 cxOf 变 -Infinity →
// 后面 `-Infinity - (-Infinity)` = **NaN**,整张图的 x 全废
// (实测症状:所有卡片叠在同一个 x、SVG width="NaN")。
var cur = 0;
var first = true;
blocks.forEach(function (bl) {
var want = (bl.center === null) ? (cur + (first ? 0 : HGAP) + bl.w / 2) : bl.center;
var left = Math.max(want - bl.w / 2, cur + (first ? 0 : HGAP));
var x = left;
bl.gp.kids.forEach(function (k, i) {
if (i) { x += HGAP; }
cxOf[k] = x + units[k]._w / 2;
x += units[k]._w;
});
cur = x;
first = false;
});
});
}
// ---- 3. 落坐标:户中心 → 两张卡的 x;每户的 y 由「代数」决定 ----
//
// ⚠️ y 用**代数**(gen)而不是树的 depth:姻亲(岳父母)不是本家的后代,
// 按 depth 会被排到"我"这一行或更低,看着完全不对。
// genIdx 在上面算轮廓时已经建好,这里直接复用(两处必须同一套索引)。
var gens = [];
units.forEach(function (u) { if (gens.indexOf(u.gen) < 0) { gens.push(u.gen); } });
gens.sort(function (a, b) { return a - b; });
// ⚠️ 边界必须按**卡片实际占用的范围**算,不能只用"户中心 ± 户宽/2":
// 双人户里两张卡是「中心 ± (CGAP+PGAP+CW)/2」,比户宽/2 更靠外,
// 只按户宽算会让最外侧的卡片伸出画布(v17 实测:爸爸卡 x=282..412 而画布只有 808)。
var allX = [];
units.forEach(function (u) {
var halfSpan;
if (u.cards.length >= 2) {
halfSpan = PGAP / 2 + CGAP / 2 + CW / 2 + CW / 2; // 最外侧卡的外沿
} else {
halfSpan = CW / 2;
}
allX.push(cxOf[u._i] - halfSpan, cxOf[u._i] + halfSpan);
});
var minX = Math.min.apply(null, allX), maxX = Math.max.apply(null, allX);
var W = (maxX - minX) + PAD * 2;
var H = PAD * 2 + (gens.length - 1) * YS + CH;
var pos = {}, lay = {};
units.forEach(function (u) {
var g = gens.indexOf(u.gen);
var y = PAD + CH / 2 + g * YS;
var cx = cxOf[u._i] - minX + PAD; // 平移到画布内
var ids = u.cards.map(function (c) { return c.id; });
var left, right;
if (ids.length >= 2) {
// 两张卡并排,中间留缝;缝的正中就是"夫妻横线"与"子女下垂线"的 x
left = cx - PGAP / 2 - CGAP / 2 - CW / 2;
right = cx + PGAP / 2 + CGAP / 2 + CW / 2;
pos[ids[0]] = { x: left, y: y };
pos[ids[1]] = { x: right, y: y };
} else {
left = right = cx;
pos[ids[0]] = { x: cx, y: y };
}
lay[u._i] = {
cx: cx, cy: y, left: left, right: right, ids: ids,
// ⚠️ 夫妻横线画在**两张卡之间的空档的正中**(纵向也在卡高的一半处),
// 视觉上就是"一条线把夫妻俩连起来"。
// 子女的下垂线从这条线的**中点**往下走 —— 这就是用户要的形状。
// (v15 把它画在 y + CH/2 − 6,等于画在卡片**内部**,线从卡身上穿过去;实测抓到 2 处。)
barY: y,
hasBar: ids.length >= 2
};
});
var drawn = {}, html = '';
units.forEach(function (u) {
u.cards.forEach(function (c) {
var p = pos[c.id];
if (!p) { return; }
var cls = 'kin-card' + (c.me ? ' is-me' : '') + (c.ph ? ' is-ph' : '');
html += '<a class="' + cls + '" data-kin-id="' + esc(c.id) + '"'
+ ' style="left:' + p.x + 'px;top:' + p.y + 'px;width:' + CW + 'px;height:' + CH + 'px"'
+ ' href="/index.php?r=people/show&id=' + encodeURIComponent(c.id) + '">'
+ '<span class="kin-ph"><img src="' + esc(c.avatar) + '" alt="" loading="lazy"></span>'
+ '<span class="kin-nm">' + esc(c.name) + '</span>'
+ '<span class="kin-lb">' + esc(c.label) + '</span>'
+ (c.ph ? '<span class="kin-mark">待确认</span>' : '')
+ '</a>';
drawn[c.id] = 1;
});
});
root.insertAdjacentHTML('beforeend',
'<div class="kin-stage" style="width:' + W + 'px;height:' + H + 'px">' + html + '</div>');
// ---- 4. 画线 ----
//
// 三条线(顺序即画法):
// ① 夫妻横线:连两张卡的**中间空档**(视觉上把夫妻连起来);
// ② 下垂线:从横线的**中点**往下(单亲户 = 本人卡的中心往下);
// ③ 横梁 + 分叉:由横梁分到每个孩子的卡顶。
// ⚠️ ② 的起点取"卡中间高度"会**竖着穿过卡片下半部分**;所以让竖向那段
// 从**卡下沿**开始 —— 视觉上仍是"从夫妻那条线的中点出来",
// 但不会压在卡上。(用户要的是"别从某一个人出来",不是"必须穿过卡面"。)
var paths = [], bars = [], drops = [];
units.forEach(function (u) {
var L = lay[u._i];
if (L.hasBar) {
// ⚠️ 夫妻横线**只画两张卡之间的那一段缝隙**(不是从卡中心连到卡中心):
// 从中心画会从卡片身上穿过去(实测 B 项抓到 4 处穿卡)。
// 起止取「左卡右沿」到「右卡左沿」。
paths.push('M' + (L.left + CW / 2) + ',' + L.barY
+ 'L' + (L.right - CW / 2) + ',' + L.barY);
}
bars.push({ ui: u._i, x1: L.left + CW / 2, x2: L.right - CW / 2,
mid: (L.left + L.right) / 2, y: L.barY, has: L.hasBar });
});
units.forEach(function (u) {
var L = lay[u._i];
var kids = (u.kids || []).map(function (id) { return pos[id]; })
.filter(function (p) { return !!p; })
.sort(function (a, b) { return a.x - b.x; });
if (!kids.length) { return; }
var srcX = (L.left + L.right) / 2; // ★ 夫妻横线中点(单亲 = 本人卡中心)
var fromY = L.cy + CH / 2; // 从卡片下沿起(见上面的说明)
var busY = L.cy + YS - CH / 2 - 24; // 子女行上方的横梁
paths.push('M' + srcX + ',' + fromY + 'L' + srcX + ',' + busY);
// ⚠️⚠️ 横梁**不能直接连"第一个孩子到最后一个孩子"**:
// 孩子分属不同的户(比如"我"和"姐姐"各是一户),
// 而"我"那一户里还有配偶(妻子)。从最左孩子的卡中心连到最右孩子的卡中心,
// 会把夹在中间的那位**配偶**也框进横梁范围 —— 看着就像"这条线也连了妻子"。
// 正解:先按 x 排序,**逐段连接相邻两个孩子**(段间缺口处断开),
// 每段只覆盖它两端的两张卡(从"左卡中心"到"右卡中心")。
for (var i = 0; i + 1 < kids.length; i++) {
var a = kids[i], b2 = kids[i + 1];
// 只在"这两张卡之间没有别的卡片"时直连;否则断开(避免压过别人)
var blocked = false;
for (var j = 0; j < units.length; j++) {
var other = pos[units[j].cards[0].id];
if (!other || other === a || other === b2) { continue; }
var oc = other.x;
if (oc > a.x + 2 && oc < b2.x - 2) { blocked = true; break; }
}
if (blocked) { continue; }
paths.push('M' + a.x + ',' + busY + 'L' + b2.x + ',' + busY);
}
kids.forEach(function (k) {
paths.push('M' + k.x + ',' + busY + 'L' + k.x + ',' + (k.y - CH / 2 - 4));
});
drops.push({ ui: u._i, x: srcX, y0: L.barY, fromY: fromY, busY: busY });
});
root.insertAdjacentHTML('afterbegin',
'<svg class="kin-lines" width="' + W + '" height="' + H + '">'
+ paths.map(function (d) { return '<path class="kin-link" d="' + d + '"></path>'; }).join('')
+ '</svg>');
window.__kinLayout = { W: W, H: H, CW: CW, CH: CH, bars: bars, drops: drops,
pos: pos, paths: paths };
// ---- 5. 拖拽 + 缩放 ----
var stage = root.querySelector('.kin-stage');
var svg = root.querySelector('svg.kin-lines');
var zoom = d3.zoom().scaleExtent([0.15, 2.5]).on('zoom', function (ev) {
var t = ev.transform;
[svg, stage].forEach(function (el) {
el.style.transformOrigin = '0 0';
el.style.transform = 'translate(' + t.x + 'px,' + t.y + 'px) scale(' + t.k + ')';
});
});
var sel = d3.select(root);
sel.call(zoom);
function fit() {
var r = root.getBoundingClientRect();
if (!r.width || !r.height) { return; }
var k = Math.min(r.width / W, r.height / H) * 0.92;
k = Math.max(0.15, Math.min(2.5, k));
var tx = (r.width - W * k) / 2;
var ty = (r.height - H * k) / 2;
sel.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
}
function centerOn(id) {
var p = pos[id];
if (!p) { return; }
var r = root.getBoundingClientRect();
var t = d3.zoomTransform(root);
var tx = r.width / 2 - p.x * t.k;
var ty = r.height / 2 - p.y * t.k;
sel.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(t.k));
}
window.kinFit = fit;
window.kinCenterOn = centerOn;
fit();
var meId = root.getAttribute('data-me') || '';
if (meId) { setTimeout(function () { centerOn(meId); }, 60); }
var btnFit = document.getElementById('kinFit');
var btnMe = document.getElementById('kinMe');
if (btnFit) { btnFit.addEventListener('click', fit); }
if (btnMe && meId) { btnMe.addEventListener('click', function () { centerOn(meId); }); }
})();
</script>
<?php endif; ?>