539 lines
16 KiB
TypeScript
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;
|
|
}
|
|
}
|