summaryrefslogtreecommitdiff
path: root/web-timeplot/src/plot/timeplot-view.js
diff options
context:
space:
mode:
Diffstat (limited to 'web-timeplot/src/plot/timeplot-view.js')
-rw-r--r--web-timeplot/src/plot/timeplot-view.js234
1 files changed, 234 insertions, 0 deletions
diff --git a/web-timeplot/src/plot/timeplot-view.js b/web-timeplot/src/plot/timeplot-view.js
new file mode 100644
index 0000000..9f00b29
--- /dev/null
+++ b/web-timeplot/src/plot/timeplot-view.js
@@ -0,0 +1,234 @@
+import { Application, Container, Graphics, Text } from 'pixi.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, onHover }) {
+ this.host = host;
+ this.onHover = onHover;
+ this.app = new Application();
+ this.container = new Container();
+ this.background = new Graphics();
+ this.grid = new Graphics();
+ this.line = new Graphics();
+ this.points = new Graphics();
+ this.crosshair = new Graphics();
+ this.overlay = 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.screenPoints = [];
+ this.bounds = { width: 100, height: 100 };
+ this.hoverRadiusPx = 20;
+ this.pointer = null;
+ }
+
+ 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.line);
+ this.container.addChild(this.points);
+ this.container.addChild(this.crosshair);
+ this.container.addChild(this.overlay);
+ this.overlay.addChild(this.titleText);
+ this.overlay.addChild(this.subtitleText);
+ this.host.appendChild(this.app.canvas);
+ this.attachPointerListeners();
+
+ return rendererPreference;
+ }
+
+ attachPointerListeners() {
+ this.host.addEventListener('pointerleave', () => {
+ this.pointer = null;
+ this.crosshair.clear();
+ this.onHover(null);
+ });
+
+ this.host.addEventListener('pointermove', (event) => {
+ const rect = this.host.getBoundingClientRect();
+ this.pointer = {
+ x: event.clientX - rect.left,
+ y: event.clientY - rect.top,
+ };
+ });
+ }
+
+ resize() {
+ this.bounds = {
+ width: this.host.clientWidth,
+ height: this.host.clientHeight,
+ };
+ }
+
+ render(state, points) {
+ this.resize();
+ this.renderFrame(state, points);
+ this.renderHover(state);
+ }
+
+ renderFrame(state, points) {
+ const width = this.bounds.width;
+ const height = this.bounds.height;
+ const padding = { top: 68, right: 24, bottom: 28, left: 52 };
+ 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,
+ 24,
+ { color: 0x050c16, alpha: 1 },
+ { color: 0x22344f, 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;
+ this.grid.moveTo(px, padding.top);
+ this.grid.lineTo(px, padding.top + plotHeight);
+ this.grid.stroke({ color: gridColor, width: 1, alpha: 0.65 });
+ }
+
+ for (let y = 0; y <= 6; y += 1) {
+ const py = padding.top + (plotHeight * y) / 6;
+ this.grid.moveTo(padding.left, py);
+ this.grid.lineTo(padding.left + plotWidth, py);
+ this.grid.stroke({ color: gridColor, width: 1, alpha: 0.65 });
+ }
+ }
+
+ 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;
+
+ this.screenPoints.push({ ...point, x, y });
+
+ if (index === 0) {
+ this.line.moveTo(x, y);
+ } else {
+ this.line.lineTo(x, y);
+ }
+ });
+
+ this.line.stroke({
+ color: 0x7af0ff,
+ width: 2.25,
+ alpha: 0.95,
+ cap: 'round',
+ join: 'round',
+ });
+
+ 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.titleText.text = 'TimePlot viewport';
+ this.titleText.x = 18;
+ this.titleText.y = 16;
+
+ this.subtitleText.text = `${state.source.preset} • ${state.source.sampleRateHz} Hz • ${points.length} visible points`;
+ this.subtitleText.x = 18;
+ this.subtitleText.y = 38;
+ }
+
+ 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,
+ });
+ }
+
+ destroy() {
+ this.app.destroy(true, { children: true });
+ }
+}