summaryrefslogtreecommitdiff
path: root/web-timeplot/src/plot
diff options
context:
space:
mode:
Diffstat (limited to 'web-timeplot/src/plot')
-rw-r--r--web-timeplot/src/plot/timeplot-view.js360
1 files changed, 284 insertions, 76 deletions
diff --git a/web-timeplot/src/plot/timeplot-view.js b/web-timeplot/src/plot/timeplot-view.js
index 9f00b29..ce90a1f 100644
--- a/web-timeplot/src/plot/timeplot-view.js
+++ b/web-timeplot/src/plot/timeplot-view.js
@@ -1,4 +1,5 @@
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));
@@ -11,17 +12,25 @@ function roundRect(graphics, x, y, width, height, radius, fill, stroke) {
}
export class TimeplotView {
- constructor({ host, onHover }) {
+ constructor({ host, panelId = 'primary', title = 'Vertical plot', subtitle = null, showReadouts = true, lineColor = 0x9fd1ff, pointColor = 0xe7f2ff }) {
this.host = host;
- this.onHover = onHover;
+ 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: {
@@ -38,10 +47,38 @@ export class TimeplotView {
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() {
@@ -57,12 +94,18 @@ export class TimeplotView {
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();
@@ -72,8 +115,7 @@ export class TimeplotView {
attachPointerListeners() {
this.host.addEventListener('pointerleave', () => {
this.pointer = null;
- this.crosshair.clear();
- this.onHover(null);
+ this.lastPointerEventAt = performance.now();
});
this.host.addEventListener('pointermove', (event) => {
@@ -82,6 +124,7 @@ export class TimeplotView {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
+ this.lastPointerEventAt = performance.now();
});
}
@@ -95,13 +138,211 @@ export class TimeplotView {
render(state, points) {
this.resize();
this.renderFrame(state, points);
- this.renderHover(state);
+ 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: 68, right: 24, bottom: 28, left: 52 };
+ 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;
@@ -116,38 +357,48 @@ export class TimeplotView {
0,
width,
height,
- 24,
- { color: 0x050c16, alpha: 1 },
- { color: 0x22344f, width: 1 },
+ 6,
+ { color: 0x05070b, alpha: 1 },
+ { color: 0x2c3b4d, width: 1 },
);
this.grid.clear();
if (state.plot.showGrid) {
- const gridColor = 0x1d3555;
- for (let x = 0; x <= 8; x += 1) {
- const px = padding.left + (plotWidth * x) / 8;
+ 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.65 });
+ this.grid.stroke({ color: gridColor, width: 1, alpha: 0.85 });
}
- for (let y = 0; y <= 6; y += 1) {
- const py = padding.top + (plotHeight * y) / 6;
+ 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.65 });
+ 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.timeMs - minTime) / (maxTime - minTime)) * plotWidth;
- const normalizedValue = (point.value - minValue) / valueSpan;
- const y = padding.top + (1 - normalizedValue) * plotHeight;
+ 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 });
@@ -159,73 +410,30 @@ export class TimeplotView {
});
this.line.stroke({
- color: 0x7af0ff,
- width: 2.25,
- alpha: 0.95,
- cap: 'round',
- join: 'round',
+ color: this.lineColor,
+ width: 2,
+ alpha: 0.96,
+ cap: 'square',
+ join: 'miter',
});
if (state.plot.showPoints) {
for (const point of this.screenPoints) {
- this.points.circle(point.x, point.y, 2.5);
- this.points.fill({ color: 0xc4f8ff, alpha: 0.95 });
+ this.points.rect(point.x - 2, point.y - 2, 4, 4);
+ this.points.fill({ color: this.pointColor, alpha: 0.92 });
}
}
}
- this.titleText.text = 'TimePlot viewport';
- this.titleText.x = 18;
- this.titleText.y = 16;
+ this.titleText.text = this.panelTitle;
+ this.titleText.x = 20;
+ this.titleText.y = 14;
- this.subtitleText.text = `${state.source.preset} • ${state.source.sampleRateHz} Hz • ${points.length} visible points`;
- this.subtitleText.x = 18;
- this.subtitleText.y = 38;
- }
+ this.subtitleText.text = this.panelSubtitle ?? `value → ${state.source.preset} · ${state.source.sampleRateHz} hz · time ↓`;
+ this.subtitleText.x = 20;
+ this.subtitleText.y = 36;
- renderHover(state) {
- this.crosshair.clear();
-
- if (!this.pointer || this.screenPoints.length === 0) {
- this.onHover(null);
- return;
- }
-
- 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) {
- this.onHover(null);
- return;
- }
-
- const x = clamp(nearestPoint.x, 0, this.bounds.width);
- const y = clamp(nearestPoint.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: 0x6ea8ff, width: 1, alpha: 0.22 });
- this.crosshair.circle(x, y, 5);
- this.crosshair.stroke({ color: 0xffffff, width: 2, alpha: 0.95 });
-
- this.onHover({
- x,
- y,
- point: nearestPoint,
- paused: state.time.paused,
- });
+ this.renderReadouts(state, width);
}
destroy() {