diff --git a/packages/alfa-quadtree/package.json b/packages/alfa-quadtree/package.json new file mode 100644 index 0000000000..5ae36480bd --- /dev/null +++ b/packages/alfa-quadtree/package.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json.schemastore.org/package", + "name": "@siteimprove/alfa-quadtree", + "homepage": "https://alfa.siteimprove.com", + "version": "0.77.0", + "license": "MIT", + "description": "Implementation of a quadtree data structure for storing and querying spatial data.", + "repository": { + "type": "git", + "url": "https://github.com/siteimprove/alfa.git", + "directory": "packages/alfa-quadtree" + }, + "bugs": "https://github.com/siteimprove/alfa/issues", + "main": "src/index.js", + "types": "src/index.d.ts", + "files": [ + "src/**/*.js", + "src/**/*.d.ts" + ], + "dependencies": { + "@siteimprove/alfa-equatable": "workspace:^0.77.0", + "@siteimprove/alfa-hash": "workspace:^0.77.0", + "@siteimprove/alfa-iterable": "workspace:^0.77.0", + "@siteimprove/alfa-json": "workspace:^0.77.0", + "@siteimprove/alfa-rectangle": "workspace:^0.77.0", + "@siteimprove/alfa-sequence": "workspace:^0.77.0" + }, + "devDependencies": { + "@siteimprove/alfa-test": "workspace:^0.77.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://npm.pkg.github.com/" + } +} diff --git a/packages/alfa-quadtree/src/index.ts b/packages/alfa-quadtree/src/index.ts new file mode 100644 index 0000000000..ece35bc702 --- /dev/null +++ b/packages/alfa-quadtree/src/index.ts @@ -0,0 +1 @@ +export * from "./quadtree"; diff --git a/packages/alfa-quadtree/src/quadtree.ts b/packages/alfa-quadtree/src/quadtree.ts new file mode 100644 index 0000000000..f59faa7892 --- /dev/null +++ b/packages/alfa-quadtree/src/quadtree.ts @@ -0,0 +1,183 @@ +import { Iterable } from "@siteimprove/alfa-iterable"; +import { Rectangle } from "@siteimprove/alfa-rectangle"; + +/** + * Implementation of a quadtree that can store any type of item that has a bounding rectangle associated with it. + * In this implementation, the items are only stored in the leaves of the quadtree and in all leaves + * they intersect with. + * + * @privateRemarks + * An alternative implementation could allow items at all levels of the quadtree, not just the leaves, and insert them into + * the first quad that fully contains the item's rectangle. That would remove the need to store the same item multiple times, + * but the search would have to compare with items at all levels, which would mean comparing with the items lying + * on the boundaries of the quads. It's not clear if that would be more or less efficient than the current implementation. + * It would also remove the need to move items after subdivision. + */ +export class QuadTree { + static readonly CAPACITY = 4; + static readonly MAX_DEPTH = 8; + + public static of(boundary: Rectangle, capacity: number): QuadTree { + return new QuadTree(boundary, capacity); + } + + private readonly _boundary: Rectangle; + private readonly _depth: number; + private _items: Array<[T, Rectangle]> = []; + private _children: Array> = []; + private _divided = false; + + private constructor(bounds: Rectangle, depth = 0) { + this._boundary = bounds; + this._depth = depth; + } + + private subdivide(): void { + const halfWidth = this._boundary.width / 2; + const halfHeight = this._boundary.height / 2; + + // Top-left + this._children.push( + QuadTree.of( + Rectangle.of(this._boundary.x, this._boundary.y, halfWidth, halfHeight), + this._depth + 1, + ), + ); + + // Top-right + this._children.push( + QuadTree.of( + Rectangle.of( + this._boundary.x + halfWidth, + this._boundary.y, + halfWidth, + halfHeight, + ), + this._depth + 1, + ), + ); + + // Bottom-left + this._children.push( + QuadTree.of( + Rectangle.of( + this._boundary.x, + this._boundary.y + halfHeight, + halfWidth, + halfHeight, + ), + this._depth + 1, + ), + ); + + // Bottom-right + this._children.push( + QuadTree.of( + Rectangle.of( + this._boundary.x + halfWidth, + this._boundary.y + halfHeight, + halfWidth, + halfHeight, + ), + this._depth + 1, + ), + ); + + this._divided = true; + } + + /** + * Try inserting an item into the quadtree. + * + * @remarks + * If the item's rectangle does not intersect the boundary of the quadtree, it won't be inserted. + */ + public insert(itemRectPair: [T, Rectangle]): void { + const [, rect] = itemRectPair; + + // If the item's rectangle does not intersect the boundary of the quadtree, don't insert it + if (!this._boundary.intersects(rect)) { + return; + } + + // If the max depth has been reached, insert the item into the current quad + if (this._depth >= QuadTree.MAX_DEPTH) { + this._items.push(itemRectPair); + return; + } + + // If the quadtree has been divided, try to insert in all children + if (this._divided) { + for (const child of this._children) { + child.insert(itemRectPair); + } + return; + } + + // If the capacity has not been reached, insert the item into the current quad + if (this._items.length < QuadTree.CAPACITY) { + this._items.push(itemRectPair); + return; + } + + // If the capacity has been reached, subdivide and move all items into the children + // before inserting the new item + + this.subdivide(); + + for (const child of this._children) { + for (const existing of this._items) { + child.insert(existing); + } + + child.insert(itemRectPair); + } + + // Clear the items in the current quad since they have been moved to the children + this._items = []; + } + + /** + * Returns all items in the current quad and all descendants + */ + private getAllItems(result = new Set()): Iterable { + for (const [item] of this._items) { + result.add(item); + } + + for (const child of this._children) { + child.getAllItems(result); + } + + return result; + } + + /** + * Query the quadtree for all items that intersect with the given range. + */ + public query(range: Rectangle, result = new Set()): Iterable { + // If the range doesn't intersect with the quad, don't search it + if (!range.intersects(this._boundary)) { + return result; + } + + // If the range is fully contained in the quad, return all items in the quad and descendants + if (this._boundary.contains(range)) { + return this.getAllItems(result); + } + + // Compare with all items in the quad (in case it's a leaf) + for (const [item, rect] of this._items) { + if (range.intersects(rect)) { + result.add(item); + } + } + + // Search the children + for (const child of this._children) { + child.query(range, result); + } + + return result; + } +} diff --git a/packages/alfa-quadtree/tsconfig.json b/packages/alfa-quadtree/tsconfig.json new file mode 100644 index 0000000000..c0ecf948e8 --- /dev/null +++ b/packages/alfa-quadtree/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../tsconfig.json", + "files": [ + "src/index.ts", + "src/quadtree.ts", + ], + "references": [ + { "path": "../alfa-equatable" }, + { "path": "../alfa-hash" }, + { "path": "../alfa-iterable" }, + { "path": "../alfa-json" }, + { "path": "../alfa-rectangle" }, + { "path": "../alfa-sequence" }, + { "path": "../alfa-test" } + ] +} diff --git a/packages/alfa-rules/src/sia-r113/rule.ts b/packages/alfa-rules/src/sia-r113/rule.ts index b85648a858..b6235d904e 100644 --- a/packages/alfa-rules/src/sia-r113/rule.ts +++ b/packages/alfa-rules/src/sia-r113/rule.ts @@ -1,5 +1,4 @@ import { Rule } from "@siteimprove/alfa-act"; -import { Cache } from "@siteimprove/alfa-cache"; import { Device } from "@siteimprove/alfa-device"; import { Document, Element } from "@siteimprove/alfa-dom"; import { Either } from "@siteimprove/alfa-either"; diff --git a/packages/tsconfig.json b/packages/tsconfig.json index 6118cc37e1..279a1f5171 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -53,6 +53,7 @@ { "path": "alfa-performance" }, { "path": "alfa-predicate" }, { "path": "alfa-promise" }, + { "path": "alfa-quadtree" }, { "path": "alfa-record" }, { "path": "alfa-rectangle" }, { "path": "alfa-reducer" },