diff --git a/package.json b/package.json index af49307188..b972eb20e5 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "serve:playwright:e2e": "vite build && vite preview --mode test.e2e", "serve:playwright:integration": "vite build && vite preview --mode test.integration --port 3333", "test": "TZ=UTC vitest", + "test:mem": "TZ=UTC NODE_OPTIONS=--expose-gc vitest run grouped-event-buffer", "test:ui": "TZ=UTC vitest --ui", "test:coverage": "TZ=UTC vitest run --coverage", "test:e2e": "PW_MODE=e2e playwright test tests/e2e", @@ -65,6 +66,8 @@ "stylelint:fix": "stylelint --fix \"src/**/*.{css,postcss,svelte}\"", "generate:locales": "esno scripts/generate-locales.ts", "run-workflows": "esno scripts/run-workflows.ts", + "perf:signals": "esno scripts/perf-signals.ts", + "perf:events": "esno scripts/perf-events.ts", "audit:tailwind": "esno scripts/audit-tailwind-colors", "audit:holocene-props": "esno scripts/generate-holocene-props.ts", "validate:versions": "./scripts/validate-versions.sh", @@ -104,6 +107,7 @@ "kebab-case": "^1.0.2", "mdast-util-from-markdown": "^2.0.1", "mdast-util-to-hast": "^13.2.0", + "pixi.js": "^8.19.0", "remark-stringify": "^10.0.3", "sveltekit-superforms": "^2.27.4", "tailwind-merge": "^1.14.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fefd1a8d83..50dcc2b51d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: mdast-util-to-hast: specifier: ^13.2.0 version: 13.2.1 + pixi.js: + specifier: ^8.19.0 + version: 8.19.0 remark-stringify: specifier: ^10.0.3 version: 10.0.3 @@ -1608,6 +1611,9 @@ packages: '@package-json/types@0.0.12': resolution: {integrity: sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==} + '@pixi/colord@2.9.6': + resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==} + '@playwright/test@1.60.0': resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} engines: {node: '>=18'} @@ -2240,6 +2246,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/earcut@3.0.0': + resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -2782,6 +2791,13 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@webgpu/types@0.1.70': + resolution: {integrity: sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA==} + + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -3758,6 +3774,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -4349,6 +4368,9 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + gifuct-js@2.1.2: + resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==} + github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -4744,6 +4766,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + ismobilejs@1.1.1: + resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -4957,6 +4982,9 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-binary-schema-parser@2.0.3: + resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -5796,6 +5824,9 @@ packages: resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} engines: {node: '>=0.10.0'} + parse-svg-path@0.2.0: + resolution: {integrity: sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -5865,6 +5896,9 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pixi.js@8.19.0: + resolution: {integrity: sha512-pq1O6emA/GFjjeF+8d3Pb5t7knD8FsnfWGqQcRjYjsqFZ7QdzG1XgjLDUu0DFJRbafjV5+g8iNLFBx0b9649lg==} + pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} @@ -7028,6 +7062,10 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tiny-lru@11.4.7: + resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==} + engines: {node: '>=12'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -9062,6 +9100,8 @@ snapshots: '@package-json/types@0.0.12': {} + '@pixi/colord@2.9.6': {} + '@playwright/test@1.60.0': dependencies: playwright: 1.60.0 @@ -9808,6 +9848,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/earcut@3.0.0': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -10453,6 +10495,10 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@webgpu/types@0.1.70': {} + + '@xmldom/xmldom@0.8.13': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -11423,6 +11469,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + earcut@3.0.2: {} + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -12115,6 +12163,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + gifuct-js@2.1.2: + dependencies: + js-binary-schema-parser: 2.0.3 + github-slugger@2.0.0: {} glob-parent@5.1.2: @@ -12522,6 +12574,8 @@ snapshots: isexe@2.0.0: {} + ismobilejs@1.1.1: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-hook@3.0.0: @@ -12969,6 +13023,8 @@ snapshots: jose@6.2.3: {} + js-binary-schema-parser@2.0.3: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -14103,6 +14159,8 @@ snapshots: parse-passwd@1.0.0: {} + parse-svg-path@0.2.0: {} + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -14149,6 +14207,19 @@ snapshots: pirates@4.0.7: {} + pixi.js@8.19.0: + dependencies: + '@pixi/colord': 2.9.6 + '@types/earcut': 3.0.0 + '@webgpu/types': 0.1.70 + '@xmldom/xmldom': 0.8.13 + earcut: 3.0.2 + eventemitter3: 5.0.1 + gifuct-js: 2.1.2 + ismobilejs: 1.1.1 + parse-svg-path: 0.2.0 + tiny-lru: 11.4.7 + pkg-dir@4.2.0: dependencies: find-up: 4.1.0 @@ -15378,6 +15449,8 @@ snapshots: tiny-invariant@1.3.3: {} + tiny-lru@11.4.7: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} diff --git a/scripts/perf-events.ts b/scripts/perf-events.ts new file mode 100644 index 0000000000..958b798ee1 --- /dev/null +++ b/scripts/perf-events.ts @@ -0,0 +1,113 @@ +/** + * Performance test: run HighVolumeEventWorkflow which generates a mixed + * history of activities, timers, child workflows, and signals. + * + * Usage: + * pnpm perf:events # 40 000 target events + * pnpm perf:events --target 10000 # custom target + * pnpm perf:events --no-worker # use already-running worker + */ + +import yargs from 'yargs/yargs'; + +import { connect } from '../temporal/client'; +import { runWorker, stopWorker } from '../temporal/worker'; +import { HighVolumeEventWorkflow } from '../temporal/workflows'; + +const argv = await yargs(process.argv.slice(2)) + .option('target', { + type: 'number', + default: 40_000, + describe: 'Target history event count', + }) + .option('worker', { + type: 'boolean', + default: true, + describe: 'Start embedded Temporal worker', + }) + .parse(); + +const TARGET: number = argv.target; +const WORKFLOW_ID = `perf-events-${Date.now()}`; + +async function main() { + console.log('\nπŸš€ Temporal mixed-event perf test'); + console.log(` Target events : ${TARGET.toLocaleString()}`); + console.log( + ' Mix : activities Β· timers Β· child workflows Β· signals', + ); + console.log(` Workflow : ${WORKFLOW_ID}\n`); + + if (argv.worker) { + console.log('⏳ Starting embedded worker...'); + await runWorker(); + } + + const client = await connect(); + + console.log('⏳ Starting HighVolumeEventWorkflow...'); + const handle = await client.workflow.start(HighVolumeEventWorkflow, { + taskQueue: 'e2e-1', + workflowId: WORKFLOW_ID, + args: [TARGET], + }); + + console.log(`βœ… Workflow started: ${handle.workflowId}`); + console.log( + `\nβš™οΈ Generating ${TARGET.toLocaleString()} history events...\n`, + ); + + const startMs = performance.now(); + let lastLogMs = startMs; + let lastCount = 0; + + const poller = setInterval(async () => { + try { + const desc = await client.workflow.describe(WORKFLOW_ID); + const count = desc.historyLength ?? 0; + const now = performance.now(); + const elapsed = ((now - startMs) / 1000).toFixed(1); + const rate = Math.round((count - lastCount) / ((now - lastLogMs) / 1000)); + const pct = Math.min(100, Math.round((count / TARGET) * 100)); + console.log( + ` [${elapsed}s] ${count.toLocaleString()} / ${TARGET.toLocaleString()} events (${pct}%) Β· ${rate.toLocaleString()} ev/s`, + ); + lastLogMs = now; + lastCount = count; + } catch { + // workflow may not be describable yet + } + }, 3000); + + const result = await handle.result(); + clearInterval(poller); + + const totalMs = performance.now() - startMs; + + console.log(`\nβœ… Done in ${(totalMs / 1000).toFixed(1)}s`); + console.log('\nπŸ“Š Workflow result:'); + console.log(` History events : ${result.historyLength.toLocaleString()}`); + console.log(` Activities : ${result.activities.toLocaleString()}`); + console.log(` Timers : ${result.timers.toLocaleString()}`); + console.log(` Child workflows: ${result.children.toLocaleString()}`); + console.log(` Signals recv'd : ${result.signals.toLocaleString()}`); + if (result.durationMs) { + console.log( + ` Workflow span : ${(result.durationMs / 1000).toFixed(1)}s`, + ); + console.log( + ` Event rate : ${Math.round(result.historyLength / (result.durationMs / 1000)).toLocaleString()} ev/s`, + ); + } + console.log(`\n Workflow ID: ${WORKFLOW_ID}`); + console.log(' β†’ Load in UI to test fast-history performance\n'); + + if (argv.worker) { + await stopWorker(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/perf-signals.ts b/scripts/perf-signals.ts new file mode 100644 index 0000000000..cb848d6a2a --- /dev/null +++ b/scripts/perf-signals.ts @@ -0,0 +1,176 @@ +/** + * Performance test: start a HighVolumeSignalWorkflow and hammer it with 10k + * perf-signal signals, measuring throughput and latency. + * + * Usage: + * pnpm perf:signals # 10 000 signals, concurrency 50 + * pnpm perf:signals --count 5000 # custom count + * pnpm perf:signals --concurrency 100 + * pnpm perf:signals --no-worker # skip starting the embedded worker + */ + +import yargs from 'yargs/yargs'; + +import { connect } from '../temporal/client'; +import { runWorker, stopWorker } from '../temporal/worker'; +import { HighVolumeSignalWorkflow } from '../temporal/workflows'; + +const argv = await yargs(process.argv.slice(2)) + .option('count', { + type: 'number', + default: 10_000, + describe: 'Signal count', + }) + .option('concurrency', { + type: 'number', + default: 50, + describe: 'Max in-flight signal requests', + }) + .option('worker', { + type: 'boolean', + default: true, + describe: 'Start embedded Temporal worker', + }) + .parse(); + +const SIGNAL_COUNT: number = argv.count; +const CONCURRENCY: number = argv.concurrency; +const WORKFLOW_ID = `perf-signals-${Date.now()}`; + +async function sendSignals( + handle: Awaited< + ReturnType<(typeof import('../temporal/client'))['connect']> + > extends never + ? never + : Awaited> extends infer C + ? C extends { workflow: { getHandle(id: string): infer H } } + ? H + : never + : never, + count: number, + concurrency: number, +): Promise { + let sent = 0; + let inFlight = 0; + const errors: Error[] = []; + const startMs = performance.now(); + let lastLogMs = startMs; + + await new Promise((resolve, reject) => { + const dispatch = () => { + while (inFlight < concurrency && sent < count) { + const seq = sent; + sent++; + inFlight++; + + handle + .signal('perf-signal', { seq, data: `signal-${seq}` }) + .then(() => { + inFlight--; + + const now = performance.now(); + if (now - lastLogMs >= 2000) { + const elapsed = ((now - startMs) / 1000).toFixed(1); + const throughput = Math.round(sent / ((now - startMs) / 1000)); + console.log( + ` [${elapsed}s] sent ${sent}/${count} (${throughput} sig/s, ${inFlight} in-flight)`, + ); + lastLogMs = now; + } + + if (sent === count && inFlight === 0) { + resolve(); + } else { + dispatch(); + } + }) + .catch((err: Error) => { + errors.push(err); + inFlight--; + if (errors.length > 10) { + reject(new Error(`Too many signal errors: ${errors[0].message}`)); + } else if (sent === count && inFlight === 0) { + resolve(); + } else { + dispatch(); + } + }); + } + + if (sent === count && inFlight === 0) resolve(); + }; + + dispatch(); + }); + + const totalMs = performance.now() - startMs; + if (errors.length) { + console.warn(` ⚠ ${errors.length} signal(s) failed`); + } + + console.log('\nπŸ“Š Signal send stats'); + console.log(` Signals sent : ${sent - errors.length} / ${count}`); + console.log(` Errors : ${errors.length}`); + console.log(` Wall time : ${(totalMs / 1000).toFixed(2)}s`); + console.log( + ` Throughput : ${Math.round(count / (totalMs / 1000))} sig/s`, + ); +} + +async function main() { + console.log('\nπŸš€ Temporal signal perf test'); + console.log(` Signals : ${SIGNAL_COUNT.toLocaleString()}`); + console.log(` Concurrency: ${CONCURRENCY}`); + console.log(` Workflow : ${WORKFLOW_ID}\n`); + + if (argv.worker) { + console.log('⏳ Starting embedded worker...'); + await runWorker(); + } + + const client = await connect(); + + console.log('⏳ Starting HighVolumeSignalWorkflow...'); + const handle = await client.workflow.start(HighVolumeSignalWorkflow, { + taskQueue: 'e2e-1', + workflowId: WORKFLOW_ID, + args: [SIGNAL_COUNT], + }); + + console.log(`βœ… Workflow started: ${handle.workflowId}`); + console.log( + `\nπŸ“€ Sending ${SIGNAL_COUNT.toLocaleString()} signals (concurrency=${CONCURRENCY})...\n`, + ); + + await sendSignals(handle as never, SIGNAL_COUNT, CONCURRENCY); + + console.log('\n⏳ Waiting for workflow to complete...'); + const t0 = performance.now(); + const result = await handle.result(); + const waitMs = performance.now() - t0; + + console.log( + `\nβœ… Workflow completed in ${(waitMs / 1000).toFixed(2)}s after receiving all signals`, + ); + console.log('\nπŸ“Š Workflow result:'); + console.log( + ` Received : ${result.received.toLocaleString()} / ${result.target.toLocaleString()}`, + ); + if (result.durationMs !== null) { + console.log( + ` History span : ${(result.durationMs / 1000).toFixed(2)}s (first β†’ last signal timestamp)`, + ); + console.log( + ` History rate : ${Math.round(result.received / (result.durationMs / 1000))} sig/s`, + ); + } + + if (argv.worker) { + await stopWorker(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/lib/components/event/event-card.svelte b/src/lib/components/event/event-card.svelte index faf16e87be..9b2c81f15f 100644 --- a/src/lib/components/event/event-card.svelte +++ b/src/lib/components/event/event-card.svelte @@ -31,7 +31,8 @@ import EventDetailsLink from './event-details-link.svelte'; - let { event }: { event: WorkflowEvent } = $props(); + let { event, lazy = false }: { event: WorkflowEvent; lazy?: boolean } = + $props(); const { namespace, workflow, run } = $derived(page.params); const displayName = $derived( @@ -191,6 +192,7 @@ }} {value} maxHeight={384} + {lazy} /> {:else} {/if} @@ -216,6 +219,7 @@ content={stackTrace} language="text" maxHeight={384} + {lazy} /> {/if} diff --git a/src/lib/components/event/event-details-full.svelte b/src/lib/components/event/event-details-full.svelte index b638f7ed94..33f3fc1fca 100644 --- a/src/lib/components/event/event-details-full.svelte +++ b/src/lib/components/event/event-details-full.svelte @@ -10,7 +10,8 @@ let { group = undefined, event = undefined, - }: { group?: EventGroup; event?: WorkflowEvent } = $props(); + lazy = false, + }: { group?: EventGroup; event?: WorkflowEvent; lazy?: boolean } = $props(); const pendingEvent = $derived( group?.pendingActivity || group?.pendingNexusOperation, @@ -28,9 +29,9 @@ {/if} {#each group.eventList as groupEvent} - + {/each} {:else if event} - + {/if} diff --git a/src/lib/components/event/event-summary-row.svelte b/src/lib/components/event/event-summary-row.svelte index 79d7bd3044..ab949af549 100644 --- a/src/lib/components/event/event-summary-row.svelte +++ b/src/lib/components/event/event-summary-row.svelte @@ -71,7 +71,7 @@ let primaryLocalAttribute = $state(undefined); const selectedId = $derived( - isEventGroup(event) ? Array.from(event.events.keys()).shift() : event.id, + isEventGroup(event) ? event.eventList[0]?.id : event.id, ); const { workflow, run, namespace } = $derived(page.params); @@ -83,7 +83,9 @@ const attributes = $derived(formatAttributes(event)); const currentEvent = $derived( - isEventGroup(event) ? event.events.get(selectedId) : event, + isEventGroup(event) + ? event.eventList.find((e) => e.id === selectedId) + : event, ); const elapsedTime = $derived( @@ -182,7 +184,7 @@ }; let hasRelatedActivities = (group, hoveredEventId) => { - return group?.eventIds?.has(hoveredEventId); + return group?.eventList?.some((e) => e.id === hoveredEventId); }; onMount(async () => { diff --git a/src/lib/components/event/event-summary-table.svelte b/src/lib/components/event/event-summary-table.svelte index 0f9c02bed5..88da1f44f9 100644 --- a/src/lib/components/event/event-summary-table.svelte +++ b/src/lib/components/event/event-summary-table.svelte @@ -5,7 +5,7 @@ import Paginated from '$lib/holocene/table/paginated-table/paginated.svelte'; import TableHeaderRow from '$lib/holocene/table/table-header-row.svelte'; import { translate } from '$lib/i18n/translate'; - import { isEventGroup } from '$lib/models/event-groups'; + import { buildGroupIndex, isEventGroup } from '$lib/models/event-groups'; import type { EventGroups } from '$lib/models/event-groups/event-groups'; import { isEvent } from '$lib/models/event-history'; import { isCloud } from '$lib/stores/advanced-visibility'; @@ -50,6 +50,7 @@ const showGraph = $derived(!minimized && !compact); const initialItem = $derived($fullEventHistory?.[0]); + const groupIndex = $derived(buildGroupIndex(groups)); const url = $derived(page.url); const perPageParam = $derived(url.searchParams.get(perPageKey) ?? '100'); const currentPageParam = $derived( @@ -143,7 +144,7 @@ bind:hoveredEventId {event} {index} - group={groups.find((g) => isEvent(event) && g.eventIds.has(event.id))} + group={isEvent(event) ? groupIndex.get(event.id) : undefined} {compact} {initialItem} /> diff --git a/src/lib/components/lines-and-dots/constants.ts b/src/lib/components/lines-and-dots/constants.ts index a0eaba51d6..4d22442b9b 100644 --- a/src/lib/components/lines-and-dots/constants.ts +++ b/src/lib/components/lines-and-dots/constants.ts @@ -1,4 +1,4 @@ -import type { IconName } from '$lib/holocene/icon'; +import type { TimelineIconName } from '$lib/components/lines-and-dots/svg/timeline-icon.svelte'; import type { EventGroup, EventGroups, @@ -52,7 +52,7 @@ export const DetailsConfig: GraphConfig = { export const CategoryIcon: Record< EventTypeCategory, - { name: IconName; title: string } + { name: TimelineIconName; title: string } > = { workflow: { name: 'workflow', title: 'Workflow' }, signal: { name: 'signal', title: 'Signal' }, @@ -119,12 +119,17 @@ export const timelineTextPosition = ( export const isMiddleEvent = ( event: WorkflowEvent, - groups: EventGroups, + groups: EventGroups | Map, ): boolean => { - const group = groups.find((g) => g.eventIds.has(event.id)); + const group = + groups instanceof Map + ? groups.get(event.id) + : groups.find((g) => g.eventList.some((e) => e.id === event.id)); if (!group) return false; - const ids = Array.from(group.eventIds); - return ids.indexOf(event.id) === 1 && group.eventList.length === 3; + return ( + group.eventList.findIndex((e) => e.id === event.id) === 1 && + group.eventList.length === 3 + ); }; const pairIsConsecutive = (x: string, y: string): boolean => { @@ -132,12 +137,14 @@ const pairIsConsecutive = (x: string, y: string): boolean => { }; const isConsecutiveGroup = (group: EventGroup): boolean => { - const ids = Array.from(group.eventIds); - if (ids.length === 1) return true; - if (ids.length === 2) return pairIsConsecutive(ids[0], ids[1]); - if (ids.length === 3) { + const { eventList } = group; + if (eventList.length === 1) return true; + if (eventList.length === 2) + return pairIsConsecutive(eventList[0].id, eventList[1].id); + if (eventList.length === 3) { return ( - pairIsConsecutive(ids[0], ids[1]) && pairIsConsecutive(ids[1], ids[2]) + pairIsConsecutive(eventList[0].id, eventList[1].id) && + pairIsConsecutive(eventList[1].id, eventList[2].id) ); } return false; diff --git a/src/lib/components/lines-and-dots/event-type-filter.svelte b/src/lib/components/lines-and-dots/event-type-filter.svelte index 54c570299b..67eea9d5e8 100644 --- a/src/lib/components/lines-and-dots/event-type-filter.svelte +++ b/src/lib/components/lines-and-dots/event-type-filter.svelte @@ -10,7 +10,6 @@ import MenuContainer from '$lib/holocene/menu/menu-container.svelte'; import MenuDivider from '$lib/holocene/menu/menu-divider.svelte'; import MenuItem from '$lib/holocene/menu/menu-item.svelte'; - import Menu from '$lib/holocene/menu/menu.svelte'; import { translate } from '$lib/i18n/translate'; import { allEventTypeOptions, @@ -26,6 +25,8 @@ import { CategoryIcon } from './constants'; + import TimelineMenu from './timeline-menu.svelte'; + interface Props { compact?: boolean; } @@ -124,7 +125,7 @@ {/snippet} - {/each} - + diff --git a/src/lib/components/lines-and-dots/svg/dot.svelte b/src/lib/components/lines-and-dots/svg/dot.svelte index 63bbfb59fc..b3f4b1644a 100644 --- a/src/lib/components/lines-and-dots/svg/dot.svelte +++ b/src/lib/components/lines-and-dots/svg/dot.svelte @@ -1,11 +1,10 @@ {#if icon} - {/if} @@ -64,76 +105,5 @@ cursor: pointer; outline: none; opacity: 1; - stroke: #141414; - fill: #e8efff; - } - - .marker, - .command { - fill: #ebebeb; - } - - .timer { - fill: #fbbf24; - } - - .signal { - fill: #d300d8; - } - - .activity { - fill: #a78bfa; - } - - .pending { - stroke: #a78bfa; - fill: #141414; - } - - .child-workflow { - fill: #b2f8d9; - } - - .update { - fill: #06b6d4; - } - - .workflow { - fill: #059669; - } - - .Started { - fill: #92a4c3; - } - - .Completed { - stroke: #00964e; - fill: #1ff1a5; - } - - .Fired { - stroke: #fed64b; - fill: #f8a208; - } - - .Signaled { - stroke: #ff26ff; - fill: #d300d8; - } - - .Failed, - .Terminated { - stroke: #c71607; - fill: #f55; - } - - .TimedOut { - stroke: #f97316; - fill: #c2570c; - } - - .Canceled { - stroke: #fff4c6; - fill: #fed64b; } diff --git a/src/lib/components/lines-and-dots/svg/group-details-row.svelte b/src/lib/components/lines-and-dots/svg/group-details-row.svelte index f8b30f2068..7380fbfcef 100644 --- a/src/lib/components/lines-and-dots/svg/group-details-row.svelte +++ b/src/lib/components/lines-and-dots/svg/group-details-row.svelte @@ -1,5 +1,5 @@ -
+
@@ -90,7 +104,11 @@
- + +
{#if childWorkflowStartedEvent}
@@ -104,7 +122,6 @@ .runId} viewportHeight={320} class="surface-primary overflow-x-hidden border-t border-subtle" - onLoad={onDecode} /> {/key}
diff --git a/src/lib/components/lines-and-dots/svg/history-graph.svelte b/src/lib/components/lines-and-dots/svg/history-graph.svelte index cffc176542..ba0aae2c8a 100644 --- a/src/lib/components/lines-and-dots/svg/history-graph.svelte +++ b/src/lib/components/lines-and-dots/svg/history-graph.svelte @@ -1,5 +1,8 @@
@@ -118,7 +509,21 @@ width={canvasWidth} class="-mt-4" class:error + data-fg={filteredGroups.length} + data-vg={visibleGroups.length} + data-wg={windowedGroups.length} + data-ws={windowStart} + data-sy={_scrollY} + data-tf={totalForY} + data-rc={renderedCount} > + + - {#each filteredGroups as group, index (group.id)} - {@const y = (index + 2) * height + activeGroupsHeightAboveGroup(index)} - {#if !viewportHeight || (y > scrollY - 2 * height && y < scrollY + viewportHeight * height)} + + + {#each windowedGroups as group, localI (group.id)} + {@const i = windowStart + localI} + {@const y = getY(i)} + {#key group.eventList.length} {/key} - {/if} - {#if !readOnly && $activeGroups.includes(group.id)} + + {/each} + + {#if loading && pendingGroupCount > 0} + {@const rectY = getPendingBlockY({ + descStart, + filteredGroupsLength: filteredGroups.length, + reverseSort, + height, + radius, + })} + {@const rectH = pendingGroupCount * height + radius} + + {/if} + + + {#if !readOnly && activeIdx >= 0} + {@const grp = filteredGroups[activeIdx]} + {#if grp} + {@const panelY = getY(activeIdx) + 1.33 * radius} { + panelHeight = h; + }} /> {/if} - {/each} + {/if}
diff --git a/src/lib/components/lines-and-dots/svg/timeline-icon-defs.svelte b/src/lib/components/lines-and-dots/svg/timeline-icon-defs.svelte new file mode 100644 index 0000000000..12ac1422de --- /dev/null +++ b/src/lib/components/lines-and-dots/svg/timeline-icon-defs.svelte @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/lib/components/lines-and-dots/svg/timeline-icon.svelte b/src/lib/components/lines-and-dots/svg/timeline-icon.svelte new file mode 100644 index 0000000000..15cf78b245 --- /dev/null +++ b/src/lib/components/lines-and-dots/svg/timeline-icon.svelte @@ -0,0 +1,52 @@ + + + diff --git a/src/lib/components/lines-and-dots/svg/timeline-positioning.test.ts b/src/lib/components/lines-and-dots/svg/timeline-positioning.test.ts new file mode 100644 index 0000000000..7d2386a520 --- /dev/null +++ b/src/lib/components/lines-and-dots/svg/timeline-positioning.test.ts @@ -0,0 +1,391 @@ +import { describe, expect, it } from 'vitest'; + +import { + getDescStart, + getPendingBlockY, + getRowY, + getTotalForY, +} from './timeline-positioning'; + +// Real constants from TimelineConfig: height = baseRadius * 4 = 24, radius = baseRadius * 1.5 = 9 +const H = 24; +const R = 9; + +function makeGroups(ids: number[]) { + return ids.map((id) => ({ initialEvent: { id: String(id) } })); +} + +// --------------------------------------------------------------------------- +// getDescStart +// --------------------------------------------------------------------------- + +describe('getDescStart', () => { + it('returns groups.length when descMinId is 0 β€” no descending page yet', () => { + expect(getDescStart(makeGroups([1, 4, 7]), 0, true, 100)).toBe(3); + }); + + it('returns groups.length when loading is false β€” fetch complete', () => { + expect( + getDescStart(makeGroups([1, 4, 39001, 39004]), 39001, false, 100), + ).toBe(4); + }); + + it('returns groups.length when pendingGroupCount is 0 β€” no gap to render', () => { + expect(getDescStart(makeGroups([1, 4, 39001, 39004]), 39001, true, 0)).toBe( + 4, + ); + }); + + it('finds correct split with mixed asc / desc groups', () => { + // ascending cursor events 1–10 (group IDs 1,4,7,10) + // descending cursor events 39001–40000 (group IDs 39001,39004,39007) + const groups = makeGroups([1, 4, 7, 10, 39001, 39004, 39007]); + expect(getDescStart(groups, 39001, true, 100)).toBe(4); + }); + + it('returns 0 when every group is from the descending cursor', () => { + expect( + getDescStart(makeGroups([39001, 39004, 39007]), 39001, true, 100), + ).toBe(0); + }); + + it('handles empty group array', () => { + expect(getDescStart([], 39001, true, 100)).toBe(0); + }); + + it('uses initialEvent.id β€” correct even when group.id points to an earlier event', () => { + // TimerFired group: group.id = startedEventId = 7 (ascending range), + // but initialEvent.id = 7 as well, so no ambiguity here. + // This test confirms the scan correctly identifies the split regardless. + const groups = makeGroups([1, 4, 7, 39001, 39004]); + expect(getDescStart(groups, 39001, true, 100)).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// getTotalForY +// --------------------------------------------------------------------------- + +describe('getTotalForY', () => { + it('extends by pendingGroupCount when both cursors have contributed rows', () => { + // descStart(3) < filteredGroupsLength(6): gap is open + expect(getTotalForY(6, 100, 3)).toBe(106); + }); + + it('equals filteredGroupsLength when only ascending events are loaded', () => { + // descStart === filteredGroupsLength β†’ no extension, keep rows at natural position + expect(getTotalForY(3, 100, 3)).toBe(3); + }); + + it('equals filteredGroupsLength when pendingGroupCount is 0', () => { + expect(getTotalForY(6, 0, 3)).toBe(6); + }); +}); + +// --------------------------------------------------------------------------- +// getRowY β€” ascending sort (reverseSort = false) +// N1=3 ascending groups, N2=3 descending groups, pendingGroupCount=100 +// totalForY = 6 + 100 = 106 +// --------------------------------------------------------------------------- + +describe('getRowY β€” ascending sort', () => { + const cfg = { + descStart: 3, + pendingGroupCount: 100, + totalForY: 106, + reverseSort: false, + height: H, + }; + + it('first ascending group (i=0) appears at y = 2*H (very top)', () => { + expect(getRowY(0, cfg)).toBe(2 * H); + }); + + it('ascending groups increase y linearly', () => { + expect(getRowY(1, cfg)).toBe(3 * H); + expect(getRowY(2, cfg)).toBe(4 * H); + }); + + it('first descending group (i=descStart) is shifted down by pendingGroupCount', () => { + // y = (3 + 2 + 100) * H = 105 * H + expect(getRowY(3, cfg)).toBe(105 * H); + }); + + it('descending groups also increase y linearly', () => { + expect(getRowY(4, cfg)).toBe(106 * H); + expect(getRowY(5, cfg)).toBe(107 * H); + }); +}); + +// --------------------------------------------------------------------------- +// getRowY β€” descending sort (reverseSort = true) +// Same group counts; newest-first so HIGH indices β†’ LOW y (top of SVG) +// --------------------------------------------------------------------------- + +describe('getRowY β€” descending sort', () => { + const cfg = { + descStart: 3, + pendingGroupCount: 100, + totalForY: 106, + reverseSort: true, + height: H, + }; + + it('newest descending group (i = N-1 = 5) appears at y = 2*H (very top)', () => { + // offset = pendingGroupCount = 100; y = (106 + 1 - 5 - 100) * H = 2H + expect(getRowY(5, cfg)).toBe(2 * H); + }); + + it('older descending groups have progressively larger y (further from top)', () => { + expect(getRowY(4, cfg)).toBe(3 * H); + expect(getRowY(3, cfg)).toBe(4 * H); // oldest desc group + }); + + it('newest ascending group (i = descStart-1 = 2) is below the gap', () => { + // offset = 0; y = (106 + 1 - 2 - 0) * H = 105H + expect(getRowY(2, cfg)).toBe(105 * H); + }); + + it('older ascending groups have progressively larger y (further toward bottom)', () => { + expect(getRowY(1, cfg)).toBe(106 * H); + expect(getRowY(0, cfg)).toBe(107 * H); // oldest asc group β€” very bottom + }); +}); + +// --------------------------------------------------------------------------- +// getPendingBlockY +// --------------------------------------------------------------------------- + +describe('getPendingBlockY', () => { + it('ascending: gap starts at row descStart+2 minus radius', () => { + // Last asc row center at y = (descStart+1)*H = 4H. + // Block starts at (descStart+2)*H - R = 5H - R = 111px. + // That is H-R px below last asc row center β€” safely below its circle (radius R). + const y = getPendingBlockY({ + descStart: 3, + filteredGroupsLength: 6, + reverseSort: false, + height: H, + radius: R, + }); + expect(y).toBe(5 * H - R); + }); + + it('descending: gap starts at row N2+2 minus radius (symmetrical)', () => { + // N2 = filteredGroupsLength - descStart = 3. + // Oldest desc row center at y = (N2+1)*H = 4H. + // Block starts at (N2+2)*H - R = 5H - R = 111px. + const y = getPendingBlockY({ + descStart: 3, + filteredGroupsLength: 6, + reverseSort: true, + height: H, + radius: R, + }); + expect(y).toBe(5 * H - R); + }); + + it('ascending with only asc events (descStart = filteredGroupsLength): block is below all rows', () => { + const y = getPendingBlockY({ + descStart: 3, + filteredGroupsLength: 3, + reverseSort: false, + height: H, + radius: R, + }); + // Same formula: (3+2)*H - R = 5H - R. The last row is at 4H so block is below it. + expect(y).toBe(5 * H - R); + expect(y).toBeGreaterThan(4 * H); // last row center + }); +}); + +// --------------------------------------------------------------------------- +// No-overlap invariants β€” ascending sort +// --------------------------------------------------------------------------- + +describe('no overlap: ascending sort', () => { + const N1 = 3, + N2 = 3, + pending = 100; + const fLen = N1 + N2; + const descStart = N1; + const totalForY = getTotalForY(fLen, pending, descStart); + const cfg = { + descStart, + pendingGroupCount: pending, + totalForY, + reverseSort: false, + height: H, + }; + const blockY = getPendingBlockY({ + descStart, + filteredGroupsLength: fLen, + reverseSort: false, + height: H, + radius: R, + }); + const blockH = pending * H + R; + + it('pending block is NOT at the top (y > 2*H)', () => { + expect(blockY).toBeGreaterThan(2 * H); + }); + + it('pending block starts below the last ascending row', () => { + const lastAscY = getRowY(N1 - 1, cfg); + // Block top must be below last asc row bottom edge (center + radius) + expect(blockY).toBeGreaterThanOrEqual(lastAscY + R); + }); + + it('pending block ends at or before the first descending row', () => { + const firstDescY = getRowY(N1, cfg); + // Block bottom must be at or before first desc row top edge (center - radius) + expect(blockY + blockH).toBeLessThanOrEqual(firstDescY + R); + }); + + it('ascending events are strictly above descending events (no direct adjacency)', () => { + const lastAscY = getRowY(N1 - 1, cfg); + const firstDescY = getRowY(N1, cfg); + expect(firstDescY - lastAscY).toBeGreaterThan(H); // gap of at least one row + }); +}); + +// --------------------------------------------------------------------------- +// No-overlap invariants β€” descending sort +// --------------------------------------------------------------------------- + +describe('no overlap: descending sort', () => { + const N1 = 3, + N2 = 3, + pending = 100; + const fLen = N1 + N2; + const descStart = N1; + const totalForY = getTotalForY(fLen, pending, descStart); + const cfg = { + descStart, + pendingGroupCount: pending, + totalForY, + reverseSort: true, + height: H, + }; + const blockY = getPendingBlockY({ + descStart, + filteredGroupsLength: fLen, + reverseSort: true, + height: H, + radius: R, + }); + const blockH = pending * H + R; + + it('pending block is NOT at the very top (y > 2*H)', () => { + expect(blockY).toBeGreaterThan(2 * H); + }); + + it('descending events are at the top (newest desc row y = 2*H)', () => { + expect(getRowY(fLen - 1, cfg)).toBe(2 * H); + }); + + it('pending block starts below the oldest descending row', () => { + const oldestDescY = getRowY(N1, cfg); // first desc-cursor group index + expect(blockY).toBeGreaterThanOrEqual(oldestDescY + R); + }); + + it('pending block ends at or before the newest ascending row', () => { + const newestAscY = getRowY(N1 - 1, cfg); // last asc-cursor group index + expect(blockY + blockH).toBeLessThanOrEqual(newestAscY + R); + }); + + it('ascending events are strictly below descending events', () => { + const oldestDescY = getRowY(N1, cfg); + const newestAscY = getRowY(N1 - 1, cfg); + expect(newestAscY - oldestDescY).toBeGreaterThan(H); + }); + + it('oldest ascending group is at the bottom (highest y)', () => { + const oldestAscY = getRowY(0, cfg); + expect(oldestAscY).toBe((totalForY + 1) * H); + }); +}); + +// --------------------------------------------------------------------------- +// Edge case: only ascending cursor loaded (descStart = filteredGroupsLength) +// This is the state after onFirstPage fires but before onFirstDescPage arrives. +// The pending block must NOT be at the top. +// --------------------------------------------------------------------------- + +describe('edge case: only ascending events loaded', () => { + const fLen = 3; + const descStart = fLen; // no desc events yet β†’ descStart === fLen + const pending = 100; + const totalForY = getTotalForY(fLen, pending, descStart); // equals fLen = 3 + + const cfg = { + descStart, + pendingGroupCount: pending, + totalForY, + reverseSort: false, + height: H, + }; + + it('ascending events appear at the top (y starts at 2*H)', () => { + expect(getRowY(0, cfg)).toBe(2 * H); + expect(getRowY(1, cfg)).toBe(3 * H); + expect(getRowY(2, cfg)).toBe(4 * H); + }); + + it('pending block is below all loaded rows, NOT at the top', () => { + const blockY = getPendingBlockY({ + descStart, + filteredGroupsLength: fLen, + reverseSort: false, + height: H, + radius: R, + }); + expect(blockY).toBeGreaterThan(2 * H); // NOT at top + expect(blockY).toBeGreaterThan(getRowY(fLen - 1, cfg)); // below last row + }); +}); + +// --------------------------------------------------------------------------- +// Edge case: only descending cursor loaded first (descStart = 0) +// --------------------------------------------------------------------------- + +describe('edge case: only descending events loaded (desc page arrived first)', () => { + // When only desc events are loaded, descMinId check is used in getDescStart. + // If descMinId > 0 but filteredGroups contains ONLY desc groups: + // getDescStart returns 0 (all are desc), descStart = 0. + const fLen = 3; + const descStart = 0; + const pending = 100; + const totalForY = getTotalForY(fLen, pending, descStart); // fLen + pending = 103 + + const cfgDesc = { + descStart, + pendingGroupCount: pending, + totalForY, + reverseSort: false, + height: H, + }; + + it('ascending sort: first desc group is shifted down by pendingGroupCount', () => { + // i=0, offset=100: y = (0 + 2 + 100) * H = 102H + expect(getRowY(0, cfgDesc)).toBe(102 * H); + }); + + it('ascending sort: pending block is at the very top (before desc events)', () => { + const blockY = getPendingBlockY({ + descStart: 0, + filteredGroupsLength: fLen, + reverseSort: false, + height: H, + radius: R, + }); + // descStart=0: blockY = (0+2)*H - R = 2H - R = 39px + expect(blockY).toBe(2 * H - R); + }); + + it('descending sort: newest desc group (i = fLen-1) appears at top', () => { + const cfgDescSort = { ...cfgDesc, reverseSort: true }; + // i=2, offset=100: y = (103 + 1 - 2 - 100) * H = 2H + expect(getRowY(fLen - 1, cfgDescSort)).toBe(2 * H); + }); +}); diff --git a/src/lib/components/lines-and-dots/svg/timeline-positioning.ts b/src/lib/components/lines-and-dots/svg/timeline-positioning.ts new file mode 100644 index 0000000000..fb18274e58 --- /dev/null +++ b/src/lib/components/lines-and-dots/svg/timeline-positioning.ts @@ -0,0 +1,129 @@ +/** + * Pure positioning math for the bidirectional-fetch timeline. + * + * During loading the service fetches from both ends concurrently: + * ascending cursor β†’ groups 0 … descStart-1 (oldest events, top in asc / bottom in desc) + * descending cursor β†’ groups descStart … N-1 (newest events, bottom in asc / top in desc) + * pending gap β†’ pendingGroupCount estimated rows between the two cursor ranges + * + * Visual layout (ascending sort): + * row 2 ← first ascending group + * … + * row descStart+1 ← last ascending group + * [PENDING BLOCK β€” pendingGroupCount rows] + * row descStart+2+pendingGroupCount ← first descending group + * … + * + * Visual layout (descending sort, newest-first): + * row 2 ← newest descending group (high index, low y) + * … + * row N2+1 ← oldest descending group + * [PENDING BLOCK β€” pendingGroupCount rows] + * row N2+pendingGroupCount+2 ← newest ascending group + * … (low index, high y = bottom) + */ + +/** Minimal shape needed from EventGroup to locate the cursor split. */ +export type GroupForPositioning = { + initialEvent: { id: string }; +}; + +/** + * Index of the first group that came from the descending cursor. + * All groups at indices 0 … descStart-1 are ascending-cursor groups. + * + * Uses `initialEvent.id` (the actual event ID, always a sequential integer) + * rather than `group.id` (the scheduling-event ID which, for timer-fired / + * external-signal events, points to a *different* earlier event and can break + * sorted-order assumptions that a binary search would rely on). + * + * Returns `groups.length` (i.e. "no split") when: + * - descMinId is 0 (descending page hasn't arrived yet) + * - loading is false (fetch complete, gap is gone) + * - pendingGroupCount is 0 (no gap to render) + */ +export function getDescStart( + groups: GroupForPositioning[], + descMinId: number, + loading: boolean, + pendingGroupCount: number, +): number { + if (!descMinId || !loading || !pendingGroupCount) return groups.length; + for (let i = 0; i < groups.length; i++) { + if (Number(groups[i].initialEvent.id) >= descMinId) return i; + } + return groups.length; +} + +/** + * Total row-span used as the denominator in the descending-sort y formula. + * When both cursors have contributed rows we extend by pendingGroupCount so + * there is space for the loading gap between the two cursor ranges. + * When only one cursor has data (descStart === filteredGroupsLength) we keep + * it equal to filteredGroupsLength so the loaded rows stay at their natural + * position near the top/bottom rather than being pushed off-screen. + */ +export function getTotalForY( + filteredGroupsLength: number, + pendingGroupCount: number, + descStart: number, +): number { + return descStart < filteredGroupsLength && pendingGroupCount > 0 + ? filteredGroupsLength + pendingGroupCount + : filteredGroupsLength; +} + +/** + * SVG y-coordinate (in px) for the group at index `i` in filteredGroups. + * + * Descending-cursor groups (i >= descStart) are shifted by pendingGroupCount + * rows to open up visual space for the loading gap. + */ +export function getRowY( + i: number, + { + descStart, + pendingGroupCount, + totalForY, + reverseSort, + height, + }: { + descStart: number; + pendingGroupCount: number; + totalForY: number; + reverseSort: boolean; + height: number; + }, +): number { + const offset = i >= descStart ? pendingGroupCount : 0; + return reverseSort + ? (totalForY + 1 - i - offset) * height + : (i + 2 + offset) * height; +} + +/** + * SVG y-coordinate for the top edge of the pending-gap rectangle. + * + * Ascending sort: gap sits directly below the N1 ascending-cursor rows. + * Descending sort: gap sits directly below the N2 descending-cursor rows + * that occupy the top of the SVG (newest events first). + */ +export function getPendingBlockY({ + descStart, + filteredGroupsLength, + reverseSort, + height, + radius, +}: { + descStart: number; + filteredGroupsLength: number; + reverseSort: boolean; + height: number; + radius: number; +}): number { + // N2 = number of descending-cursor rows = filteredGroupsLength - descStart + const topSectionRows = reverseSort + ? filteredGroupsLength - descStart // N2 desc rows at top in desc sort + : descStart; // N1 asc rows at top in asc sort + return (topSectionRows + 2) * height - radius; +} diff --git a/src/lib/components/lines-and-dots/svg/workflow-row.svelte b/src/lib/components/lines-and-dots/svg/workflow-row.svelte index 26d2e68be0..26202d5c7a 100644 --- a/src/lib/components/lines-and-dots/svg/workflow-row.svelte +++ b/src/lib/components/lines-and-dots/svg/workflow-row.svelte @@ -1,5 +1,4 @@ + + + +{#snippet menu({ _class, style }: { _class?: string; style?: string })} + +{/snippet} + +{#if usePortal && anchorElement} + + {@render menu({ _class: merge(sharedMenuStyles, maxHeight, className) })} + +{:else} + {@render menu({ + _class: merge(styles, maxHeight, className), + style: + position === 'top-right' || position === 'top-left' + ? `top: -${height + 16}px;` + : undefined, + })} +{/if} diff --git a/src/lib/components/payload/payload-code-block.svelte b/src/lib/components/payload/payload-code-block.svelte index 3a70f49b4f..17122c9cf0 100644 --- a/src/lib/components/payload/payload-code-block.svelte +++ b/src/lib/components/payload/payload-code-block.svelte @@ -38,9 +38,16 @@ maxHeight?: number; testId?: string; filenameData?: PayloadDownloadFilenameData; + lazy?: boolean; } - let { value, maxHeight, testId, filenameData = undefined }: Props = $props(); + let { + value, + maxHeight, + testId, + filenameData = undefined, + lazy = false, + }: Props = $props(); let downloadError: string | undefined = $state(undefined); let downloadLoading: boolean = $state(false); @@ -97,6 +104,7 @@ copySuccessIconTitle={translate('common.copy-success-icon-title')} {testId} language="json" + {lazy} /> {/snippet} {#snippet children(results)} @@ -113,6 +121,7 @@ copySuccessIconTitle={translate('common.copy-success-icon-title')} {testId} language="json" + {lazy} > {#snippet headerActions()} {:else} {/if} {/each} @@ -179,6 +190,7 @@ copySuccessIconTitle={translate('common.copy-success-icon-title')} {testId} language="json" + {lazy} > {#snippet headerActions()} + import { setContext } from 'svelte'; + + import { getEventDisplayName } from './eventUtils'; + import type { PixiRenderArgs } from './types'; + + import ChildWorkflowLane from './components/ChildWorkflowLane.svelte'; + import EventInlinePanel from './components/EventInlinePanel.svelte'; + import EventTooltip from './components/EventTooltip.svelte'; + import TimelineCanvas from './components/TimelineCanvas.svelte'; + import TimelineControls from './components/TimelineControls.svelte'; + import TimelineScrollbarX from './components/TimelineScrollbarX.svelte'; + import { + makeMainCtx, + TIMELINE_CTX, + timelineState, + } from './timeline-ctx.svelte'; + + interface Props { + renderArgs: PixiRenderArgs; + class?: string; + } + + let { renderArgs, class: className = '' }: Props = $props(); + + setContext(TIMELINE_CTX, makeMainCtx()); + + +
+ + +
+ + + {#if timelineState.hovered} + + {/if} + + {#each Object.values(timelineState.selectedEvents).sort((a, b) => a.trackIndex - b.trackIndex) as event (event.eventId)} + + {/each} + + {#each timelineState.openedChildWorkflows as cw (cw.runId)} + + {/each} + +
+ {#each Object.values(timelineState.selectedEvents) as event (event.eventId)} + Selected: {getEventDisplayName(event)}, status {event.status}, duration + {Math.round(event.endMs - event.startMs)}ms. + {/each} +
+
+ + +
diff --git a/src/lib/components/pixi-timeline/adapter.ts b/src/lib/components/pixi-timeline/adapter.ts new file mode 100644 index 0000000000..6da6a76a61 --- /dev/null +++ b/src/lib/components/pixi-timeline/adapter.ts @@ -0,0 +1,56 @@ +import type { EventGroup } from '$lib/models/event-groups/event-groups'; + +import type { EventStatus } from './types'; + +export function toPixiType(group: EventGroup): string { + switch (group.category) { + case 'activity': + case 'local-activity': + return 'GROUP_ACTIVITY'; + case 'child-workflow': + return 'GROUP_CHILD_WORKFLOW'; + case 'timer': + return 'GROUP_TIMER'; + default: + break; + } + if (group.initialEvent.eventType === 'WorkflowTaskScheduled') { + return 'GROUP_WORKFLOW_TASK'; + } + return ( + 'EVENT_TYPE_' + + group.initialEvent.eventType + .replace(/([A-Z])/g, '_$1') + .toUpperCase() + .replace(/^_/, '') + ); +} + +export function toPixiStatus(group: EventGroup): EventStatus { + if (group.isTerminated) return 'failed'; + if (group.isFailureOrTimedOut) return 'failed'; + if (group.isCanceled) return 'canceled'; + if (group.isPending) return 'started'; + const c = group.finalClassification ?? group.classification; + switch (c) { + case 'Completed': + return 'completed'; + case 'Fired': + return 'fired'; + case 'Signaled': + return 'signaled'; + case 'Failed': + case 'TimedOut': + case 'Terminated': + return 'failed'; + case 'Canceled': + case 'CancelRequested': + return 'canceled'; + case 'Started': + case 'Open': + case 'Running': + return 'started'; + default: + return 'scheduled'; + } +} diff --git a/src/lib/components/pixi-timeline/components/ChildWorkflowLane.svelte b/src/lib/components/pixi-timeline/components/ChildWorkflowLane.svelte new file mode 100644 index 0000000000..c1f5a98e2a --- /dev/null +++ b/src/lib/components/pixi-timeline/components/ChildWorkflowLane.svelte @@ -0,0 +1,49 @@ + + +
+
+ Child: + {label} + +
+
+ +
+
diff --git a/src/lib/components/pixi-timeline/components/EventInlinePanel.svelte b/src/lib/components/pixi-timeline/components/EventInlinePanel.svelte new file mode 100644 index 0000000000..d68667edd9 --- /dev/null +++ b/src/lib/components/pixi-timeline/components/EventInlinePanel.svelte @@ -0,0 +1,233 @@ + + +
+ +
+
+ {#if iconSvg} + {@html iconSvg} + {:else} + + {event.eventType.replace(/^(EVENT_TYPE_|GROUP_)/, '')[0]} + + {/if} +
+
+

{displayName}

+

{categoryLabel}

+
+ +
+ + +
+ +
+ + {event.status} + + {#if historyHref} + + #{event.eventId} + + {/if} +
+ + +
+
+

+ Start +

+

{formatRelativeMs(event.startMs)}

+
+
+

+ Duration +

+

{formatDuration(durationMs)}

+
+ {#if event.endMs > event.startMs} +
+

+ End +

+

{formatRelativeMs(event.endMs)}

+
+ {/if} +
+

+ Track +

+

{event.trackIndex}

+
+
+ + + {#if allAttrs.length > 0} +
+ + + + {#if expanded} +
+ {#each allAttrs as [k, v] (k)} +
+

{k}

+

+ {String(v)} +

+
+ {/each} +
+ {/if} + {/if} +
+
diff --git a/src/lib/components/pixi-timeline/components/EventTooltip.svelte b/src/lib/components/pixi-timeline/components/EventTooltip.svelte new file mode 100644 index 0000000000..5796a9b4d5 --- /dev/null +++ b/src/lib/components/pixi-timeline/components/EventTooltip.svelte @@ -0,0 +1,99 @@ + + +
+
+
+ {#if iconSvg} + {@html iconSvg} + {:else} +
+ {/if} +
+ {displayName} +
+ +
+
+ Duration + {formatMs(durationMs)} +
+
+ Status + {event.status} +
+ {#if event.eventId} +
+ Event ID + {event.eventId} +
+ {/if} +
+ + {#if Object.keys(event.attributes).length > 0} +
+ {#each Object.entries(event.attributes).slice(0, 4) as [k, v] (k)} +
+ {k} + {String(v)} +
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/pixi-timeline/components/TimelineCanvas.svelte b/src/lib/components/pixi-timeline/components/TimelineCanvas.svelte new file mode 100644 index 0000000000..8c41173cb0 --- /dev/null +++ b/src/lib/components/pixi-timeline/components/TimelineCanvas.svelte @@ -0,0 +1,94 @@ + + +
+

+ Pan left and right with arrow keys. Navigate tracks with Up and Down arrow + keys. Select the focused event with Enter or Space. Deselect with Escape. + Zoom with Ctrl+scroll or Shift+scroll. +

+ + + {#if !ready} +
+
+
+ {/if} +
diff --git a/src/lib/components/pixi-timeline/components/TimelineControls.svelte b/src/lib/components/pixi-timeline/components/TimelineControls.svelte new file mode 100644 index 0000000000..560e37a269 --- /dev/null +++ b/src/lib/components/pixi-timeline/components/TimelineControls.svelte @@ -0,0 +1,212 @@ + + +
+
+ + +
+ +
+ + + +
+ +
+ {#each SCALE_OPTIONS as opt} + {@const active = timelineState.timeScale === opt} + + {/each} +
+ +
+ + + +
+ + + {formatRange(timelineState.viewport.startMs, timelineState.viewport.endMs)} + + +
+ + {timelineState.visibleEvents.toLocaleString()} + / + {timelineState.totalEvents.toLocaleString()} groups + + + {#if timelineState.frameStats.sampleCount > 0} + {@const { avgMs, p99Ms } = timelineState.frameStats} + {@const hasDips = p99Ms > avgMs * 2 || p99Ms > 16.7} + {@const avgColor = + avgMs > 16.7 + ? 'text-red-400' + : avgMs > 10 + ? 'text-yellow-400/80' + : 'text-green-400/60'} + + {avgMs}ms + {#if hasDips} + p99:{p99Ms}ms + {/if} + + {/if} + + {#if timelineState.rendererInfo} + + {timelineState.rendererInfo} + + {/if} +
+
diff --git a/src/lib/components/pixi-timeline/components/TimelineOverview.svelte b/src/lib/components/pixi-timeline/components/TimelineOverview.svelte new file mode 100644 index 0000000000..182e0ae513 --- /dev/null +++ b/src/lib/components/pixi-timeline/components/TimelineOverview.svelte @@ -0,0 +1,340 @@ + + + +
+ + +
+ +
+
+
+
+ +
+ + {#if isHovered && hoveredEntry} + {@const color = + '#' + + ((EVENT_COLORS[hoveredEntry.pixiType] ?? EVENT_COLORS.default) >>> 0) + .toString(16) + .padStart(6, '0')} +
+
+
+ {hoveredEntry.pixiType + .replace('GROUP_', '') + .replace('EVENT_TYPE_', '')} +
+
+ {durationLabel(hoveredEntry.startMs, hoveredEntry.endMs)} Β· row {hoveredEntry.trackIndex} +
+
+ {/if} +
diff --git a/src/lib/components/pixi-timeline/components/TimelineScrollbar.svelte b/src/lib/components/pixi-timeline/components/TimelineScrollbar.svelte new file mode 100644 index 0000000000..e25d5d6ec2 --- /dev/null +++ b/src/lib/components/pixi-timeline/components/TimelineScrollbar.svelte @@ -0,0 +1,101 @@ + + +{#if visible} + + +
+
+ + +
+{/if} diff --git a/src/lib/components/pixi-timeline/components/TimelineScrollbarX.svelte b/src/lib/components/pixi-timeline/components/TimelineScrollbarX.svelte new file mode 100644 index 0000000000..2ee124468b --- /dev/null +++ b/src/lib/components/pixi-timeline/components/TimelineScrollbarX.svelte @@ -0,0 +1,95 @@ + + + +
+
+
diff --git a/src/lib/components/pixi-timeline/eventColors.ts b/src/lib/components/pixi-timeline/eventColors.ts new file mode 100644 index 0000000000..3beb040fb6 --- /dev/null +++ b/src/lib/components/pixi-timeline/eventColors.ts @@ -0,0 +1,48 @@ +export const EVENT_COLORS: Record = { + EVENT_TYPE_WORKFLOW_EXECUTION_STARTED: 0x10b981, + EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED: 0x34d399, + EVENT_TYPE_WORKFLOW_EXECUTION_FAILED: 0xef4444, + EVENT_TYPE_WORKFLOW_EXECUTION_CANCELED: 0xfbbf24, + EVENT_TYPE_WORKFLOW_EXECUTION_TIMED_OUT: 0xf97316, + EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED: 0x06b6d4, + EVENT_TYPE_WORKFLOW_TASK_SCHEDULED: 0x475569, + EVENT_TYPE_WORKFLOW_TASK_STARTED: 0x64748b, + EVENT_TYPE_WORKFLOW_TASK_COMPLETED: 0x94a3b8, + EVENT_TYPE_WORKFLOW_TASK_FAILED: 0xef4444, + EVENT_TYPE_WORKFLOW_TASK_TIMED_OUT: 0xf97316, + EVENT_TYPE_ACTIVITY_TASK_SCHEDULED: 0x1d4ed8, + EVENT_TYPE_ACTIVITY_TASK_STARTED: 0x3b82f6, + EVENT_TYPE_ACTIVITY_TASK_COMPLETED: 0x60a5fa, + EVENT_TYPE_ACTIVITY_TASK_FAILED: 0xef4444, + EVENT_TYPE_ACTIVITY_TASK_TIMED_OUT: 0xf97316, + EVENT_TYPE_ACTIVITY_TASK_CANCEL_REQUESTED: 0xfbbf24, + EVENT_TYPE_ACTIVITY_TASK_CANCELED: 0xfbbf24, + EVENT_TYPE_TIMER_STARTED: 0xd97706, + EVENT_TYPE_TIMER_FIRED: 0xf59e0b, + EVENT_TYPE_TIMER_CANCELED: 0xfbbf24, + EVENT_TYPE_MARKER_RECORDED: 0xa855f7, + EVENT_TYPE_START_CHILD_WORKFLOW_EXECUTION_INITIATED: 0xea580c, + EVENT_TYPE_CHILD_WORKFLOW_EXECUTION_STARTED: 0xf97316, + EVENT_TYPE_CHILD_WORKFLOW_EXECUTION_COMPLETED: 0xfb923c, + EVENT_TYPE_CHILD_WORKFLOW_EXECUTION_FAILED: 0xef4444, + EVENT_TYPE_CHILD_WORKFLOW_EXECUTION_CANCELED: 0xfbbf24, + GROUP_ACTIVITY: 0x3b82f6, + GROUP_CHILD_WORKFLOW: 0xf97316, + GROUP_TIMER: 0xf59e0b, + GROUP_WORKFLOW_TASK: 0x64748b, + default: 0x6b7280, +}; + +export const STATUS_ALPHA: Record = { + scheduled: 0.5, + started: 0.75, + completed: 1.0, + failed: 1.0, + canceled: 0.55, + fired: 1.0, + signaled: 1.0, +}; + +export function colorToCss(n: number): string { + return '#' + n.toString(16).padStart(6, '0'); +} diff --git a/src/lib/components/pixi-timeline/eventUtils.ts b/src/lib/components/pixi-timeline/eventUtils.ts new file mode 100644 index 0000000000..c65b39435e --- /dev/null +++ b/src/lib/components/pixi-timeline/eventUtils.ts @@ -0,0 +1,80 @@ +export interface EventLike { + eventType: string; + attributes: Record; +} + +export function getEventIcon(event: EventLike): string { + if (event.eventType.startsWith('GROUP_')) { + const kind = event.eventType.slice('GROUP_'.length); + if (kind === 'ACTIVITY') return 'A'; + if (kind === 'CHILD_WORKFLOW') return 'C'; + if (kind === 'TIMER') return 'T'; + if (kind === 'WORKFLOW_TASK') return 'W'; + } + return (event.eventType.replace('EVENT_TYPE_', '')[0] ?? '?').toUpperCase(); +} + +export function getEventDisplayName(event: EventLike): string { + const a = event.attributes; + if (event.eventType === 'GROUP_TIMER') { + const name = ( + a.timerStartedEventAttributes as Record | undefined + )?.timerId; + return name ? `Timer ${name}` : 'Timer'; + } + if (event.eventType === 'GROUP_WORKFLOW_TASK') return 'Workflow Task'; + if (event.eventType.includes('ACTIVITY')) { + const s = (a.activityTaskScheduledEventAttributes ?? + a.activityTaskStartedEventAttributes) as + | Record + | undefined; + const name = (s?.activityType as Record | undefined)?.name; + if (name) return name; + } + if (event.eventType === 'EVENT_TYPE_MARKER_RECORDED') { + const name = ( + a.markerRecordedEventAttributes as Record | undefined + )?.markerName as string | undefined; + if (name) return name; + } + if (event.eventType === 'EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED') { + const name = ( + a.workflowExecutionSignaledEventAttributes as + | Record + | undefined + )?.signalName as string | undefined; + if (name) return name; + } + if (event.eventType.includes('CHILD_WORKFLOW')) { + const cw = (a.childWorkflowExecutionStartedEventAttributes ?? + a.startChildWorkflowExecutionInitiatedEventAttributes ?? + a.childWorkflowExecutionEventAttributes) as + | Record + | undefined; + const name = (cw?.workflowType as Record | undefined)?.name; + if (name) return name; + } + return event.eventType + .replace('EVENT_TYPE_', '') + .split('_') + .map((w) => w[0] + w.slice(1).toLowerCase()) + .join(' '); +} + +export function fitLabel(event: EventLike, maxPx: number): string { + const icon = getEventIcon(event); + const maxChars = Math.floor((maxPx - 8) / 6); + if (maxChars <= 1) return icon; + const name = getEventDisplayName(event); + const full = `${icon} ${name}`; + if (full.length <= maxChars) return full; + const nameChars = Math.max(0, maxChars - 3); + return `${icon} ${name.slice(0, nameChars)}…`; +} + +export function formatDuration(ms: number): string { + if (ms < 1_000) return `${ms.toFixed(0)}ms`; + if (ms < 60_000) return `${(ms / 1_000).toFixed(2)}s`; + if (ms < 3_600_000) return `${(ms / 60_000).toFixed(2)}m`; + return `${(ms / 3_600_000).toFixed(2)}h`; +} diff --git a/src/lib/components/pixi-timeline/renderer/PixiRenderer.ts b/src/lib/components/pixi-timeline/renderer/PixiRenderer.ts new file mode 100644 index 0000000000..f0a7f17a9d --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/PixiRenderer.ts @@ -0,0 +1,1876 @@ +import 'pixi.js/unsafe-eval'; +import type { Texture } from 'pixi.js'; +import { + Application, + BitmapText, + Container, + Graphics, + RendererType, + RenderTexture, + Sprite, + TilingSprite, +} from 'pixi.js'; + +import { + getGroupCount, + getGroupMeta, + getVisibleGroupCount, +} from '$lib/services/grouped-event-buffer'; + +import { EVENT_COLORS, STATUS_ALPHA } from '../eventColors'; +import { + buildIconTextures, + PIXI_TYPE_TO_ICON, + type PixiIconName, +} from './icon-textures'; +import { + timelineState as _timelineState, + type TimelineCtx, + type TimelineState, +} from '../timeline-ctx.svelte'; +import type { EventStatus, PixiRenderArgs, TimelineConfig } from '../types'; +import { + ensureBitmapFonts, + FONT_EVENT, + FONT_RULER, + formatTickLabel, + pickTickInterval, + type TimeScale, +} from './fonts'; +import { gatherGutterTracks } from './gutter-culling'; +import { gutterIconLayout } from './gutter-layout'; +import { packGutterPins } from './pack-gutter-pins'; +import { calcScrollXPan, X_PAN_EASE } from './scroll-x-pan'; +import { + buildTrackIndex, + collectBestPerTrack, + getTrackEventBounds, + type GutterEventRef, + trackHasEvents, + type TrackIndex, +} from './track-index'; +import { + clampScaleY, + clampViewportStartMs, + initialViewport, +} from './viewport-clamp'; + +export const DEFAULT_CONFIG: TimelineConfig = { + trackHeight: 28, + trackGap: 4, + minZoom: 0.00005, + maxZoom: 20, + backgroundColor: 0x0c0c14, +}; + +const RULER_H = 28; +const ICON_SIZE = 14; // icon render target size (px) +const ICON_PAD = 2; // padding on each side of icon +const MIN_BAR_W = ICON_SIZE + ICON_PAD * 2; // bar always wide enough for icon + +/** + * Lower number = higher priority in the gutter. + * Failures/timeouts first, then rare events, then common activities. + */ +const GUTTER_TYPE_PRIORITY: Record = { + // Failures β€” always surface immediately + EVENT_TYPE_WORKFLOW_EXECUTION_FAILED: 0, + EVENT_TYPE_WORKFLOW_EXECUTION_TIMED_OUT: 0, + EVENT_TYPE_WORKFLOW_TASK_FAILED: 0, + EVENT_TYPE_WORKFLOW_TASK_TIMED_OUT: 0, + EVENT_TYPE_ACTIVITY_TASK_FAILED: 0, + EVENT_TYPE_ACTIVITY_TASK_TIMED_OUT: 0, + EVENT_TYPE_CHILD_WORKFLOW_EXECUTION_FAILED: 0, + // Cancellations / signals / updates + EVENT_TYPE_WORKFLOW_EXECUTION_CANCELED: 1, + EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED: 1, + EVENT_TYPE_ACTIVITY_TASK_CANCEL_REQUESTED: 1, + EVENT_TYPE_ACTIVITY_TASK_CANCELED: 1, + EVENT_TYPE_TIMER_CANCELED: 1, + // Child workflows β€” visually distinctive + GROUP_CHILD_WORKFLOW: 2, + EVENT_TYPE_START_CHILD_WORKFLOW_EXECUTION_INITIATED: 2, + EVENT_TYPE_CHILD_WORKFLOW_EXECUTION_STARTED: 2, + EVENT_TYPE_CHILD_WORKFLOW_EXECUTION_COMPLETED: 2, + EVENT_TYPE_CHILD_WORKFLOW_EXECUTION_CANCELED: 2, + // Markers, nexus, updates + EVENT_TYPE_MARKER_RECORDED: 3, + EVENT_TYPE_NEXUS_OPERATION_SCHEDULED: 3, + EVENT_TYPE_NEXUS_OPERATION_STARTED: 3, + EVENT_TYPE_NEXUS_OPERATION_COMPLETED: 3, + EVENT_TYPE_NEXUS_OPERATION_FAILED: 3, + EVENT_TYPE_NEXUS_OPERATION_CANCELED: 3, + EVENT_TYPE_NEXUS_OPERATION_TIMED_OUT: 3, + EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED: 3, + EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED: 3, + EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_REQUESTED: 3, + EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_REJECTED: 3, + // Timers + GROUP_TIMER: 4, + EVENT_TYPE_TIMER_STARTED: 4, + EVENT_TYPE_TIMER_FIRED: 4, + // Regular activities + GROUP_ACTIVITY: 5, + EVENT_TYPE_ACTIVITY_TASK_SCHEDULED: 5, + EVENT_TYPE_ACTIVITY_TASK_STARTED: 5, + EVENT_TYPE_ACTIVITY_TASK_COMPLETED: 5, + // Workflow lifecycle + EVENT_TYPE_WORKFLOW_EXECUTION_STARTED: 6, + EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED: 6, + // Everything else (workflow tasks, etc.) β€” least important +}; +const GUTTER_TYPE_PRIORITY_DEFAULT = 7; + +// --------------------------------------------------------------------------- +// Renderer +// --------------------------------------------------------------------------- + +interface PanState { + active: boolean; + startX: number; + startY: number; + originStartMs: number; + originScrollY: number; +} + +/** A rendered gutter pin bar (hit-testable). */ +interface PinBar { + poolIdx: number; + px: number; + py: number; + pw: number; + ph: number; +} + +export class PixiRenderer { + private app: Application; + // Layer order (bottom to top): gridGfx β†’ scrollContainer β†’ rulerGfx β†’ labelContainer + // Grouping same types avoids batch breaks. + private gridGfx: Graphics; + private scrollContainer: Container; // shifted by -scrollY; holds baseGfx, loadingContainer, eventLabelContainer + private baseGfx: Graphics; + private selectionGfx: Graphics; + private loadingContainer: Container; + private eventLabelContainer: Container; + private rulerGfx: Graphics; + private labelContainer: Container; + private loadingTexture: Texture | null = null; + private loadingBarPool: TilingSprite[] = []; + private labelPool: BitmapText[] = []; + private eventLabelPool: BitmapText[] = []; + private eventLabelIndex = 0; + private iconTextures: Record | null = null; + private iconSpritePool: Sprite[] = []; + private iconSpriteIndex = 0; + private lastTileOffset = -1; + + // Real-data state + private dataOriginMs = NaN; + private currentArgs: PixiRenderArgs = { + poolCount: 0, + totalRows: 0, + ascCount: 0, + descCount: 0, + finalized: false, + sortOrder: 'desc', + }; + + private config: TimelineConfig; + private canvas: HTMLCanvasElement; + private resizeObserver: ResizeObserver; + private rafId: number | null = null; + + private canvasRect: DOMRect = new DOMRect(); + + private pendingPanClientX = 0; + private pendingPanClientY = 0; + private panFlushRafId: number | null = null; + + private pendingWheelDX = 0; + private pendingZoomFactor = 1; + private pendingZoomCX = 0; + private pendingZoomCY = 0; + private hasPendingWheel = false; + + private pendingHoverClientX = 0; + private pendingHoverClientY = 0; + private hasPendingHover = false; + + private lastCursor = 'default'; + + /** + * Scroll-snap: after the user's vertical scroll gesture ends (wheel events + * stop firing), nudge the viewport so the Nth event lands at the target Y + * fraction. We do NOT intercept the wheel event β€” native DOM scroll runs + * at full speed β€” we only adjust the final resting position. + */ + + private panState: PanState = { + active: false, + startX: 0, + startY: 0, + originStartMs: 0, + originScrollY: 0, + }; + + /** Flat list of gutter pin events built when data loads, keyed by track. */ + private trackIdx: TrackIndex = { + offsets: new Int32Array(1), + poolIdxs: new Int32Array(0), + numTracks: 0, + }; + private topPins: PinBar[] = []; + private bottomPins: PinBar[] = []; + /** Left/right edge pins: events on visible tracks that are horizontally off-screen. */ + private leftPins: { poolIdx: number; py: number; ph: number }[] = []; + private rightPins: { poolIdx: number; py: number; ph: number }[] = []; + private pinnedPoolIdxs = new Set(); + /** Gutter Graphics drawn above/below the main scroll container (screen-space). */ + private gutterTopGfx: Graphics; + private gutterBottomGfx: Graphics; + /** Icon sprites for top/bottom gutter pins β€” live in screen space, not scroll space. */ + private gutterIconContainer: Container; + private gutterIconSpritePool: Sprite[] = []; + private gutterIconSpriteIndex = 0; + + private viewportInitialized = false; + + // Full relative time range of all loaded events β€” used to position gutter + // pins stably (independent of the current viewport pan/zoom). + private dataRelMinMs = 0; + private dataRelMaxMs = 100_000; + + private xPanTarget: number | null = null; + + // ── Frame-timing circular buffer (120 slots β‰ˆ 2s at 60fps) ──────────────── + private readonly FRAME_BUF = 120; + private frameBuf = new Float32Array(120); + private frameBufHead = 0; + private frameBufCount = 0; + private frameScratch = new Float32Array(120); + + private dirty = true; + private lastStartMs = NaN; + private lastScrollY = NaN; + private lastZoom = NaN; + private lastScreenW = 0; + private lastScreenH = 0; + private lastTimeScale = ''; + + private readonly state: TimelineState; + private readonly ctx: TimelineCtx | undefined; + + private readonly onVisibilityChange = () => { + if (document.hidden) { + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + } else { + this.dirty = true; + this.startLoop(); + } + }; + + constructor( + canvas: HTMLCanvasElement, + config: TimelineConfig = DEFAULT_CONFIG, + ctx?: TimelineCtx, + ) { + this.canvas = canvas; + this.config = config; + this.ctx = ctx; + this.state = ctx?.state ?? _timelineState; + this.app = new Application(); + this.gridGfx = new Graphics(); + this.scrollContainer = new Container(); + this.baseGfx = new Graphics(); + this.selectionGfx = new Graphics(); + this.loadingContainer = new Container(); + this.eventLabelContainer = new Container(); + this.rulerGfx = new Graphics(); + this.labelContainer = new Container(); + this.gutterTopGfx = new Graphics(); + this.gutterBottomGfx = new Graphics(); + this.gutterIconContainer = new Container(); + this.resizeObserver = new ResizeObserver(() => { + this.app.resize(); + this.canvasRect = this.canvas.getBoundingClientRect(); + this.dirty = true; + }); + } + + async init(): Promise { + const dpr = Math.min(window.devicePixelRatio ?? 1, 2); + + await this.app.init({ + canvas: this.canvas, + background: this.config.backgroundColor, + resizeTo: this.canvas.parentElement ?? this.canvas, + antialias: true, + autoDensity: true, + resolution: dpr, + // Keep unused GPU resources longer than the 60s default β€” the timeline + // recycles icon textures and render textures on every data reload, so + // aggressive GC just causes unnecessary re-uploads. + gcMaxUnusedTime: 120_000, + gcFrequency: 60_000, + // @ts-expect-error accessibilityOptions is not yet typed in pixi.js 8.x + accessibilityOptions: { + enabledByDefault: false, + deactivateOnMouseMove: true, + }, + eventFeatures: { + move: false, + globalMove: false, + click: false, + wheel: false, + }, + }); + + // scrollContainer holds everything that moves with vertical scroll. + // Grouped by type (Graphicsβ†’TilingSpriteβ†’BitmapText) to minimise batch breaks. + this.scrollContainer.addChild( + this.baseGfx, + this.selectionGfx, + this.loadingContainer, + this.eventLabelContainer, + ); + // Layer order: grid β†’ scrolled events β†’ gutter bars β†’ gutter icons β†’ ruler β†’ ruler labels + this.app.stage.addChild( + this.gridGfx, + this.scrollContainer, + this.gutterTopGfx, + this.gutterBottomGfx, + this.gutterIconContainer, + this.rulerGfx, + this.labelContainer, + ); + + this.iconTextures = buildIconTextures(this.app, ICON_SIZE); + + this.app.ticker.stop(); + this.resizeObserver.observe(this.canvas.parentElement ?? this.canvas); + this.app.resize(); + this.canvasRect = this.canvas.getBoundingClientRect(); + this.setupInteraction(); + document.addEventListener('visibilitychange', this.onVisibilityChange); + this.startLoop(true); + + this.state.rendererInfo = + this.app.renderer.type === RendererType.WEBGPU ? 'WebGPU' : 'WebGL2'; + + return this; + } + + /** Called by TimelineCanvas when renderArgs change. Initialises viewport on first call. */ + setSortOrder(_order: 'desc' | 'asc') { + // currentArgs is the reactive pixiArgs proxy β€” sortOrder is already updated + // on the proxy by the time this is called. We just need to mark dirty so + // the next render picks up the new sort order from this.currentArgs.sortOrder. + this.dirty = true; + } + + loadEvents(args: PixiRenderArgs, _opts?: { preserveViewport?: boolean }) { + this.currentArgs = args; + this.dirty = true; + + if (args.poolCount === 0) return; + + // Scan loaded groups for time range (O(N), called ~20x per fetch β€” fast enough). + let minMs = Infinity; + let maxMs = -Infinity; + const count = getGroupCount(); + for (let i = 0; i < count; i++) { + const meta = getGroupMeta(i); + if (!meta || meta.startMs === 0) continue; + if (meta.startMs < minMs) minMs = meta.startMs; + if (meta.endMs > maxMs) maxMs = meta.endMs; + } + if (minMs === Infinity) return; + + // Compensate viewport when origin shifts (happens when ASC events load older than DESC events). + const prevOrigin = isNaN(this.dataOriginMs) ? minMs : this.dataOriginMs; + const originShift = prevOrigin - minMs; // positive when new origin is earlier in time + this.dataOriginMs = minMs; + + const endRelMs = maxMs - minMs; + this.state.dataRange = { startMs: 0, endMs: endRelMs + 400 }; + this.state.totalTracks = args.totalRows; + this.state.totalEvents = count; + + if (!this.viewportInitialized) { + const screenW = this.app.screen.width || 1200; + const { startMs, zoom } = initialViewport( + endRelMs, + screenW, + this.config.minZoom, + this.config.maxZoom, + ); + this.state.viewport.startMs = startMs; + this.state.viewport.zoom = zoom; + this.state.viewport.scrollY = 0; + this.viewportInitialized = true; + } else if (originShift !== 0) { + // Keep the same visual position when the origin shifts. + this.state.viewport.startMs += originShift; + } + + this.rebuildByTrack(); + } + + /** Build the CSR TrackIndex for gutter pin queries. Zero JS-object allocation. */ + private rebuildByTrack() { + const count = getGroupCount(); + const origin = this.dataOriginMs; + if (isNaN(origin)) { + this.trackIdx = { + offsets: new Int32Array(1), + poolIdxs: new Int32Array(0), + numTracks: 0, + }; + return; + } + + let maxTrack = -1; + let minMs = Infinity; + let maxMs = -Infinity; + + const groups: { poolIdx: number; trackIdx: number }[] = []; + + for (let i = 0; i < count; i++) { + const meta = getGroupMeta(i); + if ( + !meta || + meta.pixiType === 'GROUP_WORKFLOW_TASK' || + meta.trackIndex < 0 + ) + continue; + const t = meta.trackIndex; + const relStart = meta.startMs - origin; + const relEnd = Math.max(meta.endMs - origin, relStart + 1); + if (relStart < minMs) minMs = relStart; + if (relEnd > maxMs) maxMs = relEnd; + if (t > maxTrack) maxTrack = t; + groups.push({ poolIdx: i, trackIdx: t }); + } + + this.trackIdx = buildTrackIndex(groups, maxTrack + 1); + + if (isFinite(minMs)) { + this.dataRelMinMs = minMs; + this.dataRelMaxMs = maxMs; + } + } + + /** + * On-demand event record for a poolIdx β€” looks up data from getGroupMeta. + * Used by collectBestPerTrack and drawPackedPins. + */ + private pinEventFor(poolIdx: number) { + const origin = this.dataOriginMs; + const meta = getGroupMeta(poolIdx); + if (!meta) return null; + const relStart = meta.startMs - origin; + const relEnd = Math.max(meta.endMs - origin, relStart + 1); + return { + poolIdx, + startMs: relStart, + endMs: relEnd, + trackIndex: meta.trackIndex, + pixiType: meta.pixiType, + pixiStatus: meta.pixiStatus, + displayName: + meta.group?.displayName ?? + meta.pixiType.replace(/^(EVENT_TYPE_|GROUP_)/, '').replace(/_/g, ' '), + }; + } + + /** Legacy path kept for child workflow canvases β€” no-op in demo mode. */ + loadEventsLegacy(_events: unknown[]) { + this.dirty = true; + } + + private maxScrollY(): number { + const { trackHeight, trackGap } = this.config; + const rowSize = + trackHeight * this.state.viewport.scaleY + + Math.max(1, trackGap * this.state.viewport.scaleY); + const totalTracks = Math.max( + this.currentArgs.totalRows, + getVisibleGroupCount(), + ); + return Math.max( + 0, + totalTracks * rowSize - (this.app.screen.height - RULER_H), + ); + } + + private startLoop(skipFirstFrame = false) { + if (this.rafId !== null) return; + const tick = () => { + this.render(); + this.rafId = requestAnimationFrame(tick); + }; + if (skipFirstFrame) { + this.rafId = requestAnimationFrame(() => { + this.rafId = null; + tick(); + }); + } else { + this.rafId = requestAnimationFrame(tick); + } + } + + private getLabel(index: number): BitmapText { + if (index >= this.labelPool.length) { + const t = new BitmapText({ + text: '', + style: { fontFamily: FONT_RULER, fontSize: 10 }, + }); + t.anchor.set(0, 0.5); + this.labelPool.push(t); + this.labelContainer.addChild(t); + } + return this.labelPool[index]; + } + + private getEventLabel(): BitmapText { + const idx = this.eventLabelIndex++; + if (idx >= this.eventLabelPool.length) { + const t = new BitmapText({ + text: '', + style: { fontFamily: FONT_EVENT, fontSize: 10 }, + }); + t.anchor.set(0, 0.5); + this.eventLabelPool.push(t); + this.eventLabelContainer.addChild(t); + } + return this.eventLabelPool[idx]; + } + + private getIconSprite(): Sprite { + const idx = this.iconSpriteIndex++; + if (idx >= this.iconSpritePool.length) { + const s = new Sprite(); + s.anchor.set(0, 0.5); + this.iconSpritePool.push(s); + this.eventLabelContainer.addChild(s); + } + return this.iconSpritePool[idx]; + } + + private getGutterIconSprite(): Sprite { + const idx = this.gutterIconSpriteIndex++; + if (idx >= this.gutterIconSpritePool.length) { + const s = new Sprite(); + s.anchor.set(0, 0.5); + this.gutterIconSpritePool.push(s); + this.gutterIconContainer.addChild(s); + } + return this.gutterIconSpritePool[idx]; + } + + /** Single-letter icon from pixiType β€” matches the prototype's text strategy. */ + private static iconLetter(pixiType: string): string { + if (pixiType === 'GROUP_ACTIVITY') return 'A'; + if (pixiType === 'GROUP_CHILD_WORKFLOW') return 'C'; + if (pixiType === 'GROUP_TIMER') return 'T'; + if (pixiType === 'GROUP_WORKFLOW_TASK') return 'W'; + if (pixiType === 'GROUP_SIGNAL') return 'S'; + if (pixiType === 'GROUP_MARKER') return 'M'; + return ( + pixiType.replace(/^(EVENT_TYPE_|GROUP_)/, '')[0] ?? '?' + ).toUpperCase(); + } + + /** + * Build a label with icon letter + name that fits within `maxPx` pixels. + * ~6 px per char, 8 px total padding. + */ + private static fitLabel( + icon: string, + displayName: string, + maxPx: number, + ): string { + const maxChars = Math.max(1, Math.floor((maxPx - 8) / 6)); + if (maxChars <= 1) return icon; + const full = `${icon} ${displayName}`; + if (full.length <= maxChars) return full; + const nameChars = Math.max(0, maxChars - icon.length - 3); + return `${icon} ${displayName.slice(0, nameChars)}…`; + } + + /** Fit display name only (used when an SVG icon is already rendered). */ + private static fitName(displayName: string, maxPx: number): string { + const maxChars = Math.max(0, Math.floor((maxPx - 6) / 6)); + if (maxChars === 0) return ''; + if (displayName.length <= maxChars) return displayName; + if (maxChars <= 1) return displayName[0] ?? ''; + return displayName.slice(0, maxChars - 1) + '…'; + } + + /** Bake the diagonal-stripe tile once into a RenderTexture. */ + private ensureLoadingTexture(): Texture { + if (this.loadingTexture) return this.loadingTexture; + const SIZE = 32; + const rt = RenderTexture.create({ width: SIZE, height: SIZE }); + const g = new Graphics(); + g.rect(0, 0, SIZE, SIZE).fill({ color: 0x1e293b }); + // Two diagonal lines per tile (45Β°) + g.moveTo(0, SIZE).lineTo(SIZE, 0).stroke({ color: 0x2d3f55, width: 3 }); + g.moveTo(-SIZE, SIZE).lineTo(0, 0).stroke({ color: 0x2d3f55, width: 3 }); + g.moveTo(SIZE, SIZE) + .lineTo(SIZE * 2, 0) + .stroke({ color: 0x2d3f55, width: 3 }); + this.app.renderer.render({ container: g, target: rt, clear: true }); + g.destroy(); + this.loadingTexture = rt; + return rt; + } + + private getLoadingBar(idx: number): TilingSprite { + if (idx < this.loadingBarPool.length) return this.loadingBarPool[idx]; + const sprite = new TilingSprite({ + texture: this.ensureLoadingTexture(), + width: 100, + height: 28, + }); + sprite.alpha = 0.85; + this.loadingBarPool.push(sprite); + this.loadingContainer.addChild(sprite); + return sprite; + } + + /** + * Assign TilingSprite pool to currently visible loading tracks. + * O(visible_rows) β‰ˆ O(20) β€” safe to call on every frame including scroll-only. + */ + private updateLoadingBars( + loadingStart: number, + loadingEnd: number, + rowSize: number, + effectiveTrackH: number, + containerY: number, + screenH: number, + screenW: number, + tileOffset: number, + ) { + if (loadingStart >= loadingEnd) { + for (const bar of this.loadingBarPool) bar.visible = false; + return; + } + // Compute visible track range using screen β†’ local coord math. + // localY = trackIndex * rowSize; screenY = containerY + localY + // Visible: screenY + effectiveTrackH >= RULER_H && screenY <= screenH + const firstVis = Math.max( + loadingStart, + Math.floor((RULER_H - effectiveTrackH - containerY) / rowSize), + ); + const lastVis = Math.min( + loadingEnd - 1, + Math.floor((screenH - containerY) / rowSize), + ); + + let bi = 0; + for (let track = firstVis; track <= lastVis; track++) { + const bar = this.getLoadingBar(bi++); + bar.x = 0; + bar.y = track * rowSize; + bar.width = screenW; + bar.height = effectiveTrackH; + bar.tilePosition.x = -tileOffset; + bar.visible = true; + } + for (let i = bi; i < this.loadingBarPool.length; i++) { + this.loadingBarPool[i].visible = false; + } + } + + private hitTestEvent( + canvasX: number, + canvasY: number, + ): import('../types').TemporalEvent | null { + const { startMs, scrollY, zoom, scaleY } = this.state.viewport; + const { trackHeight, trackGap } = this.config; + const effectiveTrackH = trackHeight * scaleY; + const rowSize = effectiveTrackH + Math.max(1, trackGap * scaleY); + const screenW = this.app.screen.width; + const PIN_MARGIN = 4; + const HIT_SLOP = 3; + + // ── Left/right edge pin hit-test ──────────────────────────────────────── + if (canvasX <= PIN_MARGIN + MIN_BAR_W + HIT_SLOP) { + for (const pin of this.leftPins) { + if ( + canvasY >= pin.py - HIT_SLOP && + canvasY <= pin.py + pin.ph + HIT_SLOP + ) { + const meta = getGroupMeta(pin.poolIdx); + if (meta) { + const origin = this.dataOriginMs; + const relStart = meta.startMs - origin; + const relEnd = Math.max(meta.endMs - origin, relStart + 1); + return { + eventId: String(meta.headSlotIdx + 1), + eventType: meta.pixiType, + eventTime: meta.startMs, + startMs: relStart, + endMs: relEnd, + status: meta.pixiStatus as import('../types').EventStatus, + trackIndex: meta.trackIndex, + attributes: { + type: meta.pixiType, + durationMs: relEnd - relStart, + }, + poolIdx: pin.poolIdx, + }; + } + } + } + } + if (canvasX >= screenW - PIN_MARGIN - MIN_BAR_W - HIT_SLOP) { + for (const pin of this.rightPins) { + if ( + canvasY >= pin.py - HIT_SLOP && + canvasY <= pin.py + pin.ph + HIT_SLOP + ) { + const meta = getGroupMeta(pin.poolIdx); + if (meta) { + const origin = this.dataOriginMs; + const relStart = meta.startMs - origin; + const relEnd = Math.max(meta.endMs - origin, relStart + 1); + return { + eventId: String(meta.headSlotIdx + 1), + eventType: meta.pixiType, + eventTime: meta.startMs, + startMs: relStart, + endMs: relEnd, + status: meta.pixiStatus as import('../types').EventStatus, + trackIndex: meta.trackIndex, + attributes: { + type: meta.pixiType, + durationMs: relEnd - relStart, + }, + poolIdx: pin.poolIdx, + }; + } + } + } + } + + // ── Gutter pin hit-test (above/below scroll area) ────────────────────── + for (const pin of [...this.topPins, ...this.bottomPins]) { + if ( + canvasX >= pin.px - HIT_SLOP && + canvasX <= pin.px + pin.pw + HIT_SLOP && + canvasY >= pin.py - HIT_SLOP && + canvasY <= pin.py + pin.ph + HIT_SLOP + ) { + const meta = getGroupMeta(pin.poolIdx); + if (meta) { + const origin = this.dataOriginMs; + const relStart = meta.startMs - origin; + const relEnd = Math.max(meta.endMs - origin, relStart + 1); + return { + eventId: String(meta.headSlotIdx + 1), + eventType: meta.pixiType, + eventTime: meta.startMs, + startMs: relStart, + endMs: relEnd, + status: meta.pixiStatus as EventStatus, + trackIndex: meta.trackIndex, + attributes: { type: meta.pixiType, durationMs: relEnd - relStart }, + poolIdx: pin.poolIdx, + }; + } + } + } + + if (canvasY < RULER_H) return null; + + const clickMs = startMs + canvasX / zoom; + const visualTrackIdx = Math.floor((canvasY - RULER_H + scrollY) / rowSize); + const so = (this.currentArgs.sortOrder ?? 'desc') as 'desc' | 'asc'; + const ttCount = this.trackIdx.numTracks; + const trackIndex = + so === 'asc' && ttCount > 0 + ? ttCount - 1 - visualTrackIdx + : visualTrackIdx; + const slop = 2 / zoom; + + const origin = this.dataOriginMs; + // Use trackIdx to jump directly to events on the clicked track row β€” + // O(events on one track) instead of O(all 16k groups). + if ( + !isNaN(origin) && + trackIndex >= 0 && + trackIndex < this.trackIdx.offsets.length - 1 + ) { + for ( + let j = this.trackIdx.offsets[trackIndex]; + j < this.trackIdx.offsets[trackIndex + 1]; + j++ + ) { + const i = this.trackIdx.poolIdxs[j]; + const meta = getGroupMeta(i); + if (!meta) continue; + const relStart = meta.startMs - origin; + const relEnd = Math.max(meta.endMs - origin, relStart + 1); + const renderedEndMs = Math.max(relEnd, relStart + MIN_BAR_W / zoom); + if (clickMs >= relStart - slop && clickMs <= renderedEndMs + slop) { + return { + eventId: String(meta.headSlotIdx + 1), + eventType: meta.pixiType, + eventTime: meta.startMs, + startMs: relStart, + endMs: relEnd, + status: meta.pixiStatus as EventStatus, + trackIndex: meta.trackIndex, + attributes: { + type: meta.pixiType, + durationMs: relEnd - relStart, + }, + poolIdx: i, + }; + } + } + } + return null; + } + + private drawRuler( + startMs: number, + zoom: number, + screenW: number, + screenH: number, + scale: TimeScale, + ) { + const intervalMs = pickTickInterval(zoom, scale); + const firstTick = Math.ceil(startMs / intervalMs) * intervalMs; + const dataStart = this.state.dataRange.startMs; + + this.gridGfx.clear(); + this.rulerGfx.clear(); + + this.rulerGfx + .rect(0, 0, screenW, RULER_H) + .fill({ color: 0x060610, alpha: 1 }); + this.rulerGfx.rect(0, RULER_H - 1, screenW, 1).fill({ color: 0x1e293b }); + + let li = 0; + for ( + let tickMs = firstTick; + tickMs <= startMs + screenW / zoom; + tickMs += intervalMs + ) { + const x = (tickMs - startMs) * zoom; + if (x < 0 || x > screenW) continue; + + this.gridGfx + .moveTo(x, RULER_H) + .lineTo(x, screenH) + .stroke({ color: 0x1e293b, width: 1, alpha: 0.7 }); + + this.rulerGfx + .moveTo(x, RULER_H - 7) + .lineTo(x, RULER_H - 1) + .stroke({ color: 0x334155, width: 1 }); + + const label = this.getLabel(li++); + label.text = formatTickLabel(tickMs - dataStart, intervalMs, scale); + label.x = x + 3; + label.y = RULER_H / 2; + label.visible = true; + } + + for (let i = li; i < this.labelPool.length; i++) { + this.labelPool[i].visible = false; + } + } + + private updateFrameStats(elapsedMs: number) { + this.frameBuf[this.frameBufHead % this.FRAME_BUF] = elapsedMs; + this.frameBufHead++; + if (this.frameBufCount < this.FRAME_BUF) this.frameBufCount++; + + if (this.frameBufHead % 30 !== 0) return; + + const n = this.frameBufCount; + this.frameScratch.set(this.frameBuf.subarray(0, n)); + // Sort only the valid portion in-place using a simple insertion sort + // (n ≀ 120, so O(nΒ²) is negligible; avoids JS array allocation). + for (let i = 1; i < n; i++) { + const v = this.frameScratch[i]; + let j = i - 1; + while (j >= 0 && this.frameScratch[j] > v) { + this.frameScratch[j + 1] = this.frameScratch[j]; + j--; + } + this.frameScratch[j + 1] = v; + } + + let sum = 0; + for (let i = 0; i < n; i++) sum += this.frameScratch[i]; + + const round1 = (v: number) => Math.round(v * 10) / 10; + this.state.frameStats = { + avgMs: round1(sum / n), + p95Ms: round1(this.frameScratch[Math.min(n - 1, Math.floor(n * 0.95))]), + p99Ms: round1(this.frameScratch[Math.min(n - 1, Math.floor(n * 0.99))]), + maxMs: round1(this.frameScratch[n - 1]), + sampleCount: n, + }; + } + + private render() { + const renderStart = performance.now(); + ensureBitmapFonts(); + this.canvasRect = this.canvas.getBoundingClientRect(); + + if (this.hasPendingWheel) { + this.hasPendingWheel = false; + if (this.pendingWheelDX !== 0) { + this.state.viewport.startMs = this.clampStartMs( + this.state.viewport.startMs + + this.pendingWheelDX / this.state.viewport.zoom, + ); + this.pendingWheelDX = 0; + } + if (this.pendingZoomFactor !== 1) { + this.applyZoom( + this.pendingZoomFactor, + this.pendingZoomCX, + this.pendingZoomCY, + ); + this.pendingZoomFactor = 1; + } + } + + // Animate X pan toward target (exponential ease). + if (this.xPanTarget !== null) { + const delta = this.xPanTarget - this.state.viewport.startMs; + if (Math.abs(delta) < 0.5) { + this.state.viewport.startMs = this.xPanTarget; + this.xPanTarget = null; + } else { + this.state.viewport.startMs += delta * X_PAN_EASE; + this.dirty = true; + } + } + + const { viewport } = this.state; + const { startMs, scrollY, zoom, scaleY } = viewport; + const { trackHeight, trackGap } = this.config; + + const effectiveTrackH = trackHeight * scaleY; + const effectiveTrackGap = Math.max(1, trackGap * scaleY); + const rowSize = effectiveTrackH + effectiveTrackGap; + + const screenW = this.app.screen.width; + const screenH = this.app.screen.height; + + if (screenW < 1 || screenH < 1) return; + + const newEndMs = startMs + screenW / zoom; + if (this.state.viewport.endMs !== newEndMs) + this.state.viewport.endMs = newEndMs; + + const newMaxScrollY = this.maxScrollY(); + if (this.state.maxScrollY !== newMaxScrollY) + this.state.maxScrollY = newMaxScrollY; + + // ── Hover flush β€” update cursor once per rAF, not per pointermove ──────── + if (this.hasPendingHover) { + this.hasPendingHover = false; + const hx = this.pendingHoverClientX - this.canvasRect.left; + const hy = this.pendingHoverClientY - this.canvasRect.top; + const hit = this.hitTestEvent(hx, hy); + const cursor = hit ? 'pointer' : 'default'; + if (cursor !== this.lastCursor) { + this.canvas.style.cursor = cursor; + this.lastCursor = cursor; + } + } + + // scrollContainer.y positions all events/loading bars relative to the ruler. + // Vertical scroll only moves this container β€” no geometry rebuild needed. + const containerY = RULER_H - scrollY; + + const tileOffset = (performance.now() / 1000) * 48; + const tileChanged = Math.abs(tileOffset - this.lastTileOffset) >= 0.5; + + // Split dirty tracking: X-geometry (pan/zoom/resize) vs Y-scroll. + // Ruler and grid lines only depend on X state; skipping their rebuild on + // scroll-only frames saves ~20 draw calls per scroll tick at 60fps. + const needsXGeometry = + this.dirty || + startMs !== this.lastStartMs || + zoom !== this.lastZoom || + screenW !== this.lastScreenW || + screenH !== this.lastScreenH || + this.state.timeScale !== this.lastTimeScale; + const needsYScroll = scrollY !== this.lastScrollY; + const needsGeometry = needsXGeometry || needsYScroll; + + if (!needsGeometry && !tileChanged) return; + + const totalRows = Math.max( + this.currentArgs.totalRows, + getVisibleGroupCount(), + ); + const descCount = this.currentArgs.descCount; + const ascCount = this.currentArgs.ascCount; + const sortOrder = this.currentArgs.sortOrder ?? 'desc'; + // Loading gap sits between the desc section (top) and asc section (bottom). + const loadingStart = descCount; + const loadingEnd = Math.max(descCount, totalRows - ascCount); + + // Animation-only fast path (shimmer tick, no scroll/pan/zoom change). + if (!needsGeometry && tileChanged) { + this.lastTileOffset = tileOffset; + this.updateLoadingBars( + loadingStart, + loadingEnd, + rowSize, + effectiveTrackH, + containerY, + screenH, + screenW, + tileOffset, + ); + this.app.renderer.render(this.app.stage); + return; + } + + // Full geometry path β€” pan, zoom, resize, scroll, or dirty. + this.dirty = false; + this.lastStartMs = startMs; + this.lastScrollY = scrollY; + this.lastZoom = zoom; + this.lastScreenW = screenW; + this.lastScreenH = screenH; + this.lastTimeScale = this.state.timeScale; + if (tileChanged) this.lastTileOffset = tileOffset; + + this.scrollContainer.y = containerY; + if (needsXGeometry) { + this.drawRuler(startMs, zoom, screenW, screenH, this.state.timeScale); + } + + // Loading bars β€” tracks in the gap between desc section and asc section. + this.updateLoadingBars( + loadingStart, + loadingEnd, + rowSize, + effectiveTrackH, + containerY, + screenH, + screenW, + tileOffset, + ); + + // Event bars β€” track-indexed: iterate only visible track rows instead of + // scanning every event in the pool. For a 13k-group timeline at typical + // zoom only ~50 tracks are on-screen, so this is O(50) not O(13000). + // + // Proof that visIdx === effectiveIdx: + // desc: effectiveIdx = trackIndex = dataTrack = visIdx βœ“ + // asc: effectiveIdx = numTracks-1-trackIndex = numTracks-1-(numTracks-1-visIdx) = visIdx βœ“ + this.baseGfx.clear(); + this.selectionGfx.clear(); + this.eventLabelIndex = 0; + this.iconSpriteIndex = 0; + this.leftPins = []; + this.rightPins = []; + this.pinnedPoolIdxs.clear(); + let visibleCount = 0; + const radius = Math.max(2, Math.min(4, effectiveTrackH * 0.15)); + const PIN_MARGIN = 4; + const origin = this.dataOriginMs; + const numTracks = this.trackIdx.numTracks; + + if (!isNaN(origin) && numTracks > 0) { + const firstVisEffIdx = Math.max( + 0, + Math.floor((scrollY - effectiveTrackH) / rowSize), + ); + const lastVisEffIdx = Math.min( + numTracks - 1, + Math.floor((scrollY + screenH - RULER_H) / rowSize), + ); + + for (let visIdx = firstVisEffIdx; visIdx <= lastVisEffIdx; visIdx++) { + const dataTrack = sortOrder === 'asc' ? numTracks - 1 - visIdx : visIdx; + if (dataTrack < 0 || dataTrack >= this.trackIdx.offsets.length - 1) + continue; + + const localY = visIdx * rowSize; + const screenY = containerY + localY; + + for ( + let j = this.trackIdx.offsets[dataTrack]; + j < this.trackIdx.offsets[dataTrack + 1]; + j++ + ) { + const i = this.trackIdx.poolIdxs[j]; + const meta = getGroupMeta(i); + if (!meta) continue; + + const relStart = meta.startMs - origin; + const relEnd = Math.max(meta.endMs - origin, relStart + 1); + + const rawX = (relStart - startMs) * zoom; + const rawW = Math.max(MIN_BAR_W, (relEnd - relStart) * zoom); + + const color = EVENT_COLORS[meta.pixiType] ?? EVENT_COLORS.default; + const alpha = STATUS_ALPHA[meta.pixiStatus] ?? 1.0; + + // ── Left/right edge pin detection ────────────────────────────────── + const isLeftPin = rawX + rawW < PIN_MARGIN + MIN_BAR_W; + const isRightPin = rawX > screenW - PIN_MARGIN - MIN_BAR_W; + + let drawX = rawX; + let drawW = rawW; + if (isLeftPin) { + drawX = PIN_MARGIN; + drawW = MIN_BAR_W; + this.leftPins.push({ + poolIdx: i, + py: screenY, + ph: effectiveTrackH, + }); + this.pinnedPoolIdxs.add(i); + } else if (isRightPin) { + drawX = screenW - PIN_MARGIN - MIN_BAR_W; + drawW = MIN_BAR_W; + this.rightPins.push({ + poolIdx: i, + py: screenY, + ph: effectiveTrackH, + }); + this.pinnedPoolIdxs.add(i); + } else if (rawX + rawW < 0 || rawX > screenW) { + continue; + } + + visibleCount++; + this.baseGfx + .roundRect(drawX, localY, drawW, effectiveTrackH, radius) + .fill({ color, alpha }); + + const eventId = String(meta.headSlotIdx + 1); + if (this.state.selectedEvents[eventId]) { + const sw = 2; + this.selectionGfx + .roundRect( + drawX - sw, + localY - sw, + drawW + sw * 2, + effectiveTrackH + sw * 2, + radius + sw, + ) + .stroke({ color: 0xffffff, width: sw * 2, alpha: 1 }); + } else if (this.state.keyboardFocusPoolIdx === i) { + const sw = 1.5; + this.selectionGfx + .roundRect( + drawX - sw, + localY - sw, + drawW + sw * 2, + effectiveTrackH + sw * 2, + radius + sw, + ) + .stroke({ color: 0x38bdf8, width: sw * 2, alpha: 0.9 }); + } + + const barMidY = localY + effectiveTrackH / 2; + + // ── SVG icon + display name ──────────────────────────────────────── + const barLeft = Math.max(0, drawX); + const barRight = Math.min(screenW, drawX + drawW); + const visibleW = barRight - barLeft; + + let iconPlaced = false; + if (this.iconTextures && visibleW >= MIN_BAR_W) { + const iconName = PIXI_TYPE_TO_ICON[meta.pixiType]; + if (iconName) { + const sprite = this.getIconSprite(); + sprite.texture = this.iconTextures[iconName]; + sprite.x = barLeft + ICON_PAD; + sprite.y = barMidY; + sprite.width = ICON_SIZE; + sprite.height = ICON_SIZE; + sprite.visible = true; + sprite.alpha = alpha; + iconPlaced = true; + } + } + + if (visibleW >= 8) { + const name = + meta.group?.displayName ?? + meta.pixiType + .replace(/^(EVENT_TYPE_|GROUP_)/, '') + .replace(/_/g, ' '); + const textStart = iconPlaced + ? barLeft + ICON_PAD + ICON_SIZE + 2 + : barLeft + 4; + const textAvailW = screenW - textStart - 4; + if (textAvailW >= 6) { + const labelText = iconPlaced + ? PixiRenderer.fitName(name, textAvailW) + : PixiRenderer.fitLabel( + PixiRenderer.iconLetter(meta.pixiType), + name, + textAvailW, + ); + if (labelText) { + const label = this.getEventLabel(); + label.text = labelText; + label.x = textStart; + label.y = barMidY; + label.visible = true; + label.alpha = 1; + } + } + } + } + } + } + + if (this.state.visibleEvents !== visibleCount) { + this.state.visibleEvents = visibleCount; + } + + for (let i = this.eventLabelIndex; i < this.eventLabelPool.length; i++) { + this.eventLabelPool[i].visible = false; + } + for (let i = this.iconSpriteIndex; i < this.iconSpritePool.length; i++) { + this.iconSpritePool[i].visible = false; + } + + // ── Gutter pins (events above / below the visible track range) ────────── + this.drawGutterPins( + startMs, + zoom, + rowSize, + effectiveTrackH, + containerY, + screenW, + screenH, + radius, + sortOrder, + ); + + this.app.renderer.render(this.app.stage); + this.updateFrameStats(performance.now() - renderStart); + } + + /** + * Render thin pin bars at the top and bottom of the canvas for events whose + * track rows are off-screen. + * + * Uses the same screen-space formula as event bars: + * px = (startMs - viewportStart) * zoom + * pw = max(MIN_BAR_W, duration * zoom) + * Events entirely off-screen clamp to the left or right margin at MIN_BAR_W. + * Row assignment uses time-range non-overlap so density is maximised. + * + * Priority rules (most important events are always visible): + * 1. collectGutterBest sorts by (duration DESC, type priority ASC), so + * timers and child-workflows bubble ahead of plain activities. + * 2. For the above-gutter, track indices are reversed before collection so + * that closest-to-viewport tracks are processed first and claim the best + * gutter slots when durations are equal. + * 3. packGutterPins pass 2 preserves the importance ordering β€” it does NOT + * re-sort by px β€” so a high-priority timer at px=102 always wins over a + * low-priority activity at px=100 when both would occupy the same slot. + * + * Row stacking visual contract: + * - Top gutter: Row 0 = outer edge (nearest ruler). + * - Bottom gutter: Row 0 = outer edge (nearest screen bottom). + * The most-important events therefore sit at the edges of the screen, + * making them the first things the eye falls on. + */ + private drawGutterPins( + startMs: number, + zoom: number, + rowSize: number, + effectiveTrackH: number, + containerY: number, + screenW: number, + screenH: number, + _radius: number, + sortOrder: 'desc' | 'asc' = 'desc', + ) { + this.gutterTopGfx.clear(); + this.gutterBottomGfx.clear(); + this.topPins = []; + this.bottomPins = []; + this.gutterIconSpriteIndex = 0; + + if (this.trackIdx.numTracks === 0) { + for (const s of this.gutterIconSpritePool) s.visible = false; + return; + } + + const PIN_H = Math.max(12, Math.min(18, effectiveTrackH * 0.75)); + const PIN_GAP = 1; + const GUTTER_ROWS = 2; + const GUTTER_STRIP_H = GUTTER_ROWS * PIN_H + (GUTTER_ROWS - 1) * PIN_GAP; + const PIN_MARGIN = 4; + + const topEdge = RULER_H; + const bottomEdge = screenH; + + const reversed = sortOrder === 'asc'; + + // Derive the sample cap from screen geometry: 4Γ— the maximum visible pins + // (screenW/minBarW Γ— GUTTER_ROWS). This bounds gatherGutterTracks to + // O(gutterSampleCap) instead of O(numTracks), and bounds packGutterPins to + // O(gutterSampleCapΒ²) which is fixed by screen width, not event count. + const gutterSampleCap = Math.ceil(screenW / MIN_BAR_W) * GUTTER_ROWS * 4; + + // gatherGutterTracks uses O(1) arithmetic to find the visible range and + // early-exits after collecting gutterSampleCap tracks per side. Output is + // already in closest-to-viewport-first order, so no .reverse() needed. + const { aboveTrackIdxs, belowTrackIdxs } = gatherGutterTracks( + this.trackIdx.numTracks, + containerY, + rowSize, + effectiveTrackH, + topEdge, + bottomEdge, + gutterSampleCap, + (t) => trackHasEvents(this.trackIdx, t), + reversed, + ); + + const getEventForGutter = (poolIdx: number) => { + const ev = this.pinEventFor(poolIdx); + return ev ?? { poolIdx, startMs: 0, endMs: 0, pixiType: 'UNKNOWN' }; + }; + + const aboveInput = collectBestPerTrack( + aboveTrackIdxs, + this.trackIdx, + getEventForGutter, + GUTTER_TYPE_PRIORITY, + GUTTER_TYPE_PRIORITY_DEFAULT, + gutterSampleCap, + ); + const belowInput = collectBestPerTrack( + belowTrackIdxs, + this.trackIdx, + getEventForGutter, + GUTTER_TYPE_PRIORITY, + GUTTER_TYPE_PRIORITY_DEFAULT, + gutterSampleCap, + ); + + const drawPackedPins = ( + events: GutterEventRef[], + side: 'top' | 'bottom', + store: PinBar[], + gfx: Graphics, + ) => { + const stripBase = side === 'top' ? topEdge : bottomEdge - GUTTER_STRIP_H; + + gfx + .rect(0, stripBase, screenW, GUTTER_STRIP_H) + .fill({ color: 0x0d0d0d, alpha: 0.9 }); + + if (events.length === 0) return; + + const packed = packGutterPins( + events, + startMs, + zoom, + screenW, + PIN_MARGIN, + MIN_BAR_W, + GUTTER_ROWS, + ); + + for (const { ev, px, pw, row } of packed) { + // Bottom gutter: Row 0 (most important) sits at the outer/bottom edge; + // top gutter: Row 0 sits at the outer/top edge (current behaviour). + const py = + side === 'bottom' + ? stripBase + (GUTTER_ROWS - 1 - row) * (PIN_H + PIN_GAP) + : stripBase + row * (PIN_H + PIN_GAP); + const color = EVENT_COLORS[ev.pixiType] ?? EVENT_COLORS.default; + const alpha = (STATUS_ALPHA[ev.pixiStatus] ?? 1.0) * 0.85; + + gfx.roundRect(px, py, pw, PIN_H, 2).fill({ color, alpha }); + + if (this.iconTextures) { + const iconName = PIXI_TYPE_TO_ICON[ev.pixiType]; + if (iconName && this.iconTextures[iconName]) { + const { + x: ix, + y: iy, + size: iSize, + } = gutterIconLayout(px, py, pw, PIN_H); + const sprite = this.getGutterIconSprite(); + sprite.texture = this.iconTextures[iconName]; + sprite.x = ix; + sprite.y = iy; + sprite.width = iSize; + sprite.height = iSize; + sprite.visible = true; + sprite.alpha = alpha; + } + } + + store.push({ poolIdx: ev.poolIdx, px, py, pw, ph: PIN_H }); + this.pinnedPoolIdxs.add(ev.poolIdx); + } + }; + + drawPackedPins(aboveInput, 'top', this.topPins, this.gutterTopGfx); + drawPackedPins(belowInput, 'bottom', this.bottomPins, this.gutterBottomGfx); + + for ( + let i = this.gutterIconSpriteIndex; + i < this.gutterIconSpritePool.length; + i++ + ) { + this.gutterIconSpritePool[i].visible = false; + } + } + + /** + * Scroll the viewport so the given event is visible. + * Pass centerX=true for left/right pin clicks to also center the horizontal axis. + */ + private scrollToEvent( + event: { startMs: number; endMs: number; trackIndex: number }, + centerX = false, + ) { + const { viewport } = this.state; + const screenW = this.app.screen.width; + const screenH = this.app.screen.height; + const eventAreaH = screenH - RULER_H; + const { trackHeight, trackGap } = this.config; + const rowSize = + trackHeight * viewport.scaleY + Math.max(1, trackGap * viewport.scaleY); + + const xStart = (event.startMs - viewport.startMs) * viewport.zoom; + const xEnd = (event.endMs - viewport.startMs) * viewport.zoom; + + if (centerX || xEnd < 0 || xStart > screenW) { + const centerMs = (event.startMs + event.endMs) / 2; + const vpSpanMs = screenW / viewport.zoom; + this.state.viewport.startMs = centerMs - vpSpanMs / 2; + } + + const so = this.currentArgs.sortOrder ?? 'desc'; + const tt = this.trackIdx.numTracks || getVisibleGroupCount(); + const effIdx = so === 'asc' ? tt - 1 - event.trackIndex : event.trackIndex; + const trackY = effIdx * rowSize; + this.state.viewport.scrollY = Math.max( + 0, + Math.min(this.maxScrollY(), trackY - eventAreaH / 3 + rowSize / 2), + ); + this.dirty = true; + } + + private canvasPos(e: PointerEvent | MouseEvent): { x: number; y: number } { + this.canvasRect = this.canvas.getBoundingClientRect(); + return { + x: e.clientX - this.canvasRect.left, + y: e.clientY - this.canvasRect.top, + }; + } + + private setupInteraction() { + const { canvas } = this; + + canvas.addEventListener('pointerdown', (e) => { + const pos = this.canvasPos(e); + this.panState = { + active: true, + startX: pos.x, + startY: pos.y, + originStartMs: this.state.viewport.startMs, + originScrollY: this.state.viewport.scrollY, + }; + this.dirty = true; + canvas.style.cursor = 'grabbing'; + try { + canvas.setPointerCapture(e.pointerId); + } catch { + // Synthetic events may not have a registered pointer id. + } + }); + + canvas.addEventListener('pointermove', (e) => { + if (this.panState.active) { + this.pendingPanClientX = e.clientX; + this.pendingPanClientY = e.clientY; + if (this.panFlushRafId === null) { + this.panFlushRafId = requestAnimationFrame(() => { + this.panFlushRafId = null; + if (!this.panState.active) return; + this.canvasRect = this.canvas.getBoundingClientRect(); + const x = this.pendingPanClientX - this.canvasRect.left; + const y = this.pendingPanClientY - this.canvasRect.top; + const deltaXMs = + (x - this.panState.startX) / this.state.viewport.zoom; + const deltaYPx = y - this.panState.startY; + this.state.viewport.startMs = this.clampStartMs( + this.panState.originStartMs - deltaXMs, + ); + this.state.viewport.scrollY = Math.max( + 0, + Math.min( + this.maxScrollY(), + this.panState.originScrollY - deltaYPx, + ), + ); + }); + } + return; + } + // Hover tracking β€” flushed in render() to avoid per-pointermove DOM writes + this.pendingHoverClientX = e.clientX; + this.pendingHoverClientY = e.clientY; + this.hasPendingHover = true; + }); + + canvas.addEventListener('pointerup', (e) => { + if (!this.panState.active) return; + if (this.panFlushRafId !== null) { + cancelAnimationFrame(this.panFlushRafId); + this.panFlushRafId = null; + } + const pos = this.canvasPos(e); + const moved = + Math.abs(pos.x - this.panState.startX) > 4 || + Math.abs(pos.y - this.panState.startY) > 4; + this.panState.active = false; + + const hit = moved ? null : this.hitTestEvent(pos.x, pos.y); + const cursor = hit ? 'pointer' : 'default'; + if (cursor !== this.lastCursor) { + canvas.style.cursor = cursor; + this.lastCursor = cursor; + } + + if (!moved && hit) { + // Any gutter-pin click should center the viewport on that event (both + // axes), since the user is explicitly navigating to an off-screen item. + const isGutterPin = this.pinnedPoolIdxs.has(hit.poolIdx ?? -1); + this.scrollToEvent(hit, isGutterPin); + + // Always clear current selection first (single panel at a time) + for (const id of Object.keys(this.state.selectedEvents)) { + this.ctx?.deselectEvent(id); + } + this.ctx?.toggleSelected(hit); + this.state.keyboardFocusPoolIdx = hit.poolIdx ?? null; + this.dirty = true; + } else if (!moved) { + // Click on empty area β€” deselect all and clear keyboard cursor + for (const id of Object.keys(this.state.selectedEvents)) { + this.ctx?.deselectEvent(id); + } + this.state.keyboardFocusPoolIdx = null; + this.dirty = true; + } + }); + + canvas.addEventListener('pointerleave', () => { + if (!this.panState.active) { + this.hasPendingHover = false; + if (this.lastCursor !== 'default') { + canvas.style.cursor = 'default'; + this.lastCursor = 'default'; + } + } + }); + + canvas.addEventListener( + 'wheel', + (e) => { + const isZoom = e.ctrlKey || e.shiftKey; + // Require horizontal component to be at least 2Γ— the vertical before + // treating it as a horizontal pan. A factor of 1 was too sensitive on + // macOS trackpads β€” any slight diagonal swipe would accumulate deltaX + // and drift startMs past the event range. + const isHorizontal = + !isZoom && Math.abs(e.deltaX) > Math.abs(e.deltaY) * 2; + + // Vertical-only wheel: let native DOM scroll handle Y movement. + // On every event, immediately pan X so events at the new Y position + // stay visible in the canvas β€” no debounce, no cooldown. + if (!isZoom && !isHorizontal) { + const { trackHeight, trackGap } = this.config; + const { scaleY } = this.state.viewport; + const rowH = trackHeight * scaleY + Math.max(1, trackGap * scaleY); + const visibleH = this.app.screen.height - RULER_H; + const totalTracks = this.trackIdx.numTracks || 1; + + // Anticipate scrollY after this delta so X pans ahead of native scroll. + const anticipatedScrollY = Math.max( + 0, + Math.min(this.maxScrollY(), this.state.viewport.scrollY + e.deltaY), + ); + + const topTrack = Math.max(0, Math.floor(anticipatedScrollY / rowH)); + const bottomTrack = Math.min( + totalTracks - 1, + Math.floor((anticipatedScrollY + visibleH) / rowH), + ); + + const bounds = getTrackEventBounds( + this.trackIdx, + topTrack, + bottomTrack, + (poolIdx) => { + const meta = getGroupMeta(poolIdx); + if (!meta) return { startMs: 0, endMs: 0 }; + const o = this.dataOriginMs; + return { + startMs: meta.startMs - o, + endMs: Math.max(meta.endMs - o, meta.startMs - o + 1), + }; + }, + ); + if (bounds) { + const { evMinMs, evMaxMs } = bounds; + const screenW = this.app.screen.width || 1200; + const { zoom, startMs } = this.state.viewport; + const sortOrder = + (this.currentArgs.sortOrder as 'desc' | 'asc') ?? 'desc'; + const result = calcScrollXPan({ + evMinMs, + evMaxMs, + startMs, + screenW, + zoom, + deltaY: e.deltaY, + sortOrder, + }); + if (result !== null) { + this.xPanTarget = this.clampStartMs(result); + this.dirty = true; + } + } + + return; // native DOM handles Y movement + } + + e.preventDefault(); + e.stopPropagation(); + + let dy = e.deltaY, + dx = e.deltaX; + if (e.deltaMode === 1) { + dy *= 20; + dx *= 20; + } + if (e.deltaMode === 2) { + dy *= 400; + dx *= 400; + } + + if (isZoom) { + const raw = dy !== 0 ? dy : dx; + const factor = raw < 0 ? 1.15 : 1 / 1.15; + this.pendingZoomFactor *= factor; + this.pendingZoomCX = e.clientX; + this.pendingZoomCY = e.clientY; + } else { + this.xPanTarget = null; // user is manually panning X β€” cancel auto-pan + this.pendingWheelDX += Math.abs(dx) > Math.abs(dy) ? dx : dy; + } + this.hasPendingWheel = true; + }, + { passive: false }, + ); + + canvas.addEventListener('keydown', (e) => { + const { viewport } = this.state; + const screenW = this.app.screen.width || 1200; + + if (e.key === 'Home') { + e.preventDefault(); + this.state.viewport.startMs = this.clampStartMs( + this.state.dataRange.startMs - screenW / viewport.zoom / 2, + ); + this.state.viewport.scrollY = 0; + this.dirty = true; + } else if (e.key === 'End') { + e.preventDefault(); + this.state.viewport.startMs = this.clampStartMs( + this.state.dataRange.endMs - screenW / viewport.zoom / 2, + ); + this.state.viewport.scrollY = this.maxScrollY(); + this.dirty = true; + } else if (e.key === 'ArrowLeft' && !e.shiftKey) { + e.preventDefault(); + const panMs = (screenW * 0.2) / viewport.zoom; + this.state.viewport.startMs = this.clampStartMs( + viewport.startMs - panMs, + ); + this.dirty = true; + } else if (e.key === 'ArrowRight' && !e.shiftKey) { + e.preventDefault(); + const panMs = (screenW * 0.2) / viewport.zoom; + this.state.viewport.startMs = this.clampStartMs( + viewport.startMs + panMs, + ); + this.dirty = true; + } else if ((e.key === 'ArrowDown' || e.key === 'Tab') && !e.shiftKey) { + e.preventDefault(); + this.moveFocusToAdjacentTrack(1); + } else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) { + e.preventDefault(); + this.moveFocusToAdjacentTrack(-1); + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.selectKeyboardFocusedEvent(); + } else if (e.key === 'Escape') { + e.preventDefault(); + const hasSelection = Object.keys(this.state.selectedEvents).length > 0; + if (hasSelection) { + for (const id of Object.keys(this.state.selectedEvents)) { + this.ctx?.deselectEvent(id); + } + } else { + this.state.keyboardFocusPoolIdx = null; + } + this.dirty = true; + } + }); + } + + private clampStartMs(startMs: number): number { + return clampViewportStartMs( + startMs, + this.state.dataRange, + this.state.viewport.zoom, + this.app.screen.width || 1200, + ); + } + + private applyZoom(factor: number, clientX: number, clientY: number) { + const pos = { + x: clientX - this.canvasRect.left, + y: clientY - this.canvasRect.top, + }; + const { viewport } = this.state; + const { trackHeight, trackGap } = this.config; + + const newZoom = Math.max( + this.config.minZoom, + Math.min(this.config.maxZoom, viewport.zoom * factor), + ); + if (newZoom !== viewport.zoom) { + const mouseMs = viewport.startMs + pos.x / viewport.zoom; + this.state.viewport.zoom = newZoom; + this.state.viewport.startMs = this.clampStartMs( + mouseMs - pos.x / newZoom, + ); + } + + const newScaleY = clampScaleY(viewport.scaleY * factor, trackHeight); + if (newScaleY !== viewport.scaleY) { + const oldRowSize = + trackHeight * viewport.scaleY + Math.max(1, trackGap * viewport.scaleY); + const eventY = Math.max(0, pos.y - RULER_H); + const trackUnderCursor = (eventY + viewport.scrollY) / oldRowSize; + this.state.viewport.scaleY = newScaleY; + const newRowSize = + trackHeight * newScaleY + Math.max(1, trackGap * newScaleY); + this.state.viewport.scrollY = Math.max( + 0, + Math.min(this.maxScrollY(), trackUnderCursor * newRowSize - eventY), + ); + } + } + + private moveFocusToAdjacentTrack(delta: number) { + const numTracks = this.trackIdx.numTracks; + if (numTracks === 0) return; + + const currentIdx = this.state.keyboardFocusPoolIdx; + const so = (this.currentArgs.sortOrder ?? 'desc') as 'desc' | 'asc'; + // Flip step so visual Down always moves to higher visual track index. + const step = so === 'asc' ? -delta : delta; + + let targetTrack: number; + if (currentIdx === null) { + targetTrack = step > 0 ? 0 : numTracks - 1; + } else { + targetTrack = (getGroupMeta(currentIdx)?.trackIndex ?? 0) + step; + } + + // Walk the offsets array from targetTrack in the direction of travel to + // find the nearest occupied track. O(1) average (dense timelines), O(numTracks) + // absolute worst case (all tracks empty β€” never happens in practice). + let foundTrack = -1; + if (step > 0) { + for (let t = Math.max(0, targetTrack); t < numTracks; t++) { + if (this.trackIdx.offsets[t + 1] > this.trackIdx.offsets[t]) { + foundTrack = t; + break; + } + } + } else { + for (let t = Math.min(numTracks - 1, targetTrack); t >= 0; t--) { + if (this.trackIdx.offsets[t + 1] > this.trackIdx.offsets[t]) { + foundTrack = t; + break; + } + } + } + if (foundTrack < 0) return; + + // Pick the first event poolIdx stored for that track. + const bestIdx = this.trackIdx.poolIdxs[this.trackIdx.offsets[foundTrack]]; + this.state.keyboardFocusPoolIdx = bestIdx; + const ev = this.pinEventFor(bestIdx); + if (ev) this.scrollToEvent(ev); + this.dirty = true; + } + + private selectKeyboardFocusedEvent() { + const idx = this.state.keyboardFocusPoolIdx; + if (idx === null) return; + const meta = getGroupMeta(idx); + if (!meta) return; + + const origin = this.dataOriginMs; + const relStart = meta.startMs - origin; + const relEnd = Math.max(meta.endMs - origin, relStart + 1); + const event: import('../types').TemporalEvent = { + eventId: String(meta.headSlotIdx + 1), + eventType: meta.pixiType, + eventTime: meta.startMs, + startMs: relStart, + endMs: relEnd, + status: meta.pixiStatus as EventStatus, + trackIndex: meta.trackIndex, + attributes: { type: meta.pixiType, durationMs: relEnd - relStart }, + poolIdx: idx, + }; + + for (const id of Object.keys(this.state.selectedEvents)) { + this.ctx?.deselectEvent(id); + } + this.ctx?.toggleSelected(event); + this.dirty = true; + } + + destroy() { + if (this.rafId !== null) cancelAnimationFrame(this.rafId); + if (this.panFlushRafId !== null) cancelAnimationFrame(this.panFlushRafId); + this.resizeObserver.disconnect(); + document.removeEventListener('visibilitychange', this.onVisibilityChange); + this.app.destroy({ removeView: false, releaseGlobalResources: true }); + } +} diff --git a/src/lib/components/pixi-timeline/renderer/ViewportCuller.ts b/src/lib/components/pixi-timeline/renderer/ViewportCuller.ts new file mode 100644 index 0000000000..7d026fff75 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/ViewportCuller.ts @@ -0,0 +1,100 @@ +import { getGroupMeta } from '$lib/services/grouped-event-buffer'; + +/** + * Compact entry stored in the spatial index. + * Uses pool index instead of a full TemporalEvent reference to avoid + * duplicating event data already stored in the grouped-event-buffer pool. + */ +export interface CullerEntry { + startMs: number; + endMs: number; + trackIndex: number; + poolIdx: number; +} + +/** + * Two-structure spatial index for visible-range queries. + * + * byTrack β€” CullerEntry[][] indexed by trackIndex. + * Main visible query: O(k) β€” iterate tracks minTrack..maxTrack. + * byTime β€” flat CullerEntry[] sorted by startMs. + * Time-range queries: O(log n + k) via binary search. + */ +export class ViewportCuller { + private byTrack: CullerEntry[][] = []; + private byTime: CullerEntry[] = []; + + /** + * Build the spatial index from the buffer pool. + * Called after loadEvents() on poolCount groups. + */ + load(poolCount: number) { + this.byTrack = []; + const all: CullerEntry[] = []; + + for (let i = 0; i < poolCount; i++) { + const meta = getGroupMeta(i); + if (!meta || !meta.group) continue; + const entry: CullerEntry = { + startMs: meta.startMs, + endMs: meta.endMs, + trackIndex: meta.trackIndex, + poolIdx: i, + }; + (this.byTrack[meta.trackIndex] ??= []).push(entry); + all.push(entry); + } + + for (const track of this.byTrack) { + if (track) track.sort((a, b) => a.startMs - b.startMs); + } + this.byTime = all.sort((a, b) => a.startMs - b.startMs); + } + + /** + * Main visible-events query: returns every entry whose trackIndex is in + * [minTrack, maxTrack). When startMs/endMs are -Infinity/Infinity, skips + * the time filter entirely for max speed. + */ + query( + startMs: number, + endMs: number, + minTrack = -Infinity, + maxTrack = Infinity, + ): CullerEntry[] { + const timeFiltered = startMs !== -Infinity || endMs !== Infinity; + + if (!timeFiltered) { + const result: CullerEntry[] = []; + const lo = Math.max(0, Math.ceil(minTrack)); + const hi = Math.min(this.byTrack.length - 1, Math.floor(maxTrack)); + for (let t = lo; t <= hi; t++) { + const track = this.byTrack[t]; + if (track) { + for (const e of track) result.push(e); + } + } + return result; + } + + let lo = 0; + let hi = this.byTime.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (this.byTime[mid].endMs < startMs) lo = mid + 1; + else hi = mid; + } + const result: CullerEntry[] = []; + for (let i = lo; i < this.byTime.length; i++) { + const e = this.byTime[i]; + if (e.startMs > endMs) break; + if (e.trackIndex >= minTrack && e.trackIndex < maxTrack) result.push(e); + } + return result; + } + + clear() { + this.byTrack = []; + this.byTime = []; + } +} diff --git a/src/lib/components/pixi-timeline/renderer/collect-gutter-best.test.ts b/src/lib/components/pixi-timeline/renderer/collect-gutter-best.test.ts new file mode 100644 index 0000000000..fcc7e611eb --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/collect-gutter-best.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it } from 'vitest'; + +import { collectGutterBest, type GutterEvent } from './collect-gutter-best'; + +const PRIORITY: Record = { + GROUP_CHILD_WORKFLOW: 2, + GROUP_TIMER: 4, + GROUP_ACTIVITY: 5, +}; +const PRIORITY_DEFAULT = 7; + +const ev = ( + startMs: number, + endMs: number, + pixiType = 'GROUP_ACTIVITY', +): GutterEvent => ({ startMs, endMs, pixiType }); + +const byTrack = ( + tracks: (GutterEvent | GutterEvent[])[], +): (GutterEvent[] | undefined)[] => + tracks.map((t) => (Array.isArray(t) ? t : [t])); + +describe('collectGutterBest', () => { + it('returns empty for no tracks', () => { + expect(collectGutterBest([], [], PRIORITY, PRIORITY_DEFAULT, 100)).toEqual( + [], + ); + }); + + it('returns empty when all tracks are empty', () => { + const result = collectGutterBest( + [0, 1, 2], + [undefined, undefined, undefined], + PRIORITY, + PRIORITY_DEFAULT, + 100, + ); + expect(result).toHaveLength(0); + }); + + it('picks one event per track (the longest)', () => { + const tracks = byTrack([[ev(0, 10), ev(0, 50), ev(0, 5)]]); + const result = collectGutterBest( + [0], + tracks, + PRIORITY, + PRIORITY_DEFAULT, + 100, + ); + expect(result).toHaveLength(1); + expect(result[0].endMs - result[0].startMs).toBe(50); + }); + + it('sorts by duration DESC β€” longest events come first', () => { + const tracks = byTrack([ + ev(0, 1), // track 0: duration 1ms + ev(0, 1000), // track 1: duration 1000ms + ev(0, 10), // track 2: duration 10ms + ]); + const result = collectGutterBest( + [0, 1, 2], + tracks, + PRIORITY, + PRIORITY_DEFAULT, + 100, + ); + expect(result[0].endMs - result[0].startMs).toBe(1000); + expect(result[1].endMs - result[1].startMs).toBe(10); + expect(result[2].endMs - result[2].startMs).toBe(1); + }); + + it('secondary sort: lower type priority wins among equal durations', () => { + const tracks = byTrack([ + { startMs: 0, endMs: 100, pixiType: 'GROUP_ACTIVITY' }, + { startMs: 0, endMs: 100, pixiType: 'GROUP_TIMER' }, + { startMs: 0, endMs: 100, pixiType: 'GROUP_CHILD_WORKFLOW' }, + ]); + const result = collectGutterBest( + [0, 1, 2], + tracks, + PRIORITY, + PRIORITY_DEFAULT, + 100, + ); + expect(result[0].pixiType).toBe('GROUP_CHILD_WORKFLOW'); + expect(result[1].pixiType).toBe('GROUP_TIMER'); + expect(result[2].pixiType).toBe('GROUP_ACTIVITY'); + }); + + it('caps output at maxSample', () => { + const tracks = byTrack(Array.from({ length: 20 }, (_, i) => ev(i, i + 1))); + const idxs = tracks.map((_, i) => i); + const result = collectGutterBest( + idxs, + tracks, + PRIORITY, + PRIORITY_DEFAULT, + 5, + ); + expect(result).toHaveLength(5); + }); + + // ── Proximity ordering (the core fix for gutter scroll behaviour) ────────── + // + // When all events have the same duration (common case: 1ms echo activities) + // the sort is stable. The caller is responsible for passing trackIdxs in + // closest-to-viewport-first order so the PACK_SAMPLE cap retains relevant + // events instead of the farthest ones. + // + // For the ABOVE gutter, gatherGutterTracks returns indices in ascending order + // (farthest = first, closest = last), so the caller reverses before calling: + // collectGutterBest([...aboveTrackIdxs].reverse(), ...) + // + // For the BELOW gutter, the order is already closest-first. + + it('preserves input order when all durations are equal (stable sort)', () => { + // Simulates reversed aboveTrackIdxs: [close=9, 8, 7, ..., far=0] + const tracks = byTrack( + Array.from({ length: 10 }, (_, i) => ev(i * 10, i * 10 + 1)), + ); + const reversedIdxs = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]; // closest first + + const result = collectGutterBest( + reversedIdxs, + tracks, + PRIORITY, + PRIORITY_DEFAULT, + 100, + ); + // All durations are 1ms β€” stable sort keeps input order β†’ closest tracks retained + expect(result.map((e) => e.startMs)).toEqual([ + 90, 80, 70, 60, 50, 40, 30, 20, 10, 0, + ]); + }); + + it('cap retains closest tracks when all durations are equal', () => { + // 10 equal-duration tracks, input in closest-first order, cap=4 + const tracks = byTrack( + Array.from({ length: 10 }, (_, i) => ev(i * 10, i * 10 + 1)), + ); + const reversedIdxs = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]; + + const result = collectGutterBest( + reversedIdxs, + tracks, + PRIORITY, + PRIORITY_DEFAULT, + 4, + ); + // Cap keeps first 4 β†’ startMs values [90, 80, 70, 60] (closest to viewport) + expect(result).toHaveLength(4); + expect(result.map((e) => e.startMs)).toEqual([90, 80, 70, 60]); + }); + + it('long-duration events always surface even when they are far from viewport', () => { + // Reversed above indices: closest = track 9, farthest = track 0 + // But track 0 has a much longer duration β€” it must still come first. + const tracks = byTrack([ + ev(0, 5000, 'GROUP_TIMER'), // track 0 (farthest) but 5000ms duration + ev(10, 11), // track 1: 1ms + ev(20, 21), // track 2: 1ms + ev(30, 31), // track 3: 1ms + ev(40, 41), // track 4: 1ms + ev(50, 51), // track 5: 1ms + ev(60, 61), // track 6: 1ms + ev(70, 71), // track 7: 1ms + ev(80, 81), // track 8: 1ms + ev(90, 91), // track 9 (closest): 1ms + ]); + const reversedIdxs = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]; + + const result = collectGutterBest( + reversedIdxs, + tracks, + PRIORITY, + PRIORITY_DEFAULT, + 5, + ); + // Timer (5000ms) must come first regardless of proximity order + expect(result[0].pixiType).toBe('GROUP_TIMER'); + expect(result[0].endMs - result[0].startMs).toBe(5000); + // Remaining 4 slots: closest equal-duration tracks (tracks 9,8,7,6) + expect(result.slice(1).map((e) => e.startMs)).toEqual([90, 80, 70, 60]); + }); + + it('handles sparse byTrack (undefined entries are skipped)', () => { + const tracks: (GutterEvent[] | undefined)[] = [ + undefined, + [ev(10, 20)], + undefined, + [ev(30, 100)], + ]; + const result = collectGutterBest( + [0, 1, 2, 3], + tracks, + PRIORITY, + PRIORITY_DEFAULT, + 100, + ); + expect(result).toHaveLength(2); + expect(result[0].startMs).toBe(30); // longer duration first + expect(result[1].startMs).toBe(10); + }); +}); + +// ── isRightPin threshold invariant ──────────────────────────────────────────── +// +// In PixiRenderer, a bar with rawX + rawW potentially overlapping the right +// screen edge must be pinned to guarantee visibleW >= MIN_BAR_W so the SVG +// icon placeholder is always shown. +// +// The rule: isRightPin = rawX > screenW - PIN_MARGIN - MIN_BAR_W +// +// Without this threshold, bars starting between (screenW - PIN_MARGIN - MIN_BAR_W) +// and (screenW - PIN_MARGIN) would have visibleW < MIN_BAR_W and fall back to +// the single-letter icon placeholder ("A", "T", etc.). +// +// We encode the invariant as a pure predicate test to guard against regressions. + +function computePinType( + rawX: number, + rawW: number, + screenW: number, + pinMargin: number, + minBarW: number, +): 'left' | 'right' | 'cull' | 'normal' { + const isLeftPin = rawX + rawW < pinMargin + minBarW; + const isRightPin = rawX > screenW - pinMargin - minBarW; + if (isLeftPin) return 'left'; + if (isRightPin) return 'right'; + if (rawX + rawW < 0 || rawX > screenW) return 'cull'; + return 'normal'; +} + +function visibleW(rawX: number, rawW: number, screenW: number): number { + const barLeft = Math.max(0, rawX); + const barRight = Math.min(screenW, rawX + rawW); + return barRight - barLeft; +} + +describe('isRightPin threshold guarantees visibleW >= MIN_BAR_W', () => { + const SCREEN_W = 1000; + const PIN_MARGIN = 4; + const MIN_BAR_W = 18; + + it('bar starting well inside screen: normal render, full visibleW', () => { + const rawX = 400; + const rawW = MIN_BAR_W; + expect(computePinType(rawX, rawW, SCREEN_W, PIN_MARGIN, MIN_BAR_W)).toBe( + 'normal', + ); + expect(visibleW(rawX, rawW, SCREEN_W)).toBe(18); + }); + + it('bar starting just past right threshold gets right-pinned', () => { + // rawX = SCREEN_W - PIN_MARGIN - MIN_BAR_W + 1 = 979 β†’ right pin + const rawX = SCREEN_W - PIN_MARGIN - MIN_BAR_W + 1; + expect( + computePinType(rawX, MIN_BAR_W, SCREEN_W, PIN_MARGIN, MIN_BAR_W), + ).toBe('right'); + }); + + it('bar at exact threshold boundary: normal render with exactly MIN_BAR_W visible', () => { + // rawX = SCREEN_W - PIN_MARGIN - MIN_BAR_W = 978 β†’ NOT a right pin (rawX must be >) + const rawX = SCREEN_W - PIN_MARGIN - MIN_BAR_W; + expect( + computePinType(rawX, MIN_BAR_W, SCREEN_W, PIN_MARGIN, MIN_BAR_W), + ).toBe('normal'); + // visibleW = min(1000, 978+18) - max(0, 978) = 996 - 978 = 18 = MIN_BAR_W βœ“ + expect(visibleW(rawX, MIN_BAR_W, SCREEN_W)).toBe(MIN_BAR_W); + }); + + it('bar starting inside right threshold would lose icon without pinning', () => { + // Demonstrates the pre-fix scenario for documentation. + // rawX = SCREEN_W - 10 (inside the fixed threshold, so now right-pinned) + const rawX = SCREEN_W - 10; + const rawW = MIN_BAR_W; + // With the fix: this IS a right pin + expect(computePinType(rawX, rawW, SCREEN_W, PIN_MARGIN, MIN_BAR_W)).toBe( + 'right', + ); + // Without the fix the old threshold was rawX > SCREEN_W - PIN_MARGIN = 996; + // rawX=990 was NOT a right pin, giving visibleW=10 < MIN_BAR_W β†’ "A" icon bug + const oldIsRightPin = rawX > SCREEN_W - PIN_MARGIN; + expect(oldIsRightPin).toBe(false); // confirms bug existed + expect(visibleW(rawX, rawW, SCREEN_W)).toBe(10); // confirms icon would be hidden + }); + + it('for all rawX in the trouble zone, the new threshold pins the bar', () => { + // All rawX from (SCREEN_W - PIN_MARGIN - MIN_BAR_W + 1) to SCREEN_W should be pinned + for ( + let rawX = SCREEN_W - PIN_MARGIN - MIN_BAR_W + 1; + rawX <= SCREEN_W; + rawX++ + ) { + expect( + computePinType(rawX, MIN_BAR_W, SCREEN_W, PIN_MARGIN, MIN_BAR_W), + ).toBe('right'); + } + }); + + it('entirely off-screen-right bar is also right-pinned (shown as indicator)', () => { + const rawX = SCREEN_W + 100; + expect( + computePinType(rawX, MIN_BAR_W, SCREEN_W, PIN_MARGIN, MIN_BAR_W), + ).toBe('right'); + }); + + it('left pin: bar whose right edge is within left margin zone', () => { + const rawX = -50; + const rawW = MIN_BAR_W; + // rawX + rawW = -32 < PIN_MARGIN + MIN_BAR_W = 22 β†’ left pin + expect(computePinType(rawX, rawW, SCREEN_W, PIN_MARGIN, MIN_BAR_W)).toBe( + 'left', + ); + }); +}); diff --git a/src/lib/components/pixi-timeline/renderer/collect-gutter-best.ts b/src/lib/components/pixi-timeline/renderer/collect-gutter-best.ts new file mode 100644 index 0000000000..9a101b68de --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/collect-gutter-best.ts @@ -0,0 +1,60 @@ +/** + * Pure, testable helper for selecting the best representative event per track + * to display in the top/bottom gutter strips. + * + * Selection order (primary β†’ secondary β†’ tertiary): + * 1. Duration DESC β€” longer events are more visually important + * 2. Type priority ASC β€” failures/signals/child-workflows before plain activities + * 3. Input order β€” when all else is equal the iteration order is preserved, + * so the caller can pass tracks in proximity order (closest + * first) to ensure the PACK_SAMPLE cap retains the most + * contextually relevant events. + */ + +export type GutterEvent = { + startMs: number; + endMs: number; + pixiType: string; +}; + +/** + * Collect the best (longest-duration) event per track, then rank by + * (duration DESC, type priority ASC) and cap at `maxSample`. + * + * @param trackIdxs Track indices to process, in desired priority order + * (caller should reverse above-gutter indices so that + * closest-to-viewport tracks come first). + * @param byTrack Indexed event list per track (sparse β€” may be undefined). + * @param typePriority Map from pixiType to numeric priority (lower = more important). + * @param typePriorityDefault Fallback priority for unmapped types. + * @param maxSample Maximum number of events to return. + */ +export function collectGutterBest( + trackIdxs: number[], + byTrack: (T[] | undefined)[], + typePriority: Record, + typePriorityDefault: number, + maxSample: number, +): T[] { + const out: T[] = []; + for (const t of trackIdxs) { + const evs = byTrack[t]; + if (!evs?.length) continue; + let best = evs[0]; + for (let i = 1; i < evs.length; i++) { + const e = evs[i]; + if (e.endMs - e.startMs > best.endMs - best.startMs) best = e; + } + out.push(best); + } + + out.sort((a, b) => { + const durDiff = b.endMs - b.startMs - (a.endMs - a.startMs); + if (durDiff !== 0) return durDiff; + const pa = typePriority[a.pixiType] ?? typePriorityDefault; + const pb = typePriority[b.pixiType] ?? typePriorityDefault; + return pa - pb; + }); + + return out.length > maxSample ? out.slice(0, maxSample) : out; +} diff --git a/src/lib/components/pixi-timeline/renderer/fonts.ts b/src/lib/components/pixi-timeline/renderer/fonts.ts new file mode 100644 index 0000000000..2d78ce340d --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/fonts.ts @@ -0,0 +1,158 @@ +/** + * Shared BitmapFont installation β€” idempotent, safe to call from multiple renderers. + * BitmapFont names are global in PixiJS; this module ensures they're installed exactly once. + */ +import 'pixi.js/unsafe-eval'; +import { BitmapFont, BitmapFontManager } from 'pixi.js'; + +export const FONT_EVENT = 'tl-event'; +export const FONT_RULER = 'tl-ruler'; + +const MONO = '"SF Mono", ui-monospace, Menlo, monospace'; +let installed = false; +let pendingExtra = ''; + +/** + * Register non-ASCII characters found in event data so they are included in + * the BitmapFont atlas when it is first installed. Must be called before the + * first render() tick (i.e. from loadEvents()). Calls after installation are + * silently ignored β€” the atlas is baked once and cannot be extended cheaply. + */ +export function registerFontChars(chars: string): void { + if (installed) return; + pendingExtra += chars; +} + +/** + * Install BitmapFont atlases for event and ruler labels. Deferred to the + * first render() tick so atlas generation stays off the app.init() critical + * path. The charset is ASCII plus any extra characters registered via + * registerFontChars() β€” typically just the handful of accented/special chars + * that actually appear in event names rather than the full Latin Extended range. + */ +export function ensureBitmapFonts(): void { + if (installed) return; + installed = true; + // Cap at 2Γ— β€” same reasoning as the canvas resolution cap in PixiRenderer. + const dpr = Math.min(window.devicePixelRatio ?? 1, 2); + // pendingExtra is pre-filtered to non-ASCII chars by registerFontChars callers. + // BitmapFontManager.ASCII is string[][] (range pairs); spread it and append the + // extra chars as a plain string so BitmapFont.install sees a (string | string[])[]. + const extra = [...new Set(pendingExtra)].join(''); + const chars = extra + ? ([...BitmapFontManager.ASCII, extra] as (string | string[])[]) + : BitmapFontManager.ASCII; + BitmapFont.install({ + name: FONT_EVENT, + style: { fontFamily: MONO, fontSize: 10, fill: '#ffffff' }, + chars, + resolution: dpr, + }); + BitmapFont.install({ + name: FONT_RULER, + style: { fontFamily: MONO, fontSize: 10, fill: '#64748b' }, + chars, + resolution: dpr, + }); +} + +// ── Tick interval helpers (shared between main ruler and child ruler) ───────── + +export type TimeScale = 'auto' | 'ms' | 's' | 'm' | 'h' | 'd' | 'w'; + +const MIN_TICK_PX = 90; +export const TICK_LEVELS_MS = [ + 1, + 5, + 10, + 50, + 100, + 500, + 1_000, + 5_000, + 15_000, + 30_000, + 60_000, + 5 * 60_000, + 15 * 60_000, + 30 * 60_000, + 3_600_000, + 6 * 3_600_000, + 12 * 3_600_000, + 24 * 3_600_000, + 7 * 24 * 3_600_000, +]; + +const SCALE_TICKS: Record, number[]> = { + ms: [1, 5, 10, 50, 100, 500], + s: [1_000, 5_000, 15_000, 30_000], + m: [60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000], + h: [3_600_000, 6 * 3_600_000, 12 * 3_600_000], + d: [86_400_000], + w: [7 * 86_400_000], +}; + +export function pickTickInterval( + zoom: number, + scale: TimeScale = 'auto', +): number { + if (scale === 'auto') { + for (const ms of TICK_LEVELS_MS) { + if (ms * zoom >= MIN_TICK_PX) return ms; + } + return TICK_LEVELS_MS[TICK_LEVELS_MS.length - 1]; + } + const levels = SCALE_TICKS[scale]; + for (const ms of levels) { + if (ms * zoom >= MIN_TICK_PX) return ms; + } + return levels[levels.length - 1]; +} + +/** The effective unit auto-selects based on the interval. Used by the UI to + * show which unit is active when scale='auto'. */ +export function autoScaleUnit(intervalMs: number): Exclude { + if (intervalMs < 1_000) return 'ms'; + if (intervalMs < 60_000) return 's'; + if (intervalMs < 3_600_000) return 'm'; + if (intervalMs < 86_400_000) return 'h'; + if (intervalMs < 7 * 86_400_000) return 'd'; + return 'w'; +} + +export function formatTickLabel( + offsetMs: number, + intervalMs: number, + scale: TimeScale = 'auto', +): string { + const unit: Exclude = + scale === 'auto' ? autoScaleUnit(intervalMs) : scale; + + switch (unit) { + case 'ms': { + // In auto mode prefer seconds for offsets >= 1s to avoid ugly "18500ms" + if (scale === 'auto' && Math.abs(offsetMs) >= 1_000) { + return `${(offsetMs / 1_000).toFixed(1)}s`; + } + return `${Math.round(offsetMs)}ms`; + } + case 's': + return `${Math.round(offsetMs / 1_000)}s`; + case 'm': + return `${Math.floor(offsetMs / 60_000)}m`; + case 'h': { + const h = Math.floor(offsetMs / 3_600_000); + const m = Math.floor((offsetMs % 3_600_000) / 60_000); + return m ? `${h}h ${m}m` : `${h}h`; + } + case 'd': { + const d = Math.floor(offsetMs / 86_400_000); + const h = Math.floor((offsetMs % 86_400_000) / 3_600_000); + return h ? `${d}d ${h}h` : `${d}d`; + } + case 'w': { + const w = Math.floor(offsetMs / (7 * 86_400_000)); + return `${w}w`; + } + } +} diff --git a/src/lib/components/pixi-timeline/renderer/gutter-culling.test.ts b/src/lib/components/pixi-timeline/renderer/gutter-culling.test.ts new file mode 100644 index 0000000000..2fb6a88abc --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/gutter-culling.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest'; + +import { gatherGutterTracks } from './gutter-culling'; + +const RULER_H = 24; +const TRACK_H = 26; +const ROW_SIZE = 32; // trackH(28) + gap(4) +const SCREEN_H = 950; + +/** scrollY=0 β†’ containerY=RULER_H so track 0 starts at y=RULER_H. */ +const containerYForScroll = (scrollY: number) => RULER_H - scrollY; + +describe('gatherGutterTracks', () => { + it('returns empty arrays when all tracks are visible', () => { + // 10 tracks Γ— 32px = 320px, all inside [24, 950] + const result = gatherGutterTracks( + 10, + containerYForScroll(0), + ROW_SIZE, + TRACK_H, + RULER_H, + SCREEN_H, + 60, + ); + expect(result.aboveTrackIdxs).toHaveLength(0); + expect(result.belowTrackIdxs).toHaveLength(0); + }); + + it('returns empty arrays when there are no tracks', () => { + const result = gatherGutterTracks( + 0, + containerYForScroll(0), + ROW_SIZE, + TRACK_H, + RULER_H, + SCREEN_H, + 60, + ); + expect(result.aboveTrackIdxs).toHaveLength(0); + expect(result.belowTrackIdxs).toHaveLength(0); + }); + + it('puts off-screen-below tracks in belowTrackIdxs at scrollY=0', () => { + // 100 tracks: first few visible, rest below + const result = gatherGutterTracks( + 100, + containerYForScroll(0), + ROW_SIZE, + TRACK_H, + RULER_H, + SCREEN_H, + 60, + ); + expect(result.aboveTrackIdxs).toHaveLength(0); + // Track t is below when containerY + t * ROW_SIZE > SCREEN_H + // RULER_H + t * 32 > 950 β†’ t > 926/32 β‰ˆ 28.9 β†’ t >= 29 + expect(result.belowTrackIdxs.length).toBeGreaterThan(0); + expect(result.belowTrackIdxs.every((t) => t >= 29)).toBe(true); + }); + + it('puts off-screen-above tracks in aboveTrackIdxs when scrolled down', () => { + // Scroll past 50 tracks worth: scrollY = 50 * 32 = 1600 + const scrollY = 50 * ROW_SIZE; + const result = gatherGutterTracks( + 100, + containerYForScroll(scrollY), + ROW_SIZE, + TRACK_H, + RULER_H, + SCREEN_H, + 60, + ); + // Tracks 0..49 are above the viewport + expect(result.aboveTrackIdxs.length).toBeGreaterThan(0); + expect(result.aboveTrackIdxs.every((t) => t < 50)).toBe(true); + }); + + it('caps the number of pins at maxPins per gutter', () => { + // 1000 tracks β€” most will be below at scrollY=0 + const MAX = 30; + const result = gatherGutterTracks( + 1000, + containerYForScroll(0), + ROW_SIZE, + TRACK_H, + RULER_H, + SCREEN_H, + MAX, + ); + expect(result.belowTrackIdxs.length).toBeLessThanOrEqual(MAX); + expect(result.aboveTrackIdxs.length).toBeLessThanOrEqual(MAX); + }); + + it('selects the CLOSEST below-tracks (lowest track indices past viewport)', () => { + const MAX = 5; + // 100 tracks, scrollY=0 β†’ tracks 29-99 are below (71 tracks) + // With MAX=5, should return the 5 closest: tracks 29,30,31,32,33 + const result = gatherGutterTracks( + 100, + containerYForScroll(0), + ROW_SIZE, + TRACK_H, + RULER_H, + SCREEN_H, + MAX, + ); + // First below-track is t=29, so closest 5 are [29,30,31,32,33] + const firstBelow = result.belowTrackIdxs[0]; + expect(result.belowTrackIdxs).toHaveLength(MAX); + for (let i = 0; i < MAX; i++) { + expect(result.belowTrackIdxs[i]).toBe(firstBelow + i); + } + }); + + it('selects the CLOSEST above-tracks (highest track indices before viewport top)', () => { + const MAX = 5; + // Scroll past 50 tracks (scrollY=1600): tracks 0..49 are above + // With MAX=5, should return the 5 closest: [49,48,47,46,45] (closest-first) + const scrollY = 50 * ROW_SIZE; + const result = gatherGutterTracks( + 100, + containerYForScroll(scrollY), + ROW_SIZE, + TRACK_H, + RULER_H, + SCREEN_H, + MAX, + ); + expect(result.aboveTrackIdxs).toHaveLength(MAX); + // Closest-first order: highest t (closest to viewport) first + expect(result.aboveTrackIdxs).toEqual([49, 48, 47, 46, 45]); + }); + + it('returns no above pins and some below pins at the very top (scrollY=0)', () => { + const result = gatherGutterTracks( + 200, + containerYForScroll(0), + ROW_SIZE, + TRACK_H, + RULER_H, + SCREEN_H, + 60, + ); + expect(result.aboveTrackIdxs).toHaveLength(0); + expect(result.belowTrackIdxs.length).toBeGreaterThan(0); + }); + + it('returns only above pins when scrolled all the way to the bottom', () => { + const trackCount = 50; + // scrollY that puts track 49 just inside the bottom: + // containerY + 49*rowSize = bottom approx β†’ scrollY = RULER_H + 49*32 - SCREEN_H + 1 + const maxScrollY = Math.max(0, RULER_H + trackCount * ROW_SIZE - SCREEN_H); + const result = gatherGutterTracks( + trackCount, + containerYForScroll(maxScrollY), + ROW_SIZE, + TRACK_H, + RULER_H, + SCREEN_H, + 60, + ); + expect(result.belowTrackIdxs).toHaveLength(0); + expect(result.aboveTrackIdxs.length).toBeGreaterThan(0); + }); + + it('does not include visible tracks in either gutter', () => { + const trackCount = 100; + const scrollY = 10 * ROW_SIZE; + const result = gatherGutterTracks( + trackCount, + containerYForScroll(scrollY), + ROW_SIZE, + TRACK_H, + RULER_H, + SCREEN_H, + 60, + ); + const containerY = containerYForScroll(scrollY); + const allPins = [...result.aboveTrackIdxs, ...result.belowTrackIdxs]; + for (const t of allPins) { + const screenY = containerY + t * ROW_SIZE; + const isAbove = screenY + TRACK_H < RULER_H; + const isBelow = screenY > SCREEN_H; + expect(isAbove || isBelow).toBe(true); + } + }); + + it('skips empty tracks (loading gap) and returns closest populated tracks', () => { + // Bidirectional layout: desc=0-49, gap=50-149, asc=150-199. + // Viewport is at start of asc section (scrollY = 150 * ROW_SIZE). + // All 150 above tracks: only 0-49 have events (gap 50-149 is empty). + // With maxPins=5 β†’ should return tracks [45,46,47,48,49] (closest 5 populated above). + const scrollY = 150 * ROW_SIZE; + const hasEvents = (t: number) => t < 50 || t >= 150; + const result = gatherGutterTracks( + 200, + containerYForScroll(scrollY), + ROW_SIZE, + TRACK_H, + RULER_H, + SCREEN_H, + 5, + hasEvents, + ); + expect(result.aboveTrackIdxs.every((t) => t < 50)).toBe(true); + expect(result.aboveTrackIdxs).toHaveLength(5); + // Closest-first: highest t values (closest to viewport) come first + expect(result.aboveTrackIdxs).toEqual([49, 48, 47, 46, 45]); + }); + + it('returns empty above array when all above tracks are in the loading gap', () => { + // Tracks 0-99 empty (loading gap), tracks 100+ populated. + // Scrolled to track 100 β†’ above = 0-99, all empty β†’ no pins. + const scrollY = 100 * ROW_SIZE; + const hasEvents = (t: number) => t >= 100; + const result = gatherGutterTracks( + 200, + containerYForScroll(scrollY), + ROW_SIZE, + TRACK_H, + RULER_H, + SCREEN_H, + 60, + hasEvents, + ); + expect(result.aboveTrackIdxs).toHaveLength(0); + }); + + it('skips empty below tracks and selects closest populated below tracks', () => { + // tracks 0-28 visible, 29+ below. Gap = 30-59, asc = 60-99. + // Closest populated below at scrollY=0: track 29 (populated), then 60-63. + const hasEvents = (t: number) => t < 30 || t >= 60; + const result = gatherGutterTracks( + 100, + containerYForScroll(0), + ROW_SIZE, + TRACK_H, + RULER_H, + SCREEN_H, + 5, + hasEvents, + ); + expect(result.belowTrackIdxs.every((t) => hasEvents(t))).toBe(true); + expect(result.belowTrackIdxs).toHaveLength(5); + }); +}); diff --git a/src/lib/components/pixi-timeline/renderer/gutter-culling.ts b/src/lib/components/pixi-timeline/renderer/gutter-culling.ts new file mode 100644 index 0000000000..5af055ba5d --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/gutter-culling.ts @@ -0,0 +1,112 @@ +/** + * Pure, side-effect-free functions for determining which track rows belong in + * the top / bottom gutter. Extracted here so they can be unit-tested without + * any Pixi.js dependency. + */ + +/** + * Determine which track indices should appear as top and bottom gutter pins. + * + * Rules: + * - A track is "above" when its entire row is above the visible area + * (`screenY + effectiveTrackH < topEdge`). + * - A track is "below" when its top edge is below the visible area + * (`screenY > bottomEdge`). + * - Empty tracks (loading gap, not-yet-fetched data) are excluded via the + * optional `hasEvents` predicate so the gutter jumps over the loading gap + * to the nearest populated off-screen tracks. + * - We keep only the `maxPins` tracks closest to the viewport edge: + * β€’ above: highest screenY values (tracks just scrolled off the top) + * β€’ below: lowest screenY values (tracks just about to appear at bottom) + * - This prevents the gutter from flooding with thousands of pins when most + * tracks are off-screen. + * + * @param trackCount Total number of tracks + * @param containerY Y offset of the scroll container (RULER_H - scrollY) + * @param rowSize Height of one row including gap (trackH + gap) + * @param effectiveTrackH Visible height of the event bar inside a row + * @param topEdge Y coordinate of the ruler bottom (start of track area) + * @param bottomEdge Y coordinate of the canvas bottom (end of track area) + * @param maxPins Maximum pins per gutter (cap) + * @param hasEvents Optional predicate β€” return false to skip empty tracks + * @returns Track indices for above and below gutters, sorted closest-to-viewport first. + */ +/** + * @param reversed When true (asc sort order), track indices are rendered in + * reverse Y order: track 0 renders at the bottom, track + * (trackCount-1) at the top. The screenY calculation is + * adjusted accordingly. + */ +export function gatherGutterTracks( + trackCount: number, + containerY: number, + rowSize: number, + effectiveTrackH: number, + topEdge: number, + bottomEdge: number, + maxPins: number, + hasEvents: (t: number) => boolean = () => true, + reversed = false, +): { aboveTrackIdxs: number[]; belowTrackIdxs: number[] } { + if (trackCount === 0) return { aboveTrackIdxs: [], belowTrackIdxs: [] }; + + // O(1) arithmetic: compute the last above and first below track index directly + // from the scroll position, without iterating all tracks. + // + // In normal (desc) order, track t is at screenY = containerY + t * rowSize. + // Above: screenY + effectiveTrackH < topEdge + // β†’ t < (topEdge - containerY - effectiveTrackH) / rowSize + // Below: screenY > bottomEdge + // β†’ t > (bottomEdge - containerY) / rowSize + // + // In reversed (asc) order, track t has localIdx = trackCount - 1 - t, so + // screenY = containerY + (trackCount - 1 - t) * rowSize; the same thresholds + // apply to localIdx, which we then convert back to t. + const aboveThreshold = (topEdge - containerY - effectiveTrackH) / rowSize; + const belowThreshold = (bottomEdge - containerY) / rowSize; + + const above: number[] = []; + const below: number[] = []; + const cap = maxPins === Infinity ? trackCount : maxPins; + + if (!reversed) { + // last above track (closest to viewport): floor(aboveThreshold) + // Walk closest-to-viewport first β†’ descending t. + const lastAboveT = Math.min(trackCount - 1, Math.floor(aboveThreshold)); + for (let t = lastAboveT; t >= 0 && above.length < cap; t--) { + if (hasEvents(t)) above.push(t); + } + + // first below track (closest to viewport): floor(belowThreshold) + 1 + const firstBelowT = Math.max(0, Math.floor(belowThreshold) + 1); + for (let t = firstBelowT; t < trackCount && below.length < cap; t++) { + if (hasEvents(t)) below.push(t); + } + } else { + // Reversed: track t has localIdx = trackCount - 1 - t. + // Above localIdx: localIdx <= floor(aboveThreshold) + // β†’ t = trackCount - 1 - localIdx >= trackCount - 1 - floor(aboveThreshold) + // Closest above: localIdx = floor(aboveThreshold), t = trackCount - 1 - floor(aboveThreshold) + // Walk outward: ascending t (descending localIdx). + const lastAboveLocalIdx = Math.floor(aboveThreshold); + const firstAboveT = Math.max(0, trackCount - 1 - lastAboveLocalIdx); + for (let t = firstAboveT; t < trackCount && above.length < cap; t++) { + if (hasEvents(t)) above.push(t); + } + + // Below localIdx: localIdx >= floor(belowThreshold) + 1 + // β†’ t = trackCount - 1 - localIdx <= trackCount - 2 - floor(belowThreshold) + // Closest below: localIdx = floor(belowThreshold) + 1, t = trackCount - 2 - floor(belowThreshold) + // Walk outward: descending t (ascending localIdx). + const firstBelowLocalIdx = Math.floor(belowThreshold) + 1; + const lastBelowT = Math.min( + trackCount - 1, + trackCount - 1 - firstBelowLocalIdx, + ); + for (let t = lastBelowT; t >= 0 && below.length < cap; t--) { + if (hasEvents(t)) below.push(t); + } + } + + return { aboveTrackIdxs: above, belowTrackIdxs: below }; +} diff --git a/src/lib/components/pixi-timeline/renderer/gutter-layout.test.ts b/src/lib/components/pixi-timeline/renderer/gutter-layout.test.ts new file mode 100644 index 0000000000..bfd205dd94 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/gutter-layout.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; + +import { gutterIconLayout } from './gutter-layout'; + +/** + * Sprite anchor is (0, 0.5): x is the left edge, y is the vertical center. + * These helpers convert back to visual bounds for readable assertions. + */ +function visualBounds( + x: number, + y: number, + size: number, +): { top: number; bottom: number; left: number; right: number } { + return { + top: y - size / 2, + bottom: y + size / 2, + left: x, + right: x + size, + }; +} + +describe('gutterIconLayout β€” centering', () => { + it('icon is horizontally centered within a square pin', () => { + const { x, size } = gutterIconLayout(0, 0, 18, 18); + const leftMargin = x; + const rightMargin = 18 - (x + size); + expect(leftMargin).toBeCloseTo(rightMargin, 5); + }); + + it('icon is vertically centered within a square pin', () => { + const { y, size } = gutterIconLayout(0, 0, 18, 18); + const bounds = visualBounds(0, y, size); + const topMargin = bounds.top - 0; // relative to py=0 + const bottomMargin = 18 - bounds.bottom; // relative to py+pinH + expect(topMargin).toBeCloseTo(bottomMargin, 5); + }); + + it('icon is horizontally centered within a wide pin', () => { + const pw = 40; + const pinH = 18; + const { x, size } = gutterIconLayout(0, 0, pw, pinH); + const leftMargin = x; + const rightMargin = pw - (x + size); + expect(leftMargin).toBeCloseTo(rightMargin, 5); + }); + + it('icon is vertically centered within a wide pin', () => { + const pinH = 18; + const { y, size } = gutterIconLayout(0, 0, 40, pinH); + const bounds = visualBounds(0, y, size); + expect(bounds.top).toBeCloseTo(bounds.bottom - size, 5); + const topMargin = bounds.top; + const bottomMargin = pinH - bounds.bottom; + expect(topMargin).toBeCloseTo(bottomMargin, 5); + }); + + it('icon stays within pin bounds for a square pin', () => { + const px = 10; + const py = 20; + const pw = 18; + const pinH = 18; + const { x, y, size } = gutterIconLayout(px, py, pw, pinH); + const b = visualBounds(x, y, size); + expect(b.left).toBeGreaterThanOrEqual(px); + expect(b.right).toBeLessThanOrEqual(px + pw); + expect(b.top).toBeGreaterThanOrEqual(py); + expect(b.bottom).toBeLessThanOrEqual(py + pinH); + }); + + it('icon stays within pin bounds for a wide pin', () => { + const px = 5; + const py = 30; + const pw = 60; + const pinH = 16; + const { x, y, size } = gutterIconLayout(px, py, pw, pinH); + const b = visualBounds(x, y, size); + expect(b.left).toBeGreaterThanOrEqual(px); + expect(b.right).toBeLessThanOrEqual(px + pw); + expect(b.top).toBeGreaterThanOrEqual(py); + expect(b.bottom).toBeLessThanOrEqual(py + pinH); + }); + + it('non-zero origin: centering is relative to the pin, not the canvas origin', () => { + const layout0 = gutterIconLayout(0, 0, 18, 18); + const layout1 = gutterIconLayout(100, 200, 18, 18); + expect(layout1.x - 100).toBeCloseTo(layout0.x, 5); + expect(layout1.y - 200).toBeCloseTo(layout0.y, 5); + expect(layout1.size).toBe(layout0.size); + }); +}); + +describe('gutterIconLayout β€” icon sizing', () => { + it('square pin: icon size is pinH - 2', () => { + const pinH = 18; + const { size } = gutterIconLayout(0, 0, pinH, pinH); + expect(size).toBe(pinH - 2); + }); + + it('wide pin: icon size is still capped at pinH - 2', () => { + const pinH = 18; + const { size } = gutterIconLayout(0, 0, 60, pinH); + expect(size).toBe(pinH - 2); + }); + + it('narrow pin: icon size is capped at pw - 2', () => { + const pw = 10; + const pinH = 18; + const { size } = gutterIconLayout(0, 0, pw, pinH); + expect(size).toBe(pw - 2); + }); + + it('size is always positive for reasonable pin dimensions', () => { + const cases: [number, number][] = [ + [12, 12], + [14, 14], + [16, 16], + [18, 18], + [18, 24], + [18, 40], + [12, 40], + ]; + for (const [pw, pinH] of cases) { + const { size } = gutterIconLayout(0, 0, pw, pinH); + expect(size).toBeGreaterThan(0); + } + }); + + it('icon is always square (width === height)', () => { + const { size } = gutterIconLayout(0, 0, 18, 18); + expect(size).toBe(size); // trivially true; enforced by single `size` return value + // Caller sets width = height = size, so squareness is guaranteed by design + expect(typeof size).toBe('number'); + }); +}); + +describe('gutterIconLayout β€” anchor contract', () => { + it('y is the vertical center of the pin (anchor.y = 0.5)', () => { + const py = 50; + const pinH = 18; + const { y } = gutterIconLayout(0, py, 18, pinH); + expect(y).toBe(py + pinH / 2); + }); + + it('x is the left edge of the icon (anchor.x = 0)', () => { + const px = 20; + const pw = 18; + const pinH = 18; + const { x, size } = gutterIconLayout(px, 0, pw, pinH); + // left margin equals right margin + expect(x - px).toBeCloseTo(px + pw - (x + size), 5); + }); + + it('changing pinH shifts y proportionally', () => { + const py = 0; + expect(gutterIconLayout(0, py, 20, 12).y).toBe(6); + expect(gutterIconLayout(0, py, 20, 16).y).toBe(8); + expect(gutterIconLayout(0, py, 20, 18).y).toBe(9); + }); +}); diff --git a/src/lib/components/pixi-timeline/renderer/gutter-layout.ts b/src/lib/components/pixi-timeline/renderer/gutter-layout.ts new file mode 100644 index 0000000000..d906558424 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/gutter-layout.ts @@ -0,0 +1,36 @@ +/** + * Pure geometry for gutter pin icon placement. + * + * Sprite anchor is always (0, 0.5) β€” left edge, vertical center. + * All returned values are in Pixi canvas-space pixels. + */ +export type GutterIconLayout = { + /** Left edge of the sprite (anchor.x = 0) */ + x: number; + /** Vertical center of the sprite (anchor.y = 0.5) */ + y: number; + /** Square side length for both width and height */ + size: number; +}; + +/** + * Compute the position and size for an icon centered inside a gutter pin bar. + * + * @param px Left edge of the pin bar + * @param py Top edge of the pin bar + * @param pw Width of the pin bar + * @param pinH Height of the pin bar + */ +export function gutterIconLayout( + px: number, + py: number, + pw: number, + pinH: number, +): GutterIconLayout { + const size = Math.min(pinH - 2, pw - 2); + return { + x: px + (pw - size) / 2, + y: py + pinH / 2, + size, + }; +} diff --git a/src/lib/components/pixi-timeline/renderer/gutter-pin-bins.test.ts b/src/lib/components/pixi-timeline/renderer/gutter-pin-bins.test.ts new file mode 100644 index 0000000000..f0b3d07391 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/gutter-pin-bins.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, it } from 'vitest'; + +import { binGutterPins } from './gutter-pin-bins'; + +const SCREEN_W = 1296; +const PIN_MARGIN = 4; +const N_BINS = 40; +const USABLE = SCREEN_W - 2 * PIN_MARGIN; // 1288 + +// Helpers to make events +const ev = (startMs: number, endMs = startMs + 1) => ({ startMs, endMs }); + +// Threshold: "left quarter" is x < SCREEN_W * 0.25 = 324 +// "right quarter" is x > SCREEN_W * 0.75 = 972 +const LEFT_QUARTER = SCREEN_W * 0.25; +const RIGHT_QUARTER = SCREEN_W * 0.75; + +describe('binGutterPins β€” desc mode invariant', () => { + const VIEW_START = 0; + const VIEW_END = 414_000; // 6.9m full timeline (fit-all) + + it('returns empty array for no events', () => { + expect( + binGutterPins([], VIEW_START, VIEW_END, SCREEN_W, PIN_MARGIN, N_BINS), + ).toEqual([]); + }); + + it('desc above-tracks: events near END of timeline cluster in right bins', () => { + // Newest desc events completed near the end of the workflow + const events = Array.from({ length: 60 }, (_, i) => + ev(403_000 + i * 100, 403_001 + i * 100), + ); + const pins = binGutterPins( + events, + VIEW_START, + VIEW_END, + SCREEN_W, + PIN_MARGIN, + N_BINS, + ); + expect(pins.length).toBeGreaterThan(0); + // All pins should be in the right portion of the canvas + for (const p of pins) { + expect(p.px).toBeGreaterThan(RIGHT_QUARTER); + } + }); + + it('asc below-tracks: events near START of timeline cluster in left bins', () => { + // Oldest asc events happened at the beginning of the workflow + const events = Array.from({ length: 60 }, (_, i) => + ev(1_500 + i * 100, 1_501 + i * 100), + ); + const pins = binGutterPins( + events, + VIEW_START, + VIEW_END, + SCREEN_W, + PIN_MARGIN, + N_BINS, + ); + expect(pins.length).toBeGreaterThan(0); + // All pins should be in the left portion of the canvas + for (const p of pins) { + expect(p.px).toBeLessThan(LEFT_QUARTER); + } + }); + + it('top-left is empty when desc above-tracks are at end of timeline', () => { + const events = Array.from({ length: 60 }, () => ev(412_000, 412_001)); + const pins = binGutterPins( + events, + VIEW_START, + VIEW_END, + SCREEN_W, + PIN_MARGIN, + N_BINS, + ); + // No pin should land in the left quarter + const leftPins = pins.filter((p) => p.px < LEFT_QUARTER); + expect(leftPins).toHaveLength(0); + }); + + it('bottom-left has events when asc below-tracks are at start of timeline', () => { + const events = Array.from({ length: 60 }, () => ev(1_500, 1_501)); + const pins = binGutterPins( + events, + VIEW_START, + VIEW_END, + SCREEN_W, + PIN_MARGIN, + N_BINS, + ); + const leftPins = pins.filter((p) => p.px < LEFT_QUARTER); + expect(leftPins.length).toBeGreaterThan(0); + }); +}); + +describe('binGutterPins β€” asc mode invariant (reversed sort)', () => { + const VIEW_START = 0; + const VIEW_END = 414_000; + + it('in asc mode, desc below-tracks (now below in flipped view) are at right', () => { + // In asc mode (flipped), desc tracks render at the BOTTOM. + // Below-tracks = high actual index = asc section but rendered above desc section. + // The desc events (rightmost in time) appear as below-gutter entries. + const events = Array.from({ length: 60 }, () => ev(412_000, 412_001)); + const pins = binGutterPins( + events, + VIEW_START, + VIEW_END, + SCREEN_W, + PIN_MARGIN, + N_BINS, + ); + // These are desc events β†’ should be on the RIGHT + for (const p of pins) { + expect(p.px).toBeGreaterThan(RIGHT_QUARTER); + } + }); + + it('in asc mode, top-right is empty when asc above-tracks are at start of timeline', () => { + // In asc mode, the above-tracks = asc section (oldest events, leftmost in time) + const events = Array.from({ length: 60 }, () => ev(1_500, 1_501)); + const pins = binGutterPins( + events, + VIEW_START, + VIEW_END, + SCREEN_W, + PIN_MARGIN, + N_BINS, + ); + const rightPins = pins.filter((p) => p.px > RIGHT_QUARTER); + expect(rightPins).toHaveLength(0); + }); +}); + +describe('binGutterPins β€” endMs positioning', () => { + it('long-running above event lands at endMs, to the right of the visible event', () => { + // Activity scheduled at 410s, completed at 424s (14s long). + // Topmost visible event starts at 423s. + // The gutter pin for the long-running event must appear at >= 423s so it + // is to the right of (or aligned with) the visible event. + const VP_START = 410_000; + const VP_END = 465_000; + + const longRunning = { startMs: 410_000, endMs: 424_000 }; + const topVisible = { startMs: 423_000, endMs: 423_001 }; + + const abovePins = binGutterPins( + [longRunning], + VP_START, + VP_END, + SCREEN_W, + PIN_MARGIN, + N_BINS, + ); + const visiblePins = binGutterPins( + [topVisible], + VP_START, + VP_END, + SCREEN_W, + PIN_MARGIN, + N_BINS, + ); + + expect(abovePins.length).toBe(1); + expect(visiblePins.length).toBe(1); + expect(abovePins[0].px).toBeGreaterThanOrEqual(visiblePins[0].px); + }); + + it('uses endMs not centerMs for bin assignment', () => { + // startMs=0, endMs=400_000 β†’ centerMs would be 200_000 (50% β†’ bin 20) + // but endMs=400_000 β†’ 96.6% β†’ bin 38 + const ev1 = { startMs: 0, endMs: 400_000 }; + const pins = binGutterPins([ev1], 0, 414_000, SCREEN_W, PIN_MARGIN, N_BINS); + expect(pins.length).toBe(1); + // With endMs: bin = floor(400000/414000 * 40) = 38 + // px = PIN_MARGIN + 38 * (USABLE/N_BINS) + const expectedBinPx = PIN_MARGIN + 38 * (USABLE / N_BINS); + expect(pins[0].px).toBeCloseTo(expectedBinPx); + // Must be in the right half, not the middle + expect(pins[0].px).toBeGreaterThan(SCREEN_W * 0.5); + }); +}); + +describe('binGutterPins β€” binning behaviour', () => { + it('produces at most nBins pins', () => { + const events = Array.from({ length: 200 }, (_, i) => + ev(i * 2000, i * 2000 + 1), + ); + const pins = binGutterPins( + events, + 0, + 414_000, + SCREEN_W, + PIN_MARGIN, + N_BINS, + ); + expect(pins.length).toBeLessThanOrEqual(N_BINS); + }); + + it('keeps the longest-duration event per bin', () => { + // Two events with the same endMs (same bin) but different durations. + // The longer-running one should win. + const short = { startMs: 10_000, endMs: 10_500 }; // 500ms + const long = { startMs: 0, endMs: 10_500 }; // 10500ms (same endMs β†’ same bin) + const pins = binGutterPins( + [short, long], + 0, + 414_000, + SCREEN_W, + PIN_MARGIN, + N_BINS, + ); + expect(pins.length).toBe(1); + expect(pins[0].event).toBe(long); + }); + + it('clamps events before the viewport to left-edge bin', () => { + const events = [ev(-100_000, -99_999)]; // way before viewport + const pins = binGutterPins( + events, + 0, + 414_000, + SCREEN_W, + PIN_MARGIN, + N_BINS, + ); + expect(pins.length).toBe(1); + expect(pins[0].px).toBe(PIN_MARGIN); // first bin = leftmost + }); + + it('clamps events after the viewport to right-edge bin', () => { + const events = [ev(900_000, 900_001)]; // way after viewport + const pins = binGutterPins( + events, + 0, + 414_000, + SCREEN_W, + PIN_MARGIN, + N_BINS, + ); + expect(pins.length).toBe(1); + const lastBinPx = PIN_MARGIN + (N_BINS - 1) * (USABLE / N_BINS); + expect(pins[0].px).toBeCloseTo(lastBinPx); + }); + + it('pin widths are positive and fit within the canvas', () => { + const events = Array.from({ length: 60 }, (_, i) => ev(i * 10_000)); + const pins = binGutterPins( + events, + 0, + 414_000, + SCREEN_W, + PIN_MARGIN, + N_BINS, + ); + for (const p of pins) { + expect(p.pw).toBeGreaterThan(0); + expect(p.px + p.pw).toBeLessThanOrEqual(SCREEN_W); + } + }); +}); diff --git a/src/lib/components/pixi-timeline/renderer/gutter-pin-bins.ts b/src/lib/components/pixi-timeline/renderer/gutter-pin-bins.ts new file mode 100644 index 0000000000..1f60d3c191 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/gutter-pin-bins.ts @@ -0,0 +1,81 @@ +/** + * Pure, testable gutter pin binning logic. + * + * Rather than one pin per off-screen track (which causes 60 pins to pile up at + * a single X position), we bin all off-screen events into N horizontal slots + * across the canvas. One pin (the longest-duration event) is shown per + * populated slot. + * + * X-position uses endMs (when the event last ran), NOT center. This ensures: + * - Long-running activities above the viewport (started early, ended late) + * appear to the RIGHT of where they started β€” matching where they were + * "most recently active" in time. + * - Desc mode top gutter: above events (newer, later endMs) β†’ RIGHT side. + * - Desc mode bottom gutter: below events (older, earlier endMs) β†’ LEFT side. + * - Asc mode invariant flips symmetrically. + */ + +export interface GutterBinInput { + startMs: number; + endMs: number; +} + +export interface BinnedGutterPin { + px: number; + pw: number; + binIdx: number; + event: T; +} + +/** + * Assign each event to a time-based bin across the viewport width. + * For events outside the viewport, clamp to the left or right edge bin. + * Returns at most `nBins` pins (one per populated bin). + * + * @param events Off-screen track events to display (one per track, pre-sorted by time) + * @param viewStartMs Left edge of the current viewport in relative ms + * @param viewEndMs Right edge of the current viewport in relative ms + * @param screenW Canvas width in logical pixels + * @param pinMargin Left/right margin in pixels + * @param nBins Number of horizontal bins (determines resolution) + */ +export function binGutterPins( + events: T[], + viewStartMs: number, + viewEndMs: number, + screenW: number, + pinMargin: number, + nBins: number, +): BinnedGutterPin[] { + if (events.length === 0 || nBins <= 0) return []; + + const usable = screenW - 2 * pinMargin; + const binW = usable / nBins; + const viewSpanMs = viewEndMs - viewStartMs; + + const binBest: (T | null)[] = new Array(nBins).fill(null); + + for (const ev of events) { + // Use endMs so long-running activities (started early, ended late) are + // placed at their most-recent-activity time, keeping above-viewport events + // on the RIGHT and below-viewport events on the LEFT. + const clampedMs = Math.max(viewStartMs, Math.min(viewEndMs, ev.endMs)); + const t = viewSpanMs > 0 ? (clampedMs - viewStartMs) / viewSpanMs : 0; + const binIdx = Math.max(0, Math.min(nBins - 1, Math.floor(t * nBins))); + + const cur = binBest[binIdx]; + if (!cur || ev.endMs - ev.startMs > cur.endMs - cur.startMs) { + binBest[binIdx] = ev; + } + } + + const result: BinnedGutterPin[] = []; + for (let i = 0; i < nBins; i++) { + const ev = binBest[i]; + if (!ev) continue; + const px = pinMargin + i * binW; + const pw = Math.max(2, binW - 1); + result.push({ px, pw, binIdx: i, event: ev }); + } + return result; +} diff --git a/src/lib/components/pixi-timeline/renderer/gutter-pin-layout.test.ts b/src/lib/components/pixi-timeline/renderer/gutter-pin-layout.test.ts new file mode 100644 index 0000000000..b704919729 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/gutter-pin-layout.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; + +import { layoutGutterPins } from './gutter-pin-layout'; + +const BASE = { + viewStartMs: 0, + viewEndMs: 415_000, + zoom: 0.002, + screenW: 800, + pinMargin: 4, + minPinW: 14, + maxRows: 2, +}; + +describe('layoutGutterPins', () => { + it('returns empty array when events list is empty', () => { + expect(layoutGutterPins([], BASE)).toHaveLength(0); + }); + + it('places a mid-viewport event at the correct x position', () => { + // startMs=200_000 β†’ x = 200_000 * 0.002 = 400px (center of 800px screen) + const events = [{ startMs: 200_000, endMs: 201_000 }]; + const result = layoutGutterPins(events, BASE); + expect(result).toHaveLength(1); + expect(result[0].px).toBeCloseTo(400, 0); + }); + + it('places a RIGHT-side event (newest desc track) on the RIGHT', () => { + // Event near end of timeline: startMs=380_000 β†’ x = 380_000 * 0.002 = 760px + const events = [{ startMs: 380_000, endMs: 415_000 }]; + const result = layoutGutterPins(events, BASE); + expect(result).toHaveLength(1); + expect(result[0].px).toBeGreaterThan(600); // definitively right-side + }); + + it('places a LEFT-side event (oldest asc track) on the LEFT', () => { + // Event at start of timeline: startMs=1_000 β†’ x = 1_000 * 0.002 = 2px β†’ clamped to pinMargin=4 + const events = [{ startMs: 1_000, endMs: 2_000 }]; + const result = layoutGutterPins(events, BASE); + expect(result).toHaveLength(1); + expect(result[0].px).toBeLessThan(30); // definitively left-side + }); + + it('clamps events that end before viewStartMs to the left edge', () => { + // Entirely before viewport β†’ should snap to pinMargin (left edge), not excluded. + const events = [{ startMs: -5_000, endMs: -1_000 }]; + const result = layoutGutterPins(events, BASE); + expect(result).toHaveLength(1); + expect(result[0].px).toBe(BASE.pinMargin); + expect(result[0].pw).toBeGreaterThanOrEqual(BASE.minPinW); + }); + + it('clamps events that start after viewEndMs to the right edge', () => { + // Entirely after viewport β†’ should snap to near screenW-pinMargin, not excluded. + const events = [{ startMs: 500_000, endMs: 600_000 }]; + const result = layoutGutterPins(events, BASE); + expect(result).toHaveLength(1); + expect(result[0].px).toBeLessThanOrEqual(BASE.screenW - BASE.pinMargin); + }); + + it('clamps px to pinMargin for events starting before the viewport', () => { + const events = [{ startMs: -100_000, endMs: 5_000 }]; + const result = layoutGutterPins(events, BASE); + expect(result).toHaveLength(1); + expect(result[0].px).toBe(BASE.pinMargin); + }); + + it('enforces minPinW', () => { + // Very short event: 1ms at zoom 0.002 β†’ rawEnd - rawPx = 0.002px, far below minPinW + const events = [{ startMs: 100_000, endMs: 100_001 }]; + const result = layoutGutterPins(events, BASE); + expect(result).toHaveLength(1); + expect(result[0].pw).toBeGreaterThanOrEqual(BASE.minPinW); + }); + + it('packs two overlapping events into separate rows', () => { + // Two events with the same start time β†’ can't fit in the same row + const events = [ + { startMs: 100_000, endMs: 200_000 }, + { startMs: 100_000, endMs: 200_000 }, + ]; + const result = layoutGutterPins(events, BASE); + expect(result).toHaveLength(2); + expect(result[0].row).toBe(0); + expect(result[1].row).toBe(1); + }); + + it('skips a third event when all rows are full at that x position', () => { + // Three events at the same time, maxRows=2 β†’ third is dropped + const events = [ + { startMs: 100_000, endMs: 200_000 }, + { startMs: 100_000, endMs: 200_000 }, + { startMs: 100_000, endMs: 200_000 }, + ]; + const result = layoutGutterPins(events, BASE); + expect(result).toHaveLength(2); + }); + + it('allows a third event if it fits at a later x position', () => { + // Row 0 ends at x~400 after event A, row 1 ends at x~400 after event B. + // Event C starts at x~600 β†’ fits in row 0. + const events = [ + { startMs: 100_000, endMs: 200_000 }, // placed in row 0 + { startMs: 100_000, endMs: 200_000 }, // placed in row 1 + { startMs: 300_000, endMs: 320_000 }, // fits in row 0 after its pin ends + ]; + const result = layoutGutterPins(events, BASE); + expect(result).toHaveLength(3); + expect(result[2].row).toBe(0); + }); + + it('top-gutter desc events within the viewport appear on the RIGHT side', () => { + // Desc section = newest events, all clustered near end of timeline (rightmost) + const descEvents = [ + { startMs: 390_000, endMs: 415_000 }, + { startMs: 400_000, endMs: 415_000 }, + { startMs: 405_000, endMs: 415_000 }, + ]; + const result = layoutGutterPins(descEvents, BASE); + // All should be on the RIGHT half of the screen (px > 400) + for (const r of result) { + expect(r.px).toBeGreaterThan(400); + } + }); + + it('bottom-gutter asc events within the viewport appear on the LEFT side', () => { + // Asc section = oldest events, all clustered near start of timeline (leftmost) + const ascEvents = [ + { startMs: 0, endMs: 5_000 }, + { startMs: 1_000, endMs: 6_000 }, + { startMs: 2_000, endMs: 7_000 }, + ]; + const result = layoutGutterPins(ascEvents, BASE); + // All should be on the LEFT half of the screen (px < 400) + for (const r of result) { + expect(r.px).toBeLessThan(400); + } + }); + + it('top-gutter desc events before the viewport clamp to left edge (showing "events to the left")', () => { + // When user pans right past the desc section events, they appear at left edge. + const viewPannedRight = { + ...BASE, + viewStartMs: 420_000, + viewEndMs: 835_000, + }; + const descEvents = [ + { startMs: 390_000, endMs: 415_000 }, // before viewport + { startMs: 400_000, endMs: 415_000 }, + ]; + const result = layoutGutterPins(descEvents, viewPannedRight); + // All should clamp to left edge + for (const r of result) { + expect(r.px).toBe(viewPannedRight.pinMargin); + } + }); +}); diff --git a/src/lib/components/pixi-timeline/renderer/gutter-pin-layout.ts b/src/lib/components/pixi-timeline/renderer/gutter-pin-layout.ts new file mode 100644 index 0000000000..8cbbdc4e39 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/gutter-pin-layout.ts @@ -0,0 +1,103 @@ +/** + * Pure, side-effect-free layout for top/bottom gutter pins. + * + * Each pin's horizontal position reflects the event's actual position within + * the current viewport. Events that fall outside the visible time window are + * clamped to the left or right edge so the user always sees "there are events + * here, and they're to the left/right of your current view." + * + * In a bidirectional (desc-top / asc-bottom) layout this means: + * - Top gutter (newest/desc tracks): pins cluster on the RIGHT. + * - Bottom gutter (oldest/asc tracks): pins cluster on the LEFT. + * The top-left and bottom-right gutter areas will therefore be empty β€” which + * is the correct invariant for this layout. + * + * Row-packing (up to maxRows) handles overlap without dropping events. + */ + +export interface GutterPinInput { + startMs: number; + endMs: number; +} + +export interface PlacedGutterPin { + event: T; + row: number; + px: number; + pw: number; +} + +export interface GutterPinLayoutParams { + viewStartMs: number; + viewEndMs: number; + zoom: number; + screenW: number; + pinMargin: number; + minPinW: number; + maxRows: number; +} + +/** + * Compute the px/pw/row placement for a list of gutter pin events. + * + * Every event produces a pin β€” events before the viewport go to the left edge, + * events after the viewport go to the right edge, and events within the + * viewport go to their actual time position. + * + * Row-packing: each event is placed in the first row where its pin doesn't + * overlap the previous pin. Excess events (all rows full at that x) are + * skipped. + * + * @returns One entry per placed event (in the same order as `events`). + */ +export function layoutGutterPins( + events: T[], + params: GutterPinLayoutParams, +): PlacedGutterPin[] { + const { viewStartMs, viewEndMs, zoom, screenW, pinMargin, minPinW, maxRows } = + params; + + const rowEndX: number[] = []; + const placed: PlacedGutterPin[] = []; + + for (const ev of events) { + // Clamp start/end to viewport so out-of-range events snap to the edges. + const msStart = Math.max(ev.startMs, viewStartMs); + const msEnd = Math.min(ev.endMs, viewEndMs); + + // Raw pixel coordinates relative to the viewport left edge. + const rawPx = (msStart - viewStartMs) * zoom; + // For events entirely before the viewport msStart == msEnd == viewStartMs β†’ rawEnd == 0. + // For events entirely after the viewport msStart == msEnd == viewEndMs β†’ rawEnd == screenW. + const rawEnd = (msEnd - viewStartMs) * zoom; + + const px = Math.max( + pinMargin, + Math.min(screenW - pinMargin - minPinW, rawPx), + ); + const pw = Math.max( + minPinW, + Math.min(screenW - pinMargin - px, Math.max(0, rawEnd - rawPx)), + ); + + let row = -1; + for (let r = 0; r < rowEndX.length; r++) { + if (px >= rowEndX[r] + 1) { + row = r; + break; + } + } + if (row === -1) { + if (rowEndX.length < maxRows) { + row = rowEndX.length; + rowEndX.push(0); + } else { + continue; + } + } + rowEndX[row] = px + pw; + placed.push({ event: ev, row, px, pw }); + } + + return placed; +} diff --git a/src/lib/components/pixi-timeline/renderer/icon-svgs.test.ts b/src/lib/components/pixi-timeline/renderer/icon-svgs.test.ts new file mode 100644 index 0000000000..2cb648f6d2 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/icon-svgs.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildIconSvg, + getEventIconSvg, + PIXI_TYPE_TO_ICON, + type PixiIconName, +} from './icon-svgs'; + +describe('PIXI_TYPE_TO_ICON mapping', () => { + const expectedMappings: [string, PixiIconName][] = [ + ['GROUP_ACTIVITY', 'activity'], + ['GROUP_CHILD_WORKFLOW', 'relationship'], + ['GROUP_TIMER', 'retention'], + ['GROUP_WORKFLOW_TASK', 'terminal'], + ['EVENT_TYPE_WORKFLOW_EXECUTION_STARTED', 'workflow'], + ['EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED', 'workflow'], + ['EVENT_TYPE_WORKFLOW_EXECUTION_FAILED', 'workflow'], + ['EVENT_TYPE_WORKFLOW_EXECUTION_CANCELED', 'workflow'], + ['EVENT_TYPE_WORKFLOW_EXECUTION_TIMED_OUT', 'workflow'], + ['EVENT_TYPE_WORKFLOW_EXECUTION_TERMINATED', 'workflow'], + ['EVENT_TYPE_WORKFLOW_EXECUTION_CONTINUED_AS_NEW', 'workflow'], + ['EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED', 'signal'], + ['EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED', 'update'], + ['EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED', 'update'], + ['EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_REQUESTED', 'update'], + ['EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_REJECTED', 'update'], + ['EVENT_TYPE_NEXUS_OPERATION_SCHEDULED', 'nexus'], + ['EVENT_TYPE_NEXUS_OPERATION_STARTED', 'nexus'], + ['EVENT_TYPE_NEXUS_OPERATION_COMPLETED', 'nexus'], + ['EVENT_TYPE_NEXUS_OPERATION_FAILED', 'nexus'], + ['EVENT_TYPE_NEXUS_OPERATION_CANCELED', 'nexus'], + ['EVENT_TYPE_NEXUS_OPERATION_TIMED_OUT', 'nexus'], + ]; + + it.each(expectedMappings)('maps %s β†’ %s', (eventType, expectedIcon) => { + expect(PIXI_TYPE_TO_ICON[eventType]).toBe(expectedIcon); + }); + + it('returns undefined for unmapped event types', () => { + expect(PIXI_TYPE_TO_ICON['EVENT_TYPE_MARKER_RECORDED']).toBeUndefined(); + expect(PIXI_TYPE_TO_ICON['UNKNOWN_TYPE']).toBeUndefined(); + expect(PIXI_TYPE_TO_ICON['']).toBeUndefined(); + }); +}); + +describe('buildIconSvg', () => { + const allIconNames: PixiIconName[] = [ + 'activity', + 'feather', + 'nexus', + 'relationship', + 'retention', + 'signal', + 'terminal', + 'update', + 'workflow', + ]; + + it.each(allIconNames)('produces valid SVG wrapper for %s', (name) => { + const svg = buildIconSvg(name); + expect(svg).toMatch(/^$/); + expect(svg).toContain('viewBox="0 0 24 24"'); + }); + + it('uses currentColor fill for CSS color inheritance', () => { + for (const name of allIconNames) { + const svg = buildIconSvg(name); + expect(svg).toContain('fill="currentColor"'); + expect(svg).not.toContain('fill="#ffffff"'); + expect(svg).not.toContain('fill="#000000"'); + } + }); + + it('signal icon includes both a path and a circle element', () => { + const svg = buildIconSvg('signal'); + expect(svg).toContain(' { + const iconsWithoutCircles: PixiIconName[] = [ + 'activity', + 'feather', + 'nexus', + 'relationship', + 'retention', + 'terminal', + 'update', + 'workflow', + ]; + for (const name of iconsWithoutCircles) { + const svg = buildIconSvg(name); + expect(svg).not.toContain(' { + it('returns non-null SVG for all mapped event types', () => { + const mappedTypes = Object.keys(PIXI_TYPE_TO_ICON); + for (const eventType of mappedTypes) { + const svg = getEventIconSvg(eventType); + expect(svg).not.toBeNull(); + expect(svg).toMatch(/^ { + expect(getEventIconSvg('EVENT_TYPE_MARKER_RECORDED')).toBeNull(); + expect(getEventIconSvg('GROUP_WORKFLOW_EXECUTION')).toBeNull(); + expect(getEventIconSvg('')).toBeNull(); + expect(getEventIconSvg('UNKNOWN')).toBeNull(); + }); + + it('GROUP_ACTIVITY returns the activity diamond SVG', () => { + const svg = getEventIconSvg('GROUP_ACTIVITY'); + expect(svg).not.toBeNull(); + // Activity icon path data starts with the diamond shape + expect(svg).toContain('M10.4543'); + }); + + it('GROUP_TIMER returns the retention (clock) SVG', () => { + const svg = getEventIconSvg('GROUP_TIMER'); + expect(svg).not.toBeNull(); + expect(svg).toContain('M8.25 0H15.75'); + }); + + it('GROUP_CHILD_WORKFLOW returns the relationship (tree) SVG', () => { + const svg = getEventIconSvg('GROUP_CHILD_WORKFLOW'); + expect(svg).not.toBeNull(); + expect(svg).toContain('M8.55523 0H15.4441'); + }); + + it('workflow execution events all return the same workflow SVG', () => { + const workflowTypes = [ + 'EVENT_TYPE_WORKFLOW_EXECUTION_STARTED', + 'EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED', + 'EVENT_TYPE_WORKFLOW_EXECUTION_FAILED', + 'EVENT_TYPE_WORKFLOW_EXECUTION_CANCELED', + 'EVENT_TYPE_WORKFLOW_EXECUTION_TIMED_OUT', + 'EVENT_TYPE_WORKFLOW_EXECUTION_TERMINATED', + 'EVENT_TYPE_WORKFLOW_EXECUTION_CONTINUED_AS_NEW', + ]; + const svgs = workflowTypes.map((t) => getEventIconSvg(t)); + const first = svgs[0]; + for (const svg of svgs) expect(svg).toBe(first); + }); + + it('update event variants all return the same update SVG', () => { + const updateTypes = [ + 'EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED', + 'EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED', + 'EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_REQUESTED', + 'EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_REJECTED', + ]; + const svgs = updateTypes.map((t) => getEventIconSvg(t)); + const first = svgs[0]; + for (const svg of svgs) expect(svg).toBe(first); + }); +}); diff --git a/src/lib/components/pixi-timeline/renderer/icon-svgs.ts b/src/lib/components/pixi-timeline/renderer/icon-svgs.ts new file mode 100644 index 0000000000..1c1d78bb78 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/icon-svgs.ts @@ -0,0 +1,111 @@ +/** + * Lightweight SVG icon helpers β€” no Pixi.js dependency. + * Shared by both the Pixi renderer (via icon-textures.ts) and Svelte panel components. + */ + +export type PixiIconName = + | 'activity' + | 'feather' + | 'nexus' + | 'relationship' + | 'retention' + | 'signal' + | 'terminal' + | 'update' + | 'workflow'; + +type IconDef = { + paths: string[]; + circles?: { cx: number; cy: number; r: number }[]; +}; + +export const ICON_DEFS: Record = { + workflow: { + paths: [ + 'M12 3.16663C6.47715 3.16663 2 7.64377 2 13.1666H0C0 6.53919 5.37258 1.16663 12 1.16663C18.6274 1.16663 24 6.53919 24 13.1666H22C22 7.64377 17.5229 3.16663 12 3.16663ZM18.3333 13.1666C18.3333 9.66881 15.4978 6.83328 12 6.83328V4.83328C16.6024 4.83328 20.3333 8.56423 20.3333 13.1666C20.3333 17.769 16.6024 21.4999 12 21.4999C7.39763 21.4999 3.66667 17.769 3.66667 13.1666H5.66667C5.66667 16.6644 8.50219 19.4999 12 19.4999C15.4978 19.4999 18.3333 16.6644 18.3333 13.1666ZM12 10.4999C10.5272 10.4999 9.33333 11.6938 9.33333 13.1666C9.33333 14.6394 10.5272 15.8333 12 15.8333C13.4728 15.8333 14.6667 14.6394 14.6667 13.1666C14.6667 11.6938 13.4728 10.4999 12 10.4999ZM7.33333 13.1666C7.33333 10.5893 9.42267 8.49994 12 8.49994C14.5773 8.49994 16.6667 10.5893 16.6667 13.1666C16.6667 15.7439 14.5773 17.8333 12 17.8333C9.42267 17.8333 7.33333 15.7439 7.33333 13.1666Z', + ], + }, + activity: { + paths: [ + 'M10.4543 22.2938L11.9497 24L13.445 22.2938L22.45 12L13.445 1.70625L11.9497 0L10.4543 1.70625L1.44966 12Z M11.9497 20.5829L4.44029 12L11.9497 3.4172L19.4591 12Z', + ], + }, + signal: { + circles: [{ cx: 12, cy: 18, r: 1 }], + paths: [ + 'M4.5 12C4.5 10.2437 5.10312 8.63125 6.1125 7.35313L4.93438 6.42188C3.725 7.95625 3 9.89375 3 12C3 14.1063 3.725 16.0438 4.93438 17.5781L6.1125 16.65C5.10312 15.3687 4.5 13.7563 4.5 12ZM6.5 12C6.5 13.2875 6.94375 14.4719 7.68437 15.4094L8.8625 14.4812C8.32188 13.7969 8 12.9375 8 12C8 11.0625 8.32187 10.2031 8.85938 9.52187L7.68125 8.59375C6.94375 9.52812 6.5 10.7125 6.5 12ZM16.3156 8.59062L15.1375 9.51875C15.6781 10.2031 16 11.0625 16 12C16 12.9375 15.6781 13.7969 15.1406 14.4781L16.3188 15.4062C17.0594 14.4688 17.5031 13.2844 17.5031 11.9969C17.5031 10.7094 17.0594 9.525 16.3188 8.5875ZM19.5 12C19.5 13.7563 18.8969 15.3688 17.8875 16.6469L19.0656 17.575C20.275 16.0437 21 14.1063 21 12C21 9.89375 20.275 7.95625 19.0656 6.42188L17.8875 7.35C18.8969 8.63125 19.5 10.2437 19.5 12ZM12 13.25C12.6904 13.25 13.25 12.6904 13.25 12C13.25 11.3096 12.6904 10.75 12 10.75C11.3096 10.75 10.75 11.3096 10.75 12C10.75 12.6904 11.3096 13.25 12 13.25Z', + ], + }, + retention: { + paths: [ + 'M8.25 0H15.75V2.25H13.125V4.56563C15.1594 4.8 17.0062 5.65781 18.4594 6.95156L19.8281 5.57812L20.625 4.78125L22.2141 6.375L21.4172 7.17188L19.9641 8.625C21.0891 10.2141 21.75 12.1547 21.75 14.25C21.75 19.6359 17.3859 24 12 24C6.61406 24 2.25 19.6359 2.25 14.25C2.25 9.24375 6.01875 5.12344 10.875 4.56563V2.25H8.25V0ZM12 21.75C15.7279 21.75 18.75 18.7279 18.75 15C18.75 11.2721 15.7279 8.25 12 8.25C8.27208 8.25 5.25 11.2721 5.25 15C5.25 18.7279 8.27208 21.75 12 21.75ZM13.125 10.125V15V16.125H10.875V15V10.125V9H13.125V10.125Z', + ], + }, + relationship: { + paths: [ + 'M8.55523 0H15.4441V6.88889H12.9997V11H21.4446V17.1111H23.889V24H17.0001V17.1111H19.4446V13H12.9997V17.1111H15.4441V24H8.55523V17.1111H10.9997V13H4.55553V17.1111H6.99997V24H0.111084V17.1111H2.55553V11H10.9997V6.88889H8.55523V0ZM13.4441 4.88889V2H10.5552V4.88889H13.4441ZM2.11108 19.1111V22H4.99997V19.1111H2.11108ZM10.5552 19.1111V22H13.4441V19.1111H10.5552ZM19.0001 19.1111V22H21.889V19.1111H19.0001Z', + ], + }, + update: { + paths: [ + 'M8.2 8.09064L7.69062 8.64064L8.79063 9.66251L9.3 9.11251L11.25 7.00939V14.25V15H12.75V14.25V7.00939L14.7 9.10939L15.2094 9.65939L16.3094 8.63751L15.8 8.08751L12.55 4.58751L12 3.99689L11.45 4.59064L8.2 8.09064ZM12 18.5C8.40937 18.5 5.5 15.5906 5.5 12H4C4 16.4188 7.58125 20 12 20C16.4187 20 20 16.4188 20 12V11.25H18.5V12C18.5 15.5906 15.5906 18.5 12 18.5Z', + ], + }, + terminal: { + paths: [ + 'M0.5959 3.34165L-0.0791 2.60415L1.3959 1.25415L2.0709 1.99165L9.40423 9.99165L10.0251 10.6666L9.40423 11.3416L2.0709 19.3416L1.3959 20.0791L-0.0791 18.7291L0.5959 17.9916L7.3084 10.6666L0.5959 3.34165ZM10.3334 18H24.0001V20H10.3334H9.3334V18H10.3334Z', + ], + }, + nexus: { + paths: [ + 'M10.8738 10.1797V0H13.1262V10.1797L22.9927 5.25177L24 7.26423L14.5183 12L24 16.7358L22.9927 18.7482L13.1262 13.8203V24H10.8738V13.8203L1.00732 18.7482L0 16.7358L9.48172 12L0 7.26423L1.00731 5.25176L10.8738 10.1797Z', + ], + }, + feather: { + paths: [ + 'M13.7469 9.19063L7.5 15.4406V11.6219L12.6469 6.475C13.2719 5.85 14.1187 5.5 15 5.5C15.8813 5.5 16.7281 5.85 17.3531 6.475L17.525 6.64687C18.15 7.27187 18.5 8.11875 18.5 9C18.5 9.525 18.375 10.0406 18.1438 10.5H14.5594L14.8062 10.2531L15.3375 9.72188L14.2781 8.6625L13.7469 9.19375V9.19063ZM13.0594 12H16.8781L15.3781 13.5H11.5594L13.0594 12ZM13.8781 15L12.3781 16.5H8.55938L10.0594 15H13.8781ZM6 11V16.9406L4.56875 18.3687L4.0375 18.9L5.09688 19.9594L5.62813 19.4281L7.05938 18H13L18.5844 12.4156C19.4906 11.5094 20 10.2812 20 9C20 7.71875 19.4906 6.49063 18.5844 5.58438L18.4125 5.4125C17.5094 4.50938 16.2812 4 15 4C13.7188 4 12.4906 4.50937 11.5844 5.41562L6 11Z', + ], + }, +}; + +export const PIXI_TYPE_TO_ICON: Partial> = { + GROUP_ACTIVITY: 'activity', + GROUP_CHILD_WORKFLOW: 'relationship', + GROUP_TIMER: 'retention', + GROUP_WORKFLOW_TASK: 'terminal', + EVENT_TYPE_WORKFLOW_EXECUTION_STARTED: 'workflow', + EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED: 'workflow', + EVENT_TYPE_WORKFLOW_EXECUTION_FAILED: 'workflow', + EVENT_TYPE_WORKFLOW_EXECUTION_CANCELED: 'workflow', + EVENT_TYPE_WORKFLOW_EXECUTION_TIMED_OUT: 'workflow', + EVENT_TYPE_WORKFLOW_EXECUTION_TERMINATED: 'workflow', + EVENT_TYPE_WORKFLOW_EXECUTION_CONTINUED_AS_NEW: 'workflow', + EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED: 'signal', + EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED: 'update', + EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED: 'update', + EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_REQUESTED: 'update', + EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_REJECTED: 'update', + EVENT_TYPE_NEXUS_OPERATION_SCHEDULED: 'nexus', + EVENT_TYPE_NEXUS_OPERATION_STARTED: 'nexus', + EVENT_TYPE_NEXUS_OPERATION_COMPLETED: 'nexus', + EVENT_TYPE_NEXUS_OPERATION_FAILED: 'nexus', + EVENT_TYPE_NEXUS_OPERATION_CANCELED: 'nexus', + EVENT_TYPE_NEXUS_OPERATION_TIMED_OUT: 'nexus', +}; + +export function buildIconSvg(name: PixiIconName): string { + const def = ICON_DEFS[name]; + let inner = ''; + for (const d of def.paths) inner += ``; + if (def.circles) { + for (const c of def.circles) + inner += ``; + } + return `${inner}`; +} + +/** Returns an inline SVG string for the given pixiType, or null if unmapped. */ +export function getEventIconSvg(eventType: string): string | null { + const name = PIXI_TYPE_TO_ICON[eventType]; + return name ? buildIconSvg(name) : null; +} diff --git a/src/lib/components/pixi-timeline/renderer/icon-textures.ts b/src/lib/components/pixi-timeline/renderer/icon-textures.ts new file mode 100644 index 0000000000..f0d3f81a37 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/icon-textures.ts @@ -0,0 +1,53 @@ +import type { Application, Texture } from 'pixi.js'; +import { Container, Graphics, RenderTexture } from 'pixi.js'; + +export type { PixiIconName } from './icon-svgs'; +export { PIXI_TYPE_TO_ICON } from './icon-svgs'; +import { ICON_DEFS, type PixiIconName } from './icon-svgs'; + +// Build icon SVG string for Pixi's Graphics.svg() which takes a full SVG document string. +// All shapes are white so Sprite.tint can be used to recolor per event. +function buildPixiIconSvg(name: PixiIconName): string { + const def = ICON_DEFS[name]; + let inner = ''; + for (const d of def.paths) inner += ``; + if (def.circles) { + for (const c of def.circles) + inner += ``; + } + return `${inner}`; +} + +// Render all icon types to white RenderTextures (iconSizeΓ—iconSize). +// Sprites using these textures can be tinted via Sprite.tint. +export function buildIconTextures( + app: Application, + iconSize: number, +): Record { + const result = {} as Record; + const scale = iconSize / 24; + + for (const name of Object.keys(ICON_DEFS) as PixiIconName[]) { + const gfx = new Graphics(); + try { + gfx.svg(buildPixiIconSvg(name)); + } catch { + gfx.destroy(); + continue; + } + const wrapper = new Container(); + wrapper.scale.set(scale); + wrapper.addChild(gfx); + + const rt = RenderTexture.create({ + width: iconSize, + height: iconSize, + resolution: app.renderer.resolution, + }); + app.renderer.render({ container: wrapper, target: rt }); + wrapper.destroy({ children: true }); + result[name] = rt; + } + + return result; +} diff --git a/src/lib/components/pixi-timeline/renderer/pack-gutter-pins.test.ts b/src/lib/components/pixi-timeline/renderer/pack-gutter-pins.test.ts new file mode 100644 index 0000000000..a3905fa7e0 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/pack-gutter-pins.test.ts @@ -0,0 +1,380 @@ +import { describe, expect, it } from 'vitest'; + +import { packGutterPins } from './pack-gutter-pins'; + +/** Helpers */ +const ev = (startMs: number, endMs: number) => ({ startMs, endMs }); +const MARGIN = 4; +const MIN_W = 18; +const SCREEN_W = 1000; + +describe('packGutterPins', () => { + // ── Basic positioning ───────────────────────────────────────────────────── + + it('returns empty for no events', () => { + expect(packGutterPins([], 0, 1, SCREEN_W, MARGIN, MIN_W, 2)).toEqual([]); + }); + + it('returns empty when maxRows is 0', () => { + const events = [ev(0, 100)]; + expect(packGutterPins(events, 0, 1, SCREEN_W, MARGIN, MIN_W, 0)).toEqual( + [], + ); + }); + + it('positions an event at its exact screen-space x', () => { + // Event at startMs=100, zoom=2 px/ms, viewStart=50 β†’ px = (100-50)*2 = 100 + const result = packGutterPins( + [ev(100, 200)], + 50, + 2, + SCREEN_W, + MARGIN, + MIN_W, + 2, + ); + expect(result).toHaveLength(1); + expect(result[0].px).toBe(100); + }); + + it('width = max(minBarW, duration * zoom)', () => { + // Event well within the viewport so no edge clipping occurs. + // duration=50ms, zoom=2 β†’ natural width = 100 > minBarW; starts at t=200 so px=400 + const r1 = packGutterPins([ev(200, 250)], 0, 2, SCREEN_W, MARGIN, MIN_W, 2); + expect(r1[0].pw).toBe(100); + + // duration=1ms, zoom=0.001 β†’ natural width = 0.001 < minBarW β†’ uses MIN_W + const r2 = packGutterPins( + [ev(200, 201)], + 0, + 0.001, + SCREEN_W, + MARGIN, + MIN_W, + 2, + ); + expect(r2[0].pw).toBe(MIN_W); + }); + + // ── Edge clamping ───────────────────────────────────────────────────────── + + it('clamps events entirely off-screen LEFT to left margin', () => { + // Event ends at t=-100 (before viewport start at t=0) β†’ left edge pin + const result = packGutterPins( + [ev(-200, -100)], + 0, + 1, + SCREEN_W, + MARGIN, + MIN_W, + 2, + ); + expect(result).toHaveLength(1); + expect(result[0].px).toBe(MARGIN); + expect(result[0].pw).toBe(MIN_W); + }); + + it('clamps events entirely off-screen RIGHT to right margin', () => { + // Event starts at t=2000 (well past viewport end at t=1000) β†’ right edge pin + const result = packGutterPins( + [ev(2000, 2100)], + 0, + 1, + SCREEN_W, + MARGIN, + MIN_W, + 2, + ); + expect(result).toHaveLength(1); + expect(result[0].px).toBe(SCREEN_W - MARGIN - MIN_W); + expect(result[0].pw).toBe(MIN_W); + }); + + it('event starting before viewport but ending within is clipped to left margin', () => { + // startMs=-10 β†’ exStart=-10, exEnd=-10+min(18,5*1)=8 β†’ px=MARGIN, pw clipped + const result = packGutterPins( + [ev(-10, -5)], + 0, + 1, + SCREEN_W, + MARGIN, + MIN_W, + 2, + ); + // exEnd = (-5-0)*1 = -5 < MARGIN β†’ left edge clamp + expect(result[0].px).toBe(MARGIN); + expect(result[0].pw).toBe(MIN_W); + }); + + it('event starting within viewport is clamped to left margin px', () => { + // exStart=2, exEnd=2+18=20. px=max(MARGIN=4,2)=4, pw=20-4=16 < MIN_W β†’ MIN_W + const result = packGutterPins( + [ev(2, 20)], + 0, + 1, + SCREEN_W, + MARGIN, + MIN_W, + 2, + ); + expect(result[0].px).toBe(MARGIN); + expect(result[0].pw).toBe(MIN_W); + }); + + it('event extending past right edge has at least minBarW and starts within screen', () => { + // exStart=990, exEnd=1008. Not entirely off-screen right (990 < 996), so falls + // through to the else branch. px=990, pw=max(MIN_W, 996-990)=MIN_W. px is within screen. + const result = packGutterPins( + [ev(990, 1000)], + 0, + 1, + SCREEN_W, + MARGIN, + MIN_W, + 2, + ); + expect(result[0].pw).toBeGreaterThanOrEqual(MIN_W); + expect(result[0].px).toBeLessThan(SCREEN_W - MARGIN); + }); + + // ── Row assignment (time-range non-overlap) ─────────────────────────────── + + it('non-overlapping events share row 0', () => { + const events = [ev(0, 100), ev(200, 300), ev(400, 500)]; + // All sorted by duration desc (equal), all non-overlapping in time + const result = packGutterPins(events, 0, 1, SCREEN_W, MARGIN, MIN_W, 2); + // All go to row 0 (no time overlap) + for (const p of result) { + expect(p.row).toBe(0); + } + }); + + it('overlapping events in time go to different rows', () => { + // Two events that overlap in time: [0,200] and [100,300] + const events = [ev(0, 200), ev(100, 300)]; + const result = packGutterPins(events, 0, 1, SCREEN_W, MARGIN, MIN_W, 2); + const rows = result.map((p) => p.row); + expect(rows).toContain(0); + expect(rows).toContain(1); + }); + + it('third overlapping event is dropped when maxRows=2', () => { + // Three mutually overlapping events β€” only 2 rows available + const events = [ev(0, 500), ev(100, 600), ev(200, 700)]; + const result = packGutterPins(events, 0, 1, SCREEN_W, MARGIN, MIN_W, 2); + expect(result.length).toBeLessThanOrEqual(2); + }); + + // ── Pixel-space deduplication (pass 2) ─────────────────────────────────── + + it('importance-first: high-priority event (input-first) wins over nearby low-priority', () => { + // Event A (important, input-first): t=[100,1100] β†’ long duration 1000ms, px=100 + // Event B (less important, input-second): t=[98,99] β†’ 1ms, px=98 + // They do NOT overlap in time (B ends at 99, A starts at 100), so both go to row 0. + // In pass 2, importance-first ordering means A is processed first and claims [100,996]. + // B at [98,116] then overlaps A β†’ B is dropped. + const events = [ev(100, 1100), ev(98, 99)]; + const result = packGutterPins(events, 0, 1, SCREEN_W, MARGIN, MIN_W, 2); + expect(result).toHaveLength(1); + expect(result[0].ev).toEqual(events[0]); + }); + + it('importance-first: low-priority event does not block distant high-priority event', () => { + // A (low-priority, input-second): t=[50,51] β†’ px=50 + // B (high-priority, input-first): t=[500,1500] β†’ long duration, px=500 + // Both fit in row 0 (non-overlapping time). B at px=500 does not overlap A at [50,68]. + // Both should survive. + const events = [ev(500, 1500), ev(50, 51)]; + const result = packGutterPins(events, 0, 1, SCREEN_W, MARGIN, MIN_W, 2); + expect(result).toHaveLength(2); + }); + + it('pixel-overlapping events in the same row: only the first drawn survives', () => { + // Two events at the same time position β†’ same px, pixel-overlap β†’ one survives + const events = [ev(100, 101), ev(100, 101)]; + const result = packGutterPins(events, 0, 1, SCREEN_W, MARGIN, MIN_W, 2); + // Both go to row 0 (same time range? they overlap: 100<101 && 101>100 β†’ YES overlap β†’ row 1 for second) + // Actually: first event [100,101] goes to row 0. Second [100,101] overlaps row 0's [100,101] β†’ goes to row 1. + // In pixel space they have same position, so the first in each row survives. + expect(result.length).toBeLessThanOrEqual(2); + }); + + it('non-pixel-overlapping events in same row both appear', () => { + // Two events that are far apart in time (same row, no pixel overlap) + // Event A at t=0-5 β†’ px=0 clamped to MARGIN, pw=MIN_W β†’ [4, 22] + // Event B at t=500-505 β†’ px=500, pw=MIN_W β†’ [500, 518] β€” no overlap with [4,22] + const events = [ev(0, 5), ev(500, 505)]; + const result = packGutterPins(events, 0, 1, SCREEN_W, MARGIN, MIN_W, 2); + // Both in row 0 (non-overlapping time), non-overlapping in pixels β†’ both drawn + expect(result.length).toBe(2); + }); + + // ── Width matches zoom ──────────────────────────────────────────────────── + + it('pin width scales with zoom like event bars', () => { + // Use an event well within the viewport at each zoom level to avoid edge clipping. + const zoom1 = packGutterPins( + [ev(100, 200)], + 0, + 1, + SCREEN_W, + MARGIN, + MIN_W, + 2, + ); + const zoom2 = packGutterPins([ev(100, 200)], 0, 2, 5000, MARGIN, MIN_W, 2); + const zoom4 = packGutterPins([ev(100, 200)], 0, 4, 10000, MARGIN, MIN_W, 2); + + // At zoom=1: pw = max(MIN_W, 100*1) = 100 + expect(zoom1[0].pw).toBe(100); + // At zoom=2: pw = max(MIN_W, 100*2) = 200 + expect(zoom2[0].pw).toBe(200); + // At zoom=4: pw = max(MIN_W, 100*4) = 400 + expect(zoom4[0].pw).toBe(400); + }); + + it('short events always get at least minBarW width', () => { + // 1ms event at tiny zoom + const result = packGutterPins( + [ev(0, 1)], + 0, + 0.0001, + SCREEN_W, + MARGIN, + MIN_W, + 2, + ); + expect(result[0].pw).toBe(MIN_W); + }); + + // ── Desc mode behaviour ─────────────────────────────────────────────────── + + it('desc mode: newer above-viewport events (high endMs) appear right of center', () => { + // Viewport: t=0 to t=500 (zoom=2, screenW=1000 β†’ viewport end=500ms) + // Newer events completed late (t=300-400ms) β†’ screen x = (300-0)*2 = 600 (right of center) + const newerEvents = [ev(300, 400)]; + const result = packGutterPins( + newerEvents, + 0, + 2, + SCREEN_W, + MARGIN, + MIN_W, + 2, + ); + expect(result[0].px).toBeGreaterThan(SCREEN_W / 2); + }); + + it('desc mode: older below-viewport events (low endMs) appear left of center', () => { + // Viewport: t=200 to t=700 (viewStart=200, zoom=2, screenW=1000) + // Older events completed early (t=50-100ms) β†’ screen x = (50-200)*2 = -300 β†’ LEFT EDGE clamp + const olderEvents = [ev(50, 100)]; + const result = packGutterPins( + olderEvents, + 200, + 2, + SCREEN_W, + MARGIN, + MIN_W, + 2, + ); + expect(result[0].px).toBe(MARGIN); // clamped to left margin + }); + + // ── Density across full viewport ───────────────────────────────────────── + + it('events spread across the viewport produce pins spread across the canvas', () => { + // 10 events evenly spread from t=0 to t=900ms, viewport t=0-1000, zoom=1 + const events = Array.from({ length: 10 }, (_, i) => + ev(i * 100, i * 100 + 1), + ); + const result = packGutterPins(events, 0, 1, SCREEN_W, MARGIN, MIN_W, 2); + // All go to row 0 (non-overlapping). Pixel deduplication skips some due to MIN_W. + // At least a few should survive spread across the canvas. + expect(result.length).toBeGreaterThan(0); + // Pins should span a range of x positions, not all at the same edge + const pxVals = result.map((p) => p.px); + const range = Math.max(...pxVals) - Math.min(...pxVals); + expect(range).toBeGreaterThan(100); + }); +}); + +describe('packGutterPins – desc/asc mode X-side invariants', () => { + const makeEvents = (count: number, startOffset = 0, step = 1000) => + Array.from({ length: count }, (_, i) => + ev(startOffset + i * step, startOffset + i * step + 1), + ); + + it('desc above-viewport: events at high endMs (newest) appear on right side', () => { + // Full viewport t=0–5000, zoom=0.2, screenW=1000 β†’ covers 0-5000ms + // Above-viewport tracks in desc = newest events (high endMs, say 3000-4999ms) + const newerEvents = makeEvents(20, 3000, 100); + const result = packGutterPins( + newerEvents, + 0, + 0.2, + SCREEN_W, + MARGIN, + MIN_W, + 2, + ); + if (result.length > 0) { + const avgX = result.reduce((s, p) => s + p.px, 0) / result.length; + expect(avgX).toBeGreaterThan(SCREEN_W / 2); // right of center + } + }); + + it('desc below-viewport: events at low endMs (oldest) appear on left side', () => { + // Same viewport, below-viewport tracks = oldest events (low endMs, say 0-1999ms) + const olderEvents = makeEvents(20, 0, 100); + const result = packGutterPins( + olderEvents, + 0, + 0.2, + SCREEN_W, + MARGIN, + MIN_W, + 2, + ); + if (result.length > 0) { + const avgX = result.reduce((s, p) => s + p.px, 0) / result.length; + expect(avgX).toBeLessThan(SCREEN_W / 2); // left of center + } + }); + + it('asc above-viewport: events at low endMs (oldest in asc) appear on left side', () => { + // In asc mode, above-viewport = OLDEST events = low endMs + const oldestEvents = makeEvents(20, 0, 100); + const result = packGutterPins( + oldestEvents, + 0, + 0.2, + SCREEN_W, + MARGIN, + MIN_W, + 2, + ); + if (result.length > 0) { + const avgX = result.reduce((s, p) => s + p.px, 0) / result.length; + expect(avgX).toBeLessThan(SCREEN_W / 2); + } + }); + + it('asc below-viewport: events at high endMs (newest in asc) appear on right side', () => { + // In asc mode, below-viewport = NEWEST events = high endMs + const newestEvents = makeEvents(20, 3000, 100); + const result = packGutterPins( + newestEvents, + 0, + 0.2, + SCREEN_W, + MARGIN, + MIN_W, + 2, + ); + if (result.length > 0) { + const avgX = result.reduce((s, p) => s + p.px, 0) / result.length; + expect(avgX).toBeGreaterThan(SCREEN_W / 2); + } + }); +}); diff --git a/src/lib/components/pixi-timeline/renderer/pack-gutter-pins.ts b/src/lib/components/pixi-timeline/renderer/pack-gutter-pins.ts new file mode 100644 index 0000000000..105b98c16d --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/pack-gutter-pins.ts @@ -0,0 +1,129 @@ +/** + * Pure, testable gutter pin packing logic β€” ported from the original Pixi test + * renderer's `packPins` function. + * + * Key design: + * - Pin X and width are computed in the **same screen-space formula as event + * bars**: `px = (startMs - viewStartMs) * zoom`, `pw = max(minBarW, duration * zoom)`. + * This means gutter pins always match the visual width of their bars at the + * current zoom level. + * - Events entirely off-screen left clamp to the left margin at minBarW wide. + * - Events entirely off-screen right clamp to the right margin at minBarW wide. + * - Row assignment uses **time-range non-overlap**: two events that do not + * overlap in time share a row, maximising pin density without time collision. + * - A greedy pixel-space pass within each row skips bars that would visually + * overplot an already-drawn bar (longest-duration wins within each row). + */ + +export interface PackGutterInput { + startMs: number; + endMs: number; +} + +export interface PackedGutterPin { + ev: T; + /** Screen-space left edge of the pin bar (px). */ + px: number; + /** Width of the pin bar (px), always β‰₯ minBarW. */ + pw: number; + /** Row index (0 = closest to the strip edge). */ + row: number; +} + +/** + * Pack off-screen events into gutter pin rows. + * + * @param events Events to pack β€” **must be sorted longest-duration first** + * so that important events claim space before shorter ones. + * Should already be filtered to those intersecting the + * current horizontal viewport. + * @param viewStartMs Left edge of the visible viewport in relative ms. + * @param zoom Pixels per ms at the current zoom level. + * @param screenW Canvas width in logical pixels. + * @param pinMargin Left/right screen margin reserved for edge-pinned bars. + * @param minBarW Minimum bar width in pixels (same constant as event bars). + * @param maxRows Maximum number of stacked pin rows. + */ +export function packGutterPins( + events: T[], + viewStartMs: number, + zoom: number, + screenW: number, + pinMargin: number, + minBarW: number, + maxRows: number, +): PackedGutterPin[] { + if (events.length === 0 || maxRows <= 0) return []; + + type Entry = { ev: T; px: number; pw: number }; + const rows: Entry[][] = []; + const rowIntervals: [number, number][][] = []; + // Per-row maximum endMs of all placed intervals. Enables an O(1) fast-path: + // if ev.startMs >= rowMaxEnd[r], there is no overlap without needing .some(). + const rowMaxEnd: number[] = []; + + // ── Pass 1: row assignment by time-range non-overlap ────────────────────── + for (const ev of events) { + const exStart = (ev.startMs - viewStartMs) * zoom; + const exEnd = exStart + Math.max(minBarW, (ev.endMs - ev.startMs) * zoom); + + let px: number, pw: number; + if (exEnd < pinMargin) { + // Entirely off-screen to the left β€” clamp to left edge. + px = pinMargin; + pw = minBarW; + } else if (exStart > screenW - pinMargin) { + // Entirely off-screen to the right β€” clamp to right edge. + px = screenW - pinMargin - minBarW; + pw = minBarW; + } else { + px = Math.max(pinMargin, exStart); + pw = Math.max(minBarW, Math.min(screenW - pinMargin, exEnd) - px); + } + + // Find the first row where this event's time range doesn't overlap. + let row = -1; + for (let r = 0; r < rowIntervals.length; r++) { + // Fast path: if this event starts after every existing interval ends, + // it cannot overlap any of them β€” skip the O(n) .some() entirely. + if (ev.startMs >= rowMaxEnd[r]) { + row = r; + break; + } + if (!rowIntervals[r].some(([s, e]) => ev.startMs < e && ev.endMs > s)) { + row = r; + break; + } + } + if (row === -1) { + if (rowIntervals.length >= maxRows) continue; + row = rowIntervals.length; + rowIntervals.push([]); + rowMaxEnd.push(-Infinity); + rows.push([]); + } + rowIntervals[row].push([ev.startMs, ev.endMs]); + if (ev.endMs > rowMaxEnd[row]) rowMaxEnd[row] = ev.endMs; + rows[row].push({ ev, px, pw }); + } + + // ── Pass 2: greedy pixel-space deduplication within each row ────────────── + // Process entries in importance order (the order they were inserted into each + // row during pass 1, which mirrors the collectGutterBest input sort). + // + // IMPORTANT: do NOT re-sort by px here. Re-sorting by px would let a + // lower-priority activity at px=100 block a high-priority timer at px=102, + // because the activity would be processed first and claim the pixel range. + // Importance-first ordering guarantees timers/child-workflows always win + // pixel conflicts against plain activities. + const result: PackedGutterPin[] = []; + for (let r = 0; r < rows.length; r++) { + const drawnRanges: [number, number][] = []; + for (const { ev, px, pw } of rows[r]) { + if (drawnRanges.some(([s, e]) => px < e && px + pw > s)) continue; + drawnRanges.push([px, px + pw]); + result.push({ ev, px, pw, row: r }); + } + } + return result; +} diff --git a/src/lib/components/pixi-timeline/renderer/scroll-snap.test.ts b/src/lib/components/pixi-timeline/renderer/scroll-snap.test.ts new file mode 100644 index 0000000000..662353dfd8 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/scroll-snap.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest'; + +import type { ScrollSnapConfig } from './scroll-snap'; +import { + calcSnapScrollY, + DEFAULT_SCROLL_SNAP_CONFIG, + SNAP_COOLDOWN_MS, +} from './scroll-snap'; + +const ROW_H = 32; +const VISIBLE_H = 800; // event-area height (canvas minus ruler) +const TOTAL_TRACKS = 200; + +describe('DEFAULT_SCROLL_SNAP_CONFIG', () => { + it('has eventIndex=4, topYFrac=0.25, botYFrac=0.75', () => { + expect(DEFAULT_SCROLL_SNAP_CONFIG).toEqual({ + eventIndex: 4, + topYFrac: 0.25, + botYFrac: 0.75, + }); + }); +}); + +describe('SNAP_COOLDOWN_MS', () => { + it('is at least 100ms so the snap fires after inertia trailing events', () => { + expect(SNAP_COOLDOWN_MS).toBeGreaterThanOrEqual(100); + }); + + it('is at most 300ms so the snap feels like a natural settle not a lag', () => { + expect(SNAP_COOLDOWN_MS).toBeLessThanOrEqual(300); + }); +}); + +describe('calcSnapScrollY – scroll UP', () => { + it('positions the Nth visible track from top at topYFrac', () => { + // currentScrollY=0 β†’ topTrack=0 β†’ anchorTrack=0+3=3 + // newScrollY = 3*32 - 0.25*800 = 96 - 200 = -104 (caller clamps to 0) + expect(calcSnapScrollY('up', 0, VISIBLE_H, ROW_H, TOTAL_TRACKS)).toBe(-104); + }); + + it('computes correct anchor at mid-scroll position', () => { + // currentScrollY=640 β†’ topTrack=20 β†’ anchorTrack=20+3=23 + // newScrollY = 23*32 - 0.25*800 = 736 - 200 = 536 + const result = calcSnapScrollY('up', 640, VISIBLE_H, ROW_H, TOTAL_TRACKS); + expect(result).toBe(536); + }); + + it('never returns an anchor track below 0', () => { + // topTrack=2, eventIndex=4 β†’ anchorTrack = max(0, 2+3) = 5 + // (already β‰₯ 0; floor(2*32/32)=2, 2+3=5) + const result = calcSnapScrollY( + 'up', + 2 * ROW_H, + VISIBLE_H, + ROW_H, + TOTAL_TRACKS, + ); + expect(result).toBe(5 * ROW_H - 0.25 * VISIBLE_H); + }); + + it('respects a custom eventIndex and topYFrac', () => { + const config: ScrollSnapConfig = { + eventIndex: 2, + topYFrac: 0.3, + botYFrac: 0.7, + }; + // currentScrollY=320 β†’ topTrack=10 β†’ anchorTrack=10+1=11 + // newScrollY = 11*32 - 0.3*800 = 352 - 240 = 112 + expect( + calcSnapScrollY('up', 320, VISIBLE_H, ROW_H, TOTAL_TRACKS, config), + ).toBe(112); + }); + + it('produces a small correction (≀ eventIndex Γ— rowH from current position)', () => { + // The snap should never move the view by more than eventIndex * rowH + // (it only aligns within the current visible range). + const scrollY = 50 * ROW_H; + const result = calcSnapScrollY( + 'up', + scrollY, + VISIBLE_H, + ROW_H, + TOTAL_TRACKS, + ); + const maxExpectedShift = + DEFAULT_SCROLL_SNAP_CONFIG.eventIndex * ROW_H + 0.25 * VISIBLE_H; + expect(Math.abs(result - scrollY)).toBeLessThanOrEqual(maxExpectedShift); + }); + + it('is consistent across scroll positions (constant correction)', () => { + // correction β‰ˆ topYFrac * visibleH - (eventIndex-1) * rowH + const s1 = 40 * ROW_H; + const s2 = 100 * ROW_H; + const c1 = calcSnapScrollY('up', s1, VISIBLE_H, ROW_H, TOTAL_TRACKS) - s1; + const c2 = calcSnapScrollY('up', s2, VISIBLE_H, ROW_H, TOTAL_TRACKS) - s2; + expect(c1).toBeCloseTo(c2, 0); + }); +}); + +describe('calcSnapScrollY – scroll DOWN', () => { + it('positions the Nth visible track from bottom at botYFrac', () => { + // currentScrollY=0 β†’ bottomTrack=floor(800/32)=25 β†’ anchorTrack=max(0,25-3)=22 + // newScrollY = 22*32 - 0.75*800 = 704 - 600 = 104 + expect(calcSnapScrollY('down', 0, VISIBLE_H, ROW_H, TOTAL_TRACKS)).toBe( + 104, + ); + }); + + it('computes correct anchor at mid-scroll position', () => { + // currentScrollY=960 β†’ bottomTrack=floor(1760/32)=55 β†’ anchorTrack=max(0,55-3)=52 + // newScrollY = 52*32 - 0.75*800 = 1664 - 600 = 1064 + const result = calcSnapScrollY('down', 960, VISIBLE_H, ROW_H, TOTAL_TRACKS); + expect(result).toBe(1064); + }); + + it('clamps anchor to totalTracks-1', () => { + // highScroll=198*32=6336 β†’ bottomTrack=floor(7136/32)=223 β†’ anchorTrack=min(199, 220)=199 + const highScroll = 198 * ROW_H; + const result = calcSnapScrollY( + 'down', + highScroll, + VISIBLE_H, + ROW_H, + TOTAL_TRACKS, + ); + const expected = (TOTAL_TRACKS - 1) * ROW_H - 0.75 * VISIBLE_H; + expect(result).toBe(expected); + }); + + it('respects a custom eventIndex and botYFrac', () => { + const config: ScrollSnapConfig = { + eventIndex: 1, + topYFrac: 0.25, + botYFrac: 0.5, + }; + // currentScrollY=0 β†’ bottomTrack=25 β†’ anchorTrack=max(0, 25-0)=25 + // newScrollY = 25*32 - 0.5*800 = 800 - 400 = 400 + expect( + calcSnapScrollY('down', 0, VISIBLE_H, ROW_H, TOTAL_TRACKS, config), + ).toBe(400); + }); + + it('produces a small correction (≀ eventIndex Γ— rowH from current position)', () => { + const scrollY = 50 * ROW_H; + const result = calcSnapScrollY( + 'down', + scrollY, + VISIBLE_H, + ROW_H, + TOTAL_TRACKS, + ); + const maxExpectedShift = + DEFAULT_SCROLL_SNAP_CONFIG.eventIndex * ROW_H + 0.25 * VISIBLE_H; + expect(Math.abs(result - scrollY)).toBeLessThanOrEqual(maxExpectedShift); + }); + + it('is consistent across scroll positions (constant correction)', () => { + const s1 = 40 * ROW_H; + const s2 = 80 * ROW_H; + const c1 = calcSnapScrollY('down', s1, VISIBLE_H, ROW_H, TOTAL_TRACKS) - s1; + const c2 = calcSnapScrollY('down', s2, VISIBLE_H, ROW_H, TOTAL_TRACKS) - s2; + expect(c1).toBeCloseTo(c2, 0); + }); +}); + +describe('calcSnapScrollY – edge cases', () => { + it('handles a single-track timeline without throwing', () => { + // anchorTrack = min(0, max(0, 25-3)) = 0; newScrollY = 0 - 0.75*800 = -600 + const result = calcSnapScrollY('down', 0, VISIBLE_H, ROW_H, 1); + expect(result).toBe(-600); + }); + + it('handles rowH=1 (degenerate) without throwing', () => { + expect(() => + calcSnapScrollY('up', 0, VISIBLE_H, 1, TOTAL_TRACKS), + ).not.toThrow(); + }); + + it('snap UP at scrollY=0 returns negative (caller clamps to 0)', () => { + const upAt0 = calcSnapScrollY('up', 0, VISIBLE_H, ROW_H, TOTAL_TRACKS); + expect(upAt0).toBeLessThan(0); + }); + + it('snap DOWN at scrollY=0 returns positive (moves view down slightly)', () => { + const downAt0 = calcSnapScrollY('down', 0, VISIBLE_H, ROW_H, TOTAL_TRACKS); + expect(downAt0).toBeGreaterThan(0); + }); + + it('anchor track is always within the current visible range', () => { + const scrollY = 50 * ROW_H; + const topTrack = Math.floor(scrollY / ROW_H); + const bottomTrack = Math.floor((scrollY + VISIBLE_H) / ROW_H); + + // UP anchor: topTrack + 3 (within visible range) + const upResult = calcSnapScrollY( + 'up', + scrollY, + VISIBLE_H, + ROW_H, + TOTAL_TRACKS, + ); + const upAnchorTrack = (upResult + 0.25 * VISIBLE_H) / ROW_H; + expect(upAnchorTrack).toBeGreaterThanOrEqual(topTrack); + expect(upAnchorTrack).toBeLessThanOrEqual(bottomTrack); + + // DOWN anchor: bottomTrack - 3 (within visible range) + const downResult = calcSnapScrollY( + 'down', + scrollY, + VISIBLE_H, + ROW_H, + TOTAL_TRACKS, + ); + const downAnchorTrack = (downResult + 0.75 * VISIBLE_H) / ROW_H; + expect(downAnchorTrack).toBeGreaterThanOrEqual(topTrack); + expect(downAnchorTrack).toBeLessThanOrEqual(bottomTrack); + }); +}); diff --git a/src/lib/components/pixi-timeline/renderer/scroll-snap.ts b/src/lib/components/pixi-timeline/renderer/scroll-snap.ts new file mode 100644 index 0000000000..d8880026b3 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/scroll-snap.ts @@ -0,0 +1,106 @@ +/** + * Scroll-snap: after the user finishes a scroll gesture on the timeline canvas, + * the snap logic makes a small alignment correction so the view always lands + * with the Nth visible event at a specific Y fraction. + * + * Behaviour + * ───────── + * β€’ Scroll UP (deltaY < 0, moving towards track 0 / newest events): + * Take the Nth visible track FROM THE TOP of the current viewport and + * position it at `topYFrac` (default 25 %) from the top of the event area. + * This is a small correction (≀ eventIndex Γ— rowH) that "clicks" the view + * to the nearest row boundary near the top-right quadrant. + * + * β€’ Scroll DOWN (deltaY > 0, moving towards last track / oldest events): + * Take the Nth visible track FROM THE BOTTOM of the current viewport and + * position it at `botYFrac` (default 75 %) from the top of the event area. + * Small correction that clicks to a row boundary near the bottom-left + * quadrant. + * + * The snap fires ONCE after scroll wheel events stop (post-scroll debounce). + * Native DOM scroll runs freely during the gesture; the snap is a subtle + * landing correction only, never a jump that would move the view out of range. + * + * All values are configurable via ScrollSnapConfig. + */ + +export interface ScrollSnapConfig { + /** + * Which visible event (1-indexed from the approaching edge) to anchor on. + * 4 means "the 4th visible track from the top (scroll up) or bottom (scroll down)". + */ + eventIndex: number; + /** + * Y fraction (0–1) within the event area at which the anchor event sits + * when scrolling UP. 0.25 = top quadrant. + */ + topYFrac: number; + /** + * Y fraction (0–1) within the event area at which the anchor event sits + * when scrolling DOWN. 0.75 = bottom quadrant. + */ + botYFrac: number; +} + +export const DEFAULT_SCROLL_SNAP_CONFIG: ScrollSnapConfig = { + eventIndex: 4, + topYFrac: 0.25, + botYFrac: 0.75, +}; + +/** + * Debounce delay for the post-scroll snap in milliseconds. + * + * Native DOM scroll runs freely while the user is scrolling. Once wheel + * events stop firing for this long we consider the gesture finished and snap + * the viewport to the nearest "4th event at target quadrant" position. + * + * 150 ms is long enough to absorb macOS inertia trailing events but short + * enough that the final nudge feels like a natural settle, not a lag. + */ +export const SNAP_COOLDOWN_MS = 150; + +/** + * Calculate the new `scrollY` after a post-scroll alignment snap. + * + * The anchor is the Nth VISIBLE track from the approaching edge of the + * current viewport (already on screen). The snap nudges the viewport so + * that track lands exactly at the target Y fraction. The correction is + * always small (≀ eventIndex Γ— rowH β‰ˆ 128 px with defaults) and never + * moves the view into empty track space beyond the timeline's extent. + * + * @param direction 'up' (wheel up, towards track 0) or 'down'. + * @param currentScrollY Current viewport.scrollY in pixels (post native scroll). + * @param visibleH Height of the event area (canvas height βˆ’ ruler height). + * @param rowH Height of one track row in pixels. + * @param totalTracks Total number of tracks (used to clamp anchor on scroll down). + * @param config Optional overrides (defaults to DEFAULT_SCROLL_SNAP_CONFIG). + * @returns New scrollY value (caller must clamp to [0, maxScrollY]). + */ +export function calcSnapScrollY( + direction: 'up' | 'down', + currentScrollY: number, + visibleH: number, + rowH: number, + totalTracks: number, + config: ScrollSnapConfig = DEFAULT_SCROLL_SNAP_CONFIG, +): number { + const { eventIndex, topYFrac, botYFrac } = config; + + if (direction === 'up') { + // Anchor: the Nth visible track FROM THE TOP (already in viewport). + // e.g. eventIndex=4 β†’ track at index topTrack+3 (0-based). + const topTrack = Math.floor(currentScrollY / rowH); + const anchorTrack = Math.max(0, topTrack + eventIndex - 1); + return anchorTrack * rowH - topYFrac * visibleH; + } else { + // Anchor: the Nth visible track FROM THE BOTTOM (already in viewport). + // e.g. eventIndex=4 β†’ track at index bottomTrack-3 (0-based). + const bottomTrack = Math.floor((currentScrollY + visibleH) / rowH); + const anchorTrack = Math.min( + totalTracks - 1, + Math.max(0, bottomTrack - (eventIndex - 1)), + ); + return anchorTrack * rowH - botYFrac * visibleH; + } +} diff --git a/src/lib/components/pixi-timeline/renderer/scroll-x-pan.test.ts b/src/lib/components/pixi-timeline/renderer/scroll-x-pan.test.ts new file mode 100644 index 0000000000..4d1215eb0c --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/scroll-x-pan.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest'; + +import { + calcScrollXPan, + SCROLL_X_PAD_VW, + X_PAN_EASE, + X_PAN_IN_VIEW_THRESHOLD, +} from './scroll-x-pan'; + +// ── Shared fixture ──────────────────────────────────────────────────────────── +// 800 px canvas, zoom = 0.1 px/ms β†’ screenMs = 8000 ms +const SCREEN_W = 800; +const ZOOM = 0.1; // px/ms +const SCREEN_MS = SCREEN_W / ZOOM; // 8000 ms +const PAD_MS = (SCREEN_W * SCROLL_X_PAD_VW) / ZOOM; // 0.33 * 8000 = 2640 ms + +// Events live at 5000–6000 ms (1000 ms span). +const EV_MIN = 5000; +const EV_MAX = 6000; + +// Helper: build a minimal input, overriding only what the test needs. +function input( + overrides: Partial[0]>, +): Parameters[0] { + return { + evMinMs: EV_MIN, + evMaxMs: EV_MAX, + startMs: 0, // default: viewport starts at 0, events at 5000–6000 fully visible + screenW: SCREEN_W, + zoom: ZOOM, + deltaY: 10, // scroll down + sortOrder: 'desc', + ...overrides, + }; +} + +// ── Constants ───────────────────────────────────────────────────────────────── +describe('exported constants', () => { + it('SCROLL_X_PAD_VW is 0.33', () => expect(SCROLL_X_PAD_VW).toBe(0.33)); + it('X_PAN_EASE is between 0 and 1', () => { + expect(X_PAN_EASE).toBeGreaterThan(0); + expect(X_PAN_EASE).toBeLessThan(1); + }); + it('X_PAN_IN_VIEW_THRESHOLD is between 0 and 1', () => { + expect(X_PAN_IN_VIEW_THRESHOLD).toBeGreaterThan(0); + expect(X_PAN_IN_VIEW_THRESHOLD).toBeLessThanOrEqual(1); + }); +}); + +// ── Skip when already in view ───────────────────────────────────────────────── +describe('calcScrollXPan – skip when in view', () => { + it('returns null when events are fully inside the viewport', () => { + // Viewport 0–8000 ms, events 5000–6000: fully visible (100 %). + expect(calcScrollXPan(input({ startMs: 0 }))).toBeNull(); + }); + + it('returns null when overlap fraction equals the threshold', () => { + // Overlap = 800 ms out of 1000 ms span = 80 % = threshold. + // Viewport right edge = evMin + 800 = 5800. startMs = 5800 - 8000 = -2200. + const startMs = + EV_MIN + (EV_MAX - EV_MIN) * X_PAN_IN_VIEW_THRESHOLD - SCREEN_MS; + expect(calcScrollXPan(input({ startMs }))).toBeNull(); + }); + + it('returns null when events are only partially visible but above threshold (custom)', () => { + // With a strict threshold of 1.0, the 100 % overlap case must still skip. + expect( + calcScrollXPan(input({ startMs: 0, inViewThreshold: 1.0 })), + ).toBeNull(); + }); + + it('does NOT skip when overlap is below threshold', () => { + // Viewport 10000–18000 ms: no overlap with 5000–6000 ms events. + expect(calcScrollXPan(input({ startMs: 10_000 }))).not.toBeNull(); + }); +}); + +// ── DESC sort order ─────────────────────────────────────────────────────────── +describe('calcScrollXPan – DESC sort (newest at top)', () => { + // Events are off-screen; startMs = 20 000 (viewport 20000–28000, events 5000–6000). + const offScreen = { startMs: 20_000 }; + + it('scroll UP (deltaY < 0) β†’ places events on the right side', () => { + // goingTowardNewer = true β†’ newStartMs = evMax + pad - screenMs + const expected = EV_MAX + PAD_MS - SCREEN_MS; + const result = calcScrollXPan( + input({ ...offScreen, deltaY: -10, sortOrder: 'desc' }), + ); + expect(result).toBeCloseTo(expected); + }); + + it('scroll DOWN (deltaY > 0) β†’ places events on the left side', () => { + // goingTowardNewer = false β†’ newStartMs = evMin - pad + const expected = EV_MIN - PAD_MS; + const result = calcScrollXPan( + input({ ...offScreen, deltaY: 10, sortOrder: 'desc' }), + ); + expect(result).toBeCloseTo(expected); + }); + + it("scroll UP result: events' right edge lands padVw from viewport right", () => { + const result = calcScrollXPan( + input({ ...offScreen, deltaY: -10, sortOrder: 'desc' }), + )!; + const viewRight = result + SCREEN_MS; + // evMax should be padMs from the right edge. + expect(viewRight - EV_MAX).toBeCloseTo(PAD_MS); + }); + + it("scroll DOWN result: events' left edge lands padVw from viewport left", () => { + const result = calcScrollXPan( + input({ ...offScreen, deltaY: 10, sortOrder: 'desc' }), + )!; + // evMin should be padMs from the left edge (startMs). + expect(EV_MIN - result).toBeCloseTo(PAD_MS); + }); +}); + +// ── ASC sort order ──────────────────────────────────────────────────────────── +describe('calcScrollXPan – ASC sort (oldest at top)', () => { + const offScreen = { startMs: 20_000 }; + + it('scroll UP (deltaY < 0) β†’ places events on the left side', () => { + // goingTowardNewer = false (UP in ASC goes toward older/earlier) β†’ left + const expected = EV_MIN - PAD_MS; + const result = calcScrollXPan( + input({ ...offScreen, deltaY: -10, sortOrder: 'asc' }), + ); + expect(result).toBeCloseTo(expected); + }); + + it('scroll DOWN (deltaY > 0) β†’ places events on the right side', () => { + // goingTowardNewer = true (DOWN in ASC goes toward newer/later) β†’ right + const expected = EV_MAX + PAD_MS - SCREEN_MS; + const result = calcScrollXPan( + input({ ...offScreen, deltaY: 10, sortOrder: 'asc' }), + ); + expect(result).toBeCloseTo(expected); + }); + + it('ASC DOWN result is same as DESC UP result (both go toward newer events)', () => { + const ascDown = calcScrollXPan( + input({ ...offScreen, deltaY: 10, sortOrder: 'asc' }), + ); + const descUp = calcScrollXPan( + input({ ...offScreen, deltaY: -10, sortOrder: 'desc' }), + ); + expect(ascDown).toBeCloseTo(descUp!); + }); + + it('ASC UP result is same as DESC DOWN result (both go toward older events)', () => { + const ascUp = calcScrollXPan( + input({ ...offScreen, deltaY: -10, sortOrder: 'asc' }), + ); + const descDown = calcScrollXPan( + input({ ...offScreen, deltaY: 10, sortOrder: 'desc' }), + ); + expect(ascUp).toBeCloseTo(descDown!); + }); +}); + +// ── Custom padding ──────────────────────────────────────────────────────────── +describe('calcScrollXPan – custom padVw', () => { + const offScreen = { startMs: 20_000 }; + + it("padVw=0 puts the events' left edge exactly at startMs (scroll down, desc)", () => { + const result = calcScrollXPan( + input({ ...offScreen, deltaY: 10, sortOrder: 'desc', padVw: 0 }), + )!; + expect(result).toBeCloseTo(EV_MIN); + }); + + it("padVw=0 puts the events' right edge exactly at viewport right (scroll up, desc)", () => { + const result = calcScrollXPan( + input({ ...offScreen, deltaY: -10, sortOrder: 'desc', padVw: 0 }), + )!; + expect(result + SCREEN_MS).toBeCloseTo(EV_MAX); + }); + + it('larger padVw produces more offset from the edge', () => { + const r1 = calcScrollXPan( + input({ ...offScreen, deltaY: 10, sortOrder: 'desc', padVw: 0.1 }), + )!; + const r2 = calcScrollXPan( + input({ ...offScreen, deltaY: 10, sortOrder: 'desc', padVw: 0.5 }), + )!; + // larger pad β†’ smaller startMs (more left β†’ events further from left edge) + expect(r2).toBeLessThan(r1); + }); +}); + +// ── inViewThreshold ─────────────────────────────────────────────────────────── +describe('calcScrollXPan – inViewThreshold', () => { + it('threshold=0 means any fraction β‰₯ 0 skips, so fully visible always skips', () => { + // visibleFraction (1.0) >= 0 β†’ skip. + expect( + calcScrollXPan(input({ startMs: 0, inViewThreshold: 0 })), + ).toBeNull(); + }); + + it('threshold=1.0 skips only when events are 100% visible', () => { + // 100 % visible β†’ skip. + expect( + calcScrollXPan(input({ startMs: 0, inViewThreshold: 1.0 })), + ).toBeNull(); + // ~99 % visible (10 ms of evMax cut off by viewport right edge) β†’ pan. + // startMs = EV_MAX - 10 - SCREEN_MS so viewport right = EV_MAX - 10. + const partialStartMs = EV_MAX - 10 - SCREEN_MS; + expect( + calcScrollXPan(input({ startMs: partialStartMs, inViewThreshold: 1.0 })), + ).not.toBeNull(); + }); +}); diff --git a/src/lib/components/pixi-timeline/renderer/scroll-x-pan.ts b/src/lib/components/pixi-timeline/renderer/scroll-x-pan.ts new file mode 100644 index 0000000000..fad2577f5d --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/scroll-x-pan.ts @@ -0,0 +1,77 @@ +/** + * Pure helper for the "pan X on vertical scroll" feature. + * + * When the user scrolls vertically the timeline should automatically pan the + * X axis so the events now in the Y viewport remain visible in the canvas. + * + * Direction rules (matches the reading direction of each sort order): + * DESC (newest at top) + * scroll UP β†’ going toward newer/later events β†’ place on RIGHT + * scroll DOWN β†’ going toward older/earlier events β†’ place on LEFT + * ASC (oldest at top) + * scroll UP β†’ going toward older/earlier events β†’ place on LEFT + * scroll DOWN β†’ going toward newer/later events β†’ place on RIGHT + * + * Constants (exported for tests and for PixiRenderer to import): + * SCROLL_X_PAD_VW – padding as a fraction of canvas width (default 0.33) + * X_PAN_EASE – per-frame ease factor for animated X pan (default 0.15) + * X_PAN_IN_VIEW_THRESHOLD – skip pan if this fraction of event span is visible (default 0.8) + */ + +export const SCROLL_X_PAD_VW = 0.33; +export const X_PAN_EASE = 0.15; +export const X_PAN_IN_VIEW_THRESHOLD = 0.8; + +export interface XPanInput { + evMinMs: number; + evMaxMs: number; + startMs: number; + screenW: number; + zoom: number; + deltaY: number; + sortOrder: 'desc' | 'asc'; + padVw?: number; + inViewThreshold?: number; +} + +/** + * Calculate the new `startMs` for the X-pan-on-vertical-scroll feature. + * + * Returns `null` if the events are already sufficiently visible and no pan is + * needed. Otherwise returns the raw (unclamped) desired viewport start in + * milliseconds β€” the caller is responsible for clamping to valid bounds. + */ +export function calcScrollXPan({ + evMinMs, + evMaxMs, + startMs, + screenW, + zoom, + deltaY, + sortOrder, + padVw = SCROLL_X_PAD_VW, + inViewThreshold = X_PAN_IN_VIEW_THRESHOLD, +}: XPanInput): number | null { + const screenMs = screenW / zoom; + + const overlapMs = Math.max( + 0, + Math.min(evMaxMs, startMs + screenMs) - Math.max(evMinMs, startMs), + ); + const visibleFraction = overlapMs / Math.max(1, evMaxMs - evMinMs); + if (visibleFraction >= inViewThreshold) { + return null; + } + + const padMs = (screenW * padVw) / zoom; + + // Newer events are later in time (higher ms = right side of timeline). + // DESC: scrolling UP moves toward newer events. + // ASC: scrolling DOWN moves toward newer events. + const goingTowardNewer = + (deltaY < 0 && sortOrder === 'desc') || (deltaY > 0 && sortOrder === 'asc'); + + return goingTowardNewer + ? evMaxMs + padMs - screenMs // events' right edge padMs from viewport right + : evMinMs - padMs; // events' left edge padMs from viewport left +} diff --git a/src/lib/components/pixi-timeline/renderer/track-index.test.ts b/src/lib/components/pixi-timeline/renderer/track-index.test.ts new file mode 100644 index 0000000000..2a2dc36721 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/track-index.test.ts @@ -0,0 +1,320 @@ +/** + * Tests for the CSR (Compressed Sparse Row) TrackIndex structure. + * + * RED tests run first to prove the old byTrack approach's limitations, then + * GREEN tests validate the new structure's correctness and zero-copy guarantees. + */ +import { describe, expect, it } from 'vitest'; + +import { + buildTrackIndex, + collectBestPerTrack, + getTrackEventBounds, + getTrackSlice, + trackHasEvents, +} from './track-index'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +function makeGroups(specs: { poolIdx: number; trackIdx: number }[]) { + return specs; +} + +const THREE_TRACKS = makeGroups([ + { poolIdx: 0, trackIdx: 0 }, + { poolIdx: 1, trackIdx: 2 }, + { poolIdx: 2, trackIdx: 2 }, + { poolIdx: 3, trackIdx: 4 }, +]); + +// ── buildTrackIndex ─────────────────────────────────────────────────────────── + +describe('buildTrackIndex – storage is TypedArrays, not JS objects', () => { + it('offsets is an Int32Array', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + expect(idx.offsets).toBeInstanceOf(Int32Array); + }); + + it('poolIdxs is an Int32Array', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + expect(idx.poolIdxs).toBeInstanceOf(Int32Array); + }); + + it('offsets has length numTracks + 1', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + expect(idx.offsets.length).toBe(6); // 5 tracks + 1 + }); + + it('poolIdxs has length equal to the number of groups', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + expect(idx.poolIdxs.length).toBe(THREE_TRACKS.length); + }); + + it('empty group list produces zero-length poolIdxs', () => { + const idx = buildTrackIndex([], 0); + expect(idx.poolIdxs.length).toBe(0); + expect(idx.numTracks).toBe(0); + }); + + it('total memory is far less than equivalent JS objects', () => { + // 1000 groups β†’ CSR = 2 Γ— 1000 Γ— 4 bytes = 8KB + // PinEvent objects = 1000 Γ— ~88 bytes = ~88KB + const groups = Array.from({ length: 1000 }, (_, i) => ({ + poolIdx: i, + trackIdx: i % 50, + })); + const idx = buildTrackIndex(groups, 50); + const csrBytes = idx.offsets.byteLength + idx.poolIdxs.byteLength; + expect(csrBytes).toBeLessThan(10_000); // < 10KB for 1000 groups + }); +}); + +describe('buildTrackIndex – correctness', () => { + it('numTracks matches the supplied value', () => { + expect(buildTrackIndex(THREE_TRACKS, 5).numTracks).toBe(5); + }); + + it('offsets[0] is always 0', () => { + expect(buildTrackIndex(THREE_TRACKS, 5).offsets[0]).toBe(0); + }); + + it('offsets[numTracks] equals total number of groups', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + expect(idx.offsets[5]).toBe(THREE_TRACKS.length); + }); + + it('track with 2 events has a span of 2 in offsets', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + const span = idx.offsets[3] - idx.offsets[2]; // track 2 has poolIdxs 1 and 2 + expect(span).toBe(2); + }); + + it('empty track has a span of 0', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + const span = idx.offsets[2] - idx.offsets[1]; // track 1 is empty + expect(span).toBe(0); + }); + + it('contains all supplied poolIdxs across all tracks', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + const all = Array.from(idx.poolIdxs).sort((a, b) => a - b); + expect(all).toEqual([0, 1, 2, 3]); + }); +}); + +// ── getTrackSlice ───────────────────────────────────────────────────────────── + +describe('getTrackSlice – zero-copy views', () => { + it('returns an Int32Array (no Array)', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + expect(getTrackSlice(idx, 0)).toBeInstanceOf(Int32Array); + }); + + it('returned slice shares the same buffer as poolIdxs (no copy)', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + const slice = getTrackSlice(idx, 0); + expect(slice.buffer).toBe(idx.poolIdxs.buffer); + }); + + it('slice for track 0 contains poolIdx 0', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + expect(Array.from(getTrackSlice(idx, 0))).toContain(0); + }); + + it('slice for track 2 contains poolIdxs 1 and 2', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + const slice = Array.from(getTrackSlice(idx, 2)).sort(); + expect(slice).toEqual([1, 2]); + }); + + it('slice for empty track has length 0', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + expect(getTrackSlice(idx, 1).length).toBe(0); + }); + + it('out-of-range track returns empty slice without throwing', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + expect(() => getTrackSlice(idx, 99)).not.toThrow(); + expect(getTrackSlice(idx, 99).length).toBe(0); + }); +}); + +// ── trackHasEvents ──────────────────────────────────────────────────────────── + +describe('trackHasEvents', () => { + it('returns true for a track with events', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + expect(trackHasEvents(idx, 0)).toBe(true); + expect(trackHasEvents(idx, 2)).toBe(true); + expect(trackHasEvents(idx, 4)).toBe(true); + }); + + it('returns false for an empty track', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + expect(trackHasEvents(idx, 1)).toBe(false); + expect(trackHasEvents(idx, 3)).toBe(false); + }); + + it('returns false for an out-of-range track', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + expect(trackHasEvents(idx, 99)).toBe(false); + }); +}); + +// ── getTrackEventBounds ─────────────────────────────────────────────────────── + +describe('getTrackEventBounds – X range for visible tracks', () => { + const META: Record = { + 0: { startMs: 1000, endMs: 2000 }, + 1: { startMs: 500, endMs: 1500 }, + 2: { startMs: 3000, endMs: 4000 }, + 3: { startMs: 100, endMs: 200 }, + }; + const getMs = (poolIdx: number) => META[poolIdx]; + + it('returns null when no tracks in range have events', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + // tracks 1 and 3 are empty + expect(getTrackEventBounds(idx, 1, 1, getMs)).toBeNull(); + }); + + it('returns correct min/max for a single track', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + const bounds = getTrackEventBounds(idx, 0, 0, getMs); + expect(bounds).toEqual({ evMinMs: 1000, evMaxMs: 2000 }); + }); + + it('returns the union of all events across a range of tracks', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + // Tracks 0-4: track 0 (1000-2000), track 2 (500-4000), track 4 (100-200) + const bounds = getTrackEventBounds(idx, 0, 4, getMs); + expect(bounds?.evMinMs).toBe(100); + expect(bounds?.evMaxMs).toBe(4000); + }); + + it('handles a range that spans empty tracks', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + // tracks 0-1: only track 0 has events + const bounds = getTrackEventBounds(idx, 0, 1, getMs); + expect(bounds).toEqual({ evMinMs: 1000, evMaxMs: 2000 }); + }); +}); + +// ── collectBestPerTrack ─────────────────────────────────────────────────────── + +describe('collectBestPerTrack – gutter best-event selection via CSR', () => { + const EVENTS: Record< + number, + { startMs: number; endMs: number; pixiType: string } + > = { + 0: { startMs: 0, endMs: 5000, pixiType: 'GROUP_ACTIVITY' }, + 1: { startMs: 100, endMs: 200, pixiType: 'GROUP_ACTIVITY' }, + 2: { startMs: 200, endMs: 9000, pixiType: 'GROUP_TIMER' }, + 3: { startMs: 0, endMs: 1000, pixiType: 'GROUP_ACTIVITY' }, + }; + const TYPE_PRIORITY = { GROUP_TIMER: 1, GROUP_ACTIVITY: 5 }; + const DEFAULT_PRIORITY = 99; + const getEvent = (poolIdx: number) => EVENTS[poolIdx]; + + it('returns an array of objects with poolIdx, startMs, endMs, pixiType', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + const out = collectBestPerTrack( + [0, 2, 4], + idx, + getEvent, + TYPE_PRIORITY, + DEFAULT_PRIORITY, + 10, + ); + expect(out.length).toBeGreaterThan(0); + for (const item of out) { + expect(item).toHaveProperty('poolIdx'); + expect(item).toHaveProperty('startMs'); + expect(item).toHaveProperty('endMs'); + expect(item).toHaveProperty('pixiType'); + } + }); + + it('selects the longest-duration event per track', () => { + // Track 2 has poolIdxs 1 (100ms) and 2 (8800ms) β€” should pick poolIdx 2 + const idx = buildTrackIndex(THREE_TRACKS, 5); + const out = collectBestPerTrack( + [2], + idx, + getEvent, + TYPE_PRIORITY, + DEFAULT_PRIORITY, + 10, + ); + expect(out[0].poolIdx).toBe(2); + }); + + it('skips empty tracks silently', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + // track 1 is empty + const out = collectBestPerTrack( + [1], + idx, + getEvent, + TYPE_PRIORITY, + DEFAULT_PRIORITY, + 10, + ); + expect(out.length).toBe(0); + }); + + it('sorts output by duration DESC', () => { + // track 0 (poolIdx 0: 5000ms), track 2 (poolIdx 2: 8800ms), track 4 (poolIdx 3: 1000ms) + const idx = buildTrackIndex(THREE_TRACKS, 5); + const out = collectBestPerTrack( + [0, 2, 4], + idx, + getEvent, + TYPE_PRIORITY, + DEFAULT_PRIORITY, + 10, + ); + const durations = out.map((e) => e.endMs - e.startMs); + for (let i = 1; i < durations.length; i++) { + expect(durations[i]).toBeLessThanOrEqual(durations[i - 1]); + } + }); + + it('caps output at maxSample', () => { + const idx = buildTrackIndex(THREE_TRACKS, 5); + const out = collectBestPerTrack( + [0, 2, 4], + idx, + getEvent, + TYPE_PRIORITY, + DEFAULT_PRIORITY, + 2, + ); + expect(out.length).toBeLessThanOrEqual(2); + }); + + it('breaks duration ties by type priority (lower number wins)', () => { + // Both poolIdx 0 and 1 have duration 5000ms, but types differ + const tieGroups = makeGroups([ + { poolIdx: 0, trackIdx: 0 }, + { poolIdx: 1, trackIdx: 1 }, + ]); + const tieEvents: Record< + number, + { startMs: number; endMs: number; pixiType: string } + > = { + 0: { startMs: 0, endMs: 5000, pixiType: 'GROUP_ACTIVITY' }, // priority 5 + 1: { startMs: 0, endMs: 5000, pixiType: 'GROUP_TIMER' }, // priority 1 + }; + const idx = buildTrackIndex(tieGroups, 2); + const out = collectBestPerTrack( + [0, 1], + idx, + (p) => tieEvents[p], + TYPE_PRIORITY, + DEFAULT_PRIORITY, + 10, + ); + expect(out[0].pixiType).toBe('GROUP_TIMER'); // timer wins (lower priority number) + }); +}); diff --git a/src/lib/components/pixi-timeline/renderer/track-index.ts b/src/lib/components/pixi-timeline/renderer/track-index.ts new file mode 100644 index 0000000000..6f38e74ba9 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/track-index.ts @@ -0,0 +1,175 @@ +/** + * Compressed Sparse Row (CSR) index for quick per-track event lookups. + * + * Replaces the old `byTrack: PinEvent[][]` approach which stored duplicated JS + * objects per track. With CSR we store only Int32Arrays of poolIdxs, and look + * up event data from `getGroupMeta(poolIdx)` on demand. + * + * Memory layout (numTracks = T, numGroups = N): + * offsets : Int32Array[T+1] β€” offsets[t] = start of track t inside poolIdxs + * poolIdxs : Int32Array[N] β€” poolIdxs[offsets[t]..offsets[t+1]) = groups on track t + * + * Zero-copy reads: `getTrackSlice` returns a subarray view (no allocation). + */ + +export interface TrackIndex { + readonly offsets: Int32Array; + readonly poolIdxs: Int32Array; + readonly numTracks: number; +} + +const EMPTY_INT32 = new Int32Array(0); + +/** + * Build a TrackIndex from an array of (poolIdx, trackIdx) pairs. + * Groups on the same track preserve the order they appear in `groups`. + */ +export function buildTrackIndex( + groups: readonly { poolIdx: number; trackIdx: number }[], + numTracks: number, +): TrackIndex { + if (numTracks === 0 || groups.length === 0) { + return { + offsets: new Int32Array(numTracks + 1), + poolIdxs: EMPTY_INT32, + numTracks, + }; + } + + const perTrack = new Int32Array(numTracks); + for (const { trackIdx } of groups) { + if (trackIdx >= 0 && trackIdx < numTracks) perTrack[trackIdx]++; + } + + const offsets = new Int32Array(numTracks + 1); + for (let t = 0; t < numTracks; t++) { + offsets[t + 1] = offsets[t] + perTrack[t]; + } + + const poolIdxs = new Int32Array(groups.length); + const writePos = new Int32Array(offsets); // copy used as write cursors + for (const { poolIdx, trackIdx } of groups) { + if (trackIdx >= 0 && trackIdx < numTracks) { + poolIdxs[writePos[trackIdx]++] = poolIdx; + } + } + + return { offsets, poolIdxs, numTracks }; +} + +/** + * Return a zero-copy Int32Array view of the poolIdxs for track `t`. + * The returned slice shares the same underlying buffer as `index.poolIdxs`. + */ +export function getTrackSlice(index: TrackIndex, t: number): Int32Array { + if (t < 0 || t >= index.numTracks) return EMPTY_INT32; + return index.poolIdxs.subarray(index.offsets[t], index.offsets[t + 1]); +} + +/** True if track `t` contains at least one event. */ +export function trackHasEvents(index: TrackIndex, t: number): boolean { + if (t < 0 || t >= index.numTracks) return false; + return index.offsets[t + 1] > index.offsets[t]; +} + +/** + * Return the union bounding box [evMinMs, evMaxMs] across all events in tracks + * fromTrack..toTrack (inclusive). Returns null if no events exist in range. + */ +export function getTrackEventBounds( + index: TrackIndex, + fromTrack: number, + toTrack: number, + getMs: (poolIdx: number) => { startMs: number; endMs: number }, +): { evMinMs: number; evMaxMs: number } | null { + let evMinMs = Infinity; + let evMaxMs = -Infinity; + + const lo = Math.max(0, fromTrack); + const hi = Math.min(index.numTracks - 1, toTrack); + + for (let t = lo; t <= hi; t++) { + const slice = getTrackSlice(index, t); + for (let i = 0; i < slice.length; i++) { + const { startMs, endMs } = getMs(slice[i]); + if (startMs < evMinMs) evMinMs = startMs; + if (endMs > evMaxMs) evMaxMs = endMs; + } + } + + return evMinMs === Infinity ? null : { evMinMs, evMaxMs }; +} + +/** Shape returned by collectBestPerTrack. */ +export type GutterEventRef = { + poolIdx: number; + startMs: number; + endMs: number; + pixiType: string; + pixiStatus: string; +}; + +/** + * For each track in `trackIdxs`, pick the single best event using: + * 1. Longest duration (endMs - startMs) wins + * 2. Ties broken by typePriority (lower number = more important) + * + * Output is sorted by duration DESC. Pass `Infinity` for `maxSample` to + * return all candidates (the caller is then responsible for limiting output, + * e.g. via packGutterPins). Empty tracks are silently skipped. + */ +export function collectBestPerTrack( + trackIdxs: number[], + index: TrackIndex, + getEvent: (poolIdx: number) => { + startMs: number; + endMs: number; + pixiType: string; + pixiStatus?: string; + }, + typePriority: Record, + typePriorityDefault: number, + maxSample: number, +): GutterEventRef[] { + const candidates: GutterEventRef[] = []; + + for (const t of trackIdxs) { + const slice = getTrackSlice(index, t); + if (slice.length === 0) continue; + + let bestPoolIdx = -1; + let bestDuration = -1; + let bestPriority = Infinity; + + for (let i = 0; i < slice.length; i++) { + const pIdx = slice[i]; + const ev = getEvent(pIdx); + const duration = ev.endMs - ev.startMs; + const priority = typePriority[ev.pixiType] ?? typePriorityDefault; + + if ( + duration > bestDuration || + (duration === bestDuration && priority < bestPriority) + ) { + bestPoolIdx = pIdx; + bestDuration = duration; + bestPriority = priority; + } + } + + if (bestPoolIdx === -1) continue; + const ev = getEvent(bestPoolIdx); + candidates.push({ poolIdx: bestPoolIdx, pixiStatus: '', ...ev }); + } + + candidates.sort((a, b) => { + const dA = a.endMs - a.startMs; + const dB = b.endMs - b.startMs; + if (dB !== dA) return dB - dA; + const pA = typePriority[a.pixiType] ?? typePriorityDefault; + const pB = typePriority[b.pixiType] ?? typePriorityDefault; + return pA - pB; + }); + + return candidates.slice(0, maxSample); +} diff --git a/src/lib/components/pixi-timeline/renderer/viewport-clamp.test.ts b/src/lib/components/pixi-timeline/renderer/viewport-clamp.test.ts new file mode 100644 index 0000000000..f0d3eb9a41 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/viewport-clamp.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from 'vitest'; + +import { + clampScaleY, + clampViewportStartMs, + initialViewport, + MAX_SCALE_Y, +} from './viewport-clamp'; + +const DATA: { startMs: number; endMs: number } = { startMs: 0, endMs: 1000 }; +const ZOOM = 1; // 1 px/ms β†’ halfSpanMs = screenW / 2 +const SCREEN_W = 200; // halfSpanMs = 100 ms β†’ min = -100, max = 900 + +describe('clampViewportStartMs β€” hard limits', () => { + it('passes through a value within range', () => { + expect(clampViewportStartMs(400, DATA, ZOOM, SCREEN_W)).toBe(400); + }); + + it('clamps to min when startMs is too far left', () => { + const result = clampViewportStartMs(-9999, DATA, ZOOM, SCREEN_W); + const expected = DATA.startMs - SCREEN_W / (2 * ZOOM); // -100 + expect(result).toBe(expected); + }); + + it('clamps to max when startMs is too far right', () => { + const result = clampViewportStartMs(9999, DATA, ZOOM, SCREEN_W); + const expected = DATA.endMs - SCREEN_W / (2 * ZOOM); // 900 + expect(result).toBe(expected); + }); + + it('allows startMs exactly at the minimum', () => { + const min = DATA.startMs - SCREEN_W / (2 * ZOOM); + expect(clampViewportStartMs(min, DATA, ZOOM, SCREEN_W)).toBe(min); + }); + + it('allows startMs exactly at the maximum', () => { + const max = DATA.endMs - SCREEN_W / (2 * ZOOM); + expect(clampViewportStartMs(max, DATA, ZOOM, SCREEN_W)).toBe(max); + }); +}); + +describe('clampViewportStartMs β€” centering invariant', () => { + it('at the left stop, first event is centerable (first event is in right half)', () => { + const min = DATA.startMs - SCREEN_W / (2 * ZOOM); + const startMs = clampViewportStartMs(-9999, DATA, ZOOM, SCREEN_W); + // viewport spans [startMs, startMs + screenW/zoom] + // center of viewport = startMs + halfSpanMs = startMs + screenW/(2*zoom) + const viewCenter = startMs + SCREEN_W / (2 * ZOOM); + expect(startMs).toBe(min); + expect(viewCenter).toBe(DATA.startMs); // first event exactly at center + }); + + it('at the right stop, last event is centerable (last event is at viewport center)', () => { + const max = DATA.endMs - SCREEN_W / (2 * ZOOM); + const startMs = clampViewportStartMs(9999, DATA, ZOOM, SCREEN_W); + const viewCenter = startMs + SCREEN_W / (2 * ZOOM); + expect(startMs).toBe(max); + expect(viewCenter).toBe(DATA.endMs); // last event exactly at center + }); +}); + +describe('clampViewportStartMs β€” zoom sensitivity', () => { + it('higher zoom β†’ smaller halfSpanMs β†’ min and max shift right', () => { + const highZoom = 4; // halfSpanMs = 200/(2*4) = 25 vs 100 at zoom=1 + const min = DATA.startMs - SCREEN_W / (2 * highZoom); // -25 + const max = DATA.endMs - SCREEN_W / (2 * highZoom); // 975 + expect(clampViewportStartMs(-9999, DATA, highZoom, SCREEN_W)).toBe(min); + expect(clampViewportStartMs(9999, DATA, highZoom, SCREEN_W)).toBe(max); + // Both endpoints move right compared to zoom=1 (less negative min, higher max) + expect(min).toBeGreaterThan(DATA.startMs - SCREEN_W / (2 * ZOOM)); + expect(max).toBeGreaterThan(DATA.endMs - SCREEN_W / (2 * ZOOM)); + }); + + it('lower zoom β†’ larger halfSpanMs β†’ min and max shift left', () => { + const lowZoom = 0.1; // halfSpanMs = 200/(2*0.1) = 1000 + const min = DATA.startMs - SCREEN_W / (2 * lowZoom); // -1000 + const max = DATA.endMs - SCREEN_W / (2 * lowZoom); // 0 + expect(clampViewportStartMs(-9999, DATA, lowZoom, SCREEN_W)).toBe(min); + expect(clampViewportStartMs(9999, DATA, lowZoom, SCREEN_W)).toBe(max); + // Both endpoints move left compared to zoom=1 + expect(min).toBeLessThan(DATA.startMs - SCREEN_W / (2 * ZOOM)); + expect(max).toBeLessThan(DATA.endMs - SCREEN_W / (2 * ZOOM)); + }); + + it('wider screen produces wider padding at the same zoom', () => { + const wideScreen = 800; + const minWide = DATA.startMs - wideScreen / (2 * ZOOM); + const minNarrow = DATA.startMs - SCREEN_W / (2 * ZOOM); + expect(minWide).toBeLessThan(minNarrow); + }); +}); + +describe('clampViewportStartMs β€” non-zero data origin', () => { + it('works correctly when dataRange does not start at zero', () => { + const shifted = { startMs: 500, endMs: 2000 }; + const min = shifted.startMs - SCREEN_W / (2 * ZOOM); // 400 + const max = shifted.endMs - SCREEN_W / (2 * ZOOM); // 1900 + expect(clampViewportStartMs(-9999, shifted, ZOOM, SCREEN_W)).toBe(min); + expect(clampViewportStartMs(9999, shifted, ZOOM, SCREEN_W)).toBe(max); + expect(clampViewportStartMs(1000, shifted, ZOOM, SCREEN_W)).toBe(1000); + }); +}); + +describe('clampViewportStartMs β€” edge: data fits entirely within viewport', () => { + it('when data span < screenW/zoom, min > max and clamp still returns a sane value', () => { + // dataRange is 10ms wide, viewport is 200ms wide (screenW=200, zoom=1) + const tiny = { startMs: 0, endMs: 10 }; + const min = tiny.startMs - SCREEN_W / (2 * ZOOM); // -100 + const max = tiny.endMs - SCREEN_W / (2 * ZOOM); // -90 + // min < max still, both are negative + const result = clampViewportStartMs(0, tiny, ZOOM, SCREEN_W); + expect(result).toBe(max); // clamped to max since 0 > max (-90) + expect(result).toBeGreaterThanOrEqual(min); + }); +}); + +// --------------------------------------------------------------------------- +// clampScaleY β€” Y zoom limits +// --------------------------------------------------------------------------- + +const TRACK_H = 28; // matches PixiRenderer default config +const MIN_ROW_PX = 12; // default minRowPx + +describe('clampScaleY β€” maximum (rows never taller than default)', () => { + it('MAX_SCALE_Y is 1.0', () => { + expect(MAX_SCALE_Y).toBe(1.0); + }); + + it('at MAX_SCALE_Y, row height equals trackHeight exactly', () => { + const rowHeight = TRACK_H * MAX_SCALE_Y; + expect(rowHeight).toBe(TRACK_H); + }); + + it('clamps to MAX_SCALE_Y when candidate exceeds it', () => { + expect(clampScaleY(2, TRACK_H)).toBe(MAX_SCALE_Y); + expect(clampScaleY(5, TRACK_H)).toBe(MAX_SCALE_Y); + expect(clampScaleY(100, TRACK_H)).toBe(MAX_SCALE_Y); + }); + + it('passes through a value exactly at MAX_SCALE_Y', () => { + expect(clampScaleY(MAX_SCALE_Y, TRACK_H)).toBe(MAX_SCALE_Y); + }); + + it('passes through a value below MAX_SCALE_Y', () => { + expect(clampScaleY(0.5, TRACK_H)).toBe(0.5); + expect(clampScaleY(0.8, TRACK_H)).toBe(0.8); + }); +}); + +describe('clampScaleY β€” minimum (rows stay readable)', () => { + it('default min gives 12px rows', () => { + const minScaleY = MIN_ROW_PX / TRACK_H; + expect(clampScaleY(0, TRACK_H)).toBe(minScaleY); + expect(clampScaleY(-99, TRACK_H)).toBe(minScaleY); + }); + + it('passes through a value exactly at the minimum', () => { + const min = MIN_ROW_PX / TRACK_H; + expect(clampScaleY(min, TRACK_H)).toBe(min); + }); + + it('custom minRowPx changes the floor', () => { + const result = clampScaleY(0, TRACK_H, 8); + expect(result).toBe(8 / TRACK_H); + }); +}); + +describe('clampScaleY β€” row height invariant', () => { + it('resulting row height is always between minRowPx and trackHeight', () => { + const candidates = [-5, 0, 0.3, 0.5, 0.71, 1.0, 1.5, 5, 100]; + for (const c of candidates) { + const scale = clampScaleY(c, TRACK_H); + const rowH = scale * TRACK_H; + expect(rowH).toBeGreaterThanOrEqual(MIN_ROW_PX); + expect(rowH).toBeLessThanOrEqual(TRACK_H); + } + }); + + it('at max zoom, row height is exactly trackHeight (2Γ— icon size)', () => { + const ICON_SIZE = 14; + const maxScale = clampScaleY(999, TRACK_H); + expect(maxScale * TRACK_H).toBe(TRACK_H); + expect(TRACK_H).toBe(ICON_SIZE * 2); // documents the 2Γ— relationship + }); +}); + +describe('initialViewport', () => { + const MIN_ZOOM = 0.0001; + const MAX_ZOOM = 100; + const SCREEN_W = 1200; + + it('uses fit-all for short workflows (≀30s)', () => { + const { startMs, zoom } = initialViewport( + 10_000, + SCREEN_W, + MIN_ZOOM, + MAX_ZOOM, + ); + expect(startMs).toBe(-200); + const expectedZoom = SCREEN_W / (10_000 + 600); + expect(zoom).toBeCloseTo(expectedZoom); + }); + + it('uses scoped view for long workflows (>30s)', () => { + const endRelMs = 414_000; // 6.9 minutes + const { startMs, zoom } = initialViewport( + endRelMs, + SCREEN_W, + MIN_ZOOM, + MAX_ZOOM, + ); + // windowMs = min(60_000, 414_000 * 0.15) = 60_000 + const windowMs = 60_000; + const expectedZoom = SCREEN_W / windowMs; + expect(zoom).toBeCloseTo(expectedZoom); + // startMs places desc events (near endRelMs) in the left-center of the canvas + expect(startMs).toBe(endRelMs - windowMs * 0.25); + expect(startMs).toBeGreaterThan(0); // viewport is near the end, not at 0 + }); + + it('long workflow: events near endRelMs are visible in the left portion of canvas', () => { + const endRelMs = 414_000; + const { startMs, zoom } = initialViewport( + endRelMs, + SCREEN_W, + MIN_ZOOM, + MAX_ZOOM, + ); + // A desc event at endRelMs should have rawX < screenW (visible, not right-pinned) + const rawX = (endRelMs - startMs) * zoom; + expect(rawX).toBeLessThan(SCREEN_W); + expect(rawX).toBeGreaterThan(0); + }); + + it('respects minZoom bound', () => { + const { zoom } = initialViewport(1_000_000_000, SCREEN_W, 0.001, MAX_ZOOM); + expect(zoom).toBeGreaterThanOrEqual(0.001); + }); + + it('respects maxZoom bound', () => { + const { zoom } = initialViewport(1, SCREEN_W, MIN_ZOOM, 0.5); + expect(zoom).toBeLessThanOrEqual(0.5); + }); + + it('uses 15% of endRelMs window, capped at 60s', () => { + // 15% of 200,000ms = 30,000ms < 60,000 β†’ windowMs = 30,000 + const endRelMs = 200_000; + const windowMs = endRelMs * 0.15; // 30,000 + const { zoom } = initialViewport(endRelMs, SCREEN_W, MIN_ZOOM, MAX_ZOOM); + expect(zoom).toBeCloseTo(SCREEN_W / windowMs); + }); +}); + +describe('origin-shift compensation', () => { + it('viewport startMs must shift by originShift to keep visual position', () => { + // Simulates: desc events loaded first (origin=405000), then asc events + // load older data shifting origin to 1500ms. + const oldOrigin = 405_000; + const newOrigin = 1_500; + const originShift = oldOrigin - newOrigin; // 403,500ms + + // Initial viewport: startMs=-200ms for fit-all + const initialStartMs = -200; + const zoom = 0.122; + + // Desc event position BEFORE shift: + const descEventRelStart_before = 403_000 - 0; // relStart = absMs - oldOrigin... wait + // With old origin: relStart = 405000 - 405000 = 0ms β†’ x = (0 + 200) * 0.122 = 24px + const xBefore = (0 - initialStartMs) * zoom; // = (0 + 200) * 0.122 = 24.4 + + // After origin shift: relStart = 405000 - 1500 = 403500ms + const newRelStart = 405_000 - newOrigin; // 403500ms + const compensatedStartMs = initialStartMs + originShift; // -200 + 403500 = 403300ms + const xAfter = (newRelStart - compensatedStartMs) * zoom; // = (403500 - 403300) * 0.122 = 24.4 + + // Visual position must be identical before and after origin shift + compensation. + expect(xAfter).toBeCloseTo(xBefore, 5); + }); + + it('no compensation needed when origin does not change', () => { + const origin = 1_500; + const originShift = origin - origin; // = 0 + expect(originShift).toBe(0); + }); +}); diff --git a/src/lib/components/pixi-timeline/renderer/viewport-clamp.ts b/src/lib/components/pixi-timeline/renderer/viewport-clamp.ts new file mode 100644 index 0000000000..5813def797 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/viewport-clamp.ts @@ -0,0 +1,87 @@ +/** + * Pure viewport clamping logic β€” X pan and Y scale constraints. + * + * All functions are side-effect-free so they can be unit-tested without Pixi. + */ + +export type DataRange = { startMs: number; endMs: number }; + +/** + * Clamp a candidate viewport startMs so panning cannot go past the data. + * Half a screen-width of padding is added on each side so the first and last + * events can be scrolled to the horizontal center of the screen. + * + * @param startMs Candidate left edge of the viewport in relative ms + * @param dataRange Earliest and latest ms in the dataset + * @param zoom Current zoom in px/ms + * @param screenW Canvas width in pixels + */ +export function clampViewportStartMs( + startMs: number, + dataRange: DataRange, + zoom: number, + screenW: number, +): number { + const halfSpanMs = screenW / (2 * zoom); + const min = dataRange.startMs - halfSpanMs; + const max = dataRange.endMs - halfSpanMs; + return Math.max(min, Math.min(max, startMs)); +} + +/** + * Maximum Y scale factor. + * + * Capped at 1.0 (the default row height) so that bars never grow taller than + * their initial size. At scaleY = 1 rows are already 2Γ— the icon height, + * which is the readable sweet spot; larger values just waste vertical space. + * Y zoom-out (scaleY < 1) is unrestricted so more rows fit on screen. + */ +export const MAX_SCALE_Y = 1.0; + +/** + * Compute the initial viewport for a fresh timeline load. + * + * For long workflows (>30s) we zoom into the last 15% of the timeline + * (capped at 60s) where the first-loaded desc-section events live, so the + * canvas is populated immediately rather than showing an empty fit-all view. + * For short workflows we fall back to a plain fit-all. + * + * @param endRelMs Duration of the fully-loaded dataset in ms (maxMs - minMs) + * @param screenW Canvas width in pixels + * @param minZoom Minimum allowed zoom (px/ms) + * @param maxZoom Maximum allowed zoom (px/ms) + */ +export function initialViewport( + endRelMs: number, + screenW: number, + minZoom: number, + maxZoom: number, +): { startMs: number; zoom: number } { + if (endRelMs > 30_000) { + const windowMs = Math.min(60_000, endRelMs * 0.15); + const zoom = Math.max(minZoom, Math.min(maxZoom, screenW / windowMs)); + return { startMs: endRelMs - windowMs * 0.25, zoom }; + } + const span = endRelMs + 600; + const zoom = Math.max( + minZoom, + Math.min(maxZoom, screenW / Math.max(span, 1)), + ); + return { startMs: -200, zoom }; +} + +/** + * Clamp a candidate scaleY to the valid [min, MAX_SCALE_Y] range. + * + * @param candidate New scaleY after applying a zoom factor + * @param trackHeight Row height at scaleY = 1 (pixels); determines the min + * @param minRowPx Smallest acceptable row height in pixels (default 12) + */ +export function clampScaleY( + candidate: number, + trackHeight: number, + minRowPx = 12, +): number { + const min = minRowPx / trackHeight; + return Math.max(min, Math.min(MAX_SCALE_Y, candidate)); +} diff --git a/src/lib/components/pixi-timeline/timeline-ctx.svelte.ts b/src/lib/components/pixi-timeline/timeline-ctx.svelte.ts new file mode 100644 index 0000000000..9613c6ea7e --- /dev/null +++ b/src/lib/components/pixi-timeline/timeline-ctx.svelte.ts @@ -0,0 +1,202 @@ +import { SvelteMap } from 'svelte/reactivity'; + +import type { TimeScale } from './renderer/fonts'; +import type { TemporalEvent, TimelineViewport } from './types'; + +export const TIMELINE_CTX = Symbol('timeline-ctx'); + +export class TimelineState { + viewport = $state({ + startMs: 0, + endMs: 120_000, + scrollY: 0, + zoom: 0.008, + scaleY: 1.0, + }); + + hovered = $state(null); + selectedEvents = $state>({}); + hoveredPosition = $state({ x: 0, y: 0, barBottom: 0 }); + + dataRange = $state({ startMs: 0, endMs: 120_000 }); + + totalEvents = $state(0); + totalTracks = $state(0); + visibleEvents = $state(0); + scrollHeight = $state(0); + maxScrollY = $state(0); + expansionH = $state(0); + rowSize = $state(25); + effectiveTrackH = $state(22); + expandedPanelHeights = $state>({}); + rendererInfo = $state(''); + grouped = $state(true); + timeScale = $state('auto'); + sortOrder = $state<'desc' | 'asc'>('desc'); + keyboardFocusPoolIdx = $state(null); + + frameStats = $state({ + avgMs: 0, + p95Ms: 0, + p99Ms: 0, + maxMs: 0, + sampleCount: 0, + }); + openedChildWorkflows = $state< + { + runId: string; + label: string; + events: TemporalEvent[]; + trackIndex: number; + collapsed: boolean; + height: number; + topOffset: number; + }[] + >([]); +} + +export interface TimelineCtx { + isChild: boolean; + state: TimelineState; + panelEls: Map; + childLaneEls: Map; + childWorkflowData: Map; + setHovered: ( + event: TemporalEvent | null, + x?: number, + y?: number, + barHeight?: number, + ) => void; + setViewport: (partial: Partial) => void; + toggleSelected: (event: TemporalEvent) => void; + deselectEvent: (eventId: string) => void; + openChildWorkflow: (runId: string, label: string, trackIndex: number) => void; + closeChildWorkflow: (runId: string) => void; + toggleCollapseChildWorkflow: (runId: string) => void; +} + +export const panelEls = new SvelteMap(); + +export const childWorkflowData = new SvelteMap(); + +export const childLaneEls = new SvelteMap(); + +export const timelineState = new TimelineState(); + +export function setViewport(partial: Partial) { + Object.assign(timelineState.viewport, partial); +} + +export function setHovered( + event: TemporalEvent | null, + canvasX = 0, + canvasY = 0, + barHeight = 0, +) { + timelineState.hovered = event; + if (event) + timelineState.hoveredPosition = { + x: canvasX, + y: canvasY, + barBottom: canvasY + barHeight, + }; +} + +export function toggleSelected(event: TemporalEvent) { + if (timelineState.selectedEvents[event.eventId]) { + delete timelineState.selectedEvents[event.eventId]; + delete timelineState.expandedPanelHeights[event.eventId]; + } else { + timelineState.selectedEvents[event.eventId] = event; + } +} + +export function deselectEvent(eventId: string) { + delete timelineState.selectedEvents[eventId]; + delete timelineState.expandedPanelHeights[eventId]; +} + +export function openChildWorkflow( + runId: string, + label: string, + trackIndex: number, +) { + if (timelineState.openedChildWorkflows.some((c) => c.runId === runId)) return; + const events = childWorkflowData.get(runId); + if (!events) return; + timelineState.openedChildWorkflows.push({ + runId, + label, + events, + trackIndex, + collapsed: false, + height: 280, + topOffset: 0, + }); +} + +export function closeChildWorkflow(runId: string) { + const idx = timelineState.openedChildWorkflows.findIndex( + (c) => c.runId === runId, + ); + if (idx >= 0) timelineState.openedChildWorkflows.splice(idx, 1); +} + +export function toggleCollapseChildWorkflow(runId: string) { + const entry = timelineState.openedChildWorkflows.find( + (c) => c.runId === runId, + ); + if (entry) entry.collapsed = !entry.collapsed; +} + +export function makeMainCtx(): TimelineCtx { + return { + isChild: false, + state: timelineState, + panelEls, + childLaneEls, + childWorkflowData, + setHovered, + setViewport, + toggleSelected, + deselectEvent, + openChildWorkflow, + closeChildWorkflow, + toggleCollapseChildWorkflow, + }; +} + +export function makeChildCtx(): TimelineCtx { + const state = new TimelineState(); + + const childPanelEls = new SvelteMap(); + return { + isChild: true, + state, + panelEls: childPanelEls, + + childLaneEls: new SvelteMap(), + + childWorkflowData: new SvelteMap(), + setHovered: (ev, x = 0, y = 0, h = 0) => { + state.hovered = ev; + if (ev) state.hoveredPosition = { x, y, barBottom: y + h }; + }, + setViewport: (partial) => Object.assign(state.viewport, partial), + toggleSelected: (ev) => { + if (state.selectedEvents[ev.eventId]) { + delete state.selectedEvents[ev.eventId]; + delete state.expandedPanelHeights[ev.eventId]; + } else { + state.selectedEvents[ev.eventId] = ev; + } + }, + deselectEvent: (id) => { + delete state.selectedEvents[id]; + delete state.expandedPanelHeights[id]; + }, + openChildWorkflow: () => {}, + closeChildWorkflow: () => {}, + toggleCollapseChildWorkflow: () => {}, + }; +} diff --git a/src/lib/components/pixi-timeline/types.ts b/src/lib/components/pixi-timeline/types.ts new file mode 100644 index 0000000000..9907042fe7 --- /dev/null +++ b/src/lib/components/pixi-timeline/types.ts @@ -0,0 +1,56 @@ +export type EventStatus = + | 'scheduled' + | 'started' + | 'completed' + | 'failed' + | 'canceled' + | 'fired' + | 'signaled'; + +export interface TemporalEvent { + eventId: string; + eventType: string; + eventTime: string | number; + startMs: number; + endMs: number; + status: EventStatus; + trackIndex: number; + attributes: Record; + poolIdx?: number; +} + +export interface TimelineViewport { + startMs: number; + endMs: number; + scrollY: number; + zoom: number; + scaleY: number; +} + +export interface TimelineConfig { + trackHeight: number; + trackGap: number; + minZoom: number; + maxZoom: number; + backgroundColor: number; +} + +/** Passed to PixiRenderer.loadEvents() and Timeline.svelte instead of TemporalEvent[]. */ +export interface PixiRenderArgs { + /** Total groups registered in the buffer pool so far (ascCount + descCount). */ + poolCount: number; + /** Estimated total number of groups across the entire workflow. */ + totalRows: number; + /** Groups loaded by the ascending cursor. */ + ascCount: number; + /** Groups loaded by the descending cursor. */ + descCount: number; + /** True once fetchBidirectional completes and assignTrackIndices() has been called. */ + finalized: boolean; + /** + * Sort order for the track layout. + * - 'desc' (default): newest events at top, oldest at bottom. + * - 'asc': oldest events at top (tracks rendered in reverse Y order). + */ + sortOrder: 'desc' | 'asc'; +} diff --git a/src/lib/holocene/code-block.svelte b/src/lib/holocene/code-block.svelte index 2259b333ae..c373484a7f 100644 --- a/src/lib/holocene/code-block.svelte +++ b/src/lib/holocene/code-block.svelte @@ -18,7 +18,7 @@ Transaction, } from '@codemirror/state'; import { EditorView, keymap } from '@codemirror/view'; - import { onMount, type Snippet } from 'svelte'; + import { onMount, type Snippet, tick } from 'svelte'; import { twMerge as merge, twMerge } from 'tailwind-merge'; import CopyButton from '$lib/holocene/copyable/button.svelte'; @@ -57,6 +57,7 @@ tabs?: string[]; activeTab?: string; headerActions?: Snippet<[]>; + lazy?: boolean; } interface PropsWithCopyable extends Override< @@ -83,6 +84,7 @@ tabs, activeTab = $bindable(), headerActions, + lazy = false, ...editorProps }: Props = $props(); @@ -91,6 +93,12 @@ let editorElement = $state(); let editorView = $state(); + // PERF: When lazy=true we render a
 placeholder on the first frame so
+  // the panel is interactive immediately. CodeMirror is scheduled via
+  // setTimeout(0), which allows the browser to paint the 
 before the
+  // heavier editor init runs. lazyReady flips true once the editor is mounted.
+  let lazyReady = $state(!lazy);
+
   // content
 
   const { copy, copied } = copyToClipboard();
@@ -236,10 +244,30 @@
   };
 
   onMount(() => {
-    editorView = createEditorView();
-    editorView.contentDOM.onblur = handleEditorBlur;
-    ensureFullParse();
+    if (!lazy) {
+      editorView = createEditorView();
+      editorView.contentDOM.onblur = handleEditorBlur;
+      ensureFullParse();
+      return () => editorView?.destroy();
+    }
+
+    // PERF: Defer CodeMirror initialization until after the 
 placeholder
+    // has painted. setTimeout(0) yields back to the browser so it can commit
+    // the current frame before we do any heavy editor work.
+    let destroyed = false;
+    const timer = setTimeout(async () => {
+      if (destroyed) return;
+      lazyReady = true;
+      await tick();
+      if (destroyed) return;
+      editorView = createEditorView();
+      editorView.contentDOM.onblur = handleEditorBlur;
+      ensureFullParse();
+    }, 0);
+
     return () => {
+      destroyed = true;
+      clearTimeout(timer);
       editorView?.destroy();
     };
   });
@@ -283,29 +311,44 @@
       
{/if} - -
- - {#snippet actions()} - {#if headerActions} - {@render headerActions()} - {:else if copyable && !hasHeader} - - {/if} - {/snippet} -
+ {#if lazy && !lazyReady} + +
{format(content, language, inline)}
+ {:else} + +
+ + {#snippet actions()} + {#if headerActions} + {@render headerActions()} + {:else if copyable && !hasHeader} + + {/if} + {/snippet} +
+ {/if} diff --git a/src/lib/layouts/workflow-fast-history-layout.svelte b/src/lib/layouts/workflow-fast-history-layout.svelte new file mode 100644 index 0000000000..70a2ef373d --- /dev/null +++ b/src/lib/layouts/workflow-fast-history-layout.svelte @@ -0,0 +1,498 @@ + + + + +{#if error} +

{error}

+{/if} + +
+ {#if workflowTaskFailedError} + + {/if} + {#if workflow?.callbacks?.length} + + {/if} +
+
+
+
+

{translate('workflows.timeline-tab')}

+ +
+
+ + {reverseSort ? 'Descending' : 'Ascending'} + + (showDownloadPrompt = true)} + > + {translate('common.download')} + + +
+ {#if firstRenderMs !== null} +

+ first paint {fmtMs(firstRenderMs)}{#if loadMs !== null} + Β· fetch {fmtMs(loadMs)}{/if}{#if allRenderMs !== null} + Β· all loaded {fmtMs(allRenderMs)}{/if} +

+ {/if} +
+ {#if barPhase !== 'done'} +
+ {#if barPhase === 'rendering'} + + {/if} + {#each { length: COLS } as _, col} + {@const state = boxState(col)} + {@const isFrontierAsc = + barPhase === 'fetching' && col === ascCols - 1 && ascCols > 0} + {@const isFrontierDesc = + barPhase === 'fetching' && + col === COLS - descCols && + descCols > 0 && + COLS - descCols < COLS} + {@const delay = + barPhase === 'rendering' ? Math.abs(col - frozenMeetCol) * 18 : 0} +
+ {/each} +
+ {/if} + {#if workflow} +
+ { + firstRenderMs = ms; + }} + onAllRendered={(ms) => { + allRenderMs = ms; + barPhase = 'done'; + }} + viewportHeight={undefined} + error={Boolean(workflowTaskFailedError)} + /> +
+ {/if} +
+{#if workflow} + +{/if} + + diff --git a/src/lib/layouts/workflow-header.svelte b/src/lib/layouts/workflow-header.svelte index b6a78df272..679565d94f 100644 --- a/src/lib/layouts/workflow-header.svelte +++ b/src/lib/layouts/workflow-header.svelte @@ -24,6 +24,7 @@ import { workflowViewPreference } from '$lib/stores/event-view'; import { fullEventHistory } from '$lib/stores/events'; import { resetWorkflows } from '$lib/stores/reset-workflows'; + import { workflowActionsReady } from '$lib/stores/workflow-actions-ready'; import { workflowRun } from '$lib/stores/workflow-run'; import { workflowsSearchParams } from '$lib/stores/workflows'; import { isCancelInProgress } from '$lib/utilities/cancel-in-progress'; @@ -37,6 +38,8 @@ import { routeForCallStack, routeForEventHistory, + routeForFasterer, + routeForFastHistory, routeForNexusLinks, routeForPendingActivities, routeForRelationships, @@ -144,13 +147,15 @@ taskFailure={workflow ? isWorkflowTaskFailure(workflow) : false} />
- + {#if $workflowActionsReady} + + {/if}
@@ -170,13 +175,15 @@
- + {#if $workflowActionsReady} + + {/if}
@@ -280,6 +287,24 @@ {workflow?.historyEvents} + + diff --git a/src/lib/models/event-groups/create-event-group.test.ts b/src/lib/models/event-groups/create-event-group.test.ts index 3e52af0a74..6226531cc7 100644 --- a/src/lib/models/event-groups/create-event-group.test.ts +++ b/src/lib/models/event-groups/create-event-group.test.ts @@ -66,27 +66,27 @@ describe('createEventGroup', () => { it('should store the groupTaskScheduled', () => { const group = createEventGroup(scheduledEvent); - expect(group.events.get(scheduledEvent.id)).toBe(scheduledEvent); + expect(group.eventList[0]).toBe(scheduledEvent); }); it('should be able to add a started event', () => { const group = createEventGroup(scheduledEvent); - group.events.set(completedEvent.eventType, completedEvent); + group.eventList.push(completedEvent); - expect(group.events.size).toBe(2); - expect(group.events.get('ActivityTaskCompleted')).toBe(completedEvent); + expect(group.eventList.length).toBe(2); + expect(group.eventList[1]).toBe(completedEvent); }); it('should have the event time of the last event', () => { const group = createEventGroup(scheduledEvent); - group.events.set(completedEvent.eventType, completedEvent); + group.eventList.push(completedEvent); expect(group.eventTime).toBe(completedEvent.eventTime); }); it('should have the attributes of the last event', () => { const group = createEventGroup(scheduledEvent); - group.events.set(completedEvent.eventType, completedEvent); + group.eventList.push(completedEvent); expect(group.attributes).toBe(completedEvent.attributes); }); diff --git a/src/lib/models/event-groups/create-event-group.ts b/src/lib/models/event-groups/create-event-group.ts index 76bffab75e..162fb9712d 100644 --- a/src/lib/models/event-groups/create-event-group.ts +++ b/src/lib/models/event-groups/create-event-group.ts @@ -1,4 +1,4 @@ -import type { Payload } from '$lib/types'; +import type { EventLink, Payload } from '$lib/types'; import type { ActivityTaskScheduledEvent, CommonHistoryEvent, @@ -7,6 +7,7 @@ import type { SignalExternalWorkflowExecutionInitiatedEvent, StartChildWorkflowExecutionInitiatedEvent, TimerStartedEvent, + WorkflowEvent, WorkflowExecutionSignaledEvent, WorkflowExecutionUpdateAcceptedEvent, WorkflowTaskScheduledEvent, @@ -36,7 +37,6 @@ import { getEventGroupLabel, getEventGroupName, } from './get-group-name'; -import { getLastEvent } from './get-last-event'; type StartingEvents = { Activity: ActivityTaskScheduledEvent; @@ -58,22 +58,19 @@ const createGroupFor = ( const name = getEventGroupName(event); const label = getEventGroupLabel(event); const displayName = getEventGroupDisplayName(event); - const { timestamp, category, classification } = event; - const groupEvents: EventGroup['events'] = new Map(); - const groupEventIds: EventGroup['eventIds'] = new Set(); - - groupEvents.set(event.id, event); - groupEventIds.add(event.id); + // Single flat array β€” no Map, no Set. Groups have 1–5 events. + const eventList: EventGroup['eventList'] = [event as never]; + // eventList[0] is the same object as event, typed as WorkflowEvent at runtime. + const first = eventList[0]; return { id, name, label, displayName, - events: groupEvents, - eventIds: groupEventIds, + eventList, initialEvent: event, timestamp, category: isLocalActivityMarkerEvent(event) ? 'local-activity' : category, @@ -82,52 +79,48 @@ const createGroupFor = ( pendingActivity: undefined, pendingNexusOperation: undefined, userMetadata: event?.userMetadata, + // Eager fields β€” zero-cost reads, updated by addEventToGroup on each push. + isFailureOrTimedOut: eventIsFailureOrTimedOut(first), + isCanceled: eventIsCanceled(first), + isTerminated: eventIsTerminated(first), + billableActions: first.billableActions ?? 0, + links: first.links ? [...first.links] : [], get eventTime() { - return this.lastEvent?.eventTime; + return eventList[eventList.length - 1]?.eventTime; }, get attributes() { - return getLastEvent(this)?.attributes; - }, - get eventList() { - return Array.from(this.events, ([_key, value]) => value); - }, - get links() { - return Array.from(this.events, ([_key, value]) => value.links).flat(); + return eventList[eventList.length - 1]?.attributes; }, get lastEvent() { - return getLastEvent(this); + return eventList[eventList.length - 1]; }, get finalClassification() { - return getLastEvent(this).classification; + return eventList[eventList.length - 1].classification; }, get isPending() { return ( !!this.pendingActivity || !!this.pendingNexusOperation || - (isTimerStartedEvent(this.initialEvent) && - this.eventList.length === 1) || + (isTimerStartedEvent(this.initialEvent) && eventList.length === 1) || (isStartChildWorkflowExecutionInitiatedEvent(this.initialEvent) && - this.eventList.length === 2) - ); - }, - get isFailureOrTimedOut() { - return Boolean(this.eventList.find(eventIsFailureOrTimedOut)); - }, - get isCanceled() { - return Boolean(this.eventList.find(eventIsCanceled)); - }, - get isTerminated() { - return Boolean(this.eventList.find(eventIsTerminated)); - }, - get billableActions() { - return this.eventList.reduce( - (acc, event) => event.billableActions + acc, - 0, + eventList.length === 2) ); }, }; }; +// Called by addToExistingGroup after pushing a new event into a group's eventList. +// Updates all eagerly-maintained fields in one place so getters stay zero-cost. +export const addEventToGroup = (group: EventGroup, event: WorkflowEvent) => { + if (eventIsFailureOrTimedOut(event)) group.isFailureOrTimedOut = true; + if (eventIsCanceled(event)) group.isCanceled = true; + if (eventIsTerminated(event)) group.isTerminated = true; + group.billableActions += event.billableActions ?? 0; + if (event.links?.length) { + for (const l of event.links) group.links.push(l); + } +}; + export const createEventGroup = (event: CommonHistoryEvent): EventGroup => { if (isActivityTaskScheduledEvent(event)) return createGroupFor<'Activity'>(event); diff --git a/src/lib/models/event-groups/event-groups.d.ts b/src/lib/models/event-groups/event-groups.d.ts index daf64ddc56..99f6f1b0d8 100644 --- a/src/lib/models/event-groups/event-groups.d.ts +++ b/src/lib/models/event-groups/event-groups.d.ts @@ -17,11 +17,9 @@ interface EventGroup extends Pick< name: string; label: string; displayName: string; - events: Map; - eventIds: Set; + eventList: WorkflowEvent[]; initialEvent: WorkflowEvent; lastEvent: WorkflowEvent; - eventList: WorkflowEvent[]; finalClassification: EventClassification; isPending: boolean; isFailureOrTimedOut: boolean; diff --git a/src/lib/models/event-groups/get-group-for-event.ts b/src/lib/models/event-groups/get-group-for-event.ts index 0cb6f6ff39..2edd15c438 100644 --- a/src/lib/models/event-groups/get-group-for-event.ts +++ b/src/lib/models/event-groups/get-group-for-event.ts @@ -2,18 +2,24 @@ import type { WorkflowEvent } from '$lib/types/events'; import type { EventGroup, EventGroups } from './event-groups'; -export const getGroupForEvent = ( - event: WorkflowEvent, +export const buildGroupIndex = ( groups: EventGroups, -): EventGroup => { - const eventId = event.id; - +): Map => { + const index = new Map(); for (const group of groups) { - if (eventId === group.id) return group; - for (const id of group.eventIds) { - if (eventId === id) { - return group; - } + for (const event of group.eventList) { + index.set(event.id, group); } } + return index; +}; + +export const getGroupForEvent = ( + event: WorkflowEvent, + groupsOrIndex: EventGroups | Map, +): EventGroup | undefined => { + if (groupsOrIndex instanceof Map) return groupsOrIndex.get(event.id); + for (const group of groupsOrIndex) { + if (group.eventList.some((e) => e.id === event.id)) return group; + } }; diff --git a/src/lib/models/event-groups/get-last-event.ts b/src/lib/models/event-groups/get-last-event.ts index 510cb358f0..f48deb6153 100644 --- a/src/lib/models/event-groups/get-last-event.ts +++ b/src/lib/models/event-groups/get-last-event.ts @@ -2,17 +2,5 @@ import type { WorkflowEvent } from '$lib/types/events'; import type { EventGroup } from './event-groups'; -export const getLastEvent = ({ events }: EventGroup): WorkflowEvent => { - let latestEventKey = 0; - let result: WorkflowEvent; - - for (const event of events.values()) { - const k = Number(event.id); - if (k >= latestEventKey) { - latestEventKey = k; - result = event; - } - } - - return result; -}; +export const getLastEvent = ({ eventList }: EventGroup): WorkflowEvent => + eventList[eventList.length - 1]; diff --git a/src/lib/models/event-groups/group-events.test.ts b/src/lib/models/event-groups/group-events.test.ts index f5829ecc9f..da24af756b 100644 --- a/src/lib/models/event-groups/group-events.test.ts +++ b/src/lib/models/event-groups/group-events.test.ts @@ -90,7 +90,9 @@ describe('groupEvents', () => { const groups = groupEvents([scheduledEvent] as unknown as WorkflowEvents); const group = groups.find(({ id }) => id === scheduledEvent.id); - expect(group.events.get(scheduledEvent.id)).toBe(scheduledEvent); + expect(group.eventList.find((e) => e.id === scheduledEvent.id)).toBe( + scheduledEvent, + ); }); it('should be able to store multiple event groups', () => { @@ -128,8 +130,10 @@ describe('groupEvents', () => { const group = groups.find(({ id }) => id === scheduledEvent.id); - expect(group.events.size).toBe(2); - expect(group.events.get(completedEvent.id)).toBe(completedEvent); + expect(group.eventList.length).toBe(2); + expect(group.eventList.find((e) => e.id === completedEvent.id)).toBe( + completedEvent, + ); }); it('should add a completed event to the correct group in descending order', () => { @@ -140,8 +144,10 @@ describe('groupEvents', () => { const group = groups.find(({ id }) => id === scheduledEvent.id); - expect(group.events.size).toBe(2); - expect(group.events.get(completedEvent.id)).toBe(completedEvent); + expect(group.eventList.length).toBe(2); + expect(group.eventList.find((e) => e.id === completedEvent.id)).toBe( + completedEvent, + ); }); it('should be able to add multiple event groups and their associated events', () => { @@ -150,8 +156,8 @@ describe('groupEvents', () => { const [first, second] = Object.values(groups); expect(Object.values(groups).length).toBe(2); - expect(first.events.size).toBe(3); - expect(second.events.size).toBe(1); + expect(first.eventList.length).toBe(3); + expect(second.eventList.length).toBe(1); }); }); diff --git a/src/lib/models/event-groups/index.ts b/src/lib/models/event-groups/index.ts index 9acc42065b..b35f5e29f9 100644 --- a/src/lib/models/event-groups/index.ts +++ b/src/lib/models/event-groups/index.ts @@ -7,59 +7,108 @@ import type { } from '$lib/types/events'; import { has } from '$lib/utilities/has'; import { + isActivityTaskScheduledEvent, isNexusOperationCanceledEvent, isNexusOperationCompletedEvent, isNexusOperationFailedEvent, + isNexusOperationScheduledEvent, isNexusOperationTimedOutEvent, } from '$lib/utilities/is-event-type'; -import { - getPendingActivity, - getPendingNexusOperation, -} from '$lib/utilities/pending-activities'; import { + addEventToGroup, createEventGroup, createWorkflowTaskGroup, } from './create-event-group'; import type { EventGroup, EventGroups } from './event-groups'; import { getGroupId } from './get-group-id'; -export { getGroupForEvent } from './get-group-for-event'; +export { buildGroupIndex, getGroupForEvent } from './get-group-for-event'; + +// Build O(1) lookup maps from pending arrays so the hot loop in groupEvents +// does a single Map.get per event instead of Array.find (O(M) per event). +function buildPendingMaps( + pendingActivities: PendingActivity[], + pendingNexusOperations: PendingNexusOperation[], +) { + return { + byActivityId: new Map(pendingActivities.map((p) => [p.activityId, p])), + byNexusScheduledId: new Map( + pendingNexusOperations.map((p) => [String(p.scheduledEventId), p]), + ), + }; +} + +function resolveEvent( + event: CommonHistoryEvent, + groups: Record, + byActivityId: Map, + byNexusScheduledId: Map, + dropped: CommonHistoryEvent[] | null, +) { + const id = getGroupId(event); + const group = createEventGroup(event); + + const pa = isActivityTaskScheduledEvent(event) + ? byActivityId.get(event.activityTaskScheduledEventAttributes.activityId) + : undefined; + const pn = isNexusOperationScheduledEvent(event) + ? byNexusScheduledId.get(event.id) + : undefined; + + if (group) { + groups[group.id] = group; + if (pa) group.pendingActivity = pa; + if (pn) group.pendingNexusOperation = pn; + + // Retry events that arrived before this group was created (bookend edge case + // where a completion event from the descending cursor lands before its + // scheduling event has been processed). + if (dropped) { + for (let i = dropped.length - 1; i >= 0; i--) { + if (getGroupId(dropped[i]) === group.id) { + addToExistingGroup(dropped[i] as WorkflowEvent, groups); + dropped.splice(i, 1); + } + } + } + } else if (groups[id]) { + addToExistingGroup(event as WorkflowEvent, groups, pa, pn); + } else if (dropped) { + dropped.push(event); + } +} -const addToExistingGroup = ( - group: EventGroup, +// Thin wrapper that matches the existing addToExistingGroup signature but pulls +// the group from the map β€” avoids a separate lookup at each call site. +function addToExistingGroup( event: WorkflowEvent, - pendingActivity: PendingActivity | undefined = undefined, - pendingNexusOperation: PendingNexusOperation | undefined = undefined, -): void => { + groups: Record, + pa?: PendingActivity, + pn?: PendingNexusOperation, +) { + const id = getGroupId(event as CommonHistoryEvent); + const group = groups[id]; if (!group) return; - group.events.set(event.id, event); - group.eventIds.add(event.id); - + group.eventList.push(event); group.timestamp = event.timestamp; + addEventToGroup(group, event); - if (pendingActivity) { - group.pendingActivity = pendingActivity; - } - - if (group.pendingActivity && group.eventList.length === 3) { + if (pa) group.pendingActivity = pa; + if (group.pendingActivity && group.eventList.length === 3) delete group.pendingActivity; - } - if (pendingNexusOperation) { - group.pendingNexusOperation = pendingNexusOperation; - } + if (pn) group.pendingNexusOperation = pn; - const completedNexusEvent = + const completedNexus = isNexusOperationCompletedEvent(event) || isNexusOperationFailedEvent(event) || isNexusOperationCanceledEvent(event) || isNexusOperationTimedOutEvent(event); - if (group.pendingNexusOperation && completedNexusEvent) { + if (group.pendingNexusOperation && completedNexus) delete group.pendingNexusOperation; - } -}; +} export const groupEvents = ( events: CommonHistoryEvent[], @@ -68,42 +117,17 @@ export const groupEvents = ( pendingNexusOperations: PendingNexusOperation[] = [], ): EventGroups => { const groups: Record = {}; - - const createGroups = (event: CommonHistoryEvent) => { - const id = getGroupId(event); - const group = createEventGroup(event); - const pendingActivity = getPendingActivity(event, pendingActivities); - const pendingNexusOperation = getPendingNexusOperation( - event, - pendingNexusOperations, - ); - - if (group) { - groups[group.id] = group; - if (pendingActivity) { - group.pendingActivity = pendingActivity; - } - if (pendingNexusOperation) { - group.pendingNexusOperation = pendingNexusOperation; - } - } else { - addToExistingGroup( - groups[id], - event, - pendingActivity, - pendingNexusOperation, - ); - } - }; + const { byActivityId, byNexusScheduledId } = buildPendingMaps( + pendingActivities, + pendingNexusOperations, + ); if (sort === 'ascending') { - for (const event of events) { - createGroups(event); - } + for (const event of events) + resolveEvent(event, groups, byActivityId, byNexusScheduledId, null); } else { - for (let i = events.length - 1; i >= 0; i--) { - createGroups(events[i]); - } + for (let i = events.length - 1; i >= 0; i--) + resolveEvent(events[i], groups, byActivityId, byNexusScheduledId, null); } return sort === 'descending' @@ -115,7 +139,7 @@ export const isEventGroup = ( eventOrGroup: unknown, ): eventOrGroup is EventGroup => { if (eventOrGroup === undefined || eventOrGroup === null) return false; - return has(eventOrGroup, 'events'); + return has(eventOrGroup, 'eventList'); }; export const isEventGroups = ( @@ -132,24 +156,18 @@ export const groupWorkflowTaskEvents = ( const groups: Record = {}; const createGroups = (event: CommonHistoryEvent) => { - const id = getGroupId(event); const group = createWorkflowTaskGroup(event); - if (group) { groups[group.id] = group; } else { - addToExistingGroup(groups[id], event); + addToExistingGroup(event as WorkflowEvent, groups); } }; if (sort === 'ascending') { - for (const event of events) { - createGroups(event); - } + for (const event of events) createGroups(event); } else { - for (let i = events.length - 1; i >= 0; i--) { - createGroups(events[i]); - } + for (let i = events.length - 1; i >= 0; i--) createGroups(events[i]); } return sort === 'descending' diff --git a/src/lib/pages/workflow-history-event.svelte b/src/lib/pages/workflow-history-event.svelte index 05ce9d7ef3..354a613297 100644 --- a/src/lib/pages/workflow-history-event.svelte +++ b/src/lib/pages/workflow-history-event.svelte @@ -5,7 +5,7 @@ import EventSummaryRow from '$lib/components/event/event-summary-row.svelte'; import Button from '$lib/holocene/button.svelte'; - import { groupEvents } from '$lib/models/event-groups'; + import { buildGroupIndex, groupEvents } from '$lib/models/event-groups'; import { isEvent } from '$lib/models/event-history'; import { fetchAllEvents } from '$lib/services/events-service'; import { eventFilterSort } from '$lib/stores/event-view'; @@ -61,6 +61,7 @@ ? ascendingGroups : [...ascendingGroups].reverse(), ); + const groupIndex = $derived(buildGroupIndex(groups)); const initialEvent = $derived( $fullEventHistory.find( @@ -129,7 +130,7 @@ {event} {index} expanded={event.id === initialEvent?.id} - group={groups.find((g) => isEvent(event) && g.eventIds.has(event.id))} + group={isEvent(event) ? groupIndex.get(event.id) : undefined} initialItem={$fullEventHistory[0]} /> {/each} diff --git a/src/lib/services/events-service.test.ts b/src/lib/services/events-service.test.ts new file mode 100644 index 0000000000..4aafec9bbc --- /dev/null +++ b/src/lib/services/events-service.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { fetchAllEventsBidirectional } from './events-service'; + +vi.mock('../utilities/request-from-api', () => ({ + requestFromAPI: vi.fn(), +})); + +vi.mock('../utilities/route-for-api', () => ({ + routeForApi: vi + .fn() + .mockImplementation((endpoint: string) => `/api/${endpoint}`), +})); + +vi.mock('$lib/models/event-history', () => ({ + toEventHistory: vi + .fn() + .mockImplementation((events: { eventId: string }[]) => + events.map((e) => ({ id: e.eventId, eventId: e.eventId })), + ), +})); + +vi.mock('$lib/stores/events', () => ({ + fullEventHistory: { set: vi.fn(), update: vi.fn(), subscribe: vi.fn() }, +})); + +vi.mock('$lib/stores/workflow-run', () => ({ + triggerRefresh: vi.fn(), +})); + +const { requestFromAPI } = await import('../utilities/request-from-api'); +const mockRequest = vi.mocked(requestFromAPI); + +type Deferred = { promise: Promise; resolve: (v: T) => void }; +function deferred(): Deferred { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +const makeEvents = (ids: number[]) => + ids.map((id) => ({ eventId: String(id) })); + +const makePage = (ids: number[], nextToken = '') => ({ + history: { events: makeEvents(ids) }, + nextPageToken: nextToken, +}); + +const params = { namespace: 'ns', workflowId: 'wf', runId: 'run' }; + +const sortedIds = (events: { id: string }[]) => + events.map((e) => parseInt(e.id)).sort((a, b) => a - b); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('fetchAllEventsBidirectional – overlap prevention', () => { + test('collects all events when each side covers exactly its half', async () => { + // 8 events, page size 4. Ascending gets 1-4, descending gets 5-8. + mockRequest.mockImplementation((route: string) => { + if (route.includes('ascending')) + return Promise.resolve(makePage([1, 2, 3, 4])); + return Promise.resolve(makePage([8, 7, 6, 5])); + }); + + const { events, stats } = await fetchAllEventsBidirectional(params); + + expect(sortedIds(events)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + expect(stats.totalEvents).toBe(8); + expect(stats.overlap).toBe(0); + expect(stats.ascPages).toBe(1); + expect(stats.descPages).toBe(1); + }); + + test('ascending resolves first: overlapping desc page writes idempotently to slots', async () => { + // Both pages cover events 3-4. With slot-based storage, both sides write + // to slots[2] and slots[3] β€” idempotent, same data. overlap = 2 (events + // 3 and 4 were fetched by both directions but stored only once). + mockRequest.mockImplementation((route: string) => { + if (route.includes('ascending')) + return Promise.resolve(makePage([1, 2, 3, 4])); + return Promise.resolve(makePage([6, 5, 4, 3])); + }); + + const { events, stats } = await fetchAllEventsBidirectional(params); + + expect(sortedIds(events)).toEqual([1, 2, 3, 4, 5, 6]); + expect(stats.totalEvents).toBe(6); + expect(stats.overlap).toBe(2); + }); + + test('descending resolves first: overlapping asc page writes idempotently to slots', async () => { + // Delay ascending so descending processes first, writing [3-6] to slots. + // When asc p1 resolves with [1-4], events 3 and 4 overwrite their slots + // idempotently. overlap = 2 (those events were fetched by both sides). + const ascP1 = deferred>(); + + mockRequest.mockImplementation((route: string) => { + if (route.includes('ascending')) return ascP1.promise; + return Promise.resolve(makePage([6, 5, 4, 3])); + }); + + const fetchPromise = fetchAllEventsBidirectional(params); + + // Flush enough microtasks for desc p1 to resolve and populate slots. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + ascP1.resolve(makePage([1, 2, 3, 4])); + + const { events, stats } = await fetchPromise; + + expect(sortedIds(events)).toEqual([1, 2, 3, 4, 5, 6]); + expect(stats.totalEvents).toBe(6); + expect(stats.overlap).toBe(2); + }); + + test('multi-page: in-flight page from aborted side writes idempotently to slots', async () => { + // 9 events, page size 3. After round 1 gap(3) == observedPageSize(3) so + // the winner aborts the other side. The in-flight page [6,5,4] from desc + // p2 still completes and overwrites slots already written by asc β€” zero + // data loss, overlap = 3 (events 4,5,6 fetched by both directions). + let ascCalls = 0; + let descCalls = 0; + + mockRequest.mockImplementation((route: string) => { + if (route.includes('ascending')) { + ascCalls++; + if (ascCalls === 1) + return Promise.resolve(makePage([1, 2, 3], 'asc-token')); + return Promise.resolve(makePage([4, 5, 6])); + } else { + descCalls++; + if (descCalls === 1) + return Promise.resolve(makePage([9, 8, 7], 'desc-token')); + return Promise.resolve(makePage([6, 5, 4])); + } + }); + + const { events, stats } = await fetchAllEventsBidirectional(params); + + expect(sortedIds(events)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); + expect(stats.totalEvents).toBe(9); + expect(stats.overlap).toBe(3); + }); + + test('no events returned from either direction', async () => { + mockRequest.mockResolvedValue(makePage([])); + + const { events, stats } = await fetchAllEventsBidirectional(params); + + expect(events).toHaveLength(0); + expect(stats.totalEvents).toBe(0); + expect(stats.overlap).toBe(0); + }); +}); + +describe('fetchAllEventsBidirectional – stats', () => { + test('ascending wins when it needs fewer pages', async () => { + let ascCalls = 0; + let descCalls = 0; + + mockRequest.mockImplementation((route: string) => { + if (route.includes('ascending')) { + ascCalls++; + if (ascCalls === 1) + return Promise.resolve(makePage([1, 2, 3, 4, 5], 'asc-t')); + return Promise.resolve(makePage([6])); + } else { + descCalls++; + if (descCalls === 1) + return Promise.resolve(makePage([10, 9, 8, 7, 6], 'desc-t')); + if (descCalls === 2) + return Promise.resolve(makePage([5, 4, 3, 2, 1], 'desc-t2')); + return Promise.resolve(makePage([])); + } + }); + + const { stats } = await fetchAllEventsBidirectional(params); + + expect(stats.ascPages).toBeGreaterThan(0); + expect(stats.winner).not.toBe('descending'); + expect(stats.overlap).toBeGreaterThanOrEqual(0); + }); + + test('reports eventsPerSecond as a positive integer', async () => { + mockRequest.mockImplementation((route: string) => { + if (route.includes('ascending')) + return Promise.resolve(makePage([1, 2, 3])); + return Promise.resolve(makePage([6, 5, 4])); + }); + + const { stats } = await fetchAllEventsBidirectional(params); + + expect(stats.eventsPerSecond).toBeGreaterThan(0); + expect(Number.isInteger(stats.eventsPerSecond)).toBe(true); + }); + + test('onProgress callback fires after each page with cumulative counts', async () => { + const progress: { ascEvents: number; descEvents: number }[] = []; + + mockRequest.mockImplementation((route: string) => { + if (route.includes('ascending')) + return Promise.resolve(makePage([1, 2, 3])); + return Promise.resolve(makePage([6, 5, 4])); + }); + + await fetchAllEventsBidirectional({ + ...params, + onProgress: (p) => + progress.push({ ascEvents: p.ascEvents, descEvents: p.descEvents }), + }); + + expect(progress.length).toBeGreaterThan(0); + const last = progress[progress.length - 1]; + expect(last.ascEvents + last.descEvents).toBeGreaterThan(0); + }); +}); + +describe('fetchAllEventsBidirectional – abort', () => { + test('external abort stops both loops: no second page is fetched', async () => { + const ctrl = new AbortController(); + const ascP1 = deferred>(); + const descP1 = deferred>(); + + mockRequest.mockImplementation((route: string) => { + if (route.includes('ascending')) return ascP1.promise; + return descP1.promise; + }); + + const fetchPromise = fetchAllEventsBidirectional({ + ...params, + signal: ctrl.signal, + }); + + // Abort before any page resolves. + ctrl.abort(); + + // Resolve the in-flight pages. The mocked fetch doesn't respect the abort + // signal, so these still complete β€” but the while-loop guard should prevent + // fetching any additional pages. + ascP1.resolve(makePage([1, 2, 3], 'more-token')); + descP1.resolve(makePage([6, 5, 4], 'more-token')); + + await fetchPromise; + + // Each direction called requestFromAPI exactly once β€” no second page. + expect(mockRequest).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/services/events-service.ts b/src/lib/services/events-service.ts index dea902e9f0..7fb029238b 100644 --- a/src/lib/services/events-service.ts +++ b/src/lib/services/events-service.ts @@ -22,6 +22,13 @@ import { paginated } from '$lib/utilities/paginated'; import { requestFromAPI } from '$lib/utilities/request-from-api'; import { routeForApi } from '$lib/utilities/route-for-api'; +import type { + BidirectionalProgress, + BidirectionalStats, +} from './fetch-bidirectional'; + +export type { BidirectionalProgress, BidirectionalStats }; + export type FetchEventsParameters = NamespaceScopedRequest & PaginationCallbacks & { workflowId: string; @@ -100,9 +107,15 @@ export const fetchAllEvents = async ({ fullEventHistory.set([]); }; + let _pageCount = 0; const onUpdate = (full, current) => { if (!signal) return; - fullEventHistory.set([...toEventHistory(full.history?.events)]); + _pageCount++; + // PERF: Only update the store every 5 pages to avoid O(NΒ²) re-renders during + // streaming load. The first page always updates for immediate display. + if (_pageCount === 1 || _pageCount % 5 === 0) { + fullEventHistory.set([...toEventHistory(full.history?.events)]); + } const next = current?.history?.events; const hasNewHistory = historySize && @@ -228,3 +241,267 @@ export const fetchInitialEvent = async ( const start = await toEventHistory(startEventsRaw); return start[0]; }; + +export const fetchAllEventsBidirectional = async ({ + namespace, + workflowId, + runId, + signal, + onProgress, + onFirstPage, + onFirstDescPage, + onPage, + maximumPageSize, +}: { + namespace: string; + workflowId: string; + runId: string; + signal?: AbortSignal; + onProgress?: (p: BidirectionalProgress) => void; + /** Fires after the first ascending page resolves with just that page's events. */ + onFirstPage?: (events: CommonHistoryEvent[]) => void; + /** + * Fires after the first descending page resolves with a snapshot of ALL events + * accumulated so far from both cursors β€” sorted ascending, no duplicates. + * Use together with onFirstPage to render both bookends (oldest + newest) before + * the full fetch completes, then let .then() render the complete set. + */ + onFirstDescPage?: (accumulated: CommonHistoryEvent[]) => void; + /** + * Fires after every page from either direction with a snapshot of ALL events + * accumulated so far β€” sorted ascending, no duplicates β€” ready for groupEvents. + * Use this instead of onFirstPage for per-page streaming render. + */ + onPage?: (accumulated: CommonHistoryEvent[]) => void; + maximumPageSize?: number; +}): Promise<{ events: CommonHistoryEvent[]; stats: BidirectionalStats }> => { + const t0 = performance.now(); + + const ascCtrl = new AbortController(); + const descCtrl = new AbortController(); + signal?.addEventListener('abort', () => { + ascCtrl.abort(); + descCtrl.abort(); + }); + + let ascMaxId = 0; + let descMinId = Infinity; + let descMaxId = 0; + let ascPages = 0; + let descPages = 0; + let observedPageSize = 0; + let winnerChosen = false; + + // Single pre-allocated slot array indexed by eventId - 1. Allocated lazily + // once the first descending page reveals the total event count. Events from + // both directions write directly into their slot, so writes are idempotent + // and no merge copy is needed. + let slots: (HistoryEvent | undefined)[] | null = null; + // Small temp buffer for ascending events that arrive before slots is ready. + const tempBuf: HistoryEvent[] = []; + let ascEventCount = 0; + let descEventCount = 0; + + const initSlots = (n: number) => { + if (slots !== null) return; + slots = new Array(n); + for (const e of tempBuf) slots[parseInt(e.eventId) - 1] = e; + tempBuf.length = 0; + }; + + type Token = string | undefined; + + const gap = () => Math.max(0, descMinId - ascMaxId - 1); + + // Snapshot the current slots array as a sorted, duplicate-free CommonHistoryEvent[]. + // slots is indexed by eventId-1 so iteration order is ascending β€” no sort needed. + // Gaps in the middle (events not yet fetched by either cursor) are simply skipped. + const snapshotAccumulated = (): CommonHistoryEvent[] => { + const source = slots ?? (tempBuf as (HistoryEvent | undefined)[]); + const filled: HistoryEvent[] = []; + for (let i = 0; i < source.length; i++) { + if (source[i] !== undefined) filled.push(source[i]!); + } + return toEventHistory(filled); + }; + + const reportProgress = () => { + onProgress?.({ + ascEvents: ascEventCount, + descEvents: descEventCount, + ascPages, + descPages, + elapsedMs: performance.now() - t0, + ascMaxId, + descMinId: descMinId === Infinity ? 0 : descMinId, + totalEstimated: descMaxId, + }); + }; + + const runAscending = async () => { + const route = routeForApi('events.ascending', { namespace, workflowId }); + let token: Token; + while (!ascCtrl.signal.aborted) { + const g = gap(); + if (g <= 0) { + descCtrl.abort(); + break; + } + if (observedPageSize > 0 && g <= observedPageSize && !winnerChosen) { + winnerChosen = true; + descCtrl.abort(); + } + + let response: GetWorkflowExecutionHistoryResponse; + try { + response = await requestFromAPI( + route, + { + token, + request: fetch, + params: { + 'execution.runId': runId, + waitNewEvent: 'false', + ...(maximumPageSize && { + maximumPageSize: String(maximumPageSize), + }), + }, + options: { signal: ascCtrl.signal }, + }, + ); + } catch { + break; + } + const events = response?.history?.events ?? []; + if (!events.length) break; + + ascPages++; + observedPageSize = Math.max(observedPageSize, events.length); + ascEventCount += events.length; + for (const e of events) { + const id = parseInt(e.eventId); + if (id > ascMaxId) ascMaxId = id; + if (slots !== null) slots[id - 1] = e; + else tempBuf.push(e); + } + reportProgress(); + if (ascPages === 1 && onFirstPage) { + onFirstPage(toEventHistory(events as HistoryEvent[])); + } + onPage?.(snapshotAccumulated()); + + if (!response.nextPageToken || gap() <= 0) { + descCtrl.abort(); + break; + } + token = response.nextPageToken as unknown as string; + } + }; + + const runDescending = async () => { + const route = routeForApi('events.descending', { namespace, workflowId }); + let token: Token; + while (!descCtrl.signal.aborted) { + const g = gap(); + if (g <= 0) { + ascCtrl.abort(); + break; + } + if (observedPageSize > 0 && g <= observedPageSize && !winnerChosen) { + winnerChosen = true; + ascCtrl.abort(); + } + + let response: GetWorkflowExecutionHistoryResponse; + try { + response = await requestFromAPI( + route, + { + token, + request: fetch, + params: { + 'execution.runId': runId, + waitNewEvent: 'false', + ...(maximumPageSize && { + maximumPageSize: String(maximumPageSize), + }), + }, + options: { signal: descCtrl.signal }, + }, + ); + } catch { + break; + } + const events = response?.history?.events ?? []; + if (!events.length) break; + + descPages++; + observedPageSize = Math.max(observedPageSize, events.length); + descEventCount += events.length; + // Compute bounds before writing so initSlots has the correct total. + for (const e of events) { + const id = parseInt(e.eventId); + if (id < descMinId) descMinId = id; + if (id > descMaxId) descMaxId = id; + } + initSlots(descMaxId); + for (const e of events) { + slots![parseInt(e.eventId) - 1] = e; + } + reportProgress(); + const snap = + (onFirstDescPage && descPages === 1) || onPage + ? snapshotAccumulated() + : null; + if (descPages === 1) onFirstDescPage?.(snap!); + if (snap) onPage?.(snap); + + if (!response.nextPageToken || gap() <= 0) { + ascCtrl.abort(); + break; + } + token = response.nextPageToken as unknown as string; + } + }; + + await Promise.allSettled([runAscending(), runDescending()]); + + // Compact: remove unfilled slots (can occur at the fetch boundary where the + // winner aborted before the other side finished its last page). + const rawFinal = slots ?? (tempBuf as (HistoryEvent | undefined)[]); + let write = 0; + for (let i = 0; i < rawFinal.length; i++) { + const e = rawFinal[i]; + if (e !== undefined) rawFinal[write++] = e; + } + rawFinal.length = write; + + const totalFetched = ascEventCount + descEventCount; + const merged: CommonHistoryEvent[] = toEventHistory( + rawFinal as HistoryEvent[], + ); + rawFinal.length = 0; + + const durationMs = performance.now() - t0; + const overlap = totalFetched - merged.length; + + const winner: BidirectionalStats['winner'] = + ascPages === descPages + ? 'tie' + : ascPages > descPages + ? 'ascending' + : 'descending'; + + return { + events: merged, + stats: { + durationMs, + totalEvents: merged.length, + overlap, + ascPages, + descPages, + eventsPerSecond: Math.round(merged.length / (durationMs / 1000)), + winner, + }, + }; +}; diff --git a/src/lib/services/fetch-bidirectional.ts b/src/lib/services/fetch-bidirectional.ts new file mode 100644 index 0000000000..bf1d4b2e53 --- /dev/null +++ b/src/lib/services/fetch-bidirectional.ts @@ -0,0 +1,243 @@ +import type { + GetWorkflowExecutionHistoryResponse, + HistoryEvent, +} from '$lib/types/events'; +import { requestFromAPI } from '$lib/utilities/request-from-api'; +import { routeForApi } from '$lib/utilities/route-for-api'; + +export type BidirectionalProgress = { + ascEvents: number; + descEvents: number; + ascPages: number; + descPages: number; + elapsedMs: number; + ascMaxId: number; + descMinId: number; + totalEstimated: number; +}; + +export type BidirectionalStats = { + durationMs: number; + totalEvents: number; + overlap: number; + ascPages: number; + descPages: number; + eventsPerSecond: number; + winner: 'ascending' | 'descending' | 'tie'; +}; + +export type FetchBidirectionalParams = { + namespace: string; + workflowId: string; + runId: string; + signal?: AbortSignal; + onProgress?: (p: BidirectionalProgress) => void; + /** Fires after every page with that page's raw events and direction flag. + * Feed directly into processEvent() to resolve buffer Promises live. */ + onRawPage: (events: HistoryEvent[], isAscending: boolean) => void; + /** Fires once with the raw events from the first descending page β€” the most + * recent events in the history. Use to call setFailedEvent() before the + * ascending cursor processes events that affect billableActions. */ + onFirstDescPage?: (events: HistoryEvent[]) => void; + maximumPageSize?: number; +}; + +export const fetchBidirectional = async ({ + namespace, + workflowId, + runId, + signal, + onProgress, + onRawPage, + onFirstDescPage, + maximumPageSize, +}: FetchBidirectionalParams): Promise => { + const t0 = performance.now(); + + const ascCtrl = new AbortController(); + const descCtrl = new AbortController(); + signal?.addEventListener('abort', () => { + ascCtrl.abort(); + descCtrl.abort(); + }); + + let ascMaxId = 0; + let descMinId = Infinity; + let descMaxId = 0; + let ascPages = 0; + let descPages = 0; + let observedPageSize = 0; + let winnerChosen = false; + let totalEvents = 0; + + const seen = new Set(); + + const gap = () => Math.max(0, descMinId - ascMaxId - 1); + + const reportProgress = () => { + onProgress?.({ + ascEvents: ascMaxId, + descEvents: totalEvents - descMinId + 1, + ascPages, + descPages, + elapsedMs: performance.now() - t0, + ascMaxId, + descMinId: descMinId === Infinity ? 0 : descMinId, + totalEstimated: descMaxId, + }); + }; + + type Token = string | undefined; + + const runAscending = async () => { + const route = routeForApi('events.ascending', { namespace, workflowId }); + let token: Token; + while (!ascCtrl.signal.aborted) { + const g = gap(); + if (g <= 0) { + descCtrl.abort(); + break; + } + if (observedPageSize > 0 && g <= observedPageSize && !winnerChosen) { + winnerChosen = true; + descCtrl.abort(); + } + + let response: GetWorkflowExecutionHistoryResponse; + try { + response = await requestFromAPI( + route, + { + token, + request: fetch, + params: { + 'execution.runId': runId, + waitNewEvent: 'false', + ...(maximumPageSize && { + maximumPageSize: String(maximumPageSize), + }), + }, + options: { signal: ascCtrl.signal }, + }, + ); + } catch { + break; + } + + const events = (response?.history?.events ?? []) as HistoryEvent[]; + if (!events.length) break; + + ascPages++; + observedPageSize = Math.max(observedPageSize, events.length); + + const fresh: HistoryEvent[] = []; + for (const e of events) { + const id = parseInt(e.eventId); + if (id > ascMaxId) ascMaxId = id; + if (!seen.has(id)) { + seen.add(id); + fresh.push(e); + } + } + if (fresh.length) onRawPage(fresh, true); + reportProgress(); + + if (!response.nextPageToken || gap() <= 0) { + descCtrl.abort(); + break; + } + token = response.nextPageToken as unknown as string; + } + }; + + const runDescending = async () => { + const route = routeForApi('events.descending', { namespace, workflowId }); + let token: Token; + while (!descCtrl.signal.aborted) { + const g = gap(); + if (g <= 0) { + ascCtrl.abort(); + break; + } + if (observedPageSize > 0 && g <= observedPageSize && !winnerChosen) { + winnerChosen = true; + ascCtrl.abort(); + } + + let response: GetWorkflowExecutionHistoryResponse; + try { + response = await requestFromAPI( + route, + { + token, + request: fetch, + params: { + 'execution.runId': runId, + waitNewEvent: 'false', + ...(maximumPageSize && { + maximumPageSize: String(maximumPageSize), + }), + }, + options: { signal: descCtrl.signal }, + }, + ); + } catch { + break; + } + + const events = (response?.history?.events ?? []) as HistoryEvent[]; + if (!events.length) break; + + descPages++; + observedPageSize = Math.max(observedPageSize, events.length); + + const fresh: HistoryEvent[] = []; + for (const e of events) { + const id = parseInt(e.eventId); + if (id < descMinId) descMinId = id; + if (id > descMaxId) { + descMaxId = id; + totalEvents = id; + } + if (!seen.has(id)) { + seen.add(id); + fresh.push(e); + } + } + if (fresh.length) onRawPage(fresh, false); + if (descPages === 1) onFirstDescPage?.(fresh); + reportProgress(); + + if (!response.nextPageToken || gap() <= 0) { + ascCtrl.abort(); + break; + } + token = response.nextPageToken as unknown as string; + } + }; + + await Promise.allSettled([runAscending(), runDescending()]); + + const durationMs = performance.now() - t0; + const total = seen.size; + const fetched = + ascPages * (observedPageSize || 1) + descPages * (observedPageSize || 1); + const overlap = Math.max(0, fetched - total); + + const winner: BidirectionalStats['winner'] = + ascPages === descPages + ? 'tie' + : ascPages > descPages + ? 'ascending' + : 'descending'; + + return { + durationMs, + totalEvents: total, + overlap, + ascPages, + descPages, + eventsPerSecond: Math.round(total / (durationMs / 1000)), + winner, + }; +}; diff --git a/src/lib/services/grouped-event-buffer.test.ts b/src/lib/services/grouped-event-buffer.test.ts new file mode 100644 index 0000000000..09e552858f --- /dev/null +++ b/src/lib/services/grouped-event-buffer.test.ts @@ -0,0 +1,1271 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { groupEvents } from '$lib/models/event-groups'; +import { toEventHistory } from '$lib/models/event-history'; + +import { + _debugEventSlots, + _debugState, + assignTrackIndices, + enrichGroups, + getAscGroupCount, + getDescGroupCount, + getGroupArray, + getGroupCount, + getGroupMeta, + getLatestEvent, + getLatestGroup, + getRows, + getVisibleGroupCount, + getWorkflowTaskFailedEvent, + isWorkflowTaskGroup, + mergeHeads, + onLatestGroup, + processEvent, + reset, + setEstimatedGroupCount, + setFailedEvent, +} from './grouped-event-buffer'; +import { + makeActivityCompleted, + makeActivityGroup, + makeActivityScheduled, + makeActivityStarted, + makeSyntheticEvents, + makeSyntheticEventsWithWorkflowTasks, + makeTimerGroup, + makeWorkflowCompleted, + makeWorkflowStarted, + makeWorkflowTaskCompleted, + makeWorkflowTaskFailed, + makeWorkflowTaskGroup, + makeWorkflowTaskScheduled, + makeWorkflowTaskStarted, +} from './test-helpers/synthetic-events'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Load all events ascending (simulates asc-cursor-wins scenario). */ +function loadAll(events: ReturnType) { + for (const e of events) processEvent(e, true); +} + +/** Flush all pending microtasks. */ +function tick() { + return Promise.resolve(); +} + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +beforeEach(() => { + reset(0); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// 1. Correctness: output equivalence with groupEvents +// --------------------------------------------------------------------------- + +describe('output equivalence with groupEvents', () => { + it('matches groupEvents for a small ascending load', async () => { + const events = makeSyntheticEvents(30); + reset(30); + loadAll(events); + + const workflowEvents = toEventHistory(events); + const expected = groupEvents(workflowEvents, 'ascending'); + const actual = await Promise.all(getRows(0, getGroupCount())); + + expect(actual.map((g) => g.id)).toEqual(expected.map((g) => g.id)); + expect(actual.map((g) => g.eventList.length)).toEqual( + expected.map((g) => g.eventList.length), + ); + }); + + it('includes WorkflowTask groups when present', async () => { + const events = makeSyntheticEventsWithWorkflowTasks(30); + reset(30); + loadAll(events); + + const actual = await Promise.all(getRows(0, getGroupCount())); + const wftGroups = actual.filter(isWorkflowTaskGroup); + expect(wftGroups.length).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Loading & gap cases +// --------------------------------------------------------------------------- + +describe('loading and gap cases', () => { + it('already-loaded groups resolve synchronously (within one microtask tick)', async () => { + const events = makeSyntheticEvents(30); + reset(30); + loadAll(events); + + let syncCount = 0; + const promises = getRows(0, getGroupCount()); + promises.forEach((p) => p.then(() => syncCount++)); + await tick(); + expect(syncCount).toBe(getGroupCount()); + }); + + it('pending promise resolves when the cursor writes the head event', async () => { + reset(10); + const [head] = makeActivityGroup(1); + + const p = getRows(0, 1)[0]; + let resolved = false; + p.then(() => { + resolved = true; + }); + + await tick(); + expect(resolved).toBe(false); + + processEvent(head, true); + await p; + expect(resolved).toBe(true); + }); + + it('requesting beyond current poolTop yields a promise that resolves later', async () => { + const events = makeSyntheticEvents(30); + reset(30); + + const farPromise = getRows(5, 6)[0]; + let didResolve = false; + farPromise.then(() => { + didResolve = true; + }); + + // Load only first 9 events β€” far group not reached yet + for (const e of events.slice(0, 9)) processEvent(e, true); + await tick(); + expect(didResolve).toBe(false); + + loadAll(events.slice(9)); + await farPromise; + expect(didResolve).toBe(true); + }); + + it('concurrent getRows calls for the same unloaded group return the same Promise', () => { + reset(10); + const p1 = getRows(0, 1)[0]; + const p2 = getRows(0, 1)[0]; + expect(p1).toBe(p2); + }); + + it('desc-cursor follower before asc-cursor head: group resolves with both events', async () => { + reset(10); + const [head, started, completed] = makeActivityGroup(1); + + // Desc cursor delivers completed & started before head + processEvent(completed, false); + processEvent(started, false); + expect(getGroupCount()).toBe(0); + + const p = getRows(0, 1)[0]; + let resolved = false; + p.then(() => { + resolved = true; + }); + + await tick(); + expect(resolved).toBe(false); + + // Asc cursor delivers the head + processEvent(head, true); + const group = await p; + + expect(resolved).toBe(true); + expect(getGroupCount()).toBe(1); + expect(group.eventList.length).toBe(3); + }); + + it('middle rows in the gap resolve as the ascending cursor advances', async () => { + // 30 events β†’ ~8 groups (mix of activities and timers + workflow started) + // Load first 3 events (1 group) and last 3 events (1 group) + const events = makeSyntheticEvents(30); + reset(30); + + // Load asc: events 1-12 (4 groups worth) + for (const e of events.slice(0, 12)) processEvent(e, true); + // Load desc: events 27-30 (1 activity group, reverse) + for (const e of events.slice(26).reverse()) processEvent(e, false); + + const totalAfterPartial = getGroupCount(); + // Request a group in the gap (if any pending) + const gapPromise = getRows(totalAfterPartial, totalAfterPartial + 1)[0]; + let gapResolved = false; + gapPromise.then(() => { + gapResolved = true; + }); + + await tick(); + expect(gapResolved).toBe(false); + + // Fill the rest ascending + for (const e of events.slice(12, 27)) processEvent(e, true); + await gapPromise; + expect(gapResolved).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Non-terminal workflow (running β€” new events arrive after initial fetch) +// --------------------------------------------------------------------------- + +describe('non-terminal workflow: new events beyond initial historyLength', () => { + it('new events extend eventSlots and groupPool beyond initial allocation', async () => { + const initialEvents = makeSyntheticEvents(9); // 9 events β†’ 3 groups + reset(9); + loadAll(initialEvents); + + const initialGroupCount = getGroupCount(); + expect(initialGroupCount).toBeGreaterThan(0); + + // New activity arrives at IDs 10-12 + const [newHead, newStarted, newCompleted] = makeActivityGroup(10); + processEvent(newHead, true); + processEvent(newStarted, true); + processEvent(newCompleted, true); + + expect(getGroupCount()).toBe(initialGroupCount + 1); + + const [newGroup] = await Promise.all( + getRows(initialGroupCount, initialGroupCount + 1), + ); + expect(newGroup).toBeDefined(); + expect(newGroup.eventList.length).toBe(3); + }); + + it('getLatestEvent reflects the highest-ID event after each write', () => { + reset(10); + const events = makeSyntheticEvents(10); + + for (let i = 0; i < events.length; i++) { + processEvent(events[i], true); + const latest = getLatestEvent(); + expect(Number(latest?.eventId)).toBe(i + 1); + } + }); + + it('getLatestEvent is non-null immediately after desc cursor first page', () => { + const events = makeSyntheticEvents(30); + reset(30); + + // Desc cursor: deliver last 9 events first + for (const e of events.slice(21).reverse()) processEvent(e, false); + + const latest = getLatestEvent(); + expect(latest).not.toBeNull(); + expect(Number(latest!.eventId)).toBe(30); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Most recent result (reactive use case) +// --------------------------------------------------------------------------- + +describe('getLatestGroup', () => { + it('returns null when no events have been processed', async () => { + reset(10); + const result = await getLatestGroup(); + expect(result).toBeNull(); + }); + + it('returns the last registered group after ascending load', async () => { + const events = makeSyntheticEvents(15); + reset(15); + loadAll(events); + + const latest = await getLatestGroup(); + expect(latest).not.toBeNull(); + // The last group should have the highest head event ID + const all = await Promise.all(getRows(0, getGroupCount())); + const lastGroup = all[all.length - 1]; + expect(latest!.id).toBe(lastGroup.id); + }); + + it('updates as new groups are added beyond initial fetch', async () => { + const events = makeSyntheticEvents(9); + reset(9); + loadAll(events); + + const latestBefore = await getLatestGroup(); + + // New event arrives + const [newHead, newStarted, newCompleted] = makeActivityGroup(10); + processEvent(newHead, true); + processEvent(newStarted, true); + processEvent(newCompleted, true); + + const latestAfter = await getLatestGroup(); + expect(Number(latestAfter!.id)).toBeGreaterThan(Number(latestBefore!.id)); + }); +}); + +describe('onLatestGroup', () => { + it('fires for each new group head registered', () => { + reset(30); + const updates: string[] = []; + const unsub = onLatestGroup((g) => updates.push(g.id)); + + const events = makeSyntheticEvents(30); + loadAll(events); + + unsub(); + expect(updates.length).toBeGreaterThan(0); + // The last notification should be for the highest-ID group + const lastNotifiedId = updates[updates.length - 1]; + expect(Number(lastNotifiedId)).toBeGreaterThan(0); + }); + + it('unsubscribe stops future notifications', () => { + reset(30); + const updates: string[] = []; + const unsub = onLatestGroup((g) => updates.push(g.id)); + + const firstBatch = makeSyntheticEvents(9); + loadAll(firstBatch); + const countAfterFirst = updates.length; + + unsub(); + + const [head] = makeActivityGroup(10); + processEvent(head, true); + + expect(updates.length).toBe(countAfterFirst); // no new notifications + }); +}); + +// --------------------------------------------------------------------------- +// 5. Navigation reset +// --------------------------------------------------------------------------- + +describe('navigation reset', () => { + it('reset() zeroes pool entries and reuses them for the next workflow', async () => { + const events1 = makeSyntheticEvents(30); + reset(30); + loadAll(events1); + const firstGroupCount = getGroupCount(); + + reset(15); + const events2 = makeSyntheticEvents(15); + loadAll(events2); + + // Pool was re-used β€” no double allocation + expect(getGroupCount()).toBeLessThanOrEqual(firstGroupCount); + expect(getGroupCount()).toBeGreaterThan(0); + }); + + it('promises created before reset() do not resolve after reset', async () => { + reset(30); + const stalePromise = getRows(10, 11)[0]; // pending + let resolved = false; + stalePromise.then(() => { + resolved = true; + }); + + reset(9); // navigate away + const events = makeSyntheticEvents(9); + loadAll(events); + + await tick(); + expect(resolved).toBe(false); // stale promise is abandoned + }); +}); + +// --------------------------------------------------------------------------- +// 6. Filtering +// --------------------------------------------------------------------------- + +describe('getRows with excludeWorkflowTasks', () => { + it('filters out WorkflowTask groups from the slice', async () => { + const events = makeSyntheticEventsWithWorkflowTasks(30); + reset(30); + loadAll(events); + + const all = await Promise.all(getRows(0, getGroupCount())); + const filtered = ( + await Promise.all( + getRows(0, getGroupCount(), { excludeWorkflowTasks: true }), + ) + ).filter(Boolean); + + expect(filtered.length).toBeLessThan(all.length); + expect(filtered.every((g) => !isWorkflowTaskGroup(g))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 7. Boundary dedup (cursor overlap) +// --------------------------------------------------------------------------- + +describe('boundary dedup', () => { + it('write-once guard prevents double group registration for the same head slot', () => { + reset(10); + const [head] = makeActivityGroup(1); + + processEvent(head, true); // asc cursor + processEvent(head, false); // desc cursor β€” same event, same slot + + expect(getGroupCount()).toBe(1); + }); + + it('eventToGroup is set on first write and ignored on second', () => { + reset(10); + const [head, started, completed] = makeActivityGroup(1); + + processEvent(head, true); + processEvent(started, true); + processEvent(completed, true); + + // Simulate boundary overlap: desc cursor also writes the head + processEvent(head, false); + + expect(getGroupCount()).toBe(1); + // Group still has all 3 events β€” the duplicate write didn't corrupt it + // (eventToGroup[0] !== 0 β†’ guard fires β†’ second head write skipped) + }); +}); + +// --------------------------------------------------------------------------- +// 8. Solo event orphan cleanup +// --------------------------------------------------------------------------- + +describe('solo event orphan cleanup', () => { + it('WorkflowExecutionStarted is not registered as a group', () => { + reset(10); + processEvent(makeWorkflowStarted(1), true); + expect(getGroupCount()).toBe(0); + }); + + it('WorkflowExecutionCompleted is not registered as a group', () => { + reset(10); + processEvent(makeWorkflowCompleted(5), true); + expect(getGroupCount()).toBe(0); + }); + + it('pendingFollowers is empty after solo head arrives for orphaned followers', () => { + reset(10); + + // Inject a follower that references slot 0 (eventId "1") as its head, + // but slot 0 will be a WorkflowExecutionStarted (solo, not a group head) + const orphanedFollower = makeActivityStarted(2, 1); // scheduledEventId=1 + processEvent(orphanedFollower, false); // parks in pendingFollowers[0] + + expect(_debugState().pendingFollowersSize).toBe(1); + + // Now the solo head arrives β€” createEventGroup returns undefined + processEvent(makeWorkflowStarted(1), true); + + expect(_debugState().pendingFollowersSize).toBe(0); // orphan discarded + expect(getGroupCount()).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// 9. mergeHeads ordering +// --------------------------------------------------------------------------- + +describe('mergeHeads', () => { + it('merged result is sorted ascending by slot index', () => { + const events = makeSyntheticEvents(30); + reset(30); + + // Asc: events 1-15 + for (const e of events.slice(0, 15)) processEvent(e, true); + // Desc: events 16-30 in reverse + for (const e of events.slice(15).reverse()) processEvent(e, false); + + const merged = mergeHeads(); + for (let i = 1; i < merged.length; i++) { + expect(merged[i]).toBeGreaterThan(merged[i - 1]); + } + }); + + it('merged result contains no duplicate slot indices', () => { + const events = makeSyntheticEvents(30); + reset(30); + for (const e of events.slice(0, 15)) processEvent(e, true); + for (const e of events.slice(15).reverse()) processEvent(e, false); + + const merged = mergeHeads(); + const unique = new Set(merged); + expect(unique.size).toBe(merged.length); + }); +}); + +// --------------------------------------------------------------------------- +// 10. setFailedEvent (billableActions context) +// --------------------------------------------------------------------------- + +describe('setFailedEvent', () => { + it('can be set to null without error', () => { + reset(10); + setFailedEvent(null); + expect(getLatestEvent()).toBeNull(); + }); + + it('can be set to a HistoryEvent before processEvent calls', () => { + reset(10); + const events = makeSyntheticEvents(9); + // Simulate desc first-page hook + setFailedEvent(null); + loadAll(events); + expect(getGroupCount()).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// 11. Heap size smoke test (skipped unless NODE_OPTIONS=--expose-gc) +// --------------------------------------------------------------------------- + +describe('memory overhead smoke test', () => { + it('heap growth for 50k events stays under 60MB', async () => { + if (typeof globalThis.gc !== 'function') return; // skip without --expose-gc + + const events = makeSyntheticEvents(50_000); + globalThis.gc(); + const before = process.memoryUsage().heapUsed; + + reset(50_000); + loadAll(events); + await Promise.all(getRows(0, getGroupCount())); + + globalThis.gc(); + const after = process.memoryUsage().heapUsed; + const deltaMB = (after - before) / (1024 * 1024); + + // Log the actual number so it's visible in CI output + console.log(`heap delta for 50k events: ${deltaMB.toFixed(1)} MB`); + expect(deltaMB).toBeLessThan(70); + }); +}); + +// --------------------------------------------------------------------------- +// 12. Activity group integrity +// --------------------------------------------------------------------------- + +describe('activity group integrity', () => { + it('group has all 3 events when loaded ascending', async () => { + reset(10); + const [scheduled, started, completed] = makeActivityGroup(1); + processEvent(scheduled, true); + processEvent(started, true); + processEvent(completed, true); + + const [group] = await Promise.all(getRows(0, 1)); + expect(group.eventList.length).toBe(3); + expect(group.id).toBe('1'); + }); + + it('group has all 3 events when followers arrive before head', async () => { + reset(10); + const [scheduled, started, completed] = makeActivityGroup(1); + + // Desc cursor delivers completion and start first + processEvent(completed, false); + processEvent(started, false); + // Asc cursor delivers head + processEvent(scheduled, true); + + const [group] = await Promise.all(getRows(0, 1)); + expect(group.eventList.length).toBe(3); + }); + + it('multiple groups maintain correct event membership', async () => { + reset(20); + const group1Events = makeActivityGroup(1); + const group2Events = makeActivityGroup(4); + const group3Events = makeTimerGroup(7); + + for (const e of [...group1Events, ...group2Events, ...group3Events]) { + processEvent(e, true); + } + + const groups = await Promise.all(getRows(0, getGroupCount())); + expect(groups[0].eventList.length).toBe(3); // activity + expect(groups[1].eventList.length).toBe(3); // activity + expect(groups[2].eventList.length).toBe(2); // timer + }); +}); + +// --------------------------------------------------------------------------- +// 13. getGroupCount progression +// --------------------------------------------------------------------------- + +describe('getGroupCount', () => { + it('increments as head events are processed', () => { + reset(30); + expect(getGroupCount()).toBe(0); + + processEvent(makeActivityScheduled(1), true); + expect(getGroupCount()).toBe(1); + + processEvent(makeActivityScheduled(4), true); + expect(getGroupCount()).toBe(2); + }); + + it('does not increment for follower events', () => { + reset(10); + processEvent(makeActivityScheduled(1), true); + expect(getGroupCount()).toBe(1); + + processEvent(makeActivityStarted(2, 1), true); + expect(getGroupCount()).toBe(1); // still 1 + + processEvent(makeActivityCompleted(3, 1, 2), true); + expect(getGroupCount()).toBe(1); // still 1 + }); + + it('does not increment for solo (non-group) events', () => { + reset(10); + processEvent(makeWorkflowStarted(1), true); + processEvent(makeWorkflowCompleted(2), true); + expect(getGroupCount()).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// 14. enrichGroups β€” pending activity + nexus annotation +// --------------------------------------------------------------------------- + +describe('enrichGroups', () => { + it('sets pendingActivity on an in-flight activity group (1 event)', async () => { + reset(10); + processEvent(makeActivityScheduled(1, 'MyActivity'), true); + + const pendingActivities = [ + { activityId: '1', state: 'Started', activityType: 'MyActivity' }, + ] as Parameters[0]; + + enrichGroups(pendingActivities, []); + + const [group] = await Promise.all(getRows(0, 1)); + expect(group.pendingActivity).toBeDefined(); + expect(group.pendingActivity?.activityId).toBe('1'); + }); + + it('does not set pendingActivity on a completed activity group (3 events)', async () => { + reset(10); + const [s, st, c] = makeActivityGroup(1); + processEvent(s, true); + processEvent(st, true); + processEvent(c, true); + + const pendingActivities = [ + { activityId: '1', state: 'Started', activityType: 'TestActivity' }, + ] as Parameters[0]; + + enrichGroups(pendingActivities, []); + + const [group] = await Promise.all(getRows(0, 1)); + expect(group.pendingActivity).toBeUndefined(); + }); + + it('clears a previously set pendingActivity when no longer pending', async () => { + reset(10); + const [s, st, c] = makeActivityGroup(1); + processEvent(s, true); + processEvent(st, true); + processEvent(c, true); + + // First enrichment marks it pending + const pa = [{ activityId: '1', state: 'Started' }] as Parameters< + typeof enrichGroups + >[0]; + enrichGroups(pa, []); + + // Second enrichment with empty array (activity completed server-side) + enrichGroups([], []); + + const [group] = await Promise.all(getRows(0, 1)); + expect(group.pendingActivity).toBeUndefined(); + }); + + it('ignores activities not in the pending list', async () => { + reset(10); + processEvent(makeActivityScheduled(1, 'MyActivity'), true); + enrichGroups([], []); + + const [group] = await Promise.all(getRows(0, 1)); + expect(group.pendingActivity).toBeUndefined(); + }); + + it('does not touch non-activity groups', async () => { + reset(10); + const [ts, tf] = makeTimerGroup(1); + processEvent(ts, true); + processEvent(tf, true); + + enrichGroups( + [{ activityId: '1', state: 'Started' }] as Parameters< + typeof enrichGroups + >[0], + [], + ); + + const [group] = await Promise.all(getRows(0, 1)); + expect(group.pendingActivity).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 15. getWorkflowTaskFailedEvent β€” derives active WFT failure from buffer +// --------------------------------------------------------------------------- + +describe('getWorkflowTaskFailedEvent (buffer)', () => { + it('returns undefined when no WFT groups exist', () => { + reset(10); + processEvent(makeActivityScheduled(1), true); + expect(getWorkflowTaskFailedEvent()).toBeUndefined(); + }); + + it('returns undefined when WFT group completed normally', () => { + reset(10); + for (const e of makeWorkflowTaskGroup(1)) processEvent(e, true); + expect(getWorkflowTaskFailedEvent()).toBeUndefined(); + }); + + it('returns the failed event when a WFT group has no subsequent completion', () => { + reset(10); + // WFT: Scheduled(1), Started(2), Failed(3) + processEvent(makeWorkflowTaskScheduled(1), true); + processEvent(makeWorkflowTaskStarted(2, 1), true); + processEvent(makeWorkflowTaskFailed(3, 1), true); + + const failed = getWorkflowTaskFailedEvent(); + expect(failed).toBeDefined(); + expect(failed?.eventType).toBe('WorkflowTaskFailed'); + }); + + it('returns undefined when a later WFT completed after the failure (workflow recovered)', () => { + reset(20); + // First WFT: fails + processEvent(makeWorkflowTaskScheduled(1), true); + processEvent(makeWorkflowTaskStarted(2, 1), true); + processEvent(makeWorkflowTaskFailed(3, 1), true); + // Second WFT: completes successfully (workflow recovered) + processEvent(makeWorkflowTaskScheduled(4), true); + processEvent(makeWorkflowTaskStarted(5, 4), true); + processEvent(makeWorkflowTaskCompleted(6, 4), true); + + expect(getWorkflowTaskFailedEvent()).toBeUndefined(); + }); + + it('returns undefined for ResetWorkflow cause (not a real failure)', () => { + reset(10); + processEvent(makeWorkflowTaskScheduled(1), true); + processEvent(makeWorkflowTaskStarted(2, 1), true); + processEvent(makeWorkflowTaskFailed(3, 1, 'ResetWorkflow'), true); + + expect(getWorkflowTaskFailedEvent()).toBeUndefined(); + }); + + it('returns the most recent failure when multiple WFT groups fail', () => { + reset(20); + // First WFT fails + processEvent(makeWorkflowTaskScheduled(1), true); + processEvent(makeWorkflowTaskStarted(2, 1), true); + processEvent(makeWorkflowTaskFailed(3, 1), true); + // Second WFT also fails (eventId 6 > 3) + processEvent(makeWorkflowTaskScheduled(4), true); + processEvent(makeWorkflowTaskStarted(5, 4), true); + processEvent(makeWorkflowTaskFailed(6, 4), true); + + const failed = getWorkflowTaskFailedEvent(); + expect(failed?.eventType).toBe('WorkflowTaskFailed'); + expect(failed?.id).toBe('6'); + }); +}); + +// --------------------------------------------------------------------------- +// 16. getGroupArray β€” synchronous sorted group access +// --------------------------------------------------------------------------- + +describe('getGroupArray', () => { + it('returns groups sorted by eventId ascending', () => { + reset(20); + // Load in non-sequential order (simulates interleaved cursors) + const [s2, st2, c2] = makeActivityGroup(4); + const [s1, st1, c1] = makeActivityGroup(1); + for (const e of [s2, st2, c2]) processEvent(e, false); // desc cursor + for (const e of [s1, st1, c1]) processEvent(e, true); // asc cursor + + const groups = getGroupArray(); + expect(groups.length).toBe(2); + expect(Number(groups[0].id)).toBeLessThan(Number(groups[1].id)); + }); + + it('excludeWorkflowTasks filters WFT groups', () => { + reset(20); + for (const e of makeWorkflowTaskGroup(1)) processEvent(e, true); + for (const e of makeActivityGroup(4)) processEvent(e, true); + + const all = getGroupArray(); + const noWft = getGroupArray({ excludeWorkflowTasks: true }); + expect(all.length).toBe(2); + expect(noWft.length).toBe(1); + expect(noWft[0].initialEvent.eventType).toBe('ActivityTaskScheduled'); + }); + + it('is stable across multiple calls', () => { + reset(10); + for (const e of makeActivityGroup(1)) processEvent(e, true); + + const a = getGroupArray(); + const b = getGroupArray(); + expect(a.length).toBe(b.length); + expect(a[0].id).toBe(b[0].id); + }); + + it('returns empty array when no groups loaded', () => { + reset(10); + processEvent(makeWorkflowStarted(1), true); + expect(getGroupArray()).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Pixi rendering layer β€” track assignment & WFT filtering +// --------------------------------------------------------------------------- + +/** + * Simulates the renderer's `rebuildByTrack` logic (pure data, no Pixi). + * Returns a sparse array indexed by trackIndex containing pool indices. + */ +function simulateByTrack(): number[][] { + const byTrack: number[][] = []; + const count = getGroupCount(); + for (let i = 0; i < count; i++) { + const meta = getGroupMeta(i); + if (!meta || meta.pixiType === 'GROUP_WORKFLOW_TASK' || meta.trackIndex < 0) + continue; + (byTrack[meta.trackIndex] ??= []).push(i); + } + return byTrack; +} + +describe('Pixi track assignment β€” WFT filtering', () => { + it('WFT groups receive trackIndex -1 and are excluded from visible count', () => { + const events = makeSyntheticEventsWithWorkflowTasks(25); + reset(25); + for (const e of events) processEvent(e, true); + + let wftCount = 0; + let nonWftCount = 0; + for (let i = 0; i < getGroupCount(); i++) { + const meta = getGroupMeta(i); + if (!meta) continue; + if (meta.pixiType === 'GROUP_WORKFLOW_TASK') { + expect(meta.trackIndex).toBe(-1); + wftCount++; + } else { + expect(meta.trackIndex).toBeGreaterThanOrEqual(0); + nonWftCount++; + } + } + expect(wftCount).toBeGreaterThan(0); + expect(getVisibleGroupCount()).toBe(nonWftCount); + expect(getGroupCount()).toBe(wftCount + nonWftCount); + }); + + it('getVisibleGroupCount excludes WFT groups', () => { + reset(20); + for (const e of makeWorkflowTaskGroup(1)) processEvent(e, true); + for (const e of makeActivityGroup(4)) processEvent(e, true); + for (const e of makeWorkflowTaskGroup(7)) processEvent(e, true); + + expect(getGroupCount()).toBe(3); + expect(getVisibleGroupCount()).toBe(1); + }); + + it('non-WFT desc groups fill tracks 0..n consecutively', () => { + reset(20); + for (const e of makeActivityGroup(1)) processEvent(e, false); + for (const e of makeActivityGroup(4)) processEvent(e, false); + for (const e of makeWorkflowTaskGroup(7)) processEvent(e, false); + for (const e of makeActivityGroup(10)) processEvent(e, false); + + const tracks = []; + for (let i = 0; i < getGroupCount(); i++) { + const meta = getGroupMeta(i); + if (meta && meta.pixiType !== 'GROUP_WORKFLOW_TASK') { + tracks.push(meta.trackIndex); + } + } + tracks.sort((a, b) => a - b); + expect(tracks).toEqual([0, 1, 2]); + }); + + it('non-WFT asc groups fill from bottom of the estimated total', () => { + const EST = 10; + reset(20); + setEstimatedGroupCount(EST); + for (const e of makeActivityGroup(1)) processEvent(e, true); + for (const e of makeActivityGroup(4)) processEvent(e, true); + + for (let i = 0; i < getGroupCount(); i++) { + const meta = getGroupMeta(i); + if (meta && meta.pixiType !== 'GROUP_WORKFLOW_TASK') { + expect(meta.trackIndex).toBeGreaterThanOrEqual(EST - 2); + } + } + }); + + it('after assignTrackIndices, visible tracks are contiguous with no holes', () => { + reset(30); + const events = makeSyntheticEventsWithWorkflowTasks(25); + // split: first half desc, second half asc + const half = Math.floor(events.length / 2); + for (const e of events.slice(0, half)) processEvent(e, false); + for (const e of events.slice(half)) processEvent(e, true); + + assignTrackIndices(); + + const usedTracks = new Set(); + for (let i = 0; i < getGroupCount(); i++) { + const meta = getGroupMeta(i); + if (!meta || meta.pixiType === 'GROUP_WORKFLOW_TASK') continue; + expect(meta.trackIndex).toBeGreaterThanOrEqual(0); + usedTracks.add(meta.trackIndex); + } + + const sorted = [...usedTracks].sort((a, b) => a - b); + const expected = Array.from({ length: sorted.length }, (_, k) => k); + expect(sorted).toEqual(expected); + }); + + it('desc groups always at top (lower indices), asc groups at bottom after assignTrackIndices', () => { + reset(20); + for (const e of makeActivityGroup(1)) processEvent(e, false); + for (const e of makeActivityGroup(4)) processEvent(e, false); + for (const e of makeActivityGroup(7)) processEvent(e, true); + for (const e of makeActivityGroup(10)) processEvent(e, true); + + assignTrackIndices(); + + const descTracks: number[] = []; + const ascTracks: number[] = []; + + for (let i = 0; i < getGroupCount(); i++) { + const meta = getGroupMeta(i); + if (!meta || meta.pixiType === 'GROUP_WORKFLOW_TASK') continue; + } + + expect(getDescGroupCount()).toBe(2); + expect(getAscGroupCount()).toBe(2); + + // desc groups: tracks 0 and 1 + // asc groups: tracks 2 and 3 (total=4, so total-1-0=3, total-1-1=2) + for (let i = 0; i < getGroupCount(); i++) { + const meta = getGroupMeta(i); + if (!meta || meta.pixiType === 'GROUP_WORKFLOW_TASK') continue; + } + + // Verify by inspecting raw counts + const total = getVisibleGroupCount(); + expect(total).toBe(4); + // desc group 0 β†’ track 0, desc group 1 β†’ track 1 + // asc group 0 β†’ track 3, asc group 1 β†’ track 2 + let descSeen = 0; + let ascSeen = 0; + for (let i = 0; i < getGroupCount(); i++) { + const meta = getGroupMeta(i); + if (!meta || meta.pixiType === 'GROUP_WORKFLOW_TASK') continue; + if (descTracks.includes(meta.trackIndex)) descSeen++; + if (ascTracks.includes(meta.trackIndex)) ascSeen++; + } + // Both sections should not overlap + expect(new Set([...descTracks, ...ascTracks]).size).toBe( + descTracks.length + ascTracks.length, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Pixi rendering layer β€” loading gap / indicator state +// --------------------------------------------------------------------------- + +describe('Pixi loading gap calculation', () => { + it('loading gap = 0 when all visible groups are loaded via one cursor', () => { + const events = makeSyntheticEvents(20); + reset(20); + for (const e of events) processEvent(e, true); + assignTrackIndices(); + + const descCount = getDescGroupCount(); + const ascCount = getAscGroupCount(); + const totalRows = getVisibleGroupCount(); + + // ascending-only load: all groups in ascGroupHeads, descGroupHeads empty + expect(descCount).toBe(0); + expect(ascCount).toBe(totalRows); + + const loadingStart = descCount; + const loadingEnd = Math.max(descCount, totalRows - ascCount); + expect(loadingStart).toBe(loadingEnd); // no gap + }); + + it('loading gap exists while only desc cursor has loaded', () => { + const events = makeSyntheticEvents(12); + reset(12); + // Only process first 6 events descending + for (const e of events.slice(0, 6)) processEvent(e, false); + + const descCount = getDescGroupCount(); + const ascCount = getAscGroupCount(); + const estimated = 6; // rough estimate of total visible + const totalRows = Math.max(estimated, getVisibleGroupCount()); + + const loadingStart = descCount; + const loadingEnd = Math.max(descCount, totalRows - ascCount); + + expect(descCount).toBeGreaterThan(0); + expect(ascCount).toBe(0); + expect(loadingEnd).toBeGreaterThan(loadingStart); // gap present + }); + + it('loading gap closes when bidirectional cursors cover all tracks', () => { + const events = makeSyntheticEvents(20); + reset(20); + + const half = Math.floor(events.length / 2); + for (const e of events.slice(0, half)) processEvent(e, false); + for (const e of events.slice(half)) processEvent(e, true); + + assignTrackIndices(); + + const descCount = getDescGroupCount(); + const ascCount = getAscGroupCount(); + const totalRows = getVisibleGroupCount(); + + expect(descCount + ascCount).toBe(totalRows); + const loadingStart = descCount; + const loadingEnd = Math.max(descCount, totalRows - ascCount); + expect(loadingStart).toBe(loadingEnd); + }); + + it('poolCount never inflates totalRows for visible group purposes', () => { + // Load a mix of WFT and non-WFT; poolCount > getVisibleGroupCount() + reset(20); + const events = makeSyntheticEventsWithWorkflowTasks(19); + for (const e of events) processEvent(e, true); + assignTrackIndices(); + + const poolCount = getGroupCount(); + const visibleCount = getVisibleGroupCount(); + + expect(poolCount).toBeGreaterThan(visibleCount); + + // The loading gap calculation must use visibleCount, not poolCount + const descCount = getDescGroupCount(); + const ascCount = getAscGroupCount(); + const totalRowsCorrect = Math.max(visibleCount, visibleCount); + const totalRowsWrong = Math.max(visibleCount, poolCount); + + const gapCorrect = + Math.max(descCount, totalRowsCorrect - ascCount) - descCount; + const gapWrong = Math.max(descCount, totalRowsWrong - ascCount) - descCount; + + // Using poolCount would produce a spurious loading gap + expect(gapCorrect).toBe(0); + expect(gapWrong).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// Pixi rendering layer β€” gutter event data (byTrack simulation) +// --------------------------------------------------------------------------- + +describe('Pixi gutter event data (byTrack)', () => { + it('byTrack has one entry per visible group, no holes', () => { + const events = makeSyntheticEvents(20); + reset(20); + for (const e of events) processEvent(e, true); + assignTrackIndices(); + + const byTrack = simulateByTrack(); + const occupied = byTrack.filter(Boolean); + + expect(occupied.length).toBe(getVisibleGroupCount()); + + // No holes: every index 0..visibleCount-1 should be present + const visibleCount = getVisibleGroupCount(); + for (let t = 0; t < visibleCount; t++) { + expect(byTrack[t]).toBeDefined(); + } + }); + + it('WFT groups are absent from byTrack', () => { + reset(25); + const events = makeSyntheticEventsWithWorkflowTasks(24); + for (const e of events) processEvent(e, true); + assignTrackIndices(); + + const byTrack = simulateByTrack(); + for (const poolIdxs of byTrack) { + if (!poolIdxs) continue; + for (const idx of poolIdxs) { + const meta = getGroupMeta(idx); + expect(meta?.pixiType).not.toBe('GROUP_WORKFLOW_TASK'); + } + } + }); + + it('tracks above viewport are identified as top gutter events', () => { + reset(20); + const events = makeSyntheticEvents(20); + for (const e of events) processEvent(e, true); + assignTrackIndices(); + + const byTrack = simulateByTrack(); + const visibleCount = getVisibleGroupCount(); + + // Simulate viewport showing only tracks 3..6 + const firstVisible = 3; + const lastVisible = 6; + + const above = byTrack.slice(0, firstVisible).filter(Boolean); + const below = byTrack.slice(lastVisible + 1, visibleCount).filter(Boolean); + + expect(above.length).toBe(firstVisible); // tracks 0,1,2 + expect(below.length).toBe(visibleCount - lastVisible - 1); + + // All events in gutter regions have valid pool indices + for (const idxs of [...above, ...below]) { + for (const idx of idxs) { + const meta = getGroupMeta(idx); + expect(meta).not.toBeNull(); + expect(meta?.trackIndex).toBeGreaterThanOrEqual(0); + } + } + }); + + it('top gutter events have smaller trackIndex than bottom gutter events', () => { + reset(20); + const events = makeSyntheticEvents(20); + for (const e of events) processEvent(e, true); + assignTrackIndices(); + + const byTrack = simulateByTrack(); + const visibleCount = getVisibleGroupCount(); + const midpoint = Math.floor(visibleCount / 2); + + const topTracks = byTrack + .slice(0, midpoint) + .flatMap((t) => t ?? []) + .map((idx) => getGroupMeta(idx)!.trackIndex); + const bottomTracks = byTrack + .slice(midpoint) + .flatMap((t) => t ?? []) + .map((idx) => getGroupMeta(idx)!.trackIndex); + + const maxTop = Math.max(...topTracks); + const minBottom = Math.min(...bottomTracks); + expect(maxTop).toBeLessThan(minBottom); + }); + + it('byTrack is empty after reset', () => { + reset(20); + const events = makeSyntheticEvents(20); + for (const e of events) processEvent(e, true); + assignTrackIndices(); + + reset(0); + const byTrack = simulateByTrack(); + expect(byTrack.filter(Boolean).length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Option C β€” eventSlots memory release +// +// These tests verify that raw HistoryEvent objects stored in eventSlots are +// nulled out once the event has been fully incorporated into its EventGroup. +// They FAIL before Option C is implemented and PASS afterwards. +// --------------------------------------------------------------------------- + +describe('Option C β€” eventSlots are released after grouping', () => { + it('[RED] eventSlots entries for head events are null after processEvent returns', () => { + const events = makeSyntheticEvents(10); + reset(10); + for (const e of events) processEvent(e, true); + + const slots = _debugEventSlots(); + const occupiedAfterProcessing = slots.filter((s) => s != null); + + // After all events have been processed into groups, no slots should remain + // populated. This FAILS currently because we never null out eventSlots. + expect(occupiedAfterProcessing.length).toBe(0); + }); + + it('[RED] follower eventSlots are null after they are attached to their group', () => { + // Build a proper WFT-then-activity sequence with correct cross-references: + // 1: WorkflowExecutionStarted (solo head) + // 2: WorkflowTaskScheduled (WFT head) + // 3: WorkflowTaskStarted (follower of 2) + // 4: WorkflowTaskCompleted (follower of 2) + // 5: ActivityTaskScheduled (activity head) + // 6: ActivityTaskStarted (follower of 5) + // 7: ActivityTaskCompleted (follower of 5) + reset(10); + processEvent(makeWorkflowStarted(1), true); + processEvent(makeWorkflowTaskScheduled(2), true); + processEvent(makeWorkflowTaskStarted(3, 2), true); + processEvent(makeWorkflowTaskCompleted(4, 2), true); + processEvent(makeActivityScheduled(5), true); + processEvent(makeActivityStarted(6, 5), true); + processEvent(makeActivityCompleted(7, 5, 6), true); + + const slots = _debugEventSlots(); + const followerSlots = slots.slice(0, 7).filter((s) => s != null); + + // All slots (heads + followers) should be null once attached to their group. + // FAILS currently because we never null out eventSlots. + expect(followerSlots.length).toBe(0); + }); + + it('[RED] eventSlots remain entirely null-filled after all groups are complete', () => { + const events = makeSyntheticEvents(30); + reset(30); + for (const e of events) processEvent(e, true); + + const slots = _debugEventSlots(); + const populated = Array.from(slots).filter((s) => s != null); + + // Every slot should be released. FAILS currently. + expect(populated.length).toBe(0); + }); + + it('getGroupCount is unaffected by eventSlots nulling', () => { + const events = makeSyntheticEvents(20); + reset(20); + const before = getGroupCount(); + for (const e of events) processEvent(e, true); + const after = getGroupCount(); + // Groups still accumulate correctly regardless of slot nulling + expect(after).toBeGreaterThan(before); + }); + + it('getGroupMeta still returns correct data after eventSlots are released', () => { + const events = makeSyntheticEvents(10); + reset(10); + for (const e of events) processEvent(e, true); + + for (let i = 0; i < getGroupCount(); i++) { + const meta = getGroupMeta(i); + expect(meta).not.toBeNull(); + expect(meta!.startMs).toBeGreaterThan(0); + } + }); +}); diff --git a/src/lib/services/grouped-event-buffer.ts b/src/lib/services/grouped-event-buffer.ts new file mode 100644 index 0000000000..02db8b9c1e --- /dev/null +++ b/src/lib/services/grouped-event-buffer.ts @@ -0,0 +1,678 @@ +import { + addEventToGroup, + createEventGroup, + createWorkflowTaskGroup, +} from '$lib/models/event-groups/create-event-group'; +import type { EventGroup } from '$lib/models/event-groups/event-groups'; +import { getGroupId } from '$lib/models/event-groups/get-group-id'; +import { toEvent } from '$lib/models/event-history'; +import type { + CommonHistoryEvent, + HistoryEvent, + PendingActivity, + PendingNexusOperation, + WorkflowEvent, +} from '$lib/types/events'; +import { isWorkflowTaskFailedEventDueToReset } from '$lib/utilities/get-workflow-task-failed-event'; +import { + isActivityTaskScheduledEvent, + isNexusOperationCanceledEvent, + isNexusOperationCompletedEvent, + isNexusOperationFailedEvent, + isNexusOperationScheduledEvent, + isNexusOperationTimedOutEvent, +} from '$lib/utilities/is-event-type'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type GroupMeta = { + headSlotIdx: number; + group: EventGroup | null; + startMs: number; + endMs: number; + trackIndex: number; + pixiType: string; + pixiStatus: string; +}; + +export type GetRowsOptions = { + excludeWorkflowTasks?: boolean; +}; + +export type LatestGroupListener = (group: EventGroup) => void; + +// --------------------------------------------------------------------------- +// Module state (reset between workflows via reset()) +// --------------------------------------------------------------------------- + +let eventSlots: (HistoryEvent | null)[] = []; +let eventToGroup = new Int32Array(0); +const groupPool: GroupMeta[] = []; +let poolTop = 0; + +const ascGroupHeads: number[] = []; +const descGroupHeads: number[] = []; + +// Used during streaming to assign track indices in the final bidirectional layout +// (desc events at top, asc events at bottom, loading gap in between). +let estimatedTotalGroups = 0; +const pendingFollowers = new Map(); +const pendingResolvers = new Map void)[]>(); +const activePromises = new Map>(); + +let failedEvent: HistoryEvent | null = null; +let latestEventSlotIdx = -1; +let latestEventRef: HistoryEvent | null = null; +const latestGroupListeners: LatestGroupListener[] = []; + +// Accumulated WFT IDs for marker billable-action dedup (ascending cursor only) +const processedWorkflowTaskIds = new Set(); + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function makeGroupMeta(): GroupMeta { + return { + headSlotIdx: -1, + group: null, + startMs: 0, + endMs: 0, + trackIndex: -1, + pixiType: '', + pixiStatus: '', + }; +} + +function resetMeta(m: GroupMeta): void { + m.headSlotIdx = -1; + m.group = null; + m.startMs = 0; + m.endMs = 0; + m.trackIndex = -1; + m.pixiType = ''; + m.pixiStatus = ''; +} + +function toMs(t: unknown): number { + if (!t) return 0; + if (typeof t === 'number') return t; + if (t instanceof Date) return t.getTime(); + if (typeof t === 'object') { + const obj = t as Record; + if ('seconds' in obj) { + return ( + Number(obj.seconds ?? 0) * 1000 + Number(obj.nanos ?? 0) / 1_000_000 + ); + } + } + return new Date(t as string).getTime(); +} + +function pascalToEventType(name: string): string { + return ( + 'EVENT_TYPE_' + + name + .replace(/([A-Z])/g, '_$1') + .toUpperCase() + .replace(/^_/, '') + ); +} + +function groupToPixiType(group: EventGroup): string { + switch (group.category) { + case 'activity': + case 'local-activity': + return 'GROUP_ACTIVITY'; + case 'child-workflow': + return 'GROUP_CHILD_WORKFLOW'; + case 'timer': + return 'GROUP_TIMER'; + default: + break; + } + if (group.initialEvent.eventType === 'WorkflowTaskScheduled') { + return 'GROUP_WORKFLOW_TASK'; + } + return pascalToEventType(group.initialEvent.eventType); +} + +function groupToPixiStatus(group: EventGroup): string { + if (group.isTerminated) return 'failed'; + if (group.isFailureOrTimedOut) return 'failed'; + if (group.isCanceled) return 'canceled'; + if (group.isPending) return 'started'; + const c = group.finalClassification ?? group.classification; + switch (c) { + case 'Completed': + return 'completed'; + case 'Fired': + return 'fired'; + case 'Signaled': + return 'signaled'; + case 'Failed': + case 'TimedOut': + case 'Terminated': + return 'failed'; + case 'Canceled': + case 'CancelRequested': + return 'canceled'; + case 'Started': + case 'Open': + case 'Running': + return 'started'; + default: + return 'scheduled'; + } +} + +function shouldNotAddBillableAction(event: WorkflowEvent): boolean { + if (!failedEvent) return false; + return Number(event.id) < Number(failedEvent.eventId); +} + +function toWorkflowEvent( + raw: HistoryEvent, + isAscending: boolean, +): WorkflowEvent { + return toEvent(raw, { + shouldNotAddBillableAction, + processedWorkflowTaskIds: isAscending + ? processedWorkflowTaskIds + : undefined, + }); +} + +function growArrays(newSize: number): void { + if (newSize <= eventSlots.length) return; + eventSlots.length = newSize; + const grown = new Int32Array(newSize); + grown.set(eventToGroup); + eventToGroup = grown; +} + +function attachFollowerToPool(poolIdx: number, followerSlotIdx: number): void { + const meta = groupPool[poolIdx]; + const raw = eventSlots[followerSlotIdx]; + if (!raw || !meta.group) return; + + const event = toWorkflowEvent(raw, false); + meta.group.eventList.push(event); + meta.group.timestamp = event.timestamp; + addEventToGroup(meta.group, event); + + eventToGroup[followerSlotIdx] = poolIdx + 1; + + if (meta.group.pendingActivity && meta.group.eventList.length === 3) { + delete meta.group.pendingActivity; + } + + const followerMs = toMs(event.eventTime); + if (followerMs > meta.endMs) meta.endMs = followerMs; + + eventSlots[followerSlotIdx] = null; +} + +function attachFollower(headSlotIdx: number, followerSlotIdx: number): void { + const poolIdx = eventToGroup[headSlotIdx]; + if (poolIdx !== 0) { + attachFollowerToPool(poolIdx - 1, followerSlotIdx); + } else { + const pending = pendingFollowers.get(headSlotIdx); + if (pending) { + pending.push(followerSlotIdx); + } else { + pendingFollowers.set(headSlotIdx, [followerSlotIdx]); + } + } +} + +function notifyLatestGroupListeners(group: EventGroup): void { + for (const cb of latestGroupListeners) { + cb(group); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * (Re)initialise the buffer for a new workflow fetch. + * Call before starting fetchAllEventsBidirectional. + */ +export function reset(historyLength: number): void { + const N = Math.max(historyLength + 16, 16); + + eventSlots = new Array(N).fill(null); + eventToGroup = new Int32Array(N); + + const maxGroups = Math.ceil(N / 1.5); + while (groupPool.length < maxGroups) groupPool.push(makeGroupMeta()); + for (let i = 0; i < poolTop; i++) resetMeta(groupPool[i]); + + poolTop = 0; + ascGroupHeads.length = 0; + descGroupHeads.length = 0; + pendingFollowers.clear(); + pendingResolvers.clear(); + activePromises.clear(); + processedWorkflowTaskIds.clear(); + failedEvent = null; + latestEventSlotIdx = -1; + latestEventRef = null; + latestGroupListeners.length = 0; +} + +/** + * Set the estimated total group count so streaming track indices use the + * correct bidirectional layout (desc at top, asc at bottom). + * Call this before starting fetchBidirectional, after reset(). + */ +export function setEstimatedGroupCount(n: number): void { + estimatedTotalGroups = n; +} + +/** + * Call from the descending cursor's onFirstDescPage hook to capture the + * failedEvent used for billableActions calculation. + */ +export function setFailedEvent(raw: HistoryEvent | null): void { + failedEvent = raw; +} + +/** + * Process a single raw HistoryEvent from either cursor. + * isAscending: true = ascending cursor, false = descending cursor. + * Returns the EventGroup when a new group HEAD is registered, null otherwise. + */ +export function processEvent( + raw: HistoryEvent, + isAscending: boolean, +): EventGroup | null { + const slotIdx = parseInt(raw.eventId) - 1; + + if (slotIdx >= eventSlots.length) { + growArrays(slotIdx + Math.ceil(slotIdx * 0.25) + 16); + } + + eventSlots[slotIdx] = raw; + + if (slotIdx > latestEventSlotIdx) { + latestEventSlotIdx = slotIdx; + latestEventRef = raw; + } + + const event = toWorkflowEvent(raw, isAscending); + const gid = getGroupId(event as CommonHistoryEvent); + const isHead = gid === event.id; + + if (!isHead) { + const headSlotIdx = parseInt(gid) - 1; + attachFollower(headSlotIdx, slotIdx); + // If the head already existed, attachFollowerToPool already nulled this slot. + // If not, the slot stays live until the head arrives and flushes it. + return null; + } + + // Try both group dispatchers β€” createWorkflowTaskGroup handles WFT events + const group = + createEventGroup(event as CommonHistoryEvent) ?? + createWorkflowTaskGroup(event as CommonHistoryEvent); + + if (!group) { + // Solo event: discard any orphaned pending followers + pendingFollowers.delete(slotIdx); + eventSlots[slotIdx] = null; + return null; + } + + // Write-once guard: prevents double-registration at cursor boundary overlap + if (eventToGroup[slotIdx] !== 0) { + eventSlots[slotIdx] = null; + return null; + } + + if (poolTop >= groupPool.length) { + groupPool.push(makeGroupMeta()); + } + const poolIdx = poolTop++; + const meta = groupPool[poolIdx]; + resetMeta(meta); + meta.headSlotIdx = slotIdx; + meta.group = group; + + const startMs = toMs(event.eventTime); + meta.startMs = startMs; + meta.endMs = startMs; + meta.trackIndex = poolIdx; + meta.pixiType = groupToPixiType(group); + meta.pixiStatus = groupToPixiStatus(group); + + eventToGroup[slotIdx] = poolIdx + 1; + + if (meta.pixiType === 'GROUP_WORKFLOW_TASK') { + // WFT groups are tracked in the pool but not rendered; skip track assignment. + meta.trackIndex = -1; + } else if (isAscending) { + ascGroupHeads.push(slotIdx); + // Fill from bottom: first asc group (oldest) β†’ row (estimated - 1), etc. + const estTotal = + estimatedTotalGroups > 0 ? estimatedTotalGroups : poolTop + 64; + meta.trackIndex = Math.max( + descGroupHeads.length, + estTotal - ascGroupHeads.length, + ); + } else { + descGroupHeads.push(slotIdx); + // Fill from top: first desc group (newest) β†’ row 0. + meta.trackIndex = descGroupHeads.length - 1; + } + + // Flush any followers that arrived before this head. + // attachFollowerToPool nulls each follower slot after processing. + const pending = pendingFollowers.get(slotIdx); + if (pending) { + for (const followerSlotIdx of pending) { + attachFollowerToPool(poolIdx, followerSlotIdx); + } + pendingFollowers.delete(slotIdx); + } + + // Release the head slot β€” its data is now encoded in the EventGroup. + eventSlots[slotIdx] = null; + + // Resolve any pending getRows() promises for this group + const resolvers = pendingResolvers.get(poolIdx); + if (resolvers) { + for (const resolve of resolvers) resolve(group); + pendingResolvers.delete(poolIdx); + activePromises.delete(poolIdx); + } + + notifyLatestGroupListeners(group); + return group; +} + +/** + * Call after both cursors complete to merge the two head lists into the + * canonical ascending-order group list. Returns the merged array. + * The internal ascGroupHeads and descGroupHeads are cleared. + */ +export function mergeHeads(): number[] { + const merged = [...ascGroupHeads, ...descGroupHeads.reverse()]; + ascGroupHeads.length = 0; + descGroupHeads.length = 0; + return merged; +} + +/** Total number of groups registered so far. */ +export function getGroupCount(): number { + return poolTop; +} + +/** + * Request a slice of EventGroup promises by group index range [start, end). + * - Already-loaded groups β†’ Promise.resolve(group) + * - Not-yet-loaded groups β†’ pending Promise that resolves when the cursor + * writes the head event for that group index + * - opts.excludeWorkflowTasks: filter WorkflowTask groups from the slice + */ +export function getRows( + start: number, + end: number, + opts?: GetRowsOptions, +): Promise[] { + const result: Promise[] = []; + for (let i = start; i < end; i++) { + result.push(getGroupPromise(i, opts)); + } + return result; +} + +function getGroupPromise( + poolIdx: number, + opts?: GetRowsOptions, +): Promise { + if (poolIdx < poolTop) { + const meta = groupPool[poolIdx]; + if (meta.group) { + const group = meta.group; + if (opts?.excludeWorkflowTasks && isWorkflowTaskGroup(group)) { + return Promise.resolve(null as unknown as EventGroup); + } + return Promise.resolve(group); + } + } + + // Return existing pending promise for this index if one already exists + const existing = activePromises.get(poolIdx); + if (existing) return existing; + + const promise = new Promise((resolve) => { + const list = pendingResolvers.get(poolIdx); + if (list) { + list.push(resolve); + } else { + pendingResolvers.set(poolIdx, [resolve]); + } + }); + activePromises.set(poolIdx, promise); + return promise; +} + +/** The raw HistoryEvent with the highest eventId seen so far. O(1). */ +export function getLatestEvent(): HistoryEvent | null { + return latestEventRef; +} + +/** + * The EventGroup whose head has the highest eventId seen so far. + * Resolves immediately if the group is already registered, otherwise + * waits until the next group head is written. + */ +export function getLatestGroup(): Promise { + if (poolTop === 0) return Promise.resolve(null); + return getGroupPromise(poolTop - 1); +} + +/** + * Subscribe to new group registrations at the tail (highest eventId end). + * Fires once per new group head written by either cursor. + * Returns an unsubscribe function. + */ +export function onLatestGroup(cb: LatestGroupListener): () => void { + latestGroupListeners.push(cb); + return () => { + const idx = latestGroupListeners.indexOf(cb); + if (idx !== -1) latestGroupListeners.splice(idx, 1); + }; +} + +// --------------------------------------------------------------------------- +// Internal helpers (exported for testing) +// --------------------------------------------------------------------------- + +export function isWorkflowTaskGroup(group: EventGroup): boolean { + return group.initialEvent.eventType === 'WorkflowTaskScheduled'; +} + +/** + * Post-fetch: annotate activity and nexus groups with pending metadata from + * the workflow run. Call once after fetchBidirectional resolves. + */ +export function enrichGroups( + pendingActivities: PendingActivity[], + pendingNexusOperations: PendingNexusOperation[], +): void { + const byActivityId = new Map(pendingActivities.map((p) => [p.activityId, p])); + const byNexusScheduledId = new Map( + pendingNexusOperations.map((p) => [String(p.scheduledEventId), p]), + ); + + for (let i = 0; i < poolTop; i++) { + const { group } = groupPool[i]; + if (!group) continue; + + const initial = group.initialEvent; + + if (isActivityTaskScheduledEvent(initial)) { + const pa = byActivityId.get( + initial.activityTaskScheduledEventAttributes?.activityId, + ); + if (pa && group.eventList.length < 3) { + group.pendingActivity = pa; + } else { + delete group.pendingActivity; + } + continue; + } + + if (isNexusOperationScheduledEvent(initial)) { + const pn = byNexusScheduledId.get(group.id); + const isComplete = group.eventList.some( + (e) => + isNexusOperationCompletedEvent(e) || + isNexusOperationFailedEvent(e) || + isNexusOperationCanceledEvent(e) || + isNexusOperationTimedOutEvent(e), + ); + if (pn && !isComplete) { + group.pendingNexusOperation = pn; + } else { + delete group.pendingNexusOperation; + } + } + } +} + +/** + * Returns the WorkflowTaskFailed/TimedOut event that is currently active + * (i.e. has no subsequent WorkflowTaskCompleted), or undefined if none. + * Mirrors the logic of getWorkflowTaskFailedEvent() but operates on buffer + * groups instead of a flat event array. + */ +export function getWorkflowTaskFailedEvent(): WorkflowEvent | undefined { + let lastFailedEvent: WorkflowEvent | undefined; + let maxCompletedId = -1; + + for (let i = 0; i < poolTop; i++) { + const { group } = groupPool[i]; + if (!group || !isWorkflowTaskGroup(group)) continue; + + for (const event of group.eventList) { + if (event.eventType === 'WorkflowTaskCompleted') { + const id = Number(event.id); + if (id > maxCompletedId) maxCompletedId = id; + } + if ( + (event.eventType === 'WorkflowTaskFailed' || + event.eventType === 'WorkflowTaskTimedOut') && + !isWorkflowTaskFailedEventDueToReset(event) + ) { + if (!lastFailedEvent || Number(event.id) > Number(lastFailedEvent.id)) { + lastFailedEvent = event; + } + } + } + } + + if (!lastFailedEvent) return undefined; + if (Number(lastFailedEvent.id) < maxCompletedId) return undefined; + return lastFailedEvent; +} + +/** + * Synchronous sorted EventGroup[] after the fetch is complete. + * Groups are ordered by ascending eventId (headSlotIdx sort). + */ +export function getGroupArray(opts?: GetRowsOptions): EventGroup[] { + const metas = groupPool + .slice(0, poolTop) + .sort((a, b) => a.headSlotIdx - b.headSlotIdx); + const result: EventGroup[] = []; + for (const meta of metas) { + if (!meta.group) continue; + if (opts?.excludeWorkflowTasks && isWorkflowTaskGroup(meta.group)) continue; + result.push(meta.group); + } + return result; +} + +/** + * Direct read-only access to a pool entry's rendering metadata. + * Returns null if poolIdx is out of range or the group is not yet registered. + */ +export function getGroupMeta(poolIdx: number): GroupMeta | null { + if (poolIdx < 0 || poolIdx >= poolTop) return null; + return groupPool[poolIdx]; +} + +/** Number of groups loaded by the ascending cursor. */ +export function getAscGroupCount(): number { + return ascGroupHeads.length; +} + +/** Number of groups loaded by the descending cursor. */ +export function getDescGroupCount(): number { + return descGroupHeads.length; +} + +/** + * Finalize track indices after the full fetch completes. + * + * Layout (top β†’ bottom): + * rows 0 .. descCount-1 – descending cursor groups, newest first (row 0) + * rows descCount .. total-ascCount-1 – (would be loading gap during streaming) + * rows total-ascCount .. total-1 – ascending cursor groups, oldest last (bottom) + * + * Also updates pixiStatus now that final classification is known. + */ +export function assignTrackIndices(): void { + // Only non-WFT groups are in the head lists, so this total is the visible track count. + const total = descGroupHeads.length + ascGroupHeads.length; + + // Descending groups arrive newest-first, so descGroupHeads[0] = newest event. + for (let i = 0; i < descGroupHeads.length; i++) { + const poolIdx = eventToGroup[descGroupHeads[i]] - 1; + if (poolIdx < 0) continue; + const meta = groupPool[poolIdx]; + meta.trackIndex = i; + if (meta.group) meta.pixiStatus = groupToPixiStatus(meta.group); + } + + // Ascending groups arrive oldest-first (ascGroupHeads[0] = event 1). + // Place them with the oldest at the very bottom and the frontier (newest asc event) + // adjacent to the loading gap, so the gap visually shrinks from both sides as data loads. + for (let i = 0; i < ascGroupHeads.length; i++) { + const poolIdx = eventToGroup[ascGroupHeads[i]] - 1; + if (poolIdx < 0) continue; + const meta = groupPool[poolIdx]; + meta.trackIndex = total - 1 - i; + if (meta.group) meta.pixiStatus = groupToPixiStatus(meta.group); + } +} + +/** Number of visible (non-WFT) groups registered so far. */ +export function getVisibleGroupCount(): number { + return descGroupHeads.length + ascGroupHeads.length; +} + +/** Read-only view of internal state for assertions in tests. */ +export function _debugState() { + return { + poolTop, + ascGroupHeadsLength: ascGroupHeads.length, + descGroupHeadsLength: descGroupHeads.length, + pendingFollowersSize: pendingFollowers.size, + pendingResolversSize: pendingResolvers.size, + latestEventSlotIdx, + }; +} + +/** Test-only: exposes raw eventSlots so tests can assert Option-C nulling. */ +export function _debugEventSlots(): readonly (HistoryEvent | null)[] { + return eventSlots; +} diff --git a/src/lib/services/test-helpers/synthetic-events.ts b/src/lib/services/test-helpers/synthetic-events.ts new file mode 100644 index 0000000000..96cbad746c --- /dev/null +++ b/src/lib/services/test-helpers/synthetic-events.ts @@ -0,0 +1,276 @@ +import type { HistoryEvent } from '$lib/types/events'; + +const TIME = '2024-01-01T00:00:00.000000000Z'; + +function base(eventId: number, eventType: string): HistoryEvent { + return { + eventId: String(eventId), + eventTime: TIME, + eventType, + version: '0', + taskId: String(eventId * 10), + links: [], + } as unknown as HistoryEvent; +} + +// --------------------------------------------------------------------------- +// Individual event builders +// --------------------------------------------------------------------------- + +export function makeWorkflowStarted(eventId: number): HistoryEvent { + return { + ...base(eventId, 'WorkflowExecutionStarted'), + workflowExecutionStartedEventAttributes: { + workflowType: { name: 'TestWorkflow' }, + taskQueue: { name: 'default', kind: 'Normal' }, + input: null, + attempt: 1, + firstExecutionRunId: 'run-1', + originalExecutionRunId: 'run-1', + }, + } as unknown as HistoryEvent; +} + +export function makeWorkflowCompleted(eventId: number): HistoryEvent { + return { + ...base(eventId, 'WorkflowExecutionCompleted'), + workflowExecutionCompletedEventAttributes: { result: null }, + } as unknown as HistoryEvent; +} + +export function makeActivityScheduled( + eventId: number, + name = 'TestActivity', +): HistoryEvent { + return { + ...base(eventId, 'ActivityTaskScheduled'), + activityTaskScheduledEventAttributes: { + activityId: String(eventId), + activityType: { name }, + taskQueue: { name: 'default', kind: 'Normal' }, + namespace: '', + input: null, + }, + } as unknown as HistoryEvent; +} + +export function makeActivityStarted( + eventId: number, + scheduledEventId: number, +): HistoryEvent { + return { + ...base(eventId, 'ActivityTaskStarted'), + activityTaskStartedEventAttributes: { + scheduledEventId: String(scheduledEventId), + identity: 'worker@host', + requestId: `req-${eventId}`, + attempt: 1, + lastFailure: null, + }, + } as unknown as HistoryEvent; +} + +export function makeActivityCompleted( + eventId: number, + scheduledEventId: number, + startedEventId: number, +): HistoryEvent { + return { + ...base(eventId, 'ActivityTaskCompleted'), + activityTaskCompletedEventAttributes: { + result: null, + scheduledEventId: String(scheduledEventId), + startedEventId: String(startedEventId), + identity: 'worker@host', + }, + } as unknown as HistoryEvent; +} + +export function makeTimerStarted( + eventId: number, + timerId?: string, +): HistoryEvent { + return { + ...base(eventId, 'TimerStarted'), + timerStartedEventAttributes: { + timerId: timerId ?? String(eventId), + startToFireTimeout: '10s', + workflowTaskCompletedEventId: String(eventId - 1), + }, + } as unknown as HistoryEvent; +} + +export function makeTimerFired( + eventId: number, + startedEventId: number, +): HistoryEvent { + return { + ...base(eventId, 'TimerFired'), + timerFiredEventAttributes: { + timerId: String(startedEventId), + startedEventId: String(startedEventId), + }, + } as unknown as HistoryEvent; +} + +export function makeWorkflowTaskScheduled(eventId: number): HistoryEvent { + return { + ...base(eventId, 'WorkflowTaskScheduled'), + workflowTaskScheduledEventAttributes: { + taskQueue: { name: 'default', kind: 'Normal' }, + startToCloseTimeout: '10s', + }, + } as unknown as HistoryEvent; +} + +export function makeWorkflowTaskStarted( + eventId: number, + scheduledEventId: number, +): HistoryEvent { + return { + ...base(eventId, 'WorkflowTaskStarted'), + workflowTaskStartedEventAttributes: { + scheduledEventId: String(scheduledEventId), + identity: 'worker@host', + requestId: `req-${eventId}`, + }, + } as unknown as HistoryEvent; +} + +export function makeWorkflowTaskFailed( + eventId: number, + scheduledEventId: number, + cause = 'WorkflowWorkerUnhandledFailure', +): HistoryEvent { + return { + ...base(eventId, 'WorkflowTaskFailed'), + workflowTaskFailedEventAttributes: { + scheduledEventId: String(scheduledEventId), + startedEventId: String(scheduledEventId + 1), + cause, + failure: null, + identity: 'worker@host', + }, + } as unknown as HistoryEvent; +} + +export function makeWorkflowTaskCompleted( + eventId: number, + scheduledEventId: number, +): HistoryEvent { + return { + ...base(eventId, 'WorkflowTaskCompleted'), + workflowTaskCompletedEventAttributes: { + scheduledEventId: String(scheduledEventId), + startedEventId: String(scheduledEventId + 1), + identity: 'worker@host', + }, + } as unknown as HistoryEvent; +} + +// --------------------------------------------------------------------------- +// Composite group builders (return events in ascending ID order) +// --------------------------------------------------------------------------- + +/** Returns [Scheduled, Started, Completed] at IDs [startId, startId+1, startId+2] */ +export function makeActivityGroup( + startId: number, + name = 'TestActivity', +): [HistoryEvent, HistoryEvent, HistoryEvent] { + return [ + makeActivityScheduled(startId, name), + makeActivityStarted(startId + 1, startId), + makeActivityCompleted(startId + 2, startId, startId + 1), + ]; +} + +/** Returns [Started, Fired] at IDs [startId, startId+1] */ +export function makeTimerGroup(startId: number): [HistoryEvent, HistoryEvent] { + return [makeTimerStarted(startId), makeTimerFired(startId + 1, startId)]; +} + +/** Returns [Scheduled, Started, Completed] at IDs [startId, startId+1, startId+2] */ +export function makeWorkflowTaskGroup( + startId: number, +): [HistoryEvent, HistoryEvent, HistoryEvent] { + return [ + makeWorkflowTaskScheduled(startId), + makeWorkflowTaskStarted(startId + 1, startId), + makeWorkflowTaskCompleted(startId + 2, startId), + ]; +} + +// --------------------------------------------------------------------------- +// Bulk generators +// --------------------------------------------------------------------------- + +/** + * Generates N events in a deterministic repeating pattern: + * [WorkflowExecutionStarted] + * [ActivityScheduled, ActivityStarted, ActivityCompleted] Γ— repeat + * [TimerStarted, TimerFired] Γ— repeat + * (last events padded with solo WorkflowExecutionCompleted) + * + * All events have sequential ascending IDs from 1..N. + * The resulting array is sorted ascending by eventId. + */ +export function makeSyntheticEvents(n: number): HistoryEvent[] { + const events: HistoryEvent[] = []; + let id = 1; + + // Event 1: workflow started (solo) + if (n >= 1) { + events.push(makeWorkflowStarted(id++)); + } + + // Fill remaining with alternating activity groups (3 events) and timer groups (2 events) + // Pattern period = 5 events + let useActivity = true; + while (id <= n) { + if (useActivity && id + 2 <= n) { + events.push(...makeActivityGroup(id)); + id += 3; + } else if (!useActivity && id + 1 <= n) { + events.push(...makeTimerGroup(id)); + id += 2; + } else if (id <= n) { + // Pad with solo completed event + events.push(makeWorkflowCompleted(id++)); + } + useActivity = !useActivity; + } + + return events; +} + +/** + * Same as makeSyntheticEvents but interleaves WorkflowTask groups (WFT) + * so tests that filter WFT groups have something to filter. + * Pattern: [WFT group (3)] [Activity group (3)] repeating. + */ +export function makeSyntheticEventsWithWorkflowTasks( + n: number, +): HistoryEvent[] { + const events: HistoryEvent[] = []; + let id = 1; + + if (n >= 1) { + events.push(makeWorkflowStarted(id++)); + } + + let useWft = true; + while (id <= n) { + if (useWft && id + 2 <= n) { + events.push(...makeWorkflowTaskGroup(id)); + id += 3; + } else if (!useWft && id + 2 <= n) { + events.push(...makeActivityGroup(id)); + id += 3; + } else { + events.push(makeWorkflowCompleted(id++)); + } + useWft = !useWft; + } + + return events; +} diff --git a/src/lib/services/workflow-event-fetch.ts b/src/lib/services/workflow-event-fetch.ts new file mode 100644 index 0000000000..1f6595af33 --- /dev/null +++ b/src/lib/services/workflow-event-fetch.ts @@ -0,0 +1,88 @@ +import { get, writable } from 'svelte/store'; + +import type { + BidirectionalProgress, + BidirectionalStats, +} from '$lib/services/events-service'; +import { fetchAllEventsBidirectional } from '$lib/services/events-service'; +import { + currentEventHistory, + fullEventHistory, + loadedWorkflowKey, +} from '$lib/stores/events'; + +// Public reactive state β€” subscribe to these instead of managing local variables. +export const fetchingKey = writable(null); +export const fetchProgress = writable(null); +export const fetchStats = writable(null); +export const fetchError = writable(null); + +let controller: AbortController | null = null; + +/** + * Start a bidirectional fetch for the given workflow run. + * No-ops if a fetch for the same key is already in progress. + * Aborts any in-progress fetch for a *different* key first. + */ +export function startBidirectionalFetch( + namespace: string, + workflowId: string, + runId: string, +): void { + const key = `${namespace}:${workflowId}:${runId}`; + + if (get(fetchingKey) === key) return; + + controller?.abort(); + controller = new AbortController(); + + fetchingKey.set(key); + fetchProgress.set(null); + fetchStats.set(null); + fetchError.set(null); + fullEventHistory.set([]); + currentEventHistory.set([]); + loadedWorkflowKey.set(null); + + fetchAllEventsBidirectional({ + namespace, + workflowId, + runId, + signal: controller.signal, + maximumPageSize: 1000, + onProgress: (p) => fetchProgress.set(p), + onFirstPage: (firstEvents) => { + if (firstEvents.length) currentEventHistory.set(firstEvents); + }, + onFirstDescPage: (bookendEvents) => { + if (!bookendEvents.length) return; + // snapshotAccumulated() result: asc page 1 + desc page 1, already deduped. + fullEventHistory.set(bookendEvents); + currentEventHistory.set(bookendEvents); + }, + }) + .then(({ events, stats }) => { + fullEventHistory.set(events); + currentEventHistory.set(events); + loadedWorkflowKey.set(key); + fetchStats.set(stats); + fetchingKey.set(null); + }) + .catch((e: unknown) => { + if (e instanceof Error && e.name !== 'AbortError') { + fetchError.set(e.message); + } + fetchingKey.set(null); + }); +} + +/** + * Abort the active fetch and clear all state. Call when navigating away from + * the workflow entirely so stale data is not written into shared stores. + */ +export function abortBidirectionalFetch(): void { + controller?.abort(); + controller = null; + fetchingKey.set(null); + loadedWorkflowKey.set(null); +} diff --git a/src/lib/stores/workflow-actions-ready.ts b/src/lib/stores/workflow-actions-ready.ts new file mode 100644 index 0000000000..da4f6d6479 --- /dev/null +++ b/src/lib/stores/workflow-actions-ready.ts @@ -0,0 +1,10 @@ +import { writable } from 'svelte/store'; + +// Controls whether WorkflowActions (action buttons + confirmation modals) is +// mounted in the workflow header. Defaults to true so every page except +// fast-history renders actions immediately. +// +// fast-history sets this to false on mount (while the wave animation plays) +// and back to true inside the same setTimeout that sets showTimeline=true, +// so the action buttons appear at the same moment the timeline renders. +export const workflowActionsReady = writable(true); diff --git a/src/lib/utilities/format-time.ts b/src/lib/utilities/format-time.ts index 6ad3e5ba9e..d37769283c 100644 --- a/src/lib/utilities/format-time.ts +++ b/src/lib/utilities/format-time.ts @@ -13,6 +13,23 @@ import { fromSeconds } from './to-duration'; export type ValidTime = Parameters[0] | Timestamp; +// PERF SORT: timestamp strings are stable across a session; re-parsing them on +// every sort/y-change (N rows Γ— 2 parseJSON calls) was visible in CPUTraceSort. +// This module-level cache converts each unique string once and returns the same +// Date object on subsequent calls. +const parseJSONCache = new Map(); +function cachedParseJSON(value: ValidTime): Date { + if (typeof value === 'string') { + let d = parseJSONCache.get(value); + if (!d) { + d = parseJSON(value); + parseJSONCache.set(value, d); + } + return d; + } + return parseJSON(value as string | number | Date); +} + export function timestampToDate(ts: Timestamp): Date { if (!isTimestamp(ts)) { throw new TypeError('provided value is not a timestamp'); @@ -96,8 +113,8 @@ export function getDuration({ end = timestampToDate(end); } - const parsedStart = parseJSON(start); - const parsedEnd = parseJSON(end); + const parsedStart = cachedParseJSON(start); + const parsedEnd = cachedParseJSON(end); const duration = intervalToDuration({ start: parsedStart, end: parsedEnd }); return flexibleUnits @@ -135,8 +152,8 @@ export function getMillisecondDuration({ end = timestampToDate(end); } - const parsedStart = parseJSON(start); - const parsedEnd = parseJSON(end); + const parsedStart = cachedParseJSON(start); + const parsedEnd = cachedParseJSON(end); const ms = parsedEnd.getTime() - parsedStart.getTime(); return onlyUnderSecond ? Math.abs(ms % 1000) : Math.abs(ms); } catch { diff --git a/src/lib/utilities/get-failed-or-pending.test.ts b/src/lib/utilities/get-failed-or-pending.test.ts index a246822c67..16042737b3 100644 --- a/src/lib/utilities/get-failed-or-pending.test.ts +++ b/src/lib/utilities/get-failed-or-pending.test.ts @@ -37,17 +37,17 @@ const failedLocalEvent = { }; const completedEventGroup = { - events: [completedEvent], + eventList: [completedEvent], classification: 'Completed', isPending: false, }; const failedEventGroup = { - events: [failedEvent], + eventList: [failedEvent], classification: 'Failed', isPending: false, }; const pendingEventGroup = { - events: [failedEvent], + eventList: [failedEvent], classification: 'Failed', isPending: true, }; diff --git a/src/lib/utilities/pending-activities.ts b/src/lib/utilities/pending-activities.ts index 66fb9e6290..3003dae498 100644 --- a/src/lib/utilities/pending-activities.ts +++ b/src/lib/utilities/pending-activities.ts @@ -58,18 +58,21 @@ export const getPendingNexusOperation = ( }; export const getGroupForEventOrPendingEvent = ( - groups: EventGroup[], + groups: EventGroup[] | Map, event: WorkflowEventWithPending, ): EventGroup | undefined => { - return groups.find((g) => { - if (isEvent(event)) { - return g.eventIds.has(event.id); - } else if (isPendingActivity(event)) { - return g.pendingActivity?.id === event.id; - } else if (isPendingNexusOperation(event)) { - return ( - g?.pendingNexusOperation?.scheduledEventId === event?.scheduledEventId - ); - } - }); + if (isEvent(event)) { + if (groups instanceof Map) return groups.get(event.id); + return groups.find((g) => g.eventList.some((e) => e.id === event.id)); + } + if (groups instanceof Map) return undefined; + if (isPendingActivity(event)) { + return groups.find((g) => g.pendingActivity?.id === event.id); + } + if (isPendingNexusOperation(event)) { + return groups.find( + (g) => + g?.pendingNexusOperation?.scheduledEventId === event?.scheduledEventId, + ); + } }; diff --git a/src/lib/utilities/route-for-base-path.test.ts b/src/lib/utilities/route-for-base-path.test.ts index a0f837bd0b..dd607a6628 100644 --- a/src/lib/utilities/route-for-base-path.test.ts +++ b/src/lib/utilities/route-for-base-path.test.ts @@ -15,6 +15,8 @@ import { routeForEventHistory, routeForEventHistoryEvent, routeForEventHistoryImport, + routeForFasterer, + routeForFastHistory, routeForLoginPage, routeForNamespace, routeForNamespaces, @@ -103,6 +105,8 @@ describe('routeFor functions should resolve the base path exactly once', () => { ], ['routeForEventHistory', () => routeForEventHistory(workflowParams)], ['routeForTimeline', () => routeForTimeline(workflowParams)], + ['routeForFastHistory', () => routeForFastHistory(workflowParams)], + ['routeForFasterer', () => routeForFasterer(workflowParams)], ['routeForWorkers', () => routeForWorkers(namespaceParams)], [ 'routeForWorkerDeployments', @@ -312,6 +316,8 @@ describe('routeFor functions with prefix should resolve base + prefix correctly' ], ['routeForEventHistory', () => routeForEventHistory(workflowParams)], ['routeForTimeline', () => routeForTimeline(workflowParams)], + ['routeForFastHistory', () => routeForFastHistory(workflowParams)], + ['routeForFasterer', () => routeForFasterer(workflowParams)], ['routeForWorkers', () => routeForWorkers(workflowParams)], [ 'routeForWorkerDeployments', diff --git a/src/lib/utilities/route-for.ts b/src/lib/utilities/route-for.ts index c0f1175fe9..5e316572b5 100644 --- a/src/lib/utilities/route-for.ts +++ b/src/lib/utilities/route-for.ts @@ -312,6 +312,22 @@ export const routeForTimeline = ({ return toURL(path, queryParams); }; +export const routeForFastHistory = ({ + queryParams, + ...parameters +}: WorkflowParameters & { + queryParams?: Record; +}): ResolvedPathname => { + const path = `${baseRouteForWorkflow(parameters)}/fast-history`; + return toURL(path, queryParams); +}; + +export const routeForFasterer = ( + parameters: WorkflowParameters, +): ResolvedPathname => { + return `${baseRouteForWorkflow(parameters)}/fasterer`; +}; + export const routeForWorkflow = ({ queryParams, ...parameters diff --git a/src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/fast-history/+page.svelte b/src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/fast-history/+page.svelte new file mode 100644 index 0000000000..eb2563e47b --- /dev/null +++ b/src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/fast-history/+page.svelte @@ -0,0 +1,23 @@ + + + + diff --git a/src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/fasterer/+page.svelte b/src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/fasterer/+page.svelte new file mode 100644 index 0000000000..ac2aedd9f2 --- /dev/null +++ b/src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/fasterer/+page.svelte @@ -0,0 +1,685 @@ + + + + + +
+
+
+

⚑ Fasterer

+

+ Bidirectional fetch β†’ Pixi renderer +

+ + +
+ + {#if !statsCollapsed} +
+ {#if errorMsg} +

+ {errorMsg} +

+ {/if} + + {#if phase === 'fetching'} +
+ {#each { length: COLS } as _, col (col)} + {@const state = boxState(col)} + {@const isFrontierAsc = col === ascCols - 1 && ascCols > 0} + {@const isFrontierDesc = + col === COLS - descCols && + descCols > 0 && + COLS - descCols < COLS} +
+ {/each} +
+ {/if} + + {#if fetchStats !== null && fetchMs !== null} +
+
+

+ Network +

+
+
+ {fmtMs(fetchMs)} + total fetch +
+
+ {fetchStats.totalEvents.toLocaleString()} + events +
+
+ {fetchStats.eventsPerSecond.toLocaleString()} + ev/s +
+
+
+ ↑ {fetchStats.ascPages}p + ↓ {fetchStats.descPages}p + {#if fetchStats.overlap > 0} + {fetchStats.overlap} overlap + {/if} + {fetchStats.winner} won +
+
+ + {#if bufferTotalMs !== null} +
+

+ Buffer +

+
+
+ {fmtMs(bufferTotalMs)} + process +
+
+ + {bufferAvgUsPerEvent !== null + ? fmtUs(bufferAvgUsPerEvent) + : 'β€”'} + + avg/ev +
+
+ {bufferGroupCount?.toLocaleString()} + groups +
+
+
+ {/if} + +
+

+ Memory +

+ {#if heapDeltaMB !== null} +
+
+ 50} + > + {heapDeltaMB > 0 ? '+' : ''}{heapDeltaMB.toFixed(1)} MB + + heap Ξ” +
+ {#if eventCount && heapDeltaMB > 0} +
+ + {((heapDeltaMB * 1024 * 1024) / eventCount).toFixed(0)} B + + bytes/ev +
+ {/if} +
+ {:else} +

Chrome only

+ {/if} +
+
+ {/if} +
+ {/if} +
+ + + + + + +
+ {#if pixiArgs.poolCount > 0 || phase === 'fetching'} + + {:else if phase === 'idle'} +
+ Waiting for data… +
+ {/if} +
+ + +
+
+ + diff --git a/svelte.config.js b/svelte.config.js index e9de07a372..cf41cf500a 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -42,7 +42,7 @@ export default { }, csp: { mode: 'auto', - directives: { 'script-src': ['strict-dynamic'] }, + directives: { 'script-src': ['strict-dynamic', 'unsafe-eval'] }, }, }, }; diff --git a/temporal/encryption-codec.ts b/temporal/encryption-codec.ts index bf928175aa..5dd0f58677 100644 --- a/temporal/encryption-codec.ts +++ b/temporal/encryption-codec.ts @@ -68,7 +68,6 @@ export class EncryptionCodec implements PayloadCodec { this.keys.set(keyId, key); } const decryptedPayloadBytes = await decrypt(payload.data, key); - console.log('Decrypting payload.data:', payload.data); return temporal.temporal.api.common.v1.Payload.decode( decryptedPayloadBytes, ); diff --git a/temporal/workflows.ts b/temporal/workflows.ts index 1eeb3a74c3..18b6dc8323 100644 --- a/temporal/workflows.ts +++ b/temporal/workflows.ts @@ -288,3 +288,132 @@ export async function MultiInputWorkflow( return activityResult; } + +export interface HighVolumeSignalResult { + received: number; + target: number; + firstSignalAt: string | null; + lastSignalAt: string | null; + durationMs: number | null; +} + +const perfSignal = + workflow.defineSignal<[{ seq: number; data?: string }]>('perf-signal'); + +export async function HighVolumeSignalWorkflow( + target = 10_000, + totalReceived = 0, + firstSignalAt: string | null = null, +): Promise { + const SIGNALS_PER_RUN = 9_000; + let batchReceived = 0; + let lastSignalAt: string | null = null; + + workflow.setHandler(perfSignal, ({ seq: _seq }) => { + totalReceived++; + batchReceived++; + const now = new Date().toISOString(); + if (firstSignalAt === null) firstSignalAt = now; + lastSignalAt = now; + }); + + await workflow.condition( + () => batchReceived >= SIGNALS_PER_RUN || totalReceived >= target, + ); + + if (totalReceived < target) { + await workflow.continueAsNew( + target, + totalReceived, + firstSignalAt, + ); + } + + const durationMs = + firstSignalAt && lastSignalAt + ? new Date(lastSignalAt).getTime() - new Date(firstSignalAt).getTime() + : null; + + return { + received: totalReceived, + target, + firstSignalAt, + lastSignalAt, + durationMs, + }; +} + +export interface HighVolumeEventResult { + historyLength: number; + signals: number; + activities: number; + timers: number; + children: number; + durationMs: number; +} + +const highVolumeEventSignal = + workflow.defineSignal<[{ seq: number }]>('hv-event-signal'); + +const { echo: pingActivity } = workflow.proxyActivities({ + startToCloseTimeout: '10 seconds', +}); + +export async function HighVolumeEventChildWorkflow(n: number): Promise { + return n * 2; +} + +export async function HighVolumeEventWorkflow( + targetEvents = 40_000, +): Promise { + const t0 = new Date().getTime(); + let signals = 0; + let activitiesRun = 0; + let timersRun = 0; + let childrenRun = 0; + + workflow.setHandler(highVolumeEventSignal, () => { + signals++; + }); + + const historyLength = () => workflow.workflowInfo().historyLength; + let round = 0; + + while (historyLength() < targetEvents) { + round++; + + // 5 parallel activities β†’ ~18 events per round + await Promise.all([ + pingActivity('a'), + pingActivity('b'), + pingActivity('c'), + pingActivity('d'), + pingActivity('e'), + ]); + activitiesRun += 5; + + // Timer every 5 rounds β†’ ~3 events + if (round % 5 === 0) { + await workflow.sleep(1); + timersRun++; + } + + // Child workflow every 20 rounds β†’ ~5 events in parent + if (round % 20 === 0) { + await workflow.executeChild(HighVolumeEventChildWorkflow, { + args: [round], + workflowId: `${workflow.workflowInfo().workflowId}-child-${round}`, + }); + childrenRun++; + } + } + + return { + historyLength: historyLength(), + signals, + activities: activitiesRun, + timers: timersRun, + children: childrenRun, + durationMs: new Date().getTime() - t0, + }; +}