mirror of
https://github.com/encounter/decomp.dev.git
synced 2026-07-10 03:18:48 -07:00
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:
co-authored by
Luke Street
parent
4d99772933
commit
39fa6ad3bb
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -9,6 +9,8 @@ type Unit = {
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
// Runtime fields
|
||||
filtered: boolean;
|
||||
};
|
||||
|
||||
type Measures = {
|
||||
|
||||
@@ -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)}%`;
|
||||
}
|
||||
|
||||
|
||||
+176
-4
@@ -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 (unit?.filtered) {
|
||||
canvas.style.cursor = 'default';
|
||||
hovered = null;
|
||||
} else {
|
||||
if (clickable) {
|
||||
canvas.style.cursor = unit ? 'pointer' : 'default';
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "decompal-rs",
|
||||
"name": "decomp.dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
Reference in New Issue
Block a user