Skip to content

perf: outlines #215

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 14 commits into from
Mar 7, 2025
76 changes: 43 additions & 33 deletions packages/scan/src/new-outlines/canvas.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,48 @@
import type { ActiveOutline, OutlineData } from './types';

export const OUTLINE_ARRAY_SIZE = 7;
export const MONO_FONT =
const MONO_FONT =
'Menlo,Consolas,Monaco,Liberation Mono,Lucida Console,monospace';

export const INTERPOLATION_SPEED = 0.1;
export const lerp = (start: number, end: number) => {
const INTERPOLATION_SPEED = 0.1;
const lerp = (start: number, end: number) => {
return Math.floor(start + (end - start) * INTERPOLATION_SPEED);
};

export const MAX_PARTS_LENGTH = 4;
export const MAX_LABEL_LENGTH = 40;
export const TOTAL_FRAMES = 45;
const MAX_PARTS_LENGTH = 4;
const MAX_LABEL_LENGTH = 40;
const TOTAL_FRAMES = 45;

export const primaryColor = '115,97,230';
export const secondaryColor = '128,128,128';
const PRIMARY_COLOR = '115,97,230';
// const SECONDARY_COLOR = '128,128,128';

function sortEntry(prev: [number, string[]], next: [number, string[]]): number {
return next[0] - prev[0];
}

function getSortedEntries(
countByNames: Map<number, string[]>,
): [number, string[]][] {
const entries = [...countByNames.entries()];
return entries.sort(sortEntry);
}

function getLabelTextPart([count, names]: [number, string[]]): string {
let part = `${names.slice(0, MAX_PARTS_LENGTH).join(', ')} ×${count}`;
if (part.length > MAX_LABEL_LENGTH) {
part = `${part.slice(0, MAX_LABEL_LENGTH)}…`;
}
return part;
}

export const getLabelText = (outlines: ActiveOutline[]): string => {
const nameByCount = new Map<string, number>();
for (const outline of outlines) {
const { name, count } = outline;
for (const { name, count } of outlines) {
nameByCount.set(name, (nameByCount.get(name) || 0) + count);
}

const countByNames = new Map<number, string[]>();
for (const [name, count] of nameByCount.entries()) {
for (const [name, count] of nameByCount) {
const names = countByNames.get(count);
if (names) {
names.push(name);
Expand All @@ -33,21 +51,12 @@ export const getLabelText = (outlines: ActiveOutline[]): string => {
}
}

const partsEntries = Array.from(countByNames.entries()).sort(
([countA], [countB]) => countB - countA,
);
const partsLength = partsEntries.length;
let labelText = '';
for (let i = 0; i < partsLength; i++) {
const [count, names] = partsEntries[i];
let part = `${names.slice(0, MAX_PARTS_LENGTH).join(', ')} ×${count}`;
if (part.length > MAX_LABEL_LENGTH) {
part = `${part.slice(0, MAX_LABEL_LENGTH)}…`;
}
if (i !== partsLength - 1) {
part += ', ';
}
labelText += part;
// TODO(Alexis): Optimize
const partsEntries = getSortedEntries(countByNames);
let labelText = getLabelTextPart(partsEntries[0]);
for (let i = 1, len = partsEntries.length; i < len; i++) {
// biome-ignore lint/style/useTemplate: Templates are slow
labelText += ', ' + getLabelTextPart(partsEntries[i]);
}

if (labelText.length > MAX_LABEL_LENGTH) {
Expand Down Expand Up @@ -200,15 +209,14 @@ export const drawCanvas = (
rectMap.set(rectKey, rect);
}

for (const rect of rectMap.values()) {
const { x, y, width, height, alpha } = rect;
ctx.strokeStyle = `rgba(${primaryColor},${alpha})`;
for (const { x, y, width, height, alpha } of rectMap.values()) {
ctx.strokeStyle = `rgba(${PRIMARY_COLOR},${alpha})`;
ctx.lineWidth = 1;

ctx.beginPath();
ctx.rect(x, y, width, height);
ctx.stroke();
ctx.fillStyle = `rgba(${primaryColor},${alpha * 0.1})`;
ctx.fillStyle = `rgba(${PRIMARY_COLOR},${alpha * 0.1})`;
ctx.fill();
}

Expand All @@ -229,6 +237,7 @@ export const drawCanvas = (

ctx.textRendering = 'optimizeSpeed';

// TODO(Alexis): optimizable?
for (const outlines of groupedOutlinesMap.values()) {
const first = outlines[0];
const { x, y, frame } = first;
Expand Down Expand Up @@ -259,6 +268,7 @@ export const drawCanvas = (
}
}

// TODO(Alexis): optimize
const sortedLabels = Array.from(labelMap.entries()).sort(
([_, a], [__, b]) => {
return getAreaFromOutlines(b.outlines) - getAreaFromOutlines(a.outlines);
Expand All @@ -285,7 +295,7 @@ export const drawCanvas = (
y + height > otherY &&
otherY + otherHeight > y
) {
label.text = getLabelText([...label.outlines, ...otherLabel.outlines]);
label.text = getLabelText(label.outlines.concat(otherLabel.outlines));
label.width = ctx.measureText(label.text).width;
labelMap.delete(otherKey);
}
Expand All @@ -295,13 +305,13 @@ export const drawCanvas = (
for (const label of labelMap.values()) {
const { x, y, alpha, width, height, text } = label;

let labelY: number = y - height - 4;
let labelY = y - height - 4;

if (labelY < 0) {
labelY = 0;
}

ctx.fillStyle = `rgba(${primaryColor},${alpha})`;
ctx.fillStyle = `rgba(${PRIMARY_COLOR},${alpha})`;
ctx.fillRect(x, labelY, width + 4, height + 4);

ctx.fillStyle = `rgba(255,255,255,${alpha})`;
Expand Down
90 changes: 52 additions & 38 deletions packages/scan/src/new-outlines/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,48 +83,61 @@ const mergeRects = (rects: DOMRect[]) => {
return new DOMRect(minX, minY, maxX - minX, maxY - minY);
};

export const getBatchedRectMap = async function* (
elements: Element[],
): AsyncGenerator<IntersectionObserverEntry[], void, unknown> {
const uniqueElements = new Set(elements);
const seenElements = new Set<Element>();

let resolveNext: ((value: IntersectionObserverEntry[]) => void) | null = null;
let done = false;

const observer = new IntersectionObserver((entries) => {
const newEntries: IntersectionObserverEntry[] = [];

for (const entry of entries) {
const element = entry.target;
if (!seenElements.has(element)) {
seenElements.add(element);
newEntries.push(entry);
}
interface IntersectionState {
resolveNext: ((value: IntersectionObserverEntry[]) => void) | null;
seenElements: Set<Element>;
uniqueElements: Set<Element>;
done: boolean;
}

function onIntersect(
this: IntersectionState,
entries: IntersectionObserverEntry[],
observer: IntersectionObserver,
) {
const newEntries: IntersectionObserverEntry[] = [];

for (const entry of entries) {
const element = entry.target;
if (!this.seenElements.has(element)) {
this.seenElements.add(element);
newEntries.push(entry);
}
}

if (newEntries.length > 0 && resolveNext) {
resolveNext(newEntries);
resolveNext = null;
}
if (newEntries.length > 0 && this.resolveNext) {
this.resolveNext(newEntries);
this.resolveNext = null;
}

if (seenElements.size === uniqueElements.size) {
observer.disconnect();
done = true;
if (resolveNext) {
resolveNext([]);
}
if (this.seenElements.size === this.uniqueElements.size) {
observer.disconnect();
this.done = true;
if (this.resolveNext) {
this.resolveNext([]);
}
});
}
}

for (const element of uniqueElements) {
export const getBatchedRectMap = async function* (
elements: Element[],
): AsyncGenerator<IntersectionObserverEntry[], void, unknown> {
const state: IntersectionState = {
uniqueElements: new Set(elements),
seenElements: new Set(),
resolveNext: null,
done: false,
};
const observer = new IntersectionObserver(onIntersect.bind(state));

for (const element of state.uniqueElements) {
observer.observe(element);
}

while (!done) {
while (!state.done) {
const entries = await new Promise<IntersectionObserverEntry[]>(
(resolve) => {
resolveNext = resolve;
state.resolveNext = resolve;
},
);
if (entries.length > 0) {
Expand Down Expand Up @@ -153,6 +166,7 @@ export const flushOutlines = async () => {

const rectsMap = new Map<Element, DOMRect>();

// TODO(Alexis): too complex, needs breakdown
for await (const entries of getBatchedRectMap(elements)) {
for (const entry of entries) {
const element = entry.target;
Expand Down Expand Up @@ -328,6 +342,7 @@ export const getCanvasEl = () => {
window.addEventListener('resize', () => {
if (!isResizeScheduled) {
isResizeScheduled = true;
// TODO(Alexis): bindable
setTimeout(() => {
const width = window.innerWidth;
const height = window.innerHeight;
Expand Down Expand Up @@ -362,6 +377,7 @@ export const getCanvasEl = () => {
window.addEventListener('scroll', () => {
if (!isScrollScheduled) {
isScrollScheduled = true;
// TODO(Alexis): bindable
setTimeout(() => {
const { scrollX, scrollY } = window;
const deltaX = scrollX - prevScrollX;
Expand All @@ -375,9 +391,9 @@ export const getCanvasEl = () => {
deltaY,
});
} else {
requestAnimationFrame(() => {
updateScroll(activeOutlines, deltaX, deltaY);
});
requestAnimationFrame(
updateScroll.bind(null, activeOutlines, deltaX, deltaY),
);
}
isScrollScheduled = false;
}, 16 * 2);
Expand All @@ -386,9 +402,7 @@ export const getCanvasEl = () => {

setInterval(() => {
if (blueprintMapKeys.size) {
requestAnimationFrame(() => {
flushOutlines();
});
requestAnimationFrame(flushOutlines);
}
}, 16 * 2);

Expand Down
35 changes: 7 additions & 28 deletions packages/scan/src/new-outlines/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,13 @@ export interface OutlineData {
}

export type InlineOutlineData = [
/**
* id
*/
number,
/**
* count
*/
number,
/**
* x
*/
number,
/**
* y
*/
number,
/**
* width
*/
number,
/**
* height
*/
number,
/**
* didCommit
*/
0 | 1,
id: number,
count: number,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

named tuples is a TS feature, no need for comments

x: number,
y: number,
width: number,
height: number,
didCommit: 0 | 1,
];

export interface ActiveOutline {
Expand Down