前回のオシロスコープ記事 では、リングバッファと自動スクロールを備えた 5 チャンネル 1 kHz WebGPU オシロスコープを紹介した。複数のトレースをプロットすると、どの凡例項目が どのトレースに対応するか分かりにくくなる——特に色が似ていたり プロットが密な場合。
この記事ではインタラクティブな凡例ホバーハイライトを追加する: 凡例項目をホバーするとそのトレースが強調(太字ラベル+薄い背景)され、 他のすべてのトレースが凡例とプロットの両方で暗くなる。減光は ハイライトされていない色をプロット背景に向かって脱色・明る化するため、 ハイライトされたトレースが明確に際立つ。
このテクニックは任意のチャンネル数で機能する。この例では分かりやすさのため
2 つのトレース(sin と cos)を使用する。
1 kHz・2 Hz の 2 つの正弦波(sin、cos)。右の凡例をホバーすると トレースが強調され、他方が暗くなる。WebGPU 対応ブラウザが必要 (Chrome / Edge 113+)。
動作の仕組み
アーキテクチャ
ハイライトは 3 つのレイヤーの連携で実現される:
- CPU ホバー検出(JavaScript):プロットコンテナ上の
mousemoveが マウス座標を凡例項目のヒットボックスにマップ。ホバー項目が変わると、 CPU がhighlightedChannel変数を更新し、GPU カラーバッファを 書き換える。 - GPU カラーバッファ(WebGPU ストレージバッファ):各チャンネルの色は
バーテックスシェーダが読むストレージバッファ内の
vec4<f32>。 チャンネルがハイライトされると、他のチャンネルの色が減光版に置き換わる。 - 2D キャンバスオーバーレイ(Canvas 2D):凡例は WebGPU キャンバスの 上の 2D キャンバスに描画される。ハイライトされた凡例行は太字ラベルと 薄い背景になり、減光された行は落ち着いたテキスト色を使う。
減光:脱色+背景方向への明る化
dimColor() 関数は RGB カラーと [0, 1] のファクターを受け取り、
減光版を生成する:
function dimColor(c, factor) {
const bg = 0.97; // plot background gray level
// Luminance (grayscale) of the original color.
const gray = 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2];
// Desaturate: 50% blend towards grayscale.
const r = 0.5 * c[0] + 0.5 * gray + (bg - (0.5 * c[0] + 0.5 * gray)) * factor;
const g = 0.5 * c[1] + 0.5 * gray + (bg - (0.5 * c[1] + 0.5 * gray)) * factor;
const b = 0.5 * c[2] + 0.5 * gray + (bg - (0.5 * c[2] + 0.5 * gray)) * factor;
return [r, g, b];
}factor = 0 では色は変化しない。factor = 1 では背景グレーに完全に
縮退する。factor = 0.7(ここで使用)では色は大幅に脱色・明る化されるが、
元の色の薄い版として認識できる。
2 段階アプローチ(脱色→明る化)が重要:
- 脱色は色相のコントラストを取り除き、減光されたトレースが 注目を競わないようにする。
- 背景方向への明る化は明度のコントラストを下げ、減光されたトレースが 視覚的に後退するようにする。
GPU カラーバッファの更新
チャンネルカラーは WebGPU ストレージバッファ(colorBuffer)にあり、
@binding(2) にバインドされる。バーテックスシェーダは各インスタンスの
色を channelColors[ch].rgb から読む:
@vertex
fn vs_line(@builtin(vertex_index) vi : u32,
@builtin(instance_index) ch : u32) -> LineVSOut {
// ... read (t, v) from ring buffer ...
out.color = channelColors[ch].rgb;
return out;
}ハイライトが変わると、CPU は 1 回の queue.writeBuffer 呼び出しで
カラーバッファ全体を書き換える:
function writeColors() {
const colorData = new Float32Array(NUM_CHANNELS * 4);
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
let color = CHANNEL_COLORS[ch];
if (highlightedChannel !== null && ch !== highlightedChannel) {
color = dimColor(color, 0.7);
}
colorData[ch * 4] = color[0];
colorData[ch * 4 + 1] = color[1];
colorData[ch * 4 + 2] = color[2];
colorData[ch * 4 + 3] = 1.0;
}
device.queue.writeBuffer(colorBuffer, 0, colorData);
}これは安価——バッファは NUM_CHANNELS × 16 バイト(2 チャンネルで
32 バイト)——で、GPU はパイプラインの再作成なしに次のフレームで
新しい色を拾う。
2D オーバーレイでのホバー検出
2D キャンバスオーバーレイは pointerEvents: 'none' なのでマウス
イベントをブロックしない。代わりに、mousemove リスナーがプロットの
親コンテナにアタッチされる:
container.addEventListener('mousemove', (e) => {
const rect = container.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// 凡例項目の位置(drawAxes と一致させること)
const legendX = MARGIN_LEFT + plotWidth + 8;
const legendY = MARGIN_TOP + 4;
const itemWidth = 96;
let newHighlight = null;
for (let i = 0; i < NUM_CHANNELS; i++) {
const ly = legendY + i * 18;
if (mx >= legendX - 4 && mx <= legendX + itemWidth &&
my >= ly - 9 && my <= ly + 9) {
newHighlight = i;
break;
}
}
if (newHighlight !== highlightedChannel) {
highlightedChannel = newHighlight;
writeColors(); // 変更時のみ書き換え
}
});
container.addEventListener('mouseleave', () => {
if (highlightedChannel !== null) {
highlightedChannel = null;
writeColors();
}
});重要な最適化:writeColors() は highlightedChannel が実際に変化した
ときのみ呼ばれ、毎回の mousemove イベントでは呼ばれない。
ハイライト付き凡例描画
凡例は毎フレーム 2D オーバーレイに描画される。ハイライトされた行は 太字フォントと薄い背景になり、減光された行は落ち着いたテキスト色と 減光されたスウォッチ色を使う:
for (let i = 0; i < NUM_CHANNELS; i++) {
let [r, g, b] = CHANNEL_COLORS[i];
const isHighlighted = highlightedChannel === i;
const isDimmed = highlightedChannel !== null && !isHighlighted;
if (isDimmed) {
const d = dimColor([r, g, b], 0.7);
[r, g, b] = d;
}
const ly = legendY + i * 18;
if (isHighlighted) {
octx.fillStyle = 'rgba(0,0,0,0.06)';
octx.fillRect(legendX - 4, ly - 9, 96, 18);
}
octx.fillStyle = `rgb(${Math.round(r*255)},${Math.round(g*255)},${Math.round(b*255)})`;
octx.fillRect(legendX, ly - 5, 12, 10);
octx.fillStyle = isDimmed ? '#888' : '#333';
octx.font = isHighlighted ? 'bold 11px sans-serif' : '11px sans-serif';
octx.fillText(CHANNEL_NAMES[i], legendX + 16, ly);
}自分のページで使う
スクリプトを static フォルダに置き、highlight-plot-canvas クラスの
キャンバスを position:relative コンテナで包んで追加:
<div style="position:relative;width:100%;height:450px;">
<canvas class="highlight-plot-canvas"
style="width:100%;height:100%;display:block;"></canvas>
</div>
<script src="/scripts/interactive-trace-highlighting-webgpu.js" defer></script>コンテナは position:relative でなければならない。2D オーバーレイ
キャンバスを WebGPU キャンバスの上にアンカーするため、また
mousemove 座標が正しくマップされるために必要。
チャンネル数、色、サンプルレート、正弦波周波数を変えるにはスクリプト
冒頭の定数を編集。実データ(例:WebSocket から)を供給するには
generateChunk() 関数をデータソースに置き換える——リングバッファ
レイアウトの詳細は
リングバッファオシロスコープ記事
を参照。
関連記事
- Real-Time 1 kHz WebGPU Oscilloscope with Ring Buffer Auto-Scroll — この記事が拡張するベースのオシロスコープ
- Interactive MSAA Antialiasing for WebGPU Line Plots — ここで使う MSAA サンプル数プローブ
- Interactive WebGPU Line Plots with GPU-Side Autoscale — 静的プロット用のマウスズーム/パン
- ggplot-Style WebGPU Chirp Plot with GPU Autoscale and Infinite Zoom — ここで使うグリッドシェーダテクニック
- WebGPU Line Plots with GPU-Side Function Evaluation — 元の正弦波ラインプロット
完全なソース
// SPDX-FileCopyrightText: 2026 Uli Köhler <gitlab@techoverflow.net>
// SPDX-License-Identifier: CC0-1.0
//
// Real-time WebGPU oscilloscope with interactive trace highlighting.
//
// Two sine waves (sin and cos) are plotted at 1 kHz. Hovering a
// legend item highlights that trace and dims the other — both in the
// legend and in the plot — by desaturating and lightening the
// non-highlighted channel colors towards the plot background.
//
// Based on the 5-channel oscilloscope from
// https://techoverflow.net/2026/08/19/real-time-1-khz-webgpu-oscilloscope-with-ring-buffer-auto-scroll/
//
// Auto-initializes every <canvas class="highlight-plot-canvas"> on the page.
(function () {
'use strict';
// ─────────────────────────────────────────────────────────────────────
// Configuration
// ─────────────────────────────────────────────────────────────────────
const SAMPLE_RATE = 1000;
const WINDOW_SEC = 5.0;
const WINDOW_SAMPLES = SAMPLE_RATE * WINDOW_SEC;
const NUM_CHANNELS = 2; // sin + cos
const CHUNK_SIZE = 20;
const CHUNK_INTERVAL_MS = CHUNK_SIZE / SAMPLE_RATE * 1000;
const BUFFER_SAMPLES = WINDOW_SAMPLES + CHUNK_SIZE;
const BUFFER_FLOATS = BUFFER_SAMPLES * NUM_CHANNELS * 2;
const BUFFER_BYTES = BUFFER_FLOATS * 4;
const SINE_FREQ = 2.0;
const SINE_AMPLITUDE = 1.0;
// Two colors: blue for sin, orange for cos.
const CHANNEL_COLORS = [
[0.00, 0.45, 0.75], // blue (sin)
[0.85, 0.33, 0.10], // orange (cos)
];
const CHANNEL_NAMES = ['sin', 'cos'];
const MSAA_SAMPLE_COUNTS = [1, 2, 4, 8, 16];
const MARGIN_LEFT = 64;
const MARGIN_RIGHT = 80;
const MARGIN_TOP = 16;
const MARGIN_BOTTOM = 48;
// ─────────────────────────────────────────────────────────────────────
// WGSL shader
// ─────────────────────────────────────────────────────────────────────
const SHADER = /* wgsl */ `
struct RenderUniforms {
currentTime : f32,
windowSec : f32,
yMin : f32,
yMax : f32,
plotX : f32,
plotY : f32,
plotW : f32,
plotH : f32,
canvasW : f32,
canvasH : f32,
xMajorStep : f32,
yMajorStep : f32,
writeIndex : u32,
bufferSamples : u32,
};
@group(0) @binding(0) var<uniform> ru : RenderUniforms;
@group(0) @binding(1) var<storage, read> ringBuffer : array<f32>;
@group(0) @binding(2) var<storage, read> channelColors : array<vec4<f32>>;
fn worldToClip(t : f32, v : f32) -> vec4<f32> {
let xMin = ru.currentTime - ru.windowSec;
let xMax = ru.currentTime;
let plotPx = (t - xMin) / (xMax - xMin) * ru.plotW;
let plotPy = (ru.yMax - v) / (ru.yMax - ru.yMin) * ru.plotH;
let canvasPx = ru.plotX + plotPx;
let canvasPy = ru.plotY + plotPy;
return vec4<f32>(
(canvasPx / ru.canvasW) * 2.0 - 1.0,
1.0 - (canvasPy / ru.canvasH) * 2.0,
0.0, 1.0
);
}
struct LineVSOut {
@builtin(position) clipPos : vec4<f32>,
@location(0) color : vec3<f32>,
};
@vertex
fn vs_line(@builtin(vertex_index) vi : u32,
@builtin(instance_index) ch : u32) -> LineVSOut {
let sampleIdx = (ru.writeIndex + ru.bufferSamples - ${WINDOW_SAMPLES}u + vi) % ru.bufferSamples;
let base = (sampleIdx * ${NUM_CHANNELS}u + ch) * 2u;
let t = ringBuffer[base];
let v = ringBuffer[base + 1u];
var out : LineVSOut;
out.clipPos = worldToClip(t, v);
out.color = channelColors[ch].rgb;
return out;
}
@fragment
fn fs_line(in : LineVSOut) -> @location(0) vec4<f32> {
return vec4<f32>(in.color, 1.0);
}
struct GridVSOut {
@builtin(position) clipPos : vec4<f32>,
@location(0) canvasPx : vec2<f32>,
};
@vertex
fn vs_grid(@builtin(vertex_index) vi : u32) -> GridVSOut {
var pos = array<vec2<f32>, 6>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 1.0, -1.0),
vec2<f32>( 1.0, 1.0),
vec2<f32>(-1.0, -1.0),
vec2<f32>( 1.0, 1.0),
vec2<f32>(-1.0, 1.0),
);
var out : GridVSOut;
out.clipPos = vec4<f32>(pos[vi], 0.0, 1.0);
out.canvasPx = vec2<f32>(
(pos[vi].x * 0.5 + 0.5) * ru.canvasW,
(1.0 - pos[vi].y * 0.5 - 0.5) * ru.canvasH,
);
return out;
}
fn gridLineDistPx(coord : f32, step : f32, pxPerWorld : f32) -> f32 {
let p = coord / step;
let frac_p = fract(p);
let dWorld = min(frac_p, 1.0 - frac_p);
return dWorld * pxPerWorld;
}
fn gridLineIntensity(distPx : f32, width : f32) -> f32 {
return 1.0 - smoothstep(0.0, max(width, 0.0001), distPx);
}
@fragment
fn fs_grid(in : GridVSOut) -> @location(0) vec4<f32> {
let marginColor = vec3<f32>(0.94, 0.94, 0.94);
let plotBg = vec3<f32>(0.97, 0.97, 0.97);
let px = in.canvasPx.x;
let py = in.canvasPx.y;
let inside = px >= ru.plotX && px < ru.plotX + ru.plotW
&& py >= ru.plotY && py < ru.plotY + ru.plotH;
if (!inside) {
return vec4<f32>(marginColor, 1.0);
}
let xMin = ru.currentTime - ru.windowSec;
let worldX = xMin + (px - ru.plotX) / ru.plotW * ru.windowSec;
let worldY = ru.yMax - (py - ru.plotY) / ru.plotH * (ru.yMax - ru.yMin);
let pxPerWorldX = ru.plotW / ru.windowSec;
let pxPerWorldY = ru.plotH / (ru.yMax - ru.yMin);
let minorD = min(gridLineDistPx(worldX, ru.xMajorStep * 0.5, pxPerWorldX),
gridLineDistPx(worldY, ru.yMajorStep * 0.5, pxPerWorldY));
let majorD = min(gridLineDistPx(worldX, ru.xMajorStep, pxPerWorldX),
gridLineDistPx(worldY, ru.yMajorStep, pxPerWorldY));
let xMinor = gridLineIntensity(minorD, 1.0);
let xMajor = gridLineIntensity(majorD, 1.0);
let minorColor = vec3<f32>(0.88, 0.88, 0.88);
let majorColor = vec3<f32>(0.75, 0.75, 0.78);
var color = plotBg;
color = mix(color, minorColor, xMinor * 0.5);
color = mix(color, majorColor, xMajor * 0.7);
return vec4<f32>(color, 1.0);
}
`;
// ─────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────
function niceStep(range, targetCount) {
const raw = range / targetCount;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / mag;
let step;
if (norm <= 1.5) step = 1;
else if (norm <= 3) step = 2;
else if (norm <= 7) step = 5;
else step = 10;
return step * mag;
}
function formatTick(v) {
const abs = Math.abs(v);
if (abs === 0) return '0';
if (abs >= 1000 || abs < 1e-3) return v.toExponential(1);
let s = v.toFixed(3);
s = s.replace(/0+$/, '').replace(/\.$/, '');
return s;
}
/**
* Desaturate and lighten a color towards the plot background (0.97 gray).
* factor = 0 → original color, 1 → fully background color.
*/
function dimColor(c, factor) {
const bg = 0.97;
const gray = 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2];
const r = (0.5 * c[0] + 0.5 * gray) + (bg - (0.5 * c[0] + 0.5 * gray)) * factor;
const g = 0.5 * c[1] + 0.5 * gray + (bg - (0.5 * c[1] + 0.5 * gray)) * factor;
const b = 0.5 * c[2] + 0.5 * gray + (bg - (0.5 * c[2] + 0.5 * gray)) * factor;
return [r, g, b];
}
// ─────────────────────────────────────────────────────────────────────
// Per-canvas initialization
// ─────────────────────────────────────────────────────────────────────
async function initCanvas(canvas) {
if (!navigator.gpu) {
canvas.replaceWith(document.createTextNode('WebGPU is not supported in this browser.'));
return;
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
canvas.replaceWith(document.createTextNode('No WebGPU adapter available.'));
return;
}
const device = await adapter.requestDevice();
const ctx = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();
ctx.configure({ device, format, alphaMode: 'premultiplied' });
const module = device.createShaderModule({ code: SHADER });
const renderLayout = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
{ binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
],
});
const supportedSC = [];
const linePipelines = [];
const gridPipelines = [];
const layout = device.createPipelineLayout({ bindGroupLayouts: [renderLayout] });
for (const sc of MSAA_SAMPLE_COUNTS) {
try {
const [linePipe, gridPipe] = await Promise.all([
device.createRenderPipelineAsync({
layout,
vertex: { module, entryPoint: 'vs_line' },
fragment: { module, entryPoint: 'fs_line', targets: [{ format }] },
primitive: { topology: 'line-strip' },
multisample: { count: sc },
}),
device.createRenderPipelineAsync({
layout,
vertex: { module, entryPoint: 'vs_grid' },
fragment: { module, entryPoint: 'fs_grid', targets: [{ format }] },
primitive: { topology: 'triangle-list' },
multisample: { count: sc },
}),
]);
supportedSC.push(sc);
linePipelines.push(linePipe);
gridPipelines.push(gridPipe);
} catch {
// Unsupported sample count — skip.
}
}
const TARGET_SC = 4;
let msaaIndex = supportedSC.length - 1;
const targetIdx = supportedSC.indexOf(TARGET_SC);
if (targetIdx !== -1) msaaIndex = targetIdx;
const ringBuffer = device.createBuffer({
size: BUFFER_BYTES,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
const renderUniforms = device.createBuffer({
size: 56,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const colorBuffer = device.createBuffer({
size: NUM_CHANNELS * 16,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
// ── Highlight state ────────────────────────────────────────────────
let highlightedChannel = null;
function writeColors() {
const colorData = new Float32Array(NUM_CHANNELS * 4);
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
let color = CHANNEL_COLORS[ch];
if (highlightedChannel !== null && ch !== highlightedChannel) {
color = dimColor(color, 0.7);
}
colorData[ch * 4] = color[0];
colorData[ch * 4 + 1] = color[1];
colorData[ch * 4 + 2] = color[2];
colorData[ch * 4 + 3] = 1.0;
}
device.queue.writeBuffer(colorBuffer, 0, colorData);
}
writeColors();
const bindGroup = device.createBindGroup({
layout: renderLayout,
entries: [
{ binding: 0, resource: { buffer: renderUniforms } },
{ binding: 1, resource: { buffer: ringBuffer } },
{ binding: 2, resource: { buffer: colorBuffer } },
],
});
let msaaTexture = null;
let msaaSC = 0, msaaW = 0, msaaH = 0;
function ensureMsaa(w, h, sc) {
if (sc === 1) {
if (msaaTexture) { msaaTexture.destroy(); msaaTexture = null; }
msaaSC = 1; msaaW = w; msaaH = h;
return;
}
if (msaaTexture && msaaSC === sc && msaaW === w && msaaH === h) return;
if (msaaTexture) msaaTexture.destroy();
msaaTexture = device.createTexture({
size: [w, h, 1], format,
usage: GPUTextureUsage.RENDER_ATTACHMENT,
sampleCount: sc,
});
msaaSC = sc; msaaW = w; msaaH = h;
}
const overlay = document.createElement('canvas');
overlay.style.position = 'absolute';
overlay.style.left = '0';
overlay.style.top = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.pointerEvents = 'none';
canvas.style.position = canvas.style.position || 'relative';
canvas.parentElement.appendChild(overlay);
const octx = overlay.getContext('2d');
let writeIndex = 0;
let currentTime = 0.0;
let sampleCounter = 0;
const initBuf = new Float32Array(BUFFER_FLOATS);
for (let i = 0; i < BUFFER_SAMPLES; i++) {
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
const idx = (i * NUM_CHANNELS + ch) * 2;
initBuf[idx] = -1e30;
initBuf[idx + 1] = 0.0;
}
}
device.queue.writeBuffer(ringBuffer, 0, initBuf);
const chunkFloats = CHUNK_SIZE * NUM_CHANNELS * 2;
const chunkData = new Float32Array(chunkFloats);
function generateChunk() {
for (let i = 0; i < CHUNK_SIZE; i++) {
const t = sampleCounter / SAMPLE_RATE;
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
const phase = 2 * Math.PI * SINE_FREQ * t + (ch === 0 ? 0 : Math.PI / 2);
const v = SINE_AMPLITUDE * (ch === 0 ? Math.sin(phase) : Math.cos(phase));
const idx = (i * NUM_CHANNELS + ch) * 2;
chunkData[idx] = t;
chunkData[idx + 1] = v;
}
sampleCounter++;
currentTime = t;
}
}
function writeChunk() {
const sampleStride = NUM_CHANNELS * 2 * 4;
if (writeIndex + CHUNK_SIZE <= BUFFER_SAMPLES) {
const offset = writeIndex * sampleStride;
device.queue.writeBuffer(ringBuffer, offset, chunkData);
} else {
const fits = BUFFER_SAMPLES - writeIndex;
const fitsFloats = fits * NUM_CHANNELS * 2;
const offset = writeIndex * sampleStride;
device.queue.writeBuffer(ringBuffer, offset, chunkData.subarray(0, fitsFloats));
const restFloats = (CHUNK_SIZE - fits) * NUM_CHANNELS * 2;
device.queue.writeBuffer(ringBuffer, 0, chunkData.subarray(fitsFloats));
}
writeIndex = (writeIndex + CHUNK_SIZE) % BUFFER_SAMPLES;
}
let chunkTimer = null;
function startStreaming() {
if (chunkTimer) return;
chunkTimer = setInterval(() => {
generateChunk();
writeChunk();
}, CHUNK_INTERVAL_MS);
}
function stopStreaming() {
if (chunkTimer) { clearInterval(chunkTimer); chunkTimer = null; }
}
startStreaming();
const yMin = -SINE_AMPLITUDE * 1.2;
const yMax = SINE_AMPLITUDE * 1.2;
// --- Legend hover detection ---
const container = canvas.parentElement;
container.addEventListener('mousemove', (e) => {
const rect = container.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const cssW = canvas.clientWidth;
const ml = MARGIN_LEFT, pw = cssW - MARGIN_LEFT - MARGIN_RIGHT;
const mt = MARGIN_TOP;
const legendX = ml + pw + 8;
const legendY = mt + 4;
const itemWidth = 96;
let newHighlight = null;
for (let i = 0; i < NUM_CHANNELS; i++) {
const ly = legendY + i * 18;
if (mx >= legendX - 4 && mx <= legendX + itemWidth &&
my >= ly - 9 && my <= ly + 9) {
newHighlight = i;
break;
}
}
if (newHighlight !== highlightedChannel) {
highlightedChannel = newHighlight;
writeColors();
}
});
container.addEventListener('mouseleave', () => {
if (highlightedChannel !== null) {
highlightedChannel = null;
writeColors();
}
});
function drawAxes(cssW, cssH, dpr) {
const cw = canvas.width, ch = canvas.height;
if (overlay.width !== cw || overlay.height !== ch) {
overlay.width = cw; overlay.height = ch;
}
octx.clearRect(0, 0, cw, ch);
octx.save();
octx.scale(dpr, dpr);
const ml = MARGIN_LEFT, mr = MARGIN_RIGHT, mt = MARGIN_TOP, mb = MARGIN_BOTTOM;
const px = ml, py = mt;
const pw = cssW - ml - mr, ph = cssH - mt - mb;
octx.strokeStyle = '#333';
octx.lineWidth = 1;
octx.beginPath();
octx.moveTo(px, py);
octx.lineTo(px, py + ph);
octx.lineTo(px + pw, py + ph);
octx.stroke();
octx.font = '11px sans-serif';
octx.fillStyle = '#333';
const xStep = niceStep(WINDOW_SEC, 8);
const xStart = Math.ceil((currentTime - WINDOW_SEC) / xStep) * xStep;
octx.textAlign = 'center';
octx.textBaseline = 'top';
for (let x = xStart; x <= currentTime + xStep * 0.001; x += xStep) {
const sx = px + (x - (currentTime - WINDOW_SEC)) / WINDOW_SEC * pw;
if (sx < px - 1 || sx > px + pw + 1) continue;
octx.beginPath();
octx.moveTo(sx, py + ph);
octx.lineTo(sx, py + ph + 4);
octx.stroke();
const label = x === currentTime ? '0s' : (x - currentTime).toFixed(1) + 's';
octx.fillText(label, sx, py + ph + 8);
}
const yStep = niceStep(yMax - yMin, 6);
const yStart = Math.ceil(yMin / yStep) * yStep;
octx.textAlign = 'right';
octx.textBaseline = 'middle';
for (let y = yStart; y <= yMax + yStep * 0.001; y += yStep) {
const sy = py + (yMax - y) / (yMax - yMin) * ph;
if (sy < py - 1 || sy > py + ph + 1) continue;
octx.beginPath();
octx.moveTo(px, sy);
octx.lineTo(px - 4, sy);
octx.stroke();
octx.fillText(formatTick(y), px - 8, sy);
}
octx.font = '13px sans-serif';
octx.fillStyle = '#222';
octx.textAlign = 'center';
octx.textBaseline = 'bottom';
octx.fillText('t (s, relative)', ml + pw / 2, cssH - 4);
octx.save();
octx.translate(12, mt + ph / 2);
octx.rotate(-Math.PI / 2);
octx.textAlign = 'center';
octx.textBaseline = 'top';
octx.fillText('amplitude', 0, 0);
octx.restore();
// Legend — highlighted channel is bold, others are dimmed.
const legendX = ml + pw + 8;
const legendY = mt + 4;
octx.font = '11px sans-serif';
octx.textAlign = 'left';
octx.textBaseline = 'middle';
for (let i = 0; i < NUM_CHANNELS; i++) {
let [r, g, b] = CHANNEL_COLORS[i];
const isHighlighted = highlightedChannel === i;
const isDimmed = highlightedChannel !== null && !isHighlighted;
if (isDimmed) {
const d = dimColor([r, g, b], 0.7);
[r, g, b] = d;
}
const ly = legendY + i * 18;
if (isHighlighted) {
octx.fillStyle = 'rgba(0,0,0,0.06)';
octx.fillRect(legendX - 4, ly - 9, 96, 18);
}
octx.fillStyle = `rgb(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)})`;
octx.fillRect(legendX, ly - 5, 12, 10);
octx.fillStyle = isDimmed ? '#888' : '#333';
octx.font = isHighlighted ? 'bold 11px sans-serif' : '11px sans-serif';
octx.fillText(CHANNEL_NAMES[i], legendX + 16, ly);
}
octx.restore();
}
function frame() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const cw = Math.max(1, Math.floor(canvas.clientWidth * dpr));
const ch = Math.max(1, Math.floor(canvas.clientHeight * dpr));
if (canvas.width !== cw || canvas.height !== ch) {
canvas.width = cw; canvas.height = ch;
}
const ml = MARGIN_LEFT * dpr;
const mr = MARGIN_RIGHT * dpr;
const mt = MARGIN_TOP * dpr;
const mb = MARGIN_BOTTOM * dpr;
const plotX = ml;
const plotY = mt;
const plotW = Math.max(1, cw - ml - mr);
const plotH = Math.max(1, ch - mt - mb);
const sc = supportedSC[msaaIndex];
const xMajorStep = niceStep(WINDOW_SEC, 8);
const yMajorStep = niceStep(yMax - yMin, 6);
const ruData = new Float32Array(12);
ruData[0] = currentTime;
ruData[1] = WINDOW_SEC;
ruData[2] = yMin;
ruData[3] = yMax;
ruData[4] = plotX;
ruData[5] = plotY;
ruData[6] = plotW;
ruData[7] = plotH;
ruData[8] = cw;
ruData[9] = ch;
ruData[10] = xMajorStep;
ruData[11] = yMajorStep;
device.queue.writeBuffer(renderUniforms, 0, ruData);
const bufInfo = new Uint32Array([writeIndex, BUFFER_SAMPLES]);
device.queue.writeBuffer(renderUniforms, 48, bufInfo);
ensureMsaa(cw, ch, sc);
const encoder = device.createCommandEncoder();
const canvasView = ctx.getCurrentTexture().createView();
const colorAttachment = {
clearValue: { r: 0.94, g: 0.94, b: 0.94, a: 1 },
loadOp: 'clear',
};
if (sc > 1) {
colorAttachment.view = msaaTexture.createView();
colorAttachment.resolveTarget = canvasView;
colorAttachment.storeOp = 'discard';
} else {
colorAttachment.view = canvasView;
colorAttachment.storeOp = 'store';
}
const rp = encoder.beginRenderPass({ colorAttachments: [colorAttachment] });
rp.setPipeline(gridPipelines[msaaIndex]);
rp.setBindGroup(0, bindGroup);
rp.draw(6);
rp.setPipeline(linePipelines[msaaIndex]);
rp.setBindGroup(0, bindGroup);
const validSamples = Math.min(sampleCounter, WINDOW_SAMPLES);
const firstVertex = WINDOW_SAMPLES - validSamples;
rp.draw(validSamples, NUM_CHANNELS, firstVertex, 0);
rp.end();
device.queue.submit([encoder.finish()]);
drawAxes(canvas.clientWidth, canvas.clientHeight, dpr);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
window.addEventListener('beforeunload', stopStreaming);
}
function initAll() {
const canvases = document.querySelectorAll('canvas.highlight-plot-canvas');
canvases.forEach(initCanvas);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAll);
} else {
initAll();
}
})();