2026-09-30 08:13:07 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
/**
|
2026-10-02 10:26:14 +08:00
|
|
|
|
* 我的家谱:以我为中心,上一代 / 这一代 / 下一代**所有有关系的人**。
|
2026-09-30 08:13:07 +08:00
|
|
|
|
*
|
2026-10-02 12:48:33 +08:00
|
|
|
|
* ============================ 布局算法(v16 重写) ============================
|
2026-10-01 17:42:15 +08:00
|
|
|
|
*
|
2026-10-02 12:48:33 +08:00
|
|
|
|
* 历史:v13/v14 用第三方库 family-chart → 数据一直不全(实测 20 人只画出 9 人,
|
|
|
|
|
|
* 因为它只画「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
|
|
|
|
|
|
* **横向亲戚与姻亲旁支永远进不来**)。v15 改成自己排版,人数终于对了,
|
|
|
|
|
|
* 但布局是"每代按固定格子摊开",于是**线是乱的**:
|
|
|
|
|
|
* · 同代卡片**会重叠**(一对夫妻宽 254px,却按 200px 一格摆);
|
|
|
|
|
|
* · 夫妻横线画在卡片**内部**(y = 卡中心 + 半个卡高 − 6),所以线从卡上穿过去;
|
|
|
|
|
|
* · 子女不落在父母下方,横梁跨得老远、线与线互相交叉。
|
2026-10-01 17:42:15 +08:00
|
|
|
|
*
|
2026-10-02 12:48:33 +08:00
|
|
|
|
* v16 换成**整齐树(tidy tree)**——这是家谱/组织图的标准排法,规则只有三条:
|
|
|
|
|
|
* ① 后序给每棵子树算「轮廓」(每一行被占用的 x 区间),按轮廓从左往右放,
|
|
|
|
|
|
* 保证**任何两张卡都不重叠**(同代不同代的都不会);
|
|
|
|
|
|
* ② 父(户)落在**其子女整体跨度的中点**上 —— 这样"子女从父母下方连出来"
|
|
|
|
|
|
* 天然成立,横梁最短、最不容易交叉;
|
|
|
|
|
|
* ③ 同代按长幼排序(长子左、次子右),兄弟姊妹相对次序稳定。
|
2026-10-01 19:35:48 +08:00
|
|
|
|
*
|
2026-10-02 12:48:33 +08:00
|
|
|
|
* 连线画法(用户点名的要求,一字不改):
|
|
|
|
|
|
* · 一对夫妻之间一条短横线;
|
|
|
|
|
|
* · 他们的子女**一律从这条横线正中间垂下来**(单亲户从本人卡正下方),
|
|
|
|
|
|
* 再经横梁分到每个孩子 —— 与"谁登录""孩子登记了几位父母"都无关。
|
2026-10-01 19:35:48 +08:00
|
|
|
|
*
|
2026-10-02 12:48:33 +08:00
|
|
|
|
* 拖拽 / 滚轮缩放保留(d3.zoom);另给「看全部 / 回到我」两个按钮。
|
2026-09-30 08:13:07 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
2026-10-02 10:26:14 +08:00
|
|
|
|
$graph = $graph ?? \App\Kinship::graph((int)\App\Auth::familyId());
|
|
|
|
|
|
$people = $people ?? [];
|
2026-10-02 09:28:07 +08:00
|
|
|
|
|
2026-10-02 10:26:14 +08:00
|
|
|
|
// ---- ① 以我为中心,检索上一代 / 这一代 / 下一代的**所有人** ----
|
|
|
|
|
|
$map = \App\Kinship::familyMap((int)\App\Auth::familyId(), (int)$meId, $people, 2, 2);
|
|
|
|
|
|
$gen = $map['gen'];
|
|
|
|
|
|
$units = $map['units'];
|
|
|
|
|
|
$unitOf = $map['unit_of'];
|
2026-10-01 17:42:15 +08:00
|
|
|
|
|
2026-10-02 10:26:14 +08:00
|
|
|
|
// ---- ② 户 → 子女:子女挂在**户**上(不是挂在某一个人身上)----
|
|
|
|
|
|
$unitKids = \App\Kinship::householdChildren($graph, $unitOf);
|
2026-10-02 09:28:07 +08:00
|
|
|
|
|
2026-10-02 10:26:14 +08:00
|
|
|
|
// ---- ③ 同一代里的户按长幼排(长子左、次子右)----
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$rankKey = static function (int $id) use ($people): string {
|
|
|
|
|
|
return \App\Kinship::rankValueFor($people, $id);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-02 10:26:14 +08:00
|
|
|
|
// ---- ④ 打成前端要的数据包(代数 → 户 → 人)----
|
2026-10-02 17:35:27 +08:00
|
|
|
|
|
|
|
|
|
|
// 「我」的血亲集合:只走 parent/child 边(不含配偶)能从「我」到达的人。
|
|
|
|
|
|
// 用途:给每张卡标 blood —— 前端据此判定一户的「主父户」
|
|
|
|
|
|
// (我的户同时被父母户与岳父母户认作子女,树递归只能走本家那条,
|
|
|
|
|
|
// 否则树变图、布局重复铺人;判定标准就是「父户的主位是不是我方血亲」)。
|
|
|
|
|
|
$blood = [$meId => true];
|
|
|
|
|
|
$bq = [$meId];
|
|
|
|
|
|
while ($bq) {
|
|
|
|
|
|
$bid = (int)array_shift($bq);
|
|
|
|
|
|
foreach (array_merge($graph['parents'][$bid] ?? [], $graph['children'][$bid] ?? []) as $nb) {
|
|
|
|
|
|
$nb = (int)$nb;
|
|
|
|
|
|
if (!isset($blood[$nb])) {
|
|
|
|
|
|
$blood[$nb] = true;
|
|
|
|
|
|
$bq[] = $nb;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 10:26:14 +08:00
|
|
|
|
$payloadUnits = [];
|
|
|
|
|
|
foreach ($units as $i => $u) {
|
2026-10-02 12:48:33 +08:00
|
|
|
|
$sorted = array_map('intval', $u['ids']);
|
2026-10-02 10:26:14 +08:00
|
|
|
|
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'),
|
2026-10-02 17:35:27 +08:00
|
|
|
|
// ⚠️ 本家血亲标记:前端用它判定一户的「主父户」(见上方 $blood 的说明)
|
|
|
|
|
|
'blood' => isset($blood[$id]),
|
2026-10-02 13:26:11 +08:00
|
|
|
|
// ⚠️ 长幼排序键:前端要靠它分「哥哥/弟弟」「姐姐/妹妹」。
|
|
|
|
|
|
// 不用生日硬排(很多人没填生日),用 Kinship 里已有的 rankValueFor ——
|
|
|
|
|
|
// 它先看 birth_rank,再退回生日,最后用 id 兜底,保证顺序稳定。
|
|
|
|
|
|
'rank' => \App\Kinship::rankValueFor($people, $id),
|
2026-10-02 10:26:14 +08:00
|
|
|
|
];
|
2026-10-02 09:28:07 +08:00
|
|
|
|
}
|
2026-10-02 10:26:14 +08:00
|
|
|
|
if (!$cards) {
|
2026-10-01 19:35:48 +08:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-10-02 13:38:34 +08:00
|
|
|
|
// ⚠️⚠️ 户内部:**本家血亲在前、配偶在后**(配偶排右手边,参考实现就是这样)。
|
|
|
|
|
|
//
|
|
|
|
|
|
// "血亲"不能只按"他有父母在本图上"判定 —— 一对夫妻**两边父母都可能在图上**
|
|
|
|
|
|
// (我的父母 + 妻子的父母),那样两边都算血亲,取到第一个就成了"妻子当主位、
|
|
|
|
|
|
// 我排在她右边"(实测线上就是这个症状:红围脖@639 在我的左边)。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 正解:按「**与"我"的血缘距离**」判定 —— 这一户里离「我」更近的那一位才是主位。
|
2026-10-02 17:35:27 +08:00
|
|
|
|
// 血亲集合 $blood 已在循环外算好(只走 parent/child 边的 BFS),
|
2026-10-02 13:38:34 +08:00
|
|
|
|
// 两边都在血亲集合里时,退到「与我的步数」比大小。
|
|
|
|
|
|
$head = null;
|
|
|
|
|
|
$headScore = null;
|
|
|
|
|
|
foreach ($cards as $c) {
|
|
|
|
|
|
$cid = (int)$c['id'];
|
|
|
|
|
|
// 分数越小越"本家":0 = 血亲本身,1 = 配偶,2 = 其它
|
|
|
|
|
|
$sc = isset($blood[$cid]) ? 0 : 1;
|
|
|
|
|
|
if ($head === null || $sc < $headScore) {
|
|
|
|
|
|
$head = $c;
|
|
|
|
|
|
$headScore = $sc;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-02 12:48:33 +08:00
|
|
|
|
if ($head !== null) {
|
|
|
|
|
|
$rest = [];
|
|
|
|
|
|
foreach ($cards as $c) {
|
|
|
|
|
|
if ((int)$c['id'] !== (int)$head['id']) {
|
|
|
|
|
|
$rest[] = $c;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
$cards = array_merge([$head], $rest);
|
|
|
|
|
|
}
|
2026-10-02 10:26:14 +08:00
|
|
|
|
$kids = [];
|
|
|
|
|
|
foreach ($unitKids[$i] ?? [] as $cid) {
|
|
|
|
|
|
if (isset($people[$cid])) {
|
|
|
|
|
|
$kids[] = (int)$cid;
|
2026-10-01 19:35:48 +08:00
|
|
|
|
}
|
2026-10-02 10:26:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
usort($kids, static fn(int $a, int $b): int => strcmp($rankKey($a), $rankKey($b)) ?: ($a <=> $b));
|
2026-10-02 09:28:07 +08:00
|
|
|
|
|
2026-10-02 12:48:33 +08:00
|
|
|
|
$payloadUnits[] = ['gen' => (int)$u['gen'], 'cards' => $cards, 'kids' => $kids];
|
2026-10-01 19:35:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 10:26:14 +08:00
|
|
|
|
// 同代之间按「主位长幼」排序,保证顺序稳定
|
2026-10-02 12:48:33 +08:00
|
|
|
|
usort($payloadUnits, static function (array $a, array $b) use ($rankKey): int {
|
2026-10-02 10:26:14 +08:00
|
|
|
|
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;
|
2026-10-02 12:48:33 +08:00
|
|
|
|
foreach ($payloadUnits as $u) {
|
2026-10-02 10:26:14 +08:00
|
|
|
|
$totalPeople += count($u['cards']);
|
|
|
|
|
|
}
|
|
|
|
|
|
$canChart = $totalPeople > 1;
|
|
|
|
|
|
|
|
|
|
|
|
$meName = (string)($people[$meId]['name'] ?? '自己');
|
|
|
|
|
|
$payloadJson = json_encode(
|
2026-10-02 12:48:33 +08:00
|
|
|
|
['units' => $payloadUnits, 'me' => (int)$meId],
|
2026-10-01 19:35:48 +08:00
|
|
|
|
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
|
|
|
|
|
|
);
|
2026-09-30 08:13:07 +08:00
|
|
|
|
?>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="page-head">
|
|
|
|
|
|
<h1>我的家谱</h1>
|
2026-10-02 12:48:33 +08:00
|
|
|
|
<span class="sub">以我为中心 · 上一代 / 这一代 / 下一代所有人 · 子女从夫妻横线中点连出</span>
|
2026-09-30 08:13:07 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-10-01 19:35:48 +08:00
|
|
|
|
<?php if (!$canChart): ?>
|
2026-09-30 08:13:07 +08:00
|
|
|
|
<div class="empty">
|
|
|
|
|
|
<b>还没连上任何亲属关系</b>
|
2026-10-02 10:26:14 +08:00
|
|
|
|
到「<?= e($meName) ?>」的资料页,把父母、配偶、子女填进去,
|
2026-09-30 08:13:07 +08:00
|
|
|
|
这张家谱就会自动长出来。兄弟姐妹不用单独填 —— 只要你们有同一对父母,就会自动认出来。
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<?php else: ?>
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-10-02 10:26:14 +08:00
|
|
|
|
<div class="kin-chart" id="kinChart"
|
|
|
|
|
|
data-kin="<?= e($payloadJson) ?>" data-me="<?= e((string)$meId) ?>"></div>
|
2026-09-30 08:13:07 +08:00
|
|
|
|
|
2026-10-01 12:11:43 +08:00
|
|
|
|
<p class="tree-legend">
|
2026-10-02 12:48:33 +08:00
|
|
|
|
每代一行,一代里按「户」排开;<b>一对夫妻之间有一条短横线</b>,
|
|
|
|
|
|
<b>他们的子女一律从这条横线的正中间垂下来</b>(不管是谁登录,孩子都挂在这对夫妻上),
|
2026-10-02 10:26:14 +08:00
|
|
|
|
再由横梁分到每个孩子。
|
2026-10-01 19:35:48 +08:00
|
|
|
|
「待确认」= 只填了称呼、还没对应到真实的人(虚线卡片)。
|
2026-10-02 09:28:07 +08:00
|
|
|
|
可以**拖拽平移**、**滚轮缩放**;点「回到我」把镜头挪回你自己,点卡片进对应成员页。
|
2026-10-01 12:11:43 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
|
2026-10-01 19:35:48 +08:00
|
|
|
|
<script src="<?= e(asset('/assets/vendor/family-chart/d3.min.js')) ?>"></script>
|
|
|
|
|
|
<script>
|
2026-10-02 13:26:11 +08:00
|
|
|
|
/*****************************************************************
|
2026-10-02 17:35:27 +08:00
|
|
|
|
* 家谱布局:区间法(tidy tree)+ 先摆节点、后按节点画线
|
2026-10-02 13:26:11 +08:00
|
|
|
|
*
|
2026-10-02 17:35:27 +08:00
|
|
|
|
* 参考过 gitee.com/coku2333/FamilyTreeView(用户 2026-10-02 指定的
|
|
|
|
|
|
* 「亲友+」效果),但它的游标铺法在我们的"以我为中心、含姻亲旁支"的
|
|
|
|
|
|
* 图上要叠四五层防重叠/重排修补才能不叠卡,每层又互相破坏 —— 最终
|
|
|
|
|
|
* 整棵树漂移(用户:"错的更离谱了,这次连位置都不对了")。
|
|
|
|
|
|
* 现在的规则(详见下方第 1 节注释):
|
|
|
|
|
|
* ① 每代一行;一对夫妻 = 相邻两张卡;
|
|
|
|
|
|
* ② 树递归用「区间法」分配互不重叠的 x 区间,父户居中于子女跨度
|
|
|
|
|
|
* —— 天然不叠卡,长幼序天然"兄姐在左、弟妹在右";
|
|
|
|
|
|
* ③ 连线画在卡片背后,**先把所有节点坐标摆定,再统一按节点画线**。
|
2026-10-02 13:26:11 +08:00
|
|
|
|
*****************************************************************/
|
2026-10-01 19:35:48 +08:00
|
|
|
|
(function () {
|
|
|
|
|
|
var root = document.getElementById('kinChart');
|
2026-10-02 10:26:14 +08:00
|
|
|
|
if (!root || typeof d3 === 'undefined') { return; }
|
2026-10-01 19:35:48 +08:00
|
|
|
|
|
2026-10-02 10:26:14 +08:00
|
|
|
|
var data;
|
2026-10-01 19:35:48 +08:00
|
|
|
|
try {
|
2026-10-02 10:26:14 +08:00
|
|
|
|
data = JSON.parse(root.getAttribute('data-kin') || '{}');
|
|
|
|
|
|
} catch (err) { data = {}; }
|
|
|
|
|
|
var units = (data.units || []);
|
|
|
|
|
|
if (!units.length) { return; }
|
2026-10-01 19:35:48 +08:00
|
|
|
|
|
|
|
|
|
|
function esc(s) {
|
|
|
|
|
|
return String(s == null ? '' : s)
|
|
|
|
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
|
|
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 13:26:11 +08:00
|
|
|
|
/* ---------- 版式常量 ---------- */
|
|
|
|
|
|
var IW = 88; // 卡片宽
|
|
|
|
|
|
var IH = 106; // 卡片高
|
|
|
|
|
|
var SP = 26; // 卡片间距(参考实现里的 mSpacePX)
|
|
|
|
|
|
var SLOT = IW + SP; // 一个人的"槽位"宽(配偶间隔也用 SP,与参考一致)
|
|
|
|
|
|
var YS = IH + SP; // 代与代之间的行距
|
|
|
|
|
|
var PAD = 60; // 画布留白
|
2026-10-01 19:35:48 +08:00
|
|
|
|
|
2026-10-02 13:26:11 +08:00
|
|
|
|
/* ---------- 0. 基础索引 ---------- */
|
|
|
|
|
|
var byId = {}; // personId -> {u}
|
2026-10-02 12:48:33 +08:00
|
|
|
|
units.forEach(function (u, ui) {
|
|
|
|
|
|
u._i = ui;
|
2026-10-02 13:26:11 +08:00
|
|
|
|
u.cards.forEach(function (c) { byId[String(c.id)] = { u: ui, card: c }; });
|
2026-10-02 12:48:33 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-02 13:26:11 +08:00
|
|
|
|
// 户 → 户 的父子关系(由「户 → 子女」推)
|
|
|
|
|
|
var kidsOf = {}, parentsOf = {};
|
2026-10-02 12:48:33 +08:00
|
|
|
|
units.forEach(function (u, ui) {
|
|
|
|
|
|
(u.kids || []).forEach(function (kid) {
|
2026-10-02 13:26:11 +08:00
|
|
|
|
var ref = byId[String(kid)];
|
|
|
|
|
|
if (!ref || ref.u === ui) { return; }
|
|
|
|
|
|
(kidsOf[ui] = kidsOf[ui] || []);
|
|
|
|
|
|
if (kidsOf[ui].indexOf(ref.u) < 0) {
|
|
|
|
|
|
kidsOf[ui].push(ref.u);
|
|
|
|
|
|
(parentsOf[ref.u] = parentsOf[ref.u] || []);
|
|
|
|
|
|
if (parentsOf[ref.u].indexOf(ui) < 0) { parentsOf[ref.u].push(ui); }
|
2026-10-02 12:48:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-02 13:26:11 +08:00
|
|
|
|
// ⚠️⚠️ 关键:`parentsOf` 的键是**户索引**,而下面多处是按**人 id** 去查有没有父母
|
|
|
|
|
|
// (比如"哪张卡是本家的血亲位")。两者混用会**永远查不到** ——
|
|
|
|
|
|
// 症状是"主位永远取第一张卡"(伯父那一户的主位取成了伯母),
|
|
|
|
|
|
// 进而父系/母系判定全错。这里单独建一份"人 id → 父母户索引"。 */
|
|
|
|
|
|
var personParents = {}; // personId -> [父户 ui,...]
|
|
|
|
|
|
units.forEach(function (u, ui) {
|
|
|
|
|
|
u.cards.forEach(function (c) {
|
|
|
|
|
|
var ps = parentsOf[String(ui)];
|
|
|
|
|
|
if (ps && ps.length) { personParents[String(c.id)] = ps; }
|
2026-10-02 12:48:33 +08:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-02 13:26:11 +08:00
|
|
|
|
// 我所在的户
|
|
|
|
|
|
var meUi = 0;
|
|
|
|
|
|
units.forEach(function (u, ui) {
|
|
|
|
|
|
u.cards.forEach(function (c) { if (c.me) { meUi = ui; } });
|
|
|
|
|
|
});
|
2026-10-02 12:48:33 +08:00
|
|
|
|
|
2026-10-02 13:26:11 +08:00
|
|
|
|
// 户的"血亲位"(哪张卡是本家的人):优先取"有父户"的那张
|
2026-10-02 12:48:33 +08:00
|
|
|
|
units.forEach(function (u) {
|
2026-10-02 13:26:11 +08:00
|
|
|
|
var idx = 0;
|
|
|
|
|
|
for (var i = 0; i < u.cards.length; i++) {
|
|
|
|
|
|
var ps = personParents[String(u.cards[i].id)];
|
|
|
|
|
|
if (ps && ps.length) { idx = i; break; }
|
2026-10-02 12:48:33 +08:00
|
|
|
|
}
|
2026-10-02 13:26:11 +08:00
|
|
|
|
u._headIdx = idx;
|
|
|
|
|
|
u._head = String(u.cards[idx].id);
|
|
|
|
|
|
// 主位的"排行键"(长幼):服务端已算好(birth_rank → 生日 → id 兜底),
|
|
|
|
|
|
// 前端要靠它分「哥哥/弟弟」「姐姐/妹妹」(以及"年长的往左排")。
|
|
|
|
|
|
u._rank = String(u.cards[idx].rank == null ? u.cards[idx].id : u.cards[idx].rank);
|
2026-10-02 12:48:33 +08:00
|
|
|
|
});
|
2026-10-02 13:26:11 +08:00
|
|
|
|
|
2026-10-02 17:35:27 +08:00
|
|
|
|
/* ---------- 1. 布局:区间法(tidy tree,一次成型) ----------
|
2026-10-02 15:49:50 +08:00
|
|
|
|
*
|
2026-10-02 17:35:27 +08:00
|
|
|
|
* 为什么整个推倒重写(用户 2026-10-02:"错的更离谱了,这次连位置都不对了"):
|
|
|
|
|
|
* 之前的版本是「游标铺放 → 防重叠 → 同代重排 → 再防重叠 → 再硬推」
|
|
|
|
|
|
* 叠了四五层修补,每一层都在破坏上一层的"父居中于子女",
|
|
|
|
|
|
* 多轮互相拉扯后整棵树漂移(实测"我"跑到离父母 800px 的最右侧)。
|
2026-10-02 15:49:50 +08:00
|
|
|
|
*
|
2026-10-02 17:35:27 +08:00
|
|
|
|
* 现在换成树布局的标准解法(区间法),只有三条规则,一次成型:
|
|
|
|
|
|
* ① 每棵「户子树」自底向上算**需求宽度**:
|
|
|
|
|
|
* needW = max( 自身宽, Σ子女needW + SP×(子女数−1) )
|
|
|
|
|
|
* ② 自顶向下分配**互不重叠的 x 区间**:子女依次铺在父区间内;
|
|
|
|
|
|
* ③ 父户居中于子女的实际跨度。
|
|
|
|
|
|
* 区间天然不重叠 ⇒ 任何两张卡绝不重叠,**不需要任何后续推挤修补**。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 跨树边(岳父母户也把妻子列为子女):每户只认**主父户**进树递归
|
|
|
|
|
|
* (主位卡是"我方血亲"的那个父户,服务端已给每张卡标了 blood),
|
|
|
|
|
|
* 其余父户只在**画线**时用 —— 岳父母的子女横梁照样连到妻子,
|
|
|
|
|
|
* 但布局上岳父母树不会把"我的户"再铺一遍。
|
2026-10-02 15:49:50 +08:00
|
|
|
|
*/
|
2026-10-02 17:35:27 +08:00
|
|
|
|
var rawKids = kidsOf; // 原始子女关系(画线用;跨树边保留)
|
2026-10-02 15:49:50 +08:00
|
|
|
|
|
2026-10-02 17:35:27 +08:00
|
|
|
|
var mainParent = {}; // ui -> 主父户 ui
|
|
|
|
|
|
var kidList = {}; // ui -> [子女户 ui,...](只含"把我当主父户"的)
|
|
|
|
|
|
units.forEach(function (u, ui) { u._blood = !!(u.cards[0] && u.cards[0].blood); });
|
|
|
|
|
|
units.forEach(function (u, ki) {
|
|
|
|
|
|
var ps = parentsOf[ki] || [];
|
|
|
|
|
|
if (!ps.length) { return; }
|
|
|
|
|
|
var main = null;
|
|
|
|
|
|
ps.forEach(function (p) { if (main === null && units[p]._blood) { main = p; } });
|
|
|
|
|
|
if (main === null) { main = ps[0]; }
|
|
|
|
|
|
mainParent[ki] = main;
|
|
|
|
|
|
// kidList 沿 kidsOf 的遍历顺序 push —— 服务端的长幼序原样保留
|
|
|
|
|
|
(kidList[main] = kidList[main] || []).push(ki);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/** 一户的实际宽度:单人 IW、夫妻两张 2*IW+SP(不是 n*SLOT) */
|
|
|
|
|
|
function ownW(ui) {
|
|
|
|
|
|
return units[ui].cards.length >= 2 ? 2 * IW + SP : IW;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ① 需求宽度:自底向上 */
|
|
|
|
|
|
var needW = {};
|
|
|
|
|
|
function calcNeed(ui) {
|
|
|
|
|
|
if (needW[ui] !== undefined) { return needW[ui]; }
|
|
|
|
|
|
var total = 0;
|
|
|
|
|
|
(kidList[ui] || []).forEach(function (k, i) {
|
|
|
|
|
|
total += calcNeed(k) + (i ? SP : 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
needW[ui] = Math.max(ownW(ui), total);
|
|
|
|
|
|
return needW[ui];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ②③ 分配区间:自顶向下;父户居中于子女的实际跨度 */
|
|
|
|
|
|
var placed = {}; // ui -> {gen, x0}
|
|
|
|
|
|
function assign(ui, x0) {
|
|
|
|
|
|
var own = ownW(ui);
|
|
|
|
|
|
var ks = kidList[ui] || [];
|
|
|
|
|
|
var total = 0;
|
|
|
|
|
|
ks.forEach(function (k, i) { total += needW[k] + (i ? SP : 0); });
|
|
|
|
|
|
var kx = x0 + (needW[ui] - total) / 2; // 子女在父区间内居中起铺
|
|
|
|
|
|
ks.forEach(function (k) { assign(k, kx); kx += needW[k] + SP; });
|
|
|
|
|
|
// 居中锚:所有子女(**含跨树子女**——他们已在别的树里放好)的实际跨度中点
|
|
|
|
|
|
var lo = null, hi = null;
|
|
|
|
|
|
(rawKids[ui] || []).forEach(function (k) {
|
|
|
|
|
|
if (!placed[k]) { return; }
|
|
|
|
|
|
var c = placed[k].x0 + ownW(k) / 2;
|
|
|
|
|
|
if (lo === null || c < lo) { lo = c; }
|
|
|
|
|
|
if (hi === null || c > hi) { hi = c; }
|
|
|
|
|
|
});
|
|
|
|
|
|
var want = (lo === null) ? x0 + (needW[ui] - own) / 2 : (lo + hi) / 2 - own / 2;
|
|
|
|
|
|
// 父户不得越出自己被分配的区间(越出会撞上左邻/右邻子树)
|
|
|
|
|
|
placed[ui] = {
|
|
|
|
|
|
gen: units[ui].gen,
|
|
|
|
|
|
x0: Math.max(x0, Math.min(want, x0 + needW[ui] - own))
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 森林:无主父户的户是树根;含「我」的树先放,其余树(姻亲)依次排在右侧 */
|
|
|
|
|
|
function treeHasMe(root) {
|
|
|
|
|
|
var st = [root], seen = {};
|
|
|
|
|
|
while (st.length) {
|
|
|
|
|
|
var x = st.pop();
|
|
|
|
|
|
if (seen[x]) { continue; }
|
|
|
|
|
|
seen[x] = true;
|
|
|
|
|
|
if (x === meUi) { return true; }
|
|
|
|
|
|
(kidList[x] || []).forEach(function (k) { st.push(k); });
|
2026-10-02 13:26:11 +08:00
|
|
|
|
}
|
2026-10-02 17:35:27 +08:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
var roots = [];
|
|
|
|
|
|
units.forEach(function (u, ui) { if (mainParent[ui] === undefined) { roots.push(ui); } });
|
|
|
|
|
|
roots.sort(function (a, b) {
|
|
|
|
|
|
var ma = treeHasMe(a), mb = treeHasMe(b);
|
|
|
|
|
|
return (ma === mb) ? ((units[a].gen - units[b].gen) || (a - b)) : (ma ? -1 : 1);
|
|
|
|
|
|
});
|
|
|
|
|
|
function subtreeRight(root) {
|
|
|
|
|
|
var hi = -Infinity;
|
|
|
|
|
|
(function walk(x) {
|
|
|
|
|
|
var p = placed[x];
|
|
|
|
|
|
if (p) { hi = Math.max(hi, p.x0 + ownW(x)); }
|
|
|
|
|
|
(kidList[x] || []).forEach(walk);
|
|
|
|
|
|
})(root);
|
|
|
|
|
|
return hi;
|
|
|
|
|
|
}
|
|
|
|
|
|
var cursorX = 0;
|
|
|
|
|
|
var TREE_GAP = SP; // 树与树之间的留白(姻亲树贴着主树放,
|
|
|
|
|
|
// 让岳父母尽量靠近妻子;区间法保证不重叠)
|
|
|
|
|
|
roots.forEach(function (r) {
|
|
|
|
|
|
calcNeed(r);
|
|
|
|
|
|
assign(r, cursorX);
|
|
|
|
|
|
cursorX = subtreeRight(r) + TREE_GAP;
|
|
|
|
|
|
});
|
2026-10-02 13:26:11 +08:00
|
|
|
|
|
|
|
|
|
|
/* ---------- 5. 落最终坐标 ---------- */
|
2026-10-02 17:35:27 +08:00
|
|
|
|
var gmin = 0, gmax = 0;
|
|
|
|
|
|
units.forEach(function (u) { gmin = Math.min(gmin, u.gen); gmax = Math.max(gmax, u.gen); });
|
2026-10-02 13:26:11 +08:00
|
|
|
|
var xs = [];
|
|
|
|
|
|
units.forEach(function (u, ui) {
|
|
|
|
|
|
var p = placed[ui];
|
2026-10-02 17:35:27 +08:00
|
|
|
|
if (p) { xs.push(p.x0, p.x0 + ownW(ui)); }
|
2026-10-02 13:26:11 +08:00
|
|
|
|
});
|
|
|
|
|
|
var minX = Math.min.apply(null, xs), maxX = Math.max.apply(null, xs);
|
2026-10-02 12:48:33 +08:00
|
|
|
|
var W = (maxX - minX) + PAD * 2;
|
2026-10-02 13:26:11 +08:00
|
|
|
|
var rows = [];
|
|
|
|
|
|
for (var g = gmin; g <= gmax; g++) { rows.push(g); }
|
|
|
|
|
|
var H = PAD * 2 + (rows.length - 1) * YS + IH;
|
2026-10-02 10:26:14 +08:00
|
|
|
|
|
2026-10-02 13:26:11 +08:00
|
|
|
|
var pos = {}; // personId -> {x, y}(卡中心)
|
|
|
|
|
|
units.forEach(function (u, ui) {
|
|
|
|
|
|
var p = placed[ui];
|
|
|
|
|
|
if (!p) { return; }
|
|
|
|
|
|
var y = PAD + IH / 2 + rows.indexOf(u.gen) * YS;
|
|
|
|
|
|
var x0 = p.x0 - minX + PAD;
|
|
|
|
|
|
var ids = u.cards.map(function (c) { return String(c.id); });
|
2026-10-02 10:26:14 +08:00
|
|
|
|
if (ids.length >= 2) {
|
2026-10-02 13:26:11 +08:00
|
|
|
|
pos[ids[0]] = { x: x0 + IW / 2, y: y };
|
|
|
|
|
|
pos[ids[1]] = { x: x0 + SLOT + IW / 2, y: y };
|
2026-10-02 10:26:14 +08:00
|
|
|
|
} else {
|
2026-10-02 13:26:11 +08:00
|
|
|
|
pos[ids[0]] = { x: x0 + IW / 2, y: y };
|
2026-10-02 10:26:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-02 13:26:11 +08:00
|
|
|
|
/* ---------- 6. 卡片 ---------- */
|
|
|
|
|
|
var html = '';
|
2026-10-02 10:26:14 +08:00
|
|
|
|
units.forEach(function (u) {
|
|
|
|
|
|
u.cards.forEach(function (c) {
|
2026-10-02 13:26:11 +08:00
|
|
|
|
var p = pos[String(c.id)];
|
2026-10-02 10:26:14 +08:00
|
|
|
|
if (!p) { return; }
|
|
|
|
|
|
var cls = 'kin-card' + (c.me ? ' is-me' : '') + (c.ph ? ' is-ph' : '');
|
2026-10-02 13:26:11 +08:00
|
|
|
|
var g = (c.gender === 'M' || c.gender === 'F') ? '' : ' g-u';
|
|
|
|
|
|
html += '<a class="' + cls + g + '" data-kin-id="' + esc(c.id) + '"'
|
|
|
|
|
|
+ ' style="left:' + p.x + 'px;top:' + p.y + 'px;width:' + IW + 'px;height:' + IH + 'px"'
|
2026-10-02 10:26:14 +08:00
|
|
|
|
+ ' 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>';
|
2026-10-01 19:35:48 +08:00
|
|
|
|
});
|
2026-10-02 10:26:14 +08:00
|
|
|
|
});
|
|
|
|
|
|
root.insertAdjacentHTML('beforeend',
|
|
|
|
|
|
'<div class="kin-stage" style="width:' + W + 'px;height:' + H + 'px">' + html + '</div>');
|
2026-10-01 19:35:48 +08:00
|
|
|
|
|
2026-10-02 15:49:50 +08:00
|
|
|
|
/* ---------- 7. 连线 ----------
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️⚠️ v19:**先把所有节点坐标摆定,再统一按节点坐标画线**(用户 2026-10-02 的建议原话:
|
|
|
|
|
|
* 「应该把每个节点的位置都摆好之后,再根据节点进行画线」)。
|
|
|
|
|
|
* 上一版是"边摆放边画线",于是两套坐标混着用:
|
|
|
|
|
|
* · 横梁端点用了 `placed[k].uc`(**户中心**),而卡片的真实中心是 `pos[id].x` ——
|
|
|
|
|
|
* 户中心与卡中心只在"单人户"下相等,双人户差半个槽位;
|
|
|
|
|
|
* · 「子女接父母」的竖线起点取"卡顶",而横梁的 y 是按"父户那一代的 y"推的,
|
|
|
|
|
|
* 两者不在同一个坐标系里。
|
|
|
|
|
|
* 结果:实测 5 张卡「有父母却没有线连上去」、17 段线两端都不碰任何卡片(悬空)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 现在统一成三步:
|
|
|
|
|
|
* ① 每个"人"的坐标已经在 pos[] 里定死(那是唯一的真值);
|
|
|
|
|
|
* ② 每"户"从它的卡片坐标推出:左卡中心、右卡中心、横线中点、卡顶/卡底 y;
|
|
|
|
|
|
* ③ 画线只用这些**由节点坐标推出的值**,不再引用放置阶段的游标数据。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 线的三段(与参考实现一致):
|
|
|
|
|
|
* A. 夫妻横线:两张卡中心之间(y = 卡中心高度)
|
|
|
|
|
|
* B. 下垂线:从**横线中点**往下 → 到"子女代的横梁"(y = 下一代卡顶 - SP/2)
|
|
|
|
|
|
* C. 横梁 + 分叉:横梁连到每个孩子的**卡顶中点**,再各自往下一点点接上卡
|
|
|
|
|
|
* 另外每人若有父母,从卡顶往上画一小段接到横梁(保证"和下一代连上"是闭合的)。
|
|
|
|
|
|
*/
|
2026-10-02 12:48:33 +08:00
|
|
|
|
var paths = [], bars = [], drops = [];
|
2026-10-01 19:35:48 +08:00
|
|
|
|
|
2026-10-02 13:26:11 +08:00
|
|
|
|
function line(x1, y1, x2, y2) {
|
2026-10-02 15:49:50 +08:00
|
|
|
|
if (Math.abs(x1 - x2) < 0.05 && Math.abs(y1 - y2) < 0.05) { return; }
|
2026-10-02 13:26:11 +08:00
|
|
|
|
paths.push('M' + x1.toFixed(1) + ',' + y1.toFixed(1) + 'L' + x2.toFixed(1) + ',' + y2.toFixed(1));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 15:49:50 +08:00
|
|
|
|
// ---- ② 由「人」的坐标推出「户」的画线锚点 ----
|
|
|
|
|
|
// anchor[ui] = {gen, y, left, right, mid, top, bottom, hasBar, cardIds}
|
|
|
|
|
|
var anchor = {};
|
2026-10-02 13:26:11 +08:00
|
|
|
|
units.forEach(function (u, ui) {
|
2026-10-02 15:49:50 +08:00
|
|
|
|
var ids = u.cards.map(function (c) { return String(c.id); })
|
|
|
|
|
|
.filter(function (id) { return !!pos[id]; });
|
|
|
|
|
|
if (!ids.length) { return; }
|
|
|
|
|
|
var ys = ids.map(function (id) { return pos[id].y; });
|
|
|
|
|
|
var y = ys[0]; // 同户必然同一行
|
|
|
|
|
|
var cx = ids.map(function (id) { return pos[id].x; });
|
|
|
|
|
|
var left = Math.min.apply(null, cx), right = Math.max.apply(null, cx);
|
|
|
|
|
|
anchor[ui] = {
|
|
|
|
|
|
gen: u.gen, y: y, yRow: y,
|
|
|
|
|
|
left: left, right: right,
|
|
|
|
|
|
mid: (left + right) / 2, // ★ 夫妻横线中点(单亲 = 卡中心)
|
|
|
|
|
|
top: y - IH / 2, bottom: y + IH / 2,
|
|
|
|
|
|
hasBar: ids.length >= 2,
|
|
|
|
|
|
ids: ids
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
2026-10-01 19:35:48 +08:00
|
|
|
|
|
2026-10-02 15:49:50 +08:00
|
|
|
|
// 每一代的行 y(用于算"横梁"的位置:下一代卡顶再往上一点)
|
|
|
|
|
|
var genY = {};
|
|
|
|
|
|
Object.keys(anchor).forEach(function (ui) {
|
|
|
|
|
|
var a = anchor[ui];
|
|
|
|
|
|
if (genY[a.gen] === undefined) { genY[a.gen] = a.y; }
|
|
|
|
|
|
});
|
|
|
|
|
|
function nextGenY(gen) {
|
|
|
|
|
|
var best = null;
|
|
|
|
|
|
Object.keys(genY).forEach(function (g) {
|
|
|
|
|
|
var gg = +g;
|
|
|
|
|
|
if (gg > gen && (best === null || gg < best)) { best = gg; }
|
|
|
|
|
|
});
|
|
|
|
|
|
return best === null ? null : genY[best];
|
|
|
|
|
|
}
|
2026-10-02 12:48:33 +08:00
|
|
|
|
|
2026-10-02 15:49:50 +08:00
|
|
|
|
// ---- ③ 画线:只用 anchor 与 pos ----
|
|
|
|
|
|
// A. 夫妻横线
|
|
|
|
|
|
units.forEach(function (u, ui) {
|
|
|
|
|
|
var a = anchor[ui];
|
|
|
|
|
|
if (!a) { return; }
|
|
|
|
|
|
if (a.hasBar) { line(a.left, a.y, a.right, a.y); }
|
|
|
|
|
|
bars.push({ ui: ui, x1: a.left, x2: a.right, y: a.y, mid: a.mid, has: a.hasBar });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// B/C. 从"我"这一户往下连子女(横线中点 → 横梁 → 每个孩子的卡顶)
|
|
|
|
|
|
//
|
|
|
|
|
|
// ⚠️ 横梁 y 取「**子女那一代的卡片顶** 再往上 SP/2」——
|
|
|
|
|
|
// 上一版取的是"父户的 y + IH/2 + SP/2",当两代之间还有别的行时就会错位。
|
|
|
|
|
|
Object.keys(anchor).forEach(function (k) {
|
|
|
|
|
|
var ui = +k, a = anchor[ui];
|
|
|
|
|
|
var kids = (kidsOf[ui] || []).filter(function (kk) { return anchor[kk]; });
|
|
|
|
|
|
if (!kids.length) { return; }
|
|
|
|
|
|
// ⚠️⚠️ 横梁 y 必须由**孩子自己的卡顶**算,不能用"我这一代的下一代":
|
|
|
|
|
|
// 叔伯的子女与我同代(不是下一代),舅姨的子女也是同代 ——
|
|
|
|
|
|
// 用 nextGenY() 会把横梁画到别处,孩子的竖线于是接不上(实测 18 段悬空)。
|
|
|
|
|
|
var kidTops = kids.map(function (kk) { return anchor[kk].top; });
|
|
|
|
|
|
var busY = Math.min.apply(null, kidTops) - SP / 2; // 横梁:孩子卡顶再往上 SP/2
|
|
|
|
|
|
// 下垂线:从**卡底**(不是卡中心)→ 横梁,这样不会压在卡片身上
|
|
|
|
|
|
line(a.mid, a.bottom, a.mid, busY);
|
|
|
|
|
|
drops.push({ ui: ui, x: a.mid, y0: a.bottom, busY: busY });
|
|
|
|
|
|
|
|
|
|
|
|
// 横梁:从最左孩子连到最右孩子。
|
|
|
|
|
|
//
|
|
|
|
|
|
// ⚠️ 曾经担心"长横梁横穿全图",但那不是真正的问题 ——
|
|
|
|
|
|
// 横梁的 y 落在**两代之间的空隙**里,那里**没有任何卡片**,所以横梁不穿卡。
|
|
|
|
|
|
// 真正的问题是**布局**:同一对父母的子女被别家的孩子插在中间
|
|
|
|
|
|
// (实测"堂兄"夹在"哥哥"和"我"之间),横梁才被迫跨得很远。
|
2026-10-02 17:35:27 +08:00
|
|
|
|
// 区间法布局里同一父户的子女在它的区间内相邻铺开,横梁自然是短的。
|
2026-10-02 15:49:50 +08:00
|
|
|
|
var ks = kids.map(function (kk) { return anchor[kk].mid; })
|
|
|
|
|
|
.sort(function (x, y2) { return x - y2; });
|
|
|
|
|
|
if (ks.length > 1) { line(ks[0], busY, ks[ks.length - 1], busY); }
|
|
|
|
|
|
|
|
|
|
|
|
// 每个孩子:从横梁往下接到**卡顶**
|
|
|
|
|
|
kids.forEach(function (kk) {
|
|
|
|
|
|
var c = anchor[kk];
|
|
|
|
|
|
line(c.mid, busY, c.mid, c.top);
|
2026-10-02 10:26:14 +08:00
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-10-02 09:28:07 +08:00
|
|
|
|
|
2026-10-02 15:49:50 +08:00
|
|
|
|
// D. 每个"有父母"的人:**卡顶往上接到父母那一户的横梁**
|
|
|
|
|
|
//
|
|
|
|
|
|
// ⚠️ 这里必须用「**父母那一户**的下垂线终点 busY」,
|
|
|
|
|
|
// 而不是"本代卡顶 - SP/2" —— 后者只在"父母与我在相邻两代"时才相等,
|
|
|
|
|
|
// 姻亲/旁支一错位就接不上(实测 6 张卡"有父母却没有线连上去")。
|
|
|
|
|
|
// 上面 B/C 段已经把每户的 busY 算好放进 drops[],这里直接查。
|
|
|
|
|
|
var busOfUnit = {};
|
|
|
|
|
|
drops.forEach(function (d) { busOfUnit[d.ui] = d.busY; });
|
|
|
|
|
|
Object.keys(anchor).forEach(function (k) {
|
|
|
|
|
|
var ui = +k, a = anchor[ui];
|
|
|
|
|
|
var ps = parentsOf[String(ui)] || [];
|
|
|
|
|
|
if (!ps.length) { return; }
|
|
|
|
|
|
// 取"负责连他的那一户"(parentsOf 里的第一个)
|
|
|
|
|
|
var pu = ps[0];
|
|
|
|
|
|
var busY = busOfUnit[pu];
|
|
|
|
|
|
if (busY === undefined) {
|
|
|
|
|
|
// 父母那一户没有子女横梁(数据异常):退化到"本代卡顶 - SP/2"
|
|
|
|
|
|
busY = a.top - SP / 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
line(a.mid, busY, a.mid, a.top);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-02 10:26:14 +08:00
|
|
|
|
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>');
|
2026-10-02 09:28:07 +08:00
|
|
|
|
|
2026-10-02 13:26:11 +08:00
|
|
|
|
window.__kinLayout = { W: W, H: H, IW: IW, IH: IH, SLOT: SLOT, bars: bars,
|
|
|
|
|
|
drops: drops, pos: pos, paths: paths };
|
2026-10-02 10:26:14 +08:00
|
|
|
|
|
2026-10-02 13:26:11 +08:00
|
|
|
|
/* ---------- 8. 拖拽 + 缩放 ---------- */
|
2026-10-02 10:26:14 +08:00
|
|
|
|
var stage = root.querySelector('.kin-stage');
|
2026-10-02 13:26:11 +08:00
|
|
|
|
var svg = root.querySelector('svg.kin-lines');
|
2026-10-02 10:26:14 +08:00
|
|
|
|
|
2026-10-02 13:26:11 +08:00
|
|
|
|
var zoom = d3.zoom().scaleExtent([0.15, 3]).on('zoom', function (ev) {
|
2026-10-02 10:26:14 +08:00
|
|
|
|
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; }
|
2026-10-02 13:26:11 +08:00
|
|
|
|
var k = Math.min(r.width / W, r.height / H) * 0.94;
|
|
|
|
|
|
k = Math.max(0.15, Math.min(3, k));
|
|
|
|
|
|
sel.call(zoom.transform, d3.zoomIdentity.translate(
|
|
|
|
|
|
(r.width - W * k) / 2, (r.height - H * k) / 2).scale(k));
|
2026-10-02 09:28:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
function centerOn(id) {
|
2026-10-02 13:26:11 +08:00
|
|
|
|
var p = pos[String(id)];
|
2026-10-02 10:26:14 +08:00
|
|
|
|
if (!p) { return; }
|
|
|
|
|
|
var r = root.getBoundingClientRect();
|
|
|
|
|
|
var t = d3.zoomTransform(root);
|
2026-10-02 13:26:11 +08:00
|
|
|
|
sel.call(zoom.transform, d3.zoomIdentity.translate(
|
|
|
|
|
|
r.width / 2 - p.x * t.k, r.height / 2 - p.y * t.k).scale(t.k));
|
2026-10-02 09:28:07 +08:00
|
|
|
|
}
|
2026-10-02 10:26:14 +08:00
|
|
|
|
window.kinFit = fit;
|
2026-10-02 09:28:07 +08:00
|
|
|
|
window.kinCenterOn = centerOn;
|
|
|
|
|
|
|
|
|
|
|
|
var meId = root.getAttribute('data-me') || '';
|
2026-10-02 13:26:11 +08:00
|
|
|
|
fit();
|
|
|
|
|
|
// 进来先把镜头对准「我」
|
|
|
|
|
|
if (meId && window.kinCenterOn) { setTimeout(function () { centerOn(meId); }, 60); }
|
2026-10-02 09:28:07 +08:00
|
|
|
|
|
|
|
|
|
|
var btnFit = document.getElementById('kinFit');
|
2026-10-02 13:26:11 +08:00
|
|
|
|
var btnMe = document.getElementById('kinMe');
|
2026-10-02 10:26:14 +08:00
|
|
|
|
if (btnFit) { btnFit.addEventListener('click', fit); }
|
|
|
|
|
|
if (btnMe && meId) { btnMe.addEventListener('click', function () { centerOn(meId); }); }
|
2026-10-01 19:35:48 +08:00
|
|
|
|
})();
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
2026-09-30 08:13:07 +08:00
|
|
|
|
<?php endif; ?>
|