Skip to content

feat(admin): KeyViz heatmap page in web/admin SPA (Phase 2-B)#680

Merged
bootjp merged 5 commits intomainfrom
feat/keyviz-spa-integration
Apr 26, 2026
Merged

feat(admin): KeyViz heatmap page in web/admin SPA (Phase 2-B)#680
bootjp merged 5 commits intomainfrom
feat/keyviz-spa-integration

Conversation

@bootjp
Copy link
Copy Markdown
Owner

@bootjp bootjp commented Apr 26, 2026

Summary

Phase 2-B of the Key Visualizer design (docs/admin_ui_key_visualizer_design.md): integrate the heatmap into the existing web/admin/ SPA rather than building a parallel dashboard. Doc + implementation in one PR (doc commit first, per CLAUDE.md design-doc-first workflow).

  • New page at /keyviz — canvas heatmap polling /admin/api/v1/keyviz/matrix with series picker (writes / reads / write_bytes / read_bytes), row-budget input (clamped at 1024), and off / 5 s / 30 s auto-refresh.
  • Cold cells (value 0) render as the page background, not a faint blue — spotting actually-cold routes stays the dominant visual signal.
  • Row-detail flyout on hover: bucket_id, start, end, aggregate, route_count, route_ids (with truncation indicator). start / end decode through a printable-or-hex preview so binary keys do not render as mojibake.
  • Dependency-free: hand-rolled five-stop colour ramp in lib/colorRamp.ts. No d3, no ECharts. Bundle grew from ~155 kB to ~208 kB (raw), 64 kB gzipped.

Backend is unchanged. The handler at /admin/api/v1/keyviz/matrix and its sampler wiring already shipped under Phase 2-A (PRs #639 / #645 / #646 / #647 / #651 / #660 / #661 / #672).

Design

docs/design/2026_04_27_proposed_keyviz_spa_integration.md — proposed status, lands with this PR. Parent design §12 phase table is split into 2-A (server, shipped) / 2-B (SPA, this PR) / 2-C (cluster fan-out, open).

Five-lens self-review

  1. Data loss — n/a; SPA is read-only against an existing handler.
  2. Concurrency / distributed — n/a; single browser tab polling a single handler. Sampler concurrency was already covered by Phase 2-A tests.
  3. Performance — Canvas + per-cell fillRect runs under §10 budget at 1024 × 500. Auto-refresh defaults off; 5-second cadence is the lower bound (sampler flush is 1 s).
  4. Data consistency — SPA renders whatever the handler returns; consistency guarantees come from the existing leader-issued counters in the sampler.
  5. Test coveragetsc -b --noEmit clean; vite build clean; go build ./internal/admin/... clean (embed glob unaffected); go test ./internal/admin/... clean. Manual verification documented in the design doc §5.

Test plan

  • npm run lint (tsc -b --noEmit) — clean
  • npm run build (Vite) — clean, output goes to internal/admin/dist
  • go build ./internal/admin/... — clean
  • go test ./internal/admin/... — clean
  • Manual: make run + make client, navigate to /keyviz, see hot routes light up red within ~5 s of write traffic
  • Manual: series picker swaps the displayed counter; row-budget input clamps at 1024; auto-refresh polls without flicker

Out of scope

  • Cluster fan-out — handler is currently node-local. Phase 2-C will add a cross-node admin RPC; this PR will pick up the aggregate view automatically once that ships.
  • Drill-down per-route sparkline — Phase 3.
  • Routes / Raft Groups correlation — Phase 1 SPA pages not yet built; correlation lands when those pages do.
  • localStorage for series / rows / refresh — punt to follow-up.

bootjp added 2 commits April 27, 2026 03:22
Documents the decision to integrate the KeyViz heatmap into the
existing web/admin/ SPA rather than building a parallel frontend.
Covers the rationale (shared toolchain, auth, embed pipeline),
the surface area (one new page + three line edits in App/Layout +
api client extension), rendering specifics (canvas heatmap with
hand-rolled colour ramp, no charting dep), and what is explicitly
out of scope (drill-down, fan-out, Routes/Raft Groups correlation).
Adds /keyviz to the existing admin SPA: a canvas-based heatmap that
polls /admin/api/v1/keyviz/matrix and renders rows × time-bins with a
hand-rolled five-stop colour ramp. The page exposes the series picker
(writes / reads / write_bytes / read_bytes), a row-budget input
clamped at 1024, and an off / 5 s / 30 s auto-refresh selector.

Cold cells (value 0) render as the page background, not a faint blue,
so spotting actually-cold routes stays the dominant visual signal.
Hovering a row reveals start / end / route_count / route_ids with
truncated indicator and a printable-or-hex preview of the bytes.

Wires:
- web/admin/src/api/client.ts — KeyVizMatrix / KeyVizRow / KeyVizSeries
  types and api.keyVizMatrix(params, signal).
- web/admin/src/lib/colorRamp.ts — dependency-free perceptual ramp.
- web/admin/src/pages/KeyViz.tsx — page + heatmap + row detail.
- web/admin/src/App.tsx, components/Layout.tsx — route + nav entry.
- docs/admin_ui_key_visualizer_design.md §12 — phase table updated to
  reflect 2-A (server, shipped), 2-B (this PR), 2-C (fan-out, open).

No backend changes; the handler at /admin/api/v1/keyviz/matrix and
its sampler wiring already shipped under Phase 2-A. SPA passes
tsc -b --noEmit and vite build clean.
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 26, 2026

Warning

Rate limit exceeded

@bootjp has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 50 minutes and 46 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 50 minutes and 46 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9d7d612c-4107-4d25-a866-7067f434c23a

📥 Commits

Reviewing files that changed from the base of the PR and between 2bae541 and a606df1.

📒 Files selected for processing (7)
  • docs/admin_ui_key_visualizer_design.md
  • docs/design/2026_04_27_proposed_keyviz_spa_integration.md
  • web/admin/src/App.tsx
  • web/admin/src/api/client.ts
  • web/admin/src/components/Layout.tsx
  • web/admin/src/lib/colorRamp.ts
  • web/admin/src/pages/KeyViz.tsx
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/keyviz-spa-integration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements Phase 2-B of the Key Visualizer, integrating a heatmap into the admin SPA. It includes updates to the design documentation, new API client definitions, and a React-based heatmap page using HTML5 Canvas. Feedback identifies a discrepancy between documentation and implementation regarding canvas rendering methods, potential label overlap on the time axis, and a UX issue in the row budget input field that prevents clearing the value.

Comment thread web/admin/src/pages/KeyViz.tsx Outdated
Comment on lines +121 to +125
// putImageData over the full canvas keeps render under the
// §10 budget at 1024 × 500. We build the buffer column-major and
// expand each cell into a cellW × cellH block via fillRect; this
// avoids per-pixel iteration on the larger axis while still
// letting the colour ramp run once per cell.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a discrepancy between the comment and the implementation. The comment on line 121 mentions using putImageData to stay within the performance budget, but the code on line 134 uses ctx.fillRect in a loop. For a large heatmap (e.g., 1024x500), putImageData is generally more efficient as it performs a single memory copy to the canvas buffer, whereas fillRect incurs the overhead of a drawing command for every non-zero cell. If fillRect is intended, the comment should be updated to reflect the actual implementation.

Comment thread web/admin/src/pages/KeyViz.tsx Outdated

function TimeAxis({ columnUnixMs, cellW }: TimeAxisProps) {
if (columnUnixMs.length === 0) return null;
const stride = Math.max(1, Math.ceil(columnUnixMs.length / 10));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The stride calculation for the time axis does not account for the physical width of the labels (e.g., "HH:mm:ss"). If the heatmap is dense (small cellW), labels may overlap. It is better to calculate the stride based on a minimum pixel distance between labels to ensure readability.

Suggested change
const stride = Math.max(1, Math.ceil(columnUnixMs.length / 10));
const stride = Math.max(1, Math.ceil(60 / cellW));

Comment thread web/admin/src/pages/KeyViz.tsx Outdated
Comment on lines +326 to +329
onChange={(e) => {
const n = Number.parseInt(e.target.value, 10);
if (Number.isFinite(n) && n > 0) onChange(Math.min(n, rowsCap));
}}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The onChange handler for the RowsInput prevents the user from clearing the input field. When the input is empty, Number.parseInt returns NaN, causing the if condition to fail and the state to remain unchanged. This results in the input field appearing "stuck" when a user tries to delete the existing value to type a new one. Consider allowing the state to handle an empty string or providing a fallback value to improve the user experience.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 26, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented Apr 26, 2026

Claude finished @bootjp's task in 2m 51s —— View job


Code Review — Phase 2-B KeyViz SPA Integration

Tasks

  • Gather context from PR description and existing review comments
  • Read all changed source files
  • Post comprehensive review feedback

Overall the integration is clean: three one-liner edits to routing/nav, a well-typed API client extension, a tidy colour ramp module, and a self-contained KeyViz.tsx. The design-doc-first workflow is followed correctly. A few issues worth addressing before merge:


Bug — RowsInput gets stuck when user clears the field

web/admin/src/pages/KeyViz.tsx lines 326–329

onChange={(e) => {
  const n = Number.parseInt(e.target.value, 10);
  if (Number.isFinite(n) && n > 0) onChange(Math.min(n, rowsCap));
}}

parseInt("")NaN, so the if fails, the parent state stays at the last valid value, and the controlled input snaps back to the old number. The user cannot clear the field to type a new one — it's stuck.

Fix: introduce a local string state inside RowsInput so the field can be empty mid-edit; only propagate to the parent when a valid positive integer is committed.

function RowsInput({ value, onChange }: RowsInputProps) {
  const [raw, setRaw] = useState(String(value));

  // Keep local state in sync when the parent resets (e.g. on matrix refresh)
  useEffect(() => setRaw(String(value)), [value]);

  return (
    <label className="flex items-center gap-1">
      <span className="text-xs text-muted">Rows</span>
      <input
        type="number"
        min={1}
        max={rowsCap}
        step={1}
        className="input text-sm w-24"
        value={raw}
        onChange={(e) => {
          setRaw(e.target.value);
          const n = Number.parseInt(e.target.value, 10);
          if (Number.isFinite(n) && n > 0) onChange(Math.min(n, rowsCap));
        }}
      />
    </label>
  );
}

Fix this →


Medium — Comment says putImageData, code uses fillRect — fix both places

web/admin/src/pages/KeyViz.tsx lines 121–125

// putImageData over the full canvas keeps render under the
// §10 budget at 1024 × 500. We build the buffer column-major and
// expand each cell into a cellW × cellH block via fillRect; this
// avoids per-pixel iteration on the larger axis while still
// letting the colour ramp run once per cell.

The comment opens with putImageData but the code never calls it — it calls ctx.fillRect in a loop. fillRect is fine here (sparse matrices skip zero cells entirely), but the comment is contradictory and will mislead future readers. Update it to describe what the code actually does.

The design doc has the same inconsistency at §4.3: "Our single-pass ImageData.data write fits under that on every browser we've tested" — this should also be corrected to match the fillRect approach.

Fix this →


Medium — Time-axis labels overlap at small cell widths

web/admin/src/pages/KeyViz.tsx line 187

const stride = Math.max(1, Math.ceil(columnUnixMs.length / 10));

This always produces ~10 ticks regardless of cell width. At cellW = 2 (many columns), an HH:mm:ss label (~54 px at 10 px monospace) occupies 27 cells, so adjacent ticks at a 2-cell stride will overlap heavily. The fix is to derive the stride from the label width relative to the cell width:

const LABEL_PX = 54; // HH:mm:ss at ~10px monospace
const stride = Math.max(1, Math.ceil(LABEL_PX / cellW));

TimeAxisProps already receives cellW, so no signature change is needed.

Fix this →


Minor — Design doc says "clicking" but implementation uses hover

docs/design/2026_04_27_proposed_keyviz_spa_integration.md §3.1

clicking a row reveals bucket_id, start, end

The implementation shows the flyout on onMouseMove, not onClick. Hover is actually the better UX for a heatmap, but the doc should match the code. One sentence to update.


Minor — setHoverRow fires on every pixel of mouse movement

web/admin/src/pages/KeyViz.tsx lines 139–144

const onMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
  const rect = e.currentTarget.getBoundingClientRect();
  const y = e.clientY - rect.top;
  const idx = Math.floor(y / cellH);
  if (idx >= 0 && idx < matrix.rows.length) setHoverRow(idx);
};

Every mousemove event (potentially hundreds/second) calls setHoverRow even when the cursor hasn't crossed a row boundary, triggering a React re-render each time. Add a bail-out:

if (idx >= 0 && idx < matrix.rows.length) setHoverRow((prev) => (prev === idx ? prev : idx));

React 18 batches updates so this won't cause visible jank, but the functional update avoids scheduling a re-render when the value hasn't changed. Worth doing before it becomes noticeable at 1024 rows.


Minor — Canvas appears blurry on retina / high-DPR displays

web/admin/src/pages/KeyViz.tsx lines 113–116, 168–169

canvas.width and canvas.height are set to CSS pixel dimensions. On a 2× DPR display the canvas buffer is half the physical resolution — all cell edges render blurry. Standard fix: multiply the buffer dimensions by devicePixelRatio, scale the context, and keep the CSS style dimensions in CSS pixels.

This is a follow-up candidate if targeting non-1× displays is not a Phase 2-B goal, but worth noting.


colorRamp.ts — looks good

The ramp function is correct. Linear interpolation in RGBA space produces slight saturation dips between perceptually-saturated stops (e.g., between blue and green), but for a diagnostic heatmap that's acceptable. The transparent-at-zero design (cold cells = page background) is a deliberate and good choice.

One micro-nit: the as const assertion on the computed tuple is needed for the RGBA return type but may confuse readers unfamiliar with TypeScript's inference for Math.round(...) (which returns number, not 0). A comment noting it's a type assertion, not a runtime freeze, could help — but this is extremely low priority.


api/client.ts additions — clean

The KeyVizSeries, KeyVizRow, KeyVizMatrix, KeyVizParams types and api.keyVizMatrix follow the existing client patterns exactly. from_unix_ms / to_unix_ms are in KeyVizParams for future use but the page doesn't wire them yet — fine for Phase 2-B scope.


App.tsx, Layout.tsx — clean

One-liner additions in each. No issues.


Summary

# Severity File Issue
1 Bug KeyViz.tsx:326 RowsInput stuck when cleared
2 Medium KeyViz.tsx:121, design doc §4.3 putImageData comment vs fillRect reality
3 Medium KeyViz.tsx:187 Time-axis label overlap at small cellW
4 Minor design doc §3.1 "clicking" vs hover
5 Minor KeyViz.tsx:142 setHoverRow fires on every pixel
6 Minor KeyViz.tsx:113 No DPR handling, blurry on retina

Issue #1 (stuck input) and #3 (label overlap) are the ones I'd fix before merging; the rest can follow up.

Three medium-priority items from the round-1 Gemini pass:

- Stale comment claiming putImageData on line 121 — the implementation
  uses fillRect; comment now describes what the code actually does.
- TimeAxis stride did not account for label width, so at small cellW
  the HH:mm:ss labels visually merged. New floor of
  ceil(56px / cellW) keeps adjacent labels apart at any cell size.
- RowsInput silently swallowed empty input (Number.parseInt('') →
  NaN → onChange skipped), leaving the user unable to clear and
  retype. Field is now backed by a draft string, committed on blur
  or Enter; an empty/invalid commit reverts to the previous value.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 26, 2026

@claude review

Round-1 follow-ups (9e3e44d) addressing Gemini's three medium items:

  • Stale comment at web/admin/src/pages/KeyViz.tsx:121 — replaced the putImageData claim with a description matching the actual fillRect per-cell loop.
  • TimeAxis stride — added a timeAxisLabelMinPxGap (56 px) floor so the HH:mm:ss labels never overlap at small cell widths.
  • RowsInput — switched to a draft-string backing field; Enter / blur commits, empty/invalid reverts. The user can now clear and retype.

tsc -b --noEmit and vite build clean.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 26, 2026

Claude finished @bootjp's task in 2m 21s —— View job


Code Review — Phase 2-B KeyViz SPA Integration (Round 2)

Tasks

  • Gather context from PR description and trigger comment
  • Read KeyViz.tsx — verify three Gemini fixes + full review
  • Read colorRamp.ts
  • Read api/client.ts changes
  • Read App.tsx / Layout.tsx routing changes
  • Read design doc
  • Post comprehensive review feedback

All three Gemini items are correctly resolved in 9e3e44dc. Notes on each, plus new findings below.

Gemini fixes — verified ✓

1. Stale putImageData comment (KeyViz.tsx:121–124) — The comment now accurately describes the fillRect per-cell loop and explains the zero-skip optimisation. Clean.

2. TimeAxis stride (KeyViz.tsx:190–200) — timeAxisLabelMinPxGap = 56 is well-chosen (a HH:mm:ss string at 10 px monospace is ≈48 px; 56 px adds a small gap). The three-way Math.max (≥1, pixel-gap-floor, column-count/10) correctly handles both ends: dense columns and sparse columns.

3. RowsInput draft-string (KeyViz.tsx:328–372) — Clean implementation. Commit on Enter+blur, revert on empty/invalid, useEffect to resync draft when value changes from outside. One small note: Number.parseInt(draft, 10) followed by Number.isFinite(n) correctly rejects NaN and Infinity. Fine.


New findings

Medium — TimeAxis lives outside the scrollable container

KeyViz.tsx:162–171:

<div className="overflow-auto border border-border rounded">
  <canvas ref={canvasRef}  />    {/* scrolls */}
</div>
<TimeAxis columnUnixMs={} cellW={cellW} />    {/* does NOT scroll */}

TimeAxis renders position: absolute spans keyed on idx * cellW. When the canvas is wider than the viewport (e.g., 960 columns → canvas = 1920 px, cellW = 2), the canvas scrolls but the time labels stay fixed. The result is misaligned or invisible labels on any moderately long time range.

Fix: move TimeAxis inside the overflow-auto container, or wrap both in a single horizontally-scrollable div:

<div className="overflow-auto border border-border rounded">
  <canvas ref={canvasRef}  />
  <TimeAxis columnUnixMs={} cellW={cellW} />
</div>

The TimeAxis div already has position: relative; height: 1rem so it will expand the scrollable region naturally.

Fix this →

Minor — Design doc §4.3 still says ImageData.data

docs/design/2026_04_27_proposed_keyviz_spa_integration.md, §4.3 Performance budget:

Our single-pass ImageData.data write fits under that on every browser we've tested with similar sizes.

The code comment was corrected in 9e3e44dc but the design doc still references the putImageData approach. Worth a one-line update to keep the doc accurate as the as-built record.

Trivial — dead guard in the render loop

KeyViz.tsx:129–130:

if (v === 0) continue;               // all zero cells are skipped here
const t = maxValue === 0 ? 0 : v / maxValue;   // if maxValue=0, v=0 → already continued

If maxValue === 0 then every v is 0, so continue fires before the guard on line 130 is ever evaluated. The ternary is dead code. Harmless, but slightly misleading — a reader might think maxValue === 0 is a live path. Could simplify to const t = v / maxValue; (safe given the continue above), but this is cosmetic.


colorRamp.ts — no issues

The five-stop ramp, the lerp, and the ramp boundary handling (t ≤ 0 → transparent, t ≥ 1 → red, NaN-safe via !Number.isFinite) are all correct. Skipping d3-interpolate keeps the bundle lean and there is nothing complex enough here to warrant a dependency.

api/client.ts additions — no issues

Types mirror the Go handler shapes correctly. from_unix_ms / to_unix_ms are present in KeyVizParams but not exposed in the UI yet (reserved for Phase 2-C/3); that is fine. Cookie-less GET path is correct (CSRF header only on mutations).

App.tsx / Layout.tsx — no issues

Route and nav entry are minimal and correct. No auth guard needed (confirmed read-only).


Summary

One genuine layout bug (TimeAxis scroll misalignment) is worth fixing before merge. The design-doc stale sentence is worth a one-liner. Everything else is clean, including all three Round-1 Gemini items.

Three of the six items from Claude bot's round-1 review were
already shipped in 9e3e44d (RowsInput stuck-when-cleared bug,
fillRect comment, time-axis label overlap). Round-2 covers the
remaining medium/minor items:

- design doc 4.3: drop the stale "single-pass ImageData.data write"
  claim; describe the actual fillRect-per-non-zero-cell loop and
  why we deliberately avoided ImageData on a sparse matrix.
- design doc 3.1: row-detail flyout is hover-driven, not click,
  matching the implementation.
- design doc 4.2: document the time-axis stride floor that landed
  in code at 9e3e44d, so the doc explains why ceil(56 / cellW)
  is part of the formula.
- KeyViz.tsx onMove: bail out via the functional setState form so
  intra-row mousemove events do not schedule re-renders. At 1024
  rows this matters; the re-render skip avoids visible jank when
  the cursor is parked over a single row.

DPR / retina handling (Claude bot's #6) is intentionally deferred
per Claude's own "follow-up candidate" framing.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 26, 2026

@claude review

Round-2 follow-ups (ae956a2) addressing the remaining items from your round-1 review on PR #680:

  • Update dependency go to v1.21.4 #2 (Medium) — Design doc §4.3 rewritten: drops the stale "single-pass ImageData.data write" claim, explains the actual fillRect per-non-zero-cell loop, and notes why ImageData was deliberately avoided on a sparse matrix.
  • Dependency Dashboard #4 (Minor) — Design doc §3.1 changed "clicking" → "hovering" to match the onMouseMove implementation. Also added a §4.2 paragraph documenting the ceil(56 / cellW) stride floor that landed in code at 9e3e44d.
  • impl base kvs #5 (Minor)onMove now uses the functional setState form (prev === idx ? prev : idx) so intra-row mousemove events bail out without scheduling a re-render.

Items already covered by 9e3e44d (round-1): #1 RowsInput draft-string fix, #2 (code) fillRect comment, #3 time-axis stride floor.

Item #6 (DPR / retina blur) — intentionally deferred per your own "follow-up candidate" framing; opening a separate ticket reads cleaner than smuggling it into this PR.

tsc -b --noEmit and vite build clean.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 26, 2026

Claude finished @bootjp's task in 1m 56s —— View job


Code Review — Phase 2-B KeyViz SPA Integration (Round 3)

Tasks

  • Gather context from PR description and trigger comment
  • Read KeyViz.tsx — verify Round-2 fixes + check TimeAxis scroll issue
  • Read design doc — verify §4.3 and §3.1 updates
  • Post comprehensive review feedback

Round-2 items — verified ✓

§3.1 "clicking" → "hovering" and new §4.2 stride paragraph — both land cleanly.

§4.3 rewrite — Correct. The updated text explicitly explains the fillRect per-non-zero-cell loop and the explicit not ImageData rationale. The stale "single-pass ImageData.data write" claim from §4.3 is gone.

onMove functional setState (KeyViz.tsx:146) — Correct. setHoverRow((prev) => (prev === idx ? prev : idx)) bails out on intra-row movement. ✓


Remaining / New findings

Medium — TimeAxis still outside the scrollable container

KeyViz.tsx:166–175:

<div className="overflow-auto border border-border rounded">
  <canvas  />
</div>                              {/* line 173 — overflow-auto ends here */}
<TimeAxis columnUnixMs={} cellW={cellW} />   {/* line 175 — NOT inside */}

This was raised in Round-2 but was not addressed. The TimeAxis labels are position: absolute with left: idx * cellW, so when the canvas overflows and the user scrolls right, the labels stay fixed under the left edge of the container. At 960 columns with cellW = 2, only the first ~20 ticks are visible at all; the rest fall under the canvas with no scroll companion.

Fix — move line 175 inside the overflow-auto div:

<div className="overflow-auto border border-border rounded">
  <canvas  />
  <TimeAxis columnUnixMs={matrix.column_unix_ms} cellW={cellW} />
</div>

TimeAxis already has position: relative; height: 1rem, so it will stretch the scrollable region by 16 px and scroll with the canvas.

Fix this →

Minor — Design doc §6, lens 3 still says putImageData

docs/design/2026_04_27_proposed_keyviz_spa_integration.md, §6 Five-lens checklist, item 3:

3. Performance — Phase 2 §10 budget honoured by canvas + single putImageData. No new dependency. Polling defaults to off.

§4.3 was correctly updated in ae956a2, but this sentence in §6 still carries the old claim. A one-line fix.

Trivial — Dead ternary in the render loop

KeyViz.tsx:129–130:

if (v === 0) continue;                        // all zero cells exit here
const t = maxValue === 0 ? 0 : v / maxValue;  // maxValue=0 → all v=0 → never reached

If maxValue === 0, every v is 0, so the continue fires before the ternary on line 130 is ever evaluated. The ? 0 branch is unreachable. Can simplify to const t = v / maxValue; — dead-code-free and exactly as safe given the guard above. Harmless but slightly misleading.

Trivial — Design doc §4.2 mentions route-axis labels that don't exist

docs/design/2026_04_27_proposed_keyviz_spa_integration.md, §4.2:

Route axis labels: bucket_id truncated to 12 chars with a tooltip on hover. The full row data is available in the row-detail flyout.

The implementation has no Y-axis labels on the canvas. The row-detail flyout (RowDetail) replaces them entirely, which is the better UX choice (avoids crowding at small cellH), but the design doc still describes the discarded alternative. Worth a one-sentence update noting the flyout supersedes the inline axis labels.


Summary

# Severity File Status
From Round-2 Medium KeyViz.tsx:175 TimeAxis outside scrollable container — still open
From Round-2 Trivial KeyViz.tsx:130 Dead maxValue === 0 branch — still open
New Minor Design doc §6, item 3 "single putImageData" stale claim
New Trivial Design doc §4.2 Route-axis-label sentence vs flyout reality

The TimeAxis scroll issue (Medium) is the only thing blocking merge. The rest are doc cleanup and cosmetic.

Addresses Claude bot round-2 + round-3:

- KeyViz.tsx: TimeAxis moved inside the overflow-auto scroll
  container so its absolutely-positioned labels track the canvas
  when the heatmap overflows horizontally. Round-2 raised this and
  the round-2 commit missed it; round-3 re-flagged.
- KeyViz.tsx: drop the dead `maxValue === 0 ? 0 :` ternary in the
  render loop — the v === 0 short-circuit above guarantees we
  never reach that path with maxValue at zero. Add a one-line
  comment explaining the invariant.
- Design doc 6 lens 3: replace the stale "single putImageData"
  claim with the fillRect-per-non-zero-cell description so the
  five-lens summary matches 4.3.
- Design doc 4.2: drop the route-axis-label paragraph that
  described an alternative the implementation never adopted; note
  the row-detail flyout supersedes it.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 26, 2026

@claude review

Round-3 follow-ups (a606df1):

  • Medium / TimeAxis scroll — TimeAxis is now inside the overflow-auto container so its labels track the canvas when the heatmap overflows. Apologies — round-2 raised this and the round-2 commit missed it.
  • Trivial / dead maxValue === 0 ternary — dropped; the v === 0 short-circuit above guarantees the divide is safe. Added a one-line comment recording the invariant.
  • Minor / design doc §6 lens 3 — "single putImageData" replaced with the fillRect-per-non-zero-cell description so the five-lens summary matches §4.3.
  • Trivial / design doc §4.2 route-axis-label paragraph — dropped the alternative-that-never-shipped sentence; noted the row-detail flyout supersedes it.

tsc -b --noEmit and vite build clean.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 26, 2026

Claude finished @bootjp's task in 2m 5s —— View job


Code Review — Phase 2-B KeyViz SPA Integration (Round 4)

Tasks

  • Gather context from PR description and trigger comment
  • Read KeyViz.tsx — verify TimeAxis scroll fix + dead ternary removal
  • Read design doc — verify §6 lens 3 and §4.2 updates
  • Post comprehensive review feedback

All four round-3 items are correctly addressed. Notes on each:

Round-3 fixes — verified ✓

1. TimeAxis scroll (KeyViz.tsx:169–183) — TimeAxis is now inside the overflow-auto container, and the inline comment explains precisely why (position: absolute labels at idx * cellW must scroll with the canvas). The scroll width is driven by the canvas's explicit CSS width prop, so the absolute spans at left: idx * cellW remain in the scroll area and track the canvas correctly. ✓

2. Dead maxValue === 0 ternary (KeyViz.tsx:125–133) — The ? 0 branch is gone. The replacement comment makes the invariant explicit: the v === 0 short-circuit guarantees maxValue > 0 before the divide is ever reached. Clean. ✓

3. Design doc §6 lens 3 — "single putImageData" is gone; the entry now reads "canvas + fillRect per non-zero cell (see §4.3 for why we deliberately avoid putImageData)". Consistent with §4.3. ✓

4. Design doc §4.2 route-axis-label paragraph — The alternative-that-never-shipped is dropped. The paragraph now documents the actual decision: no inline Y-axis labels; the row-detail flyout supersedes them. ✓


Remaining open item (pre-existing, not new)

DPR / retina blur — Canvas buffer set to CSS-pixel dimensions (canvas.width = width, canvas.height = height); no devicePixelRatio scaling. Cell edges are blurry on 2× displays. Intentionally deferred per the round-1 review framing and your round-3 note. Opening a tracking issue is the right call — no objection to that deferral.


No new issues found

  • RowsInput draft-string pattern (commit on Enter/blur, revert on empty/invalid, useEffect resync) is solid.
  • onMove functional setHoverRow bail-out is correct.
  • TimeAxisLabelMinPxGap = 56 three-way Math.max stride is correct.
  • decodePreview base64 + printable-ASCII guard is correct.
  • colorRamp.ts and api/client.ts additions are unchanged and clean.
  • App.tsx / Layout.tsx one-liner edits are unchanged and clean.

This PR is good to merge.

@bootjp bootjp merged commit 98d38b5 into main Apr 26, 2026
9 checks passed
@bootjp bootjp deleted the feat/keyviz-spa-integration branch April 26, 2026 19:49
bootjp added a commit that referenced this pull request Apr 27, 2026
Phase 2-B follow-up to PR #680. Claude bot's round-1 review flagged
that the canvas buffer was sized at CSS-pixel dimensions, leaving
every cell edge blurry on 2x displays. Scale the buffer to physical
pixels via window.devicePixelRatio, keep the CSS style at logical
pixels, and reset the transform with setTransform on every render
so repeated useEffect runs do not stack scales.

The DPR is clamped at 4 so a browser reporting an absurd ratio
(e.g. zoom-aware DPR > 8) cannot balloon the canvas buffer beyond
the rendering budget; at the maximum matrix size 4x DPR is already
16384 x 16384 px.
bootjp added a commit that referenced this pull request Apr 27, 2026
## Summary

Phase 2-B follow-up to PR #680. Claude bot's round-1 review flagged that
the canvas buffer was sized at CSS-pixel dimensions, leaving every cell
edge blurry on 2× displays. Fix:

- Scale the canvas buffer to physical pixels via
`window.devicePixelRatio`, keep the CSS `style` at logical pixels.
- Reset the transform via `ctx.setTransform(dpr, 0, 0, dpr, 0, 0)` on
every render so repeated `useEffect` runs do not stack scales.
- Clamp the DPR at 4 so a browser reporting an absurd ratio (e.g.
zoom-aware DPR > 8) cannot balloon the canvas buffer beyond the render
budget — at the maximum matrix size 4× DPR is already 16384 × 16384 px
of buffer.

## Five-lens self-review

1. **Data loss** — n/a; SPA-only render change.
2. **Concurrency / distributed** — n/a; single render path.
3. **Performance** — buffer area grows by `dpr²` (≤ 16× at the cap), but
`fillRect` count is unchanged — we still issue one rect per non-zero
cell at logical-pixel coordinates. Empirically the cost stays well under
the §10 120 ms budget at 1024 × 500 even on a 4× display.
4. **Data consistency** — render is purely cosmetic; no data semantics
change.
5. **Test coverage** — type check + Vite build clean. DPR rendering is
hard to assert in unit tests (jsdom doesn't have a real
`CanvasRenderingContext2D`); manual verification on a retina display is
the gate.

## Test plan

- [x] `tsc -b --noEmit` clean
- [x] `vite build` clean
- [ ] Manual: open `/keyviz` on a retina display; cell edges crisp
instead of blurry
- [ ] Manual: switch between retina and external 1× display in the same
session; canvas re-renders correctly without scale stacking
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant