PageSourceSearch

https://followamir.com/genealogy/layout.d764d4a6d07f95a5.js

js followamir.com collected 2026-10-02 01:29:35 UTC 53,428 bytes, 1,377 lines download raw bytes

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.