diff options
Diffstat (limited to 'web-timeplot/src/plot/timeplot-view.js')
| -rw-r--r-- | web-timeplot/src/plot/timeplot-view.js | 234 |
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 }); + } +} |
