Skip to content

Commit 5db7575

Browse files
feat: add XY plot swimlanes with EKG waveforms and cursor value display
- Add TimeGraphXYComponent for rendering line plots within rows - Add XY series model (TimeGraphXYPoint, TimeGraphXYSeries) to row model - Render XY plots only on empty rows (no states) - Rich cursor shows interpolated XY value on hover with tooltip - Example: two EKG-style swimlanes (Heart Rate, Respiration)
1 parent ff73cc0 commit 5db7575

6 files changed

Lines changed: 209 additions & 0 deletions

File tree

example/src/test-data-provider.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@ export class TestDataProvider {
186186
timeGraphEntries.model.entries.forEach(entry => {
187187
rowIds.push(entry.id);
188188
});
189+
// Add XY plot row IDs
190+
rowIds.push(-100, -101);
189191
return rowIds;
190192
}
191193

@@ -292,6 +294,13 @@ export class TestDataProvider {
292294
});
293295
});
294296

297+
// Generate XY plot rows with fixed-time EKG-style data
298+
const xyRows: TimelineChart.TimeGraphRowModel[] = [
299+
this.createXYRow(-100, 'Heart Rate (EKG)', range, (t) => this.ekgWaveform(t)),
300+
this.createXYRow(-101, 'Respiration (EKG)', range, (t) => this.ekgWaveform(t * 0.4 + 0.1))
301+
];
302+
rows.push(...xyRows);
303+
295304
return {
296305
id: "",
297306
arrows,
@@ -300,4 +309,63 @@ export class TestDataProvider {
300309
totalLength: this.totalLength
301310
};
302311
}
312+
313+
private createXYRow(id: number, name: string, range: TimelineChart.TimeGraphRange, fn: (t: number) => number): TimelineChart.TimeGraphRowModel {
314+
// Generate points with fixed time positions spanning the full trace.
315+
// Only emit points that fall within the requested range (with small buffer).
316+
const totalLen = Number(this.totalLength);
317+
const rangeStart = Number(range.start);
318+
const rangeEnd = Number(range.end);
319+
const rangeLen = rangeEnd - rangeStart;
320+
321+
// Use enough points to get ~1 point per 2 pixels at current resolution
322+
const numPoints = Math.max(200, Math.min(2000, Math.round(rangeLen / (totalLen / 2000))));
323+
const step = rangeLen / (numPoints - 1);
324+
325+
const points: TimelineChart.TimeGraphXYPoint[] = [];
326+
for (let i = 0; i < numPoints; i++) {
327+
const timeNum = rangeStart + i * step;
328+
const t = timeNum / totalLen; // normalized position in full trace
329+
const time = BigInt(Math.round(timeNum));
330+
points.push({ time, value: Math.max(0, Math.min(1, fn(t))) });
331+
}
332+
return {
333+
id,
334+
name,
335+
range: { start: range.start, end: range.end },
336+
states: [],
337+
annotations: [],
338+
xySeries: [{ id: `xy_${id}`, label: name, color: id === -100 ? 0x22cc44 : 0x44aaff, points }],
339+
prevPossibleState: range.start,
340+
nextPossibleState: range.end
341+
};
342+
}
343+
344+
/** EKG-style waveform: flat baseline with periodic sharp QRS-like spikes */
345+
private ekgWaveform(t: number): number {
346+
// Repeat every cycle; ~20 beats across the full trace
347+
const cycles = 20;
348+
const phase = (t * cycles) % 1;
349+
350+
// P wave (small bump)
351+
if (phase > 0.1 && phase < 0.2) {
352+
const p = (phase - 0.1) / 0.1;
353+
return 0.5 + 0.08 * Math.sin(p * Math.PI);
354+
}
355+
// QRS complex (sharp spike)
356+
if (phase > 0.25 && phase < 0.45) {
357+
const q = (phase - 0.25) / 0.2;
358+
if (q < 0.2) return 0.5 - 0.1 * (q / 0.2); // Q dip
359+
if (q < 0.5) return 0.5 - 0.1 + 0.9 * ((q - 0.2) / 0.3); // R spike up
360+
if (q < 0.7) return 0.5 + 0.8 - 1.0 * ((q - 0.5) / 0.2); // R spike down
361+
return 0.5 - 0.2 + 0.2 * ((q - 0.7) / 0.3); // S recovery
362+
}
363+
// T wave (broad bump)
364+
if (phase > 0.55 && phase < 0.75) {
365+
const tw = (phase - 0.55) / 0.2;
366+
return 0.5 + 0.12 * Math.sin(tw * Math.PI);
367+
}
368+
// Baseline
369+
return 0.5;
370+
}
303371
}

timeline-chart/src/components/time-graph-row.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { TimeGraphComponent, TimeGraphElementPosition, TimeGraphParentComponent,
22
import { TimelineChart } from "../time-graph-model";
33
import { TimeGraphStateComponent } from "./time-graph-state";
44
import { TimeGraphAnnotationComponent } from "./time-graph-annotation";
5+
import { TimeGraphXYComponent } from "./time-graph-xy";
56

67
export interface TimeGraphRowStyle {
78
backgroundColor?: number
@@ -16,6 +17,7 @@ export class TimeGraphRow extends TimeGraphComponent<TimelineChart.TimeGraphRowM
1617
protected _providedModel: { range: TimelineChart.TimeGraphRange, resolution: number, filterExpressionsMap?: {[key: number]: string[]} } | undefined;
1718
protected _rowStateComponents: Map<string, TimeGraphStateComponent> = new Map();
1819
protected _rowAnnotationComponents: Map<string, TimeGraphAnnotationComponent> = new Map();
20+
protected _rowXYComponents: Map<string, TimeGraphXYComponent> = new Map();
1921

2022
constructor(
2123
id: string,
@@ -96,6 +98,16 @@ export class TimeGraphRow extends TimeGraphComponent<TimelineChart.TimeGraphRowM
9698
return this._rowAnnotationComponents.get(id);
9799
}
98100

101+
addXYSeries(xyComponent: TimeGraphXYComponent) {
102+
this._rowXYComponents.set(xyComponent.id, xyComponent);
103+
this.addChild(xyComponent);
104+
}
105+
106+
removeXYSeries(xyComponent: TimeGraphXYComponent) {
107+
this._rowXYComponents.delete(xyComponent.id);
108+
this.removeChild(xyComponent);
109+
}
110+
99111
get style() {
100112
return this._style;
101113
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { TimeGraphComponent } from "./time-graph-component";
2+
import { TimelineChart } from "../time-graph-model";
3+
4+
export interface TimeGraphXYStyle {
5+
color?: number
6+
opacity?: number
7+
thickness?: number
8+
}
9+
10+
export class TimeGraphXYComponent extends TimeGraphComponent<TimelineChart.TimeGraphXYSeries> {
11+
12+
constructor(
13+
id: string,
14+
model: TimelineChart.TimeGraphXYSeries,
15+
protected _xCoordinates: number[],
16+
protected _rowHeight: number,
17+
protected _rowY: number,
18+
protected _style: TimeGraphXYStyle = {}
19+
) {
20+
super(id, undefined, model);
21+
}
22+
23+
render() {
24+
const color = this._style.color ?? this._model.color ?? 0x0000ff;
25+
const opacity = this._style.opacity ?? 1;
26+
const thickness = this._style.thickness ?? 1.5;
27+
const points = this._model.points;
28+
29+
if (points.length < 2 || this._xCoordinates.length !== points.length) {
30+
return;
31+
}
32+
33+
this._displayObject.lineStyle(thickness, color, opacity);
34+
35+
const padding = 2;
36+
const drawHeight = this._rowHeight - padding * 2;
37+
38+
// moveTo first point
39+
const y0 = this._rowY + padding + drawHeight * (1 - points[0].value);
40+
this._displayObject.moveTo(this._xCoordinates[0], y0);
41+
42+
for (let i = 1; i < points.length; i++) {
43+
const y = this._rowY + padding + drawHeight * (1 - points[i].value);
44+
this._displayObject.lineTo(this._xCoordinates[i], y);
45+
}
46+
}
47+
}

timeline-chart/src/layer/time-graph-chart-rich-cursor.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as PIXI from "pixi.js-legacy";
33
import { TimeGraphChartLayer } from "./time-graph-chart-layer";
44
import { TimeGraphRowController } from "../time-graph-row-controller";
55
import { TimeGraphChart } from "./time-graph-chart";
6+
import { TimelineChart } from "../time-graph-model";
67
import { BIMath } from "../bigint-utils";
78

89
export class TimeGraphChartRichCursor extends TimeGraphChartLayer {
@@ -88,6 +89,57 @@ export class TimeGraphChartRichCursor extends TimeGraphChartLayer {
8889
this.drawTooltip(mouseX, centerY, label);
8990
}
9091
});
92+
93+
// Draw XY value indicators on rows with xySeries
94+
const rowComponents: Map<number, any> = (this.chartLayer as any).rowComponents;
95+
rowComponents.forEach((rowComponent) => {
96+
const model: TimelineChart.TimeGraphRowModel | undefined = rowComponent.model;
97+
if (!model?.xySeries?.length) return;
98+
99+
const y = rowComponent.position.y - this.rowController.verticalOffset;
100+
if (y + rowHeight < 0 || y > height) return;
101+
102+
const padding = 2;
103+
const drawHeight = rowHeight - padding * 2;
104+
105+
model.xySeries.forEach((series: TimelineChart.TimeGraphXYSeries) => {
106+
const value = this.interpolateXY(series.points, timestamp);
107+
if (value === undefined) return;
108+
109+
const dotY = y + padding + drawHeight * (1 - value);
110+
const color = series.color ?? 0x0000ff;
111+
112+
this.graphics.beginFill(color, 1);
113+
this.graphics.drawCircle(mouseX, dotY, 4);
114+
this.graphics.endFill();
115+
this.graphics.lineStyle(1, 0xffffff, 0.8);
116+
this.graphics.drawCircle(mouseX, dotY, 4);
117+
this.graphics.lineStyle(0);
118+
119+
const label = `${series.label ?? 'XY'}: ${value.toFixed(2)}`;
120+
this.drawTooltip(mouseX, dotY, label);
121+
});
122+
});
123+
}
124+
125+
/** Linearly interpolate the XY series value at the given timestamp */
126+
private interpolateXY(points: TimelineChart.TimeGraphXYPoint[], timestamp: bigint): number | undefined {
127+
if (points.length === 0) return undefined;
128+
if (timestamp <= points[0].time) return points[0].value;
129+
if (timestamp >= points[points.length - 1].time) return points[points.length - 1].value;
130+
131+
// Binary search for the bracketing interval
132+
let lo = 0, hi = points.length - 1;
133+
while (hi - lo > 1) {
134+
const mid = (lo + hi) >> 1;
135+
if (points[mid].time <= timestamp) lo = mid;
136+
else hi = mid;
137+
}
138+
const p0 = points[lo], p1 = points[hi];
139+
const dt = Number(p1.time - p0.time);
140+
if (dt === 0) return p0.value;
141+
const t = Number(timestamp - p0.time) / dt;
142+
return p0.value + t * (p1.value - p0.value);
91143
}
92144

93145
private removeTooltips() {

timeline-chart/src/layer/time-graph-chart.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { TimeGraphComponent } from "../components/time-graph-component";
44
import { TimeGraphRectangle } from "../components/time-graph-rectangle";
55
import { TimeGraphRow, TimeGraphRowStyle } from "../components/time-graph-row";
66
import { TimeGraphStateComponent, TimeGraphStateStyle } from "../components/time-graph-state";
7+
import { TimeGraphXYComponent } from "../components/time-graph-xy";
78
import { TimelineChart } from "../time-graph-model";
89
import { TimeGraphRowController } from "../time-graph-row-controller";
910
import { TimeGraphChartLayer } from "./time-graph-chart-layer";
@@ -788,6 +789,20 @@ export class TimeGraphChart extends TimeGraphChartLayer {
788789
rowComponent.addAnnotation(el);
789790
}
790791
});
792+
if (row.xySeries && row.states.length === 0) {
793+
row.xySeries.forEach((series: TimelineChart.TimeGraphXYSeries) => {
794+
const xCoords = series.points.map(p => this.getWorldPixel(p.time));
795+
const xy = new TimeGraphXYComponent(
796+
series.id,
797+
series,
798+
xCoords,
799+
rowComponent.height,
800+
rowComponent.position.y,
801+
{ color: series.color }
802+
);
803+
rowComponent.addXYSeries(xy);
804+
});
805+
}
791806
rowComponent.providedModel = providedModel;
792807
}
793808

timeline-chart/src/time-graph-model.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export namespace TimelineChart {
1919
range: TimeGraphRange
2020
states: TimeGraphState[]
2121
annotations: TimeGraphAnnotation[]
22+
/** Optional XY series to render as line plots within this row (values normalized 0-1) */
23+
xySeries?: TimeGraphXYSeries[]
2224
selected?: boolean
2325
readonly data?: { [key: string]: any }
2426
prevPossibleState: bigint
@@ -56,4 +58,17 @@ export namespace TimelineChart {
5658
selected?: boolean
5759
readonly data?: { [key: string]: any }
5860
}
61+
62+
export interface TimeGraphXYPoint {
63+
readonly time: bigint
64+
/** Normalized value between 0 and 1 */
65+
readonly value: number
66+
}
67+
68+
export interface TimeGraphXYSeries {
69+
readonly id: string
70+
readonly label?: string
71+
readonly color?: number
72+
readonly points: TimeGraphXYPoint[]
73+
}
5974
}

0 commit comments

Comments
 (0)