Files
FamilyBook/templates/kin/tree.php
T

335 lines
14 KiB
PHP
Raw Normal View History

<?php
/**
* 我的家谱:以我为中心,上一代 / 这一代 / 下一代**所有有关系的人**。
*
* ============================ v15 为什么又重写渲染 ============================
*
* v13/v14 用第三方库 family-chart 画,结果**数据一直不全**(用户两次反馈:
* 「显示不齐全」「数据仍然不全,差很多」)。实测(_tooling/_fb_kin_cover_probe.py)
* 给出了硬证据:20 人的夹具,库**只画了 9 人** ——
* 伯父、伯母、岳父、岳母、嫂子、堂兄、堂嫂、舅子、侄子、堂侄、外甥**一个都没画**。
* 原因在库的算法本身:它只画「**树根**的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
* **横向的亲戚(伯父那一支)与姻亲旁支(妻子的兄弟)永远进不来**。
* 换树根也救不了 —— 换成最高祖先,"妻子的兄弟"照样不在树里。
*
* 所以这一版**不再依赖库的树算法**:
* · `Kinship::familyMap()` 以「我」为中心按代数检索**所有人**
* (父母、子女、配偶、配偶的父母与子女、每层的配偶、兄弟姐妹的子女…);
* · 前端按「**代分层、户并排**」自己排版、自己画线(见下面的 JS)。
*
* 用户点名的两条(都已兑现):
* ① 「显示本人的上一代、这一代、下一代的所有人」→ 见 familyMap 的两轮贯穿;
* ② 「子女必须从**夫妻之间的横线**出来,不能从夫妻其中一个出来
* (否则母亲登录看不到自己的孩子)」→ 子女挂在**户**上,
* 竖线起点 = 夫妻横线的**中点**(`x = (左卡中心 + 右卡中心) / 2`),
* 与"是父亲登录还是母亲登录"完全无关。
*
* ⚠️ 为什么不再用库:库把布局写死在它的树算法里,我们既改不了"画谁"(横向亲戚不进树),
* 也改不了"线从哪出"。自己排版之后这两件事都变成几行代码。
* ⚠️ 拖拽/缩放仍要保留(用户明确说「反正可以缩放和拖动」):用原生 d3.zoom,
* 不再需要库(d3.min.js 还在 vendor 里,可继续复用)。
*/
$graph = $graph ?? \App\Kinship::graph((int)\App\Auth::familyId());
$people = $people ?? [];
// ⚠️ 模板里不再有 $nodes:v15 起家谱数据由 Kinship::familyMap() 现算,
// 不再经过 Kinship::tree() 的 units/generations 那套(那套是给旧画法用的)。
// ---- ① 以我为中心,检索上一代 / 这一代 / 下一代的**所有人** ----
// up/down 取 2:卡片一行放得下三代,再多会挤(本人 = 0 代,-1/-2 为长辈,+1/+2 为晚辈)。
$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) {
$ids = array_map('intval', $u['ids']);
$sorted = $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;
}
$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, // 这个户的孩子(连线从这里画下去)
];
}
// 同代之间按「主位长幼」排序,保证顺序稳定
$payloadUnitsSorted = $payloadUnits;
usort($payloadUnitsSorted, 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 ($payloadUnitsSorted as $u) {
$totalPeople += count($u['cards']);
}
$canChart = $totalPeople > 1;
$meName = (string)($people[$meId]['name'] ?? '自己');
$payloadJson = json_encode(
['units' => $payloadUnitsSorted, '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>
<?php
/* 画布。里面的内容(卡片 + svg 连线)由下面的脚本按数据算出来再落进去。
⚠️ 尺寸不能是 0:脚本要按容器的真实宽高算初始 fit(0×0 会缩到看不清)。 */
?>
<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 GAP = 16; // 夫妻两张卡之间的缝(横线就画在这一段上)
var XS = 200; // 同代相邻「户」的中心距
var YS = 156; // 代与代之间的行距
var PAD = 70; // 画布留白
// ---- 1. 分代 ----
var byGen = {};
units.forEach(function (u) { (byGen[u.gen] = byGen[u.gen] || []).push(u); });
var gens = Object.keys(byGen).map(Number).sort(function (a, b) { return a - b; });
// ---- 2. 每代居中排开;算出每户的中心 x 与两张卡的位置 ----
var maxPer = 1;
gens.forEach(function (g) { maxPer = Math.max(maxPer, byGen[g].length); });
var W = PAD * 2 + Math.max(1, maxPer) * XS;
var H = PAD * 2 + (gens.length - 1) * YS + CH;
var pos = {}; // id => {x,y}
var lay = {}; // 户序号 => {cx, cy, left, right, barY, ids}
var genGi = {};
gens.forEach(function (g, gi) { genGi[g] = gi; });
units.forEach(function (u, ui) {
var row = byGen[u.gen];
var i = row.indexOf(u);
var gi = genGi[u.gen];
var y = PAD + CH / 2 + gi * YS;
var startX = (W - (row.length - 1) * XS) / 2;
var cx = startX + i * XS;
var ids = u.cards.map(function (c) { return c.id; });
var half = CW / 2;
var left, right;
if (ids.length >= 2) {
left = cx - GAP / 2 - half; // 左卡中心
right = cx + GAP / 2 + half; // 右卡中心
pos[ids[0]] = { x: left, y: y, g: u.gen };
pos[ids[1]] = { x: right, y: y, g: u.gen };
} else {
left = right = cx;
pos[ids[0]] = { x: cx, y: y, g: u.gen };
}
lay[ui] = {
cx: cx, cy: y, left: left, right: right, ids: ids,
// 夫妻横线的 y:卡片下沿往下一点,正好在两张卡之间
barY: y + CH / 2 - 6,
hasBar: ids.length >= 2
};
});
// ---- 3. 落卡片 ----
var 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>';
});
});
root.insertAdjacentHTML('beforeend',
'<div class="kin-stage" style="width:' + W + 'px;height:' + H + 'px">' + html + '</div>');
// ---- 4. 画线 ----
// 用户的核心要求:「夫妻之间有横线;所有子女都从这条**横线**出来」。
// 竖线起点恒为 (左卡中心 + 右卡中心)/2 —— 与"谁登录"无关,
// 也与"孩子只登记了父亲还是只登记了母亲"无关。
var paths = [];
var bars = []; // 调试/断言用:每户的横线区间与中点
var drops = []; // 调试/断言用:每户下垂线的起点 x
Object.keys(lay).forEach(function (k) {
var L = lay[k];
if (L.hasBar) {
paths.push('M' + L.left + ',' + L.barY + 'L' + L.right + ',' + L.barY);
}
bars.push({ ui: +k, x1: L.left, x2: L.right, mid: (L.left + L.right) / 2,
y: L.barY, has: L.hasBar });
});
units.forEach(function (u, ui) {
var L = lay[ui];
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 y0 = L.barY;
var busY = L.cy + YS - CH / 2 - 30; // 子女行上方的横梁
paths.push('M' + srcX + ',' + y0 + 'L' + srcX + ',' + busY);
if (kids.length > 1) {
paths.push('M' + kids[0].x + ',' + busY + 'L' + kids[kids.length - 1].x + ',' + busY);
}
kids.forEach(function (k) {
paths.push('M' + k.x + ',' + busY + 'L' + k.x + ',' + (k.y - CH / 2 + 2));
});
drops.push({ ui: ui, x: srcX, y0: y0, 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. 拖拽 + 缩放(d3.zoom)----
var stage = root.querySelector('.kin-stage');
var svg = root.querySelector('svg.kin-lines');
var wrap = d3.select(root).append('g'); // 占位,真正的变换施加在 svg + stage 上
wrap.remove();
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; ?>