1const NODE_WIDTH = 264; 2const NODE_HEIGHT = 512; 3const ROW_HEIGHT = 620; 4const BOARD_PAD_X = 120; 5const BOARD_PAD_Y = 112; 6const COMPONENT_GAP = 180; 7const PERSON_GAP = 72; 8const SIDECAR_GAP_X = 96; 9const SIDECAR_STACK_OFFSET_Y = 28; 10const SIDECAR_CLUSTER_GAP_X = 56; 11 12const STRUCTURAL_STATUSES = new Set(["confirmed", "working"]); 13const SPECULATIVE_PERSON_STATUSES = new Set(["candidate", "unresolved"]); 14const LAYOUT_RELATION_TYPES = new Set(["parent", "spouse"]); 15const HIGHLIGHT_RELATION_TYPES = new Set(["parent", "spouse", "sibling", "possible_link"]); 16 17function isTrustedCandidateStructuralRelation(relation) { 18 return relation?.status === "candidate" 19 && relation?.type === "spouse" 20 && relation?.confidence === "high"; 21} 22 23function isStructuralLayoutRelation(relation) { 24 return LAYOUT_RELATION_TYPES.has(relation?.type) 25 && (STRUCTURAL_STATUSES.has(relation?.status) || isTrustedCandidateStructuralRelation(relation)); 26} 27 28function unique(values) { 29 return [...new Set((values || []).filter(Boolean))]; 30} 31 32function average(values) { 33 if (!values.length) { 34 return null; 35 } 36 return values.reduce((sum, value) => sum + value, 0) / values.length; 37} 38 39function preferredUnionCenter(memberCenters, childCenters) { 40 if (childCenters.length && memberCenters.length) { 41 return average([ 42 ...childCenters, 43 ...childCenters, 44 ...memberCenters 45 ]); 46 } 47 return average(childCenters) ?? average(memberCenters) ?? 0; 48} 49 50function getBirthYear(person) { 51 const sortValue = person?.life?.birth?.sortValue; 52 const year = Number.parseInt(String(sortValue || "").slice(0, 4), 10); 53 return Number.isFinite(year) ? year : null; 54} 55 56function personSort(leftPerson, rightPerson) { 57 return (getBirthYear(leftPerson) ?? 9999) - (getBirthYear(rightPerson) ?? 9999) 58 || String(leftPerson?.displayName || "").localeCompare(String(rightPerson?.displayName || ""), "en-GB"); 59} 60 61function familyLabel(person) { 62 return person?.family || "Archive"; 63} 64 65function strongest(order, ...values) { 66 return values 67 .filter((value) => value != null) 68 .reduce((best, value) => (best == null || order.indexOf(value) > order.indexOf(best) ? value : best), null); 69} 70 71function formatGenerationYears(ids, peopleById) { 72 const years = ids 73 .map((id) => getBirthYear(peopleById.get(id))) 74 .filter((year) => Number.isFinite(year)); 75 if (!years.length) { 76 return "Undated"; 77 } 78 const first = Math.min(...years); 79 const last = Math.max(...years); 80 return first === last ? String(first) : `${first}-${last}`; 81} 82 83function ownerMatchesPerson(ownerLabel, person) { 84 const ownerKey = String(ownerLabel || "").toLowerCase(); 85 return String(person?.displayName || "").toLowerCase().startsWith(ownerKey); 86} 87 88function dedupeRelationships(relationships) { 89 const confidenceOrder = ["low", "medium", "high"]; 90 const statusOrder = ["unresolved", "candidate", "working", "confirmed"]; 91 const map = new Map(); 92 for (const relation of relationships || []) { 93 const key = relation.type === "parent" 94 ? `${relation.type}:${relation.from}:${relation.to}` 95 : `${relation.type}:${[relation.from, relation.to].sort().join(":")}`; 96 if (!map.has(key)) { 97 map.set(key, { ...relation, id: key }); 98 continue; 99 } 100 const current = map.get(key); 101 map.set(key, { 102 ...current, 103 ...relation, 104 id: key, 105 role: current.type === "parent" && ["father", "mother"].includes(relation.role) ? relation.role : current.role || relation.role, 106 confidence: strongest(confidenceOrder, current.confidence, relation.confidence), 107 status: strongest(statusOrder, current.status, relation.status), 108 notes: unique([...(current.notes || []), ...(relation.notes || [])]), 109 evidence: unique([...(current.evidence || []), ...(relation.evidence || [])]) 110 }); 111 } 112 return [...map.values()]; 113} 114 115function buildConnectedComponents(people, relationships, filterFn = null) { 116 const adjacency = new Map(people.map((person) => [person.id, new Set()])); 117 for (const relation of relationships || []) { 118 if (filterFn && !filterFn(relation)) { 119 continue; 120 } 121 if (!adjacency.has(relation.from) || !adjacency.has(relation.to)) { 122 continue; 123 } 124 adjacency.get(relation.from).add(relation.to); 125 adjacency.get(relation.to).add(relation.from); 126 } 127
128 const pending = new Set(people.map((person) => person.id)); 129 const components = []; 130 while (pending.size) { 131 const start = pending.values().next().value; 132 pending.delete(start); 133 const stack = [start]; 134 const component = []; 135 while (stack.length) { 136 const current = stack.pop(); 137 component.push(current); 138 for (const next of adjacency.get(current) || []) { 139 if (!pending.has(next)) { 140 continue; 141 } 142 pending.delete(next); 143 stack.push(next); 144 } 145 } 146 components.push(component); 147 } 148 return components; 149} 150 151function buildSummaryById(people, relationships) { 152 const summaryById = new Map( 153 people.map((person) => [person.id, { parents: [], children: [], spouses: [], siblings: [], possible: [] }]) 154 ); 155 156 for (const relation of relationships || []) { 157 if (relation.type === "parent") { 158 summaryById.get(relation.to)?.parents.push(relation.from); 159 summaryById.get(relation.from)?.children.push(relation.to); 160 } else if (relation.type === "spouse") { 161 summaryById.get(relation.from)?.spouses.push(relation.to); 162 summaryById.get(relation.to)?.spouses.push(relation.from); 163 } else if (relation.type === "sibling") { 164 summaryById.get(relation.from)?.siblings.push(relation.to); 165 summaryById.get(relation.to)?.siblings.push(relation.from); 166 } else if (relation.type === "possible_link") { 167 summaryById.get(relation.from)?.possible.push(relation.to); 168 summaryById.get(relation.to)?.possible.push(relation.from); 169 } 170 } 171 172 for (const [personId, summary] of summaryById.entries()) { 173 for (const key of Object.keys(summary)) { 174 summary[key] = unique(summary[key]); 175 } 176 summaryById.set(personId, summary); 177 } 178 179 return summaryById; 180} 181 182function computeComponentGenerations(componentPeople, structuralRelations, componentIds) { 183 const generations = new Map(componentPeople.map((person) => [person.id, null])); 184 const parentRelations = structuralRelations.filter((relation) => relation.type === "parent"); 185 const pairRelations = structuralRelations.filter((relation) => relation.type === "spouse"); 186 const parentsByChild = new Map(componentPeople.map((person) => [person.id, []])); 187 188 for (const relation of parentRelations) { 189 parentsByChild.get(relation.to)?.push(relation.from); 190 } 191 192 const seeds = componentPeople 193 .filter((person) => !(parentsByChild.get(person.id) || []).length) 194 .sort(personSort); 195 if (!seeds.length && componentPeople.length) { 196 seeds.push([...componentPeople].sort(personSort)[0]); 197 } 198 for (const person of seeds) { 199 generations.set(person.id, 0); 200 } 201 202 for (let step = 0; step < componentPeople.length * 3; step += 1) {
203 let changed = false; 204 205 for (const relation of parentRelations) { 206 const parentGeneration = generations.get(relation.from); 207 const childGeneration = generations.get(relation.to); 208 if (parentGeneration != null) { 209 const nextChildGeneration = Math.max(parentGeneration + 1, childGeneration ?? -Infinity); 210 if (childGeneration == null || nextChildGeneration !== childGeneration) { 211 generations.set(relation.to, nextChildGeneration); 212 changed = true; 213 } 214 } 215 if (childGeneration != null && parentGeneration == null) { 216 generations.set(relation.from, Math.max(0, childGeneration - 1)); 217 changed = true; 218 } 219 } 220 221 for (const relation of pairRelations) { 222 const leftGeneration = generations.get(relation.from); 223 const rightGeneration = generations.get(relation.to); 224 if (leftGeneration != null && rightGeneration == null) { 225 generations.set(relation.to, leftGeneration); 226 changed = true; 227 } else if (rightGeneration != null && leftGeneration == null) { 228 generations.set(relation.from, rightGeneration); 229 changed = true; 230 } else if (leftGeneration != null && rightGeneration != null && leftGeneration !== rightGeneration) { 231 const nextGeneration = Math.max(leftGeneration, rightGeneration); 232 if (leftGeneration !== nextGeneration) { 233 generations.set(relation.from, nextGeneration); 234 changed = true; 235 } 236 if (rightGeneration !== nextGeneration) { 237 generations.set(relation.to, nextGeneration); 238 changed = true; 239 } 240 } 241 } 242 243 if (!changed) { 244 break; 245 } 246 } 247 248 const birthYears = componentPeople.map(getBirthYear).filter((year) => Number.isFinite(year)); 249 const baseYear = birthYears.length ? Math.min(...birthYears) : 1900; 250 for (const person of componentPeople) { 251 if (generations.get(person.id) != null) { 252 continue; 253 } 254 const birthYear = getBirthYear(person); 255 generations.set(person.id, birthYear == null ? 0 : Math.max(0, Math.floor((birthYear - baseYear) / 26))); 256 } 257 258 for (const relation of parentRelations) { 259 const parentGeneration = generations.get(relation.from); 260 const childGeneration = generations.get(relation.to); 261 if (childGeneration <= parentGeneration) { 262 generations.set(relation.to, parentGeneration + 1); 263 } 264 } 265 266 const orderedValues = [...new Set(componentIds.map((id) => generations.get(id)).sort((left, right) => left - right))]; 267 const compressed = new Map(orderedValues.map((value, index) => [value, index])); 268 for (const personId of componentIds) { 269 generations.set(personId, compressed.get(generations.get(personId)) ?? 0); 270 } 271 272 return generations; 273} 274 275function buildStructuralSummary(primaryIds, structuralRelations, peopleById) { 276 const primarySet = new Set(primaryIds); 277 const summary = new Map(primaryIds.map((personId) => [personId, { parents: [], children: [], spouses: [], siblings: [] }])); 278 for (const relation of structuralRelations) { 279 if (!primarySet.has(relation.from) || !primarySet.has(relation.to)) { 280 continue; 281 } 282 if (relation.type === "parent") { 283 summary.get(relation.to).parents.push(relation.from); 284 summary.get(relation.from).children.push(relation.to); 285 } else if (relation.type === "spouse") { 286 summary.get(relation.from).spouses.push(relation.to); 287 summary.get(relation.to).spouses.push(relation.from); 288 } else if (relation.type === "sibling") { 289 summary.get(relation.from).siblings.push(relation.to); 290 summary.get(relation.to).siblings.push(relation.from); 291 } 292 } 293 for (const [personId, entry] of summary.entries()) { 294 entry.parents = unique(entry.parents).sort((left, right) => personSort(peopleById.get(left), peopleById.get(right))); 295 entry.children = unique(entry.children).sort((left, right) => personSort(peopleById.get(left), peopleById.get(right))); 296 entry.spouses = unique(entry.spouses).sort((left, right) => personSort(peopleById.get(left), peopleById.get(right))); 297 entry.siblings = unique(entry.siblings).sort((left, right) => personSort(peopleById.get(left), peopleById.get(right))); 298 summary.set(personId, entry); 299 } 300 return summary; 301} 302 303function buildUnionMap(primaryIds, structuralRelations, peopleById, generations) { 304 const primarySet = new Set(primaryIds); 305 const parentRelations = structuralRelations.filter((relation) => relation.type === "parent"); 306 const spouseRelations = structuralRelations.filter((relation) => relation.type === "spouse"); 307 const relationByKey = new Map(parentRelations.map((relation) => [`${relation.from}:${relation.to}`, relation])); 308 const unions = new Map(); 309 const incomingUnionByPerson = new Map();
310 const outgoingUnionIdsByPerson = new Map(primaryIds.map((personId) => [personId, []])); 311 312 const ensureUnion = (members, sourceType = "parents") => { 313 const sortedMembers = unique(members).filter((memberId) => primarySet.has(memberId)) 314 .sort((left, right) => personSort(peopleById.get(left), peopleById.get(right))); 315 if (!sortedMembers.length) { 316 return null; 317 } 318 const key = `union:${sortedMembers.join("|")}`; 319 if (!unions.has(key)) { 320 unions.set(key, { 321 id: key, 322 sourceType, 323 members: sortedMembers, 324 children: [], 325 generation: Math.min(...sortedMembers.map((memberId) => generations.get(memberId) ?? 0)), 326 status: null, 327 confidence: null 328 }); 329 for (const memberId of sortedMembers) { 330 outgoingUnionIdsByPerson.get(memberId)?.push(key); 331 } 332 } else if (sourceType === "parents") { 333 unions.get(key).sourceType = "parents"; 334 } 335 return unions.get(key); 336 }; 337 338 const parentsByChild = new Map(primaryIds.map((personId) => [personId, []])); 339 for (const relation of parentRelations) { 340 if (!primarySet.has(relation.from) || !primarySet.has(relation.to)) { 341 continue; 342 } 343 parentsByChild.get(relation.to).push(relation.from); 344 } 345 346 for (const [childId, parentIds] of parentsByChild.entries()) { 347 const sortedParents = unique(parentIds).sort((left, right) => personSort(peopleById.get(left), peopleById.get(right))); 348 if (!sortedParents.length) { 349 continue; 350 } 351 const union = ensureUnion(sortedParents, "parents"); 352 if (!union.children.includes(childId)) { 353 union.children.push(childId); 354 } 355 incomingUnionByPerson.set(childId, union.id); 356 const parentRelationsForChild = sortedParents 357 .map((parentId) => relationByKey.get(`${parentId}:${childId}`)) 358 .filter(Boolean); 359 union.status = strongest(["unresolved", "candidate", "working", "confirmed"], union.status, ...parentRelationsForChild.map((relation) => relation.status)); 360 union.confidence = strongest(["low", "medium", "high"], union.confidence, ...parentRelationsForChild.map((relation) => relation.confidence)); 361 } 362 363 for (const relation of spouseRelations) { 364 if (!primarySet.has(relation.from) || !primarySet.has(relation.to)) { 365 continue; 366 } 367 const union = ensureUnion([relation.from, relation.to], "spouse"); 368 union.status = strongest(["unresolved", "candidate", "working", "confirmed"], union.status, relation.status); 369 union.confidence = strongest(["low", "medium", "high"], union.confidence, relation.confidence); 370 } 371 372 for (const union of unions.values()) { 373 union.children = unique(union.children) 374 .sort((left, right) => (generations.get(left) ?? 0) - (generations.get(right) ?? 0) || personSort(peopleById.get(left), peopleById.get(right))); 375 } 376 377 for (const [personId, unionIds] of outgoingUnionIdsByPerson.entries()) { 378 outgoingUnionIdsByPerson.set(personId, unique(unionIds).sort()); 379 } 380 381 return { unions, incomingUnionByPerson, outgoingUnionIdsByPerson }; 382} 383 384function initialLayerOrder(primaryIds, generations, peopleById) { 385 const personLayers = new Map(); 386 for (const personId of primaryIds) { 387 const generation = generations.get(personId) ?? 0; 388 if (!personLayers.has(generation)) { 389 personLayers.set(generation, []); 390 } 391 personLayers.get(generation).push(personId); 392 } 393 for (const [generation, ids] of personLayers.entries()) { 394 personLayers.set(generation, ids.sort((left, right) => personSort(peopleById.get(left), peopleById.get(right)))); 395 } 396 return personLayers; 397} 398 399function buildUnionLayers(unions) { 400 const unionLayers = new Map(); 401 for (const union of unions.values()) { 402 if (!unionLayers.has(union.generation)) { 403 unionLayers.set(union.generation, []); 404 } 405 unionLayers.get(union.generation).push(union.id); 406 } 407 for (const [generation, ids] of unionLayers.entries()) { 408 unionLayers.set(generation, ids.sort()); 409 } 410 return unionLayers; 411} 412 413function buildLayerSequence(maxGeneration, unionLayers) { 414 const layers = []; 415 for (let generation = 0; generation <= maxGeneration; generation += 1) { 416 layers.push({ key: `p:${generation}`, type: "person", generation }); 417 if ((unionLayers.get(generation) || []).length) { 418 layers.push({ key: `u:${generation}`, type: "union", generation }); 419 } 420 } 421 return layers; 422} 423 424function nodeOrderIndex(orders, layerKey) { 425 const ids = orders.get(layerKey) || []; 426 return new Map(ids.map((id, index) => [id, index])); 427} 428 429function orderNodes(layerIds, neighborMap, neighborOrderIndex, fallbackOrderIndex, tieBreakerById = new Map()) { 430 return [...layerIds].sort((leftId, rightId) => { 431 const leftNeighbors = (neighborMap.get(leftId) || []).map((neighborId) => neighborOrderIndex.get(neighborId)).filter((index) => index != null); 432 const rightNeighbors = (neighborMap.get(rightId) || []).map((neighborId) => neighborOrderIndex.get(neighborId)).filter((index) => index != null); 433 const leftWeight = leftNeighbors.length ? average(leftNeighbors) : fallbackOrderIndex.get(leftId) ?? 0; 434 const rightWeight = rightNeighbors.length ? average(rightNeighbors) : fallbackOrderIndex.get(rightId) ?? 0; 435 return leftWeight - rightWeight 436 || String(tieBreakerById.get(leftId) || "").localeCompare(String(tieBreakerById.get(rightId) || ""), "en-GB")
437 || (fallbackOrderIndex.get(leftId) ?? 0) - (fallbackOrderIndex.get(rightId) ?? 0); 438 }); 439} 440 441function relaxedPackLayer(ids, desiredCenters) { 442 if (!ids.length) { 443 return new Map(); 444 } 445 const minSpacing = NODE_WIDTH + PERSON_GAP; 446 447 const leftPacked = []; 448 let leftCursor = null; 449 for (const id of ids) { 450 const desired = desiredCenters.get(id) ?? 0; 451 const center = leftCursor == null ? desired : Math.max(desired, leftCursor + minSpacing); 452 leftPacked.push(center); 453 leftCursor = center; 454 } 455 456 const rightPacked = new Array(ids.length); 457 let rightCursor = null; 458 for (let index = ids.length - 1; index >= 0; index -= 1) { 459 const id = ids[index]; 460 const desired = desiredCenters.get(id) ?? 0; 461 const center = rightCursor == null ? desired : Math.min(desired, rightCursor - minSpacing); 462 rightPacked[index] = center; 463 rightCursor = center; 464 } 465 466 const normalized = []; 467 let cursor = null; 468 ids.forEach((id, index) => { 469 const blended = average([leftPacked[index], rightPacked[index]]); 470 const center = cursor == null ? blended : Math.max(blended, cursor + minSpacing); 471 normalized.push({ id, center }); 472 cursor = center; 473 }); 474 475 const desiredAverage = average([...desiredCenters.values()].filter((value) => Number.isFinite(value))) ?? 0; 476 const currentAverage = average(normalized.map((entry) => entry.center)) ?? 0; 477 const delta = desiredAverage - currentAverage; 478 479 const shifted = []; 480 cursor = null; 481 for (const entry of normalized) { 482 const center = cursor == null ? entry.center + delta : Math.max(entry.center + delta, cursor + minSpacing); 483 shifted.push({ id: entry.id, center }); 484 cursor = center; 485 } 486 487 return new Map(shifted.map((entry) => [entry.id, entry.center])); 488} 489 490function computePrimaryComponentLayout(componentIds, componentPeople, peopleById, allComponentRelations) { 491 const structuralPeople = unique( 492 allComponentRelations 493 .filter((relation) => isStructuralLayoutRelation(relation)) 494 .flatMap((relation) => [relation.from, relation.to]) 495 .filter((personId) => componentIds.includes(personId)) 496 ); 497 const stablePeople = componentPeople.filter((person) => !SPECULATIVE_PERSON_STATUSES.has(person.status)); 498 let primaryIds = structuralPeople.length 499 ? structuralPeople 500 : (stablePeople.length ? stablePeople.map((person) => person.id) : componentIds.slice()); 501 let primarySet = new Set(primaryIds); 502 503 const highConfidenceRelations = allComponentRelations.filter( 504 (relation) => isStructuralLayoutRelation(relation) 505 && primarySet.has(relation.from) 506 && primarySet.has(relation.to) 507 ); 508 509 if (!highConfidenceRelations.some((relation) => relation.type === "parent" || relation.type === "spouse")) { 510 primaryIds = componentIds.slice(); 511 primarySet = new Set(primaryIds); 512 } 513 514 let structuralRelations = allComponentRelations.filter( 515 (relation) => isStructuralLayoutRelation(relation) 516 && primarySet.has(relation.from) 517 && primarySet.has(relation.to) 518 ); 519 520 if (!structuralRelations.some((relation) => relation.type === "parent" || relation.type === "spouse")) { 521 structuralRelations = allComponentRelations.filter( 522 (relation) => LAYOUT_RELATION_TYPES.has(relation.type) 523 && primarySet.has(relation.from) 524 && primarySet.has(relation.to) 525 ); 526 } 527 528 const primaryPeople = primaryIds.map((personId) => peopleById.get(personId)); 529 const generations = computeComponentGenerations(primaryPeople, structuralRelations, primaryIds); 530 const maxGeneration = Math.max(...primaryIds.map((personId) => generations.get(personId) ?? 0), 0); 531 const structuralSummary = buildStructuralSummary(primaryIds, structuralRelations, peopleById); 532 const { unions, incomingUnionByPerson, outgoingUnionIdsByPerson } = buildUnionMap(primaryIds, structuralRelations, peopleById, generations); 533 const personTieBreakerById = new Map(primaryIds.map((personId) => { 534 const person = peopleById.get(personId); 535 const summary = structuralSummary.get(personId) || {}; 536 const parentKey = summary.parents?.length ? summary.parents.join("|") : `family:${familyLabel(person)}`; 537 const childKey = summary.children?.length ? summary.children.join("|") : "children:none"; 538 const birthKey = String(getBirthYear(person) ?? 9999).padStart(4, "0"); 539 return [personId, `${parentKey}::${childKey}::${birthKey}::${String(person?.displayName || "")}`]; 540 })); 541 const unionTieBreakerById = new Map([...unions.values()].map((u
541nion) => [ 542 union.id, 543 `${union.sourceType}::${union.members.join("|")}::${union.children.join("|")}` 544 ])); 545 const personLayers = initialLayerOrder(primaryIds, generations, peopleById); 546 const unionLayers = buildUnionLayers(unions); 547 const layers = buildLayerSequence(maxGeneration, unionLayers); 548 const layerOrders = new Map(); 549 550 for (const layer of layers) { 551 layerOrders.set(layer.key, layer.type === "person" ? [...(personLayers.get(layer.generation) || [])] : [...(unionLayers.get(layer.generation) || [])]); 552 } 553 554 const personIncomingUnions = new Map(primaryIds.map((personId) => [personId, incomingUnionByPerson.get(personId) ? [incomingUnionByPerson.get(personId)] : []])); 555 const personOutgoingUnions = new Map(primaryIds.map((personId) => [personId, outgoingUnionIdsByPerson.get(personId) || []])); 556 const unionMembers = new Map([...unions.values()].map((union) => [union.id, union.members])); 557 const unionChildren = new Map([...unions.values()].map((union) => [union.id, union.children])); 558 559 for (let pass = 0; pass < 8; pass += 1) { 560 for (let index = 1; index < layers.length; index += 1) { 561 const layer = layers[index]; 562 const prevLayer = layers[index - 1]; 563 const fallbackIndex = nodeOrderIndex(layerOrders, layer.key); 564 const prevIndex = nodeOrderIndex(layerOrders, prevLayer.key); 565 const tieBreakerById = layer.type === "person" ? personTieBreakerById : unionTieBreakerById; 566 const neighborMap = new Map(); 567 for (const id of layerOrders.get(layer.key) || []) { 568 neighborMap.set(id, layer.type === "person" ? personIncomingUnions.get(id) || [] : unionMembers.get(id) || []); 569 } 570 layerOrders.set(layer.key, orderNodes(layerOrders.get(layer.key) || [], neighborMap, prevIndex, fallbackIndex, tieBreakerById)); 571 } 572 573 for (let index = layers.length - 2; index >= 0; index -= 1) { 574 const layer = layers[index]; 575 const nextLayer = layers[index + 1]; 576 const fallbackIndex = nodeOrderIndex(layerOrders, layer.key); 577 const nextIndex = nodeOrderIndex(layerOrders, nextLayer.key); 578 const tieBreakerById = layer.type === "person" ? personTieBreakerById : unionTieBreakerById; 579 const neighborMap = new Map(); 580 for (const id of layerOrders.get(layer.key) || []) { 581 neighborMap.set(id, layer.type === "person" ? personOutgoingUnions.get(id) || [] : unionChildren.get(id) || []); 582 } 583 layerOrders.set(layer.key, orderNodes(layerOrders.get(layer.key) || [], neighborMap, nextIndex, fallbackIndex, tieBreakerById)); 584 } 585 } 586 587 const layerCenters = new Map(); 588 for (let generation = 0; generation <= maxGeneration; generation += 1) { 589 const ids = layerOrders.get(`p:${generation}`) || []; 590 const centers = new Map(); 591 let cursor = 0; 592 for (const personId of ids) { 593 centers.set(personId, cursor + NODE_WIDTH / 2); 594 cursor += NODE_WIDTH + PERSON_GAP; 595 } 596 layerCenters.set(`p:${generation}`, centers); 597 } 598 599 const unionCenters = new Map(); 600 for (let iteration = 0; iteration < 10; iteration += 1) { 601 for (const union of unions.values()) { 602 const memberCenters = union.members 603 .map((memberId) => layerCenters.get(`p:${generations.get(memberId)}`)?.get(memberId)) 604 .filter((value) => Number.isFinite(value)); 605 const childCenters = union.children 606 .map((childId) => layerCenters.get(`p:${generations.get(childId)}`)?.get(childId)) 607 .filter((value) => Number.isFinite(value)); 608 unionCenters.set(union.id, preferredUnionCenter(memberCenters, childCenters)); 609 } 610 611 for (let generation = 0; generation <= maxGeneration; generation += 1) { 612 const ids = layerOrders.get(`p:${generation}`) || []; 613 const desiredCenters = new Map(); 614 for (const personId of ids) { 615 const targets = []; 616 const incomingUnionId = incomingUnionByPerson.get(personId); 617 if (incomingUnionId && unionCenters.has(incomingUnionId)) { 618 const incomingUnion = unions.get(incomingUnionId); 619 const incomingCenter = unionCenters.get(incomingUnionId); 620 targets.push(incomingCenter, incomingCenter); 621 if (incomingUnion?.members.length === 1) { 622 targets.push(incomingCenter, incomingCenter); 623 } 624 } 625 for (const unionId of outgoingUnionIdsByPerson.get(personId) || []) { 626 if (unionCenters.has(unionId)) { 627 const union = unions.get(unionId); 628 const unionCenter = unionCenters.get(unionId); 629 const weight = union?.members.length === 1 ? 4 : (union?.children?.length ? 3 : 1); 630 for (let repeat = 0; repeat < weight; repeat += 1) { 631 targets.push(unionCenter); 632 } 633 } 634 } 635 desiredCenters.set(personId, average(targets) ?? layerCenters.get(`p:${generation}`)?.get(personId) ?? 0); 636 } 637 layerCenters.set(`p:${generation}`, relaxedPackLayer(ids, desiredCenters)); 638 } 639 } 640 641 for (const union of unions.values()) { 642 const memberCenters = union.members 643 .map((memberId) => layerCenters.get(`p:${generations.get(memberId)}`)?.get(memberId)) 644 .filter((value) => Number.isFinite(value)); 645 const childCenters = union.children 646 .map((childId) => layerCenters.get(`p:${generations.get(childId)}`)?.get(childId)) 647 .filter((value) => Number.isFinite(value)); 648 union.center = preferredUnionCenter(memberCenters, childCenters); 649 } 650 651 const positions = new Map(); 652 let minX = Infinity; 653 let maxX = -Infinity; 654 for (let generation = 0; generation <= maxGeneration; generation += 1) { 655 const centers = layerCenters.get(`p:${generation}`) || new Map();
656 for (const [personId, centerX] of centers.entries()) { 657 const x = Math.round(centerX - NODE_WIDTH / 2); 658 const y = generation * ROW_HEIGHT; 659 positions.set(personId, { x, y, generation }); 660 minX = Math.min(minX, x); 661 maxX = Math.max(maxX, x + NODE_WIDTH); 662 } 663 } 664 665 if (!Number.isFinite(minX)) { 666 minX = 0; 667 maxX = NODE_WIDTH; 668 } 669 670 for (const [personId, position] of positions.entries()) { 671 positions.set(personId, { 672 ...position, 673 x: position.x - minX 674 }); 675 } 676 677 const normalizedUnionAnchors = new Map(); 678 for (const union of unions.values()) { 679 const memberCenters = union.members 680 .map((memberId) => (positions.get(memberId)?.x ?? 0) + NODE_WIDTH / 2); 681 const childCenters = union.children 682 .map((childId) => (positions.get(childId)?.x ?? 0) + NODE_WIDTH / 2) 683 .filter((value) => Number.isFinite(value)); 684 const x = preferredUnionCenter(memberCenters, childCenters) ?? union.center ?? average(memberCenters) ?? 0; 685 const y = union.generation * ROW_HEIGHT + NODE_HEIGHT + 36; 686 normalizedUnionAnchors.set(union.id, { x, y, generation: union.generation }); 687 } 688 689 const componentWidth = Math.max(NODE_WIDTH, maxX - minX); 690 const sidecarIds = componentIds.filter((personId) => !primarySet.has(personId)); 691 692 return { 693 primaryIds, 694 sidecarIds, 695 positions, 696 generations, 697 unions, 698 unionAnchors: normalizedUnionAnchors, 699 componentWidth 700 }; 701} 702 703function relationPriority(relation) { 704 if (!relation) { 705 return 99; 706 } 707 if (relation.type === "parent") { 708 return 0; 709 } 710 if (relation.type === "spouse") { 711 return 1; 712 } 713 if (relation.type === "sibling") { 714 return 2; 715 } 716 return 3; 717} 718 719function deriveSidecarGeneration(sidecarId, anchorId, relation, anchorGeneration, birthYear, baseYear) { 720 if (relation?.type === "parent-group") { 721 return anchorGeneration + 1; 722 } 723 if (relation?.type === "parent") { 724 if (relation.from === anchorId && relation.to === sidecarId) { 725 return anchorGeneration + 1; 726 } 727 if (relation.to === anchorId && relation.from === sidecarId) { 728 return Math.max(0, anchorGeneration - 1); 729 } 730 } 731 if (relation?.type === "spouse" || relation?.type === "sibling") { 732 return anchorGeneration; 733 } 734 if (Number.isFinite(birthYear) && Number.isFinite(baseYear)) { 735 return Math.max(0, Math.floor((birthYear - baseYear) / 26)); 736 } 737 return anchorGeneration; 738} 739 740function placeSidecars(componentIds, sidecarIds, positions, peopleById, componentRelations, componentCenterX, offsetY, unionAnchors, unions) { 741 if (!sidecarIds.length) { 742 return positions; 743 } 744 745 const componentBirthYears = componentIds.map((personId) => getBirthYear(peopleById.get(personId))).filter((year) => Number.isFinite(year)); 746 const baseYear = componentBirthYears.length ? Math.min(...componentBirthYears) : null; 747 const sidecarsByAnchor = new Map(); 748 const sidecarAnchorMeta = new Map(); 749 const sidecarSet = new Set(sidecarIds); 750 const primaryIds = componentIds.filter((personId) => !sidecarSet.has(personId)); 751 752 for (const sidecarId of sidecarIds) { 753 const parentCandidates = componentRelations 754 .filter((relation) => relation.type === "parent" && relation.to === sidecarId && primaryIds.includes(relation.from)) 755 .map((relation) => relation.from) 756 .sort((left, right) => personSort(peopleById.get(left), peopleById.get(right))); 757 758 if (parentCandidates.length) { 759 const parentSet = unique(parentCandidates); 760 const matchingUnion = [...unions.values()].find((union) => union.members.length === parentSet.length && union.members.every((memberId, index) => memberId === parentSet[index])); 761 if (matchingUnion && unionAnchors.has(matchingUnion.id)) { 762 const anchorKey = `union:${matchingUnion.id}`; 763 if (!sidecarsByAnchor.has(anchorKey)) { 764 sidecarsByAnchor.set(anchorKey, []); 765 } 766 sidecarsByAnchor.get(anchorKey).push({ 767 sidecarId, 768 relation: { type: "parent-group" }, 769 anchorId: matchingUnion.id, 770 anchorKind: "union" 771 }); 772 continue; 773 } 774} 775 776 const candidateRelations = componentRelations 777 .filter((relation) => HIGHLIGHT_RELATION_TYPES.has(relation.type) && (relation.from === sidecarId || relation.to === sidecarId)) 778 .map((relation) => ({ 779 relation, 780 anchorId: relation.from === sidecarId ? relation.to : relation.from, 781 anchorKind: "person" 782 })) 783 .filter((entry) => primaryIds.includes(entry.anchorId) && positions.has(entry.anchorId)) 784 .sort((left, right) => 785 relationPriority(left.relation) - relationPriority(right.relation) 786 || (positions.get(left.anchorId)?.x ?? 0) - (positions.get(right.anchorId)?.x ?? 0) 787 ); 788 789 const anchor = candidateRelations[0]; 790 if (!anchor) {
791 const isolatedX = Math.max(...[0, ...primaryIds.map((personId) => positions.get(personId)?.x ?? 0)]) + NODE_WIDTH + SIDECAR_GAP_X; 792 const isolatedGeneration = Math.max(...primaryIds.map((personId) => positions.get(personId)?.generation ?? 0), 0); 793 positions.set(sidecarId, { 794 x: isolatedX, 795 y: offsetY + isolatedGeneration * ROW_HEIGHT, 796 generation: isolatedGeneration 797 }); 798 continue; 799 } 800 801 const anchorKey = `${anchor.anchorKind}:${anchor.anchorId}`; 802 if (!sidecarsByAnchor.has(anchorKey)) { 803 sidecarsByAnchor.set(anchorKey, []); 804 } 805 sidecarsByAnchor.get(anchorKey).push({ 806 sidecarId, 807 relation: anchor.relation, 808 anchorId: anchor.anchorId, 809 anchorKind: anchor.anchorKind 810 }); 811 } 812 813 for (const [anchorKey, entries] of sidecarsByAnchor.entries()) { 814 const firstEntry = entries[0]; 815 const anchorPosition = firstEntry.anchorKind === "union" 816 ? unionAnchors.get(firstEntry.anchorId) 817 : positions.get(firstEntry.anchorId); 818 if (!anchorPosition) { 819 continue; 820 } 821 const direction = anchorPosition.x < componentCenterX ? 1 : -1; 822 const sortedEntries = entries.sort((left, right) => personSort(peopleById.get(left.sidecarId), peopleById.get(right.sidecarId))); 823 const groupedEntries = new Map(); 824 for (const entry of sortedEntries) { 825 const generation = deriveSidecarGeneration( 826 entry.sidecarId, 827 entry.anchorId, 828 entry.relation, 829 anchorPosition.generation, 830 getBirthYear(peopleById.get(entry.sidecarId)), 831 baseYear 832 ); 833 if (!groupedEntries.has(generation)) { 834 groupedEntries.set(generation, []); 835 } 836 groupedEntries.get(generation).push({ ...entry, generation }); 837 } 838 839 for (const [generation, generationEntries] of groupedEntries.entries()) { 840 const shouldCenterCluster = generationEntries.length > 1 841 && generationEntries.every((entry) => entry.relation?.type === "parent" || entry.relation?.type === "parent-group"); 842 843 if (shouldCenterCluster) { 844 const spacing = NODE_WIDTH + SIDECAR_CLUSTER_GAP_X; 845 const startX = anchorPosition.x - ((generationEntries.length - 1) * spacing) / 2; 846 generationEntries.forEach((entry, index) => { 847 const stackOffset = index % 2 === 0 ? -SIDECAR_STACK_OFFSET_Y : SIDECAR_STACK_OFFSET_Y; 848 positions.set(entry.sidecarId, { 849 x: startX + index * spacing, 850 y: offsetY + generation * ROW_HEIGHT + stackOffset, 851 generation 852 }); 853 sidecarAnchorMeta.set(entry.sidecarId, { 854 x: anchorPosition.x, 855 y: anchorPosition.y, 856 generation 857 }); 858 }); 859 continue; 860 } 861 862 generationEntries.forEach((entry, index) => { 863 const baseColumn = entry.relation?.type === "spouse" || entry.relation?.type === "sibling" ? 2 : 1; 864 const column = Math.floor(index / 2) + baseColumn; 865 const stackOffset = index % 2 === 0 ? -SIDECAR_STACK_OFFSET_Y : SIDECAR_STACK_OFFSET_Y; 866 const x = anchorPosition.x + direction * column * (NODE_WIDTH + SIDECAR_GAP_X); 867 const y = offsetY + generation * ROW_HEIGHT + stackOffset; 868 positions.set(entry.sidecarId, { x, y, generation }); 869 sidecarAnchorMeta.set(entry.sidecarId, { 870 x: anchorPosition.x, 871 y: anchorPosition.y, 872 generation 873 }); 874 }); 875 } 876 } 877 878 const placedRects = componentIds 879 .filter((personId) => !sidecarSet.has(personId)) 880 .map((personId) => { 881 const position = positions.get(personId); 882 return position ? { 883 id: personId, 884 left: position.x, 885 right: position.x + NODE_WIDTH, 886 top: position.y, 887 bottom: position.y + NODE_HEIGHT 888 } : null; 889 }) 890 .filter(Boolean); 891 892 const overlapsRect = (leftRect, rightRect) => 893 leftRect.left < rightRect.right 894 && leftRect.right > rightRect.left 895 && leftRect.top < rightRect.bottom 896 && leftRect.bottom > rightRect.top; 897 898 const orderedSidecars = sidecarIds 899 .filter((personId) => positions.has(personId)) 900 .sort((leftId, rightId) => { 901 const leftPosition = positions.get(leftId); 902 const rightPosition = positions.get(rightId); 903 return (leftPosition?.generation ?? 0) - (rightPosition?.generation ?? 0) 904 || (leftPosition?.y ?? 0) - (rightPosition?.y ?? 0) 905 || (leftPosition?.x ?? 0) - (rightPosition?.x ?? 0) 906 || personSort(peopleById.get(leftId), peopleById.get(rightId)); 907 }); 908 909 for (const sidecarId of orderedSidecars) { 910 const position = positions.get(sidecarId); 911 if (!position) { 912 continue; 913 } 914 const basePosition = { ...position }; 915 const anchorMeta = sidecarAnchorMeta.get(sidecarId) || { 916 x: basePosition.x + NODE_WIDTH / 2, 917 y: basePosition.y + NODE_HEIGHT / 2, 918 generation: basePosition.generation 919 }; 920 const preferredDirection = basePosition.x + NODE_WIDTH / 2 < componentCenterX ? -1 : 1; 921 const directions = [preferredDirection, preferredDirection * -1]; 922 let nextPosition = { ...basePosition }; 923 let bestScore = Number.POSITIVE_INFINITY; 924 for (let distance = 0; distance < 10; distance += 1) { 925 const candidatePositions = distance === 0 926 ? [{ ...basePosition }] 927 : directions.map((direction) => ({ 928 ...basePosition, 929 x: basePosition.x + direction * distance * (NODE_WIDTH + SIDECAR_CLUSTER_GAP_
929X) 930 })); 931 candidatePositions.forEach((candidate) => { 932 const rect = { 933 left: candidate.x, 934 right: candidate.x + NODE_WIDTH, 935 top: candidate.y, 936 bottom: candidate.y + NODE_HEIGHT 937 }; 938 if (placedRects.some((occupiedRect) => overlapsRect(rect, occupiedRect))) { 939 return; 940 } 941 const score = Math.abs(candidate.x + NODE_WIDTH / 2 - anchorMeta.x) 942 + Math.abs(candidate.y + NODE_HEIGHT / 2 - anchorMeta.y) * 0.35 943 + distance * 4; 944 if (score < bestScore) { 945 bestScore = score; 946 nextPosition = candidate; 947 } 948 }); 949 if (Number.isFinite(bestScore)) { 950 break; 951 } 952 } 953 positions.set(sidecarId, nextPosition); 954 placedRects.push({ 955 id: sidecarId, 956 left: nextPosition.x, 957 right: nextPosition.x + NODE_WIDTH, 958 top: nextPosition.y, 959 bottom: nextPosition.y + NODE_HEIGHT 960 }); 961 } 962 963 return positions; 964} 965 966function resolveCardCollisions(people, positions, peopleById) { 967 const sortedIds = () => [...people] 968 .sort((left, right) => { 969 const leftPosition = positions.get(left.id) || { x: 0, y: 0 }; 970 const rightPosition = positions.get(right.id) || { x: 0, y: 0 }; 971 return leftPosition.y - rightPosition.y 972 || leftPosition.x - rightPosition.x 973 || personSort(left, right); 974 }) 975 .map((person) => person.id); 976 977 for (let pass = 0; pass < 6; pass += 1) { 978 let moved = false; 979 const ids = sortedIds(); 980 for (let index = 0; index < ids.length; index += 1) { 981 const leftId = ids[index]; 982 const leftPosition = positions.get(leftId); 983 if (!leftPosition) { 984 continue; 985 } 986 987 for (let nextIndex = index + 1; nextIndex < ids.length; nextIndex += 1) { 988 const rightId = ids[nextIndex]; 989 const rightPosition = positions.get(rightId); 990 if (!rightPosition) { 991 continue; 992 } 993 if (rightPosition.y > leftPosition.y + NODE_HEIGHT + SIDECAR_STACK_OFFSET_Y) { 994 break; 995 } 996 997 const overlapsHorizontally = leftPosition.x < rightPosition.x + NODE_WIDTH 998 && leftPosition.x + NODE_WIDTH > rightPosition.x; 999 const overlapsVertically = leftPosition.y < rightPosition.y + NODE_HEIGHT 1000 && leftPosition.y + NODE_HEIGHT > rightPosition.y; 1001 if (!overlapsHorizontally || !overlapsVertically) { 1002 continue; 1003 } 1004 1005 const nextX = leftPosition.x + NODE_WIDTH + PERSON_GAP; 1006 if (nextX > rightPosition.x) { 1007 positions.set(rightId, { ...rightPosition, x: nextX }); 1008 moved = true; 1009 } 1010 } 1011 } 1012 1013 if (!moved) { 1014 break; 1015 } 1016 } 1017} 1018 1019function refreshUnionAnchors(unions, positions, unionAnchors) { 1020 for (const union of unions.values()) { 1021 const memberPositions = union.members 1022 .map((memberId) => positions.get(memberId)) 1023 .filter(Boolean); 1024 const memberCenters = memberPositions 1025 .map((position) => position.x + NODE_WIDTH / 2) 1026 .filter((value) => Number.isFinite(value)); 1027 const childCenters = union.children 1028 .map((childId) => (positions.get(childId)?.x ?? 0) + NODE_WIDTH / 2) 1029 .filter((value) => Number.isFinite(value)); 1030 const currentAnchor = unionAnchors.get(union.id); 1031 if (!memberCenters.length && !childCenters.length) { 1032 continue; 1033 } 1034 const memberBottom = memberPositions.length 1035 ? Math.max(...memberPositions.map((position) => position.y + NODE_HEIGHT)) 1036 : null; 1037 unionAnchors.set(union.id, { 1038 ...currentAnchor, 1039 x: Math.round(preferredUnionCenter(memberCenters, childCenters)), 1040 y: memberBottom == null 1041 ? (currentAnchor?.y ?? (union.generation * ROW_HEIGHT + NODE_HEIGHT + BOARD_PAD_Y + 36)) 1042 : memberBottom + 36 1043 }); 1044 } 1045} 1046 1047function buildDisplayEdges(people, peopleById, relationships, unions) { 1048 const displayEdges = []; 1049 const renderedParentKeys = new Set(); 1050 const spousePairs = new Set( 1051 relationships 1052 .filter((relation) => relation.type === "spouse") 1053 .map((relation) => [relation.from, relation.to].sort().join(":")) 1054 ); 1055 1056 for (const union of unions.values()) { 1057 if (union.members.length === 2 && spousePairs.has(union.members.slice().sort().join(":"))) { 1058 const spouseRelations = relationships.filter( 1059 (relation) => relation.type === "spouse" && [relation.from, relation.to].sort().join(":") === union.members.slice().sort().join(":") 1060 ); 1061 displayEdges.push({ 1062 id: `edge:spouse:${union.id}`, 1063 type: "spouse", 1064 from: union.members[0], 1065 to: union.members[1], 1066 people: [...union.members], 1067 color: peopleById.get(union.members[0])?.familyColor || peopleById.get(union.members[1])?.familyColor || "#0041c8", 1068 status: strongest(["unresolved", "candidate", "working", "confirmed"], union.status, ...spouseRelations.map((relation) => relation.status)), 1069 confidence: strongest(["low", "medium", "high"], union.confidence, ...spouseRelations.map((relation) => relation.confidence)), 1070 unionId: union.id 1071 }); 1072 } 1073
1074 union.children.forEach((childId, childIndex) => { 1075 if (union.members.length === 2) { 1076 displayEdges.push({ 1077 id: `family:${union.id}:${childId}`, 1078 type: "family", 1079 parents: [...union.members], 1080 child: childId, 1081 childIndex, 1082 childCount: union.children.length, 1083 showParentStems: !spousePairs.has(union.members.slice().sort().join(":")), 1084 people: [...union.members, childId], 1085 color: peopleById.get(union.members[0])?.familyColor || peopleById.get(childId)?.familyColor || "#0041c8", 1086 status: union.status, 1087 confidence: union.confidence, 1088 unionId: union.id 1089 }); 1090 union.members.forEach((parentId) => renderedParentKeys.add(`${parentId}:${childId}`)); 1091 } else if (union.members.length === 1) { 1092 displayEdges.push({ 1093 id: `edge:parent:${union.id}:${childId}`, 1094 type: "parent", 1095 from: union.members[0], 1096 to: childId, 1097 people: [union.members[0], childId], 1098 color: peopleById.get(union.members[0])?.familyColor || peopleById.get(childId)?.familyColor || "#0041c8", 1099 status: union.status, 1100 confidence: union.confidence, 1101 unionId: union.id 1102 }); 1103 renderedParentKeys.add(`${union.members[0]}:${childId}`); 1104 } 1105 }); 1106 } 1107 1108 for (const relation of relationships) { 1109 if (relation.type === "parent") { 1110 const key = `${relation.from}:${relation.to}`; 1111 if (renderedParentKeys.has(key)) { 1112 continue; 1113 } 1114 displayEdges.push({ 1115 id: `edge:${relation.id}`, 1116 type: "parent", 1117 from: relation.from, 1118 to: relation.to, 1119 people: [relation.from, relation.to], 1120 color: peopleById.get(relation.from)?.familyColor || peopleById.get(relation.to)?.familyColor || "#0041c8", 1121 status: relation.status, 1122 confidence: relation.confidence 1123 }); 1124 } else if (relation.type === "spouse") { 1125 const pairKey = [relation.from, relation.to].sort().join(":"); 1126 if ([...unions.values()].some((union) => union.members.length === 2 && union.members.slice().sort().join(":") === pairKey)) { 1127 continue; 1128 } 1129 displayEdges.push({ 1130 id: `edge:${relation.id}`, 1131 type: "spouse", 1132 from: relation.from, 1133 to: relation.to, 1134 people: [relation.from, relation.to], 1135 color: peopleById.get(relation.from)?.familyColor || peopleById.get(relation.to)?.familyColor || "#0041c8", 1136 status: relation.status, 1137 confidence: relation.confidence 1138 }); 1139 } else if (relation.type === "possible_link" || relation.type === "sibling") { 1140 displayEdges.push({ 1141 id: `edge:${relation.id}`, 1142 type: "possible_link", 1143 from: relation.from, 1144 to: relation.to, 1145 people: [relation.from, relation.to], 1146 color: peopleById.get(relation.from)?.familyColor || peopleById.get(relation.to)?.familyColor || "#737688", 1147 status: relation.status, 1148 confidence: relation.confidence 1149 }); 1150 } 1151 } 1152 1153 const connectedEdgeIdsByPerson = new Map(people.map((person) => [person.id, []])); 1154 for (const edge of displayEdges) { 1155 for (const personId of edge.people || []) { 1156 if (connectedEdgeIdsByPerson.has(personId)) { 1157 connectedEdgeIdsByPerson.get(personId).push(edge.id); 1158 } 1159 } 1160 } 1161 1162 return { displayEdges, connectedEdgeIdsByPerson }; 1163} 1164 1165function buildFamilyLegend(people) { 1166 return [...people.reduce((map, person) => { 1167 const key = familyLabel(person); 1168 if (!map.has(key)) { 1169 map.set(key, { family: key, color: person.familyColor || "#0041c8", count: 0 }); 1170 } 1171 map.get(key).count += 1; 1172 return map; 1173 }, new Map()).values()] 1174 .sort((left, right) => right.count - left.count || left.family.localeCompare(right.family, "en-GB")); 1175} 1176 1177function buildHighlightPeople(summaryById, people) { 1178 return new Map( 1179 people.map((person) => { 1180 const summary = summaryById.get(person.id); 1181 return [ 1182 person.id, 1183 unique([ 1184 person.id, 1185 ...(summary?.parents || []), 1186 ...(summary?.children || []), 1187 ...(summary?.spouses || []), 1188 ...(summary?.possible || []) 1189 ]) 1190 ]; 1191 }) 1192 ); 1193} 1194 1195function buildEventMap(people, events) {
1196 const map = new Map(people.map((person) => [person.id, []])); 1197 for (const event of events || []) { 1198 for (const participant of event.subjectPeople || event.participants || []) { 1199 if (map.has(participant)) { 1200 map.get(participant).push(event); 1201 } 1202 } 1203 } 1204 return map; 1205} 1206 1207export function buildDeterministicGenealogyGraph(data, options = {}) { 1208 const viewportWidth = options.viewportWidth || 1600; 1209 const viewportHeight = options.viewportHeight || 900; 1210 1211 const rawPeople = (data.people || []).map((person) => ({ ...person, tags: Array.isArray(person.tags) ? person.tags : [] })); 1212 const suppressedIds = new Set( 1213 rawPeople 1214 .filter((person) => { 1215 const match = String(person.displayName || "").match(/^(.+?)'s\s+(father|mother|parent)\b/i); 1216 if (!match) { 1217 return false; 1218 } 1219 const ownerLabel = match[1]; 1220 const role = String(match[2] || "").toLowerCase(); 1221 const targetPerson = rawPeople.find((candidate) => !String(candidate.id || "").startsWith("unresolved-") && ownerMatchesPerson(ownerLabel, candidate)); 1222 return Boolean( 1223 targetPerson && 1224 (data.relationships || []).some((relation) => 1225 relation.type === "parent" 1226 && relation.to === targetPerson.id 1227 && !String(relation.from || "").startsWith("unresolved-") 1228 && (role === "parent" || relation.role === role || relation.role === "parent") 1229 ) 1230 ); 1231 }) 1232 .map((person) => person.id) 1233 ); 1234 1235 const people = rawPeople.filter((person) => !suppressedIds.has(person.id)); 1236 const peopleById = new Map(people.map((person) => [person.id, person])); 1237 const relationships = dedupeRelationships( 1238 (data.relationships || []).filter( 1239 (relation) => 1240 !suppressedIds.has(relation.from) 1241 && !suppressedIds.has(relation.to) 1242 && peopleById.has(relation.from) 1243 && peopleById.has(relation.to) 1244 ) 1245 ); 1246 const summaryById = buildSummaryById(people, relationships); 1247 const eventMap = buildEventMap(people, data.events || []); 1248 1249 const components = buildConnectedComponents( 1250 people, 1251 relationships, 1252 (relation) => relation.type !== "possible_link" 1253 ).sort((left, right) => { 1254 const leftPeople = left.map((id) => peopleById.get(id)).sort(personSort); 1255 const rightPeople = right.map((id) => peopleById.get(id)).sort(personSort); 1256 return personSort(leftPeople[0], rightPeople[0]); 1257 }); 1258 1259 const componentEntries = components.map((personIds) => { 1260 const knownBirthYears = personIds 1261 .map((personId) => getBirthYear(peopleById.get(personId))) 1262 .filter((year) => Number.isFinite(year)); 1263 return { 1264 personIds, 1265 earliestBirth: knownBirthYears.length ? Math.min(...knownBirthYears) : 9999 1266 }; 1267 }).sort((left, right) => left.earliestBirth - right.earliestBirth || left.personIds[0].localeCompare(right.personIds[0], "en-GB")); 1268 1269 const positions = new Map(); 1270 const unions = new Map(); 1271 const unionAnchors = new Map(); 1272 let cursorX = BOARD_PAD_X; 1273 1274 for (const componentEntry of componentEntries) { 1275 const componentIds = componentEntry.personIds; 1276 const componentPeople = componentIds.map((personId) => peopleById.get(personId)); 1277 const componentRelations = relationships.filter((relation) => componentIds.includes(relation.from) && componentIds.includes(relation.to)); 1278 const layout = computePrimaryComponentLayout(componentIds, componentPeople, peopleById, componentRelations); 1279 1280 const translatedPositions = new Map(); 1281 for (const [personId, position] of layout.positions.entries()) { 1282 translatedPositions.set(personId, { 1283 ...position, 1284 x: position.x + cursorX, 1285 y: position.y + BOARD_PAD_Y 1286 }); 1287 } 1288 1289 const translatedUnionAnchors = new Map(); 1290 for (const [unionId, anchor] of layout.unionAnchors.entries()) { 1291 translatedUnionAnchors.set(unionId, { 1292 ...anchor, 1293 x: anchor.x + cursorX, 1294 y: anchor.y + BOARD_PAD_Y 1295 }); 1296 } 1297 1298 const componentCenterX = cursorX + layout.componentWidth / 2; 1299 placeSidecars( 1300 componentIds, 1301 layout.sidecarIds, 1302 translatedPositions, 1303 peopleById, 1304 componentRelations, 1305 componentCenterX, 1306 BOARD_PAD_Y, 1307 translatedUnionAnchors, 1308 layout.unions 1309 ); 1310 1311 for (const [personId, position] of translatedPositions.entries()) { 1312 positions.set(personId, position); 1313 } 1314 for (const [unionId, anchor] of translatedUnionAnchors.entries()) { 1315 unionAnchors.set(unionId, anchor); 1316 } 1317 for (const union of layout.unions.values()) { 1318 unions.set(union.id, union); 1319 } 1320
1321 const componentMaxX = Math.max(...componentIds.map((personId) => (translatedPositions.get(personId)?.x ?? 0) + NODE_WIDTH), cursorX + layout.componentWidth); 1322 cursorX = componentMaxX + COMPONENT_GAP; 1323 } 1324 1325 resolveCardCollisions(people, positions, peopleById); 1326 refreshUnionAnchors(unions, positions, unionAnchors); 1327 1328 const { displayEdges, connectedEdgeIdsByPerson } = buildDisplayEdges(people, peopleById, relationships, unions); 1329 const maxGeneration = Math.max(...people.map((person) => positions.get(person.id)?.generation ?? 0), 0); 1330 const rows = [...new Set(people.map((person) => positions.get(person.id)?.generation ?? 0))] 1331 .sort((left, right) => left - right) 1332 .map((generation) => { 1333 const ids = people 1334 .filter((person) => (positions.get(person.id)?.generation ?? 0) === generation) 1335 .map((person) => person.id) 1336 .sort((leftId, rightId) => (positions.get(leftId)?.x ?? 0) - (positions.get(rightId)?.x ?? 0)); 1337 return { 1338 generation, 1339 ids, 1340 label: formatGenerationYears(ids, peopleById) 1341 }; 1342 }); 1343 1344 const boardWidth = Math.max( 1345 Math.round(viewportWidth * 1.1), 1346 Math.max(...people.map((person) => (positions.get(person.id)?.x ?? 0) + NODE_WIDTH), BOARD_PAD_X) + BOARD_PAD_X 1347 ); 1348 const boardHeight = Math.max( 1349 Math.round(viewportHeight * 0.8), 1350 BOARD_PAD_Y * 2 + (maxGeneration + 1) * ROW_HEIGHT 1351 ); 1352 1353 return { 1354 people, 1355 peopleById, 1356 relationships, 1357 summaryById, 1358 eventMap, 1359 positions, 1360 rows, 1361 boardWidth, 1362 boardHeight, 1363 displayEdges, 1364 connectedEdgeIdsByPerson, 1365 highlightPeopleByPerson: buildHighlightPeople(summaryById, people), 1366 familyLegend: buildFamilyLegend(people), 1367 componentCount: componentEntries.length, 1368 unionAnchors 1369 }; 1370} 1371 1372export { 1373 BOARD_PAD_Y, 1374 NODE_HEIGHT, 1375 NODE_WIDTH, 1376 ROW_HEIGHT 1377};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.