凡例ホバーによる WebGPU オシロスコープのインタラクティブなトレースハイライト

前回のオシロスコープ記事 では、リングバッファと自動スクロールを備えた 5 チャンネル 1 kHz WebGPU オシロスコープを紹介した。複数のトレースをプロットすると、どの凡例項目が どのトレースに対応するか分かりにくくなる——特に色が似ていたり プロットが密な場合。

この記事ではインタラクティブな凡例ホバーハイライトを追加する: 凡例項目をホバーするとそのトレースが強調(太字ラベル+薄い背景)され、 他のすべてのトレースが凡例とプロットの両方で暗くなる。減光は ハイライトされていない色をプロット背景に向かって脱色・明る化するため、 ハイライトされたトレースが明確に際立つ。

このテクニックは任意のチャンネル数で機能する。この例では分かりやすさのため 2 つのトレース(sin と cos)を使用する。

1 kHz・2 Hz の 2 つの正弦波(sin、cos)。右の凡例をホバーすると トレースが強調され、他方が暗くなる。WebGPU 対応ブラウザが必要 (Chrome / Edge 113+)。

動作の仕組み

アーキテクチャ

ハイライトは 3 つのレイヤーの連携で実現される:

  1. CPU ホバー検出(JavaScript):プロットコンテナ上の mousemove が マウス座標を凡例項目のヒットボックスにマップ。ホバー項目が変わると、 CPU が highlightedChannel 変数を更新し、GPU カラーバッファを 書き換える。
  2. GPU カラーバッファ(WebGPU ストレージバッファ):各チャンネルの色は バーテックスシェーダが読むストレージバッファ内の vec4<f32>。 チャンネルがハイライトされると、他のチャンネルの色が減光版に置き換わる。
  3. 2D キャンバスオーバーレイ(Canvas 2D):凡例は WebGPU キャンバスの 上の 2D キャンバスに描画される。ハイライトされた凡例行は太字ラベルと 薄い背景になり、減光された行は落ち着いたテキスト色を使う。

減光:脱色+背景方向への明る化

dimColor() 関数は RGB カラーと [0, 1] のファクターを受け取り、 減光版を生成する:

dim.js
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 から読む:

line.wgsl
@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 呼び出しで カラーバッファ全体を書き換える:

writeColors.js
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 リスナーがプロットの 親コンテナにアタッチされる:

hover.js
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 オーバーレイに描画される。ハイライトされた行は 太字フォントと薄い背景になり、減光された行は落ち着いたテキスト色と 減光されたスウォッチ色を使う:

legend.js
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 コンテナで包んで追加:

example.html
<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() 関数をデータソースに置き換える——リングバッファ レイアウトの詳細は リングバッファオシロスコープ記事 を参照。

関連記事

完全なソース

interactive-trace-highlighting-webgpu.js
// 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();
  }
})();

同じカテゴリの類似記事をチェック: Javascript WebGPU Web