diff --git a/demo/effect-integrator.html b/demo/effect-integrator.html new file mode 100644 index 0000000..db78072 --- /dev/null +++ b/demo/effect-integrator.html @@ -0,0 +1,59 @@ + + + + + + Physics + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/common/EffectIntegrator.ts b/src/common/EffectIntegrator.ts new file mode 100644 index 0000000..3adf9a1 --- /dev/null +++ b/src/common/EffectIntegrator.ts @@ -0,0 +1,119 @@ +import type { FolkShape } from '../folk-shape'; + +/** + * Coordinates effects between multiple systems, integrating their proposals into a single result. + * Systems register, yield effects, and await integration when all systems are ready. + */ +export class EffectIntegrator { + private pending = new Map(); + private systems = new Set(); + private waiting = new Set(); + private resolvers: ((value: Map) => void)[] = []; + + /** Register a system to participate in effect integration */ + register(id: string) { + this.systems.add(id); + return { + yield: (element: E, effect: T) => { + if (!this.pending.has(element)) { + this.pending.set(element, []); + } + (this.pending.get(element)! as T[]).push(effect); + }, + + /** Wait for all systems to submit effects, then receive integrated results */ + integrate: async (): Promise> => { + this.waiting.add(id); + + if (this.waiting.size === this.systems.size) { + // Last system to call integrate - do integration + for (const [element, effects] of this.pending) { + this.pending.set(element, (this.constructor as typeof EffectIntegrator).integrate(element, effects as T[])); + } + const results = this.pending as Map; + + // Reset for next frame + this.pending = new Map(); + this.waiting.clear(); + + // Resolve all waiting systems + this.resolvers.forEach((resolve) => resolve(results)); + this.resolvers = []; + + return results; + } + + // Not all systems ready - wait for integration + return new Promise((resolve) => { + this.resolvers.push(resolve); + }); + }, + }; + } + + /** Integrate multiple effects into a single result. Must be implemented by derived classes. */ + protected static integrate(element: Element, effects: any[]): any { + throw new Error('Derived class must implement static integrate'); + } +} + +// Transform-specific integrator +interface TransformEffect { + x: number; + y: number; + rotation: number; + width: number; + height: number; +} + +export class TransformIntegrator extends EffectIntegrator { + private static instance: TransformIntegrator; + + static register(id: string) { + if (!TransformIntegrator.instance) { + TransformIntegrator.instance = new TransformIntegrator(); + } + return TransformIntegrator.instance.register(id); + } + + /** If the element is focused, return the elements rect, otherwise average the effects */ + protected static override integrate(element: FolkShape, effects: TransformEffect[]): TransformEffect { + if (element === document.activeElement) { + // If the element is focused, we don't want to apply any effects and just return the current state + const rect = element.getTransformDOMRect(); + return { + x: rect.x, + y: rect.y, + rotation: rect.rotation, + width: rect.width, + height: rect.height, + }; + } + + // Accumulate all effects + const result = effects.reduce( + (acc, effect) => ({ + x: acc.x + effect.x, + y: acc.y + effect.y, + rotation: acc.rotation + effect.rotation, + width: effect.width, + height: effect.height, + }), + { x: 0, y: 0, rotation: 0, width: effects[0].width, height: effects[0].height } + ); + + // Average all effects + const count = effects.length; + result.x /= count; + result.y /= count; + result.rotation /= count; + + // Apply averaged results to element + element.x = result.x; + element.y = result.y; + element.rotation = result.rotation; + + // Return averaged result + return result; + } +} diff --git a/src/folk-graph.ts b/src/folk-graph.ts index 6c5e5e1..5f0fe5c 100644 --- a/src/folk-graph.ts +++ b/src/folk-graph.ts @@ -4,6 +4,7 @@ import { Layout } from 'webcola'; import { FolkShape } from './folk-shape.ts'; import { AnimationFrameController, AnimationFrameControllerHost } from './common/animation-frame-controller.ts'; import { FolkBaseConnection } from './folk-base-connection'; +import { TransformIntegrator } from './common/EffectIntegrator.ts'; export class FolkGraph extends FolkBaseSet implements AnimationFrameControllerHost { static override tagName = 'folk-graph'; @@ -11,6 +12,7 @@ export class FolkGraph extends FolkBaseSet implements AnimationFrameControllerHo private graphSim = new Layout(); private nodes = new Map(); private arrows = new Set(); + private integrator = TransformIntegrator.register('graph'); #rAF = new AnimationFrameController(this); connectedCallback() { @@ -34,21 +36,31 @@ export class FolkGraph extends FolkBaseSet implements AnimationFrameControllerHo } } - tick() { - // TODO: figure out how to let cola continue running. I was never able to do that in the past... + async tick() { this.graphSim.start(1, 0, 0, 0, true, false); - this.graphSim.nodes().forEach((node: any) => { + // Yield graph layout effects + for (const node of this.graphSim.nodes() as any[]) { const shape = node.id; - if (shape === document.activeElement) { - const rect = shape.getTransformDOMRect(); - node.x = rect.center.x; - node.y = rect.center.y; - } else { - shape.x = node.x - shape.width / 2; - shape.y = node.y - shape.height / 2; + this.integrator.yield(shape, { + x: node.x - shape.width / 2, + y: node.y - shape.height / 2, + rotation: shape.rotation, + width: shape.width, + height: shape.height, + }); + } + + // Get integrated results and update graph state + const results = await this.integrator.integrate(); + for (const [shape, result] of results) { + // TODO: this is a hack to get the node from the graph + const node = this.graphSim.nodes().find((n: any) => n.id === shape); + if (node) { + node.x = result.x + shape.width / 2; + node.y = result.y + shape.height / 2; } - }); + } } private createGraph() { @@ -58,7 +70,9 @@ export class FolkGraph extends FolkBaseSet implements AnimationFrameControllerHo const colaNodes = this.createNodes(); const colaLinks = this.createLinks(); - this.graphSim.nodes(colaNodes).links(colaLinks).linkDistance(250).avoidOverlaps(true).handleDisconnected(true); + console.log(colaNodes, colaLinks); + + this.graphSim.nodes(colaNodes).links(colaLinks).linkDistance(150).avoidOverlaps(true).handleDisconnected(true); } private createNodes() { diff --git a/src/folk-physics.ts b/src/folk-physics.ts index 3fd0709..701d1ba 100644 --- a/src/folk-physics.ts +++ b/src/folk-physics.ts @@ -3,6 +3,7 @@ import { FolkBaseSet } from './folk-base-set.ts'; import { PropertyValues } from '@lit/reactive-element'; import { FolkShape } from './folk-shape'; import RAPIER, { init } from '@dimforge/rapier2d-compat'; +import { TransformIntegrator } from './common/EffectIntegrator.ts'; await init(); export class FolkPhysics extends FolkBaseSet { @@ -15,6 +16,7 @@ export class FolkPhysics extends FolkBaseSet { private elementToRect: Map = new Map(); private animationFrameId?: number; private lastTimestamp?: number; + private integrator = TransformIntegrator.register('physics'); connectedCallback() { super.connectedCallback(); @@ -119,7 +121,7 @@ export class FolkPhysics extends FolkBaseSet { } private startSimulation() { - const step = (timestamp: number) => { + const step = async (timestamp: number) => { if (!this.lastTimestamp) { this.lastTimestamp = timestamp; } @@ -127,16 +129,33 @@ export class FolkPhysics extends FolkBaseSet { if (this.world) { this.world.step(); - // Update visual elements based on physics - this.bodies.forEach((body, shape) => { - if (shape !== document.activeElement) { - const position = body.translation(); - // Scale up the position when applying to visual elements - shape.x = position.x / FolkPhysics.PHYSICS_SCALE - shape.width / 2; - shape.y = position.y / FolkPhysics.PHYSICS_SCALE - shape.height / 2; - shape.rotation = body.rotation(); + // Yield physics effects + for (const [shape, body] of this.bodies) { + const position = body.translation(); + this.integrator.yield(shape, { + x: position.x / FolkPhysics.PHYSICS_SCALE - shape.width / 2, + y: position.y / FolkPhysics.PHYSICS_SCALE - shape.height / 2, + rotation: body.rotation(), + width: shape.width, + height: shape.height, + }); + } + + // Get integrated results and update physics state + const results = await this.integrator.integrate(); + for (const [shape, result] of results) { + const body = this.bodies.get(shape); + if (body) { + body.setTranslation( + { + x: (result.x + result.width / 2) * FolkPhysics.PHYSICS_SCALE, + y: (result.y + result.height / 2) * FolkPhysics.PHYSICS_SCALE, + }, + true + ); + body.setRotation(result.rotation, true); } - }); + } } this.animationFrameId = requestAnimationFrame(step);