From 39fa6ad3bbf21b75f06eb77de9dd938fc8c91f31 Mon Sep 17 00:00:00 2001 From: LagoLunatic Date: Sat, 12 Jul 2025 01:38:59 -0400 Subject: [PATCH] Implement treemap filtering (#7) * Fix package-lock * Implement treemap filtering * Fix missing return * Switch treemap algorithm to binary to reduce thin rectangles * Dim filtered out units instead of hiding them * Draw stroke behind fill This prevents a unit's color from being covered up by its own black outline. * Clip filtered unit outlines This is to prevent thin highlighted units from being hidden behind the outline of a filtered out unit. * Fix near-100 percentages being rounded up to 100% * Use radial gradients in treemap units * Also update svg generation to use blue for near-complete * Restore cached canvas, biome fmt * Update svg rendering with gradients * Implement filter URL param to link a treemap search * One more type hint * Increase blue saturation for partial match * Update blue colors --------- Co-authored-by: Luke Street --- crates/images/src/treemap.rs | 27 +++-- crates/web/src/handlers/report.rs | 3 + crates/web/src/handlers/treemap.rs | 32 ++++- js/env.d.ts | 2 + js/history.ts | 3 + js/treemap.ts | 186 +++++++++++++++++++++++++++-- package-lock.json | 2 +- 7 files changed, 235 insertions(+), 20 deletions(-) diff --git a/crates/images/src/treemap.rs b/crates/images/src/treemap.rs index fdb733b..d544f30 100644 --- a/crates/images/src/treemap.rs +++ b/crates/images/src/treemap.rs @@ -1,4 +1,4 @@ -use palette::{Mix, Srgb}; +use palette::{FromColor, Hsl, Mix, Srgb}; use streemap::Rect; pub fn layout_units(items: &mut [T], aspect: f32, size_fn: S, mut set_rect_fn: R) @@ -11,7 +11,7 @@ where } else { Rect::from_size(aspect, 1.0) }; - streemap::ordered_pivot_by_middle(rect, items, size_fn, |item, mut rect| { + streemap::binary(rect, items, size_fn, |item, mut rect| { if aspect > 1.0 { rect.y *= aspect; rect.h *= aspect; @@ -23,13 +23,26 @@ where }); } -fn rgb(r: u8, g: u8, b: u8) -> Srgb { - Srgb::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0) +pub fn hsl(h: u16, s: u8, l: u8) -> Srgb { + let hsl = Hsl::new(h as f32, s as f32 / 100.0, l as f32 / 100.0); + Srgb::from_color(hsl) +} + +pub fn color_mix(c1: Srgb, c2: Srgb, percent: f32) -> Srgb { + c1.mix(c2, percent) } pub fn unit_color(fuzzy_match_percent: f32) -> String { - let red = rgb(42, 49, 64); - let green = rgb(0, 200, 0); - let (r, g, b) = red.mix(green, fuzzy_match_percent / 100.0).into_components(); + html_color(if fuzzy_match_percent == 100.0 { + hsl(120, 100, 39) + } else { + let nonmatch = hsl(221, 0, 21); + let nearmatch = hsl(221, 100, 35); + nonmatch.mix(nearmatch, fuzzy_match_percent / 100.0) + }) +} + +pub fn html_color(c: Srgb) -> String { + let (r, g, b) = c.into_components(); format!("#{:02x}{:02x}{:02x}", (r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8) } diff --git a/crates/web/src/handlers/report.rs b/crates/web/src/handlers/report.rs index 4bf53c1..237858c 100644 --- a/crates/web/src/handlers/report.rs +++ b/crates/web/src/handlers/report.rs @@ -959,6 +959,9 @@ async fn render_report( } } } + label { + input name="filter" required placeholder="Filter, e.g.: 'camera <70% >10kb'"; + } @if units.is_empty() { p.muted { @if current_unit.is_some() { diff --git a/crates/web/src/handlers/treemap.rs b/crates/web/src/handlers/treemap.rs index 891fc6b..b845bda 100644 --- a/crates/web/src/handlers/treemap.rs +++ b/crates/web/src/handlers/treemap.rs @@ -1,22 +1,44 @@ use anyhow::Result; -use decomp_dev_images::svg; +use decomp_dev_images::{ + svg, + treemap::{color_mix, hsl, html_color}, +}; use image::ImageFormat; use maud::{PreEscaped, html}; use crate::handlers::report::ReportTemplateUnit; pub fn render_svg(units: &[ReportTemplateUnit], w: u32, h: u32) -> String { + let complete_c0 = html_color(hsl(120, 100, 39)); + let complete_c1 = html_color(hsl(120, 100, 17)); html! { (PreEscaped("")) - svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox=(format!("0 0 {w} {h}")) { - style { ".unit { stroke: #000; stroke-width: 1; }" } - @for unit in units { + svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox=(format!("0 0 {w} {h}")) width=(w) height=(h) { + style { ".unit { stroke: #000; stroke-width: 0.5; }" } + @for (i, unit) in units.iter().enumerate() { + radialGradient id=(format!("unit-{i}")) + gradientUnits="userSpaceOnUse" + cx=(format!("{}%", (unit.x + (unit.w * 0.4)) * 100.0)) + cy=(format!("{}%", (unit.y + (unit.h * 0.4)) * 100.0)) + fr=(format!("{}%", (unit.w + unit.h) * 10.0)) + r=(format!("{}%", (unit.w + unit.h) * 50.0)) { + @let pct = unit.fuzzy_match_percent; + @if pct == 100.0 { + stop offset="0%" stop-color=(complete_c0) {} + stop offset="100%" stop-color=(complete_c1) {} + } @else { + stop offset="0%" stop-color=(html_color(color_mix(hsl(200, 0, 21), hsl(200, 100, 35), pct / 100.0))) {} + stop offset="100%" stop-color=(html_color(color_mix(hsl(200, 0, 15), hsl(200, 100, 15), pct / 100.0))) {} + } + } + } + @for (i, unit) in units.iter().enumerate() { rect.unit width=(format!("{}%", unit.w * 100.0)) height=(format!("{}%", unit.h * 100.0)) x=(format!("{}%", unit.x * 100.0)) y=(format!("{}%", unit.y * 100.0)) - fill=(unit.color) {} + fill=(format!("url(#unit-{i})")) {} } } } diff --git a/js/env.d.ts b/js/env.d.ts index b8fdded..646de22 100644 --- a/js/env.d.ts +++ b/js/env.d.ts @@ -9,6 +9,8 @@ type Unit = { y: number; w: number; h: number; + // Runtime fields + filtered: boolean; }; type Measures = { diff --git a/js/history.ts b/js/history.ts index 1d63ccc..0880cb1 100644 --- a/js/history.ts +++ b/js/history.ts @@ -12,6 +12,9 @@ function percentValue( _seriesIdx: number, _idx: number | null, ) { + if (rawValue > 99.99 && rawValue < 100.0) { + rawValue = 99.99; + } return rawValue == null ? '' : `${rawValue.toFixed(2)}%`; } diff --git a/js/treemap.ts b/js/treemap.ts index 7fa5fcb..c0de719 100644 --- a/js/treemap.ts +++ b/js/treemap.ts @@ -64,9 +64,13 @@ const drawTooltip = ( ctx.textBaseline = 'middle'; const { x, y, w, h } = unitBounds(unit, width, height); + let percent = unit.fuzzy_match_percent; + if (percent > 99.99 && percent < 100.0) { + percent = 99.99; + } const text = ellipsize( ctx, - `${unit.name} • ${formatSize(unit.total_code)} • ${unit.fuzzy_match_percent.toFixed(2)}%`, + `${unit.name} • ${formatSize(unit.total_code)} • ${percent.toFixed(2)}%`, width, ); const m = ctx.measureText(text); @@ -129,6 +133,7 @@ let hovered: Unit | null = null; let dirty = false; let isTouch = false; let cachedCanvas: HTMLCanvasElement | null = null; +let unitsDirty = false; const setup = ( ctx: CanvasRenderingContext2D, @@ -138,6 +143,10 @@ const setup = ( ) => { ctx.setTransform(ratio, 0, 0, ratio, 0, 0); // Scale to device pixel ratio ctx.clearRect(0, 0, width, height); + // Clear the canvas with dark mode's background color, even in light mode. + // This is so that transparency doesn't make the canvas look bad in light mode. + ctx.fillStyle = '#181c25'; + ctx.fillRect(0, 0, width, height); ctx.lineWidth = 1; ctx.strokeStyle = '#000'; }; @@ -150,11 +159,40 @@ const drawUnits = ( ) => { for (const unit of units) { const { x, y, w, h } = unitBounds(unit, width, height); - ctx.fillStyle = unit.color; + + let innerColor: string; + let outerColor: string; + if (unit.fuzzy_match_percent === 100.0) { + innerColor = 'hsl(120 100% 39%)'; + outerColor = 'hsl(120 100% 17%)'; + } else { + innerColor = `color-mix(in srgb, hsl(200 0% 21%), hsl(200 100% 35%) ${unit.fuzzy_match_percent}%)`; + outerColor = `color-mix(in srgb, hsl(200 0% 15%), hsl(200 100% 15%) ${unit.fuzzy_match_percent}%)`; + } + const cx = x + w * 0.4; + const cy = y + h * 0.4; + const r0 = (w + h) * 0.1; + const r1 = (w + h) * 0.5; + const gradient = ctx.createRadialGradient(cx, cy, r0, cx, cy, r1); + gradient.addColorStop(0, innerColor); + gradient.addColorStop(1, outerColor); + ctx.fillStyle = gradient; + ctx.beginPath(); ctx.rect(x, y, w, h); - ctx.fill(); + + ctx.save(); + if (unit.filtered) { + ctx.clip(); + } ctx.stroke(); + ctx.restore(); + + if (unit.filtered) { + ctx.globalAlpha = 0.1; + } + ctx.fill(); + ctx.globalAlpha = 1.0; } }; @@ -165,6 +203,7 @@ const draw = (canvas: HTMLCanvasElement, units: Unit[]) => { const renderHeight = Math.round(height * ratio); if ( !dirty && + !unitsDirty && canvas.width === renderWidth && canvas.height === renderHeight ) { @@ -181,10 +220,13 @@ const draw = (canvas: HTMLCanvasElement, units: Unit[]) => { if (!cachedCanvas) { cachedCanvas = document.createElement('canvas'); } + if ( + unitsDirty || cachedCanvas.width !== renderWidth || cachedCanvas.height !== renderHeight ) { + unitsDirty = false; cachedCanvas.width = renderWidth; cachedCanvas.height = renderHeight; const cachedCtx = cachedCanvas.getContext('2d'); @@ -194,6 +236,7 @@ const draw = (canvas: HTMLCanvasElement, units: Unit[]) => { setup(cachedCtx, ratio, width, height); drawUnits(cachedCtx, units, width, height); } + const ctx = canvas.getContext('2d'); if (!ctx) { return; @@ -220,11 +263,30 @@ const findUnit = ( const { width, height, left, top } = canvas.getBoundingClientRect(); const mx = clientX - left; const my = clientY - top; + let nearOverlapUnit: Unit | null = null; + const epsilon = 3; for (const unit of units) { + if (unit.filtered) { + continue; + } const { x, y, w, h } = unitBounds(unit, width, height); if (mx >= x && mx <= x + w && my >= y && my <= y + h) { return unit; } + // If the unit doesn't exactly overlap the cursor, check if it's within a few pixels of overlapping. + // This is needed to make it possible to hover and click units that have subpixel widths/heights. + if ( + !nearOverlapUnit && + mx >= x - epsilon && + mx <= x + w + epsilon && + my >= y - epsilon && + my <= y + h + epsilon + ) { + nearOverlapUnit = unit; + } + } + if (nearOverlapUnit) { + return nearOverlapUnit; } return null; }; @@ -245,10 +307,15 @@ const drawTreemap = (id: string, clickable: boolean, units: Unit[]) => { if (unit === hovered) { return; } - if (clickable) { - canvas.style.cursor = unit ? 'pointer' : 'default'; + if (unit?.filtered) { + canvas.style.cursor = 'default'; + hovered = null; + } else { + if (clickable) { + canvas.style.cursor = unit ? 'pointer' : 'default'; + } + hovered = unit; } - hovered = unit; dirty = true; queueDraw(); }; @@ -263,6 +330,35 @@ const drawTreemap = (id: string, clickable: boolean, units: Unit[]) => { dirty = true; queueDraw(); }; + + const updateFilter = (filter: string) => { + // Separate multiple different filter terms with spaces. + const terms = filter.toLowerCase().split(/\s+/); + for (const unit of units) { + unit.filtered = !terms.every((term) => + checkFilterTermMatches(term, unit), + ); + } + unitsDirty = true; + queueDraw(); + }; + const handleFilter = (evt: Event) => { + if ( + evt.currentTarget === null || + !(evt.currentTarget instanceof HTMLInputElement) + ) { + return; + } + updateFilter(evt.currentTarget.value); + const url = new URL(window.location.href); + if (evt.currentTarget.value) { + url.searchParams.set('filter', evt.currentTarget.value); + } else { + url.searchParams.delete('filter'); + } + window.history.replaceState({}, '', url); + }; + canvas.addEventListener('mousemove', (e) => { isTouch = false; handleHover(e); @@ -275,13 +371,19 @@ const drawTreemap = (id: string, clickable: boolean, units: Unit[]) => { canvas.addEventListener('touchend', handleLeave); canvas.addEventListener('click', ({ clientX, clientY }) => { const unit = findUnit(canvas, units, clientX, clientY); - if (!unit || !unit.name || !clickable) { + if (!unit || !unit.name || unit.filtered || !clickable) { return; } const url = new URL(window.location.href); url.searchParams.set('unit', unit.name); + url.searchParams.delete('filter'); window.location.href = url.toString(); }); + const filterInput = document.querySelector('input[name="filter"]'); + if (filterInput && filterInput instanceof HTMLInputElement) { + updateFilter(filterInput.value); // Initialize on page load + filterInput.addEventListener('input', handleFilter); + } updatePixelRatio(queueDraw, false); draw(canvas, units); }; @@ -303,4 +405,74 @@ const updatePixelRatio = (redraw: () => void, now: boolean) => { } }; +const SPECIAL_TERM_REGEXP = new RegExp( + `^(>|<|>=|<=|=|==|!=)(\\d+(?:\\.\\d+)?)(%|${UNITS.join('|')})$`, + 'i', +); + +const checkFilterTermMatches = (term: string, unit: Unit): boolean => { + const match = term.match(SPECIAL_TERM_REGEXP); + if (match) { + // Filter based on match percent or size. + const operator = match[1]; + const type = match[3]; + + let lhs: number; + let rhs: number; + switch (type) { + case '%': + // Match percent + lhs = unit.fuzzy_match_percent; + rhs = Number.parseFloat(match[2]); + break; + default: { + // Size unit, e.g. kB + lhs = unit.total_code; + rhs = Number.parseFloat(match[2]); + let sizeUnitIndex = 0; + while (sizeUnitIndex < UNITS.length - 1) { + if (type.toLowerCase() === UNITS[sizeUnitIndex].toLowerCase()) { + break; + } + rhs *= 1000.0; + sizeUnitIndex += 1; + } + break; + } + } + + switch (operator) { + case '>': + return lhs > rhs; + case '<': + return lhs < rhs; + case '>=': + return lhs >= rhs; + case '<=': + return lhs <= rhs; + case '=': + case '==': + return lhs === rhs; + case '!=': + return lhs !== rhs; + default: + return false; + } + } + // Filter based on name. + return unit.name.toLowerCase().includes(term); +}; + window.drawTreemap = drawTreemap; + +(function () { + const url = new URL(window.location.href); + const filterFromUrl = url.searchParams.get('filter'); + if (filterFromUrl) { + const filterInput = document.querySelector('input[name="filter"]'); + if (filterInput && filterInput instanceof HTMLInputElement) { + filterInput.value = filterFromUrl; + filterInput.scrollIntoView(); + } + } +})(); diff --git a/package-lock.json b/package-lock.json index 39c96f2..1148712 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "decompal-rs", + "name": "decomp.dev", "lockfileVersion": 3, "requires": true, "packages": {