/** * 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"; // ── Skill constants (mirrored from rtime/schemas to avoid server import) ── const SKILL_COLORS: Record = { facilitation: '#8b5cf6', design: '#ec4899', tech: '#3b82f6', outreach: '#10b981', logistics: '#f59e0b', }; const SKILL_LABELS: Record = { 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; } #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``; 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; }); this.#fetchCommitments(); this.#startAnimation(); return root; } disconnectedCallback() { if (this.#animFrame) cancelAnimationFrame(this.#animFrame); this.#animFrame = 0; this.#removeGhost(); } // ── Data fetching ── async #fetchCommitments() { try { const resp = await fetch(`/${this.#spaceSlug}/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): FolkCommitmentPool { const shape = FolkShape.fromData(data) as FolkCommitmentPool; if (data.spaceSlug) shape.spaceSlug = data.spaceSlug; return shape; } override applyData(data: Record): void { super.applyData(data); if (data.spaceSlug && data.spaceSlug !== this.#spaceSlug) this.spaceSlug = data.spaceSlug; } }