rspace-online/lib/folk-commitment-pool.ts

539 lines
16 KiB
TypeScript

/**
* folk-commitment-pool — Canvas shape rendering a physics-based basket of
* commitment orbs. Orbs can be dragged out onto folk-task-request shapes.
*
* Extracted from modules/rtime/components/folk-timebank-app.ts (pool view).
*/
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import { getModuleApiBase } from "../shared/url-helpers";
// ── Skill constants (mirrored from rtime/schemas to avoid server import) ──
const SKILL_COLORS: Record<string, string> = {
facilitation: '#8b5cf6', design: '#ec4899', tech: '#3b82f6',
outreach: '#10b981', logistics: '#f59e0b',
};
const SKILL_LABELS: Record<string, string> = {
facilitation: 'Facilitation', design: 'Design', tech: 'Tech',
outreach: 'Outreach', logistics: 'Logistics',
};
// ── Types ──
interface PoolCommitment {
id: string;
memberName: string;
hours: number;
skill: string;
desc: string;
ownerDid?: string;
}
// ── Orb class ──
class Orb {
c: PoolCommitment;
baseRadius: number;
radius: number;
x: number;
y: number;
vx: number;
vy: number;
hoverT = 0;
phase: number;
opacity = 0;
color: string;
constructor(c: PoolCommitment, cx: number, cy: number, r: number) {
this.c = c;
this.baseRadius = 18 + c.hours * 9;
this.radius = this.baseRadius;
const a = Math.random() * Math.PI * 2;
const d = Math.random() * (r - this.baseRadius - 10);
this.x = cx + Math.cos(a) * d;
this.y = cy + Math.sin(a) * d;
this.vx = (Math.random() - 0.5) * 0.4;
this.vy = (Math.random() - 0.5) * 0.4;
this.phase = Math.random() * Math.PI * 2;
this.color = SKILL_COLORS[c.skill] || '#8b5cf6';
}
update(hovered: Orb | null, cx: number, cy: number, r: number) {
this.phase += 0.008;
this.vx += Math.sin(this.phase) * 0.003;
this.vy += Math.cos(this.phase * 0.73 + 1) * 0.003;
this.vx *= 0.996;
this.vy *= 0.996;
this.x += this.vx;
this.y += this.vy;
const dx = this.x - cx, dy = this.y - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
const maxDist = r - this.radius - 3;
if (dist > maxDist && dist > 0.1) {
const nx = dx / dist, ny = dy / dist;
this.x = cx + nx * maxDist;
this.y = cy + ny * maxDist;
const dot = this.vx * nx + this.vy * ny;
this.vx -= 1.3 * dot * nx;
this.vy -= 1.3 * dot * ny;
this.vx *= 0.6; this.vy *= 0.6;
}
const isH = hovered === this;
this.hoverT += ((isH ? 1 : 0) - this.hoverT) * 0.12;
this.radius = this.baseRadius + this.hoverT * 5;
if (this.opacity < 1) this.opacity = Math.min(1, this.opacity + 0.025);
}
draw(ctx: CanvasRenderingContext2D) {
if (this.opacity < 0.01) return;
ctx.save();
ctx.globalAlpha = this.opacity;
if (this.hoverT > 0.05) { ctx.shadowColor = this.color; ctx.shadowBlur = 24 * this.hoverT; }
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fillStyle = this.color + '15';
ctx.fill();
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius * 0.82, 0, Math.PI * 2);
const g = ctx.createRadialGradient(
this.x - this.radius * 0.15, this.y - this.radius * 0.15, 0,
this.x, this.y, this.radius * 0.82
);
g.addColorStop(0, this.color + 'dd');
g.addColorStop(1, this.color);
ctx.fillStyle = g;
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = this.color;
ctx.lineWidth = 1.5;
ctx.stroke();
if (this.hoverT > 0.05) {
ctx.globalAlpha = this.opacity * 0.08 * this.hoverT;
ctx.beginPath();
ctx.ellipse(this.x, this.y + this.radius + 4, this.radius * 0.6, 4, 0, 0, Math.PI * 2);
ctx.fillStyle = '#000';
ctx.fill();
ctx.globalAlpha = this.opacity;
}
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const fs = Math.max(10, this.radius * 0.3);
ctx.font = '600 ' + fs + 'px -apple-system, sans-serif';
ctx.fillText(this.c.memberName.split(' ')[0], this.x, this.y - fs * 0.35);
ctx.font = '500 ' + (fs * 0.78) + 'px -apple-system, sans-serif';
ctx.fillStyle = '#ffffffcc';
ctx.fillText(this.c.hours + 'hr \u00b7 ' + (SKILL_LABELS[this.c.skill] || this.c.skill), this.x, this.y + fs * 0.55);
ctx.restore();
}
contains(px: number, py: number) {
const dx = px - this.x, dy = py - this.y;
return dx * dx + dy * dy < this.radius * this.radius;
}
}
// ── Ripple class ──
class Ripple {
x: number; y: number; r = 8; o = 0.5; color: string;
constructor(x: number, y: number, color: string) { this.x = x; this.y = y; this.color = color; }
update() { this.r += 1.5; this.o -= 0.008; return this.o > 0; }
draw(ctx: CanvasRenderingContext2D) {
ctx.save(); ctx.globalAlpha = this.o;
ctx.strokeStyle = this.color; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
}
}
// ── Styles ──
const styles = css`
:host {
background: transparent;
border-radius: 12px;
overflow: hidden;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
.empty-msg {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 13px;
font-style: italic;
pointer-events: none;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-commitment-pool": FolkCommitmentPool;
}
}
export class FolkCommitmentPool extends FolkShape {
static override tagName = "folk-commitment-pool";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n");
const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
// ── Module-disabled gating ──
static #enabledModules: Set<string> | null = null;
static #instances = new Set<FolkCommitmentPool>();
static setEnabledModules(ids: string[] | null) {
FolkCommitmentPool.#enabledModules = ids ? new Set(ids) : null;
for (const inst of FolkCommitmentPool.#instances) inst.#syncDisabledState();
}
#isModuleDisabled(): boolean {
const enabled = FolkCommitmentPool.#enabledModules;
if (!enabled) return false;
return !enabled.has("rtime");
}
#syncDisabledState() {
if (!this.#wrapper) return;
const disabled = this.#isModuleDisabled();
const wasDisabled = this.hasAttribute("data-module-disabled");
if (disabled && !wasDisabled) {
this.#showDisabledOverlay();
} else if (!disabled && wasDisabled) {
this.removeAttribute("data-module-disabled");
this.#wrapper.querySelector(".disabled-overlay")?.remove();
this.#fetchCommitments();
this.#startAnimation();
}
}
#showDisabledOverlay() {
this.setAttribute("data-module-disabled", "");
if (this.#animFrame) { cancelAnimationFrame(this.#animFrame); this.#animFrame = 0; }
let overlay = this.#wrapper?.querySelector(".disabled-overlay") as HTMLElement;
if (!overlay && this.#wrapper) {
overlay = document.createElement("div");
overlay.className = "disabled-overlay";
overlay.style.cssText = "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;background:rgba(15,23,42,0.85);border-radius:inherit;z-index:10;color:#94a3b8;font-size:13px;";
overlay.innerHTML = '<span style="font-size:24px">🔒</span><span>rTime is disabled</span>';
this.#wrapper.appendChild(overlay);
}
}
#spaceSlug = "demo";
#canvas!: HTMLCanvasElement;
#ctx!: CanvasRenderingContext2D;
#orbs: Orb[] = [];
#ripples: Ripple[] = [];
#animFrame = 0;
#hoveredOrb: Orb | null = null;
#draggingOrb: Orb | null = null;
#ghost: HTMLDivElement | null = null;
#dpr = 1;
#wrapper!: HTMLElement;
get spaceSlug() { return this.#spaceSlug; }
set spaceSlug(v: string) {
this.#spaceSlug = v;
this.#fetchCommitments();
}
override createRenderRoot() {
const root = super.createRenderRoot();
this.#wrapper = document.createElement("div");
this.#wrapper.style.cssText = "width:100%;height:100%;position:relative;";
this.#wrapper.innerHTML = html`<canvas></canvas><div class="empty-msg" style="display:none">No commitments yet</div>`;
const slot = root.querySelector("slot");
const container = slot?.parentElement as HTMLElement;
if (container) container.replaceWith(this.#wrapper);
this.#canvas = this.#wrapper.querySelector("canvas")!;
this.#ctx = this.#canvas.getContext("2d")!;
this.#canvas.addEventListener("pointermove", this.#onPointerMove);
this.#canvas.addEventListener("pointerdown", this.#onPointerDown);
this.#canvas.addEventListener("pointerleave", () => { this.#hoveredOrb = null; });
FolkCommitmentPool.#instances.add(this);
if (this.#isModuleDisabled()) {
this.#showDisabledOverlay();
} else {
this.#fetchCommitments();
this.#startAnimation();
}
return root;
}
disconnectedCallback() {
FolkCommitmentPool.#instances.delete(this);
if (this.#animFrame) cancelAnimationFrame(this.#animFrame);
this.#animFrame = 0;
this.#removeGhost();
}
// ── Data fetching ──
async #fetchCommitments() {
try {
const resp = await fetch(`${getModuleApiBase("rtime")}/api/commitments`);
if (!resp.ok) return;
const data = await resp.json();
const commitments: PoolCommitment[] = data.commitments || [];
this.#rebuildOrbs(commitments);
} catch { /* network error, keep existing orbs */ }
}
#rebuildOrbs(commitments: PoolCommitment[]) {
const w = this.width || 500;
const h = this.height || 500;
const cx = w / 2, cy = h / 2;
const r = Math.min(cx, cy) * 0.85;
const emptyMsg = this.#wrapper.querySelector(".empty-msg") as HTMLElement;
if (emptyMsg) emptyMsg.style.display = commitments.length === 0 ? "flex" : "none";
// Preserve existing orbs by commitment ID
const existing = new Map(this.#orbs.map(o => [o.c.id, o]));
this.#orbs = commitments.map(c => existing.get(c.id) || new Orb(c, cx, cy, r));
}
// ── Canvas coord helpers ──
#canvasCoords(e: PointerEvent): { x: number; y: number } {
const rect = this.#canvas.getBoundingClientRect();
return {
x: (e.clientX - rect.left) * (this.#canvas.width / this.#dpr / rect.width),
y: (e.clientY - rect.top) * (this.#canvas.height / this.#dpr / rect.height),
};
}
#findOrbAt(x: number, y: number): Orb | null {
for (let i = this.#orbs.length - 1; i >= 0; i--) {
if (this.#orbs[i].contains(x, y)) return this.#orbs[i];
}
return null;
}
// ── Pointer handlers ──
#onPointerMove = (e: PointerEvent) => {
if (this.#draggingOrb) return; // drag is handled on document
const { x, y } = this.#canvasCoords(e);
const orb = this.#findOrbAt(x, y);
this.#hoveredOrb = orb;
this.#canvas.style.cursor = orb ? "grab" : "default";
};
#onPointerDown = (e: PointerEvent) => {
const { x, y } = this.#canvasCoords(e);
const orb = this.#findOrbAt(x, y);
if (!orb) return;
// Prevent FolkShape from starting a shape-move
e.stopPropagation();
this.#draggingOrb = orb;
this.#ripples.push(new Ripple(orb.x, orb.y, orb.color));
// Create ghost element on document.body
this.#ghost = document.createElement("div");
this.#ghost.style.cssText = `
position: fixed; z-index: 99999; pointer-events: none;
width: ${orb.radius * 2}px; height: ${orb.radius * 2}px;
border-radius: 50%;
background: ${orb.color};
opacity: 0.8;
box-shadow: 0 0 20px ${orb.color}80;
display: flex; align-items: center; justify-content: center;
color: white; font-size: 11px; font-weight: 600;
font-family: -apple-system, sans-serif;
transform: translate(-50%, -50%);
left: ${e.clientX}px; top: ${e.clientY}px;
`;
this.#ghost.textContent = `${orb.c.hours}hr ${SKILL_LABELS[orb.c.skill] || orb.c.skill}`;
document.body.appendChild(this.#ghost);
// Dispatch drag start
document.dispatchEvent(new CustomEvent("commitment-drag-start", {
detail: {
commitmentId: orb.c.id,
skill: orb.c.skill,
hours: orb.c.hours,
memberName: orb.c.memberName,
ownerDid: orb.c.ownerDid,
},
}));
document.addEventListener("pointermove", this.#onDocPointerMove);
document.addEventListener("pointerup", this.#onDocPointerUp);
};
#onDocPointerMove = (e: PointerEvent) => {
if (this.#ghost) {
this.#ghost.style.left = `${e.clientX}px`;
this.#ghost.style.top = `${e.clientY}px`;
}
document.dispatchEvent(new CustomEvent("commitment-drag-move", {
detail: { clientX: e.clientX, clientY: e.clientY },
}));
};
#onDocPointerUp = (e: PointerEvent) => {
document.removeEventListener("pointermove", this.#onDocPointerMove);
document.removeEventListener("pointerup", this.#onDocPointerUp);
document.dispatchEvent(new CustomEvent("commitment-drag-end", {
detail: {
clientX: e.clientX,
clientY: e.clientY,
commitmentId: this.#draggingOrb?.c.id,
skill: this.#draggingOrb?.c.skill,
hours: this.#draggingOrb?.c.hours,
memberName: this.#draggingOrb?.c.memberName,
ownerDid: this.#draggingOrb?.c.ownerDid,
},
}));
this.#draggingOrb = null;
this.#removeGhost();
};
#removeGhost() {
if (this.#ghost) {
this.#ghost.remove();
this.#ghost = null;
}
}
// ── Animation loop ──
#startAnimation() {
const frame = () => {
this.#animFrame = requestAnimationFrame(frame);
this.#poolFrame();
};
this.#animFrame = requestAnimationFrame(frame);
}
#poolFrame() {
const w = this.width || 500;
const h = this.height || 500;
const cx = w / 2, cy = h / 2;
const r = Math.min(cx, cy) * 0.85;
// Resize canvas if needed
this.#dpr = window.devicePixelRatio || 1;
const cw = Math.round(w * this.#dpr);
const ch = Math.round(h * this.#dpr);
if (this.#canvas.width !== cw || this.#canvas.height !== ch) {
this.#canvas.width = cw;
this.#canvas.height = ch;
}
const ctx = this.#ctx;
ctx.setTransform(this.#dpr, 0, 0, this.#dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// Draw basket
ctx.save();
ctx.strokeStyle = '#334155';
ctx.lineWidth = 2;
ctx.setLineDash([8, 4]);
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
// Basket label
ctx.fillStyle = '#475569';
ctx.font = '500 12px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Commitment Pool', cx, cy - r + 20);
ctx.fillStyle = '#64748b';
ctx.font = '400 11px -apple-system, sans-serif';
ctx.fillText(`${this.#orbs.length} pledges`, cx, cy - r + 36);
ctx.restore();
// Update & draw ripples
this.#ripples = this.#ripples.filter(rp => { rp.update(); rp.draw(ctx); return rp.o > 0; });
// Orb-to-orb collision
for (let i = 0; i < this.#orbs.length; i++) {
for (let j = i + 1; j < this.#orbs.length; j++) {
const a = this.#orbs[i], b = this.#orbs[j];
const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const minD = a.radius + b.radius + 2;
if (dist < minD && dist > 0.01) {
const nx = dx / dist, ny = dy / dist;
const overlap = (minD - dist) / 2;
a.x -= nx * overlap; a.y -= ny * overlap;
b.x += nx * overlap; b.y += ny * overlap;
const relV = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
if (relV > 0) {
a.vx -= relV * nx * 0.5; a.vy -= relV * ny * 0.5;
b.vx += relV * nx * 0.5; b.vy += relV * ny * 0.5;
}
}
}
}
// Update & draw orbs
for (const orb of this.#orbs) {
if (orb === this.#draggingOrb) {
orb.opacity = 0.3;
orb.draw(ctx);
continue;
}
orb.update(this.#hoveredOrb, cx, cy, r);
orb.draw(ctx);
}
}
// ── Serialization ──
override toJSON() {
return {
...super.toJSON(),
type: "folk-commitment-pool",
spaceSlug: this.#spaceSlug,
};
}
static override fromData(data: Record<string, any>): FolkCommitmentPool {
const shape = FolkShape.fromData(data) as FolkCommitmentPool;
if (data.spaceSlug) shape.spaceSlug = data.spaceSlug;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.spaceSlug && data.spaceSlug !== this.#spaceSlug) this.spaceSlug = data.spaceSlug;
}
}