diff options
| author | grothedev <grothedev@gmail.com> | 2025-10-02 20:13:46 -0400 |
|---|---|---|
| committer | grothedev <grothedev@gmail.com> | 2025-10-02 20:13:46 -0400 |
| commit | 39124e6854b2740e9f30c058e873355dfd739a4e (patch) | |
| tree | 5cd1fb7986b1aebbde79ce478ecfc0b077690414 /web-timeplot/src | |
| parent | 836459dce3f50767d41978be4a2f7ac788e6a9ba (diff) | |
starting with pixijs implementation
Diffstat (limited to 'web-timeplot/src')
| -rw-r--r-- | web-timeplot/src/main.js | 220 | ||||
| -rw-r--r-- | web-timeplot/src/metrics.js | 142 | ||||
| -rw-r--r-- | web-timeplot/src/waterfall.js | 192 |
3 files changed, 554 insertions, 0 deletions
diff --git a/web-timeplot/src/main.js b/web-timeplot/src/main.js new file mode 100644 index 0000000..0d065f7 --- /dev/null +++ b/web-timeplot/src/main.js @@ -0,0 +1,220 @@ +import { Application } from 'pixi.js'; +import { WaterfallGraph } from './waterfall.js'; +import { PerformanceMetrics } from './metrics.js'; + +class TimePlot { + constructor() { + this.app = null; + this.graphs = []; + this.metrics = new PerformanceMetrics(60, 10000); + this.showMetrics = true; + this.showGrid = true; + this.time = 0; + } + + async init() { + const container = document.getElementById('canvas-container'); + + // Try WebGPU first, fallback to WebGL + let preferredRenderer = 'webgpu'; + let preference = 'webgpu'; + + // Check WebGPU availability + if (!navigator.gpu) { + console.log('WebGPU not available, using WebGL'); + preferredRenderer = 'webgl'; + preference = 'webgl'; + } + + try { + this.app = new Application(); + + await this.app.init({ + preference: preference, + width: window.innerWidth, + height: window.innerHeight - 60, // Account for controls + backgroundColor: 0x1a1a26, + antialias: true, + autoDensity: true, + resolution: window.devicePixelRatio || 1, + }); + + container.appendChild(this.app.canvas); + + // Display actual renderer type + const rendererType = this.app.renderer.type; + document.getElementById('renderer-type').textContent = rendererType; + console.log('Using renderer:', rendererType); + + // Create two waterfall graphs side by side + this.setupGraphs(); + this.setupControls(); + this.setupKeyboard(); + this.handleResize(); + + // Start animation loop + this.app.ticker.add(() => this.update()); + + console.log('TimePlot initialized successfully'); + + } catch (error) { + console.error('Failed to initialize PixiJS:', error); + + // Ultimate fallback + if (preference === 'webgpu') { + console.log('Retrying with WebGL...'); + this.init(); // Retry will use WebGL + } + } + } + + setupGraphs() { + const width = this.app.screen.width; + const height = this.app.screen.height; + + // Left graph + const graph1 = new WaterfallGraph({ + x: 0, + y: 0, + width: width / 2, + height: height, + title: 'Frequency vs Time', + color: 0xff6666, + }); + + // Right graph + const graph2 = new WaterfallGraph({ + x: width / 2, + y: 0, + width: width / 2, + height: height, + title: 'Position vs Time', + color: 0x66ff66, + }); + + this.graphs.push(graph1, graph2); + this.graphs.forEach(graph => { + this.app.stage.addChild(graph.container); + }); + } + + setupControls() { + document.getElementById('toggle-grid').addEventListener('click', () => { + this.toggleGrid(); + }); + + document.getElementById('toggle-metrics').addEventListener('click', () => { + this.toggleMetrics(); + }); + + document.getElementById('export-metrics').addEventListener('click', () => { + this.exportMetrics(); + }); + + this.updateControlButtons(); + } + + setupKeyboard() { + window.addEventListener('keydown', (e) => { + switch(e.key.toLowerCase()) { + case 'g': + this.toggleGrid(); + break; + case 'm': + this.toggleMetrics(); + break; + case 'e': + this.exportMetrics(); + break; + } + }); + } + + handleResize() { + window.addEventListener('resize', () => { + const width = window.innerWidth; + const height = window.innerHeight - 60; + + this.app.renderer.resize(width, height); + + // Update graphs + this.graphs[0]?.resize(0, 0, width / 2, height); + this.graphs[1]?.resize(width / 2, 0, width / 2, height); + }); + } + + update() { + this.metrics.beginFrame(); + this.metrics.beginUpdate(); + + this.time += 0.016; // ~60fps increment + + // Update each graph + this.graphs.forEach((graph, idx) => { + graph.update(this.time, idx); + }); + + const updateMs = this.metrics.endUpdate(); + + this.metrics.beginRender(); + + // Rendering happens automatically via PixiJS + + const renderMs = this.metrics.endRender(); + + // Calculate total vertices (estimate) + const vertexCount = this.graphs.reduce((sum, g) => sum + g.getVertexCount(), 0); + const lineCount = this.graphs.reduce((sum, g) => sum + g.getLineCount(), 0); + + this.metrics.endFrame(updateMs, renderMs, vertexCount, lineCount); + + // Update UI + if (this.showMetrics) { + this.updateMetricsDisplay(); + } + } + + toggleGrid() { + this.showGrid = !this.showGrid; + this.graphs.forEach(graph => graph.setGridVisible(this.showGrid)); + this.updateControlButtons(); + console.log('Grid:', this.showGrid ? 'ON' : 'OFF'); + } + + toggleMetrics() { + this.showMetrics = !this.showMetrics; + this.updateControlButtons(); + console.log('Metrics:', this.showMetrics ? 'ON' : 'OFF'); + } + + exportMetrics() { + const csv = this.metrics.exportToCSV(); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `timeplot-metrics-${Date.now()}.csv`; + a.click(); + URL.revokeObjectURL(url); + console.log('Metrics exported'); + } + + updateMetricsDisplay() { + const display = document.getElementById('metrics-display'); + if (display) { + display.textContent = this.metrics.formatSummary(); + } + } + + updateControlButtons() { + const gridBtn = document.getElementById('toggle-grid'); + const metricsBtn = document.getElementById('toggle-metrics'); + + gridBtn.classList.toggle('active', this.showGrid); + metricsBtn.classList.toggle('active', this.showMetrics); + } +} + +// Initialize the application +const app = new TimePlot(); +app.init().catch(console.error); diff --git a/web-timeplot/src/metrics.js b/web-timeplot/src/metrics.js new file mode 100644 index 0000000..fdda10a --- /dev/null +++ b/web-timeplot/src/metrics.js @@ -0,0 +1,142 @@ +/** + * RollingAverage - Maintains a rolling window of values for smooth averaging + */ +class RollingAverage { + constructor(capacity) { + this.values = []; + this.capacity = capacity; + this.sum = 0; + } + + push(value) { + if (this.values.length >= this.capacity) { + const old = this.values.shift(); + this.sum -= old; + } + this.values.push(value); + this.sum += value; + } + + average() { + return this.values.length > 0 ? this.sum / this.values.length : 0; + } + + min() { + return this.values.length > 0 ? Math.min(...this.values) : 0; + } + + max() { + return this.values.length > 0 ? Math.max(...this.values) : 0; + } +} + +/** + * PerformanceMetrics - Tracks and analyzes frame performance + */ +export class PerformanceMetrics { + constructor(rollingWindow = 60, historyCapacity = 10000) { + // Rolling averages + this.frameTime = new RollingAverage(rollingWindow); + this.updateTime = new RollingAverage(rollingWindow); + this.renderTime = new RollingAverage(rollingWindow); + this.vertexCount = new RollingAverage(rollingWindow); + this.lineCount = new RollingAverage(rollingWindow); + + // History for export + this.history = []; + this.historyCapacity = historyCapacity; + + // Frame timing + this.frameStart = 0; + this.updateStart = 0; + this.renderStart = 0; + + this.totalFrames = 0; + } + + beginFrame() { + this.frameStart = performance.now(); + } + + beginUpdate() { + this.updateStart = performance.now(); + } + + endUpdate() { + const duration = performance.now() - this.updateStart; + return duration; + } + + beginRender() { + this.renderStart = performance.now(); + } + + endRender() { + const duration = performance.now() - this.renderStart; + return duration; + } + + endFrame(updateMs, renderMs, vertexCount, lineCount) { + const totalMs = performance.now() - this.frameStart; + + // Update rolling averages + this.frameTime.push(totalMs); + this.updateTime.push(updateMs); + this.renderTime.push(renderMs); + this.vertexCount.push(vertexCount); + this.lineCount.push(lineCount); + + // Store in history + const record = { + frame: this.totalFrames, + totalMs, + updateMs, + renderMs, + vertexCount, + lineCount, + fps: totalMs > 0 ? 1000 / totalMs : 0, + }; + + if (this.history.length >= this.historyCapacity) { + this.history.shift(); + } + this.history.push(record); + + this.totalFrames++; + } + + getFPS() { + const avg = this.frameTime.average(); + return avg > 0 ? 1000 / avg : 0; + } + + getMinFPS() { + const max = this.frameTime.max(); + return max > 0 ? 1000 / max : 0; + } + + getMaxFPS() { + const min = this.frameTime.min(); + return min > 0 ? 1000 / min : 0; + } + + formatSummary() { + return `FPS: ${this.getFPS().toFixed(1)} (min: ${this.getMinFPS().toFixed(1)}, max: ${this.getMaxFPS().toFixed(1)}) | ` + + `Frame: ${this.frameTime.average().toFixed(2)}ms | ` + + `Update: ${this.updateTime.average().toFixed(2)}ms | ` + + `Render: ${this.renderTime.average().toFixed(2)}ms | ` + + `Vertices: ${Math.round(this.vertexCount.average())} | ` + + `Lines: ${Math.round(this.lineCount.average())}`; + } + + exportToCSV() { + let csv = 'frame,total_ms,update_ms,render_ms,vertex_count,line_count,fps\n'; + + for (const record of this.history) { + csv += `${record.frame},${record.totalMs},${record.updateMs},${record.renderMs},` + + `${record.vertexCount},${record.lineCount},${record.fps}\n`; + } + + return csv; + } +} diff --git a/web-timeplot/src/waterfall.js b/web-timeplot/src/waterfall.js new file mode 100644 index 0000000..78d8e40 --- /dev/null +++ b/web-timeplot/src/waterfall.js @@ -0,0 +1,192 @@ +import { Container, Graphics, Text } from 'pixi.js'; + +/** + * WaterfallGraph - A scrolling waterfall display + * Starts simple with basic line rendering + */ +export class WaterfallGraph { + constructor(config) { + this.x = config.x; + this.y = config.y; + this.width = config.width; + this.height = config.height; + this.title = config.title; + this.baseColor = config.color || 0xff6666; + + this.container = new Container(); + this.container.x = this.x; + this.container.y = this.y; + + // Graphics layers + this.borderGraphics = new Graphics(); + this.gridGraphics = new Graphics(); + this.linesGraphics = new Graphics(); + + this.container.addChild(this.gridGraphics); + this.container.addChild(this.linesGraphics); + this.container.addChild(this.borderGraphics); + + // Title text + this.titleText = new Text({ + text: this.title, + style: { + fontFamily: 'Arial', + fontSize: 18, + fill: 0xeeeeee, + } + }); + this.titleText.x = 10; + this.titleText.y = 10; + this.container.addChild(this.titleText); + + // Waterfall data + this.lines = []; + this.maxLines = 50; + this.pointsPerLine = 100; + this.frameCounter = 0; + + this.showGrid = true; + + this.draw(); + } + + draw() { + this.drawBorder(); + this.drawGrid(); + } + + drawBorder() { + this.borderGraphics.clear(); + this.borderGraphics.rect(0, 0, this.width, this.height); + this.borderGraphics.stroke({ width: 2, color: 0x606070 }); + } + + drawGrid() { + this.gridGraphics.clear(); + + if (!this.showGrid) return; + + this.gridGraphics.alpha = 0.3; + + const divisions = 10; + const color = 0x4a7a9a; + + // Vertical lines + for (let i = 0; i <= divisions; i++) { + const x = (i / divisions) * this.width; + this.gridGraphics.moveTo(x, 0); + this.gridGraphics.lineTo(x, this.height); + this.gridGraphics.stroke({ width: 1, color }); + } + + // Horizontal lines + for (let i = 0; i <= divisions; i++) { + const y = (i / divisions) * this.height; + this.gridGraphics.moveTo(0, y); + this.gridGraphics.lineTo(this.width, y); + this.gridGraphics.stroke({ width: 1, color }); + } + } + + update(time, graphIdx) { + this.frameCounter++; + + // Add new line every 10 frames + if (this.frameCounter % 10 === 0 && this.lines.length < this.maxLines) { + this.addLine(time, graphIdx); + } + + // Scroll existing lines down + this.scrollLines(); + + // Remove off-screen lines + this.lines = this.lines.filter(line => line.yOffset < this.height + 50); + + // Redraw all lines + this.drawLines(); + } + + addLine(time, graphIdx) { + const line = { + points: [], + yOffset: 0, + color: this.generateColor(time), + }; + + // Generate sine wave points + const phase = time + (graphIdx * 2); + const freq = 2.0 + Math.sin(time * 0.5 + graphIdx) * 1.0; + + for (let i = 0; i < this.pointsPerLine; i++) { + const x = (i / this.pointsPerLine) * this.width; + const normalizedX = (i / this.pointsPerLine) * 2 - 1; // -1 to 1 + const y = Math.sin(i * 0.1 * freq + phase) * 30; // Amplitude in pixels + + line.points.push({ x, y }); + } + + this.lines.push(line); + } + + scrollLines() { + const scrollSpeed = 1.0; + this.lines.forEach(line => { + line.yOffset += scrollSpeed; + }); + } + + drawLines() { + this.linesGraphics.clear(); + + for (const line of this.lines) { + if (line.points.length < 2) continue; + + // Start path + const firstPoint = line.points[0]; + this.linesGraphics.moveTo(firstPoint.x, firstPoint.y + line.yOffset); + + // Draw line strip + for (let i = 1; i < line.points.length; i++) { + const point = line.points[i]; + this.linesGraphics.lineTo(point.x, point.y + line.yOffset); + } + + this.linesGraphics.stroke({ width: 2, color: line.color }); + } + } + + generateColor(time) { + // Cycle through colors based on time + const hue = (time * 0.1) % 1.0; + const r = Math.floor(Math.abs(Math.sin(hue * Math.PI * 2)) * 255); + const g = Math.floor(Math.abs(Math.sin((hue + 0.33) * Math.PI * 2)) * 255); + const b = Math.floor(Math.abs(Math.sin((hue + 0.66) * Math.PI * 2)) * 255); + + return (r << 16) | (g << 8) | b; + } + + setGridVisible(visible) { + this.showGrid = visible; + this.drawGrid(); + } + + resize(x, y, width, height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + + this.container.x = x; + this.container.y = y; + + this.draw(); + } + + getVertexCount() { + return this.lines.reduce((sum, line) => sum + line.points.length, 0); + } + + getLineCount() { + return this.lines.length; + } +} |
