@oh-my-pi/omp-stats 18.0.10 → 18.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/client/index.css +1 -1
  3. package/dist/client/index.js +101 -101
  4. package/dist/client/styles.css +265 -0
  5. package/dist/types/client/api.d.ts +7 -1
  6. package/dist/types/client/app/routes.d.ts +1 -1
  7. package/dist/types/client/data/useHashRoute.d.ts +2 -0
  8. package/dist/types/client/routes/TracesRoute.d.ts +11 -0
  9. package/dist/types/client/routes/index.d.ts +1 -0
  10. package/dist/types/client/traces/AggregatesPanel.d.ts +9 -0
  11. package/dist/types/client/traces/Minimap.d.ts +14 -0
  12. package/dist/types/client/traces/SpanDrawer.d.ts +14 -0
  13. package/dist/types/client/traces/SummaryStrip.d.ts +9 -0
  14. package/dist/types/client/traces/TimelineCanvas.d.ts +25 -0
  15. package/dist/types/client/traces/TraceView.d.ts +11 -0
  16. package/dist/types/client/traces/TranscriptList.d.ts +24 -0
  17. package/dist/types/client/traces/time-scale.d.ts +40 -0
  18. package/dist/types/client/traces/trace-colors.d.ts +31 -0
  19. package/dist/types/db.d.ts +24 -1
  20. package/dist/types/index.d.ts +1 -1
  21. package/dist/types/parser.d.ts +8 -0
  22. package/dist/types/server.d.ts +3 -2
  23. package/dist/types/shared-types.d.ts +130 -0
  24. package/dist/types/trace.d.ts +33 -0
  25. package/dist/types/types.d.ts +35 -1
  26. package/package.json +8 -8
  27. package/src/client/App.tsx +11 -1
  28. package/src/client/api.ts +23 -0
  29. package/src/client/app/routes.ts +7 -0
  30. package/src/client/data/useHashRoute.ts +28 -10
  31. package/src/client/routes/ProvidersRoute.tsx +1 -0
  32. package/src/client/routes/TracesRoute.tsx +182 -0
  33. package/src/client/routes/index.ts +1 -0
  34. package/src/client/styles.css +316 -0
  35. package/src/client/traces/AggregatesPanel.tsx +88 -0
  36. package/src/client/traces/Minimap.tsx +177 -0
  37. package/src/client/traces/SpanDrawer.tsx +309 -0
  38. package/src/client/traces/SummaryStrip.tsx +37 -0
  39. package/src/client/traces/TimelineCanvas.tsx +768 -0
  40. package/src/client/traces/TraceView.tsx +344 -0
  41. package/src/client/traces/TranscriptList.tsx +166 -0
  42. package/src/client/traces/time-scale.ts +250 -0
  43. package/src/client/traces/trace-colors.ts +77 -0
  44. package/src/db.ts +77 -0
  45. package/src/index.ts +1 -6
  46. package/src/parser.ts +55 -2
  47. package/src/server.ts +69 -17
  48. package/src/shared-types.ts +138 -0
  49. package/src/trace.ts +1002 -0
  50. package/src/types.ts +45 -1
@@ -1329,3 +1329,319 @@
1329
1329
  transition: none;
1330
1330
  }
1331
1331
  }
1332
+
1333
+ /* ==========================================================================
1334
+ Traces (session flamegraph)
1335
+ ========================================================================== */
1336
+ .stats-trace-view {
1337
+ display: flex;
1338
+ flex-direction: column;
1339
+ gap: 14px;
1340
+ }
1341
+
1342
+ .stats-trace-header {
1343
+ display: flex;
1344
+ align-items: center;
1345
+ gap: 12px;
1346
+ flex-wrap: wrap;
1347
+ min-width: 0;
1348
+ }
1349
+
1350
+ .stats-trace-back {
1351
+ display: inline-flex;
1352
+ align-items: center;
1353
+ gap: 6px;
1354
+ padding: 6px 12px;
1355
+ font-size: 12px;
1356
+ color: var(--muted);
1357
+ background: var(--surface);
1358
+ border: 1px solid var(--border);
1359
+ border-radius: var(--radius-md);
1360
+ cursor: pointer;
1361
+ transition: color 0.14s ease, border-color 0.14s ease;
1362
+ }
1363
+
1364
+ .stats-trace-back:hover {
1365
+ color: var(--text);
1366
+ border-color: var(--border-strong);
1367
+ }
1368
+
1369
+ .stats-trace-back:focus-visible {
1370
+ outline: 2px solid var(--link);
1371
+ outline-offset: 2px;
1372
+ }
1373
+
1374
+ .stats-trace-title {
1375
+ font-size: 15px;
1376
+ font-weight: 600;
1377
+ color: var(--text);
1378
+ margin: 0;
1379
+ min-width: 0;
1380
+ overflow: hidden;
1381
+ text-overflow: ellipsis;
1382
+ white-space: nowrap;
1383
+ }
1384
+
1385
+ .stats-trace-cwd {
1386
+ font-size: 11px;
1387
+ color: var(--dim);
1388
+ border: 1px solid var(--border);
1389
+ border-radius: 999px;
1390
+ padding: 3px 10px;
1391
+ max-width: 280px;
1392
+ overflow: hidden;
1393
+ text-overflow: ellipsis;
1394
+ white-space: nowrap;
1395
+ }
1396
+
1397
+ /* Summary strip: one bordered card, hairline-divided stat columns. */
1398
+ .stats-trace-summary {
1399
+ display: flex;
1400
+ flex-wrap: wrap;
1401
+ background: var(--surface);
1402
+ border: 1px solid var(--border);
1403
+ border-radius: var(--radius-lg);
1404
+ overflow: hidden;
1405
+ }
1406
+
1407
+ .stats-trace-summary-cell {
1408
+ display: flex;
1409
+ flex-direction: column;
1410
+ gap: 3px;
1411
+ padding: 10px 16px;
1412
+ border-right: 1px solid var(--border);
1413
+ min-width: 76px;
1414
+ }
1415
+
1416
+ .stats-trace-summary-cell:last-child {
1417
+ border-right: none;
1418
+ }
1419
+
1420
+ .stats-trace-summary-label {
1421
+ font-size: 9px;
1422
+ text-transform: uppercase;
1423
+ letter-spacing: 0.07em;
1424
+ color: var(--dim);
1425
+ }
1426
+
1427
+ .stats-trace-summary-value {
1428
+ font-size: 13px;
1429
+ font-weight: 500;
1430
+ color: var(--text);
1431
+ font-variant-numeric: tabular-nums;
1432
+ }
1433
+
1434
+ /* Toolbar row above the timeline. */
1435
+ .stats-trace-toolbar {
1436
+ display: flex;
1437
+ align-items: center;
1438
+ gap: 12px;
1439
+ flex-wrap: wrap;
1440
+ }
1441
+
1442
+ .stats-trace-toolbar-hint {
1443
+ font-size: 10px;
1444
+ color: var(--dim);
1445
+ margin-left: auto;
1446
+ }
1447
+
1448
+ .stats-trace-input {
1449
+ background: var(--surface);
1450
+ border: 1px solid var(--border);
1451
+ border-radius: var(--radius-md);
1452
+ color: var(--text);
1453
+ font-size: 12px;
1454
+ padding: 6px 10px;
1455
+ }
1456
+
1457
+ .stats-trace-input:focus-visible {
1458
+ outline: 2px solid var(--link);
1459
+ outline-offset: 1px;
1460
+ }
1461
+
1462
+ .stats-trace-input::placeholder {
1463
+ color: var(--dim);
1464
+ }
1465
+
1466
+ .stats-trace-check {
1467
+ display: inline-flex;
1468
+ align-items: center;
1469
+ gap: 6px;
1470
+ font-size: 12px;
1471
+ color: var(--muted);
1472
+ cursor: pointer;
1473
+ user-select: none;
1474
+ }
1475
+
1476
+ .stats-trace-icon-btn {
1477
+ display: inline-flex;
1478
+ align-items: center;
1479
+ justify-content: center;
1480
+ width: 26px;
1481
+ height: 26px;
1482
+ background: transparent;
1483
+ border: 1px solid var(--border);
1484
+ border-radius: var(--radius-md);
1485
+ color: var(--muted);
1486
+ cursor: pointer;
1487
+ transition: color 0.14s ease, border-color 0.14s ease;
1488
+ }
1489
+
1490
+ .stats-trace-icon-btn:hover:not(:disabled) {
1491
+ color: var(--text);
1492
+ border-color: var(--border-strong);
1493
+ }
1494
+
1495
+ .stats-trace-icon-btn:focus-visible {
1496
+ outline: 2px solid var(--link);
1497
+ outline-offset: 2px;
1498
+ }
1499
+
1500
+ .stats-trace-icon-btn:disabled {
1501
+ opacity: 0.4;
1502
+ cursor: default;
1503
+ }
1504
+
1505
+ /* Timeline card: minimap + canvas share one bordered surface. */
1506
+ .stats-trace-timeline-card {
1507
+ background: var(--surface);
1508
+ border: 1px solid var(--border);
1509
+ border-radius: var(--radius-lg);
1510
+ padding: 12px;
1511
+ display: flex;
1512
+ flex-direction: column;
1513
+ gap: 10px;
1514
+ }
1515
+
1516
+ .stats-trace-timeline {
1517
+ position: relative;
1518
+ display: flex;
1519
+ max-height: 560px;
1520
+ overflow-y: auto;
1521
+ overflow-x: hidden;
1522
+ }
1523
+
1524
+ .stats-trace-canvas {
1525
+ cursor: grab;
1526
+ outline: none;
1527
+ touch-action: none;
1528
+ border-radius: var(--radius-sm);
1529
+ }
1530
+
1531
+ .stats-trace-canvas:focus-visible {
1532
+ outline: 2px solid var(--link);
1533
+ outline-offset: 2px;
1534
+ }
1535
+
1536
+ .stats-trace-canvas:active {
1537
+ cursor: grabbing;
1538
+ }
1539
+
1540
+ .stats-trace-chevron {
1541
+ display: inline-flex;
1542
+ background: none;
1543
+ border: none;
1544
+ padding: 0;
1545
+ cursor: pointer;
1546
+ color: var(--muted);
1547
+ }
1548
+
1549
+ .stats-trace-chevron:hover {
1550
+ color: var(--text);
1551
+ }
1552
+
1553
+ .stats-trace-gutter-track {
1554
+ font-size: 11px;
1555
+ font-weight: 500;
1556
+ color: var(--text);
1557
+ }
1558
+
1559
+ .stats-trace-gutter-model {
1560
+ font-size: 9px;
1561
+ color: var(--dim);
1562
+ }
1563
+
1564
+ .stats-trace-gutter-lane {
1565
+ position: absolute;
1566
+ font-size: 8px;
1567
+ color: var(--dim);
1568
+ text-transform: uppercase;
1569
+ letter-spacing: 0.08em;
1570
+ }
1571
+
1572
+ /* Transcript list under the timeline. */
1573
+ .stats-trace-list {
1574
+ background: var(--surface);
1575
+ border: 1px solid var(--border);
1576
+ border-radius: var(--radius-lg);
1577
+ height: 340px;
1578
+ overflow-y: auto;
1579
+ }
1580
+
1581
+ .stats-trace-row {
1582
+ display: flex;
1583
+ align-items: center;
1584
+ gap: 10px;
1585
+ height: 28px;
1586
+ width: 100%;
1587
+ padding: 0 12px;
1588
+ background: transparent;
1589
+ border: none;
1590
+ border-bottom: 1px solid var(--border);
1591
+ text-align: left;
1592
+ cursor: pointer;
1593
+ transition: background 0.1s ease;
1594
+ }
1595
+
1596
+ .stats-trace-row:hover {
1597
+ background: var(--bg-hover);
1598
+ }
1599
+
1600
+ .stats-trace-row:focus-visible {
1601
+ outline: 2px solid var(--link);
1602
+ outline-offset: -2px;
1603
+ }
1604
+
1605
+ .stats-trace-row[data-selected="true"] {
1606
+ background: var(--accent-muted);
1607
+ }
1608
+
1609
+ .stats-trace-row-static {
1610
+ cursor: default;
1611
+ }
1612
+
1613
+ .stats-trace-row-static:hover {
1614
+ background: transparent;
1615
+ }
1616
+
1617
+ .stats-trace-kind {
1618
+ flex-shrink: 0;
1619
+ width: 58px;
1620
+ font-size: 8px;
1621
+ text-transform: uppercase;
1622
+ letter-spacing: 0.06em;
1623
+ border-radius: 3px;
1624
+ padding: 2px 0;
1625
+ text-align: center;
1626
+ overflow: hidden;
1627
+ text-overflow: ellipsis;
1628
+ white-space: nowrap;
1629
+ }
1630
+
1631
+ .stats-trace-row-meta {
1632
+ flex-shrink: 0;
1633
+ margin-left: auto;
1634
+ font-size: 10px;
1635
+ color: var(--dim);
1636
+ font-variant-numeric: tabular-nums;
1637
+ display: flex;
1638
+ gap: 10px;
1639
+ }
1640
+
1641
+ @media (prefers-reduced-motion: reduce) {
1642
+ .stats-trace-back,
1643
+ .stats-trace-icon-btn,
1644
+ .stats-trace-row {
1645
+ transition: none;
1646
+ }
1647
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Collapsible per-tool duration aggregates for one trace, sorted by total
3
+ * time descending (as produced by the server).
4
+ */
5
+
6
+ import { ChevronDown, ChevronRight } from "lucide-react";
7
+ import { useMemo, useState } from "react";
8
+ import { formatDurationMs, formatInteger } from "../data/formatters";
9
+ import type { TraceToolStat } from "../types";
10
+ import { DataTable } from "../ui/DataTable";
11
+
12
+ export interface AggregatesPanelProps {
13
+ toolStats: TraceToolStat[];
14
+ }
15
+
16
+ export function AggregatesPanel({ toolStats }: AggregatesPanelProps) {
17
+ const [open, setOpen] = useState(false);
18
+
19
+ const columns = useMemo(
20
+ () => [
21
+ { key: "tool", header: "Tool", render: (item: TraceToolStat) => item.tool },
22
+ { key: "calls", header: "Calls", numeric: true, render: (item: TraceToolStat) => formatInteger(item.calls) },
23
+ {
24
+ key: "errors",
25
+ header: "Errors",
26
+ numeric: true,
27
+ render: (item: TraceToolStat) => formatInteger(item.errors),
28
+ },
29
+ {
30
+ key: "total",
31
+ header: "Total",
32
+ numeric: true,
33
+ render: (item: TraceToolStat) => formatDurationMs(item.totalMs),
34
+ },
35
+ {
36
+ key: "avg",
37
+ header: "Avg",
38
+ numeric: true,
39
+ render: (item: TraceToolStat) => formatDurationMs(item.calls > 0 ? item.totalMs / item.calls : 0),
40
+ },
41
+ { key: "max", header: "Max", numeric: true, render: (item: TraceToolStat) => formatDurationMs(item.maxMs) },
42
+ ],
43
+ [],
44
+ );
45
+
46
+ if (toolStats.length === 0) return null;
47
+
48
+ return (
49
+ <div className="stats-panel">
50
+ <button
51
+ type="button"
52
+ onClick={() => setOpen(prev => !prev)}
53
+ aria-expanded={open}
54
+ style={{
55
+ display: "flex",
56
+ alignItems: "center",
57
+ gap: 6,
58
+ width: "100%",
59
+ background: "none",
60
+ border: "none",
61
+ padding: "10px 14px",
62
+ cursor: "pointer",
63
+ textAlign: "left",
64
+ }}
65
+ >
66
+ {open ? (
67
+ <ChevronDown size={14} className="stats-text-muted" aria-hidden="true" />
68
+ ) : (
69
+ <ChevronRight size={14} className="stats-text-muted" aria-hidden="true" />
70
+ )}
71
+ <span className="stats-panel-title">Tool Aggregates</span>
72
+ <span className="stats-text-muted" style={{ fontSize: 11 }}>
73
+ {toolStats.length} tools
74
+ </span>
75
+ </button>
76
+ {open && (
77
+ <div className="stats-panel-body">
78
+ <DataTable
79
+ columns={columns}
80
+ data={toolStats}
81
+ keyExtractor={item => item.tool}
82
+ emptyText="No tool calls"
83
+ />
84
+ </div>
85
+ )}
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Timeline overview strip: per-category density over the full virtual domain
3
+ * plus a draggable/resizable viewport brush, mirroring DevTools' overview.
4
+ */
5
+
6
+ import { useCallback, useEffect, useRef, useState } from "react";
7
+ import type { TraceSpanKind, TraceTrack } from "../types";
8
+ import { useSystemTheme } from "../useSystemTheme";
9
+ import type { TimelineViewport } from "./TimelineCanvas";
10
+ import type { TraceScale } from "./time-scale";
11
+ import { TRACE_THEMES } from "./trace-colors";
12
+
13
+ export interface MinimapProps {
14
+ tracks: TraceTrack[];
15
+ scale: TraceScale;
16
+ viewport: TimelineViewport;
17
+ onViewportChange: (viewport: TimelineViewport) => void;
18
+ }
19
+
20
+ const HEIGHT = 44;
21
+ const STRIP_KINDS: TraceSpanKind[] = ["turn", "model", "tool", "subagent"];
22
+ const EDGE_PX = 6;
23
+ const MIN_WINDOW_U = 10;
24
+
25
+ type DragMode = "move" | "left" | "right" | "create";
26
+
27
+ export function Minimap({ tracks, scale, viewport, onViewportChange }: MinimapProps) {
28
+ const theme = useSystemTheme();
29
+ const colors = TRACE_THEMES[theme];
30
+ const canvasRef = useRef<HTMLCanvasElement>(null);
31
+ const [width, setWidth] = useState(800);
32
+ const dragRef = useRef<{ mode: DragMode; startX: number; startViewport: TimelineViewport } | null>(null);
33
+
34
+ useEffect(() => {
35
+ const canvas = canvasRef.current;
36
+ if (!canvas) return;
37
+ const observer = new ResizeObserver(entries => {
38
+ setWidth(Math.max(100, Math.floor(entries[0].contentRect.width)));
39
+ });
40
+ observer.observe(canvas);
41
+ return () => observer.disconnect();
42
+ }, []);
43
+
44
+ const [d0, d1] = scale.domain;
45
+ const dSpan = Math.max(d1 - d0, 1e-9);
46
+ const toX = useCallback((u: number) => ((u - d0) / dSpan) * width, [d0, dSpan, width]);
47
+ const toU = useCallback((x: number) => d0 + (x / Math.max(width, 1)) * dSpan, [d0, dSpan, width]);
48
+
49
+ useEffect(() => {
50
+ const canvas = canvasRef.current;
51
+ if (!canvas) return;
52
+ const ctx = canvas.getContext("2d");
53
+ if (!ctx) return;
54
+ const dpr = devicePixelRatio;
55
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
56
+ ctx.clearRect(0, 0, width, HEIGHT);
57
+
58
+ // Density strips: bucket span coverage per pixel per category.
59
+ const stripH = HEIGHT / STRIP_KINDS.length;
60
+ for (let k = 0; k < STRIP_KINDS.length; k++) {
61
+ const kind = STRIP_KINDS[k];
62
+ const buckets = new Float32Array(width);
63
+ for (const track of tracks) {
64
+ for (const span of track.spans) {
65
+ if (span.kind !== kind) continue;
66
+ const x0 = Math.max(0, Math.floor(toX(scale.toU(span.start))));
67
+ const x1 = Math.min(width - 1, Math.ceil(toX(scale.toU(span.end))));
68
+ for (let x = x0; x <= x1; x++) buckets[x] = Math.min(1, buckets[x] + 0.34);
69
+ }
70
+ }
71
+ ctx.fillStyle = colors.category[kind];
72
+ for (let x = 0; x < width; x++) {
73
+ if (buckets[x] <= 0) continue;
74
+ ctx.globalAlpha = 0.25 + buckets[x] * 0.75;
75
+ ctx.fillRect(x, k * stripH + 1, 1, stripH - 2);
76
+ }
77
+ ctx.globalAlpha = 1;
78
+ }
79
+
80
+ // Viewport brush.
81
+ const vx0 = toX(viewport.u0);
82
+ const vx1 = toX(viewport.u1);
83
+ ctx.fillStyle = theme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)";
84
+ ctx.fillRect(vx0, 0, vx1 - vx0, HEIGHT);
85
+ ctx.strokeStyle = colors.selection;
86
+ ctx.strokeRect(vx0 + 0.5, 0.5, vx1 - vx0 - 1, HEIGHT - 1);
87
+ ctx.fillStyle = colors.selection;
88
+ ctx.fillRect(vx0, 0, 2, HEIGHT);
89
+ ctx.fillRect(vx1 - 2, 0, 2, HEIGHT);
90
+ }, [tracks, scale, viewport, colors, theme, width, toX]);
91
+
92
+ const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
93
+ const rect = event.currentTarget.getBoundingClientRect();
94
+ const x = event.clientX - rect.left;
95
+ const vx0 = toX(viewport.u0);
96
+ const vx1 = toX(viewport.u1);
97
+ let mode: DragMode;
98
+ if (Math.abs(x - vx0) <= EDGE_PX) mode = "left";
99
+ else if (Math.abs(x - vx1) <= EDGE_PX) mode = "right";
100
+ else if (x > vx0 && x < vx1) mode = "move";
101
+ else {
102
+ mode = "create";
103
+ const u = toU(x);
104
+ onViewportChange(clamp({ u0: u, u1: u + MIN_WINDOW_U }, d0, d1));
105
+ }
106
+ dragRef.current = { mode, startX: x, startViewport: viewport };
107
+ event.currentTarget.setPointerCapture(event.pointerId);
108
+ };
109
+
110
+ const handlePointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {
111
+ const drag = dragRef.current;
112
+ if (!drag) return;
113
+ const rect = event.currentTarget.getBoundingClientRect();
114
+ const x = event.clientX - rect.left;
115
+ const deltaU = toU(x) - toU(drag.startX);
116
+ const { u0, u1 } = drag.startViewport;
117
+ if (drag.mode === "move") {
118
+ onViewportChange(clamp({ u0: u0 + deltaU, u1: u1 + deltaU }, d0, d1));
119
+ } else if (drag.mode === "left") {
120
+ onViewportChange(clamp({ u0: Math.min(u0 + deltaU, u1 - MIN_WINDOW_U), u1 }, d0, d1));
121
+ } else if (drag.mode === "right") {
122
+ onViewportChange(clamp({ u0, u1: Math.max(u1 + deltaU, u0 + MIN_WINDOW_U) }, d0, d1));
123
+ } else {
124
+ const anchor = toU(drag.startX);
125
+ const current = toU(x);
126
+ onViewportChange(
127
+ clamp(
128
+ {
129
+ u0: Math.min(anchor, current),
130
+ u1: Math.max(anchor, current, Math.min(anchor, current) + MIN_WINDOW_U),
131
+ },
132
+ d0,
133
+ d1,
134
+ ),
135
+ );
136
+ }
137
+ };
138
+
139
+ const handlePointerUp = () => {
140
+ dragRef.current = null;
141
+ };
142
+
143
+ return (
144
+ <canvas
145
+ ref={canvasRef}
146
+ width={Math.floor(width * devicePixelRatio)}
147
+ height={Math.floor(HEIGHT * devicePixelRatio)}
148
+ style={{
149
+ width: "100%",
150
+ height: HEIGHT,
151
+ display: "block",
152
+ cursor: "crosshair",
153
+ borderRadius: 4,
154
+ border: "1px solid var(--border)",
155
+ touchAction: "none",
156
+ }}
157
+ role="slider"
158
+ aria-label="Timeline overview brush"
159
+ aria-valuemin={0}
160
+ aria-valuemax={100}
161
+ aria-valuenow={Math.round(((viewport.u0 - d0) / dSpan) * 100)}
162
+ tabIndex={-1}
163
+ onPointerDown={handlePointerDown}
164
+ onPointerMove={handlePointerMove}
165
+ onPointerUp={handlePointerUp}
166
+ />
167
+ );
168
+ }
169
+
170
+ function clamp(viewport: TimelineViewport, d0: number, d1: number): TimelineViewport {
171
+ let span = Math.max(MIN_WINDOW_U, viewport.u1 - viewport.u0);
172
+ span = Math.min(span, d1 - d0 || MIN_WINDOW_U);
173
+ let start = viewport.u0;
174
+ if (start < d0) start = d0;
175
+ if (start + span > d1) start = d1 - span;
176
+ return { u0: start, u1: start + span };
177
+ }