202 lines
5.3 KiB
TypeScript
202 lines
5.3 KiB
TypeScript
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<E extends Element = Element> {
|
|
new (): E;
|
|
}
|
|
|
|
export interface ElementConfig<E extends Element = Element> {
|
|
constructor: ElementConstructor<E>;
|
|
events?: Record<string, (event: Event) => Record<string, any>>;
|
|
onAdd?(element: E): void | Record<string, any>;
|
|
onUpdate?(element: E, data: ReadonlyMap<string, any>, updatedValues: Set<string>): 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<ElementConstructor, ElementConfig>();
|
|
|
|
static registerElement<E extends Element>(config: ElementConfig<E>) {
|
|
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<string, any>) {
|
|
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<FolkCluster>();
|
|
#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;
|
|
}
|
|
}
|