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 <luke@street.dev>
This commit is contained in:
LagoLunatic
2025-07-11 23:38:59 -06:00
committed by GitHub
co-authored by Luke Street
parent 4d99772933
commit 39fa6ad3bb
7 changed files with 235 additions and 20 deletions
+20 -7
View File
@@ -1,4 +1,4 @@
use palette::{Mix, Srgb};
use palette::{FromColor, Hsl, Mix, Srgb};
use streemap::Rect;
pub fn layout_units<T, S, R>(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)
}
+3
View File
@@ -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() {
+27 -5
View File
@@ -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("<?xml version=\"1.0\" encoding=\"utf-8\"?>"))
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})")) {}
}
}
}
Vendored
+2
View File
@@ -9,6 +9,8 @@ type Unit = {
y: number;
w: number;
h: number;
// Runtime fields
filtered: boolean;
};
type Measures = {
+3
View File
@@ -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)}%`;
}
+179 -7
View File
@@ -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();
}
}
})();
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "decompal-rs",
"name": "decomp.dev",
"lockfileVersion": 3,
"requires": true,
"packages": {