From 16d9ba375c3ce54ce6ffc1483541cefce8b7dd2d Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Tue, 16 Jun 2026 17:03:57 -0600 Subject: [PATCH 01/39] Add fast timeline so far just bidirectional fetching --- src/lib/layouts/workflow-header.svelte | 10 + src/lib/services/events-service.test.ts | 253 +++++++++++++++ src/lib/services/events-service.ts | 222 +++++++++++++ src/lib/utilities/route-for-base-path.test.ts | 3 + src/lib/utilities/route-for.ts | 10 + .../[run]/fast-history/+page.svelte | 304 ++++++++++++++++++ 6 files changed, 802 insertions(+) create mode 100644 src/lib/services/events-service.test.ts create mode 100644 src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/fast-history/+page.svelte diff --git a/src/lib/layouts/workflow-header.svelte b/src/lib/layouts/workflow-header.svelte index b6a78df272..49afd1e63c 100644 --- a/src/lib/layouts/workflow-header.svelte +++ b/src/lib/layouts/workflow-header.svelte @@ -37,6 +37,7 @@ import { routeForCallStack, routeForEventHistory, + routeForFastHistory, routeForNexusLinks, routeForPendingActivities, routeForRelationships, @@ -280,6 +281,15 @@ {workflow?.historyEvents} + ({ + 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 is filtered to zero duplicates', async () => { + // Both pages cover events 3-4. Since ascending resolves first (ascMaxId=4), + // descending's filter (id > 4) removes events 3 and 4 from its bucket. + 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(0); + }); + + test('descending resolves first: overlapping asc page is filtered to zero duplicates', async () => { + // Delay ascending so descending processes first (descMinId=3). + // Ascending's filter (id < 3) then removes events 3 and 4 from its bucket. + 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 update descMinId. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + // Now resolve asc p1. descMinId is already 3, so filter (id < 3) keeps only [1, 2]. + 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(0); + }); + + test('multi-page: in-flight page from aborted side is filtered, not discarded', async () => { + // 9 events, page size 3. After round 1, gap(3) == observedPageSize(3) so winner fires. + // Ascending wins, aborts descending. But desc p2 may already be in-flight + // and returns [6,5,4]; the filter (id > ascMaxId=6) trims it to nothing. + 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')); + // This page would be in-flight when ascending aborts descending. + 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(0); + }); + + 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).toBe(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..888360d162 100644 --- a/src/lib/services/events-service.ts +++ b/src/lib/services/events-service.ts @@ -22,6 +22,27 @@ import { paginated } from '$lib/utilities/paginated'; 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 FetchEventsParameters = NamespaceScopedRequest & PaginationCallbacks & { workflowId: string; @@ -228,3 +249,204 @@ export const fetchInitialEvent = async ( const start = await toEventHistory(startEventsRaw); return start[0]; }; + +export const fetchAllEventsBidirectional = async ({ + namespace, + workflowId, + runId, + signal, + onProgress, + maximumPageSize, +}: { + namespace: string; + workflowId: string; + runId: string; + signal?: AbortSignal; + onProgress?: (p: BidirectionalProgress) => 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; + + const ascBucket: CommonHistoryEvent[] = []; + const descBucket: CommonHistoryEvent[] = []; + + type Token = string | undefined; + + const gap = () => Math.max(0, descMinId - ascMaxId - 1); + + const reportProgress = () => { + onProgress?.({ + ascEvents: ascBucket.length, + descEvents: descBucket.length, + 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); + const ascNonOverlap = events.filter( + (e) => parseInt(e.eventId) < descMinId, + ); + ascBucket.push(...toEventHistory(ascNonOverlap)); + ascMaxId = Math.max(ascMaxId, ...events.map((e) => parseInt(e.eventId))); + 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 ?? []; + if (!events.length) break; + + descPages++; + observedPageSize = Math.max(observedPageSize, events.length); + const descNonOverlap = events.filter( + (e) => parseInt(e.eventId) > ascMaxId, + ); + descBucket.push(...toEventHistory(descNonOverlap)); + const descIds = events.map((e) => parseInt(e.eventId)); + descMinId = Math.min(descMinId, ...descIds); + descMaxId = Math.max(descMaxId, ...descIds); + reportProgress(); + + if (!response.nextPageToken || gap() <= 0) { + ascCtrl.abort(); + break; + } + token = response.nextPageToken as unknown as string; + } + }; + + await Promise.allSettled([runAscending(), runDescending()]); + + // Merge and sort. Dedup is a safety net for the rare case where a page was + // already in-flight when the winner aborted the other direction. + const seen = new Set(); + const merged: CommonHistoryEvent[] = []; + for (const e of [...ascBucket, ...descBucket].sort( + (a, b) => parseInt(a.id) - parseInt(b.id), + )) { + if (!seen.has(e.id)) { + seen.add(e.id); + merged.push(e); + } + } + + const durationMs = performance.now() - t0; + const overlap = ascBucket.length + descBucket.length - 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/utilities/route-for-base-path.test.ts b/src/lib/utilities/route-for-base-path.test.ts index a0f837bd0b..3162c7b855 100644 --- a/src/lib/utilities/route-for-base-path.test.ts +++ b/src/lib/utilities/route-for-base-path.test.ts @@ -15,6 +15,7 @@ import { routeForEventHistory, routeForEventHistoryEvent, routeForEventHistoryImport, + routeForFastHistory, routeForLoginPage, routeForNamespace, routeForNamespaces, @@ -103,6 +104,7 @@ describe('routeFor functions should resolve the base path exactly once', () => { ], ['routeForEventHistory', () => routeForEventHistory(workflowParams)], ['routeForTimeline', () => routeForTimeline(workflowParams)], + ['routeForFastHistory', () => routeForFastHistory(workflowParams)], ['routeForWorkers', () => routeForWorkers(namespaceParams)], [ 'routeForWorkerDeployments', @@ -312,6 +314,7 @@ describe('routeFor functions with prefix should resolve base + prefix correctly' ], ['routeForEventHistory', () => routeForEventHistory(workflowParams)], ['routeForTimeline', () => routeForTimeline(workflowParams)], + ['routeForFastHistory', () => routeForFastHistory(workflowParams)], ['routeForWorkers', () => routeForWorkers(workflowParams)], [ 'routeForWorkerDeployments', diff --git a/src/lib/utilities/route-for.ts b/src/lib/utilities/route-for.ts index c0f1175fe9..a8056ec195 100644 --- a/src/lib/utilities/route-for.ts +++ b/src/lib/utilities/route-for.ts @@ -312,6 +312,16 @@ 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 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..de89f91b12 --- /dev/null +++ b/src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/fast-history/+page.svelte @@ -0,0 +1,304 @@ + + + + +
+
+
+

Bidirectional Fetch

+

+ Ascending + descending pages run in parallel and stop when they meet in + the middle. +

+
+
+ + +
+
+ + {#if error} +
+ {error} +
+ {:else} +
+
+ + + Ascending + {#if progress || stats} + · {fmt(stats?.ascPages ?? progress?.ascPages ?? 0)}p + {/if} + + + {#if done} + {fmtMs(stats.durationMs)} · {fmt(stats.totalEvents)} events + {:else if progress} + {fmtMs(progress.elapsedMs)} + {:else} + Starting… + {/if} + + + {#if progress || stats} + {fmt(stats?.descPages ?? progress?.descPages ?? 0)}p · + {/if} + Descending + + +
+ +
+ {#if !progress && !stats} +
+ {:else if done} +
+
+
+
+
+
+ {fmt(stats.eventsPerSecond)}/s · winner: {stats.winner} +
+ {:else} +
+
+
+
+
+ {#if total && gapPct > 2} +
+ {fmt(Math.round((gapPct * total) / 100))} remaining +
+ {/if} + {/if} +
+ + {#if total} +
+ 1 + {#if !done && progress?.ascMaxId && progress?.descMinId} + + {fmt(progress.ascMaxId)} ↑ + + + ↓ {fmt(progress.descMinId)} + + {/if} + {fmt(total)} +
+ {/if} +
+ + {#if done} +
+
+

Total time

+

+ {fmtMs(stats.durationMs)} +

+
+
+

Events loaded

+

+ {fmt(stats.totalEvents)} +

+
+
+

Throughput

+

+ {fmt(stats.eventsPerSecond)}/s +

+
+
+

Ascending pages

+

{stats.ascPages}

+
+
+

Descending pages

+

{stats.descPages}

+
+
+

Winner

+

+ {stats.winner} +

+
+
+ +
+
+ total_events{fmt(stats.totalEvents)} + duration_ms{Math.round(stats.durationMs)} + events_per_sec{fmt(stats.eventsPerSecond)} + asc_pages{stats.ascPages} + desc_pages{stats.descPages} + total_pages{stats.ascPages + stats.descPages} + winner{stats.winner} + {#if stats.overlap > 0} + overlap_deduped{fmt(stats.overlap)} + {/if} +
+
+ {/if} + {/if} +
From 1bffd0a5c739acc4c4aef8448957914dd645617c Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Tue, 16 Jun 2026 17:08:13 -0600 Subject: [PATCH 02/39] Single start call --- .../[run]/fast-history/+page.svelte | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) 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 index de89f91b12..233c0d27b9 100644 --- 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 @@ -47,27 +47,7 @@ } onMount(() => { - controller = new AbortController(); - - fetchAllEventsBidirectional({ - namespace, - workflowId, - runId, - signal: controller.signal, - maximumPageSize: pageSize, - onProgress: (p) => { - progress = p; - }, - }) - .then(({ stats: s }) => { - stats = s; - }) - .catch((e: unknown) => { - if (e instanceof Error && e.name !== 'AbortError') { - error = e.message; - } - }); - + start(); return () => controller.abort(); }); From d7af4734febd84fd8553d83522db14178aace0ed Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Wed, 17 Jun 2026 09:52:35 -0600 Subject: [PATCH 03/39] Dang that's a lot of events --- src/lib/layouts/workflow-run-layout.svelte | 18 +- src/lib/services/events-service.ts | 32 +- .../[run]/fast-history/+page.svelte | 380 +++++++++--------- 3 files changed, 208 insertions(+), 222 deletions(-) diff --git a/src/lib/layouts/workflow-run-layout.svelte b/src/lib/layouts/workflow-run-layout.svelte index 19037e4bc3..d264ad3385 100644 --- a/src/lib/layouts/workflow-run-layout.svelte +++ b/src/lib/layouts/workflow-run-layout.svelte @@ -130,14 +130,16 @@ }); } - $fullEventHistory = await fetchAllEvents({ - namespace, - workflowId, - runId, - sort: 'ascending', - signal: workflowRunController.signal, - historySize: workflow.historyEvents, - }); + if (!page.url.pathname.endsWith('/fast-history')) { + $fullEventHistory = await fetchAllEvents({ + namespace, + workflowId, + runId, + sort: 'ascending', + signal: workflowRunController.signal, + historySize: workflow.historyEvents, + }); + } }; const getOnlyWorkflowWithPendingActivities = async ( diff --git a/src/lib/services/events-service.ts b/src/lib/services/events-service.ts index 888360d162..a2b1863c8d 100644 --- a/src/lib/services/events-service.ts +++ b/src/lib/services/events-service.ts @@ -282,8 +282,8 @@ export const fetchAllEventsBidirectional = async ({ let observedPageSize = 0; let winnerChosen = false; - const ascBucket: CommonHistoryEvent[] = []; - const descBucket: CommonHistoryEvent[] = []; + const ascBucket: HistoryEvent[] = []; + const descBucket: HistoryEvent[] = []; type Token = string | undefined; @@ -341,11 +341,10 @@ export const fetchAllEventsBidirectional = async ({ ascPages++; observedPageSize = Math.max(observedPageSize, events.length); - const ascNonOverlap = events.filter( - (e) => parseInt(e.eventId) < descMinId, - ); - ascBucket.push(...toEventHistory(ascNonOverlap)); ascMaxId = Math.max(ascMaxId, ...events.map((e) => parseInt(e.eventId))); + for (const e of events) { + if (parseInt(e.eventId) < descMinId) ascBucket.push(e); + } reportProgress(); if (!response.nextPageToken || gap() <= 0) { @@ -395,13 +394,12 @@ export const fetchAllEventsBidirectional = async ({ descPages++; observedPageSize = Math.max(observedPageSize, events.length); - const descNonOverlap = events.filter( - (e) => parseInt(e.eventId) > ascMaxId, - ); - descBucket.push(...toEventHistory(descNonOverlap)); const descIds = events.map((e) => parseInt(e.eventId)); descMinId = Math.min(descMinId, ...descIds); descMaxId = Math.max(descMaxId, ...descIds); + for (const e of events) { + if (parseInt(e.eventId) > ascMaxId) descBucket.push(e); + } reportProgress(); if (!response.nextPageToken || gap() <= 0) { @@ -414,18 +412,18 @@ export const fetchAllEventsBidirectional = async ({ await Promise.allSettled([runAscending(), runDescending()]); - // Merge and sort. Dedup is a safety net for the rare case where a page was - // already in-flight when the winner aborted the other direction. + // Merge, dedup, sort, then transform once. const seen = new Set(); - const merged: CommonHistoryEvent[] = []; + const rawMerged: HistoryEvent[] = []; for (const e of [...ascBucket, ...descBucket].sort( - (a, b) => parseInt(a.id) - parseInt(b.id), + (a, b) => parseInt(a.eventId) - parseInt(b.eventId), )) { - if (!seen.has(e.id)) { - seen.add(e.id); - merged.push(e); + if (!seen.has(e.eventId)) { + seen.add(e.eventId); + rawMerged.push(e); } } + const merged: CommonHistoryEvent[] = toEventHistory(rawMerged); const durationMs = performance.now() - t0; const overlap = ascBucket.length + descBucket.length - merged.length; 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 index 233c0d27b9..8559214ec5 100644 --- 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 @@ -16,9 +16,10 @@ let progress = $state(null); let stats = $state(null); let error = $state(null); - let pageSize = $state(1000); let controller: AbortController; + export { stats, progress }; + function start() { progress = null; stats = null; @@ -31,7 +32,7 @@ workflowId, runId, signal: controller.signal, - maximumPageSize: pageSize, + maximumPageSize: 1000, onProgress: (p) => { progress = p; }, @@ -56,6 +57,10 @@ ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(2)}s`; const total = $derived(stats?.totalEvents ?? progress?.totalEstimated ?? 0); + const done = $derived(stats !== null); + + const COLS = 40; + const ROWS = 10; const ascPct = $derived.by(() => { if (stats) @@ -71,214 +76,195 @@ return Math.min(100, ((total - progress.descMinId + 1) / total) * 100); }); - const gapPct = $derived(Math.max(0, 100 - ascPct - descPct)); - const done = $derived(stats !== null); + const ascCols = $derived(done ? COLS : Math.floor((ascPct / 100) * COLS)); + const descCols = $derived(done ? COLS : Math.floor((descPct / 100) * COLS)); + + function boxState(col: number) { + if (done) return 'done'; + if (col < ascCols) return 'asc'; + if (col >= COLS - descCols) return 'desc'; + return 'empty'; + } + + const meetCol = $derived(Math.floor((ascCols + (COLS - descCols)) / 2)); -
-
-
-

Bidirectional Fetch

-

- Ascending + descending pages run in parallel and stop when they meet in - the middle. -

-
-
- - +
+ {#if error} +

{error}

+ {:else} +
+ + + Ascending + {#if progress || stats}· {fmt( + stats?.ascPages ?? progress?.ascPages ?? 0, + )}p{/if} + + + {#if done} + {fmtMs(stats.durationMs)} · {fmt(stats.totalEvents)} events · {fmt( + stats.eventsPerSecond, + )}/s + {:else if progress} + {fmtMs(progress.elapsedMs)} · {fmt( + progress.ascEvents + progress.descEvents, + )} events + {:else} + Starting… + {/if} + + + {#if progress || stats}{fmt( + stats?.descPages ?? progress?.descPages ?? 0, + )}p ·{/if} + Descending + +
-
- {#if error}
- {error} -
- {:else} -
-
- - - Ascending - {#if progress || stats} - · {fmt(stats?.ascPages ?? progress?.ascPages ?? 0)}p - {/if} - - - {#if done} - {fmtMs(stats.durationMs)} · {fmt(stats.totalEvents)} events - {:else if progress} - {fmtMs(progress.elapsedMs)} - {:else} - Starting… - {/if} - - - {#if progress || stats} - {fmt(stats?.descPages ?? progress?.descPages ?? 0)}p · - {/if} - Descending - - -
- -
- {#if !progress && !stats} + {#each { length: ROWS } as _, row} + {#each { length: COLS } as _, col} + {@const state = boxState(col)} + {@const isFrontierAsc = !done && col === ascCols - 1} + {@const isFrontierDesc = !done && col === COLS - descCols} + {@const doneDelay = Math.abs(col - meetCol) * 18}
- {:else if done} -
-
-
-
-
-
- {fmt(stats.eventsPerSecond)}/s · winner: {stats.winner} -
- {:else} -
-
-
-
-
- {#if total && gapPct > 2} -
- {fmt(Math.round((gapPct * total) / 100))} remaining -
- {/if} - {/if} -
- - {#if total} -
- 1 - {#if !done && progress?.ascMaxId && progress?.descMinId} - - {fmt(progress.ascMaxId)} ↑ - - - ↓ {fmt(progress.descMinId)} - - {/if} - {fmt(total)} -
- {/if} + {/each} + {/each}
- {#if done} -
-
-

Total time

-

- {fmtMs(stats.durationMs)} -

-
-
-

Events loaded

-

- {fmt(stats.totalEvents)} -

-
-
-

Throughput

-

- {fmt(stats.eventsPerSecond)}/s -

-
-
-

Ascending pages

-

{stats.ascPages}

-
-
-

Descending pages

-

{stats.descPages}

-
-
-

Winner

-

- {stats.winner} -

-
-
- -
-
- total_events{fmt(stats.totalEvents)} - duration_ms{Math.round(stats.durationMs)} - events_per_sec{fmt(stats.eventsPerSecond)} - asc_pages{stats.ascPages} - desc_pages{stats.descPages} + 1 + {#if !done && progress?.ascMaxId && progress?.descMinId} + - total_pages{stats.ascPages + stats.descPages} + - winner{stats.winner} - {#if stats.overlap > 0} - overlap_deduped{fmt(stats.overlap)} - {/if} -
+ ↓ {fmt(progress.descMinId)} + + {/if} + {fmt(total)}
{/if} {/if}
+ + From 093d5eec9577c09b483def4b1fee178565c55918 Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Wed, 17 Jun 2026 10:50:55 -0600 Subject: [PATCH 04/39] First round of optimizations --- .../svg/timeline-graph-row.svelte | 56 ++++- .../models/event-groups/create-event-group.ts | 30 ++- src/lib/services/events-service.test.ts | 35 ++-- src/lib/services/events-service.ts | 69 +++++-- .../[run]/fast-history/+page.svelte | 191 ++++++++++-------- 5 files changed, 241 insertions(+), 140 deletions(-) diff --git a/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte b/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte index 6af68644ad..9c072b5552 100644 --- a/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte +++ b/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte @@ -56,14 +56,28 @@ const timelineWidth = $derived(canvasWidth - 2 * gutter); const pendingActivity = $derived(group?.pendingActivity); - const accessibleName = $derived( - translate('events.row-accessible-name', { - eventType: group.displayName, - classification: getEventClassificationLabel( - group.finalClassification || group.classification, - ), - }), - ); + // PERF MODERATE: i18next appeared in the CPU trace (resetRegExp, + // extractFromKey) because it re-processes its interpolation regex pattern on + // every translate() call. For a completed group, displayName and classification + // never change, so the result is stable. Cache manually because $derived + // re-evaluates when the group prop reference changes (e.g. after a store + // update), even if the string values are identical. + let _accessibleName = ''; + let _accessibleNameKey = ''; + const accessibleName = $derived.by(() => { + const classification = getEventClassificationLabel( + group.finalClassification || group.classification, + ); + const key = `${group.displayName}|${classification}`; + if (key !== _accessibleNameKey) { + _accessibleName = translate('events.row-accessible-name', { + eventType: group.displayName, + classification, + }); + _accessibleNameKey = key; + } + return _accessibleName; + }); const pauseTime = $derived( pendingActivity && pendingActivity.pauseInfo?.pauseTime, ); @@ -85,11 +99,33 @@ } }); + // PERF HIGH: getDistancePointsAndPositions calls getMillisecondDuration + // (date-fns parseJSON → Date allocation) for every event in the group, plus + // timelineTextPosition. On a canvas resize, Svelte's $derived re-runs this for + // every visible row simultaneously. With 4k+ rows that means 4k × N Date + // allocations in a single microtask flush. The closure-level cache below skips + // the recompute when the three inputs that actually affect pixel positions have + // not changed. events.size is included so the cache busts if new events arrive + // while the workflow is still running. + let _posCache: + | { + points: number[]; + textAnchor: string; + textIndex: number; + textPosition: [number, number]; + backdrop: boolean; + } + | undefined; + let _posCacheKey = ''; + const getDistancePointsAndPositions = ( endTime: string | Date | number, timelineWidth: number, y: number, ) => { + const cacheKey = `${endTime}|${timelineWidth}|${y}|${group.events.size}`; + if (_posCache && cacheKey === _posCacheKey) return _posCache; + const workflowDistance = getMillisecondDuration({ start: startTime, end: endTime, @@ -128,7 +164,9 @@ TimelineConfig, ); - return { points, textAnchor, textIndex, textPosition, backdrop }; + _posCache = { points, textAnchor, textIndex, textPosition, backdrop }; + _posCacheKey = cacheKey; + return _posCache; }; const { points, textAnchor, textIndex, textPosition, backdrop } = $derived( diff --git a/src/lib/models/event-groups/create-event-group.ts b/src/lib/models/event-groups/create-event-group.ts index 76bffab75e..f59acaebff 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, @@ -67,6 +67,22 @@ const createGroupFor = ( groupEvents.set(event.id, event); groupEventIds.add(event.id); + // PERF CRITICAL: eventList and links were the #1 source of GC pressure on the + // 40k-event timeline — a CPU trace showed 19s of GC time caused by these two + // getters. Each call previously did Array.from(this.events, ...) allocating a + // brand-new array. They are called from at least 6 places per render + // (getDistancePointsAndPositions, isPending, isFailureOrTimedOut, isCanceled, + // isTerminated, billableActions), multiplied across ~10k groups = ~60k short- + // lived arrays per frame continuously feeding the major GC. + // + // Fix: cache behind events.size. This is safe because events are append-only + // (Map.set is the only mutation, never Map.delete), so size increasing is a + // reliable signal that the cached array is stale. + let _eventList: EventGroup['eventList'] | undefined; + let _eventListSize = 0; + let _links: EventLink[] | undefined; + let _linksSize = 0; + return { id, name, @@ -89,10 +105,18 @@ const createGroupFor = ( return getLastEvent(this)?.attributes; }, get eventList() { - return Array.from(this.events, ([_key, value]) => value); + if (!_eventList || this.events.size !== _eventListSize) { + _eventList = Array.from(this.events, ([_key, value]) => value); + _eventListSize = this.events.size; + } + return _eventList; }, get links() { - return Array.from(this.events, ([_key, value]) => value.links).flat(); + if (!_links || this.events.size !== _linksSize) { + _links = Array.from(this.events, ([_key, value]) => value.links).flat(); + _linksSize = this.events.size; + } + return _links; }, get lastEvent() { return getLastEvent(this); diff --git a/src/lib/services/events-service.test.ts b/src/lib/services/events-service.test.ts index 01e2d52b66..4aafec9bbc 100644 --- a/src/lib/services/events-service.test.ts +++ b/src/lib/services/events-service.test.ts @@ -75,9 +75,10 @@ describe('fetchAllEventsBidirectional – overlap prevention', () => { expect(stats.descPages).toBe(1); }); - test('ascending resolves first: overlapping desc page is filtered to zero duplicates', async () => { - // Both pages cover events 3-4. Since ascending resolves first (ascMaxId=4), - // descending's filter (id > 4) removes events 3 and 4 from its bucket. + 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])); @@ -88,12 +89,13 @@ describe('fetchAllEventsBidirectional – overlap prevention', () => { expect(sortedIds(events)).toEqual([1, 2, 3, 4, 5, 6]); expect(stats.totalEvents).toBe(6); - expect(stats.overlap).toBe(0); + expect(stats.overlap).toBe(2); }); - test('descending resolves first: overlapping asc page is filtered to zero duplicates', async () => { - // Delay ascending so descending processes first (descMinId=3). - // Ascending's filter (id < 3) then removes events 3 and 4 from its bucket. + 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) => { @@ -103,25 +105,25 @@ describe('fetchAllEventsBidirectional – overlap prevention', () => { const fetchPromise = fetchAllEventsBidirectional(params); - // Flush enough microtasks for desc p1 to resolve and update descMinId. + // Flush enough microtasks for desc p1 to resolve and populate slots. await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); - // Now resolve asc p1. descMinId is already 3, so filter (id < 3) keeps only [1, 2]. 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(0); + expect(stats.overlap).toBe(2); }); - test('multi-page: in-flight page from aborted side is filtered, not discarded', async () => { - // 9 events, page size 3. After round 1, gap(3) == observedPageSize(3) so winner fires. - // Ascending wins, aborts descending. But desc p2 may already be in-flight - // and returns [6,5,4]; the filter (id > ascMaxId=6) trims it to nothing. + 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; @@ -135,7 +137,6 @@ describe('fetchAllEventsBidirectional – overlap prevention', () => { descCalls++; if (descCalls === 1) return Promise.resolve(makePage([9, 8, 7], 'desc-token')); - // This page would be in-flight when ascending aborts descending. return Promise.resolve(makePage([6, 5, 4])); } }); @@ -144,7 +145,7 @@ describe('fetchAllEventsBidirectional – overlap prevention', () => { expect(sortedIds(events)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); expect(stats.totalEvents).toBe(9); - expect(stats.overlap).toBe(0); + expect(stats.overlap).toBe(3); }); test('no events returned from either direction', async () => { @@ -183,7 +184,7 @@ describe('fetchAllEventsBidirectional – stats', () => { expect(stats.ascPages).toBeGreaterThan(0); expect(stats.winner).not.toBe('descending'); - expect(stats.overlap).toBe(0); + expect(stats.overlap).toBeGreaterThanOrEqual(0); }); test('reports eventsPerSecond as a positive integer', async () => { diff --git a/src/lib/services/events-service.ts b/src/lib/services/events-service.ts index a2b1863c8d..8a3fc5c143 100644 --- a/src/lib/services/events-service.ts +++ b/src/lib/services/events-service.ts @@ -282,8 +282,22 @@ export const fetchAllEventsBidirectional = async ({ let observedPageSize = 0; let winnerChosen = false; - const ascBucket: HistoryEvent[] = []; - const descBucket: HistoryEvent[] = []; + // 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; @@ -291,8 +305,8 @@ export const fetchAllEventsBidirectional = async ({ const reportProgress = () => { onProgress?.({ - ascEvents: ascBucket.length, - descEvents: descBucket.length, + ascEvents: ascEventCount, + descEvents: descEventCount, ascPages, descPages, elapsedMs: performance.now() - t0, @@ -341,9 +355,12 @@ export const fetchAllEventsBidirectional = async ({ ascPages++; observedPageSize = Math.max(observedPageSize, events.length); - ascMaxId = Math.max(ascMaxId, ...events.map((e) => parseInt(e.eventId))); + ascEventCount += events.length; for (const e of events) { - if (parseInt(e.eventId) < descMinId) ascBucket.push(e); + const id = parseInt(e.eventId); + if (id > ascMaxId) ascMaxId = id; + if (slots !== null) slots[id - 1] = e; + else tempBuf.push(e); } reportProgress(); @@ -394,11 +411,16 @@ export const fetchAllEventsBidirectional = async ({ descPages++; observedPageSize = Math.max(observedPageSize, events.length); - const descIds = events.map((e) => parseInt(e.eventId)); - descMinId = Math.min(descMinId, ...descIds); - descMaxId = Math.max(descMaxId, ...descIds); + 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) { - if (parseInt(e.eventId) > ascMaxId) descBucket.push(e); + slots![parseInt(e.eventId) - 1] = e; } reportProgress(); @@ -412,21 +434,24 @@ export const fetchAllEventsBidirectional = async ({ await Promise.allSettled([runAscending(), runDescending()]); - // Merge, dedup, sort, then transform once. - const seen = new Set(); - const rawMerged: HistoryEvent[] = []; - for (const e of [...ascBucket, ...descBucket].sort( - (a, b) => parseInt(a.eventId) - parseInt(b.eventId), - )) { - if (!seen.has(e.eventId)) { - seen.add(e.eventId); - rawMerged.push(e); - } + // 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; } - const merged: CommonHistoryEvent[] = toEventHistory(rawMerged); + rawFinal.length = write; + + const totalFetched = ascEventCount + descEventCount; + const merged: CommonHistoryEvent[] = toEventHistory( + rawFinal as HistoryEvent[], + ); + rawFinal.length = 0; const durationMs = performance.now() - t0; - const overlap = ascBucket.length + descBucket.length - merged.length; + const overlap = totalFetched - merged.length; const winner: BidirectionalStats['winner'] = ascPages === descPages 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 index 8559214ec5..9c3a9f70aa 100644 --- 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 @@ -5,17 +5,20 @@ import { page } from '$app/state'; + import WorkflowTimelineLayout from '$lib/layouts/workflow-timeline-layout.svelte'; import { type BidirectionalProgress, type BidirectionalStats, fetchAllEventsBidirectional, } from '$lib/services/events-service'; + import { fullEventHistory } from '$lib/stores/events'; const { namespace, workflow: workflowId, run: runId } = $derived(page.params); let progress = $state(null); let stats = $state(null); let error = $state(null); + let showTimeline = $state(false); let controller: AbortController; export { stats, progress }; @@ -24,6 +27,7 @@ progress = null; stats = null; error = null; + showTimeline = false; controller?.abort(); controller = new AbortController(); @@ -37,8 +41,13 @@ progress = p; }, }) - .then(({ stats: s }) => { + .then(({ events, stats: s }) => { stats = s; + fullEventHistory.set(events); + const waveMs = Math.floor((COLS / 2) * 18) + 400; + setTimeout(() => { + showTimeline = true; + }, waveMs); }) .catch((e: unknown) => { if (e instanceof Error && e.name !== 'AbortError') { @@ -89,97 +98,101 @@ const meetCol = $derived(Math.floor((ascCols + (COLS - descCols)) / 2)); -
- {#if error} -

{error}

- {:else} -
- - - Ascending - {#if progress || stats}· {fmt( - stats?.ascPages ?? progress?.ascPages ?? 0, - )}p{/if} - - - {#if done} - {fmtMs(stats.durationMs)} · {fmt(stats.totalEvents)} events · {fmt( - stats.eventsPerSecond, - )}/s - {:else if progress} - {fmtMs(progress.elapsedMs)} · {fmt( - progress.ascEvents + progress.descEvents, - )} events - {:else} - Starting… - {/if} - - - {#if progress || stats}{fmt( - stats?.descPages ?? progress?.descPages ?? 0, - )}p ·{/if} - Descending - - -
- -
- {#each { length: ROWS } as _, row} - {#each { length: COLS } as _, col} - {@const state = boxState(col)} - {@const isFrontierAsc = !done && col === ascCols - 1} - {@const isFrontierDesc = !done && col === COLS - descCols} - {@const doneDelay = Math.abs(col - meetCol) * 18} -
+{#if showTimeline} + +{:else} +
+ {#if error} +

{error}

+ {:else} +
+ + + Ascending + {#if progress || stats}· {fmt( + stats?.ascPages ?? progress?.ascPages ?? 0, + )}p{/if} + + + {#if done} + {fmtMs(stats.durationMs)} · {fmt(stats.totalEvents)} events · {fmt( + stats.eventsPerSecond, + )}/s + {:else if progress} + {fmtMs(progress.elapsedMs)} · {fmt( + progress.ascEvents + progress.descEvents, + )} events + {:else} + Starting… + {/if} + + + {#if progress || stats}{fmt( + stats?.descPages ?? progress?.descPages ?? 0, + )}p ·{/if} + Descending + + +
+ +
+ {#each { length: ROWS } as _, row} + {#each { length: COLS } as _, col} + {@const state = boxState(col)} + {@const isFrontierAsc = !done && col === ascCols - 1} + {@const isFrontierDesc = !done && col === COLS - descCols} + {@const doneDelay = Math.abs(col - meetCol) * 18} +
+ {/each} {/each} - {/each} -
- - {#if total} -
- 1 - {#if !done && progress?.ascMaxId && progress?.descMinId} - - {fmt(progress.ascMaxId)} ↑ - - - ↓ {fmt(progress.descMinId)} - - {/if} - {fmt(total)}
+ + {#if total} +
+ 1 + {#if !done && progress?.ascMaxId && progress?.descMinId} + + {fmt(progress.ascMaxId)} ↑ + + + ↓ {fmt(progress.descMinId)} + + {/if} + {fmt(total)} +
+ {/if} {/if} - {/if} -
+
+{/if} diff --git a/src/lib/components/lines-and-dots/svg/line.svelte b/src/lib/components/lines-and-dots/svg/line.svelte index 8d0c6e3e0a..c4c690ac57 100644 --- a/src/lib/components/lines-and-dots/svg/line.svelte +++ b/src/lib/components/lines-and-dots/svg/line.svelte @@ -59,6 +59,9 @@ const statusColor = getStatusStrokeColor(classification); if (statusColor !== DEFAULT_STROKE_COLOR) color = statusColor; } + // PERF: Use === comparisons instead of [a, b].includes(x). The inline array + // literal allocates a new array on every call; with thousands of Line instances + // rendered per frame this created measurable GC pressure (visible in CPUTrace2). if (delayed && (classification === 'Running' || status === 'Running')) { color = getStatusStrokeColor('Delayed'); } diff --git a/src/lib/components/lines-and-dots/svg/text.svelte b/src/lib/components/lines-and-dots/svg/text.svelte index 672be95979..34d8e27cc4 100644 --- a/src/lib/components/lines-and-dots/svg/text.svelte +++ b/src/lib/components/lines-and-dots/svg/text.svelte @@ -40,10 +40,49 @@ const [x, y] = $derived(point); + // PERF: getBBox() forces the browser to flush SVG layout and compute exact glyph + // metrics for every call — the SVG equivalent of element.clientWidth. With 10k + // rows each calling getBBox() after mount, this was 10k forced SVG reflows. + // + // Fix: OffscreenCanvas.measureText() queries the same underlying font metrics + // engine but without touching the DOM layout tree. One canvas context is shared + // across all Text instances (module-level singleton). Results are cached by + // (text, font) because the same event-type label strings repeat thousands of + // times in a 40k event timeline — each unique string is measured exactly once. + const _textWidthCache = new Map(); + let _ctx: OffscreenCanvasRenderingContext2D | null = null; + + function measureText(text: string, weight: string, size: string): number { + if (!text) return 0; + const font = `${weight} ${size} ui-sans-serif,system-ui,sans-serif`; + const cacheKey = `${font}|${text}`; + const cached = _textWidthCache.get(cacheKey); + if (cached !== undefined) return cached; + if (!_ctx) { + _ctx = new OffscreenCanvas(0, 0).getContext('2d'); + } + if (!_ctx) return text.length * 7.5; + _ctx.font = font; + const width = _ctx.measureText(text).width; + _textWidthCache.set(cacheKey, width); + return width; + } + let textElement: SVGTextElement = $state(); const showIcon = $derived(icon && config); - const textWidth = $derived(textElement?.getBBox()?.width || 0); + // Only measure when the width is actually used (backdrop rectangle or icon + // x-offset). Skipping the canvas call for plain text rows avoids any work + // for the majority of timeline rows that have neither. + const textWidth = $derived( + backdrop || showIcon + ? measureText( + textElement?.textContent ?? '', + String(fontWeight), + fontSize, + ) + : 0, + ); const backdropWidth = $derived(showIcon ? textWidth + 36 : textWidth + 12); const textX = $derived( showIcon && textAnchor === 'start' ? x + config.radius * 2 : x, diff --git a/src/lib/components/lines-and-dots/svg/timeline-graph.svelte b/src/lib/components/lines-and-dots/svg/timeline-graph.svelte index a2f5bb1fc8..ae647b5aca 100644 --- a/src/lib/components/lines-and-dots/svg/timeline-graph.svelte +++ b/src/lib/components/lines-and-dots/svg/timeline-graph.svelte @@ -15,6 +15,7 @@ import Line from './line.svelte'; import TimelineAxis from './timeline-axis.svelte'; import TimelineGraphRow from './timeline-graph-row.svelte'; + import TimelineIconDefs from './timeline-icon-defs.svelte'; import WorkflowRow from './workflow-row.svelte'; interface Props { @@ -42,6 +43,48 @@ let canvasWidth = $state(0); let scrollY = $state(0); + // PERF: bind:clientWidth={canvasWidth} compiled to bind_element_size which reads + // element.clientWidth inside a Svelte effect during every reactive flush (~150× + // in T4). Each read forces a full synchronous layout of the 40k-row SVG (~3ms + // each = 4.2% total CPU). Two fixes combined: + // 1. Use contentRect.width from the ResizeObserver callback — the browser + // already computed this, no additional reflow needed. + // 2. Debounce via requestAnimationFrame to break any oscillation where a width + // change causes re-renders that change the width again. + let containerEl = $state(null); + + $effect(() => { + if (!containerEl) return; + // PERF: Use contentRect.width from the ResizeObserver callback for ALL reads — + // including the initial one. contentRect is computed by the browser during layout + // and returned as part of the notification; reading it here does NOT force an + // additional synchronous reflow (unlike offsetWidth/clientWidth which do). + // + // The first callback fires in the same rendering cycle as observe(), so there is + // no one-frame flash. We apply it immediately (no RAF) so the SVG has a real + // width on the first paint. Subsequent callbacks are RAF-debounced to prevent + // oscillation where a width change causes re-renders that alter the width again. + let isFirst = true; + let rafId: ReturnType; + const observer = new ResizeObserver((entries) => { + const w = Math.round(entries[0].contentRect.width); + if (isFirst) { + isFirst = false; + canvasWidth = w; + } else { + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + canvasWidth = w; + }); + } + }); + observer.observe(containerEl); + return () => { + observer.disconnect(); + cancelAnimationFrame(rafId); + }; + }); + const expandedGroupHeight = $derived(readOnly ? 0 : $activeGroupHeight); const filteredGroups = $derived( getFailedOrPendingGroups(groups, $eventStatusFilter), @@ -86,7 +129,7 @@
@@ -119,6 +162,13 @@ class="-mt-4" class:error > + + scrollY - 2 * height && y < scrollY + viewportHeight * height)} + {#key group.events.size} // PERF: Drop-in replacement for inside SVG contexts (dot, text, - // workflow-row). The generic Icon chain is: - // icon.svelte {...rest} → individual-icon.svelte {...props} → svg.svelte {...rest} - // Every extra prop (x, y, strokeWidth …) triggers a Proxy ownKeys enumeration - // and an array.includes(SVG_NAMESPACE_ATTRS) call per attribute in Svelte's - // set_attributes(). At 4k+ rows this shows up in CPU traces at 0.5-1% of - // total samples just from the spread overhead. + // workflow-row). Renders a single element pointing at a + // defined once in TimelineIconDefs (inside the root of timeline-graph). // - // This component renders the directly with explicit bindings, covering - // the eleven icons used in the timeline. Add more here as needed. + // This eliminates three previous cost sources: + // 1. {…rest} spread → ownKeys Proxy + array.includes per attribute (fixed T2) + // 2. {#if name===…} chain → BranchManager per icon instance (0.26% in T3) + // 3. {@html paths[name]} → innerHTML parse per instance (not better than #2) + // + // is the native SVG instancing mechanism: one DOM node, no JS branching, + // no repeated path data, no string parsing. The symbol content is shared and + // the browser caches it. Only href, x, y, width, height are set per instance. + // + // fill="currentColor" in each symbol inherits CSS `color` from the + // element, so Tailwind text-* classes work as expected for coloring. export type TimelineIconName = | 'workflow' @@ -30,6 +35,7 @@ width?: number; height?: number; class?: string; + style?: string; } let { @@ -39,80 +45,8 @@ width = 16, height = 16, class: className = '', + style = '', }: Props = $props(); - + 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 index 9c3a9f70aa..74417daf92 100644 --- 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 @@ -5,13 +5,16 @@ import { page } from '$app/state'; - import WorkflowTimelineLayout from '$lib/layouts/workflow-timeline-layout.svelte'; + import TimelineGraph from '$lib/components/lines-and-dots/svg/timeline-graph.svelte'; + import { groupEvents } from '$lib/models/event-groups'; import { type BidirectionalProgress, type BidirectionalStats, fetchAllEventsBidirectional, } from '$lib/services/events-service'; import { fullEventHistory } from '$lib/stores/events'; + import { workflowRun } from '$lib/stores/workflow-run'; + import { orderGroupsByPending } from '$lib/utilities/order-groups-by-pending'; const { namespace, workflow: workflowId, run: runId } = $derived(page.params); @@ -96,10 +99,36 @@ } const meetCol = $derived(Math.floor((ascCols + (COLS - descCols)) / 2)); + + const workflow = $derived($workflowRun.workflow); + + // PERF: Render TimelineGraph directly instead of WorkflowTimelineLayout. + // WorkflowTimelineLayout includes EventTypeFilter → Menu (bind:clientHeight on + // the dropdown
    ), InputAndResults, EventHistoryLegend, and ToggleButtons. + // During the initial mount of 10k SVG rows the DOM shifts constantly, firing + // the Menu's ResizeObserver → Svelte effect → clientHeight read on every flush. + // In CPUTrace3 this accumulated to 3557 samples (3.5% of total CPU). + // + // Compute groups directly from $fullEventHistory so filteredEventHistory and + // EventTypeFilter are never instantiated for this route. + const groups = $derived.by(() => { + if (!showTimeline || !workflow) return []; + return orderGroupsByPending( + groupEvents( + $fullEventHistory, + 'ascending', + workflow.pendingActivities ?? [], + workflow.pendingNexusOperations ?? [], + ), + false, + ); + }); {#if showTimeline} - + {#if workflow} + + {/if} {:else}
    {#if error} From f54d1277f852372bba18c6aefbff81897cda0884 Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Wed, 17 Jun 2026 13:33:00 -0600 Subject: [PATCH 07/39] Fix click taking so long (removes push down effect) forgot to add icon defs for perf --- .../svg/group-details-row.svelte | 13 ++- .../lines-and-dots/svg/timeline-graph.svelte | 59 +++++++----- .../svg/timeline-icon-defs.svelte | 90 +++++++++++++++++++ 3 files changed, 135 insertions(+), 27 deletions(-) create mode 100644 src/lib/components/lines-and-dots/svg/timeline-icon-defs.svelte 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..f0f9b01385 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 @@ -9,7 +9,7 @@ import Icon from '$lib/holocene/icon/icon.svelte'; import { translate } from '$lib/i18n/translate'; import type { EventGroup } from '$lib/models/event-groups/event-groups'; - import { activeGroupHeight, setActiveGroup } from '$lib/stores/active-events'; + import { setActiveGroup } from '$lib/stores/active-events'; import { formatEventGroupDuration } from '$lib/utilities/event-group-duration'; import { isChildWorkflowExecutionStartedEvent } from '$lib/utilities/is-event-type'; @@ -21,15 +21,14 @@ export let x = 0; export let y: number; + // PERF: bind:offsetHeight is kept only to size the foreignObject correctly. + // Previously, contentHeight was also written to $activeGroupHeight, which + // caused expandedGroupHeight in timeline-graph.svelte to change, dirtying + // all 10k rows and producing 2× UpdateLayoutTree over 63k nodes (~504ms). + // That cascade is now broken: rows no longer use $activeGroupHeight at all. let offsetHeight; $: contentHeight = offsetHeight || 0; - const setActiveGroupHeight = (height) => { - $activeGroupHeight = height; - }; - - $: setActiveGroupHeight(contentHeight || 0); - $: status = group?.finalClassification || group?.classification; $: ({ namespace } = $page.params); $: width = canvasWidth; diff --git a/src/lib/components/lines-and-dots/svg/timeline-graph.svelte b/src/lib/components/lines-and-dots/svg/timeline-graph.svelte index ae647b5aca..4e3d2c9211 100644 --- a/src/lib/components/lines-and-dots/svg/timeline-graph.svelte +++ b/src/lib/components/lines-and-dots/svg/timeline-graph.svelte @@ -1,7 +1,7 @@
    + {#each filteredGroups as group, index (group.id)} - {@const y = (index + 2) * height + activeGroupsHeightAboveGroup(index)} + {@const y = (index + 2) * height} {#if !viewportHeight || (y > scrollY - 2 * height && y < scrollY + viewportHeight * height)} + {#if !readOnly} + {#each $activeGroups as id (id)} + {@const idx = groupIndexMap.get(id)} + {#if idx !== undefined} + {@const grp = filteredGroups[idx]} + {#if grp} + + {/if} + {/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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 5b49aed0e12eeca44b7ecb12a45b62fff00f8f17 Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Wed, 17 Jun 2026 13:55:54 -0600 Subject: [PATCH 08/39] Inline the details pane again --- .../svg/group-details-row.svelte | 6 + .../lines-and-dots/svg/timeline-graph.svelte | 168 ++++++++++++------ 2 files changed, 120 insertions(+), 54 deletions(-) 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 f0f9b01385..9da30f0c21 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 @@ -20,6 +20,11 @@ export let endTime: string | Date | number = Date.now(); export let x = 0; export let y: number; + // PERF: Callback so timeline-graph can update the on the row + // section below this panel. This is the only way panel height flows back up — + // it goes into a single SVG transform attribute (O(1)), NOT into per-row Y + // positions (which was the O(N) cascade we eliminated). + export let onHeight: ((h: number) => void) | undefined = undefined; // PERF: bind:offsetHeight is kept only to size the foreignObject correctly. // Previously, contentHeight was also written to $activeGroupHeight, which @@ -28,6 +33,7 @@ // That cascade is now broken: rows no longer use $activeGroupHeight at all. let offsetHeight; $: contentHeight = offsetHeight || 0; + $: onHeight?.(contentHeight); $: status = group?.finalClassification || group?.classification; $: ({ namespace } = $page.params); diff --git a/src/lib/components/lines-and-dots/svg/timeline-graph.svelte b/src/lib/components/lines-and-dots/svg/timeline-graph.svelte index 4e3d2c9211..b1aa4512e3 100644 --- a/src/lib/components/lines-and-dots/svg/timeline-graph.svelte +++ b/src/lib/components/lines-and-dots/svg/timeline-graph.svelte @@ -103,16 +103,6 @@ const startTime = $derived( (!isWorkflowDelayed(workflow) && firstStartTime) || workflow.startTime, ); - // PERF: Use a fixed 800px buffer instead of measuring the expanded panel height. - // Previously, GroupDetailsRow measured its height via bind:offsetHeight and wrote - // it to $activeGroupHeight, which then updated expandedGroupHeight, which triggered - // a full reactive flush updating Y on all 10k rows. The buffer covers the max - // panel height (header ~50px + event details ~400px + child workflow 320px). - const timelineHeight = $derived( - Math.max(height * (filteredGroups.length + 2), 120) + - ($activeGroups.length > 0 ? 800 : 0), - ); - const canvasHeight = $derived(timelineHeight + 120); const handleScroll = (e: Event) => { scrollY = (e?.target as HTMLElement)?.scrollTop; @@ -121,6 +111,73 @@ const groupIndexMap = $derived( new Map(filteredGroups.map((g, i) => [g.id, i])), ); + + // PERF: Index of the currently active group in filteredGroups (-1 = none). + // Derived here so only the two rendering sections below subscribe to + // $activeGroups, not the main row {#each}. + const activeIdx = $derived( + $activeGroups.length > 0 ? (groupIndexMap.get($activeGroups[0]) ?? -1) : -1, + ); + + // PERF: Actual panel height from GroupDetailsRow's bind:offsetHeight reactive + // chain. Only used in the $effect below to stamp the transform value. + let panelHeight = $state(0); + + $effect(() => { + if ($activeGroups.length === 0) panelHeight = 0; + }); + + // PERF IMPERATIVE TRANSFORM APPROACH: + // A plain Map of group-id → SVG wrapper element, populated by the + // use:registerRow action on each row. Not reactive — Svelte never observes + // this Map, so registering/deregistering rows causes no reactive cascade. + const rowWrappers = new Map(); + + function registerRow(el: SVGGElement, id: string) { + rowWrappers.set(id, el); + return { + destroy() { + rowWrappers.delete(id); + }, + }; + } + + // PERF: This $effect subscribes only to activeIdx, panelHeight, and + // groupIndexMap (which changes when filteredGroups changes). It never makes + // individual rows reactive to $activeGroups. + // + // On click: one JS loop over all registered row wrappers + one + // setAttribute/removeAttribute per row that actually changes its transform. + // No component destroy/recreate, no cache clearing, no onMount re-runs. + // + // Cost: O(N) JS iterations (~50ms for 10k rows) regardless of which row + // is clicked — uniformly better than the two-loop approach for top clicks + // and comparable for bottom clicks. + $effect(() => { + const idx = activeIdx; + const shift = panelHeight; + const idxMap = groupIndexMap; // reactive dep so effect re-runs on filter changes + + for (const [id, el] of rowWrappers) { + const i = idxMap.get(id); + if (i === undefined) continue; + if (idx >= 0 && i > idx && shift > 0) { + el.setAttribute('transform', `translate(0, ${shift})`); + } else { + el.removeAttribute('transform'); + } + } + }); + + // PERF: timelineHeight uses panelHeight (actual measured panel size) so the + // SVG is tall enough to contain the shifted lower rows. Clamped to at least + // 800px when a panel is open so the SVG doesn't clip on the first frame + // before ResizeObserver delivers its first measurement. + const timelineHeight = $derived( + Math.max(height * (filteredGroups.length + 2), 120) + + (activeIdx >= 0 ? Math.max(panelHeight, 800) : 0), + ); + const canvasHeight = $derived(timelineHeight + 120);
    - {#each filteredGroups as group, index (group.id)} - {@const y = (index + 2) * height} - {#if !viewportHeight || (y > scrollY - 2 * height && y < scrollY + viewportHeight * height)} - - {#key group.events.size} - - {/key} - {/if} - {/each} - - {#if !readOnly} - {#each $activeGroups as id (id)} - {@const idx = groupIndexMap.get(id)} - {#if idx !== undefined} - {@const grp = filteredGroups[idx]} - {#if grp} - + {#if !viewportHeight || (y > scrollY - 2 * height && y < scrollY + viewportHeight * height)} + + {#key group.events.size} + - {/if} + {/key} {/if} - {/each} + + {/each} + + + {#if !readOnly && activeIdx >= 0} + {@const grp = filteredGroups[activeIdx]} + {#if grp} + { + panelHeight = h; + }} + /> + {/if} {/if} From 4805e8afef9875843b9f98d41fd7fe19855ae030 Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Wed, 17 Jun 2026 14:17:44 -0600 Subject: [PATCH 09/39] Fix rendering details by making codeblocks render async and using pre as a start --- src/lib/components/event/event-card.svelte | 6 +- .../event/event-details-full.svelte | 7 +- .../svg/group-details-row.svelte | 46 +++++--- .../lines-and-dots/svg/timeline-graph.svelte | 30 ++++-- .../payload/payload-code-block.svelte | 14 ++- src/lib/holocene/code-block.svelte | 101 +++++++++++++----- temporal/encryption-codec.ts | 1 - 7 files changed, 146 insertions(+), 59 deletions(-) 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/lines-and-dots/svg/group-details-row.svelte b/src/lib/components/lines-and-dots/svg/group-details-row.svelte index 9da30f0c21..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 @@ -
+
@@ -95,7 +104,11 @@
- + +
{#if childWorkflowStartedEvent}
@@ -109,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/timeline-graph.svelte b/src/lib/components/lines-and-dots/svg/timeline-graph.svelte index b1aa4512e3..ad28dc8474 100644 --- a/src/lib/components/lines-and-dots/svg/timeline-graph.svelte +++ b/src/lib/components/lines-and-dots/svg/timeline-graph.svelte @@ -158,10 +158,24 @@ const shift = panelHeight; const idxMap = groupIndexMap; // reactive dep so effect re-runs on filter changes + if (idx < 0) { + // Panel closed — clear all transforms. Iterating only values is cheaper + // than the full [id, el] destructure when we don't need the key. + for (const el of rowWrappers.values()) { + el.removeAttribute('transform'); + } + return; + } + + // PERF: Panel just opened but ResizeObserver hasn't fired yet (shift=0). + // Skip the loop entirely — rows have no transforms yet so removing them + // is a no-op, and we don't know the correct shift to apply. + if (shift === 0) return; + for (const [id, el] of rowWrappers) { const i = idxMap.get(id); if (i === undefined) continue; - if (idx >= 0 && i > idx && shift > 0) { + if (i > idx) { el.setAttribute('transform', `translate(0, ${shift})`); } else { el.removeAttribute('transform'); @@ -169,13 +183,15 @@ } }); - // PERF: timelineHeight uses panelHeight (actual measured panel size) so the - // SVG is tall enough to contain the shifted lower rows. Clamped to at least - // 800px when a panel is open so the SVG doesn't clip on the first frame - // before ResizeObserver delivers its first measurement. + // PERF: timelineHeight is driven purely by panelHeight (delivered async by + // the ResizeObserver in group-details-row). On click, panelHeight starts at + // 0, so timelineHeight does NOT change — Line and TimelineAxis are not + // dirtied. They only update once the panel is actually measured, which is + // the correct moment. The old Math.max(panelHeight, 800) caused a spurious + // +800px jump every click, triggering unnecessary setAttribute calls on + // both Line components and all Axis tick marks. const timelineHeight = $derived( - Math.max(height * (filteredGroups.length + 2), 120) + - (activeIdx >= 0 ? Math.max(panelHeight, 800) : 0), + Math.max(height * (filteredGroups.length + 2), 120) + panelHeight, ); const canvasHeight = $derived(timelineHeight + 120); 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()} ; + 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/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, ); From 65eeca3d5907cae9070b2c77b5486ed3ecf1e28c Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Wed, 17 Jun 2026 15:06:34 -0600 Subject: [PATCH 10/39] Do a swap on Y position for rendering --- .../lines-and-dots/svg/timeline-graph.svelte | 30 +++- .../layouts/workflow-timeline-layout.svelte | 11 +- src/lib/utilities/format-time.ts | 25 +++- .../[run]/fast-history/+page.svelte | 138 +++++++++++++++--- 4 files changed, 166 insertions(+), 38 deletions(-) diff --git a/src/lib/components/lines-and-dots/svg/timeline-graph.svelte b/src/lib/components/lines-and-dots/svg/timeline-graph.svelte index ad28dc8474..70d5b9fb1e 100644 --- a/src/lib/components/lines-and-dots/svg/timeline-graph.svelte +++ b/src/lib/components/lines-and-dots/svg/timeline-graph.svelte @@ -26,6 +26,7 @@ viewportHeight: number | undefined; readOnly?: boolean; error?: boolean; + reverseSort?: boolean; } let { @@ -36,6 +37,7 @@ viewportHeight, readOnly = false, error = false, + reverseSort = false, }: Props = $props(); const { height, gutter, radius } = TimelineConfig; @@ -157,25 +159,24 @@ const idx = activeIdx; const shift = panelHeight; const idxMap = groupIndexMap; // reactive dep so effect re-runs on filter changes + // PERF SORT: reverseSort flips which side of activeIdx receives the shift. + // In ascending mode rows AFTER (i > idx) move down. In descending mode + // rows BEFORE (i < idx) are visually below the panel and move down instead. + const isDesc = reverseSort; if (idx < 0) { - // Panel closed — clear all transforms. Iterating only values is cheaper - // than the full [id, el] destructure when we don't need the key. for (const el of rowWrappers.values()) { el.removeAttribute('transform'); } return; } - // PERF: Panel just opened but ResizeObserver hasn't fired yet (shift=0). - // Skip the loop entirely — rows have no transforms yet so removing them - // is a no-op, and we don't know the correct shift to apply. if (shift === 0) return; for (const [id, el] of rowWrappers) { const i = idxMap.get(id); if (i === undefined) continue; - if (i > idx) { + if (isDesc ? i < idx : i > idx) { el.setAttribute('transform', `translate(0, ${shift})`); } else { el.removeAttribute('transform'); @@ -268,8 +269,18 @@ path, no layout pass). No component lifecycle operations at all. Uniformly fast for both top and bottom clicks. --> + {#each filteredGroups as group, i (group.id)} - {@const y = (i + 2) * height} + {@const y = reverseSort + ? (filteredGroups.length + 1 - i) * height + : (i + 2) * height} {#if !viewportHeight || (y > scrollY - 2 * height && y < scrollY + viewportHeight * height)} - {#each filteredGroups as group, i (group.id)} + {#each visibleGroups as group, localI (group.id)} + {@const i = reverseSort + ? filteredGroups.length - visibleGroups.length + localI + : localI} {@const y = reverseSort ? (filteredGroups.length + 1 - i) * height : (i + 2) * height} 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 index 1805b73d1d..1dd8d0afa4 100644 --- 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 @@ -49,12 +49,21 @@ let isRendering = $state(false); let controller: AbortController; + let t0 = 0; + let loadMs = $state(null); + let firstRenderMs = $state(null); + let allRenderMs = $state(null); + export { stats, progress }; function start() { + t0 = performance.now(); progress = null; stats = null; error = null; + loadMs = null; + firstRenderMs = null; + allRenderMs = null; showTimeline = false; controller?.abort(); controller = new AbortController(); @@ -71,6 +80,7 @@ }) .then(({ events, stats: s }) => { stats = s; + loadMs = performance.now() - t0; fullEventHistory.set(events); // Also populate currentEventHistory so filteredEventHistory (used by // EventTypeFilter and WorkflowError) derives correctly from these events. @@ -235,6 +245,13 @@
+ {#if loadMs !== null} +

+ fetch {fmtMs(loadMs)}{#if firstRenderMs !== null} + · first row {fmtMs(firstRenderMs)}{/if}{#if allRenderMs !== null} + · all rows {fmtMs(allRenderMs)}{/if} +

+ {/if} {#if workflow}
@@ -242,6 +259,13 @@ {workflow} {groups} {reverseSort} + startedAt={t0} + onFirstRender={(ms) => { + firstRenderMs = ms; + }} + onAllRendered={(ms) => { + allRenderMs = ms; + }} viewportHeight={undefined} error={Boolean(workflowTaskFailedError)} /> From 444f9071789254f54896238a971cd595939692b3 Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Wed, 17 Jun 2026 16:37:01 -0600 Subject: [PATCH 15/39] Fix this so it packs properly --- .../workflow-fast-history-layout.svelte | 515 ++++++++++++++++++ .../[run]/fast-history/+page.svelte | 510 +---------------- 2 files changed, 527 insertions(+), 498 deletions(-) create mode 100644 src/lib/layouts/workflow-fast-history-layout.svelte 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..42bc6b6ad7 --- /dev/null +++ b/src/lib/layouts/workflow-fast-history-layout.svelte @@ -0,0 +1,515 @@ + + + + +{#if showTimeline} + +
+ {#if workflowTaskFailedError} + + {/if} + {#if workflow?.callbacks?.length} + + {/if} +
+
+
+
+

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

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

+ fetch {fmtMs(loadMs)}{#if firstRenderMs !== null} + · first row {fmtMs(firstRenderMs)}{/if}{#if allRenderMs !== null} + · all rows {fmtMs(allRenderMs)}{/if} +

+ {/if} +
+ {#if workflow} +
+ { + firstRenderMs = ms; + }} + onAllRendered={(ms) => { + allRenderMs = ms; + }} + viewportHeight={undefined} + error={Boolean(workflowTaskFailedError)} + /> +
+ {/if} +
+ {#if workflow} + + {/if} +{:else} +
+ {#if error} +

{error}

+ {:else} +
+ + + Ascending + {#if progress || stats}· {fmt( + stats?.ascPages ?? progress?.ascPages ?? 0, + )}p{/if} + + + {#if done} + {fmtMs(stats.durationMs)} · {fmt(stats.totalEvents)} events · {fmt( + stats.eventsPerSecond, + )}/s + {:else if progress} + {fmtMs(progress.elapsedMs)} · {fmt( + progress.ascEvents + progress.descEvents, + )} events + {:else} + Starting… + {/if} + + + {#if progress || stats}{fmt( + stats?.descPages ?? progress?.descPages ?? 0, + )}p ·{/if} + Descending + + +
+ +
+ {#if isRendering} +
+ {/if} + {#each { length: ROWS } as _, row} + {#each { length: COLS } as _, col} + {@const state = boxState(col)} + {@const isFrontierAsc = !done && col === ascCols - 1} + {@const isFrontierDesc = !done && col === COLS - descCols} + {@const doneDelay = Math.abs(col - meetCol) * 18} +
+ {/each} + {/each} +
+ + {#if total} +
+ 1 + {#if !done && progress?.ascMaxId && progress?.descMinId} + + {fmt(progress.ascMaxId)} ↑ + + + ↓ {fmt(progress.descMinId)} + + {/if} + {fmt(total)} +
+ {/if} + {/if} +
+{/if} + + 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 index 1dd8d0afa4..eb2563e47b 100644 --- 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 @@ -1,509 +1,23 @@ - - -{#if showTimeline} - -
- {#if workflowTaskFailedError} - - {/if} - {#if workflow?.callbacks?.length} - - {/if} -
-
-
-
-

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

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

- fetch {fmtMs(loadMs)}{#if firstRenderMs !== null} - · first row {fmtMs(firstRenderMs)}{/if}{#if allRenderMs !== null} - · all rows {fmtMs(allRenderMs)}{/if} -

- {/if} -
- {#if workflow} -
- { - firstRenderMs = ms; - }} - onAllRendered={(ms) => { - allRenderMs = ms; - }} - viewportHeight={undefined} - error={Boolean(workflowTaskFailedError)} - /> -
- {/if} -
- {#if workflow} - - {/if} -{:else} -
- {#if error} -

{error}

- {:else} -
- - - Ascending - {#if progress || stats}· {fmt( - stats?.ascPages ?? progress?.ascPages ?? 0, - )}p{/if} - - - {#if done} - {fmtMs(stats.durationMs)} · {fmt(stats.totalEvents)} events · {fmt( - stats.eventsPerSecond, - )}/s - {:else if progress} - {fmtMs(progress.elapsedMs)} · {fmt( - progress.ascEvents + progress.descEvents, - )} events - {:else} - Starting… - {/if} - - - {#if progress || stats}{fmt( - stats?.descPages ?? progress?.descPages ?? 0, - )}p ·{/if} - Descending - - -
- -
- {#if isRendering} -
- {/if} - {#each { length: ROWS } as _, row} - {#each { length: COLS } as _, col} - {@const state = boxState(col)} - {@const isFrontierAsc = !done && col === ascCols - 1} - {@const isFrontierDesc = !done && col === COLS - descCols} - {@const doneDelay = Math.abs(col - meetCol) * 18} -
- {/each} - {/each} -
- - {#if total} -
- 1 - {#if !done && progress?.ascMaxId && progress?.descMinId} - - {fmt(progress.ascMaxId)} ↑ - - - ↓ {fmt(progress.descMinId)} - - {/if} - {fmt(total)} -
- {/if} - {/if} -
-{/if} - - + + From 120f491c9d396beca0c35c1c5219648d0fb708d3 Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Thu, 18 Jun 2026 09:07:00 -0600 Subject: [PATCH 16/39] Better timers, more loading --- .../lines-and-dots/svg/timeline-graph.svelte | 161 ++++++- .../workflow-fast-history-layout.svelte | 443 ++++-------------- src/lib/services/events-service.ts | 5 + 3 files changed, 251 insertions(+), 358 deletions(-) diff --git a/src/lib/components/lines-and-dots/svg/timeline-graph.svelte b/src/lib/components/lines-and-dots/svg/timeline-graph.svelte index a8273ff0d3..77c3f8f280 100644 --- a/src/lib/components/lines-and-dots/svg/timeline-graph.svelte +++ b/src/lib/components/lines-and-dots/svg/timeline-graph.svelte @@ -1,4 +1,6 @@ @@ -349,7 +441,7 @@ ? filteredGroups.length - visibleGroups.length + localI : localI} {@const y = reverseSort - ? (filteredGroups.length + 1 - i) * height + ? (totalForY + 1 - i) * height : (i + 2) * height} {#if !viewportHeight || (y > scrollY - 2 * height && y < scrollY + viewportHeight * height)} @@ -372,6 +464,21 @@ {/each} + {#if loading && pendingGroupCount > 0} + {@const rectY = reverseSort + ? 2 * height - radius + : (filteredGroups.length + 2) * height - radius} + {@const rectH = pendingGroupCount * height + radius} + + {/if} + From 46efe7f03771b418b19965a406a16f42484e7985 Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Thu, 18 Jun 2026 11:03:24 -0600 Subject: [PATCH 18/39] tests for positioning and first and last page rendering first then wait for the middle of the timeline --- .../lines-and-dots/svg/timeline-graph.svelte | 54 ++- .../svg/timeline-positioning.test.ts | 391 ++++++++++++++++++ .../svg/timeline-positioning.ts | 129 ++++++ .../workflow-fast-history-layout.svelte | 8 +- src/lib/services/events-service.ts | 35 ++ 5 files changed, 595 insertions(+), 22 deletions(-) create mode 100644 src/lib/components/lines-and-dots/svg/timeline-positioning.test.ts create mode 100644 src/lib/components/lines-and-dots/svg/timeline-positioning.ts diff --git a/src/lib/components/lines-and-dots/svg/timeline-graph.svelte b/src/lib/components/lines-and-dots/svg/timeline-graph.svelte index bce3c9b905..2b7a77de56 100644 --- a/src/lib/components/lines-and-dots/svg/timeline-graph.svelte +++ b/src/lib/components/lines-and-dots/svg/timeline-graph.svelte @@ -12,6 +12,12 @@ import { TimelineConfig } from '../constants'; import EndTimeInterval from '../end-time-interval.svelte'; + import { + getDescStart, + getPendingBlockY, + getRowY, + getTotalForY, + } from './timeline-positioning'; import GroupDetailsRow from './group-details-row.svelte'; import Line from './line.svelte'; @@ -34,6 +40,7 @@ onAllRendered?: (ms: number) => void; loading?: boolean; totalExpectedEvents?: number; + descMinId?: number; } let { @@ -50,6 +57,7 @@ onAllRendered, loading = false, totalExpectedEvents = 0, + descMinId = 0, }: Props = $props(); const { height, gutter, radius } = TimelineConfig; @@ -329,18 +337,24 @@ } }); - // For descending sort, rows must be placed using the *estimated* total group - // count so they land at the correct final y from the very first paint. - // Without this, the oldest events (the only ones available on the first - // ascending page) are placed at the top of the SVG, then jump to the bottom - // as more events arrive and filteredGroups.length grows. - // pendingGroupCount is derived from totalExpectedEvents so it includes the - // full expected dataset; for ascending sort the standard length is correct - // because new rows always append below existing ones. + const descStart = $derived( + getDescStart(filteredGroups, descMinId, loading, pendingGroupCount), + ); + const totalForY = $derived( - reverseSort && pendingGroupCount > 0 - ? filteredGroups.length + pendingGroupCount - : filteredGroups.length, + getTotalForY(filteredGroups.length, pendingGroupCount, descStart), + ); + + const getY = $derived.by( + () => + (i: number): number => + getRowY(i, { + descStart, + pendingGroupCount, + totalForY, + reverseSort, + height, + }), ); // PERF: timelineHeight is driven purely by panelHeight (delivered async by @@ -444,9 +458,7 @@ {@const i = reverseSort ? filteredGroups.length - visibleGroups.length + localI : localI} - {@const y = reverseSort - ? (totalForY + 1 - i) * height - : (i + 2) * height} + {@const y = getY(i)} {#if !viewportHeight || (y > scrollY - 2 * height && y < scrollY + viewportHeight * height)} - {#key group.events.size} + {#key group.eventList.length} { 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 f59acaebff..c61a82ba42 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 { EventLink, Payload } from '$lib/types'; +import type { Payload } from '$lib/types'; import type { ActivityTaskScheduledEvent, CommonHistoryEvent, @@ -58,38 +58,18 @@ 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); - - // PERF CRITICAL: eventList and links were the #1 source of GC pressure on the - // 40k-event timeline — a CPU trace showed 19s of GC time caused by these two - // getters. Each call previously did Array.from(this.events, ...) allocating a - // brand-new array. They are called from at least 6 places per render - // (getDistancePointsAndPositions, isPending, isFailureOrTimedOut, isCanceled, - // isTerminated, billableActions), multiplied across ~10k groups = ~60k short- - // lived arrays per frame continuously feeding the major GC. - // - // Fix: cache behind events.size. This is safe because events are append-only - // (Map.set is the only mutation, never Map.delete), so size increasing is a - // reliable signal that the cached array is stale. - let _eventList: EventGroup['eventList'] | undefined; - let _eventListSize = 0; - let _links: EventLink[] | undefined; - let _linksSize = 0; + // Single flat array — no Map, no Set, no closure cache. + // Groups have 1–5 events so array ops are O(1) in practice. + const eventList: EventGroup['eventList'] = [event as never]; return { id, name, label, displayName, - events: groupEvents, - eventIds: groupEventIds, + eventList, initialEvent: event, timestamp, category: isLocalActivityMarkerEvent(event) ? 'local-activity' : category, @@ -104,19 +84,10 @@ const createGroupFor = ( get attributes() { return getLastEvent(this)?.attributes; }, - get eventList() { - if (!_eventList || this.events.size !== _eventListSize) { - _eventList = Array.from(this.events, ([_key, value]) => value); - _eventListSize = this.events.size; - } - return _eventList; - }, get links() { - if (!_links || this.events.size !== _linksSize) { - _links = Array.from(this.events, ([_key, value]) => value.links).flat(); - _linksSize = this.events.size; - } - return _links; + const out = []; + for (const e of eventList) for (const l of e.links) out.push(l); + return out; }, get lastEvent() { return getLastEvent(this); @@ -128,26 +99,24 @@ const createGroupFor = ( 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) + eventList.length === 2) ); }, get isFailureOrTimedOut() { - return Boolean(this.eventList.find(eventIsFailureOrTimedOut)); + return Boolean(eventList.find(eventIsFailureOrTimedOut)); }, get isCanceled() { - return Boolean(this.eventList.find(eventIsCanceled)); + return Boolean(eventList.find(eventIsCanceled)); }, get isTerminated() { - return Boolean(this.eventList.find(eventIsTerminated)); + return Boolean(eventList.find(eventIsTerminated)); }, get billableActions() { - return this.eventList.reduce( - (acc, event) => event.billableActions + acc, - 0, - ); + let n = 0; + for (const e of eventList) n += e.billableActions; + return n; }, }; }; 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..037b1e0612 100644 --- a/src/lib/models/event-groups/get-group-for-event.ts +++ b/src/lib/models/event-groups/get-group-for-event.ts @@ -9,11 +9,6 @@ export const getGroupForEvent = ( const eventId = event.id; for (const group of groups) { - if (eventId === group.id) return group; - for (const id of group.eventIds) { - if (eventId === id) { - return group; - } - } + if (group.eventList.some((e) => e.id === eventId)) 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..d3e630f233 100644 --- a/src/lib/models/event-groups/get-last-event.ts +++ b/src/lib/models/event-groups/get-last-event.ts @@ -2,11 +2,11 @@ import type { WorkflowEvent } from '$lib/types/events'; import type { EventGroup } from './event-groups'; -export const getLastEvent = ({ events }: EventGroup): WorkflowEvent => { +export const getLastEvent = ({ eventList }: EventGroup): WorkflowEvent => { let latestEventKey = 0; let result: WorkflowEvent; - for (const event of events.values()) { + for (const event of eventList) { const k = Number(event.id); if (k >= latestEventKey) { latestEventKey = k; 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 bc802a495f..25f4d6aedd 100644 --- a/src/lib/models/event-groups/index.ts +++ b/src/lib/models/event-groups/index.ts @@ -90,8 +90,7 @@ function addToExistingGroup( 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; if (pa) group.pendingActivity = pa; @@ -138,7 +137,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 = ( diff --git a/src/lib/pages/workflow-history-event.svelte b/src/lib/pages/workflow-history-event.svelte index 05ce9d7ef3..71d6bd0886 100644 --- a/src/lib/pages/workflow-history-event.svelte +++ b/src/lib/pages/workflow-history-event.svelte @@ -129,7 +129,9 @@ {event} {index} expanded={event.id === initialEvent?.id} - group={groups.find((g) => isEvent(event) && g.eventIds.has(event.id))} + group={groups.find( + (g) => isEvent(event) && g.eventList.some((e) => e.id === event.id), + )} initialItem={$fullEventHistory[0]} /> {/each} 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..3c09517bf0 100644 --- a/src/lib/utilities/pending-activities.ts +++ b/src/lib/utilities/pending-activities.ts @@ -63,7 +63,7 @@ export const getGroupForEventOrPendingEvent = ( ): EventGroup | undefined => { return groups.find((g) => { if (isEvent(event)) { - return g.eventIds.has(event.id); + return g.eventList.some((e) => e.id === event.id); } else if (isPendingActivity(event)) { return g.pendingActivity?.id === event.id; } else if (isPendingNexusOperation(event)) { From c7b8cbee63edcf0f4d8d5da816934b0f7749429c Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Thu, 18 Jun 2026 15:02:03 -0600 Subject: [PATCH 23/39] =?UTF-8?q?perf:=20O(1)=20group=20lookups,=20eager?= =?UTF-8?q?=20classification=20flags,=20and=20cheaper=20lastEvent=20buildG?= =?UTF-8?q?roupIndex:=20one=20pass=20over=20filteredGroups=20builds=20a=20?= =?UTF-8?q?Map=20so=20any=20event=E2=86=92group=20l?= =?UTF-8?q?ookup=20is=20O(1)=20instead=20of=20O(groups=20=C3=97=20events-p?= =?UTF-8?q?er-group).=20Updated=20every=20call=20site=20that=20previously?= =?UTF-8?q?=20did=20groups.find(g=20=3D>=20g.eventList.some(...))=20to=20u?= =?UTF-8?q?se=20the=20index:=20history-graph,=20event-summary-table,=20wor?= =?UTF-8?q?kflow-history-event,=20isMiddleEvent,=20and=20getGroupForEventO?= =?UTF-8?q?rPendingEvent=20all=20accept=20Map=20|=20EventGroup[]=20and=20f?= =?UTF-8?q?ast-path=20on=20instanceof=20Map.=20getLastEvent:=20removed=20t?= =?UTF-8?q?he=20ID-scanning=20loop=20(which=20compared=20numeric=20event?= =?UTF-8?q?=20IDs=20to=20find=20the=20maximum)=20and=20replaced=20it=20wit?= =?UTF-8?q?h=20eventList[eventList.length=20-=201].=20Events=20are=20alway?= =?UTF-8?q?s=20appended=20in=20ascending-ID=20order=20by=20addToExistingGr?= =?UTF-8?q?oup=20so=20the=20last=20element=20is=20always=20the=20most=20re?= =?UTF-8?q?cent=20event.=20Eager=20classification=20fields:=20isFailureOrT?= =?UTF-8?q?imedOut,=20isCanceled,=20isTerminated,=20billableActions,=20and?= =?UTF-8?q?=20links=20were=20all=20getters=20that=20scanned=20or=20reduced?= =?UTF-8?q?=20over=20eventList=20on=20every=20access.=20They=20are=20now?= =?UTF-8?q?=20plain=20fields=20initialized=20from=20the=20first=20event=20?= =?UTF-8?q?in=20createGroupFor=20and=20updated=20by=20a=20new=20addEventTo?= =?UTF-8?q?Group=20helper=20called=20on=20each=20push=20in=20addToExisting?= =?UTF-8?q?Group.=20Read=20cost=20drops=20from=20O(events-=20per-group)=20?= =?UTF-8?q?to=20a=20single=20field=20access,=20which=20matters=20because?= =?UTF-8?q?=20these=20are=20read=20from=20the=20SVG=20render=20path=20for?= =?UTF-8?q?=20every=20visible=20row.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/event-summary-table.svelte | 7 ++- .../components/lines-and-dots/constants.ts | 7 ++- .../lines-and-dots/svg/history-graph.svelte | 8 ++- .../models/event-groups/create-event-group.ts | 54 +++++++++---------- .../event-groups/get-group-for-event.ts | 23 +++++--- src/lib/models/event-groups/get-last-event.ts | 16 +----- src/lib/models/event-groups/index.ts | 4 +- src/lib/pages/workflow-history-event.svelte | 7 ++- src/lib/utilities/pending-activities.ts | 27 +++++----- 9 files changed, 81 insertions(+), 72 deletions(-) diff --git a/src/lib/components/event/event-summary-table.svelte b/src/lib/components/event/event-summary-table.svelte index 1cf1c1413d..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,9 +144,7 @@ bind:hoveredEventId {event} {index} - group={groups.find( - (g) => isEvent(event) && g.eventList.some((e) => e.id === 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 ab9bc78344..4d22442b9b 100644 --- a/src/lib/components/lines-and-dots/constants.ts +++ b/src/lib/components/lines-and-dots/constants.ts @@ -119,9 +119,12 @@ export const timelineTextPosition = ( export const isMiddleEvent = ( event: WorkflowEvent, - groups: EventGroups, + groups: EventGroups | Map, ): boolean => { - const group = groups.find((g) => g.eventList.some((e) => e.id === 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; return ( group.eventList.findIndex((e) => e.id === event.id) === 1 && 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 @@ + + + +
+
+

⚡ Fasterer

+

+ Bidirectional fetch → grouped-event-buffer +

+ +
+ + {#if errorMsg} +

+ {errorMsg} +

+ {/if} + + {#if phase !== 'idle'} +
+ {#if phase === 'done'} + + {/if} + {#each { length: COLS } as _, col} + {@const state = boxState(col)} + {@const isFrontierAsc = + phase === 'fetching' && col === ascCols - 1 && ascCols > 0} + {@const isFrontierDesc = + phase === 'fetching' && + col === COLS - descCols && + descCols > 0 && + COLS - descCols < COLS} + {@const delay = + phase === 'done' ? Math.abs(col - frozenMeetCol) * 18 : 0} +
+ {/each} +
+ {/if} + + {#if fetchStats !== null && fetchMs !== null} +
+
+

+ Network +

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

+ Buffer +

+
+
+ {fmtMs(bufferTotalMs)} + total process +
+
+ + {bufferAvgUsPerEvent !== null + ? fmtUs(bufferAvgUsPerEvent) + : '—'} + + avg / event +
+
+ {bufferGroupCount?.toLocaleString()} + groups +
+
+
+ {#if eventCount && bufferGroupCount} + {(eventCount / bufferGroupCount).toFixed(1)} events / group avg + {/if} + {#if eventCount && bufferTotalMs} + {Math.round( + eventCount / (bufferTotalMs / 1000), + ).toLocaleString()} ev/s throughput + {/if} +
+
+ {/if} + +
+

+ Memory +

+ {#if heapDeltaMB !== null} +
+
+ 50} + > + {heapDeltaMB > 0 ? '+' : ''}{heapDeltaMB.toFixed(1)} MB + + heap Δ (buffer pass) +
+ {#if eventCount && heapDeltaMB > 0} +
+ + {((heapDeltaMB * 1024 * 1024) / eventCount).toFixed(0)} B + + bytes / event +
+ {/if} +
+
+ Chrome performance.memory +
+ {:else} +

+ Not available — use Chrome for heap stats +

+ {/if} +
+
+ {/if} + + {#if rows.length > 0} +
+
+

+ First {rows.length} groups + (workflow tasks excluded) +

+ {rowsResolved} / {rows.length} resolved +
+
+
+ # + name + classification + events + timestamp +
+ {#each rows as group, i} + {#if group === undefined} +
+ {i + 1} + resolving… +
+ {:else if group !== null} +
+ {i + 1} + {group.displayName || group.name || group.label || '—'} + {group.finalClassification ?? '—'} + {group.eventList.length} + {group.timestamp ?? '—'} +
+ {/if} + {/each} +
+
+ {/if} +
+ + From 56b8f6a52ac1e026fcf9f11db1fe9f6ea8b04aa9 Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Thu, 18 Jun 2026 17:38:06 -0600 Subject: [PATCH 27/39] Fasterer --- src/lib/layouts/workflow-header.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/layouts/workflow-header.svelte b/src/lib/layouts/workflow-header.svelte index 63919e6b44..679565d94f 100644 --- a/src/lib/layouts/workflow-header.svelte +++ b/src/lib/layouts/workflow-header.svelte @@ -297,7 +297,7 @@ )} /> Date: Fri, 19 Jun 2026 10:22:52 -0600 Subject: [PATCH 28/39] Implemented pixi renderer --- package.json | 1 + pnpm-lock.yaml | 73 + .../components/pixi-timeline/Timeline.svelte | 106 ++ src/lib/components/pixi-timeline/adapter.ts | 56 + .../components/ChildWorkflowLane.svelte | 49 + .../components/EventInlinePanel.svelte | 202 +++ .../components/EventTooltip.svelte | 88 ++ .../components/TimelineCanvas.svelte | 76 + .../components/TimelineControls.svelte | 138 ++ .../components/TimelineOverview.svelte | 340 +++++ .../components/TimelineScrollbar.svelte | 101 ++ .../components/TimelineScrollbarX.svelte | 95 ++ .../components/pixi-timeline/eventColors.ts | 48 + .../components/pixi-timeline/eventUtils.ts | 80 + .../pixi-timeline/renderer/PixiRenderer.ts | 1313 +++++++++++++++++ .../pixi-timeline/renderer/ViewportCuller.ts | 100 ++ .../pixi-timeline/renderer/fonts.ts | 103 ++ .../pixi-timeline/renderer/icon-textures.ts | 146 ++ .../pixi-timeline/timeline-ctx.svelte.ts | 190 +++ src/lib/components/pixi-timeline/types.ts | 50 + .../workflow-fast-history-layout.svelte | 122 +- src/lib/services/fetch-bidirectional.ts | 6 + src/lib/services/grouped-event-buffer.test.ts | 208 +++ src/lib/services/grouped-event-buffer.ts | 303 +++- .../services/test-helpers/synthetic-events.ts | 17 + src/lib/utilities/route-for-base-path.test.ts | 3 + .../[workflow]/[run]/fasterer/+page.svelte | 566 ++++--- svelte.config.js | 2 +- 28 files changed, 4216 insertions(+), 366 deletions(-) create mode 100644 src/lib/components/pixi-timeline/Timeline.svelte create mode 100644 src/lib/components/pixi-timeline/adapter.ts create mode 100644 src/lib/components/pixi-timeline/components/ChildWorkflowLane.svelte create mode 100644 src/lib/components/pixi-timeline/components/EventInlinePanel.svelte create mode 100644 src/lib/components/pixi-timeline/components/EventTooltip.svelte create mode 100644 src/lib/components/pixi-timeline/components/TimelineCanvas.svelte create mode 100644 src/lib/components/pixi-timeline/components/TimelineControls.svelte create mode 100644 src/lib/components/pixi-timeline/components/TimelineOverview.svelte create mode 100644 src/lib/components/pixi-timeline/components/TimelineScrollbar.svelte create mode 100644 src/lib/components/pixi-timeline/components/TimelineScrollbarX.svelte create mode 100644 src/lib/components/pixi-timeline/eventColors.ts create mode 100644 src/lib/components/pixi-timeline/eventUtils.ts create mode 100644 src/lib/components/pixi-timeline/renderer/PixiRenderer.ts create mode 100644 src/lib/components/pixi-timeline/renderer/ViewportCuller.ts create mode 100644 src/lib/components/pixi-timeline/renderer/fonts.ts create mode 100644 src/lib/components/pixi-timeline/renderer/icon-textures.ts create mode 100644 src/lib/components/pixi-timeline/timeline-ctx.svelte.ts create mode 100644 src/lib/components/pixi-timeline/types.ts diff --git a/package.json b/package.json index 41882cc434..b972eb20e5 100644 --- a/package.json +++ b/package.json @@ -107,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/src/lib/components/pixi-timeline/Timeline.svelte b/src/lib/components/pixi-timeline/Timeline.svelte new file mode 100644 index 0000000000..dd8605aca1 --- /dev/null +++ b/src/lib/components/pixi-timeline/Timeline.svelte @@ -0,0 +1,106 @@ + + +
+ + +
+ +
+ +
+ + + {#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} +
+ +
+
+
+ + +
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..ea9941f1ad --- /dev/null +++ b/src/lib/components/pixi-timeline/components/EventInlinePanel.svelte @@ -0,0 +1,202 @@ + + +
+ +
+
+ {icon} +
+
+

{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..e9457e0ab4 --- /dev/null +++ b/src/lib/components/pixi-timeline/components/EventTooltip.svelte @@ -0,0 +1,88 @@ + + +
+
+
+ {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..b95d822c08 --- /dev/null +++ b/src/lib/components/pixi-timeline/components/TimelineCanvas.svelte @@ -0,0 +1,76 @@ + + +
+ + + {#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..cece1240e7 --- /dev/null +++ b/src/lib/components/pixi-timeline/components/TimelineControls.svelte @@ -0,0 +1,138 @@ + + +
+
+ + +
+ +
+ + + +
+ + + {formatRange(timelineState.viewport.startMs, timelineState.viewport.endMs)} + + +
+ + {timelineState.visibleEvents.toLocaleString()} + / + {timelineState.totalEvents.toLocaleString()} groups + + {#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..8508021676 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/PixiRenderer.ts @@ -0,0 +1,1313 @@ +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, +} 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, +} from './fonts'; + +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 +// --------------------------------------------------------------------------- +// Renderer +// --------------------------------------------------------------------------- + +interface PanState { + active: boolean; + startX: number; + startY: number; + originStartMs: number; + originScrollY: number; +} + +/** Lightweight per-event record used by the gutter pin logic. */ +interface PinEvent { + poolIdx: number; + startMs: number; + endMs: number; + trackIndex: number; + pixiType: string; + pixiStatus: string; + displayName: string; +} + +/** 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 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, + }; + + 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'; + + 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 byTrack: PinEvent[][] = []; + 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; + + private viewportInitialized = false; + + private dirty = true; + private lastStartMs = NaN; + private lastScrollY = NaN; + private lastZoom = NaN; + private lastScreenW = 0; + private lastScreenH = 0; + + 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.loadingContainer = new Container(); + this.eventLabelContainer = new Container(); + this.rulerGfx = new Graphics(); + this.labelContainer = new Container(); + this.gutterTopGfx = new Graphics(); + this.gutterBottomGfx = new Graphics(); + 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, + 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.loadingContainer, + this.eventLabelContainer, + ); + // Layer order: grid → scrolled events → gutter pins → ruler → ruler labels + this.app.stage.addChild( + this.gridGfx, + this.scrollContainer, + this.gutterTopGfx, + this.gutterBottomGfx, + 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. */ + 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; + + 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 span = endRelMs + 600; + const screenW = this.app.screen.width || 1200; + const zoom = Math.max( + this.config.minZoom, + Math.min(this.config.maxZoom, screenW / Math.max(span, 1)), + ); + this.state.viewport.startMs = -200; + this.state.viewport.zoom = zoom; + this.state.viewport.scrollY = 0; + this.viewportInitialized = true; + } + + if (args.finalized) { + this.rebuildByTrack(); + } + } + + /** Build a track-indexed lookup for gutter pin queries. */ + private rebuildByTrack() { + this.byTrack = []; + const count = getGroupCount(); + const origin = this.dataOriginMs; + if (isNaN(origin)) return; + for (let i = 0; i < count; i++) { + const meta = getGroupMeta(i); + if (!meta) continue; + const t = meta.trackIndex; + (this.byTrack[t] ??= []).push({ + poolIdx: i, + startMs: meta.startMs - origin, + endMs: Math.max(meta.endMs - origin, meta.startMs - origin + 1), + trackIndex: t, + 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, + this.currentArgs.poolCount, + ); + 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]; + } + + /** 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 trackIndex = Math.floor((canvasY - RULER_H + scrollY) / rowSize); + const slop = 2 / zoom; + + const origin = this.dataOriginMs; + const count = this.currentArgs.poolCount; + if (!isNaN(origin) && count > 0) { + for (let i = 0; i < count; i++) { + const meta = getGroupMeta(i); + if (!meta || meta.trackIndex !== trackIndex) continue; + const relStart = meta.startMs - origin; + const relEnd = Math.max(meta.endMs - origin, relStart + 1); + // Extend the hit zone to cover the visual minimum-width bar. + 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, + ) { + const intervalMs = pickTickInterval(zoom); + 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); + 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 render() { + ensureBitmapFonts(); + + if (this.hasPendingWheel) { + this.hasPendingWheel = false; + if (this.pendingWheelDX !== 0) { + 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; + } + } + + 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; + this.canvasRect = this.canvas.getBoundingClientRect(); + 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; + + // Requires a full draw-call rebuild when geometry changes or scroll position changes. + // Scroll must rebuild so Y-culling uses the current scrollY (only visible tracks drawn). + const needsGeometry = + this.dirty || + startMs !== this.lastStartMs || + zoom !== this.lastZoom || + scrollY !== this.lastScrollY || + screenW !== this.lastScreenW || + screenH !== this.lastScreenH; + + if (!needsGeometry && !tileChanged) return; + + const poolCount = this.currentArgs.poolCount; + const totalRows = Math.max(this.currentArgs.totalRows, poolCount); + const descCount = this.currentArgs.descCount; + const ascCount = this.currentArgs.ascCount; + // 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; + if (tileChanged) this.lastTileOffset = tileOffset; + + this.scrollContainer.y = containerY; + this.drawRuler(startMs, zoom, screenW, screenH); + + // Loading bars — tracks in the gap between desc section and asc section. + this.updateLoadingBars( + loadingStart, + loadingEnd, + rowSize, + effectiveTrackH, + containerY, + screenH, + screenW, + tileOffset, + ); + + // Event bars — read directly from the buffer pool, no intermediate copy. + this.baseGfx.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; + if (!isNaN(origin)) { + for (let i = 0; i < poolCount; i++) { + 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 localY = meta.trackIndex * rowSize; + const screenY = containerY + localY; + + if (screenY + effectiveTrackH < RULER_H || screenY > screenH) continue; + + 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; + + 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 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; + + // SVG icon: always render when the icon texture is available. + // MIN_BAR_W == ICON_SIZE + ICON_PAD*2 so the icon always fits. + 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; + } + } + + // Text label: show display name after the icon (or letter+name if no icon). + 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; + // Allow label to overflow bar rightward up to screen edge. + 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, + ); + + this.app.renderer.render(this.app.stage); + } + + /** + * Render thin pin bars at the top and bottom of the canvas for events whose + * track rows are off-screen. Each off-screen track contributes at most one bar + * per gutter row (the longest-duration event wins). + */ + private drawGutterPins( + startMs: number, + zoom: number, + rowSize: number, + effectiveTrackH: number, + containerY: number, + screenW: number, + screenH: number, + _radius: number, + ) { + this.gutterTopGfx.clear(); + this.gutterBottomGfx.clear(); + this.topPins = []; + this.bottomPins = []; + // NOTE: pinnedPoolIdxs is cleared at the start of the render loop + // (before left/right pins are collected). Top/bottom pins are additive. + + if (this.byTrack.length === 0) return; + + const PIN_H = Math.max(6, Math.min(12, effectiveTrackH * 0.6)); + const PIN_MARGIN = 4; + const MIN_PIN_W = 8; + const viewEndMs = startMs + screenW / zoom; + + const topEdge = RULER_H; + const bottomEdge = screenH; + + const aboveTracks: PinEvent[] = []; + const belowTracks: PinEvent[] = []; + + for (let t = 0; t < this.byTrack.length; t++) { + const trackEvents = this.byTrack[t]; + if (!trackEvents || trackEvents.length === 0) continue; + + const screenY = containerY + t * rowSize; + const isAbove = screenY + effectiveTrackH < topEdge; + const isBelow = screenY > bottomEdge; + if (!isAbove && !isBelow) continue; + + // Pick the longest-duration event in this track that overlaps the viewport + let best: PinEvent | null = null; + for (const ev of trackEvents) { + if (ev.endMs < startMs || ev.startMs > viewEndMs) continue; + if (!best || ev.endMs - ev.startMs > best.endMs - best.startMs) + best = ev; + } + if (!best) { + // Fall back to longest event regardless of viewport overlap (gutter marker) + for (const ev of trackEvents) { + if (!best || ev.endMs - ev.startMs > best.endMs - best.startMs) + best = ev; + } + } + if (!best) continue; + + if (isAbove) aboveTracks.push(best); + else belowTracks.push(best); + } + + const renderPins = ( + events: PinEvent[], + side: 'top' | 'bottom', + store: PinBar[], + gfx: Graphics, + ) => { + const MAX_ROWS = 2; + const rows: { event: PinEvent; px: number; pw: number }[][] = []; + + for (const ev of events) { + const exStart = (ev.startMs - startMs) * zoom; + const exEnd = + exStart + Math.max(MIN_PIN_W, (ev.endMs - ev.startMs) * zoom); + const px = Math.max( + PIN_MARGIN, + Math.min(screenW - PIN_MARGIN - MIN_PIN_W, exStart), + ); + const pw = Math.max( + MIN_PIN_W, + Math.min(screenW - PIN_MARGIN - px, exEnd - px), + ); + + let row = -1; + for (let r = 0; r < rows.length; r++) { + const last = rows[r][rows[r].length - 1]; + if (!last || last.px + last.pw + 2 <= px) { + row = r; + break; + } + } + if (row === -1) { + if (rows.length >= MAX_ROWS) continue; + row = rows.length; + rows.push([]); + } + rows[row].push({ event: ev, px, pw }); + } + + for (let r = 0; r < rows.length; r++) { + const py = + side === 'top' + ? topEdge + r * (PIN_H + 2) + : bottomEdge - (rows.length - r) * (PIN_H + 2); + + for (const { event: ev, px, pw } of rows[r]) { + 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 (pw >= 18) { + // Label would require a pooled BitmapText — for now rely on tooltip + } + store.push({ poolIdx: ev.poolIdx, px, py, pw, ph: PIN_H }); + this.pinnedPoolIdxs.add(ev.poolIdx); + } + } + }; + + renderPins(aboveTracks, 'top', this.topPins, this.gutterTopGfx); + renderPins(belowTracks, 'bottom', this.bottomPins, this.gutterBottomGfx); + } + + /** + * 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 trackY = event.trackIndex * 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'; + canvas.setPointerCapture(e.pointerId); + }); + + 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.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) { + // Determine if this came from a left/right pin (need to center X axis). + const isXPin = + this.pinnedPoolIdxs.has(hit.poolIdx ?? -1) && + (this.leftPins.some((p) => p.poolIdx === hit.poolIdx) || + this.rightPins.some((p) => p.poolIdx === hit.poolIdx)); + this.scrollToEvent(hit, isXPin); + + // 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.dirty = true; + } else if (!moved) { + // Click on empty area — deselect all + for (const id of Object.keys(this.state.selectedEvents)) { + this.ctx?.deselectEvent(id); + } + 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; + const isHorizontal = !isZoom && Math.abs(e.deltaX) > Math.abs(e.deltaY); + // Vertical-only scroll: let native overflow-y scroll handle it. + if (!isZoom && !isHorizontal) return; + + 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.pendingWheelDX += Math.abs(dx) > Math.abs(dy) ? dx : dy; + } + this.hasPendingWheel = true; + }, + { passive: false }, + ); + } + + 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 = mouseMs - pos.x / newZoom; + } + + const minScaleY = 12 / trackHeight; + const newScaleY = Math.max( + minScaleY, + Math.min(5, viewport.scaleY * factor), + ); + 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), + ); + } + } + + 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/fonts.ts b/src/lib/components/pixi-timeline/renderer/fonts.ts new file mode 100644 index 0000000000..744f3f7918 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/fonts.ts @@ -0,0 +1,103 @@ +/** + * 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) ───────── + +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, +]; + +export function pickTickInterval(zoom: number): number { + for (const ms of TICK_LEVELS_MS) { + if (ms * zoom >= MIN_TICK_PX) return ms; + } + return TICK_LEVELS_MS[TICK_LEVELS_MS.length - 1]; +} + +export function formatTickLabel(offsetMs: number, intervalMs: number): string { + if (intervalMs < 1_000) return `${offsetMs}ms`; + if (intervalMs < 60_000) return `${Math.round(offsetMs / 1_000)}s`; + if (intervalMs < 3_600_000) return `${Math.floor(offsetMs / 60_000)}m`; + if (intervalMs < 86_400_000) { + 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`; + } + 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`; +} 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..cfbf946019 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/icon-textures.ts @@ -0,0 +1,146 @@ +import type { Application, Texture } from 'pixi.js'; +import { Container, Graphics, RenderTexture } from 'pixi.js'; + +// Icon names match the timeline-icon-defs.svelte symbols. +export type PixiIconName = + | 'activity' + | 'feather' + | 'nexus' + | 'relationship' + | 'retention' + | 'signal' + | 'terminal' + | 'update' + | 'workflow'; + +type IconDef = { + paths: string[]; + circles?: { cx: number; cy: number; r: number }[]; +}; + +// SVG path data extracted from timeline-icon-defs.svelte (viewBox 0 0 24 24). +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', + ], + }, +}; + +// Map from grouped-event-buffer pixiType → icon name. +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', +}; + +// Build icon SVG string for Pixi's Graphics.svg() which takes SVG element strings. +// All shapes are white so Sprite.tint can be used to recolor per event. +function buildIconSvg(def: IconDef): string { + 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(buildIconSvg(ICON_DEFS[name])); + } catch { + // Icon path failed — leave texture empty, icon just won't render. + gfx.destroy(); + continue; + } + // Scale from 24×24 viewbox to iconSize×iconSize via a container transform. + 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/timeline-ctx.svelte.ts b/src/lib/components/pixi-timeline/timeline-ctx.svelte.ts new file mode 100644 index 0000000000..d796389e76 --- /dev/null +++ b/src/lib/components/pixi-timeline/timeline-ctx.svelte.ts @@ -0,0 +1,190 @@ +import { SvelteMap } from 'svelte/reactivity'; + +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); + 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..247bd990a9 --- /dev/null +++ b/src/lib/components/pixi-timeline/types.ts @@ -0,0 +1,50 @@ +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; +} diff --git a/src/lib/layouts/workflow-fast-history-layout.svelte b/src/lib/layouts/workflow-fast-history-layout.svelte index 8c33fff4c7..70a2ef373d 100644 --- a/src/lib/layouts/workflow-fast-history-layout.svelte +++ b/src/lib/layouts/workflow-fast-history-layout.svelte @@ -16,26 +16,34 @@ import ToggleButton from '$lib/holocene/toggle-button/toggle-button.svelte'; import ToggleButtons from '$lib/holocene/toggle-button/toggle-buttons.svelte'; import { translate } from '$lib/i18n/translate'; - import { groupEvents } from '$lib/models/event-groups'; + import type { EventGroup } from '$lib/models/event-groups/event-groups'; + import { toEvent } from '$lib/models/event-history'; + import type { + BidirectionalProgress, + BidirectionalStats, + } from '$lib/services/fetch-bidirectional'; + import { fetchBidirectional } from '$lib/services/fetch-bidirectional'; import { - type BidirectionalProgress, - type BidirectionalStats, - fetchAllEventsBidirectional, - } from '$lib/services/events-service'; + enrichGroups, + getWorkflowTaskFailedEvent as getBufferWftFailedEvent, + getGroupArray, + processEvent, + reset as resetBuffer, + } from '$lib/services/grouped-event-buffer'; import { clearActives } from '$lib/stores/active-events'; import { eventFilterSort } from '$lib/stores/event-view'; - import { - currentEventHistory, - filteredEventHistory, - fullEventHistory, - } from '$lib/stores/events'; + import { currentEventHistory, fullEventHistory } from '$lib/stores/events'; + import { eventTypeFilter } from '$lib/stores/filters'; import { workflowActionsReady } from '$lib/stores/workflow-actions-ready'; import { workflowRun } from '$lib/stores/workflow-run'; + import type { + WorkflowTaskFailedEvent, + WorkflowTaskTimedOutEvent, + } from '$lib/types/events'; import { parseEventFilterParams, updateEventFilterParams, } from '$lib/utilities/event-filter-params'; - import { getWorkflowTaskFailedEvent } from '$lib/utilities/get-workflow-task-failed-event'; import { orderGroupsByPending } from '$lib/utilities/order-groups-by-pending'; interface Props { @@ -54,6 +62,7 @@ let fetchComplete = $state(false); let barPhase = $state<'fetching' | 'rendering' | 'done'>('fetching'); let controller: AbortController; + let bufferGroups = $state([]); let t0 = 0; let loadMs = $state(null); @@ -71,10 +80,17 @@ fetchComplete = false; barPhase = 'fetching'; frozenMeetCol = COLS / 2; + bufferGroups = []; controller?.abort(); controller = new AbortController(); - fetchAllEventsBidirectional({ + const estimatedSize = + parseInt($workflowRun.workflow?.historyEvents ?? '0') || 0; + resetBuffer(estimatedSize); + + let ascFirstDone = false; + + fetchBidirectional({ namespace, workflowId, runId, @@ -83,34 +99,43 @@ onProgress: (p) => { progress = p; }, - onFirstPage: (firstEvents) => { - if (!firstEvents.length) return; - // Merge ascending first page into fullEventHistory so input-and-results - // can find the WorkflowExecutionStarted event (and its input payload) - // regardless of sort order. - fullEventHistory.update((curr) => - curr.length ? [...firstEvents, ...curr] : firstEvents, - ); - if (reverseSort) return; - currentEventHistory.set(firstEvents); - workflowActionsReady.set(true); - }, - onFirstDescPage: (bookendEvents) => { - if (!bookendEvents.length) return; - // Merge descending first page into fullEventHistory so input-and-results - // can also find the completion event (and its result payload). + onFirstDescPage: (descFirst) => { + if (!descFirst.length) return; + const processed = descFirst + .map((e) => toEvent(e)) + .filter(Boolean) as import('$lib/types/events').WorkflowEvent[]; fullEventHistory.update((curr) => - curr.length ? [...curr, ...bookendEvents] : bookendEvents, + curr.length ? [...curr, ...processed] : processed, ); - currentEventHistory.set(bookendEvents); + currentEventHistory.set(processed); if (reverseSort) workflowActionsReady.set(true); + // Show both bookends: first asc groups + first desc groups together. + bufferGroups = getGroupArray(); + }, + onRawPage: (events, isAscending) => { + for (const event of events) processEvent(event, isAscending); + + if (isAscending && !ascFirstDone) { + ascFirstDone = true; + const processed = events + .map((e) => toEvent(e)) + .filter(Boolean) as import('$lib/types/events').WorkflowEvent[]; + fullEventHistory.update((curr) => + curr.length ? [...processed, ...curr] : processed, + ); + if (!reverseSort) { + currentEventHistory.set(processed); + workflowActionsReady.set(true); + } + // Show the left bookend immediately. + bufferGroups = getGroupArray(); + } }, }) - .then(async ({ events, stats: s }) => { + .then(async (s) => { stats = s; loadMs = performance.now() - t0; - // Freeze the meeting column at the actual cursor junction, then enter - // rendering phase so the done-wave ripples out from that exact point. + const asc = Math.floor( (s.ascPages / (s.ascPages + s.descPages)) * COLS, ); @@ -124,8 +149,13 @@ requestAnimationFrame(() => requestAnimationFrame(() => r())), ); - fullEventHistory.set(events); - currentEventHistory.set(events); + const workflow = $workflowRun.workflow; + enrichGroups( + workflow?.pendingActivities ?? [], + workflow?.pendingNexusOperations ?? [], + ); + bufferGroups = getGroupArray(); + fetchComplete = true; workflowActionsReady.set(true); }) @@ -201,17 +231,17 @@ const workflow = $derived($workflowRun.workflow); const workflowTaskFailedError = $derived( - getWorkflowTaskFailedEvent($currentEventHistory, 'ascending'), + fetchComplete + ? (getBufferWftFailedEvent() as + | WorkflowTaskFailedEvent + | WorkflowTaskTimedOutEvent + | undefined) + : undefined, ); - const ascendingGroups = $derived.by(() => { - if (!workflow) return []; - return groupEvents( - $filteredEventHistory, - 'ascending', - workflow.pendingActivities ?? [], - workflow.pendingNexusOperations ?? [], - ); + const filteredBufferGroups = $derived.by(() => { + const active = $eventTypeFilter; + return bufferGroups.filter((g) => active.includes(g.category)); }); // PERF SORT: never reverse the array — always pass ascending key order so @@ -220,7 +250,9 @@ // to orderGroupsByPending puts pending groups at the visually correct position // for each mode: front (low i, low y) in ascending, back (high i, low y in // descending mirror) so they always appear at the top of the viewport. - const groups = $derived(orderGroupsByPending(ascendingGroups, !reverseSort)); + const groups = $derived( + orderGroupsByPending(filteredBufferGroups, !reverseSort), + ); {#if error} diff --git a/src/lib/services/fetch-bidirectional.ts b/src/lib/services/fetch-bidirectional.ts index 6d3bec6e9c..bf1d4b2e53 100644 --- a/src/lib/services/fetch-bidirectional.ts +++ b/src/lib/services/fetch-bidirectional.ts @@ -35,6 +35,10 @@ export type FetchBidirectionalParams = { /** 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; }; @@ -45,6 +49,7 @@ export const fetchBidirectional = async ({ signal, onProgress, onRawPage, + onFirstDescPage, maximumPageSize, }: FetchBidirectionalParams): Promise => { const t0 = performance.now(); @@ -200,6 +205,7 @@ export const fetchBidirectional = async ({ } } if (fresh.length) onRawPage(fresh, false); + if (descPages === 1) onFirstDescPage?.(fresh); reportProgress(); if (!response.nextPageToken || gap() <= 0) { diff --git a/src/lib/services/grouped-event-buffer.test.ts b/src/lib/services/grouped-event-buffer.test.ts index 3244c22023..1206d18e98 100644 --- a/src/lib/services/grouped-event-buffer.test.ts +++ b/src/lib/services/grouped-event-buffer.test.ts @@ -5,10 +5,13 @@ import { toEventHistory } from '$lib/models/event-history'; import { _debugState, + enrichGroups, + getGroupArray, getGroupCount, getLatestEvent, getLatestGroup, getRows, + getWorkflowTaskFailedEvent, isWorkflowTaskGroup, mergeHeads, onLatestGroup, @@ -26,6 +29,11 @@ import { makeTimerGroup, makeWorkflowCompleted, makeWorkflowStarted, + makeWorkflowTaskCompleted, + makeWorkflowTaskFailed, + makeWorkflowTaskGroup, + makeWorkflowTaskScheduled, + makeWorkflowTaskStarted, } from './test-helpers/synthetic-events'; // --------------------------------------------------------------------------- @@ -619,3 +627,203 @@ describe('getGroupCount', () => { 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); + }); +}); diff --git a/src/lib/services/grouped-event-buffer.ts b/src/lib/services/grouped-event-buffer.ts index ac8ddd0839..b626b42bd4 100644 --- a/src/lib/services/grouped-event-buffer.ts +++ b/src/lib/services/grouped-event-buffer.ts @@ -9,8 +9,19 @@ 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 @@ -19,6 +30,11 @@ import type { type GroupMeta = { headSlotIdx: number; group: EventGroup | null; + startMs: number; + endMs: number; + trackIndex: number; + pixiType: string; + pixiStatus: string; }; export type GetRowsOptions = { @@ -38,6 +54,10 @@ 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>(); @@ -54,12 +74,97 @@ const processedWorkflowTaskIds = new Set(); // --------------------------------------------------------------------------- function makeGroupMeta(): GroupMeta { - return { headSlotIdx: -1, group: null }; + 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 { @@ -102,6 +207,9 @@ function attachFollowerToPool(poolIdx: number, followerSlotIdx: number): void { 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; } function attachFollower(headSlotIdx: number, followerSlotIdx: number): void { @@ -154,6 +262,15 @@ export function reset(historyLength: number): void { 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. @@ -165,8 +282,12 @@ export function setFailedEvent(raw: HistoryEvent | null): void { /** * 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): void { +export function processEvent( + raw: HistoryEvent, + isAscending: boolean, +): EventGroup | null { const slotIdx = parseInt(raw.eventId) - 1; if (slotIdx >= eventSlots.length) { @@ -186,7 +307,7 @@ export function processEvent(raw: HistoryEvent, isAscending: boolean): void { if (!isHead) { const headSlotIdx = parseInt(gid) - 1; attachFollower(headSlotIdx, slotIdx); - return; + return null; } // Try both group dispatchers — createWorkflowTaskGroup handles WFT events @@ -197,11 +318,11 @@ export function processEvent(raw: HistoryEvent, isAscending: boolean): void { if (!group) { // Solo event: discard any orphaned pending followers pendingFollowers.delete(slotIdx); - return; + return null; } // Write-once guard: prevents double-registration at cursor boundary overlap - if (eventToGroup[slotIdx] !== 0) return; + if (eventToGroup[slotIdx] !== 0) return null; if (poolTop >= groupPool.length) { groupPool.push(makeGroupMeta()); @@ -212,12 +333,28 @@ export function processEvent(raw: HistoryEvent, isAscending: boolean): void { 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 (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 @@ -238,6 +375,7 @@ export function processEvent(raw: HistoryEvent, isAscending: boolean): void { } notifyLatestGroupListeners(group); + return group; } /** @@ -344,6 +482,161 @@ 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 { + const total = poolTop; + + // 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); + } +} + /** Read-only view of internal state for assertions in tests. */ export function _debugState() { return { diff --git a/src/lib/services/test-helpers/synthetic-events.ts b/src/lib/services/test-helpers/synthetic-events.ts index 578ad15f78..96cbad746c 100644 --- a/src/lib/services/test-helpers/synthetic-events.ts +++ b/src/lib/services/test-helpers/synthetic-events.ts @@ -137,6 +137,23 @@ export function makeWorkflowTaskStarted( } 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, diff --git a/src/lib/utilities/route-for-base-path.test.ts b/src/lib/utilities/route-for-base-path.test.ts index 3162c7b855..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,7 @@ import { routeForEventHistory, routeForEventHistoryEvent, routeForEventHistoryImport, + routeForFasterer, routeForFastHistory, routeForLoginPage, routeForNamespace, @@ -105,6 +106,7 @@ 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', @@ -315,6 +317,7 @@ 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/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/fasterer/+page.svelte b/src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/fasterer/+page.svelte index 54e0e63a5b..474bb89701 100644 --- a/src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/fasterer/+page.svelte +++ b/src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/fasterer/+page.svelte @@ -4,20 +4,23 @@ import { page } from '$app/state'; import PageTitle from '$lib/components/page-title.svelte'; - import type { EventGroup } from '$lib/models/event-groups/event-groups'; - import { - type BidirectionalProgress, - type BidirectionalStats, - fetchBidirectional, + import Timeline from '$lib/components/pixi-timeline/Timeline.svelte'; + import type { PixiRenderArgs } from '$lib/components/pixi-timeline/types'; + import type { + BidirectionalProgress, + BidirectionalStats, } from '$lib/services/fetch-bidirectional'; + import { fetchBidirectional } from '$lib/services/fetch-bidirectional'; import { + assignTrackIndices, + getAscGroupCount, + getDescGroupCount, getGroupCount, - getRows, processEvent, reset as resetBuffer, + setEstimatedGroupCount, } from '$lib/services/grouped-event-buffer'; import { workflowRun } from '$lib/stores/workflow-run'; - import type { HistoryEvent } from '$lib/types/events'; const { namespace, workflow: workflowId, run: runId } = $derived(page.params); @@ -39,7 +42,7 @@ let controller: AbortController; const COLS = 40; - let frozenMeetCol = $state(COLS / 2); + let _frozenMeetCol = $state(COLS / 2); const total = $derived( fetchStats?.totalEvents ?? progress?.totalEstimated ?? 0, @@ -81,10 +84,38 @@ ); } - const ROW_LIMIT = 1000; - // undefined = not yet resolved, null = resolved but filtered (WFT), EventGroup = real row - let rows = $state<(EventGroup | null | undefined)[]>([]); - let rowsResolved = $state(0); + let estimatedGroups = $state(0); + + let pixiArgs = $state({ + poolCount: 0, + totalRows: 0, + ascCount: 0, + descCount: 0, + finalized: false, + }); + + let _pixiRafId: number | null = null; + function schedulePixiUpdate() { + if (_pixiRafId !== null) return; + _pixiRafId = requestAnimationFrame(() => { + _pixiRafId = null; + if (pixiArgs.finalized) return; + pixiArgs = { + poolCount: getGroupCount(), + totalRows: estimatedGroups, + ascCount: getAscGroupCount(), + descCount: getDescGroupCount(), + finalized: false, + }; + }); + } + + function cancelScheduledUpdate() { + if (_pixiRafId !== null) { + cancelAnimationFrame(_pixiRafId); + _pixiRafId = null; + } + } function run() { phase = 'fetching'; @@ -97,28 +128,24 @@ bufferAvgUsPerEvent = null; heapDeltaMB = null; eventCount = null; - frozenMeetCol = COLS / 2; + _frozenMeetCol = COLS / 2; controller?.abort(); controller = new AbortController(); - // Pre-allocate the buffer using historyEvents count from the workflow - // metadata so the first processEvent calls never need to grow the arrays. - const estimatedSize = + const historySize = parseInt($workflowRun.workflow?.historyEvents ?? '0') || 0; - resetBuffer(estimatedSize); - - // Request the first 1000 rows RIGHT NOW before any data has arrived. - // Each Promise is registered in pendingResolvers and will resolve the - // moment processEvent() completes its group — data streams in live. - rows = new Array(ROW_LIMIT).fill(undefined); - rowsResolved = 0; - const promises = getRows(0, ROW_LIMIT, { excludeWorkflowTasks: true }); - promises.forEach((p, i) => { - p.then((group) => { - rows[i] = group ?? null; - rowsResolved += 1; - }); - }); + resetBuffer(historySize); + + estimatedGroups = Math.max(0, Math.ceil(historySize / 2)); + setEstimatedGroupCount(estimatedGroups); + + pixiArgs = { + poolCount: 0, + totalRows: estimatedGroups, + ascCount: 0, + descCount: 0, + finalized: false, + }; const heapBefore = heapNow(); const t0 = performance.now(); @@ -140,6 +167,7 @@ } bufferEventCount += events.length; bufferTotalMs = (bufferTotalMs ?? 0) + (performance.now() - t1); + schedulePixiUpdate(); }, }) .then((stats) => { @@ -153,7 +181,7 @@ const desc = Math.floor( (stats.descPages / (stats.ascPages + stats.descPages)) * COLS, ); - frozenMeetCol = Math.floor((asc + (COLS - desc)) / 2); + _frozenMeetCol = Math.floor((asc + (COLS - desc)) / 2); bufferGroupCount = getGroupCount(); bufferAvgUsPerEvent = @@ -166,11 +194,17 @@ heapDeltaMB = (heapAfter - heapBefore) / (1024 * 1024); } + cancelScheduledUpdate(); + assignTrackIndices(); phase = 'done'; - // Trim rows to actual group count (may be less than ROW_LIMIT) - const actual = getGroupCount(); - if (actual < ROW_LIMIT) rows = rows.slice(0, actual); + pixiArgs = { + poolCount: getGroupCount(), + totalRows: getGroupCount(), + ascCount: getAscGroupCount(), + descCount: getDescGroupCount(), + finalized: true, + }; }) .catch((e: unknown) => { if (e instanceof Error && e.name !== 'AbortError') { @@ -182,7 +216,10 @@ onMount(() => { run(); - return () => controller?.abort(); + return () => { + controller?.abort(); + if (_pixiRafId !== null) cancelAnimationFrame(_pixiRafId); + }; }); const fmtMs = (ms: number) => @@ -194,280 +231,211 @@ const fmtUs = (us: number) => us < 1000 ? `${us.toFixed(1)}µs` : `${(us / 1000).toFixed(2)}ms`; + + let statsCollapsed = $state(false); -
-
-

⚡ Fasterer

-

- Bidirectional fetch → grouped-event-buffer -

- -
- - {#if errorMsg} -

- {errorMsg} -

- {/if} - - {#if phase !== 'idle'} -
- {#if phase === 'done'} - - {/if} - {#each { length: COLS } as _, col} - {@const state = boxState(col)} - {@const isFrontierAsc = - phase === 'fetching' && col === ascCols - 1 && ascCols > 0} - {@const isFrontierDesc = - phase === 'fetching' && - col === COLS - descCols && - descCols > 0 && - COLS - descCols < COLS} - {@const delay = - phase === 'done' ? Math.abs(col - frozenMeetCol) * 18 : 0} -
- {/each} +
+
+
+

⚡ Fasterer

+

+ Bidirectional fetch → Pixi renderer +

+ +
- {/if} - {#if fetchStats !== null && fetchMs !== null} -
-
-

- Network -

-
-
- {fmtMs(fetchMs)} - total fetch -
-
- {fetchStats.totalEvents.toLocaleString()} - events -
-
- {fetchStats.eventsPerSecond.toLocaleString()} - events / sec -
-
-
- ↑ {fetchStats.ascPages} pages - ↓ {fetchStats.descPages} pages - {#if fetchStats.overlap > 0} - {fetchStats.overlap} overlap - {/if} - {fetchStats.winner} won -
-
+ {#if !statsCollapsed} +
+ {#if errorMsg} +

+ {errorMsg} +

+ {/if} - {#if bufferTotalMs !== null} -
-

- Buffer -

-
-
- {fmtMs(bufferTotalMs)} - total process -
-
- - {bufferAvgUsPerEvent !== null - ? fmtUs(bufferAvgUsPerEvent) - : '—'} - - avg / event -
-
- {bufferGroupCount?.toLocaleString()} - groups -
+ {#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 eventCount && bufferGroupCount} - {(eventCount / bufferGroupCount).toFixed(1)} events / group avg - {/if} - {#if eventCount && bufferTotalMs} - {Math.round( - eventCount / (bufferTotalMs / 1000), - ).toLocaleString()} ev/s throughput - {/if} -
-
- {/if} + {/if} -
-

- Memory -

- {#if heapDeltaMB !== null} -
-
- 50} + {#if fetchStats !== null && fetchMs !== null} +
+
+

- {heapDeltaMB > 0 ? '+' : ''}{heapDeltaMB.toFixed(1)} MB - - heap Δ (buffer pass) + 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 eventCount && heapDeltaMB > 0} -
- +

- {((heapDeltaMB * 1024 * 1024) / eventCount).toFixed(0)} B - - bytes / event + Buffer +

+
+
+ {fmtMs(bufferTotalMs)} + process +
+
+ + {bufferAvgUsPerEvent !== null + ? fmtUs(bufferAvgUsPerEvent) + : '—'} + + avg/ev +
+
+ {bufferGroupCount?.toLocaleString()} + groups +
+
{/if} -
-
- Chrome performance.memory -
- {:else} -

- Not available — use Chrome for heap stats -

- {/if} -
-
- {/if} - - {#if rows.length > 0} -
-
-

- First {rows.length} groups - (workflow tasks excluded) -

- {rowsResolved} / {rows.length} resolved -
-
-
- # - name - classification - events - timestamp -
- {#each rows as group, i} - {#if group === undefined} -
- {i + 1} - resolving… -
- {:else if group !== null} +
- {i + 1} - {group.displayName || group.name || group.label || '—'} - {group.finalClassification ?? '—'} - {group.eventList.length} - {group.timestamp ?? '—'} + 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} - {/each} +
+ {/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'] }, }, }, }; From 8213ce2ad7f2dc7cc7c93a3889046c6ccb7af922 Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Fri, 19 Jun 2026 10:52:14 -0600 Subject: [PATCH 29/39] Fix gutters --- .../pixi-timeline/renderer/PixiRenderer.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/lib/components/pixi-timeline/renderer/PixiRenderer.ts b/src/lib/components/pixi-timeline/renderer/PixiRenderer.ts index 8508021676..16ed2a6513 100644 --- a/src/lib/components/pixi-timeline/renderer/PixiRenderer.ts +++ b/src/lib/components/pixi-timeline/renderer/PixiRenderer.ts @@ -989,7 +989,14 @@ export class PixiRenderer { const viewEndMs = startMs + screenW / zoom; const topEdge = RULER_H; - const bottomEdge = screenH; + // Clamp bottom edge to the visible portion of the canvas inside the browser viewport. + // The canvas may overflow the window when the stats panel is open, so we must not + // draw the bottom gutter below what the user can actually see. + const visibleH = Math.min( + screenH, + Math.max(0, window.innerHeight - this.canvasRect.top), + ); + const bottomEdge = visibleH; const aboveTracks: PinEvent[] = []; const belowTracks: PinEvent[] = []; @@ -1023,6 +1030,10 @@ export class PixiRenderer { else belowTracks.push(best); } + // Sort by horizontal position so the row-packing algorithm fills left-to-right. + aboveTracks.sort((a, b) => a.startMs - b.startMs); + belowTracks.sort((a, b) => a.startMs - b.startMs); + const renderPins = ( events: PinEvent[], side: 'top' | 'bottom', From e46b4bd66e330123af30f5f203138a147359c7c2 Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Fri, 19 Jun 2026 14:15:26 -0600 Subject: [PATCH 30/39] lots of sizing and icon stuff --- .../components/EventInlinePanel.svelte | 19 +- .../components/EventTooltip.svelte | 13 +- .../pixi-timeline/renderer/PixiRenderer.ts | 106 +++++- .../pixi-timeline/renderer/icon-textures.ts | 111 +----- src/lib/services/grouped-event-buffer.test.ts | 358 ++++++++++++++++++ src/lib/services/grouped-event-buffer.ts | 13 +- .../[workflow]/[run]/fasterer/+page.svelte | 3 +- 7 files changed, 493 insertions(+), 130 deletions(-) diff --git a/src/lib/components/pixi-timeline/components/EventInlinePanel.svelte b/src/lib/components/pixi-timeline/components/EventInlinePanel.svelte index ea9941f1ad..79f8ff0c77 100644 --- a/src/lib/components/pixi-timeline/components/EventInlinePanel.svelte +++ b/src/lib/components/pixi-timeline/components/EventInlinePanel.svelte @@ -4,11 +4,8 @@ import { page } from '$app/state'; import { EVENT_COLORS } from '../eventColors'; - import { - formatDuration, - getEventDisplayName, - getEventIcon, - } from '../eventUtils'; + import { formatDuration, getEventDisplayName } from '../eventUtils'; + import { getEventIconSvg } from '../renderer/icon-svgs'; import { TIMELINE_CTX, type TimelineCtx } from '../timeline-ctx.svelte'; import type { TemporalEvent } from '../types'; @@ -29,7 +26,7 @@ ); const displayName = $derived(getEventDisplayName(event)); - const icon = $derived(getEventIcon(event)); + const iconSvg = $derived(getEventIconSvg(event.eventType)); const categoryLabel = $derived.by(() => { const t = event.eventType; @@ -85,10 +82,16 @@
- {icon} + {#if iconSvg} + {@html iconSvg} + {:else} + + {event.eventType.replace(/^(EVENT_TYPE_|GROUP_)/, '')[0]} + + {/if}

{displayName}

diff --git a/src/lib/components/pixi-timeline/components/EventTooltip.svelte b/src/lib/components/pixi-timeline/components/EventTooltip.svelte index e9457e0ab4..5796a9b4d5 100644 --- a/src/lib/components/pixi-timeline/components/EventTooltip.svelte +++ b/src/lib/components/pixi-timeline/components/EventTooltip.svelte @@ -1,6 +1,7 @@
-
+
+ {#if iconSvg} + {@html iconSvg} + {:else} +
+ {/if} +
{displayName}
diff --git a/src/lib/components/pixi-timeline/renderer/PixiRenderer.ts b/src/lib/components/pixi-timeline/renderer/PixiRenderer.ts index 16ed2a6513..617e754180 100644 --- a/src/lib/components/pixi-timeline/renderer/PixiRenderer.ts +++ b/src/lib/components/pixi-timeline/renderer/PixiRenderer.ts @@ -14,6 +14,7 @@ import { import { getGroupCount, getGroupMeta, + getVisibleGroupCount, } from '$lib/services/grouped-event-buffer'; import { EVENT_COLORS, STATUS_ALPHA } from '../eventColors'; @@ -35,6 +36,8 @@ import { formatTickLabel, pickTickInterval, } from './fonts'; +import { gutterIconLayout } from './gutter-layout'; +import { clampViewportStartMs } from './viewport-clamp'; export const DEFAULT_CONFIG: TimelineConfig = { trackHeight: 28, @@ -153,6 +156,10 @@ export class PixiRenderer { /** 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; @@ -197,6 +204,7 @@ export class PixiRenderer { 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(); @@ -229,12 +237,13 @@ export class PixiRenderer { this.loadingContainer, this.eventLabelContainer, ); - // Layer order: grid → scrolled events → gutter pins → ruler → ruler labels + // 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, ); @@ -306,7 +315,12 @@ export class PixiRenderer { if (isNaN(origin)) return; for (let i = 0; i < count; i++) { const meta = getGroupMeta(i); - if (!meta) continue; + if ( + !meta || + meta.pixiType === 'GROUP_WORKFLOW_TASK' || + meta.trackIndex < 0 + ) + continue; const t = meta.trackIndex; (this.byTrack[t] ??= []).push({ poolIdx: i, @@ -334,7 +348,7 @@ export class PixiRenderer { Math.max(1, trackGap * this.state.viewport.scaleY); const totalTracks = Math.max( this.currentArgs.totalRows, - this.currentArgs.poolCount, + getVisibleGroupCount(), ); return Math.max( 0, @@ -396,6 +410,17 @@ export class PixiRenderer { 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'; @@ -623,7 +648,12 @@ export class PixiRenderer { if (!isNaN(origin) && count > 0) { for (let i = 0; i < count; i++) { const meta = getGroupMeta(i); - if (!meta || meta.trackIndex !== trackIndex) continue; + if ( + !meta || + meta.pixiType === 'GROUP_WORKFLOW_TASK' || + meta.trackIndex !== trackIndex + ) + continue; const relStart = meta.startMs - origin; const relEnd = Math.max(meta.endMs - origin, relStart + 1); // Extend the hit zone to cover the visual minimum-width bar. @@ -704,8 +734,10 @@ export class PixiRenderer { if (this.hasPendingWheel) { this.hasPendingWheel = false; if (this.pendingWheelDX !== 0) { - this.state.viewport.startMs += - this.pendingWheelDX / this.state.viewport.zoom; + this.state.viewport.startMs = this.clampStartMs( + this.state.viewport.startMs + + this.pendingWheelDX / this.state.viewport.zoom, + ); this.pendingWheelDX = 0; } if (this.pendingZoomFactor !== 1) { @@ -773,7 +805,10 @@ export class PixiRenderer { if (!needsGeometry && !tileChanged) return; const poolCount = this.currentArgs.poolCount; - const totalRows = Math.max(this.currentArgs.totalRows, poolCount); + const totalRows = Math.max( + this.currentArgs.totalRows, + getVisibleGroupCount(), + ); const descCount = this.currentArgs.descCount; const ascCount = this.currentArgs.ascCount; // Loading gap sits between the desc section (top) and asc section (bottom). @@ -835,7 +870,7 @@ export class PixiRenderer { if (!isNaN(origin)) { for (let i = 0; i < poolCount; i++) { const meta = getGroupMeta(i); - if (!meta) continue; + if (!meta || meta.pixiType === 'GROUP_WORKFLOW_TASK') continue; const relStart = meta.startMs - origin; const relEnd = Math.max(meta.endMs - origin, relStart + 1); @@ -978,14 +1013,18 @@ export class PixiRenderer { this.gutterBottomGfx.clear(); this.topPins = []; this.bottomPins = []; + this.gutterIconSpriteIndex = 0; // NOTE: pinnedPoolIdxs is cleared at the start of the render loop // (before left/right pins are collected). Top/bottom pins are additive. - if (this.byTrack.length === 0) return; + if (this.byTrack.length === 0) { + for (const s of this.gutterIconSpritePool) s.visible = false; + return; + } - const PIN_H = Math.max(6, Math.min(12, effectiveTrackH * 0.6)); + const PIN_H = Math.max(12, Math.min(18, effectiveTrackH * 0.75)); const PIN_MARGIN = 4; - const MIN_PIN_W = 8; + const MIN_PIN_W = PIN_H; const viewEndMs = startMs + screenW / zoom; const topEdge = RULER_H; @@ -1083,8 +1122,23 @@ export class PixiRenderer { const alpha = (STATUS_ALPHA[ev.pixiStatus] ?? 1.0) * 0.85; gfx.roundRect(px, py, pw, PIN_H, 2).fill({ color, alpha }); - if (pw >= 18) { - // Label would require a pooled BitmapText — for now rely on tooltip + 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); @@ -1094,6 +1148,14 @@ export class PixiRenderer { renderPins(aboveTracks, 'top', this.topPins, this.gutterTopGfx); renderPins(belowTracks, 'bottom', this.bottomPins, this.gutterBottomGfx); + + for ( + let i = this.gutterIconSpriteIndex; + i < this.gutterIconSpritePool.length; + i++ + ) { + this.gutterIconSpritePool[i].visible = false; + } } /** @@ -1168,8 +1230,9 @@ export class PixiRenderer { const deltaXMs = (x - this.panState.startX) / this.state.viewport.zoom; const deltaYPx = y - this.panState.startY; - this.state.viewport.startMs = - this.panState.originStartMs - deltaXMs; + this.state.viewport.startMs = this.clampStartMs( + this.panState.originStartMs - deltaXMs, + ); this.state.viewport.scrollY = Math.max( 0, Math.min( @@ -1276,6 +1339,15 @@ export class PixiRenderer { ); } + 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, @@ -1291,7 +1363,9 @@ export class PixiRenderer { if (newZoom !== viewport.zoom) { const mouseMs = viewport.startMs + pos.x / viewport.zoom; this.state.viewport.zoom = newZoom; - this.state.viewport.startMs = mouseMs - pos.x / newZoom; + this.state.viewport.startMs = this.clampStartMs( + mouseMs - pos.x / newZoom, + ); } const minScaleY = 12 / trackHeight; diff --git a/src/lib/components/pixi-timeline/renderer/icon-textures.ts b/src/lib/components/pixi-timeline/renderer/icon-textures.ts index cfbf946019..f0d3f81a37 100644 --- a/src/lib/components/pixi-timeline/renderer/icon-textures.ts +++ b/src/lib/components/pixi-timeline/renderer/icon-textures.ts @@ -1,110 +1,19 @@ import type { Application, Texture } from 'pixi.js'; import { Container, Graphics, RenderTexture } from 'pixi.js'; -// Icon names match the timeline-icon-defs.svelte symbols. -export type PixiIconName = - | 'activity' - | 'feather' - | 'nexus' - | 'relationship' - | 'retention' - | 'signal' - | 'terminal' - | 'update' - | 'workflow'; +export type { PixiIconName } from './icon-svgs'; +export { PIXI_TYPE_TO_ICON } from './icon-svgs'; +import { ICON_DEFS, type PixiIconName } from './icon-svgs'; -type IconDef = { - paths: string[]; - circles?: { cx: number; cy: number; r: number }[]; -}; - -// SVG path data extracted from timeline-icon-defs.svelte (viewBox 0 0 24 24). -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', - ], - }, -}; - -// Map from grouped-event-buffer pixiType → icon name. -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', -}; - -// Build icon SVG string for Pixi's Graphics.svg() which takes SVG element strings. +// 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 buildIconSvg(def: IconDef): string { +function buildPixiIconSvg(name: PixiIconName): string { + const def = ICON_DEFS[name]; let inner = ''; - for (const d of def.paths) { - inner += ``; - } + for (const d of def.paths) inner += ``; if (def.circles) { - for (const c of def.circles) { + for (const c of def.circles) inner += ``; - } } return `${inner}`; } @@ -121,13 +30,11 @@ export function buildIconTextures( for (const name of Object.keys(ICON_DEFS) as PixiIconName[]) { const gfx = new Graphics(); try { - gfx.svg(buildIconSvg(ICON_DEFS[name])); + gfx.svg(buildPixiIconSvg(name)); } catch { - // Icon path failed — leave texture empty, icon just won't render. gfx.destroy(); continue; } - // Scale from 24×24 viewbox to iconSize×iconSize via a container transform. const wrapper = new Container(); wrapper.scale.set(scale); wrapper.addChild(gfx); diff --git a/src/lib/services/grouped-event-buffer.test.ts b/src/lib/services/grouped-event-buffer.test.ts index 1206d18e98..5e74d59faf 100644 --- a/src/lib/services/grouped-event-buffer.test.ts +++ b/src/lib/services/grouped-event-buffer.test.ts @@ -5,18 +5,24 @@ import { toEventHistory } from '$lib/models/event-history'; import { _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 { @@ -827,3 +833,355 @@ describe('getGroupArray', () => { 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); + }); +}); diff --git a/src/lib/services/grouped-event-buffer.ts b/src/lib/services/grouped-event-buffer.ts index b626b42bd4..f177f34321 100644 --- a/src/lib/services/grouped-event-buffer.ts +++ b/src/lib/services/grouped-event-buffer.ts @@ -342,7 +342,10 @@ export function processEvent( eventToGroup[slotIdx] = poolIdx + 1; - if (isAscending) { + 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 = @@ -614,7 +617,8 @@ export function getDescGroupCount(): number { * Also updates pixiStatus now that final classification is known. */ export function assignTrackIndices(): void { - const total = poolTop; + // 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++) { @@ -637,6 +641,11 @@ export function assignTrackIndices(): void { } } +/** 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 { 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 index 474bb89701..4cfacd7cc7 100644 --- a/src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/fasterer/+page.svelte +++ b/src/routes/(app)/namespaces/[namespace]/workflows/[workflow]/[run]/fasterer/+page.svelte @@ -16,6 +16,7 @@ getAscGroupCount, getDescGroupCount, getGroupCount, + getVisibleGroupCount, processEvent, reset as resetBuffer, setEstimatedGroupCount, @@ -200,7 +201,7 @@ pixiArgs = { poolCount: getGroupCount(), - totalRows: getGroupCount(), + totalRows: getVisibleGroupCount(), ascCount: getAscGroupCount(), descCount: getDescGroupCount(), finalized: true, From f61b97c60999f9521c25335a44a1cc1857af260d Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Fri, 19 Jun 2026 14:15:38 -0600 Subject: [PATCH 31/39] Gutter tests and icon stuff --- .../renderer/gutter-layout.test.ts | 159 +++++++++++++++++ .../pixi-timeline/renderer/gutter-layout.ts | 36 ++++ .../pixi-timeline/renderer/icon-svgs.test.ts | 162 ++++++++++++++++++ .../pixi-timeline/renderer/icon-svgs.ts | 111 ++++++++++++ .../renderer/viewport-clamp.test.ts | 110 ++++++++++++ .../pixi-timeline/renderer/viewport-clamp.ts | 55 ++++++ 6 files changed, 633 insertions(+) create mode 100644 src/lib/components/pixi-timeline/renderer/gutter-layout.test.ts create mode 100644 src/lib/components/pixi-timeline/renderer/gutter-layout.ts create mode 100644 src/lib/components/pixi-timeline/renderer/icon-svgs.test.ts create mode 100644 src/lib/components/pixi-timeline/renderer/icon-svgs.ts create mode 100644 src/lib/components/pixi-timeline/renderer/viewport-clamp.test.ts create mode 100644 src/lib/components/pixi-timeline/renderer/viewport-clamp.ts 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/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/viewport-clamp.test.ts b/src/lib/components/pixi-timeline/renderer/viewport-clamp.test.ts new file mode 100644 index 0000000000..2254ee863c --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/viewport-clamp.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; + +import { clampViewportStartMs } 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); + }); +}); 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..568b0866c8 --- /dev/null +++ b/src/lib/components/pixi-timeline/renderer/viewport-clamp.ts @@ -0,0 +1,55 @@ +/** + * 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; + +/** + * 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)); +} From 4de183d681633b7a0657dde8a0bf2fea726bf79e Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Fri, 19 Jun 2026 18:18:27 -0600 Subject: [PATCH 32/39] Viewport clamping --- .../pixi-timeline/renderer/PixiRenderer.ts | 8 +- .../renderer/viewport-clamp.test.ts | 76 ++++++++++++++++++- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/src/lib/components/pixi-timeline/renderer/PixiRenderer.ts b/src/lib/components/pixi-timeline/renderer/PixiRenderer.ts index 617e754180..8ce8652fae 100644 --- a/src/lib/components/pixi-timeline/renderer/PixiRenderer.ts +++ b/src/lib/components/pixi-timeline/renderer/PixiRenderer.ts @@ -37,7 +37,7 @@ import { pickTickInterval, } from './fonts'; import { gutterIconLayout } from './gutter-layout'; -import { clampViewportStartMs } from './viewport-clamp'; +import { clampScaleY, clampViewportStartMs } from './viewport-clamp'; export const DEFAULT_CONFIG: TimelineConfig = { trackHeight: 28, @@ -1368,11 +1368,7 @@ export class PixiRenderer { ); } - const minScaleY = 12 / trackHeight; - const newScaleY = Math.max( - minScaleY, - Math.min(5, viewport.scaleY * factor), - ); + const newScaleY = clampScaleY(viewport.scaleY * factor, trackHeight); if (newScaleY !== viewport.scaleY) { const oldRowSize = trackHeight * viewport.scaleY + Math.max(1, trackGap * viewport.scaleY); diff --git a/src/lib/components/pixi-timeline/renderer/viewport-clamp.test.ts b/src/lib/components/pixi-timeline/renderer/viewport-clamp.test.ts index 2254ee863c..a3d87ebbe1 100644 --- a/src/lib/components/pixi-timeline/renderer/viewport-clamp.test.ts +++ b/src/lib/components/pixi-timeline/renderer/viewport-clamp.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { clampViewportStartMs } from './viewport-clamp'; +import { + clampScaleY, + clampViewportStartMs, + 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 @@ -108,3 +112,73 @@ describe('clampViewportStartMs — edge: data fits entirely within viewport', () 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 + }); +}); From 483b3b9e553cf570f35295857e648b3b1b8df1df Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Sun, 21 Jun 2026 11:23:21 -0600 Subject: [PATCH 33/39] Working pretty okay now --- .../components/pixi-timeline/Timeline.svelte | 97 ++-- .../components/TimelineCanvas.svelte | 8 +- .../components/TimelineControls.svelte | 46 ++ .../pixi-timeline/renderer/PixiRenderer.ts | 424 ++++++++++++------ .../pixi-timeline/renderer/fonts.ts | 83 +++- .../renderer/gutter-culling.test.ts | 245 ++++++++++ .../pixi-timeline/renderer/gutter-culling.ts | 75 ++++ .../renderer/gutter-pin-bins.test.ts | 263 +++++++++++ .../pixi-timeline/renderer/gutter-pin-bins.ts | 81 ++++ .../renderer/gutter-pin-layout.test.ts | 157 +++++++ .../renderer/gutter-pin-layout.ts | 103 +++++ .../renderer/pack-gutter-pins.test.ts | 358 +++++++++++++++ .../renderer/pack-gutter-pins.ts | 114 +++++ .../renderer/viewport-clamp.test.ts | 101 +++++ .../pixi-timeline/renderer/viewport-clamp.ts | 32 ++ .../pixi-timeline/timeline-ctx.svelte.ts | 3 + src/lib/components/pixi-timeline/types.ts | 6 + .../[workflow]/[run]/fasterer/+page.svelte | 86 +++- 18 files changed, 2061 insertions(+), 221 deletions(-) create mode 100644 src/lib/components/pixi-timeline/renderer/gutter-culling.test.ts create mode 100644 src/lib/components/pixi-timeline/renderer/gutter-culling.ts create mode 100644 src/lib/components/pixi-timeline/renderer/gutter-pin-bins.test.ts create mode 100644 src/lib/components/pixi-timeline/renderer/gutter-pin-bins.ts create mode 100644 src/lib/components/pixi-timeline/renderer/gutter-pin-layout.test.ts create mode 100644 src/lib/components/pixi-timeline/renderer/gutter-pin-layout.ts create mode 100644 src/lib/components/pixi-timeline/renderer/pack-gutter-pins.test.ts create mode 100644 src/lib/components/pixi-timeline/renderer/pack-gutter-pins.ts diff --git a/src/lib/components/pixi-timeline/Timeline.svelte b/src/lib/components/pixi-timeline/Timeline.svelte index dd8605aca1..134bb9910e 100644 --- a/src/lib/components/pixi-timeline/Timeline.svelte +++ b/src/lib/components/pixi-timeline/Timeline.svelte @@ -11,7 +11,6 @@ import TimelineScrollbarX from './components/TimelineScrollbarX.svelte'; import { makeMainCtx, - setViewport, TIMELINE_CTX, timelineState, } from './timeline-ctx.svelte'; @@ -24,82 +23,36 @@ let { renderArgs, class: className = '' }: Props = $props(); setContext(TIMELINE_CTX, makeMainCtx()); - - let trackAreaHeight = $state(0); - let trackAreaWidth = $state(0); - let scrollEl: HTMLDivElement; - - // Spacer height that gives the native scrollbar the correct range. - // When scrollTop reaches maxScrollY the last track is at the bottom of the canvas. - const spacerH = $derived(timelineState.maxScrollY); - - // Keep the DOM scrollTop in sync when drag/zoom changes viewport.scrollY. - let lastScrollY = 0; - $effect(() => { - const scrollY = timelineState.viewport.scrollY; - if (scrollEl && Math.abs(scrollEl.scrollTop - scrollY) > 1) { - lastScrollY = scrollY; - scrollEl.scrollTop = scrollY; - } - }); - - function handleScroll() { - const scrollY = scrollEl.scrollTop; - if (Math.abs(scrollY - lastScrollY) > 0) { - lastScrollY = scrollY; - setViewport({ scrollY }); - } - }
-
- -
- -
- - - {#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} -
- -
-
+
+ + + {#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}
diff --git a/src/lib/components/pixi-timeline/components/TimelineCanvas.svelte b/src/lib/components/pixi-timeline/components/TimelineCanvas.svelte index b95d822c08..2d820da9b6 100644 --- a/src/lib/components/pixi-timeline/components/TimelineCanvas.svelte +++ b/src/lib/components/pixi-timeline/components/TimelineCanvas.svelte @@ -55,12 +55,18 @@ renderer.loadEventsLegacy(events); } }); + + $effect(() => { + if (!rendererReady || !renderer) return; + renderer.setSortOrder(renderArgs?.sortOrder ?? 'desc'); + });
diff --git a/src/lib/components/pixi-timeline/components/TimelineControls.svelte b/src/lib/components/pixi-timeline/components/TimelineControls.svelte index cece1240e7..5df9d230c8 100644 --- a/src/lib/components/pixi-timeline/components/TimelineControls.svelte +++ b/src/lib/components/pixi-timeline/components/TimelineControls.svelte @@ -1,6 +1,11 @@ -
+ +

⚡ Fasterer

@@ -418,7 +486,15 @@ {/if}
-
+ + + + + +
{#if pixiArgs.poolCount > 0 || phase === 'fetching'} {:else if phase === 'idle'} @@ -427,6 +503,12 @@
{/if}
+ + +
diff --git a/src/lib/services/events-service.ts b/src/lib/services/events-service.ts index d730033938..7fb029238b 100644 --- a/src/lib/services/events-service.ts +++ b/src/lib/services/events-service.ts @@ -107,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 && From 573c1a819701d45d6d435e79091b4130c9ab3af4 Mon Sep 17 00:00:00 2001 From: alex thomsen Date: Sun, 28 Jun 2026 14:40:55 -0600 Subject: [PATCH 39/39] =?UTF-8?q?perf(pixi-timeline):=20keyboard=20nav,=20?= =?UTF-8?q?rendering=20optimisations,=20frame-time=20HUD=20Keyboard=20acce?= =?UTF-8?q?ssibility=20-=20Tab/Shift+Tab=20cycles=20through=20events;=20Ar?= =?UTF-8?q?rowUp/Down=20moves=20between=20tracks;=20=20=20Enter/Space=20se?= =?UTF-8?q?lects;=20Escape=20closes=20panel=20then=20clears=20focus=20-=20?= =?UTF-8?q?Mouse=20click=20syncs=20keyboard=20focus=20index=20so=20arrow?= =?UTF-8?q?=20navigation=20continues=20from=20=20=20the=20last=20clicked?= =?UTF-8?q?=20event=20-=20EventInlinePanel=20steals=20focus=20on=20open=20?= =?UTF-8?q?and=20returns=20it=20to=20the=20canvas=20on=20close=20-=20ARIA:?= =?UTF-8?q?=20role=3Dapplication,=20aria-label,=20aria-live=20announcement?= =?UTF-8?q?=20region,=20visible=20=20=20focus=20outline,=20keyboard=20hint?= =?UTF-8?q?=20(sr-only)=20Rendering=20performance=20-=20Track-indexed=20re?= =?UTF-8?q?nder=20loop:=20iterate=20only=20visible=20track=20rows=20instea?= =?UTF-8?q?d=20of=20all=20=20=20events=20(O(visibleTracks)=20vs=20O(allEve?= =?UTF-8?q?nts)=20=E2=80=94=20~533=C3=97=20for=2050=20k=20events)=20-=20Sp?= =?UTF-8?q?lit=20needsGeometry=20into=20needsXGeometry=20/=20needsYScroll?= =?UTF-8?q?=20so=20the=20ruler=20and=20=20=20grid=20skip=20rebuilding=20on?= =?UTF-8?q?=20vertical-scroll-only=20frames=20-=20hitTestEvent=20and=20mov?= =?UTF-8?q?eFocusToAdjacentTrack=20use=20trackIdx=20CSR=20lookups=20=20=20?= =?UTF-8?q?(O(events=20on=20one=20track)=20and=20O(1)=20avg=20respectively?= =?UTF-8?q?)=20-=20gatherGutterTracks:=20O(1)=20viewport=20arithmetic=20re?= =?UTF-8?q?places=20O(numTracks)=20scan;=20=20=20early-exits=20after=20gut?= =?UTF-8?q?terSampleCap=20tracks,=20returns=20closest-first=20so=20the=20?= =?UTF-8?q?=20=20downstream=20.reverse()=20copy=20is=20eliminated=20-=20pa?= =?UTF-8?q?ckGutterPins:=20rowMaxEnd=20per-row=20fast-path=20skips=20the?= =?UTF-8?q?=20O(n)=20.some()=20check=20=20=20when=20an=20event=20starts=20?= =?UTF-8?q?after=20all=20existing=20intervals=20end=20(common=20for=20=20?= =?UTF-8?q?=20sequential=20workflows=20=E2=80=94=20reduces=20pass=201=20fr?= =?UTF-8?q?om=20O(N=C2=B2)=20to=20O(N))=20Frame-time=20HUD=20-=20Circular?= =?UTF-8?q?=20Float32Array=20buffer=20(120=20slots,=20zero=20allocation=20?= =?UTF-8?q?on=20hot=20path);=20=20=20insertion-sorted=20in-place=20every?= =?UTF-8?q?=2030=20active=20frames=20-=20Controls=20bar=20shows=20avg=20ms?= =?UTF-8?q?=20(green/yellow/red)=20and=20p99=20in=20red=20when=20it=20=20?= =?UTF-8?q?=20exceeds=202=C3=97=20avg=20or=2016.7=20ms;=20hover=20tooltip?= =?UTF-8?q?=20exposes=20p95=20and=20max?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/pixi-timeline/Timeline.svelte | 8 + .../components/EventInlinePanel.svelte | 36 +- .../components/TimelineCanvas.svelte | 16 +- .../components/TimelineControls.svelte | 38 +- .../pixi-timeline/renderer/PixiRenderer.ts | 472 ++++++++++++------ .../renderer/gutter-culling.test.ts | 9 +- .../pixi-timeline/renderer/gutter-culling.ts | 75 ++- .../renderer/pack-gutter-pins.ts | 11 + .../pixi-timeline/renderer/track-index.ts | 5 +- .../pixi-timeline/timeline-ctx.svelte.ts | 9 + 10 files changed, 499 insertions(+), 180 deletions(-) diff --git a/src/lib/components/pixi-timeline/Timeline.svelte b/src/lib/components/pixi-timeline/Timeline.svelte index 134bb9910e..6f6bea87dc 100644 --- a/src/lib/components/pixi-timeline/Timeline.svelte +++ b/src/lib/components/pixi-timeline/Timeline.svelte @@ -1,6 +1,7 @@
@@ -99,7 +127,7 @@