Skip to content

feat(elfvis): ELF comparison mode#2

Open
marciogranzotto wants to merge 9 commits intomainfrom
feat/elf-comparison
Open

feat(elfvis): ELF comparison mode#2
marciogranzotto wants to merge 9 commits intomainfrom
feat/elf-comparison

Conversation

@marciogranzotto
Copy link

Summary

  • Side-by-side treemap comparison of two ELF files with delta coloring (green=shrank, red=grew, white=unchanged)
  • Cross-highlight on hover shows matching symbol in the other treemap
  • Tooltips show before→after sizes with diff for both leaf symbols and parent nodes (aggregate)
  • New symbols shown in bright green, removed in bright red
  • Sequential loading: load first ELF, then click "compare with..." to load second

What Changed (Technical)

  • src/diff.rs: New module for computing per-symbol deltas between two ELFs
  • src/color.rs: Added delta_color() green-white-red gradient for diff visualization
  • src/render.rs: Added render_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 tooltips
  • src/tree.rs: Added flatten_paths() helper
  • www/index.html: Dual canvas layout, compare button, CSS for side-by-side mode

Test plan

  • Load an ELF file, click "compare with...", load a second ELF
  • Verify side-by-side treemaps with green/red/white coloring
  • Hover symbols: tooltip shows name, before → after, +/-diff
  • Hover parent nodes: tooltip shows aggregate diff of all children
  • Same symbol in both ELFs shows correct delta (not "new"/"removed")
  • "load another file" resets comparison state cleanly

🤖 Generated with Claude Code

marciogranzotto and others added 9 commits March 5, 2026 16:31
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>
@marciogranzotto marciogranzotto marked this pull request as ready for review March 5, 2026 20:21
Copilot AI review requested due to automatic review settings March 5, 2026 20:21
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +15 to +22
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
}
}
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment on lines +489 to +493
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..]);

Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment on lines +179 to +184
fn render_diff_node(
ctx: &CanvasRenderingContext2d,
node: &LayoutNode,
deltas: &HashMap<String, Delta>,
path: &mut String,
) {
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment on lines +189 to +195
let old_len = path.len();
if !node.name.is_empty() {
if !path.is_empty() {
path.push('/');
}
path.push_str(&node.name);
}
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment on lines +248 to +249
path.truncate(old_len);
}
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment on lines +473 to +489
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) {
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +518 to +525
// 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);
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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).

Suggested change
// 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);
}

Copilot uses AI. Check for mistakes.
Comment on lines +364 to +366
STATE.with(|s| {
let mut state = s.borrow_mut();
let paths_a = state.symbol_sizes.clone().unwrap_or_default();
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
<span class="sep">&middot;</span>
<span id="totalsize"></span>
<span class="spacer"></span>
<a id="compare-btn" style="display:none">compare with...</a>
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
<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>

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,101 @@
use std::collections::HashMap;

/// Delta info for a single path.
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
/// Delta info for a single path.
/// Delta info for a single symbol/key.

Copilot uses AI. Check for mistakes.
@chrismerck
Copy link
Contributor

Pretty slick! Thanks for contribution.

One issue I notice is that the labels for the directories tend not to render in comparison mode, but they show up in the main viewer.

Comparing the BigAss fans support between most recent Bond Bridge stable and old stable:

image image

Suggestion: support drag-and-drop into the tab, puts some boarder around the "compare with..." as a hint that it's a drop zone.

@chrismerck
Copy link
Contributor

New symbols shown in bright green, removed in bright red

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.

@chrismerck
Copy link
Contributor

Ooo, other issue, seems to be showing up many of the directories twice. 🤔

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants