Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions packages/grida-tree/__tests__/geo-tree-builder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { node } from "../src/geo-tree-builder";

describe("geo tree builder", () => {
test("builds nested tree", () => {
const tree = node("html")
.child(node("body").child(node("div").child(node("span"))))
.build();
expect(tree).toEqual({
id: "html",
bounds: { x: 0, y: 0, width: 0, height: 0 },
children: [
{
id: "body",
bounds: { x: 0, y: 0, width: 0, height: 0 },
children: [
{
id: "div",
bounds: { x: 0, y: 0, width: 0, height: 0 },
children: [
{
id: "span",
bounds: { x: 0, y: 0, width: 0, height: 0 },
children: [],
},
],
},
],
},
],
});
});

test("accepts multiple children", () => {
const root = node("root");
const childA = node("a");
const childB = node("b");
root.child(childA, childB);
expect(root.build().children?.map((c) => c.id)).toEqual(["a", "b"]);
});

test("sets bounds", () => {
const root = node("root").bounds(1, 2, 3, 4).build();
expect(root.bounds).toEqual({ x: 1, y: 2, width: 3, height: 4 });
});
});
55 changes: 55 additions & 0 deletions packages/grida-tree/__tests__/hit-testing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import cmath from "@grida/cmath";
import { getDeepest, GeoNode } from "../src/hit-testing";

describe("getDeepest", () => {
const tree: GeoNode = {
id: "root",
bounds: { x: 0, y: 0, width: 100, height: 100 },
children: [
{
id: "a",
bounds: { x: 10, y: 10, width: 40, height: 40 },
children: [
{
id: "a1",
bounds: { x: 20, y: 20, width: 10, height: 10 },
},
],
},
{
id: "b",
bounds: { x: 60, y: 60, width: 30, height: 30 },
},
],
};

test("returns deepest node for point", () => {
const point: cmath.Vector2 = [22, 22];
const result = getDeepest(tree, point);
expect(result?.id).toBe("a1");
});

test("returns root when point only hits root", () => {
const point: cmath.Vector2 = [5, 5];
const result = getDeepest(tree, point);
expect(result?.id).toBe("root");
});

test("respects 'intersects' mode for rectangles", () => {
const rect: cmath.Rectangle = { x: 18, y: 18, width: 20, height: 20 };
const result = getDeepest(tree, rect, "intersects");
expect(result?.id).toBe("a1");
});

test("respects 'contains' mode for rectangles", () => {
const rect: cmath.Rectangle = { x: 15, y: 15, width: 5, height: 5 };
const result = getDeepest(tree, rect, "contains");
expect(result?.id).toBe("a");
});

test("returns null when nothing is hit", () => {
const rect: cmath.Rectangle = { x: 200, y: 200, width: 10, height: 10 };
const result = getDeepest(tree, rect, "intersects");
expect(result).toBeNull();
});
});
71 changes: 71 additions & 0 deletions packages/grida-tree/__tests__/walk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { walk } from "../src/walk";

type Node = { id: string; children?: Node[] };

const tree: Node = {
id: "root",
children: [
{
id: "a",
children: [
{ id: "c" },
{ id: "d" },
],
},
{ id: "b" },
],
};

describe("walk", () => {
test("traverses nodes in preorder", () => {
const order: string[] = [];
walk(tree, {
enter(node) {
order.push(node.id);
},
});
expect(order).toEqual(["root", "a", "c", "d", "b"]);
});

test("can skip subtree when enter returns false", () => {
const order: string[] = [];
walk(tree, {
enter(node) {
order.push(node.id);
if (node.id === "a") return false;
},
});
expect(order).toEqual(["root", "a", "b"]);
});

test("invokes exit after children", () => {
const enter: string[] = [];
const exit: string[] = [];
walk(tree, {
enter(node) {
enter.push(node.id);
},
exit(node) {
exit.push(node.id);
},
});
expect(enter).toEqual(["root", "a", "c", "d", "b"]);
expect(exit).toEqual(["c", "d", "a", "b", "root"]);
});

test("terminates traversal early", () => {
const enter: string[] = [];
const exit: string[] = [];
walk(tree, {
enter(node, _parent, _ctx, terminate) {
enter.push(node.id);
if (node.id === "a") terminate();
},
exit(node) {
exit.push(node.id);
},
});
expect(enter).toEqual(["root", "a"]);
expect(exit).toEqual([]);
});
});
3 changes: 3 additions & 0 deletions packages/grida-tree/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
export * from "./src/lib";
export * from "./src/hit-testing";
export * from "./src/walk";
export * from "./src/geo-tree-builder";
8 changes: 7 additions & 1 deletion packages/grida-tree/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@
"scripts": {
"test": "jest"
},
"dependencies": {
"@grida/cmath": "workspace:*"
},
"jest": {
"preset": "ts-jest"
"preset": "ts-jest",
"moduleNameMapper": {
"^@grida/cmath$": "<rootDir>/../grida-cmath"
}
}
}
39 changes: 39 additions & 0 deletions packages/grida-tree/src/geo-tree-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import cmath from "@grida/cmath";
import type { GeoNode } from "./hit-testing";

export interface GeoNodeBuilder {
id: string;
children: GeoNodeBuilder[];
child(...nodes: GeoNodeBuilder[]): GeoNodeBuilder;
bounds(x: number, y: number, width: number, height: number): GeoNodeBuilder;
build(): GeoNode;
}

const defaultRect: cmath.Rectangle = { x: 0, y: 0, width: 0, height: 0 };

export function node(id: string): GeoNodeBuilder {
let rect = { ...defaultRect };

const builder: GeoNodeBuilder = {
id,
children: [],
child(...kids: GeoNodeBuilder[]) {
this.children.push(...kids);
return this;
},
bounds(x: number, y: number, width: number, height: number) {
rect = { x, y, width, height };
return this;
},
build() {
return {
id,
bounds: rect,
children: this.children.map((c) => c.build()),
};
},
};

return builder;
}

45 changes: 45 additions & 0 deletions packages/grida-tree/src/hit-testing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import cmath from "@grida/cmath";
export interface GeoNode {
id: string;
bounds: cmath.Rectangle;
children?: GeoNode[];
}

export type HitTestingMode = "contains" | "intersects";

type Envelope = cmath.Vector2 | cmath.Rectangle;

function isHit(
rect: cmath.Rectangle,
envelope: Envelope,
mode: HitTestingMode
): boolean {
if (Array.isArray(envelope)) {
return cmath.rect.containsPoint(rect, envelope);
}
return mode === "contains"
? cmath.rect.contains(envelope, rect)
: cmath.rect.intersects(rect, envelope);
}

export function getDeepest(
tree: GeoNode,
envelope: Envelope,
mode: HitTestingMode = "intersects"
): GeoNode | null {
function dfs(node: GeoNode, depth: number): { node: GeoNode; depth: number } | null {
if (!isHit(node.bounds, envelope, mode)) {
return null;
}
let deepest: { node: GeoNode; depth: number } = { node, depth };
for (const child of node.children ?? []) {
const hit = dfs(child, depth + 1);
if (hit && hit.depth > deepest.depth) {
deepest = hit;
}
}
return deepest;
}
const result = dfs(tree, 0);
return result ? result.node : null;
}
99 changes: 99 additions & 0 deletions packages/grida-tree/src/walk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
export interface Ctx {
depth: number;
index: number;
}

export interface WalkCallbacks<T> {
/**
* Called when a node is visited.
* Return `false` to skip walking the node's children.
*/
enter?: (
node: T,
parent: T | null,
ctx: Ctx,
terminate: () => void,
) => void | boolean;
/**
* Called after all children of the node have been visited.
*/
exit?: (
node: T,
parent: T | null,
ctx: Ctx,
terminate: () => void,
) => void;
}

/**
* Walk a tree in depth-first order without recursion.
*
* @param tree - Root node or an array of root nodes.
* @param callbacks - Optional callbacks invoked on enter/exit of each node.
*/
export function walk<T extends { children?: T[] }>(
tree: T | T[],
callbacks: WalkCallbacks<T>
): void {
const roots = Array.isArray(tree) ? tree : [tree];
type Frame = {
node: T;
parent: T | null;
depth: number;
index: number;
state: 0 | 1;
};
const stack: Frame[] = [];
let terminated = false;
const terminate = () => {
terminated = true;
};

for (let i = roots.length - 1; i >= 0; i--) {
stack.push({ node: roots[i]!, parent: null, depth: 0, index: i, state: 0 });
}

while (stack.length && !terminated) {
const frame = stack.pop()!;
if (frame.state === 0) {
const res = callbacks.enter?.(
frame.node,
frame.parent,
{ depth: frame.depth, index: frame.index },
terminate,
);
if (terminated) break;
if (res === false) {
callbacks.exit?.(
frame.node,
frame.parent,
{ depth: frame.depth, index: frame.index },
terminate,
);
if (terminated) break;
continue;
}
frame.state = 1;
stack.push(frame);
const children = frame.node.children ?? [];
for (let i = children.length - 1; i >= 0; i--) {
if (terminated) break;
stack.push({
node: children[i]!,
parent: frame.node,
depth: frame.depth + 1,
index: i,
state: 0,
});
}
} else {
callbacks.exit?.(
frame.node,
frame.parent,
{ depth: frame.depth, index: frame.index },
terminate,
);
if (terminated) break;
}
}
}
1 change: 1 addition & 0 deletions packages/grida-tree/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"esModuleInterop": true,
"noImplicitAny": true,
"strict": true
}
Expand Down
Loading