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