* For each long thread, identify intermediate tables to "promote" as extra * leaves so the thread renders as multiple stacked segments. * * Item-count strategy (no pixel estimates): * * 1. Count "items" per thread: each trigger contributes 1 + #effective * interaction entries + #charts
(
leafTables: DictTable[],
allTables: DictTable[],
chartElements: { tableId: string }[],
fittableColumns: number,
)
| 2503 | * instruction with the promoted table shown as a dimmed ghost. |
| 2504 | */ |
| 2505 | function computeSplitExtraLeaves( |
| 2506 | leafTables: DictTable[], |
| 2507 | allTables: DictTable[], |
| 2508 | chartElements: { tableId: string }[], |
| 2509 | fittableColumns: number, |
| 2510 | ): DictTable[] { |
| 2511 | if (fittableColumns <= 1) return []; |
| 2512 | const tableById = new Map(allTables.map(t => [t.id, t])); |
| 2513 | |
| 2514 | // Per-trigger item count = 1 (table) + interaction entries + charts. |
| 2515 | const itemsForTrigger = (resultTableId: string, interaction: InteractionEntry[] | undefined): number => { |
| 2516 | const charts = chartElements.filter(ce => ce.tableId === resultTableId).length; |
| 2517 | return 1 + effectiveEntryCount(interaction) + charts; |
| 2518 | }; |
| 2519 | |
| 2520 | // Compute per-thread totals up front so we can pick the budget. |
| 2521 | const triggersByLeaf: Trigger[][] = []; |
| 2522 | const threadItems: number[] = []; |
| 2523 | for (const lt of leafTables) { |
| 2524 | const triggers = getTriggers(lt, allTables); |
| 2525 | triggersByLeaf.push(triggers); |
| 2526 | let items = 0; |
| 2527 | for (const tp of triggers) items += itemsForTrigger(tp.resultTableId, tp.interaction); |
| 2528 | // Leaf trigger contributes its own row count + leaf table + leaf charts. |
| 2529 | items += itemsForTrigger(lt.id, lt.derive?.trigger?.interaction); |
| 2530 | threadItems.push(items); |
| 2531 | } |
| 2532 | const totalItems = threadItems.reduce((s, v) => s + v, 0); |
| 2533 | if (totalItems === 0) return []; |
| 2534 | |
| 2535 | const budget = totalItems / fittableColumns; |
| 2536 | |
| 2537 | const extras: DictTable[] = []; |
| 2538 | for (let li = 0; li < leafTables.length; li++) { |
| 2539 | const lt = leafTables[li]; |
| 2540 | if (!lt.derive) continue; |
| 2541 | const triggers = triggersByLeaf[li]; |
| 2542 | if (triggers.length < 3) continue; |
| 2543 | |
| 2544 | const K = Math.max(1, Math.round(threadItems[li] / budget)); |
| 2545 | if (K <= 1) continue; |
| 2546 | |
| 2547 | // Per-trigger items, with the leaf weight folded into the LAST entry |
| 2548 | // (the leaf step renders inside the trailing segment but isn't part |
| 2549 | // of the `triggers` array). |
| 2550 | const triggerItems = triggers.map(tp => itemsForTrigger(tp.resultTableId, tp.interaction)); |
| 2551 | triggerItems[triggerItems.length - 1] += itemsForTrigger(lt.id, lt.derive.trigger?.interaction); |
| 2552 | |
| 2553 | // Stability strategy: prefer greedy cuts (which only shift when |
| 2554 | // earlier content changes) over balanced ones (which redistribute |
| 2555 | // on every new trigger). Only re-balance when the trailing segment |
| 2556 | // grows past 1.5× the previous segment — i.e. greedy has produced |
| 2557 | // a noticeably lopsided layout that warrants a reshuffle. |
| 2558 | let cuts = greedyPartitionCuts(triggerItems, K, budget); |
| 2559 | if (cuts.length === 0) { |
| 2560 | // Greedy couldn't find K segments at this budget — fall back to |
| 2561 | // balanced (which uses binary search and always finds a valid |
| 2562 | // partition when one exists). |
no test coverage detected