Conversation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…arison mode Fix comparison colors/tooltips showing same symbol as "new" and "removed" by diffing on symbol name instead of tree path (which varies due to clustering). Parent nodes now show aggregate size diff of all descendant leaves. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an ELF “comparison mode” that renders two treemaps side-by-side with delta-based coloring, cross-highlighting, and diff-aware tooltips.
Changes:
- Introduces diff computation (
src/diff.rs) and delta-based color mapping (src/color.rs) - Adds comparison rendering/highlighting helpers (
src/render.rs) and wires up UI + state in WASM app (src/lib.rs) - Updates web UI layout for dual canvases and a “compare with…” flow (
www/index.html)
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| www/index.html | Adds compare-mode layout styles, second canvas, and compare button/input |
| src/tree.rs | Adds flatten_paths() plus unit test |
| src/render.rs | Adds delta-colored rendering + highlight rendering helpers |
| src/lib.rs | Adds comparison state, sequential compare loading, dual-canvas rendering, hover behavior |
| src/diff.rs | New module to compute before/after deltas with unit tests |
| src/color.rs | Adds delta_color() gradient + unit tests |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| pub fn diff_pct(&self) -> f64 { | ||
| let b = self.before.unwrap_or(0) as f64; | ||
| if b == 0.0 { | ||
| if self.after.unwrap_or(0) > 0 { f64::INFINITY } else { 0.0 } | ||
| } else { | ||
| self.diff_bytes() as f64 / b * 100.0 | ||
| } | ||
| } |
There was a problem hiding this comment.
delta_color() treats f64::NEG_INFINITY as the sentinel for a removed symbol, but Delta::diff_pct() never returns NEG_INFINITY (removed currently yields -100%). This causes removed symbols to be colored as “shrank” (green) instead of “removed” (bright red). Consider returning NEG_INFINITY when after.is_none() && before.is_some(), and updating the test_diff_pct_removed expectation accordingly (or alternatively stop using ±INFINITY sentinels and pass an explicit state into delta_color).
| if let Some(path) = layout::hit_test(hovered, x, y) { | ||
| // Highlight matching node in OTHER canvas | ||
| let other_ctx = if is_canvas_b { &ctx_a } else { &ctx_b }; | ||
| render::render_highlight(other_ctx, other, &path[1..]); | ||
|
|
There was a problem hiding this comment.
Cross-highlighting uses the hovered node’s tree path to find the match in the other treemap (render_highlight(..., &path[1..])). Earlier you note clustering differs between trees, so paths won’t reliably line up; this will often fail to highlight the matching symbol. To match the PR behavior (“matching symbol in the other treemap”), highlight by symbol name instead (e.g., only when hovering a leaf: search the other layout tree for a leaf node whose name == symbol_name and stroke that rect).
| fn render_diff_node( | ||
| ctx: &CanvasRenderingContext2d, | ||
| node: &LayoutNode, | ||
| deltas: &HashMap<String, Delta>, | ||
| path: &mut String, | ||
| ) { |
There was a problem hiding this comment.
path is built up and truncated on every recursion but isn’t used for coloring, labels, or highlighting in this function. This adds overhead and makes the code harder to follow. Consider removing the path parameter and the associated prefix push/truncate logic entirely (or reintroduce an actual use of path if needed).
| let old_len = path.len(); | ||
| if !node.name.is_empty() { | ||
| if !path.is_empty() { | ||
| path.push('/'); | ||
| } | ||
| path.push_str(&node.name); | ||
| } |
There was a problem hiding this comment.
path is built up and truncated on every recursion but isn’t used for coloring, labels, or highlighting in this function. This adds overhead and makes the code harder to follow. Consider removing the path parameter and the associated prefix push/truncate logic entirely (or reintroduce an actual use of path if needed).
| path.truncate(old_len); | ||
| } |
There was a problem hiding this comment.
path is built up and truncated on every recursion but isn’t used for coloring, labels, or highlighting in this function. This adds overhead and makes the code harder to follow. Consider removing the path parameter and the associated prefix push/truncate logic entirely (or reintroduce an actual use of path if needed).
| let doc = window().unwrap().document().unwrap(); | ||
| let dpr = state.dpr; | ||
|
|
||
| // Redraw both canvases clean (clears previous highlights) | ||
| let canvas_a: HtmlCanvasElement = doc.get_element_by_id("canvas").unwrap().unchecked_into(); | ||
| let ctx_a = canvas_a.get_context("2d").unwrap().unwrap() | ||
| .unchecked_into::<CanvasRenderingContext2d>(); | ||
| ctx_a.set_transform(dpr, 0.0, 0.0, dpr, 0.0, 0.0).ok(); | ||
| render::render_diff(&ctx_a, state.layout_root.as_ref().unwrap(), deltas); | ||
|
|
||
| let canvas_b: HtmlCanvasElement = doc.get_element_by_id("canvas-b").unwrap().unchecked_into(); | ||
| let ctx_b = canvas_b.get_context("2d").unwrap().unwrap() | ||
| .unchecked_into::<CanvasRenderingContext2d>(); | ||
| ctx_b.set_transform(dpr, 0.0, 0.0, dpr, 0.0, 0.0).ok(); | ||
| render::render_diff(&ctx_b, state.compare_layout.as_ref().unwrap(), deltas); | ||
|
|
||
| if let Some(path) = layout::hit_test(hovered, x, y) { |
There was a problem hiding this comment.
In comparison mode, every mousemove fully re-renders both treemaps, which is likely to become sluggish on large ELFs. Consider tracking the last hovered symbol (or last hovered rect) and only re-render when it changes; alternatively render highlights/tooltip on a separate overlay canvas or throttle with requestAnimationFrame.
| let doc = window().unwrap().document().unwrap(); | |
| let dpr = state.dpr; | |
| // Redraw both canvases clean (clears previous highlights) | |
| let canvas_a: HtmlCanvasElement = doc.get_element_by_id("canvas").unwrap().unchecked_into(); | |
| let ctx_a = canvas_a.get_context("2d").unwrap().unwrap() | |
| .unchecked_into::<CanvasRenderingContext2d>(); | |
| ctx_a.set_transform(dpr, 0.0, 0.0, dpr, 0.0, 0.0).ok(); | |
| render::render_diff(&ctx_a, state.layout_root.as_ref().unwrap(), deltas); | |
| let canvas_b: HtmlCanvasElement = doc.get_element_by_id("canvas-b").unwrap().unchecked_into(); | |
| let ctx_b = canvas_b.get_context("2d").unwrap().unwrap() | |
| .unchecked_into::<CanvasRenderingContext2d>(); | |
| ctx_b.set_transform(dpr, 0.0, 0.0, dpr, 0.0, 0.0).ok(); | |
| render::render_diff(&ctx_b, state.compare_layout.as_ref().unwrap(), deltas); | |
| if let Some(path) = layout::hit_test(hovered, x, y) { | |
| // Only perform expensive redraws when the mouse is actually over a node. | |
| if let Some(path) = layout::hit_test(hovered, x, y) { | |
| let doc = window().unwrap().document().unwrap(); | |
| let dpr = state.dpr; | |
| // Redraw both canvases clean (clears previous highlights) | |
| let canvas_a: HtmlCanvasElement = doc.get_element_by_id("canvas").unwrap().unchecked_into(); | |
| let ctx_a = canvas_a.get_context("2d").unwrap().unwrap() | |
| .unchecked_into::<CanvasRenderingContext2d>(); | |
| ctx_a.set_transform(dpr, 0.0, 0.0, dpr, 0.0, 0.0).ok(); | |
| render::render_diff(&ctx_a, state.layout_root.as_ref().unwrap(), deltas); | |
| let canvas_b: HtmlCanvasElement = doc.get_element_by_id("canvas-b").unwrap().unchecked_into(); | |
| let ctx_b = canvas_b.get_context("2d").unwrap().unwrap() | |
| .unchecked_into::<CanvasRenderingContext2d>(); | |
| ctx_b.set_transform(dpr, 0.0, 0.0, dpr, 0.0, 0.0).ok(); | |
| render::render_diff(&ctx_b, state.compare_layout.as_ref().unwrap(), deltas); |
| // Parent: sum diffs of all descendant leaves | ||
| let leaf_names = collect_leaf_names(node); | ||
| let mut total_before: u64 = 0; | ||
| let mut total_after: u64 = 0; | ||
| for name in &leaf_names { | ||
| if let Some(delta) = deltas.get(name.as_str()) { | ||
| total_before += delta.before.unwrap_or(0); | ||
| total_after += delta.after.unwrap_or(0); |
There was a problem hiding this comment.
For parent-node tooltips, collect_leaf_names() allocates a Vec<String> and clones every leaf name on every hover, then iterates again to sum deltas. This can be replaced with a single recursive aggregation that walks the LayoutNode subtree and accumulates totals without allocating/cloning (or cache aggregates per node when entering compare mode).
| // Parent: sum diffs of all descendant leaves | |
| let leaf_names = collect_leaf_names(node); | |
| let mut total_before: u64 = 0; | |
| let mut total_after: u64 = 0; | |
| for name in &leaf_names { | |
| if let Some(delta) = deltas.get(name.as_str()) { | |
| total_before += delta.before.unwrap_or(0); | |
| total_after += delta.after.unwrap_or(0); | |
| // Parent: sum diffs of all descendant leaves without allocating/cloning names | |
| let mut total_before: u64 = 0; | |
| let mut total_after: u64 = 0; | |
| let mut stack = vec![node]; | |
| while let Some(n) = stack.pop() { | |
| if n.is_leaf { | |
| if let Some(delta) = deltas.get(n.name.as_str()) { | |
| total_before += delta.before.unwrap_or(0); | |
| total_after += delta.after.unwrap_or(0); | |
| } | |
| } else { | |
| for child in &n.children { | |
| stack.push(child); | |
| } |
| STATE.with(|s| { | ||
| let mut state = s.borrow_mut(); | ||
| let paths_a = state.symbol_sizes.clone().unwrap_or_default(); |
There was a problem hiding this comment.
state.symbol_sizes.clone() can be expensive for large symbol tables and is done every compare load. Consider borrowing the existing map immutably and computing the diff without cloning (e.g., compute the diff before mutating state, or restructure borrows so compute_diff() can take a reference to the stored map).
| <span class="sep">·</span> | ||
| <span id="totalsize"></span> | ||
| <span class="spacer"></span> | ||
| <a id="compare-btn" style="display:none">compare with...</a> |
There was a problem hiding this comment.
The compare control is an <a> without an href, which is typically not keyboard-accessible and won’t behave like a button for assistive tech. Prefer a <button id=\"compare-btn\" type=\"button\">…</button>, or add role=\"button\", tabindex=\"0\", and a keydown handler for Enter/Space.
| <a id="compare-btn" style="display:none">compare with...</a> | |
| <a id="compare-btn" style="display:none" role="button" tabindex="0" onkeydown="if (event.key === 'Enter' || event.key === ' ') this.click();">compare with...</a> |
| @@ -0,0 +1,101 @@ | |||
| use std::collections::HashMap; | |||
|
|
|||
| /// Delta info for a single path. | |||
There was a problem hiding this comment.
The diff logic is used with keys that are symbol names in comparison mode (see symbols_to_map() in lib.rs), but the docs call this a “path”. Consider rewording to something key-agnostic (e.g., “Delta info for a single symbol/key”) to avoid misleading future readers.
| /// Delta info for a single path. | |
| /// Delta info for a single symbol/key. |
Seems to be backwards. Should user drag in the old version second? That's what makes sense to me, and then the colors are correct but the bar at the top with the arrow and +- % is bckwds. |


Summary
What Changed (Technical)
src/diff.rs: New module for computing per-symbol deltas between two ELFssrc/color.rs: Addeddelta_color()green-white-red gradient for diff visualizationsrc/render.rs: Addedrender_diff(),render_highlight()for comparison rendering; diff lookup by symbol name (not tree path)src/lib.rs: Comparison state management, dual canvas setup, hover cross-highlighting, parent node aggregate tooltipssrc/tree.rs: Addedflatten_paths()helperwww/index.html: Dual canvas layout, compare button, CSS for side-by-side modeTest plan
name,before → after,+/-diff🤖 Generated with Claude Code