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 }); } }