import { aabbIntersection } from './common/collision.ts'; import { TransformEvent } from './common/TransformEvent.ts'; import { FolkHull } from './folk-hull'; import { FolkShape } from './folk-shape.ts'; interface ElementConstructor { new (): E; } export interface ElementConfig { constructor: ElementConstructor; events?: Record Record>; onAdd?(element: E): void | Record; onUpdate?(element: E, data: ReadonlyMap, updatedValues: Set): void; onRemove?(element: E): void; } // TODO don't hard code this const PROXIMITY = 100; declare global { interface HTMLElementTagNameMap { 'folk-cluster': FolkCluster; 'folk-proximity': FolkProximity; } } export class FolkCluster extends FolkHull { static override tagName = 'folk-cluster'; static #config = new Map(); static registerElement(config: ElementConfig) { this.#config.set(config.constructor, config); } #data = new Map(); isElementInCluster(element: FolkShape) { return this.sourceElements.has(element); } isElementInProximity(element: FolkShape) { for (const el of this.sourceElements) { if (aabbIntersection((el as FolkShape).getTransformDOMRect(), element.getTransformDOMRect(), PROXIMITY)) return true; } return false; } addElements(...elements: FolkShape[]) { this.sources = Array.from(this.sourceElements) .concat(elements) .map((el) => `#${el.id}`) .join(', '); let data = {}; for (const geometry of elements) { const element = geometry.firstElementChild; if (element === null) continue; const config = this.#getConfig(element); if (config) { for (const event of Object.keys(config.events || {})) { element.addEventListener(event, this.#handleEvent); } const newData = config.onAdd?.(element); data = Object.assign(data, newData); } } this.#handleUpdate(data); } #handleEvent = (event: Event) => { const config = this.#getConfig(event.currentTarget as Element); if (config) { const data = config.events?.[event.type]?.(event); if (data === undefined) return; this.#handleUpdate(data); } }; #handleUpdate(data: Record) { const keys = new Set(Object.keys(data)); for (const key of keys) { this.#data.set(key, data[key]); } for (const geometry of this.sourceElements) { const element = geometry.firstElementChild; if (element === null) continue; const config = this.#getConfig(element); config?.onUpdate?.(element, this.#data, keys); } } removeElement(geometry: FolkShape) { this.sources = Array.from(this.sourceElements) .filter((el) => el !== geometry) .map((el) => `#${el.id}`) .join(', '); const element = geometry.firstElementChild; if (element === null) return; const config = this.#getConfig(element); if (config) { for (const event of Object.keys(config.events || {})) { element.removeEventListener(event, this.#handleEvent); } config.onRemove?.(element); } } #getConfig(element: Element) { const config = (this.constructor as typeof FolkCluster).#config; return config.get(element.constructor as ElementConstructor); } } export class FolkProximity extends HTMLElement { static tagName = 'folk-proximity'; static define() { if (customElements.get(this.tagName)) return; FolkShape.define(); FolkCluster.define(); customElements.define(this.tagName, this); } #clusters = new Set(); #geometries = Array.from(this.querySelectorAll('folk-shape')); constructor() { super(); this.addEventListener('transform', this.#handleProximity); } #handleProximity = (e: TransformEvent) => { const el = e.target as FolkShape; const cluster = this.#findCluster(el); if (cluster === null) { for (const cluster of this.#clusters) { // what if its in proximity to multiple clusters? if (cluster.isElementInProximity(el)) { cluster.addElements(el); return; } } for (const geometry of this.#geometries) { if (geometry === el) break; if (aabbIntersection(geometry.getTransformDOMRect(), el.getTransformDOMRect(), PROXIMITY)) { const cluster = document.createElement('folk-cluster'); cluster.addElements(geometry, el); this.#clusters.add(cluster); this.appendChild(cluster); return; } } } else { const isInCluster = Array.from(cluster.sourceElements) .filter((element) => el !== element) .some((element) => aabbIntersection(el.getTransformDOMRect(), (element as FolkShape).getTransformDOMRect(), PROXIMITY) ); if (!isInCluster) { cluster.removeElement(el); if (cluster.sourcesMap.size === 1) { this.#clusters.delete(cluster); cluster.remove(); } } } }; #findCluster(element: FolkShape) { for (const cluster of this.#clusters) { if (cluster.isElementInCluster(element)) return cluster; } return null; } }