tarinai/js/ui_family_render.js
2026-06-25 14:51:58 +09:00

400 lines
19 KiB
JavaScript

"use strict";
function lineageSplitByActualEdges(component) {
const family = lineageEdgeFamily(world.family || {});
const nodes = Array.from(new Map(lineageCleanComponent(component, family).filter(Boolean).map(n => [n.id, n])).values());
const idSet = new Set(nodes.map(n => n.id));
const parts = window.TarinaiFamilyGraph.connectedComponents(nodes, {
sort: lineageNodeSort,
edgesOf: (n) => [
...lineageMutualParentIds(n, family, idSet),
...lineageMutualChildIds(n, family, idSet),
],
filter: part => part.length && part.some(x => x.alive),
});
return parts;
}
function lineageExpandComponent(component, family) {
family = lineageEdgeFamily(family);
const ids = new Set(component.filter(Boolean).map(n => n.id));
let changed = true;
while (changed) {
changed = false;
for (const id of Array.from(ids)) {
const n = family[id];
if (!n) continue;
for (const p of lineageMutualParentIds(n, family)) {
if (!ids.has(p)) { ids.add(p); changed = true; }
}
for (const c of lineageMutualChildIds(n, family)) {
if (!ids.has(c)) { ids.add(c); changed = true; }
}
}
}
return Array.from(ids).map(id => family[id]).filter(Boolean);
}
function lineageCleanComponent(component, family) {
family = lineageEdgeFamily(family);
const expanded = lineageExpandComponent(component, family);
const idSet = new Set(expanded.map(n => n.id));
return expanded.filter(n => lineageMutualParentIds(n, family, idSet).length || lineageMutualChildIds(n, family, idSet).length);
}
function lineageFastLayoutNeeded(nodes, idSet, family) {
const edgeCount = nodes.reduce((sum, n) => sum + lineageParentIds(n, idSet, family).length, 0);
const maxGenerationWidth = Math.max(0, ...Array.from(new Set(nodes.map(n => n.generation || 1))).map(g => nodes.filter(n => (n.generation || 1) === g).length));
return {
edgeCount,
fast: nodes.length > 140 || edgeCount > 260 || maxGenerationWidth > 34,
};
}
function lineageLayoutRowsFast(rows, idSet) {
const NODE_W = 132;
const NODE_H = 84;
const SIBLING_GAP = 22;
const ROW_GAP = 116;
const LEFT_PAD = 82;
const TOP_PAD = 44;
const RIGHT_PAD = 48;
const BOTTOM_PAD = 30;
const positions = new Map();
let width = 420;
rows.forEach((row, rowIndex) => {
const y = TOP_PAD + rowIndex * (NODE_H + ROW_GAP);
let x = LEFT_PAD;
for (const n of row) {
positions.set(n.id, { x, y, rowIndex, node: n });
x += NODE_W + SIBLING_GAP;
}
width = Math.max(width, x - SIBLING_GAP + RIGHT_PAD);
});
const height = TOP_PAD + Math.max(0, rows.length - 1) * (NODE_H + ROW_GAP) + NODE_H + BOTTOM_PAD;
return { positions, width, height, nodeW: NODE_W, nodeH: NODE_H, topPad: TOP_PAD, rowGap: ROW_GAP, fastMode: true };
}
function lineageBuildLinksFast(nodes, idSet, layout) {
const { positions, nodeW, nodeH } = layout;
const partnerLinks = [];
const childLinks = [];
const seenPartners = new Set();
let childLinkGroups = 0;
let childLinkEdges = 0;
for (const child of nodes) {
const childPos = positions.get(child.id);
if (!childPos) continue;
const parents = lineageParentIds(child, idSet).map(id => positions.get(id)).filter(Boolean).sort((a, b) => a.x - b.x);
if (!parents.length) continue;
childLinkGroups += 1;
childLinkEdges += parents.length;
const childX = childPos.x + nodeW / 2;
const childY = childPos.y;
const fromY = Math.max(...parents.map(p => p.y + nodeH));
const joinX = parents.reduce((sum, p) => sum + p.x + nodeW / 2, 0) / parents.length;
if (parents.length >= 2) {
const left = parents[0];
const right = parents[parents.length - 1];
const partnerKey = parents.map(p => p.node?.id).filter(Boolean).sort().join("+");
if (partnerKey && !seenPartners.has(partnerKey)) {
seenPartners.add(partnerKey);
const y = ((left.y + nodeH / 2) + (right.y + nodeH / 2)) / 2;
partnerLinks.push(`M ${(left.x + nodeW).toFixed(1)} ${y.toFixed(1)} L ${right.x.toFixed(1)} ${y.toFixed(1)}`);
}
}
const laneY = Math.min(childY - 18, fromY + Math.max(28, (childY - fromY) * 0.48));
childLinks.push(`M ${joinX.toFixed(1)} ${fromY.toFixed(1)} L ${joinX.toFixed(1)} ${laneY.toFixed(1)} L ${childX.toFixed(1)} ${laneY.toFixed(1)} L ${childX.toFixed(1)} ${childY.toFixed(1)}`);
}
return { partnerLinks, childLinks, childLinkGroups, childLinkEdges };
}
function buildLineageTreeLayout(component, family) {
family = lineageEdgeFamily(family);
const cleaned = lineageCleanComponent(component, family);
const nodes = Array.from(new Map(cleaned.filter(Boolean).map(n => [n.id, n])).values()).sort(lineageNodeSort);
const idToNode = new Map(nodes.map(n => [n.id, n]));
const idSet = new Set(idToNode.keys());
const generations = Array.from(new Set(nodes.map(n => n.generation || 1))).sort((a, b) => a - b);
const rows = lineageRowsForLayout(nodes, generations, idToNode, idSet);
const perf = lineageFastLayoutNeeded(nodes, idSet, family);
const layout = perf.fast ? lineageLayoutRowsFast(rows, idSet) : lineageLayoutRows(rows, idSet);
const links = perf.fast ? lineageBuildLinksFast(nodes, idSet, layout) : lineageBuildLinks(nodes, idSet, layout);
const labels = generations.map((g, rowIndex) => ({ generation: g, y: layout.topPad + rowIndex * (layout.nodeH + layout.rowGap) + 18 }));
const renderedNodes = nodes.map(n => ({ node: n, ...(layout.positions.get(n.id) || {}) })).filter(n => Number.isFinite(n.x) && Number.isFinite(n.y));
return { ...layout, labels, nodes: renderedNodes, childLinks: links.childLinks, partnerLinks: links.partnerLinks, childLinkGroups: links.childLinkGroups, childLinkEdges: links.childLinkEdges, fastMode: perf.fast, edgeCount: perf.edgeCount };
}
function lineageFamilyHtml(component, index, family, signature = "") {
const uniqueComponent = Array.from(new Map(component.filter(Boolean).map(n => [n.id, n])).values()).sort(lineageNodeSort);
const cacheKey = lineageFamilyCacheKey(uniqueComponent, family, signature);
if (uiCache.archiveHtmlCache?.has(cacheKey)) return lineageHydrateFamilyHtml(uiCache.archiveHtmlCache.get(cacheKey), index);
const layout = buildLineageTreeLayout(uniqueComponent, family);
const aliveCount = uniqueComponent.filter(n => n.alive).length;
const childEdgeCount = lineageExpectedChildEdgeCount(uniqueComponent, family);
const maskId = "{{LINEAGE_MASK_ID}}";
const nodeMaskRects = layout.nodes
.map(p => `<rect x="${(p.x + 1).toFixed(1)}" y="${(p.y + 1).toFixed(1)}" width="${(layout.nodeW - 2).toFixed(1)}" height="${(layout.nodeH - 2).toFixed(1)}" rx="9" fill="black"></rect>`)
.join("");
const labels = layout.labels.map(label => `<div class="tree-generation-label" style="top:${Math.round(label.y)}px">\u4e16\u4ee3 ${label.generation}</div>`).join("");
const partnerPaths = layout.partnerLinks.map(d => `<path class="partner-link" d="${d}"></path>`).join("");
const childPaths = layout.childLinks.map(d => `<path class="child-link" d="${d}"></path>`).join("");
const nodeHtml = layout.nodes.map(p => lineageTreeNodeHtml(p.node, p.x, p.y)).join("");
const empty = !layout.childLinks.length ? `<div class="lineage-empty-family">\u89aa\u5b50\u95a2\u4fc2\u306e\u7dda\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002</div>` : "";
const html = `<section class="lineage-family lineage-family-tree" data-lineage-index="{{LINEAGE_INDEX}}" data-lineage-key="${escapeHtml(cacheKey)}" data-lineage-child-edges="${childEdgeCount}" data-lineage-rendered-child-edges="${layout.childLinkEdges || 0}" data-lineage-child-groups="${layout.childLinkGroups || 0}" data-lineage-child-paths="${layout.childLinks.length}">
<h3>\u5bb6\u7cfb {{LINEAGE_INDEX}} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}</h3>
<div class="lineage-tree-panel" style="width:${Math.ceil(layout.width)}px;height:${Math.ceil(layout.height)}px">
<svg class="lineage-links" viewBox="0 0 ${Math.ceil(layout.width)} ${Math.ceil(layout.height)}" aria-hidden="true">
<defs><mask id="${maskId}" maskUnits="userSpaceOnUse"><rect x="0" y="0" width="${Math.ceil(layout.width)}" height="${Math.ceil(layout.height)}" fill="white"></rect>${nodeMaskRects}</mask></defs>
<g mask="url(#${maskId})">${partnerPaths}${childPaths}</g>
</svg>
${labels}${nodeHtml}${empty}
</div>
</section>`;
uiCache.archiveHtmlCache?.set(cacheKey, html);
return lineageHydrateFamilyHtml(html, index);
}
function renderArchive() {
if (!ui.archiveContent) return;
if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) {
uiCache.archiveScheduled = false;
if (!uiCache.archiveVersion) {
ui.archiveContent.innerHTML = `<div class="selected-info empty">\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u306fOFF\u3067\u3059\u3002\u300c\u66f4\u65b0\u300d\u3092ON\u306b\u3059\u308b\u3068\u63cf\u753b\u3057\u307e\u3059\u3002</div>`;
} else if (world.familyTreeDirty) {
lineageSetArchiveStaleStatus(true);
}
return;
}
if (uiCache.deferArchiveUntil && performance.now() < uiCache.deferArchiveUntil) return;
uiCache.deferArchiveUntil = 0;
uiCache.archiveScheduled = false;
const scrollState = lineageCaptureArchiveScroll();
const familyVersion = world.familyVersion || 0;
const familyDirty = Boolean(world.familyTreeDirty);
if (uiCache.archiveRenderRunning) {
if (familyDirty) uiCache.archivePendingDirtyAfterRun = true;
return;
}
if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === familyVersion && !familyDirty) return;
if (uiCache.archiveVersion && !lineageArchiveNearViewport()) {
uiCache.archivePendingOffscreen = true;
const hiddenInterval = 30000;
const hiddenNow = performance.now();
if (hiddenNow - (uiCache.archiveLastHiddenCheckAt || -Infinity) < hiddenInterval) return;
uiCache.archiveLastHiddenCheckAt = hiddenNow;
}
const now = performance.now();
const minUpdateGap = 4800;
if (uiCache.archiveVersion && !uiCache.archiveRenderForce && now - (uiCache.archiveLastRenderedAt || 0) < minUpdateGap) {
if (!uiCache.archiveRenderTimer) {
uiCache.archiveRenderTimer = setTimeout(() => {
uiCache.archiveRenderTimer = 0;
uiCache.archiveRenderForce = true;
renderArchive();
uiCache.archiveRenderForce = false;
}, Math.max(80, minUpdateGap - (now - (uiCache.archiveLastRenderedAt || 0))));
}
return;
}
if (world.familyPrunePending && world.pruneExtinctFamilies) {
world.familyPrunePending = false;
world.pruneExtinctFamilies();
}
const family = world.family || {};
const version = String(world.familyVersion || 0);
if (uiCache.archiveVersion === version) {
world.familyTreeDirty = false;
uiCache.archivePendingDirtyAfterRun = false;
return;
}
const renderStarted = performance.now();
lineageSetArchiveStaleStatus(false);
if (uiCache.archiveRenderTimer) {
clearTimeout(uiCache.archiveRenderTimer);
uiCache.archiveRenderTimer = 0;
}
uiCache.archiveVersion = version;
uiCache.archiveFamilyVersion = world.familyVersion || 0;
uiCache.archiveLastScheduleFamilyVersion = world.familyVersion || 0;
uiCache.archiveLastRenderedAt = performance.now();
uiCache.lastArchiveWindow = "";
if (!uiCache.archiveHtmlCache) uiCache.archiveHtmlCache = new Map();
const activeCacheKeys = new Set();
const token = `${version}:${Math.random().toString(36).slice(2)}`;
uiCache.archiveRenderToken = token;
uiCache.archiveRenderRunning = true;
ui.archiveContent.innerHTML = `<div class="lineage-render-status">\u5bb6\u7cfb\u56f3\u3092\u6e96\u5099\u4e2d\u2026</div><div class="lineage-forest"></div>`;
lineageRestoreArchiveScroll(scrollState);
const status = ui.archiveContent.querySelector(".lineage-render-status");
const forest = ui.archiveContent.querySelector(".lineage-forest");
const renderComponents = (components) => {
if (uiCache.archiveRenderToken !== token || !forest) {
uiCache.archiveRenderRunning = false;
return;
}
const splitComponents = components.flatMap(lineageSplitByActualEdges).filter(c => c.length);
uiCache.archiveRows = splitComponents;
if (!splitComponents.length) {
ui.archiveContent.innerHTML = `<div class="selected-info empty">\u751f\u304d\u3066\u3044\u308b\u69cb\u6210\u54e1\u3092\u6301\u3064\u5bb6\u7cfb\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002</div>`;
lineageRestoreArchiveScroll(scrollState);
uiCache.archiveRenderRunning = false;
world.familyTreeDirty = false;
uiCache.archivePendingDirtyAfterRun = false;
return;
}
if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 0 / ${splitComponents.length}`;
let index = 0;
const pump = () => {
if (uiCache.archiveRenderToken !== token || !forest) {
uiCache.archiveRenderRunning = false;
return;
}
const started = performance.now();
let nodeBudget = 28;
let renderedThisSlice = 0;
while (index < splitComponents.length) {
const component = splitComponents[index];
if (renderedThisSlice > 0 && component.length > nodeBudget) break;
const signature = lineageComponentSignature(component, family);
activeCacheKeys.add(lineageFamilyCacheKey(component, family, signature));
forest.insertAdjacentHTML("beforeend", lineageFamilyHtml(component, index, family, signature));
nodeBudget -= Math.max(1, component.length);
index += 1;
renderedThisSlice += 1;
if (index < splitComponents.length && (nodeBudget <= 0 || performance.now() - started > 6)) break;
}
lineageRestoreArchiveScroll(scrollState);
if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 ${index} / ${splitComponents.length}`;
if (index < splitComponents.length) {
lineageIdleSchedule(pump, 220);
} else {
if (status) status.remove();
for (const key of Array.from(uiCache.archiveHtmlCache.keys())) {
if (!activeCacheKeys.has(key)) uiCache.archiveHtmlCache.delete(key);
}
lineageRestoreArchiveScroll(scrollState);
const renderDirtiedAgain = uiCache.archivePendingDirtyAfterRun || (world.familyVersion || 0) !== familyVersion;
uiCache.archiveRenderRunning = false;
world.familyTreeDirty = renderDirtiedAgain;
if (uiCache.archiveDiagnostics) {
uiCache.archiveDiagnostics.renders = (uiCache.archiveDiagnostics.renders || 0) + 1;
uiCache.archiveDiagnostics.lastMs = Math.round((performance.now() - renderStarted) * 10) / 10;
uiCache.archiveDiagnostics.lastFamilies = splitComponents.length;
uiCache.archiveDiagnostics.lastNodes = splitComponents.reduce((sum, component) => sum + component.length, 0);
}
if (renderDirtiedAgain) {
uiCache.archivePendingDirtyAfterRun = false;
scheduleArchiveWindowRender();
}
}
};
lineageIdleSchedule(pump, 160);
};
lineageBuildComponentsIdle(
family,
token,
(label, done, total) => {
if (status) status.textContent = `${label}\u2026 ${Math.min(done || 0, total || 0)} / ${total || 0}`;
},
renderComponents
);
}
function resetArchiveRenderState() {
if (uiCache.archiveRenderTimer) {
clearTimeout(uiCache.archiveRenderTimer);
uiCache.archiveRenderTimer = 0;
}
uiCache.archiveRenderToken = "";
uiCache.archiveRenderRunning = false;
uiCache.archiveScheduled = false;
uiCache.archivePendingOffscreen = false;
uiCache.archivePendingWhileRunning = false;
uiCache.archivePendingDirtyAfterRun = false;
uiCache.archiveLastHiddenCheckAt = -Infinity;
uiCache.archiveLastScheduleFamilyVersion = null;
uiCache.archiveVersion = "";
uiCache.archiveFamilyVersion = null;
uiCache.archiveRelationSignature = "";
uiCache.archiveRows = [];
uiCache.lastArchiveWindow = "";
uiCache.archiveHtmlCache?.clear?.();
}
function validateFamilyTree() {
const issues = world.validateFamily ? [...world.validateFamily()] : [];
const rendered = new Map();
document.querySelectorAll("[data-family-tarinai-id]").forEach(node => {
const id = node.getAttribute("data-family-tarinai-id");
rendered.set(id, (rendered.get(id) || 0) + 1);
});
for (const [id, count] of rendered.entries()) {
if (count > 1) issues.push({ type: "duplicate-render-node", id, count });
}
for (const component of uiCache.archiveRows || []) {
for (const n of component || []) {
if (n?.id && !rendered.has(n.id)) issues.push({ type: "missing-render-node", id: n.id });
}
}
document.querySelectorAll(".lineage-family-tree[data-lineage-child-edges]").forEach((section, index) => {
const expectedEdges = Number(section.getAttribute("data-lineage-child-edges") || 0);
const renderedEdges = Number(section.getAttribute("data-lineage-rendered-child-edges") || 0);
const expectedGroups = Number(section.getAttribute("data-lineage-child-groups") || 0);
const renderedPaths = Number(section.getAttribute("data-lineage-child-paths") || 0);
const pathCount = section.querySelectorAll(".lineage-links .child-link").length;
if (renderedEdges !== expectedEdges) {
issues.push({ type: "render-child-edge-count-mismatch", familyIndex: index, expectedEdges, renderedEdges });
}
if (expectedEdges > 0 && pathCount < 1) {
issues.push({ type: "missing-render-child-link", familyIndex: index, expectedEdges, pathCount });
}
if (expectedGroups > 0 && pathCount < expectedGroups) {
issues.push({ type: "missing-render-child-link-group", familyIndex: index, expectedGroups, pathCount });
}
if (renderedPaths !== pathCount) {
issues.push({ type: "render-child-path-count-mismatch", familyIndex: index, renderedPaths, pathCount });
}
});
return issues;
}
function scheduleArchiveWindowRender() {
if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) {
uiCache.archiveScheduled = false;
if (world.familyTreeDirty) lineageSetArchiveStaleStatus(true);
return;
}
const familyDirty = Boolean(world.familyTreeDirty);
if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === (world.familyVersion || 0) && !familyDirty) return;
if (uiCache.archiveRenderRunning) {
if (familyDirty) uiCache.archivePendingDirtyAfterRun = true;
return;
}
if (uiCache.archiveVersion && !lineageArchiveNearViewport()) {
uiCache.archivePendingOffscreen = true;
const now = performance.now();
if (now - (uiCache.archiveLastHiddenCheckAt || -Infinity) < 30000) return;
uiCache.archiveLastHiddenCheckAt = now;
}
if (uiCache.archiveRenderTimer) return;
if (uiCache.archiveScheduled) return;
uiCache.archiveScheduled = true;
const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0));
schedule(() => {
renderArchive();
}, { timeout: 1200 });
}
function schedulePendingArchiveRender() {
if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) return;
if (!uiCache.archivePendingOffscreen) return;
if (!lineageArchiveNearViewport()) return;
uiCache.archivePendingOffscreen = false;
scheduleArchiveWindowRender();
}
window.validateFamilyTree = validateFamilyTree;
window.familyTreeDiagnostics = () => ({ ...(uiCache.archiveDiagnostics || {}) });
window.resetArchiveRenderState = resetArchiveRenderState;