Files
roeming 220619d01d Add filter option for linked/unlinked TUs (#20)
* forward linked status to ts

* Add filter check for linked/unlinked TUs

* functions reflect linking status of TU

* use correct ts boolean type
2026-01-31 19:02:48 -07:00

484 lines
13 KiB
TypeScript

import { formatPercent } from './util';
const unitBounds = (unit: Unit, width: number, height: number) => {
return {
x: unit.x * width,
y: unit.y * height,
w: unit.w * width,
h: unit.h * height,
};
};
const BORDER_RADIUS = 5;
const PADDING_W = 10;
const PADDING_H = 5;
const MARGIN = 5;
const ellipsize = (
ctx: CanvasRenderingContext2D,
text: string,
width: number,
) => {
const ellipsis = '…';
const padding = PADDING_W * 2;
let m = ctx.measureText(text);
if (m.actualBoundingBoxRight + m.actualBoundingBoxLeft + padding <= width) {
return text;
}
let n = 3;
while (true) {
const start = text.length / 4 - n / 4;
const ellipsized = text.slice(0, start) + ellipsis + text.slice(start + n);
m = ctx.measureText(ellipsized);
if (m.actualBoundingBoxRight + m.actualBoundingBoxLeft + padding <= width) {
return ellipsized;
}
n++;
}
};
const UNITS = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const formatSize = (value: number): string => {
let unit = 0;
while (value >= 1000.0 && unit < UNITS.length - 1) {
// biome-ignore lint/style/noParameterAssign: don't care
value /= 1000.0;
unit += 1;
}
return `${value.toFixed(2)} ${UNITS[unit]}`;
};
const drawTooltip = (
ctx: CanvasRenderingContext2D,
unit: Unit,
width: number,
height: number,
) => {
const style = getComputedStyle(ctx.canvas);
const fontWeight = style.getPropertyValue('--font-weight') || 'normal';
const fontSize = style.getPropertyValue('--font-size') || '16px';
const fontFamily = style.getPropertyValue('--font-family') || 'sans-serif';
const tooltipBackground =
style.getPropertyValue('--tooltip-background') || '#fff';
const tooltipColor = style.getPropertyValue('--tooltip-color') || '#000';
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;
ctx.textBaseline = 'middle';
const { x, y, w, h } = unitBounds(unit, width, height);
const text = ellipsize(
ctx,
`${unit.name}${formatSize(unit.total_code)}${formatPercent(unit.fuzzy_match_percent)}`,
width,
);
const m = ctx.measureText(text);
const bw = m.actualBoundingBoxRight + m.actualBoundingBoxLeft + PADDING_W * 2;
const bh = m.fontBoundingBoxAscent + m.fontBoundingBoxDescent + PADDING_H * 2;
let bx = x + (w - bw) / 2;
let by = y - bh - MARGIN;
let ay = y;
if (isTouch) {
bx = (width - bw) / 2;
if (y + h / 2 < height / 2) {
// Draw at the bottom
by = height - bh - MARGIN;
} else {
// Draw at the top
by = MARGIN;
}
} else {
if (bx + bw > width) {
bx = width - bw;
}
if (bx < 0) {
bx = 0;
}
if (by < 0) {
// Draw below the box
by = y + h + MARGIN;
ay = y + h;
}
if (by + bh > height) {
// Draw inside the box
by = y + MARGIN;
ay = y;
}
}
ctx.fillStyle = tooltipBackground;
ctx.beginPath();
ctx.roundRect(bx, by, bw, bh, BORDER_RADIUS);
if (!isTouch) {
// Arrow
const ax = x + w / 2;
if (ay < by) {
// Top
ctx.moveTo(ax, ay);
ctx.lineTo(ax + MARGIN, by);
ctx.lineTo(ax - MARGIN, by);
} else {
// Bottom
ctx.moveTo(ax, ay);
ctx.lineTo(ax + MARGIN, by + bh);
ctx.lineTo(ax - MARGIN, by + bh);
}
}
ctx.fill();
ctx.fillStyle = tooltipColor;
ctx.fillText(text, bx + PADDING_W, by + bh / 2);
};
let hovered: Unit | null = null;
let dirty = false;
let isTouch = false;
let cachedCanvas: HTMLCanvasElement | null = null;
let unitsDirty = false;
const setup = (
ctx: CanvasRenderingContext2D,
ratio: number,
width: number,
height: number,
) => {
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';
};
const drawUnits = (
ctx: CanvasRenderingContext2D,
units: Unit[],
width: number,
height: number,
) => {
for (const unit of units) {
const { x, y, w, h } = unitBounds(unit, width, height);
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.save();
if (unit.filtered) {
ctx.clip();
}
ctx.stroke();
ctx.restore();
if (unit.filtered) {
ctx.globalAlpha = 0.1;
}
ctx.fill();
ctx.globalAlpha = 1.0;
}
};
const draw = (canvas: HTMLCanvasElement, units: Unit[]) => {
const { width, height } = canvas.getBoundingClientRect();
const ratio = window.devicePixelRatio;
const renderWidth = Math.round(width * ratio);
const renderHeight = Math.round(height * ratio);
if (
!dirty &&
!unitsDirty &&
canvas.width === renderWidth &&
canvas.height === renderHeight
) {
// Nothing changed
return;
}
dirty = false;
// High DPI support
if (canvas.width !== renderWidth || canvas.height !== renderHeight) {
canvas.width = renderWidth;
canvas.height = renderHeight;
}
// Update cached canvas if needed
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');
if (!cachedCtx) {
return;
}
setup(cachedCtx, ratio, width, height);
drawUnits(cachedCtx, units, width, height);
}
const ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
// Use 1:1 scale for rendering cached canvas
setup(ctx, 1, renderWidth, renderHeight);
ctx.drawImage(cachedCanvas, 0, 0);
ctx.scale(ratio, ratio); // Restore device scale
if (hovered) {
const { x, y, w, h } = unitBounds(hovered, width, height);
ctx.lineWidth = 2;
ctx.strokeStyle = '#fff';
ctx.strokeRect(x, y, w, h);
drawTooltip(ctx, hovered, width, height);
}
};
const findUnit = (
canvas: HTMLCanvasElement,
units: Unit[],
clientX: number,
clientY: number,
): Unit | null => {
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;
};
const drawTreemap = (id: string, clickable: boolean, units: Unit[]) => {
const canvas = document.getElementById(id) as HTMLCanvasElement;
if (!canvas || !canvas.getContext) {
return;
}
const queueDraw = () => requestAnimationFrame(() => draw(canvas, units));
const resizeObserver = new ResizeObserver(queueDraw);
resizeObserver.observe(canvas);
const handleHover = ({
clientX,
clientY,
}: { clientX: number; clientY: number }) => {
const unit = findUnit(canvas, units, clientX, clientY);
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();
};
const handleLeave = () => {
if (!hovered) {
return;
}
if (clickable) {
canvas.style.cursor = 'default';
}
hovered = null;
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);
});
canvas.addEventListener('mouseleave', handleLeave);
canvas.addEventListener('touchmove', (e) => {
isTouch = true;
handleHover(e.touches[0]);
});
canvas.addEventListener('touchend', handleLeave);
canvas.addEventListener('click', ({ clientX, clientY }) => {
const unit = findUnit(canvas, units, clientX, clientY);
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);
};
let remove: (() => void) | null = null;
const updatePixelRatio = (redraw: () => void, now: boolean) => {
if (remove != null) {
remove();
}
const media = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
const cb = () => updatePixelRatio(redraw, true);
media.addEventListener('change', cb);
remove = () => {
media.removeEventListener('change', cb);
};
if (now) {
redraw();
}
};
const SPECIAL_TERM_REGEXP = new RegExp(
`^(>|<|>=|<=|=|==|!=)(\\d+(?:\\.\\d+)?)(%|${UNITS.join('|')})$`,
'i',
);
const checkFilterTermMatches = (term: string, unit: Unit): boolean => {
if (term === "is:linked") {
return unit.is_linked;
} else if (term === "is:unlinked") {
return !unit.is_linked;
}
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;
(() => {
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();
}
}
})();