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