diff --git a/modules/rnetwork/components/folk-trust-sankey.ts b/modules/rnetwork/components/folk-trust-sankey.ts index 7d7fbc85..beb5b810 100644 --- a/modules/rnetwork/components/folk-trust-sankey.ts +++ b/modules/rnetwork/components/folk-trust-sankey.ts @@ -36,7 +36,26 @@ const SANKEY_AUTHORITY_DISPLAY: Record "fin-ops": { label: "Econ", color: "#10b981" }, "dev-ops": { label: "Tech", color: "#3b82f6" }, }; -const FLOW_COLOR = "#a78bfa"; +// Demo DAO members +const DEMO_MEMBERS = [ + { did: "demo:alice", name: "Alice" }, + { did: "demo:bob", name: "Bob" }, + { did: "demo:carol", name: "Carol" }, + { did: "demo:dave", name: "Dave" }, + { did: "demo:eve", name: "Eve" }, + { did: "demo:frank", name: "Frank" }, + { did: "demo:grace", name: "Grace" }, + { did: "demo:heidi", name: "Heidi" }, + { did: "demo:ivan", name: "Ivan" }, + { did: "demo:judy", name: "Judy" }, +]; + +// Per-delegator color palette for flow bands +const DELEGATOR_PALETTE = [ + "#7c3aed", "#6366f1", "#8b5cf6", "#a855f7", + "#c084fc", "#818cf8", "#6d28d9", "#4f46e5", + "#7e22ce", "#5b21b6", "#4338ca", "#9333ea", +]; class FolkTrustSankey extends HTMLElement { private shadow: ShadowRoot; @@ -48,6 +67,10 @@ class FolkTrustSankey extends HTMLElement { private error = ""; private timeSliderValue = 100; // 0-100, percentage of history private animationEnabled = true; + private hoveredFlowId: string | null = null; + private hoveredNodeDid: string | null = null; + private _demoTimer: ReturnType | null = null; + private _demoIdCounter = 0; constructor() { super(); @@ -60,11 +83,15 @@ class FolkTrustSankey extends HTMLElement { this.space = this.getAttribute("space") || "demo"; this.authority = this.getAttribute("authority") || "gov-ops"; this.render(); - this.loadData(); - // Listen for cross-component sync - this._delegationsHandler = () => this.loadData(); - document.addEventListener("delegations-updated", this._delegationsHandler); + if (this.space === "demo") { + this.initDemoSimulation(); + } else { + this.loadData(); + // Listen for cross-component sync + this._delegationsHandler = () => this.loadData(); + document.addEventListener("delegations-updated", this._delegationsHandler); + } } disconnectedCallback() { @@ -72,6 +99,10 @@ class FolkTrustSankey extends HTMLElement { document.removeEventListener("delegations-updated", this._delegationsHandler); this._delegationsHandler = null; } + if (this._demoTimer) { + clearInterval(this._demoTimer); + this._demoTimer = null; + } } private getAuthBase(): string { @@ -148,6 +179,151 @@ class FolkTrustSankey extends HTMLElement { this.render(); } + // ── Demo simulation ────────────────────────────────────────── + + private demoNextId(): string { return `demo-${++this._demoIdCounter}`; } + + private demoRand(arr: T[]): T { return arr[Math.floor(Math.random() * arr.length)]; } + + /** Total outbound weight for a given did+authority (active only) */ + private demoOutboundWeight(did: string, authority: string): number { + return this.flows + .filter(f => f.fromDid === did && f.authority === authority && f.state === "active") + .reduce((s, f) => s + f.weight, 0); + } + + /** Generate initial demo delegations and start the mutation loop */ + private initDemoSimulation() { + const now = Date.now(); + this.flows = []; + this.events = []; + + // Seed ~12 initial delegations spread across all 3 verticals + const authorities: string[] = ["gov-ops", "fin-ops", "dev-ops"]; + for (const auth of authorities) { + // Each authority gets 3-5 initial delegations + const count = 3 + Math.floor(Math.random() * 3); + for (let i = 0; i < count; i++) { + const from = this.demoRand(DEMO_MEMBERS); + let to = this.demoRand(DEMO_MEMBERS); + // No self-delegation + while (to.did === from.did) to = this.demoRand(DEMO_MEMBERS); + + // Skip if this exact pair already exists for this authority + if (this.flows.some(f => f.fromDid === from.did && f.toDid === to.did && f.authority === auth)) continue; + + // Weight between 0.05 and 0.40 — capped so total outbound stays <= 1.0 + const maxAvailable = 1.0 - this.demoOutboundWeight(from.did, auth); + if (maxAvailable < 0.05) continue; + const weight = Math.min(0.05 + Math.random() * 0.35, maxAvailable); + + const createdAt = now - Math.floor(Math.random() * 7 * 24 * 60 * 60 * 1000); // within last week + this.flows.push({ + id: this.demoNextId(), fromDid: from.did, fromName: from.name, + toDid: to.did, toName: to.name, authority: auth, + weight: Math.round(weight * 100) / 100, + state: "active", createdAt, revokedAt: null, + }); + this.events.push({ + id: this.demoNextId(), sourceDid: from.did, targetDid: to.did, + eventType: "delegate", authority: auth, weightDelta: weight, createdAt, + }); + } + } + + this.loading = false; + this.render(); + + // Mutate every 2.5–4s + this._demoTimer = setInterval(() => this.demoTick(), 2500 + Math.random() * 1500); + } + + /** Single mutation step — add, adjust, revoke, or reactivate a delegation */ + private demoTick() { + const now = Date.now(); + const active = this.flows.filter(f => f.state === "active"); + const revoked = this.flows.filter(f => f.state === "revoked"); + const roll = Math.random(); + + if (roll < 0.35 || active.length < 4) { + // — Add a new delegation — + const auth = this.demoRand(["gov-ops", "fin-ops", "dev-ops"] as string[]); + const from = this.demoRand(DEMO_MEMBERS); + let to = this.demoRand(DEMO_MEMBERS); + let attempts = 0; + while ((to.did === from.did || this.flows.some(f => f.fromDid === from.did && f.toDid === to.did && f.authority === auth && f.state === "active")) && attempts < 20) { + to = this.demoRand(DEMO_MEMBERS); + attempts++; + } + if (to.did === from.did) return; // couldn't find a valid pair + + const maxAvailable = 1.0 - this.demoOutboundWeight(from.did, auth); + if (maxAvailable < 0.05) return; + const weight = Math.min(0.05 + Math.random() * 0.30, maxAvailable); + const rounded = Math.round(weight * 100) / 100; + + this.flows.push({ + id: this.demoNextId(), fromDid: from.did, fromName: from.name, + toDid: to.did, toName: to.name, authority: auth, + weight: rounded, state: "active", createdAt: now, revokedAt: null, + }); + this.events.push({ + id: this.demoNextId(), sourceDid: from.did, targetDid: to.did, + eventType: "delegate", authority: auth, weightDelta: rounded, createdAt: now, + }); + + } else if (roll < 0.65 && active.length > 0) { + // — Adjust weight of an existing delegation — + const flow = this.demoRand(active); + const maxAvailable = 1.0 - this.demoOutboundWeight(flow.fromDid, flow.authority) + flow.weight; + const delta = (Math.random() - 0.4) * 0.15; // slight bias toward increase + const newWeight = Math.max(0.03, Math.min(maxAvailable, flow.weight + delta)); + const rounded = Math.round(newWeight * 100) / 100; + const actualDelta = rounded - flow.weight; + flow.weight = rounded; + + this.events.push({ + id: this.demoNextId(), sourceDid: flow.fromDid, targetDid: flow.toDid, + eventType: "adjust", authority: flow.authority, weightDelta: actualDelta, createdAt: now, + }); + + } else if (roll < 0.85 && active.length > 4) { + // — Revoke a delegation — + const flow = this.demoRand(active); + flow.state = "revoked"; + flow.revokedAt = now; + + this.events.push({ + id: this.demoNextId(), sourceDid: flow.fromDid, targetDid: flow.toDid, + eventType: "revoke", authority: flow.authority, weightDelta: -flow.weight, createdAt: now, + }); + + } else if (revoked.length > 0) { + // — Reactivate a revoked delegation — + const flow = this.demoRand(revoked); + const maxAvailable = 1.0 - this.demoOutboundWeight(flow.fromDid, flow.authority); + if (maxAvailable < flow.weight) return; // can't fit + flow.state = "active"; + flow.revokedAt = null; + flow.createdAt = now; // reset so it shows as recent + + this.events.push({ + id: this.demoNextId(), sourceDid: flow.fromDid, targetDid: flow.toDid, + eventType: "delegate", authority: flow.authority, weightDelta: flow.weight, createdAt: now, + }); + } + + // Cap events list to prevent unbounded growth + if (this.events.length > 300) this.events = this.events.slice(-200); + + // Clear hover state to avoid stale references, then re-render + this.hoveredFlowId = null; + this.hoveredNodeDid = null; + this.render(); + } + + // ── Data filtering ────────────────────────────────────────── + private getFilteredFlows(): DelegationFlow[] { let filtered = this.flows.filter(f => f.authority === this.authority); @@ -176,97 +352,238 @@ class FolkTrustSankey extends HTMLElement { return `
No delegation flows for ${this.authority}${this.timeSliderValue < 100 ? " at this time" : ""}.
`; } - const W = 600, H = Math.max(300, flows.length * 40 + 60); - const leftX = 120, rightX = W - 120; - const nodeW = 16; + // --- True Sankey layout with stacked flow ports --- + const MIN_BAND = 6; // minimum band thickness in px + const WEIGHT_SCALE = 80; // scale factor: weight 1.0 = 80px band + const NODE_W = 14; + const NODE_GAP = 12; // vertical gap between nodes + const LABEL_W = 110; // space for text labels on each side + const W = 620; + const leftX = LABEL_W; + const rightX = W - LABEL_W; // Collect unique delegators and delegates const delegators = [...new Set(flows.map(f => f.fromDid))]; const delegates = [...new Set(flows.map(f => f.toDid))]; - // Position nodes vertically - const leftH = H - 40; - const rightH = H - 40; - const leftPositions = new Map(); - const rightPositions = new Map(); - + // Assign colors per delegator + const delegatorColor = new Map(); delegators.forEach((did, i) => { - leftPositions.set(did, 20 + (leftH * (i + 0.5)) / delegators.length); - }); - delegates.forEach((did, i) => { - rightPositions.set(did, 20 + (rightH * (i + 0.5)) / delegates.length); + delegatorColor.set(did, DELEGATOR_PALETTE[i % DELEGATOR_PALETTE.length]); }); - // Build SVG - const flowPaths: string[] = []; + // Compute node heights based on total weight + const leftTotals = new Map(); + const rightTotals = new Map(); + for (const did of delegators) { + leftTotals.set(did, flows.filter(f => f.fromDid === did).reduce((s, f) => s + f.weight, 0)); + } + for (const did of delegates) { + rightTotals.set(did, flows.filter(f => f.toDid === did).reduce((s, f) => s + f.weight, 0)); + } + + const nodeHeight = (total: number) => Math.max(MIN_BAND, total * WEIGHT_SCALE); + const bandThickness = (weight: number) => Math.max(MIN_BAND * 0.5, weight * WEIGHT_SCALE); + + // Compute total height needed for each side + const leftTotalH = delegators.reduce((s, did) => s + nodeHeight(leftTotals.get(did)!), 0) + (delegators.length - 1) * NODE_GAP; + const rightTotalH = delegates.reduce((s, did) => s + nodeHeight(rightTotals.get(did)!), 0) + (delegates.length - 1) * NODE_GAP; + const H = Math.max(240, Math.max(leftTotalH, rightTotalH) + 60); + + // Position nodes: center the column vertically, stack nodes top-to-bottom + const leftNodeY = new Map(); // top-edge Y of each left node + const rightNodeY = new Map(); + + let curY = (H - leftTotalH) / 2; + for (const did of delegators) { + leftNodeY.set(did, curY); + curY += nodeHeight(leftTotals.get(did)!) + NODE_GAP; + } + curY = (H - rightTotalH) / 2; + for (const did of delegates) { + rightNodeY.set(did, curY); + curY += nodeHeight(rightTotals.get(did)!) + NODE_GAP; + } + + // --- Stacked port allocation --- + // Track how much of each node's height has been consumed by flow connections + const leftPortOffset = new Map(); // cumulative offset from node top + const rightPortOffset = new Map(); + for (const did of delegators) leftPortOffset.set(did, 0); + for (const did of delegates) rightPortOffset.set(did, 0); + + // Sort flows by delegator order then delegate order for consistent stacking + const sortedFlows = [...flows].sort((a, b) => { + const ai = delegators.indexOf(a.fromDid); + const bi = delegators.indexOf(b.fromDid); + if (ai !== bi) return ai - bi; + return delegates.indexOf(a.toDid) - delegates.indexOf(b.toDid); + }); + + // Build flow band paths (filled bezier area between two curves) + const flowBands: string[] = []; + const flowTooltips: string[] = []; const particles: string[] = []; + const midX = (leftX + NODE_W + rightX) / 2; - for (let i = 0; i < flows.length; i++) { - const f = flows[i]; - const y1 = leftPositions.get(f.fromDid)!; - const y2 = rightPositions.get(f.toDid)!; - const thickness = 1.5 + Math.log10(1 + f.weight * 9) * 3; - const midX = (leftX + nodeW + rightX - nodeW) / 2; + for (const f of sortedFlows) { + const thickness = bandThickness(f.weight); + const color = delegatorColor.get(f.fromDid) || "#7c3aed"; + const gradRef = `url(#grad-${delegators.indexOf(f.fromDid)})`; - // Bezier path - const path = `M ${leftX + nodeW} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${rightX - nodeW} ${y2}`; - flowPaths.push(` - - + // Left side: source port position + const lTop = leftNodeY.get(f.fromDid)!; + const lOffset = leftPortOffset.get(f.fromDid)!; + const srcY1 = lTop + lOffset; + const srcY2 = srcY1 + thickness; + leftPortOffset.set(f.fromDid, lOffset + thickness); + + // Right side: target port position + const rTop = rightNodeY.get(f.toDid)!; + const rOffset = rightPortOffset.get(f.toDid)!; + const tgtY1 = rTop + rOffset; + const tgtY2 = tgtY1 + thickness; + rightPortOffset.set(f.toDid, rOffset + thickness); + + const sx = leftX + NODE_W; + const tx = rightX; + const isHovered = this.hoveredFlowId === f.id; + const opacity = this.hoveredFlowId ? (isHovered ? 0.85 : 0.15) : 0.55; + + // Filled bezier band: top curve left-to-right, bottom curve right-to-left + const bandPath = [ + `M ${sx} ${srcY1}`, + `C ${midX} ${srcY1}, ${midX} ${tgtY1}, ${tx} ${tgtY1}`, + `L ${tx} ${tgtY2}`, + `C ${midX} ${tgtY2}, ${midX} ${srcY2}, ${sx} ${srcY2}`, + "Z", + ].join(" "); + + // Center-line path for particles + const srcMid = (srcY1 + srcY2) / 2; + const tgtMid = (tgtY1 + tgtY2) / 2; + const centerPath = `M ${sx} ${srcMid} C ${midX} ${srcMid}, ${midX} ${tgtMid}, ${tx} ${tgtMid}`; + + const fromName = flows.find(fl => fl.fromDid === f.fromDid)?.fromName || f.fromDid.slice(0, 12); + const toName = flows.find(fl => fl.toDid === f.toDid)?.toName || f.toDid.slice(0, 12); + + flowBands.push(` + `); - // Animated particles + // Invisible wider hit area for hover + flowTooltips.push(` + + ${this.esc(fromName)} → ${this.esc(toName)}: ${Math.round(f.weight * 100)}% + + `); + + // Weight label on band (only if thick enough) + if (thickness >= 14) { + const labelX = midX; + const labelY = ((srcY1 + srcY2) / 2 + (tgtY1 + tgtY2) / 2) / 2 + 3; + flowBands.push(` + ${Math.round(f.weight * 100)}% + `); + } + + // Animated particles along center-line if (this.animationEnabled) { - const duration = 3 + Math.random() * 2; + const duration = 2.5 + Math.random() * 1.5; const delay = Math.random() * duration; + const particleR = Math.max(1.5, thickness * 0.12); particles.push(` - - + + `); } } - // Left nodes (delegators) - const leftNodes = delegators.map(did => { - const y = leftPositions.get(did)!; - const name = flows.find(f => f.fromDid === did)?.fromName || did.slice(0, 8); - const total = flows.filter(f => f.fromDid === did).reduce((s, f) => s + f.weight, 0); - const h = Math.max(12, total * 40); - return ` - - ${this.esc(name)} - ${Math.round(total * 100)}% - `; + // Build per-delegator gradients + const gradients = delegators.map((did, i) => { + const color = delegatorColor.get(did)!; + return ` + + + + `; }).join(""); - // Right nodes (delegates) - const rightNodes = delegates.map(did => { - const y = rightPositions.get(did)!; - const name = flows.find(f => f.toDid === did)?.toName || did.slice(0, 8); - const total = flows.filter(f => f.toDid === did).reduce((s, f) => s + f.weight, 0); - const h = Math.max(12, total * 40); + // --- Weight rankings: all unique people ranked by received trust --- + const allDids = new Set(); + flows.forEach(f => { allDids.add(f.fromDid); allDids.add(f.toDid); }); + const receivedWeight = new Map(); + allDids.forEach(did => receivedWeight.set(did, 0)); + flows.forEach(f => receivedWeight.set(f.toDid, (receivedWeight.get(f.toDid) || 0) + f.weight)); + const ranked = [...receivedWeight.entries()].sort((a, b) => b[1] - a[1]); + const rankOf = new Map(); + ranked.forEach(([did], i) => rankOf.set(did, i + 1)); - // Sparkline: recent weight changes (last 30 days) + // Name lookup helper + const nameOf = (did: string, side: "from" | "to"): string => { + const f = side === "from" + ? flows.find(fl => fl.fromDid === did) + : flows.find(fl => fl.toDid === did); + if (side === "from") return f?.fromName || did.slice(0, 8); + return f?.toName || did.slice(0, 8); + }; + + // Left nodes (delegators) + const leftNodes = delegators.map(did => { + const y = leftNodeY.get(did)!; + const total = leftTotals.get(did)!; + const h = nodeHeight(total); + const name = nameOf(did, "from"); + const color = delegatorColor.get(did)!; + const midY = y + h / 2; + const rank = rankOf.get(did) || 0; + const recW = receivedWeight.get(did) || 0; + return ` + + + ${this.esc(name)} + ${Math.round(total * 100)}% out + ${recW > 0 ? `#${rank} (${Math.round(recW * 100)}% in)` : ""} + `; + }).join(""); + + // Right nodes (delegates) — sorted by rank + const rightNodes = delegates.map(did => { + const y = rightNodeY.get(did)!; + const total = rightTotals.get(did)!; + const h = nodeHeight(total); + const name = nameOf(did, "to"); + const midY = y + h / 2; + const rank = rankOf.get(did) || 0; + + // Sparkline const sparkline = this.renderSparkline(did, 30); + // Rank badge + const rankColor = rank <= 3 ? "#fbbf24" : "#a78bfa"; + return ` - - ${this.esc(name)} - ${Math.round(total * 100)}% received - ${sparkline ? `${sparkline}` : ""} - `; + + + #${rank} + ${this.esc(name)} + ${Math.round(total * 100)}% received + ${sparkline ? `${sparkline}` : ""} + `; }).join(""); return ` - - - - - - - ${flowPaths.join("")} + ${gradients} + ${flowBands.join("")} + ${flowTooltips.join("")} ${particles.join("")} ${leftNodes} ${rightNodes} @@ -343,6 +660,17 @@ class FolkTrustSankey extends HTMLElement { .sankey-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; } .sankey-title { font-size: 15px; font-weight: 600; } + .demo-badge { + font-size: 9px; font-weight: 700; letter-spacing: 0.06em; + padding: 2px 8px; border-radius: 4px; + background: rgba(239, 68, 68, 0.15); color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.3); + animation: demo-pulse 2s ease-in-out infinite; + } + @keyframes demo-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } .authority-filter { display: flex; gap: 4px; flex-wrap: wrap; @@ -360,6 +688,9 @@ class FolkTrustSankey extends HTMLElement { border-radius: 12px; padding: 16px; overflow-x: auto; } .sankey-svg { display: block; min-height: 200px; } + .flow-band { transition: opacity 0.2s, fill 0.2s; cursor: pointer; } + .flow-hit { cursor: pointer; } + .sankey-node { transition: opacity 0.2s; } .time-slider { display: flex; align-items: center; gap: 10px; margin-top: 12px; @@ -385,6 +716,7 @@ class FolkTrustSankey extends HTMLElement {
Delegation Flows + ${this.space === "demo" ? `LIVE DEMO` : ""}
${SANKEY_AUTHORITIES.map(a => ``).join("")}
@@ -408,9 +740,9 @@ class FolkTrustSankey extends HTMLElement {
-
Delegators
-
Delegates
-
Flow (width = weight)
+
Band width = weight
+
Trust received (hover)
+
Trust given (hover)
`} `; @@ -442,6 +774,118 @@ class FolkTrustSankey extends HTMLElement { this.animationEnabled = !this.animationEnabled; this.render(); }); + + // Flow band hover — highlight individual flows (only when no node is hovered) + this.shadow.querySelectorAll(".flow-band, .flow-hit").forEach(el => { + el.addEventListener("mouseenter", () => { + if (this.hoveredNodeDid) return; // node hover takes priority + const id = (el as HTMLElement).dataset.flowId; + if (id && this.hoveredFlowId !== id) { + this.hoveredFlowId = id; + this.updateHighlights(); + } + }); + el.addEventListener("mouseleave", () => { + if (this.hoveredNodeDid) return; + if (this.hoveredFlowId) { + this.hoveredFlowId = null; + this.updateHighlights(); + } + }); + }); + + // Node hover — highlight inbound (teal) vs outbound (amber) flows + this.shadow.querySelectorAll(".sankey-node").forEach(el => { + el.addEventListener("mouseenter", () => { + const did = (el as HTMLElement).dataset.nodeDid; + if (did && this.hoveredNodeDid !== did) { + this.hoveredNodeDid = did; + this.hoveredFlowId = null; + this.updateHighlights(); + } + }); + el.addEventListener("mouseleave", () => { + if (this.hoveredNodeDid) { + this.hoveredNodeDid = null; + this.updateHighlights(); + } + }); + }); + } + + // Inbound = trust flowing INTO the hovered node (teal) + // Outbound = trust flowing FROM the hovered node (amber) + private static readonly COLOR_INBOUND = "#10b981"; + private static readonly COLOR_OUTBOUND = "#f59e0b"; + + /** Update all flow band visual states for hover (node or flow) */ + private updateHighlights() { + const hNode = this.hoveredNodeDid; + const hFlow = this.hoveredFlowId; + const active = !!(hNode || hFlow); + + this.shadow.querySelectorAll(".flow-band").forEach(band => { + const el = band as SVGElement; + const ds = (band as HTMLElement).dataset; + const origFill = ds.origFill || ""; + + if (hNode) { + // Node hover mode: color by direction + const isInbound = ds.toDid === hNode; + const isOutbound = ds.fromDid === hNode; + if (isInbound) { + el.setAttribute("fill", FolkTrustSankey.COLOR_INBOUND); + el.setAttribute("opacity", "0.8"); + el.setAttribute("stroke", FolkTrustSankey.COLOR_INBOUND); + } else if (isOutbound) { + el.setAttribute("fill", FolkTrustSankey.COLOR_OUTBOUND); + el.setAttribute("opacity", "0.8"); + el.setAttribute("stroke", FolkTrustSankey.COLOR_OUTBOUND); + } else { + el.setAttribute("fill", origFill); + el.setAttribute("opacity", "0.08"); + el.setAttribute("stroke", "none"); + } + } else if (hFlow) { + // Single flow hover mode + el.setAttribute("fill", origFill); + el.setAttribute("opacity", ds.flowId === hFlow ? "0.85" : "0.15"); + } else { + // No hover — restore defaults + el.setAttribute("fill", origFill); + el.setAttribute("opacity", "0.55"); + } + }); + + // Dim/show band weight labels + this.shadow.querySelectorAll(".flow-layer text").forEach(t => { + (t as SVGElement).setAttribute("opacity", active ? "0.1" : "0.7"); + }); + + // Highlight/dim node groups — keep connected nodes visible + if (hNode) { + // Build set of DIDs connected to the hovered node + const connectedDids = new Set([hNode]); + this.shadow.querySelectorAll(".flow-band").forEach(band => { + const ds = (band as HTMLElement).dataset; + if (ds.fromDid === hNode) connectedDids.add(ds.toDid || ""); + if (ds.toDid === hNode) connectedDids.add(ds.fromDid || ""); + }); + this.shadow.querySelectorAll(".sankey-node").forEach(g => { + const did = (g as HTMLElement).dataset.nodeDid || ""; + if (did === hNode) { + (g as SVGElement).style.opacity = "1"; + } else if (connectedDids.has(did)) { + (g as SVGElement).style.opacity = "0.85"; + } else { + (g as SVGElement).style.opacity = "0.3"; + } + }); + } else { + this.shadow.querySelectorAll(".sankey-node").forEach(g => { + (g as SVGElement).style.opacity = "1"; + }); + } } private esc(s: string): string { diff --git a/modules/rtime/components/folk-timebank-app.ts b/modules/rtime/components/folk-timebank-app.ts index 5a1e70d3..7ebd7c03 100644 --- a/modules/rtime/components/folk-timebank-app.ts +++ b/modules/rtime/components/folk-timebank-app.ts @@ -72,6 +72,7 @@ interface Wire { hours: number; status: 'proposed' | 'committed'; connectionId?: string; + connectionType?: 'commitment' | 'dependency'; } // ── Orb class ── @@ -274,6 +275,10 @@ class FolkTimebankApp extends HTMLElement { private poolPointerId: number | null = null; private poolPointerStart: { x: number; y: number; cx: number; cy: number } | null = null; private poolPanelCollapsed = false; + private _panelSplitPct = 35; + private _dividerDragging = false; + private _dividerDragStartX = 0; + private _dividerDragStartPct = 0; // Pan/zoom state private panX = 0; @@ -313,6 +318,7 @@ class FolkTimebankApp extends HTMLElement { // Data private commitments: Commitment[] = []; private tasks: TaskData[] = []; + private projectFrames: { id: string; title: string; taskIds: string[]; color?: string; x: number; y: number; w: number; h: number }[] = []; // Collaborate state private intents: any[] = []; @@ -353,9 +359,12 @@ class FolkTimebankApp extends HTMLElement { else this.currentView = 'canvas'; this.dpr = window.devicePixelRatio || 1; this._theme = (localStorage.getItem('rtime-theme') as 'dark' | 'light') || 'dark'; + this._panelSplitPct = parseFloat(localStorage.getItem('rtime-split-pct') || '35'); this.render(); this.applyTheme(); this.setupPoolPanel(); + this.setupDivider(); + this.applyPanelSplit(); this.setupCanvas(); this.setupCollaborate(); this.fetchData(); @@ -510,7 +519,8 @@ class FolkTimebankApp extends HTMLElement {
- Commitments + Commitment Pool + drag orb to weave \u2192
@@ -522,13 +532,17 @@ class FolkTimebankApp extends HTMLElement {
+
+
- + + +
@@ -545,6 +559,9 @@ class FolkTimebankApp extends HTMLElement { + + + @@ -559,6 +576,7 @@ class FolkTimebankApp extends HTMLElement { +
@@ -681,7 +699,7 @@ class FolkTimebankApp extends HTMLElement { @@ -765,10 +783,12 @@ class FolkTimebankApp extends HTMLElement { this.poolPanelCollapsed = !this.poolPanelCollapsed; const panel = this.shadow.getElementById('poolPanel')!; panel.classList.toggle('collapsed', this.poolPanelCollapsed); + const divider = this.shadow.getElementById('panelDivider'); + if (divider) divider.style.display = this.poolPanelCollapsed ? 'none' : ''; const btn = this.shadow.getElementById('poolPanelToggle')!; btn.textContent = this.poolPanelCollapsed ? '\u27E9' : '\u27E8'; btn.title = this.poolPanelCollapsed ? 'Expand panel' : 'Collapse panel'; - if (!this.poolPanelCollapsed) setTimeout(() => this.resizePoolCanvas(), 50); + if (!this.poolPanelCollapsed) setTimeout(() => { this.applyPanelSplit(); }, 50); }); // Add commitment modal @@ -842,6 +862,74 @@ class FolkTimebankApp extends HTMLElement { this.basketR = Math.min(this.poolW, this.poolH) * 0.42; } + private setupDivider() { + const divider = this.shadow.getElementById('panelDivider'); + if (!divider) return; + + divider.addEventListener('pointerdown', (e: PointerEvent) => { + e.preventDefault(); + this._dividerDragging = true; + this._dividerDragStartX = e.clientX; + this._dividerDragStartPct = this._panelSplitPct; + divider.setPointerCapture(e.pointerId); + divider.classList.add('active'); + }); + + divider.addEventListener('pointermove', (e: PointerEvent) => { + if (!this._dividerDragging) return; + const view = this.shadow.getElementById('canvas-view'); + if (!view) return; + const viewW = view.getBoundingClientRect().width; + if (viewW < 10) return; + const dx = e.clientX - this._dividerDragStartX; + const deltaPct = (dx / viewW) * 100; + this._panelSplitPct = Math.max(20, Math.min(65, this._dividerDragStartPct + deltaPct)); + this.applyPanelSplit(); + }); + + divider.addEventListener('pointerup', (e: PointerEvent) => { + if (!this._dividerDragging) return; + this._dividerDragging = false; + divider.releasePointerCapture(e.pointerId); + divider.classList.remove('active'); + localStorage.setItem('rtime-split-pct', String(this._panelSplitPct)); + }); + + divider.addEventListener('pointercancel', () => { + this._dividerDragging = false; + divider.classList.remove('active'); + }); + + // Detail "Drag to Weave" button + const dragBtn = this.shadow.getElementById('detailDragBtn'); + if (dragBtn) { + dragBtn.addEventListener('pointerdown', (e: PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (this.selectedOrb) { + this.hideDetail(); + this.startOrbDrag(this.selectedOrb, e.clientX, e.clientY); + } + }); + } + + // Add Frame button + const addFrameBtn = this.shadow.getElementById('addFrame'); + if (addFrameBtn) { + addFrameBtn.addEventListener('click', () => this.addProjectFrame()); + } + } + + private applyPanelSplit() { + const panel = this.shadow.getElementById('poolPanel'); + const divider = this.shadow.getElementById('panelDivider'); + if (!panel) return; + if (this.poolPanelCollapsed) return; + panel.style.width = this._panelSplitPct + '%'; + if (divider) divider.style.display = ''; + this.resizePoolCanvas(); + } + private availableHours(commitmentId: string): number { const commitment = this.commitments.find(c => c.id === commitmentId); if (!commitment) return 0; @@ -933,7 +1021,10 @@ class FolkTimebankApp extends HTMLElement { ctx.font = '600 13px -apple-system, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; - ctx.fillText('COMMITMENT BASKET', this.basketCX, this.basketCY - this.basketR - 14); + ctx.fillText('COMMITMENT POOL', this.basketCX, this.basketCY - this.basketR - 20); + ctx.font = '400 10px -apple-system, sans-serif'; + ctx.fillStyle = '#a78bfa88'; + ctx.fillText('pledge your time here', this.basketCX, this.basketCY - this.basketR - 6); } private poolFrame = () => { @@ -1023,12 +1114,30 @@ class FolkTimebankApp extends HTMLElement { private showDetail(orb: Orb, cx: number, cy: number) { const el = this.shadow.getElementById('poolDetail')!; + const origC = (orb as any)._originalCommitment || orb.c; const c = orb.c; (this.shadow.getElementById('detailDot') as HTMLElement).style.background = SKILL_COLORS[c.skill] || '#8b5cf6'; this.shadow.getElementById('detailName')!.textContent = c.memberName; this.shadow.getElementById('detailSkill')!.textContent = SKILL_LABELS[c.skill] || c.skill; this.shadow.getElementById('detailHours')!.textContent = c.hours + ' hour' + (c.hours !== 1 ? 's' : '') + ' pledged'; this.shadow.getElementById('detailDesc')!.textContent = c.desc; + + // Woven percentage badge + const wovenEl = this.shadow.getElementById('detailWoven'); + if (wovenEl) { + const totalHrs = origC.hours; + const usedHrs = this.connections + .filter(w => w.from === 'cn-' + origC.id) + .reduce((sum, w) => sum + (w.hours || 0), 0); + const pct = totalHrs > 0 ? Math.round((usedHrs / totalHrs) * 100) : 0; + if (usedHrs > 0) { + wovenEl.textContent = pct + '% woven (' + usedHrs + '/' + totalHrs + 'h)'; + wovenEl.style.display = ''; + } else { + wovenEl.style.display = 'none'; + } + } + const panel = this.shadow.getElementById('poolPanel')!.getBoundingClientRect(); let left = cx - panel.left + 10, top = cy - panel.top - 20; if (left + 210 > panel.width) left = cx - panel.left - 220; @@ -1262,46 +1371,128 @@ class FolkTimebankApp extends HTMLElement { return true; } + /** Generate a multi-strand woven/braided connection group. */ + private wovenPath(x1: number, y1: number, x2: number, y2: number, skill: string, hours: number, isCommitted: boolean): SVGGElement { + const g = ns('g') as SVGGElement; + g.setAttribute('class', 'woven-connection'); + const color = SKILL_COLORS[skill] || '#8b5cf6'; + const strandCount = Math.min(4, Math.ceil(hours / 2)); + const dx = x2 - x1, dy = y2 - y1; + const dist = Math.sqrt(dx * dx + dy * dy); + const spread = Math.min(12, dist * 0.04); // perpendicular strand spread + + // Normal perpendicular vector + const nx = dist > 0 ? -dy / dist : 0; + const ny = dist > 0 ? dx / dist : 1; + + // Envelope path at low opacity for "cable" feel + const envOff = spread * (strandCount - 1) * 0.5 + 3; + const cd = Math.max(40, Math.abs(dx) * 0.5); + const envPath = ns('path'); + const envD = `M${x1 + nx * envOff},${y1 + ny * envOff} C${x1 + cd + nx * envOff},${y1 + ny * envOff} ${x2 - cd + nx * envOff},${y2 + ny * envOff} ${x2 + nx * envOff},${y2 + ny * envOff}` + + ` L${x2 - nx * envOff},${y2 - ny * envOff}` + + ` C${x2 - cd - nx * envOff},${y2 - ny * envOff} ${x1 + cd - nx * envOff},${y1 - ny * envOff} ${x1 - nx * envOff},${y1 - ny * envOff} Z`; + envPath.setAttribute('d', envD); + envPath.setAttribute('fill', color); + envPath.setAttribute('opacity', '0.05'); + g.appendChild(envPath); + + // Individual strands + for (let i = 0; i < strandCount; i++) { + const offset = (i - (strandCount - 1) / 2) * spread; + const phase = i * Math.PI * 0.6; // phase shift for intertwining + const amplitude = spread * 0.7; + + // Build cubic bezier with phase-shifted control points + const sx = x1 + nx * offset; + const sy = y1 + ny * offset; + const ex = x2 + nx * offset; + const ey = y2 + ny * offset; + + const cp1x = x1 + cd + nx * (offset + Math.sin(phase) * amplitude); + const cp1y = y1 + ny * (offset + Math.sin(phase) * amplitude); + const cp2x = x2 - cd + nx * (offset + Math.sin(phase + Math.PI) * amplitude); + const cp2y = y2 + ny * (offset + Math.sin(phase + Math.PI) * amplitude); + + const strand = ns('path'); + strand.setAttribute('d', `M${sx},${sy} C${cp1x},${cp1y} ${cp2x},${cp2y} ${ex},${ey}`); + strand.setAttribute('fill', 'none'); + strand.setAttribute('stroke', color); + strand.setAttribute('stroke-width', '1.8'); + strand.setAttribute('opacity', String(i % 2 === 0 ? 0.85 : 0.55)); + if (!isCommitted) strand.setAttribute('stroke-dasharray', '6 4'); + g.appendChild(strand); + } + + return g; + } + private renderConnections() { this.connectionsLayer.innerHTML = ''; this.connections.forEach(conn => { const from = this.weaveNodes.find(n => n.id === conn.from); const to = this.weaveNodes.find(n => n.id === conn.to); if (!from || !to) return; + + const isDep = (conn as any).connectionType === 'dependency'; + let x1: number, y1: number; - if (from.type === 'commitment') { + if (isDep) { + // Dependency: from right center of source task + x1 = from.x + from.w; y1 = from.y + from.h / 2; + } else if (from.type === 'commitment') { const cx = from.x + from.w / 2, cy = from.y + from.h / 2; const pts = hexPoints(cx, cy, from.hexR || HEX_R); x1 = pts[1][0]; y1 = pts[1][1]; } else { x1 = from.x + from.w; y1 = from.y + from.h / 2; } - let x2 = to.x, y2 = to.y + to.h / 2; - if (to.type === 'task') { - const skills = Object.keys(to.data.needs); - const idx = skills.indexOf(conn.skill); - if (idx >= 0) y2 = to.y + TASK_H_BASE + idx * TASK_ROW + TASK_ROW / 2; + + let x2: number, y2: number; + if (isDep) { + // Dependency: to left center of target task + x2 = to.x; y2 = to.y + to.h / 2; + } else { + x2 = to.x; y2 = to.y + to.h / 2; + if (to.type === 'task') { + const skills = Object.keys(to.data.needs); + const idx = skills.indexOf(conn.skill); + if (idx >= 0) y2 = to.y + TASK_H_BASE + idx * TASK_ROW + TASK_ROW / 2; + } } + const isCommitted = conn.status === 'committed'; - const p = ns('path'); - p.setAttribute('d', bezier(x1, y1, x2, y2)); - p.setAttribute('class', 'connection-line active'); - p.setAttribute('stroke', isCommitted ? '#10b981' : '#f59e0b'); - p.setAttribute('stroke-width', '2'); - if (!isCommitted) p.setAttribute('stroke-dasharray', '6 4'); - this.connectionsLayer.appendChild(p); + + if (isDep) { + // Dependency arrow: simple gray dashed bezier with arrowhead + const p = ns('path'); + p.setAttribute('d', bezier(x1, y1, x2, y2)); + p.setAttribute('fill', 'none'); + p.setAttribute('stroke', '#64748b'); + p.setAttribute('stroke-width', '1.5'); + p.setAttribute('stroke-dasharray', '6 3'); + p.setAttribute('marker-end', 'url(#dep-arrow)'); + p.setAttribute('class', 'dep-connection'); + this.connectionsLayer.appendChild(p); + } else { + // Commitment: multi-strand woven path + const wovenG = this.wovenPath(x1, y1, x2, y2, conn.skill, conn.hours, isCommitted); + this.connectionsLayer.appendChild(wovenG); + } // Hour label at midpoint const mx = (x1 + x2) / 2, my = (y1 + y2) / 2; - const label = ns('text') as SVGTextElement; - label.setAttribute('x', String(mx)); - label.setAttribute('y', String(my - 6)); - label.setAttribute('text-anchor', 'middle'); - label.setAttribute('font-size', '10'); - label.setAttribute('font-weight', '600'); - label.setAttribute('fill', isCommitted ? '#10b981' : '#f59e0b'); - label.textContent = conn.hours + 'h ' + (isCommitted ? '\u2713' : '\u23f3'); - this.connectionsLayer.appendChild(label); + if (!isDep) { + const label = ns('text') as SVGTextElement; + label.setAttribute('x', String(mx)); + label.setAttribute('y', String(my - 6)); + label.setAttribute('text-anchor', 'middle'); + label.setAttribute('font-size', '10'); + label.setAttribute('font-weight', '600'); + label.setAttribute('fill', isCommitted ? '#10b981' : '#f59e0b'); + label.textContent = conn.hours + 'h ' + (isCommitted ? '\u2713' : '\u23f3'); + this.connectionsLayer.appendChild(label); + } }); // ── Mycelial suggestion preview ── @@ -1587,6 +1778,37 @@ class FolkTimebankApp extends HTMLElement { if (committed >= needed) g.appendChild(svgText('\u2713', node.w - 16, ry + TASK_ROW / 2 + 4, 13, '#10b981', '600', 'middle')); }); + // Dependency ports (diamond-shaped) + // Input dep-port: left center + const depInHit = ns('rect'); + depInHit.setAttribute('x', '-12'); depInHit.setAttribute('y', String(node.h / 2 - 12)); + depInHit.setAttribute('width', '24'); depInHit.setAttribute('height', '24'); + depInHit.setAttribute('fill', 'transparent'); + depInHit.setAttribute('class', 'dep-port'); + depInHit.setAttribute('data-node', node.id); depInHit.setAttribute('data-port', 'dep-input'); + g.appendChild(depInHit); + const depIn = ns('polygon'); + depIn.setAttribute('points', `0,${node.h / 2 - 5} 5,${node.h / 2} 0,${node.h / 2 + 5} -5,${node.h / 2}`); + depIn.setAttribute('fill', '#475569'); depIn.setAttribute('stroke', '#64748b'); depIn.setAttribute('stroke-width', '1'); + depIn.setAttribute('class', 'dep-port-diamond'); + depIn.style.pointerEvents = 'none'; + g.appendChild(depIn); + + // Output dep-port: right center + const depOutHit = ns('rect'); + depOutHit.setAttribute('x', String(node.w - 12)); depOutHit.setAttribute('y', String(node.h / 2 - 12)); + depOutHit.setAttribute('width', '24'); depOutHit.setAttribute('height', '24'); + depOutHit.setAttribute('fill', 'transparent'); + depOutHit.setAttribute('class', 'dep-port'); + depOutHit.setAttribute('data-node', node.id); depOutHit.setAttribute('data-port', 'dep-output'); + g.appendChild(depOutHit); + const depOut = ns('polygon'); + depOut.setAttribute('points', `${node.w},${node.h / 2 - 5} ${node.w + 5},${node.h / 2} ${node.w},${node.h / 2 + 5} ${node.w - 5},${node.h / 2}`); + depOut.setAttribute('fill', '#475569'); depOut.setAttribute('stroke', '#64748b'); depOut.setAttribute('stroke-width', '1'); + depOut.setAttribute('class', 'dep-port-diamond'); + depOut.style.pointerEvents = 'none'; + g.appendChild(depOut); + if (ready) { const btnY = node.baseH! + 4; const btnR = ns('rect'); @@ -1610,6 +1832,7 @@ class FolkTimebankApp extends HTMLElement { this.nodesLayer.innerHTML = ''; this.weaveNodes.forEach(n => this.nodesLayer.appendChild(this.renderNode(n))); this.renderConnections(); + this.renderProjectFrames(); } // SVG pointer events (with pan/zoom support) @@ -1656,6 +1879,68 @@ class FolkTimebankApp extends HTMLElement { return; } + // Frame resize handle + const resizeHandle = (e.target as Element).closest('.frame-resize-handle') as SVGElement; + if (resizeHandle) { + const frameId = resizeHandle.getAttribute('data-frame-id'); + const frame = this.projectFrames.find(f => f.id === frameId); + if (frame) { + e.preventDefault(); + const startPt = this.screenToCanvas(e.clientX, e.clientY); + const startW = frame.w, startH = frame.h; + const resizeMove = (ev: PointerEvent) => { + const curPt = this.screenToCanvas(ev.clientX, ev.clientY); + frame.w = Math.max(120, startW + (curPt.x - startPt.x)); + frame.h = Math.max(80, startH + (curPt.y - startPt.y)); + this.renderAll(); + }; + const resizeUp = () => { + document.removeEventListener('pointermove', resizeMove); + document.removeEventListener('pointerup', resizeUp); + this.autoAssignTasksToFrame(frame); + this.renderAll(); + }; + document.addEventListener('pointermove', resizeMove); + document.addEventListener('pointerup', resizeUp); + return; + } + } + + // Frame drag (click on frame rect but not resize handle) + const frameRect = (e.target as Element).closest('.project-frame-rect') as SVGElement; + if (frameRect) { + const frameGroup = frameRect.closest('.project-frame-group') as SVGElement; + const frameId = frameGroup?.getAttribute('data-frame-id'); + const frame = this.projectFrames.find(f => f.id === frameId); + if (frame) { + e.preventDefault(); + const startPt = this.screenToCanvas(e.clientX, e.clientY); + const startX = frame.x, startY = frame.y; + const dragMove = (ev: PointerEvent) => { + const curPt = this.screenToCanvas(ev.clientX, ev.clientY); + frame.x = startX + (curPt.x - startPt.x); + frame.y = startY + (curPt.y - startPt.y); + this.renderAll(); + }; + const dragUp = () => { + document.removeEventListener('pointermove', dragMove); + document.removeEventListener('pointerup', dragUp); + }; + document.addEventListener('pointermove', dragMove); + document.addEventListener('pointerup', dragUp); + return; + } + } + + // Dep-port connections (task-to-task dependencies) + const depPort = (e.target as Element).closest('.dep-port') as SVGElement; + if (depPort) { + e.preventDefault(); + this.connecting = { nodeId: depPort.getAttribute('data-node')!, portType: depPort.getAttribute('data-port')!, skill: '__dep__' }; + this.tempConn.style.display = 'block'; + return; + } + const port = (e.target as Element).closest('.port') as SVGElement; if (port) { e.preventDefault(); @@ -1707,7 +1992,13 @@ class FolkTimebankApp extends HTMLElement { if (this.connecting) { const fn = this.weaveNodes.find(n => n.id === this.connecting!.nodeId); if (!fn) return; - if (this.connecting.portType === 'input') { + if (this.connecting.portType === 'dep-input') { + const x1 = fn.x, y1 = fn.y + fn.h / 2; + this.tempConn.setAttribute('d', bezier(pt.x, pt.y, x1, y1)); + } else if (this.connecting.portType === 'dep-output') { + const outX = fn.x + fn.w, outY = fn.y + fn.h / 2; + this.tempConn.setAttribute('d', bezier(outX, outY, pt.x, pt.y)); + } else if (this.connecting.portType === 'input') { const skills = fn.type === 'task' ? Object.keys(fn.data.needs) : []; const idx = skills.indexOf(this.connecting.skill!); const x1 = fn.x; @@ -1738,6 +2029,29 @@ class FolkTimebankApp extends HTMLElement { } if (this.connecting) { + // Check for dep-port target first + const depTarget = (e.target as Element).closest('.dep-port') as SVGElement; + if (depTarget && this.connecting.skill === '__dep__') { + const tId = depTarget.getAttribute('data-node')!; + const tType = depTarget.getAttribute('data-port')!; + if (tId !== this.connecting.nodeId) { + let fromId: string | undefined, toId: string | undefined; + if (this.connecting.portType === 'dep-output' && tType === 'dep-input') { + fromId = this.connecting.nodeId; toId = tId; + } else if (this.connecting.portType === 'dep-input' && tType === 'dep-output') { + fromId = tId; toId = this.connecting.nodeId; + } + if (fromId && toId && !this.connections.find(c => c.from === fromId && c.to === toId && (c as any).connectionType === 'dependency')) { + this.connections.push({ from: fromId, to: toId, skill: '__dep__', hours: 0, status: 'committed', connectionType: 'dependency' } as any); + this.renderAll(); + } + } + this.connecting = null; + this.tempConn.style.display = 'none'; + this.tempConn.setAttribute('d', ''); + return; + } + const port = (e.target as Element).closest('.port') as SVGElement; if (port) { const tId = port.getAttribute('data-node')!; @@ -1778,6 +2092,19 @@ class FolkTimebankApp extends HTMLElement { this.tempConn.setAttribute('d', ''); return; } + if (this.dragNode && this.dragNode.type === 'task') { + // Auto-assign task to frame if dropped inside + const nodeCx = this.dragNode.x + this.dragNode.w / 2; + const nodeCy = this.dragNode.y + this.dragNode.h / 2; + for (const frame of this.projectFrames) { + if (nodeCx >= frame.x && nodeCx <= frame.x + frame.w && + nodeCy >= frame.y && nodeCy <= frame.y + frame.h) { + if (!frame.taskIds.includes(this.dragNode.id)) frame.taskIds.push(this.dragNode.id); + } else { + frame.taskIds = frame.taskIds.filter(id => id !== this.dragNode!.id); + } + } + } this.dragNode = null; } @@ -2034,6 +2361,137 @@ class FolkTimebankApp extends HTMLElement { this.renderAll(); } + // ── Project Frames ── + + private renderProjectFrames() { + // Clear old frames but keep intent frames + this.intentFramesLayer.querySelectorAll('.project-frame-group').forEach(el => el.remove()); + + for (const frame of this.projectFrames) { + const g = ns('g') as SVGGElement; + g.setAttribute('class', 'project-frame-group'); + g.setAttribute('data-frame-id', frame.id); + + const fillColor = frame.color || '#8b5cf620'; + const strokeColor = frame.color ? frame.color + '66' : '#8b5cf666'; + + const rect = ns('rect'); + rect.setAttribute('x', String(frame.x)); + rect.setAttribute('y', String(frame.y)); + rect.setAttribute('width', String(frame.w)); + rect.setAttribute('height', String(frame.h)); + rect.setAttribute('rx', '12'); + rect.setAttribute('fill', fillColor); + rect.setAttribute('stroke', strokeColor); + rect.setAttribute('stroke-width', '2'); + rect.setAttribute('stroke-dasharray', '8 4'); + rect.setAttribute('class', 'project-frame-rect'); + g.appendChild(rect); + + // Title bar + const titleBg = ns('rect'); + titleBg.setAttribute('x', String(frame.x)); + titleBg.setAttribute('y', String(frame.y)); + titleBg.setAttribute('width', String(frame.w)); + titleBg.setAttribute('height', '28'); + titleBg.setAttribute('rx', '12'); + titleBg.setAttribute('fill', strokeColor); + g.appendChild(titleBg); + // Bottom corners mask + const titleMask = ns('rect'); + titleMask.setAttribute('x', String(frame.x)); + titleMask.setAttribute('y', String(frame.y + 16)); + titleMask.setAttribute('width', String(frame.w)); + titleMask.setAttribute('height', '12'); + titleMask.setAttribute('fill', strokeColor); + g.appendChild(titleMask); + + const titleText = svgText(frame.title, frame.x + 12, frame.y + 18, 11, '#f1f5f9', '600'); + g.appendChild(titleText); + + // Resize handle (bottom-right) + const resizeHandle = ns('rect'); + resizeHandle.setAttribute('x', String(frame.x + frame.w - 16)); + resizeHandle.setAttribute('y', String(frame.y + frame.h - 16)); + resizeHandle.setAttribute('width', '16'); + resizeHandle.setAttribute('height', '16'); + resizeHandle.setAttribute('fill', 'transparent'); + resizeHandle.setAttribute('class', 'frame-resize-handle'); + resizeHandle.setAttribute('data-frame-id', frame.id); + resizeHandle.style.cursor = 'nwse-resize'; + g.appendChild(resizeHandle); + + // Resize grip visual + const grip = ns('path'); + grip.setAttribute('d', `M${frame.x + frame.w - 12},${frame.y + frame.h - 4} L${frame.x + frame.w - 4},${frame.y + frame.h - 12} M${frame.x + frame.w - 8},${frame.y + frame.h - 4} L${frame.x + frame.w - 4},${frame.y + frame.h - 8}`); + grip.setAttribute('stroke', '#64748b'); + grip.setAttribute('stroke-width', '1.5'); + grip.setAttribute('fill', 'none'); + grip.style.pointerEvents = 'none'; + g.appendChild(grip); + + this.intentFramesLayer.appendChild(g); + } + } + + private addProjectFrame() { + const wrap = this.shadow.getElementById('canvasWrap'); + const wrapRect = wrap?.getBoundingClientRect(); + const cx = wrapRect ? wrapRect.width / 2 : 400; + const cy = wrapRect ? wrapRect.height / 2 : 300; + const pt = this.screenToCanvas( + (wrapRect?.left || 0) + cx, + (wrapRect?.top || 0) + cy + ); + + const frame = { + id: 'frame-' + Date.now(), + title: 'Project Frame', + taskIds: [] as string[], + x: pt.x - 150, + y: pt.y - 100, + w: 300, + h: 200, + }; + this.projectFrames.push(frame); + + // Auto-assign tasks that are already inside bounds + this.autoAssignTasksToFrame(frame); + this.renderAll(); + } + + private autoAssignTasksToFrame(frame: { id: string; taskIds: string[]; x: number; y: number; w: number; h: number }) { + for (const node of this.weaveNodes) { + if (node.type !== 'task') continue; + const nodeCx = node.x + node.w / 2; + const nodeCy = node.y + node.h / 2; + if (nodeCx >= frame.x && nodeCx <= frame.x + frame.w && + nodeCy >= frame.y && nodeCy <= frame.y + frame.h) { + if (!frame.taskIds.includes(node.id)) frame.taskIds.push(node.id); + } + } + } + + private recomputeFrameBounds(frameId: string) { + const frame = this.projectFrames.find(f => f.id === frameId); + if (!frame || frame.taskIds.length === 0) return; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const tid of frame.taskIds) { + const node = this.weaveNodes.find(n => n.id === tid); + if (!node) continue; + minX = Math.min(minX, node.x); + minY = Math.min(minY, node.y); + maxX = Math.max(maxX, node.x + node.w); + maxY = Math.max(maxY, node.y + node.h); + } + if (minX === Infinity) return; + const pad = 20; + frame.x = minX - pad; + frame.y = minY - pad - 28; // extra for title bar + frame.w = (maxX - minX) + pad * 2; + frame.h = (maxY - minY) + pad * 2 + 28; + } + /** Highlight task nodes that have unfulfilled ports matching the given skill. */ private applySkillHighlights(skill: string) { const groups = this.nodesLayer.querySelectorAll('.task-node'); @@ -3026,7 +3484,7 @@ const CSS_TEXT = ` /* Pool panel (left side) */ .pool-panel { - width: 260px; + min-width: 180px; flex-shrink: 0; background: #1e293b; border-right: 1px solid #334155; @@ -3082,6 +3540,56 @@ const CSS_TEXT = ` .pool-detail-skill { font-size: 0.78rem; color: #94a3b8; margin-bottom: 0.2rem; } .pool-detail-hours { font-size: 0.82rem; font-weight: 600; color: #8b5cf6; } .pool-detail-desc { font-size: 0.75rem; color: #94a3b8; margin-top: 0.25rem; line-height: 1.4; } +.pool-detail-woven { + font-size: 0.72rem; color: #8b5cf6; font-weight: 600; + margin-top: 0.35rem; padding: 0.2rem 0.5rem; + background: rgba(139,92,246,0.1); border-radius: 0.25rem; + display: inline-block; +} +.pool-detail-drag-btn { + margin-top: 0.5rem; padding: 0.35rem 0.75rem; width: 100%; + background: linear-gradient(135deg, #8b5cf6, #ec4899); + color: #fff; border: none; border-radius: 0.375rem; + font-size: 0.78rem; font-weight: 600; cursor: grab; + transition: opacity 0.15s; touch-action: none; +} +.pool-detail-drag-btn:hover { opacity: 0.85; } +.pool-detail-drag-btn:active { cursor: grabbing; } + +/* Pool header hint */ +.pool-hint { + font-size: 0.68rem; font-weight: 400; color: #64748b; + font-style: italic; margin-left: auto; margin-right: 0.5rem; +} + +/* Resizable divider */ +.panel-divider { + width: 6px; + flex-shrink: 0; + background: #1e293b; + border-left: 1px solid #334155; + border-right: 1px solid #334155; + cursor: ew-resize; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; + touch-action: none; + z-index: 10; +} +.panel-divider:hover, .panel-divider.active { + background: #8b5cf6; + border-color: #8b5cf6; +} +.divider-pip { + width: 2px; height: 24px; + border-radius: 1px; + background: #475569; + pointer-events: none; +} +.panel-divider:hover .divider-pip, .panel-divider.active .divider-pip { + background: #fff; +} .pool-panel-sidebar { flex-shrink: 0; overflow-y: auto; max-height: 200px; border-top: 1px solid #334155; } @@ -3173,6 +3681,20 @@ const CSS_TEXT = ` .task-node.ready .node-rect { stroke: #10b981; stroke-width: 2; } .task-node.skill-match .node-rect { stroke: #fbbf24; stroke-width: 2.5; filter: url(#glowGold); } +/* Dependency ports */ +.dep-port { cursor: crosshair; touch-action: none; } +.dep-port:hover + .dep-port-diamond { fill: #8b5cf6; stroke: #a78bfa; } +.dep-port-diamond { transition: fill 0.15s; } +.dep-connection { pointer-events: none; } +.woven-connection { pointer-events: none; } + +/* Project frames */ +.project-frame-group { cursor: grab; } +.project-frame-group:active { cursor: grabbing; } +.project-frame-rect { transition: stroke 0.15s; } +.project-frame-group:hover .project-frame-rect { stroke-width: 2.5; } +.frame-resize-handle { cursor: nwse-resize; } + /* Intent frames */ .intent-frame { pointer-events: none; } .intent-frame-label { pointer-events: none; } @@ -3651,6 +4173,12 @@ const CSS_TEXT = ` :host([data-theme="light"]) .zoom-controls button:hover { border-color: #8b5cf6; background: #f1f5f9; } :host([data-theme="light"]) .pool-detail { background: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.12); } :host([data-theme="light"]) .pool-detail-name { color: #1e293b; } +:host([data-theme="light"]) .panel-divider { background: #f1f5f9; border-color: #e2e8f0; } +:host([data-theme="light"]) .panel-divider:hover, :host([data-theme="light"]) .panel-divider.active { background: #8b5cf6; border-color: #8b5cf6; } +:host([data-theme="light"]) .divider-pip { background: #94a3b8; } +:host([data-theme="light"]) .dep-port-diamond { fill: #e2e8f0; stroke: #94a3b8; } +:host([data-theme="light"]) .project-frame-rect { stroke: #8b5cf644; } +:host([data-theme="light"]) .pool-detail-woven { background: rgba(139,92,246,0.08); } /* Hex hover stroke */ .hex-hover-stroke { transition: stroke-width 0.15s; } @@ -3711,13 +4239,16 @@ const CSS_TEXT = ` .exec-step-checklist input[type="checkbox"] { accent-color: #8b5cf6; } @media (max-width: 768px) { - .pool-panel { width: 200px; } + #canvas-view { flex-direction: column; } + .pool-panel { width: 100% !important; max-height: 220px; min-width: unset; border-right: none; border-bottom: 1px solid #334155; } + .panel-divider { display: none !important; } + .pool-hint { display: none; } .exec-panel { width: 95vw; } .task-edit-panel { width: 95vw; } } @media (max-width: 640px) { - .pool-panel { width: 180px; } - .pool-panel.collapsed { width: 36px; } + .pool-panel { max-height: 180px; } + .pool-panel.collapsed { width: 100% !important; max-height: 36px; } } `; diff --git a/modules/rtime/schemas.ts b/modules/rtime/schemas.ts index 97d3fb96..848945a5 100644 --- a/modules/rtime/schemas.ts +++ b/modules/rtime/schemas.ts @@ -62,6 +62,20 @@ export interface Connection { skill: string; hours: number; // hours allocated in this connection status: 'proposed' | 'committed'; // approval state + connectionType?: 'commitment' | 'dependency'; // commitment = resource flow, dependency = structural sequence +} + +// ── Project Frame ── + +export interface ProjectFrame { + id: string; + title: string; + taskIds: string[]; + color?: string; // optional skill-color override + x: number; + y: number; + w: number; + h: number; } export interface ExecState { @@ -95,6 +109,7 @@ export interface TasksDoc { tasks: Record; connections: Record; execStates: Record; + projectFrames: Record; } // ── External Time Log (backlog-md integration) ── @@ -174,6 +189,7 @@ export const tasksSchema: DocSchema = { tasks: {}, connections: {}, execStates: {}, + projectFrames: {}, }), };