summaryrefslogtreecommitdiff
path: root/src/plot/timeplot-view.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/plot/timeplot-view.js')
-rw-r--r--src/plot/timeplot-view.js442
1 files changed, 442 insertions, 0 deletions
diff --git a/src/plot/timeplot-view.js b/src/plot/timeplot-view.js
new file mode 100644
index 0000000..ce90a1f
--- /dev/null
+++ b/src/plot/timeplot-view.js
@@ -0,0 +1,442 @@
+import { Application, Container, Graphics, Text } from 'pixi.js';
+import { formatDuration, formatValue, formatWallClock } from '../utils-format.js';
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+function roundRect(graphics, x, y, width, height, radius, fill, stroke) {
+ graphics.roundRect(x, y, width, height, radius);
+ graphics.fill(fill);
+ graphics.stroke(stroke);
+}
+
+export class TimeplotView {
+ constructor({ host, panelId = 'primary', title = 'Vertical plot', subtitle = null, showReadouts = true, lineColor = 0x9fd1ff, pointColor = 0xe7f2ff }) {
+ this.host = host;
+ this.panelId = panelId;
+ this.panelTitle = title;
+ this.panelSubtitle = subtitle;
+ this.showReadouts = showReadouts;
+ this.lineColor = lineColor;
+ this.pointColor = pointColor;
+ this.app = new Application();
+ this.container = new Container();
+ this.background = new Graphics();
+ this.grid = new Graphics();
+ this.axes = new Graphics();
+ this.line = new Graphics();
+ this.points = new Graphics();
+ this.crosshair = new Graphics();
+ this.overlay = new Container();
+ this.readoutBackground = new Graphics();
+ this.axisLabelLayer = new Container();
+ this.titleText = new Text({
+ text: 'Plot viewport',
+ style: {
+ fill: 0xeef4ff,
+ fontFamily: 'Inter, sans-serif',
+ fontSize: 16,
+ },
+ });
+ this.subtitleText = new Text({
+ text: 'Synthetic data stream',
+ style: {
+ fill: 0x8ca3c7,
+ fontFamily: 'Inter, sans-serif',
+ fontSize: 12,
+ },
+ });
+ this.realTimeText = new Text({
+ text: '',
+ style: {
+ fill: 0xe8eef7,
+ fontFamily: 'IBM Plex Mono, monospace',
+ fontSize: 11,
+ },
+ });
+ this.plotTimeText = new Text({
+ text: '',
+ style: {
+ fill: 0xe8eef7,
+ fontFamily: 'IBM Plex Mono, monospace',
+ fontSize: 11,
+ },
+ });
+ this.axisTitleText = new Text({
+ text: '',
+ style: {
+ fill: 0x90a0b7,
+ fontFamily: 'Inter, sans-serif',
+ fontSize: 10,
+ fontWeight: '600',
+ letterSpacing: 1.5,
+ },
+ });
+ this.screenPoints = [];
+ this.bounds = { width: 100, height: 100 };
+ this.hoverRadiusPx = 20;
+ this.pointer = null;
+ this.lastPointerEventAt = 0;
+ this.axisLabels = [];
+ }
+
+ async init() {
+ const rendererPreference = navigator.gpu ? 'webgpu' : 'webgl';
+ await this.app.init({
+ preference: rendererPreference,
+ resizeTo: this.host,
+ antialias: true,
+ backgroundAlpha: 0,
+ resolution: Math.min(window.devicePixelRatio || 1, 2),
+ });
+
+ this.app.stage.addChild(this.container);
+ this.container.addChild(this.background);
+ this.container.addChild(this.grid);
+ this.container.addChild(this.axes);
+ this.container.addChild(this.line);
+ this.container.addChild(this.points);
+ this.container.addChild(this.crosshair);
+ this.container.addChild(this.overlay);
+ this.overlay.addChild(this.readoutBackground);
+ this.overlay.addChild(this.axisLabelLayer);
+ this.overlay.addChild(this.titleText);
+ this.overlay.addChild(this.subtitleText);
+ this.overlay.addChild(this.realTimeText);
+ this.overlay.addChild(this.plotTimeText);
+ this.overlay.addChild(this.axisTitleText);
+ this.host.appendChild(this.app.canvas);
+ this.attachPointerListeners();
+
+ return rendererPreference;
+ }
+
+ attachPointerListeners() {
+ this.host.addEventListener('pointerleave', () => {
+ this.pointer = null;
+ this.lastPointerEventAt = performance.now();
+ });
+
+ this.host.addEventListener('pointermove', (event) => {
+ const rect = this.host.getBoundingClientRect();
+ this.pointer = {
+ x: event.clientX - rect.left,
+ y: event.clientY - rect.top,
+ };
+ this.lastPointerEventAt = performance.now();
+ });
+ }
+
+ resize() {
+ this.bounds = {
+ width: this.host.clientWidth,
+ height: this.host.clientHeight,
+ };
+ }
+
+ render(state, points) {
+ this.resize();
+ this.renderFrame(state, points);
+ this.clearHover();
+ }
+
+ clearHover() {
+ this.crosshair.clear();
+ }
+
+ getHoverCandidate() {
+ if (!this.pointer || this.screenPoints.length === 0) {
+ return null;
+ }
+
+ let nearestPoint = null;
+ let nearestDistance = Infinity;
+
+ for (const point of this.screenPoints) {
+ const dx = point.x - this.pointer.x;
+ const dy = point.y - this.pointer.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+ if (distance < nearestDistance) {
+ nearestPoint = point;
+ nearestDistance = distance;
+ }
+ }
+
+ if (!nearestPoint || nearestDistance > this.hoverRadiusPx) {
+ return null;
+ }
+
+ return {
+ panelId: this.panelId,
+ point: nearestPoint,
+ x: clamp(nearestPoint.x, 0, this.bounds.width),
+ y: clamp(nearestPoint.y, 0, this.bounds.height),
+ pointerX: this.pointer.x,
+ pointerY: this.pointer.y,
+ distance: nearestDistance,
+ lastPointerEventAt: this.lastPointerEventAt,
+ };
+ }
+
+ hasPointer() {
+ return this.pointer !== null;
+ }
+
+ findNearestScreenPointByTime(timeMs) {
+ if (this.screenPoints.length === 0) {
+ return null;
+ }
+
+ let nearestPoint = null;
+ let nearestDelta = Infinity;
+
+ for (const point of this.screenPoints) {
+ const delta = Math.abs(point.timeMs - timeMs);
+ if (delta < nearestDelta) {
+ nearestPoint = point;
+ nearestDelta = delta;
+ }
+ }
+
+ return nearestPoint;
+ }
+
+ renderLinkedHover(hoverPoint) {
+ this.crosshair.clear();
+
+ if (!hoverPoint) {
+ return;
+ }
+
+ const x = clamp(hoverPoint.x, 0, this.bounds.width);
+ const y = clamp(hoverPoint.y, 0, this.bounds.height);
+
+ this.crosshair.moveTo(x, 0);
+ this.crosshair.lineTo(x, this.bounds.height);
+ this.crosshair.moveTo(0, y);
+ this.crosshair.lineTo(this.bounds.width, y);
+ this.crosshair.stroke({ color: 0x8cb8ff, width: 1, alpha: 0.24 });
+ this.crosshair.rect(x - 4, y - 4, 8, 8);
+ this.crosshair.stroke({ color: 0xffffff, width: 1.5, alpha: 0.95 });
+ }
+
+ ensureAxisLabelCount(count) {
+ while (this.axisLabels.length < count) {
+ const label = new Text({
+ text: '',
+ style: {
+ fill: 0x90a0b7,
+ fontFamily: 'IBM Plex Mono, monospace',
+ fontSize: 10,
+ },
+ });
+ this.axisLabels.push(label);
+ this.axisLabelLayer.addChild(label);
+ }
+
+ while (this.axisLabels.length > count) {
+ const label = this.axisLabels.pop();
+ this.axisLabelLayer.removeChild(label);
+ label.destroy();
+ }
+ }
+
+ renderAxes({ padding, plotWidth, plotHeight, minTime, maxTime, minValue, maxValue, width }) {
+ const axisColor = 0x3e4c5f;
+ const tickColor = 0x4f627a;
+ const timeTickCount = 5;
+ const valueTickCount = 5;
+ const labels = [];
+
+ this.axes.clear();
+ this.axes.moveTo(padding.left, padding.top);
+ this.axes.lineTo(padding.left, padding.top + plotHeight);
+ this.axes.lineTo(padding.left + plotWidth, padding.top + plotHeight);
+ this.axes.stroke({ color: axisColor, width: 1, alpha: 1 });
+
+ for (let index = 0; index < timeTickCount; index += 1) {
+ const ratio = timeTickCount === 1 ? 0 : index / (timeTickCount - 1);
+ const y = padding.top + ratio * plotHeight;
+ const timeMs = minTime + ratio * (maxTime - minTime);
+
+ this.axes.moveTo(padding.left - 8, y);
+ this.axes.lineTo(padding.left, y);
+ this.axes.stroke({ color: tickColor, width: 1, alpha: 1 });
+
+ labels.push({
+ text: formatDuration(timeMs),
+ x: 14,
+ y: y - 7,
+ anchorX: 0,
+ });
+ }
+
+ for (let index = 0; index < valueTickCount; index += 1) {
+ const ratio = valueTickCount === 1 ? 0 : index / (valueTickCount - 1);
+ const x = padding.left + ratio * plotWidth;
+ const value = minValue + ratio * (maxValue - minValue);
+
+ this.axes.moveTo(x, padding.top + plotHeight);
+ this.axes.lineTo(x, padding.top + plotHeight + 8);
+ this.axes.stroke({ color: tickColor, width: 1, alpha: 1 });
+
+ labels.push({
+ text: formatValue(value),
+ x,
+ y: padding.top + plotHeight + 10,
+ anchorX: 0.5,
+ });
+ }
+
+ this.ensureAxisLabelCount(labels.length);
+ labels.forEach((config, index) => {
+ const label = this.axisLabels[index];
+ label.text = config.text;
+ label.x = config.x;
+ label.y = config.y;
+ label.anchor.set(config.anchorX, 0);
+ });
+
+ this.axisTitleText.text = 'TIME';
+ this.axisTitleText.x = 18;
+ this.axisTitleText.y = padding.top - 18;
+ this.axisTitleText.rotation = 0;
+
+ this.axes.moveTo(padding.left + plotWidth, padding.top + plotHeight);
+ this.axes.lineTo(width - 14, padding.top + plotHeight);
+ this.axes.stroke({ color: 0x202a35, width: 1, alpha: 1 });
+ }
+
+ renderReadouts(state, width) {
+ if (!this.showReadouts) {
+ this.readoutBackground.clear();
+ this.realTimeText.text = '';
+ this.plotTimeText.text = '';
+ return;
+ }
+
+ const boxWidth = 168;
+ const boxHeight = 22;
+ const gap = 6;
+ const left = width - boxWidth - 18;
+ const top = 14;
+
+ this.readoutBackground.clear();
+ this.readoutBackground.rect(left, top, boxWidth, boxHeight);
+ this.readoutBackground.fill({ color: 0x10161d, alpha: 1 });
+ this.readoutBackground.stroke({ color: 0x2f3c4d, width: 1, alpha: 1 });
+ this.readoutBackground.rect(left, top + boxHeight + gap, boxWidth, boxHeight);
+ this.readoutBackground.fill({ color: 0x10161d, alpha: 1 });
+ this.readoutBackground.stroke({ color: 0x2f3c4d, width: 1, alpha: 1 });
+
+ this.realTimeText.text = `REAL ${formatWallClock(state.time.realNowMs)}`;
+ this.realTimeText.x = left + 10;
+ this.realTimeText.y = top + 5;
+
+ this.plotTimeText.text = `PLOT ${formatDuration(state.time.plotTimeMs)}`;
+ this.plotTimeText.x = left + 10;
+ this.plotTimeText.y = top + boxHeight + gap + 5;
+ }
+
+ renderFrame(state, points) {
+ const width = this.bounds.width;
+ const height = this.bounds.height;
+ const padding = { top: 72, right: 28, bottom: 46, left: 88 };
+ const plotWidth = Math.max(10, width - padding.left - padding.right);
+ const plotHeight = Math.max(10, height - padding.top - padding.bottom);
+ const minTime = state.time.plotTimeMs - state.plot.windowDurationMs;
+ const maxTime = Math.max(state.time.plotTimeMs, minTime + 1);
+ const { min: minValue, max: maxValue } = state.plot.valueRange;
+ const valueSpan = Math.max(0.001, maxValue - minValue);
+
+ this.background.clear();
+ roundRect(
+ this.background,
+ 0,
+ 0,
+ width,
+ height,
+ 6,
+ { color: 0x05070b, alpha: 1 },
+ { color: 0x2c3b4d, width: 1 },
+ );
+
+ this.grid.clear();
+ if (state.plot.showGrid) {
+ const gridColor = 0x21344a;
+ for (let x = 0; x <= 6; x += 1) {
+ const px = padding.left + (plotWidth * x) / 6;
+ this.grid.moveTo(px, padding.top);
+ this.grid.lineTo(px, padding.top + plotHeight);
+ this.grid.stroke({ color: gridColor, width: 1, alpha: 0.85 });
+ }
+
+ for (let y = 0; y <= 8; y += 1) {
+ const py = padding.top + (plotHeight * y) / 8;
+ this.grid.moveTo(padding.left, py);
+ this.grid.lineTo(padding.left + plotWidth, py);
+ this.grid.stroke({ color: gridColor, width: 1, alpha: 0.85 });
+ }
+ }
+
+ this.renderAxes({
+ padding,
+ plotWidth,
+ plotHeight,
+ minTime,
+ maxTime,
+ minValue,
+ maxValue,
+ width,
+ });
+
+ this.line.clear();
+ this.points.clear();
+ this.screenPoints = [];
+
+ if (points.length > 0) {
+ points.forEach((point, index) => {
+ const x = padding.left + ((point.value - minValue) / valueSpan) * plotWidth;
+ const y = padding.top + ((point.timeMs - minTime) / (maxTime - minTime)) * plotHeight;
+
+ this.screenPoints.push({ ...point, x, y });
+
+ if (index === 0) {
+ this.line.moveTo(x, y);
+ } else {
+ this.line.lineTo(x, y);
+ }
+ });
+
+ this.line.stroke({
+ color: this.lineColor,
+ width: 2,
+ alpha: 0.96,
+ cap: 'square',
+ join: 'miter',
+ });
+
+ if (state.plot.showPoints) {
+ for (const point of this.screenPoints) {
+ this.points.rect(point.x - 2, point.y - 2, 4, 4);
+ this.points.fill({ color: this.pointColor, alpha: 0.92 });
+ }
+ }
+ }
+
+ this.titleText.text = this.panelTitle;
+ this.titleText.x = 20;
+ this.titleText.y = 14;
+
+ this.subtitleText.text = this.panelSubtitle ?? `value → ${state.source.preset} · ${state.source.sampleRateHz} hz · time ↓`;
+ this.subtitleText.x = 20;
+ this.subtitleText.y = 36;
+
+ this.renderReadouts(state, width);
+ }
+
+ destroy() {
+ this.app.destroy(true, { children: true });
+ }
+}