summaryrefslogtreecommitdiff
path: root/web-timeplot/src
diff options
context:
space:
mode:
authorgrothedev <grothedev@gmail.com>2025-10-02 20:13:46 -0400
committergrothedev <grothedev@gmail.com>2025-10-02 20:13:46 -0400
commit39124e6854b2740e9f30c058e873355dfd739a4e (patch)
tree5cd1fb7986b1aebbde79ce478ecfc0b077690414 /web-timeplot/src
parent836459dce3f50767d41978be4a2f7ac788e6a9ba (diff)
starting with pixijs implementation
Diffstat (limited to 'web-timeplot/src')
-rw-r--r--web-timeplot/src/main.js220
-rw-r--r--web-timeplot/src/metrics.js142
-rw-r--r--web-timeplot/src/waterfall.js192
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;
+ }
+}