Merge branch 'dev'
CI/CD / deploy (push) Failing after 2m46s
Details
CI/CD / deploy (push) Failing after 2m46s
Details
This commit is contained in:
commit
06327f07e1
|
|
@ -36,7 +36,26 @@ const SANKEY_AUTHORITY_DISPLAY: Record<string, { label: string; color: string }>
|
|||
"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<typeof setInterval> | 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<T>(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 `<div class="empty">No delegation flows for ${this.authority}${this.timeSliderValue < 100 ? " at this time" : ""}.</div>`;
|
||||
}
|
||||
|
||||
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<string, number>();
|
||||
const rightPositions = new Map<string, number>();
|
||||
|
||||
// Assign colors per delegator
|
||||
const delegatorColor = new Map<string, string>();
|
||||
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<string, number>();
|
||||
const rightTotals = new Map<string, number>();
|
||||
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<string, number>(); // top-edge Y of each left node
|
||||
const rightNodeY = new Map<string, number>();
|
||||
|
||||
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<string, number>(); // cumulative offset from node top
|
||||
const rightPortOffset = new Map<string, number>();
|
||||
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(`
|
||||
<path d="${path}" fill="none" stroke="${FLOW_COLOR}" stroke-width="${thickness}" opacity="0.3"/>
|
||||
<path d="${path}" fill="none" stroke="url(#flow-gradient)" stroke-width="${thickness}" opacity="0.6"/>
|
||||
// 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(`
|
||||
<path class="flow-band" data-flow-id="${f.id}" data-from-did="${f.fromDid}" data-to-did="${f.toDid}"
|
||||
data-orig-fill="${gradRef}" d="${bandPath}"
|
||||
fill="${gradRef}" opacity="${opacity}"
|
||||
stroke="${color}" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
`);
|
||||
|
||||
// Animated particles
|
||||
// Invisible wider hit area for hover
|
||||
flowTooltips.push(`
|
||||
<path class="flow-hit" data-flow-id="${f.id}" data-from-did="${f.fromDid}" data-to-did="${f.toDid}"
|
||||
d="${centerPath}" fill="none" stroke="transparent" stroke-width="${Math.max(thickness, 12)}">
|
||||
<title>${this.esc(fromName)} → ${this.esc(toName)}: ${Math.round(f.weight * 100)}%</title>
|
||||
</path>
|
||||
`);
|
||||
|
||||
// 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(`
|
||||
<text x="${labelX}" y="${labelY}" fill="#fff" font-size="9" font-weight="600"
|
||||
text-anchor="middle" opacity="${this.hoveredFlowId ? (isHovered ? 0.9 : 0.1) : 0.7}"
|
||||
pointer-events="none">${Math.round(f.weight * 100)}%</text>
|
||||
`);
|
||||
}
|
||||
|
||||
// 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(`
|
||||
<circle r="${Math.max(2, thickness * 0.4)}" fill="${FLOW_COLOR}" opacity="0.8">
|
||||
<animateMotion dur="${duration}s" begin="${delay}s" repeatCount="indefinite" path="${path}"/>
|
||||
<circle r="${particleR}" fill="#fff" opacity="0.6">
|
||||
<animateMotion dur="${duration}s" begin="${delay}s" repeatCount="indefinite" path="${centerPath}"/>
|
||||
</circle>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 `
|
||||
<rect x="${leftX}" y="${y - h/2}" width="${nodeW}" height="${h}" rx="3" fill="#7c3aed" opacity="0.8"/>
|
||||
<text x="${leftX - 6}" y="${y + 4}" fill="var(--rs-text-primary)" font-size="11" text-anchor="end" font-weight="500">${this.esc(name)}</text>
|
||||
<text x="${leftX - 6}" y="${y + 16}" fill="var(--rs-text-muted)" font-size="9" text-anchor="end">${Math.round(total * 100)}%</text>
|
||||
`;
|
||||
// Build per-delegator gradients
|
||||
const gradients = delegators.map((did, i) => {
|
||||
const color = delegatorColor.get(did)!;
|
||||
return `<linearGradient id="grad-${i}" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="${color}" stop-opacity="0.8"/>
|
||||
<stop offset="50%" stop-color="${color}" stop-opacity="0.5"/>
|
||||
<stop offset="100%" stop-color="${color}" stop-opacity="0.35"/>
|
||||
</linearGradient>`;
|
||||
}).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<string>();
|
||||
flows.forEach(f => { allDids.add(f.fromDid); allDids.add(f.toDid); });
|
||||
const receivedWeight = new Map<string, number>();
|
||||
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<string, number>();
|
||||
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 `
|
||||
<g class="sankey-node" data-node-did="${did}" style="cursor:pointer">
|
||||
<rect x="${leftX}" y="${y}" width="${NODE_W}" height="${h}" rx="3" fill="${color}" opacity="0.9"/>
|
||||
<text x="${leftX - 8}" y="${midY + 1}" fill="var(--rs-text-primary)" font-size="11" text-anchor="end" font-weight="600" dominant-baseline="middle">${this.esc(name)}</text>
|
||||
<text x="${leftX - 8}" y="${midY + 13}" fill="var(--rs-text-muted)" font-size="9" text-anchor="end">${Math.round(total * 100)}% out</text>
|
||||
${recW > 0 ? `<text x="${leftX - 8}" y="${midY + 23}" fill="#10b981" font-size="8" text-anchor="end">#${rank} (${Math.round(recW * 100)}% in)</text>` : ""}
|
||||
</g>`;
|
||||
}).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 `
|
||||
<rect x="${rightX - nodeW}" y="${y - h/2}" width="${nodeW}" height="${h}" rx="3" fill="#a78bfa" opacity="0.8"/>
|
||||
<text x="${rightX + 6}" y="${y + 4}" fill="var(--rs-text-primary)" font-size="11" text-anchor="start" font-weight="500">${this.esc(name)}</text>
|
||||
<text x="${rightX + 6}" y="${y + 16}" fill="var(--rs-text-muted)" font-size="9" text-anchor="start">${Math.round(total * 100)}% received</text>
|
||||
${sparkline ? `<g transform="translate(${rightX + 6}, ${y + 20})">${sparkline}</g>` : ""}
|
||||
`;
|
||||
<g class="sankey-node" data-node-did="${did}" style="cursor:pointer">
|
||||
<rect x="${rightX}" y="${y}" width="${NODE_W}" height="${h}" rx="3" fill="#a78bfa" opacity="0.9"/>
|
||||
<text x="${rightX + NODE_W + 8}" y="${midY - 4}" fill="${rankColor}" font-size="9" text-anchor="start" font-weight="700">#${rank}</text>
|
||||
<text x="${rightX + NODE_W + 8}" y="${midY + 8}" fill="var(--rs-text-primary)" font-size="11" text-anchor="start" font-weight="600">${this.esc(name)}</text>
|
||||
<text x="${rightX + NODE_W + 8}" y="${midY + 20}" fill="var(--rs-text-muted)" font-size="9" text-anchor="start">${Math.round(total * 100)}% received</text>
|
||||
${sparkline ? `<g transform="translate(${rightX + NODE_W + 8}, ${midY + 24})">${sparkline}</g>` : ""}
|
||||
</g>`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<svg width="100%" viewBox="0 0 ${W} ${H}" class="sankey-svg">
|
||||
<defs>
|
||||
<linearGradient id="flow-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#7c3aed" stop-opacity="0.6"/>
|
||||
<stop offset="100%" stop-color="#a78bfa" stop-opacity="0.4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g class="flow-layer">${flowPaths.join("")}</g>
|
||||
<defs>${gradients}</defs>
|
||||
<g class="flow-layer">${flowBands.join("")}</g>
|
||||
<g class="hit-layer">${flowTooltips.join("")}</g>
|
||||
<g class="particle-layer">${particles.join("")}</g>
|
||||
<g class="left-nodes">${leftNodes}</g>
|
||||
<g class="right-nodes">${rightNodes}</g>
|
||||
|
|
@ -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 {
|
|||
|
||||
<div class="sankey-header">
|
||||
<span class="sankey-title">Delegation Flows</span>
|
||||
${this.space === "demo" ? `<span class="demo-badge">LIVE DEMO</span>` : ""}
|
||||
<div class="authority-filter">
|
||||
${SANKEY_AUTHORITIES.map(a => `<button class="authority-btn ${this.authority === a ? "active" : ""}" data-authority="${a}">${SANKEY_AUTHORITY_DISPLAY[a]?.label || a}</button>`).join("")}
|
||||
</div>
|
||||
|
|
@ -408,9 +740,9 @@ class FolkTrustSankey extends HTMLElement {
|
|||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item"><span class="legend-dot" style="background:#7c3aed"></span> Delegators</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background:#a78bfa"></span> Delegates</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background:linear-gradient(90deg,#7c3aed,#a78bfa)"></span> Flow (width = weight)</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background:linear-gradient(90deg,#7c3aed,#a78bfa);width:24px"></span> Band width = weight</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background:#10b981"></span> Trust received (hover)</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background:#f59e0b"></span> Trust given (hover)</div>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
|
|
@ -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<string>([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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
<!-- Left pool panel -->
|
||||
<div class="pool-panel" id="poolPanel">
|
||||
<div class="pool-panel-header">
|
||||
<span>Commitments</span>
|
||||
<span>Commitment Pool</span>
|
||||
<span class="pool-hint">drag orb to weave \u2192</span>
|
||||
<button id="poolPanelToggle" title="Collapse panel">\u27E8</button>
|
||||
</div>
|
||||
<canvas id="pool-canvas"></canvas>
|
||||
|
|
@ -522,13 +532,17 @@ class FolkTimebankApp extends HTMLElement {
|
|||
<div class="pool-detail-skill" id="detailSkill"></div>
|
||||
<div class="pool-detail-hours" id="detailHours"></div>
|
||||
<div class="pool-detail-desc" id="detailDesc"></div>
|
||||
<div class="pool-detail-woven" id="detailWoven"></div>
|
||||
<button class="pool-detail-drag-btn" id="detailDragBtn">Drag to Weave \u2192</button>
|
||||
</div>
|
||||
<div class="pool-panel-sidebar" id="poolSidebar">
|
||||
<div class="sidebar-section">Task Templates</div>
|
||||
<div id="sidebarTasks"></div>
|
||||
</div>
|
||||
<button class="add-btn" id="addBtn">+ Add</button>
|
||||
<button class="add-btn" id="addBtn">+ Pledge Time</button>
|
||||
</div>
|
||||
<!-- Resizable divider -->
|
||||
<div class="panel-divider" id="panelDivider"><div class="divider-pip"></div></div>
|
||||
<!-- SVG infinite canvas -->
|
||||
<div class="canvas-wrap" id="canvasWrap">
|
||||
<svg id="weave-svg" xmlns="http://www.w3.org/2000/svg">
|
||||
|
|
@ -545,6 +559,9 @@ class FolkTimebankApp extends HTMLElement {
|
|||
<filter id="glowGold" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="6" flood-color="#fbbf24" flood-opacity="0.4"/>
|
||||
</filter>
|
||||
<marker id="dep-arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#64748b"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)"/>
|
||||
<g id="canvas-content" transform="translate(0 0) scale(1)">
|
||||
|
|
@ -559,6 +576,7 @@ class FolkTimebankApp extends HTMLElement {
|
|||
<button id="zoomIn" title="Zoom in">+</button>
|
||||
<button id="zoomOut" title="Zoom out">\u2212</button>
|
||||
<button id="zoomReset" title="Reset zoom">\u2299</button>
|
||||
<button id="addFrame" title="Add project frame">\u25A1</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -681,7 +699,7 @@ class FolkTimebankApp extends HTMLElement {
|
|||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" id="modalCancel">Cancel</button>
|
||||
<button class="modal-submit" id="modalSubmit">Drop into Pool</button>
|
||||
<button class="modal-submit" id="modalSubmit">Pledge to Pool</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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; }
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, Task>;
|
||||
connections: Record<string, Connection>;
|
||||
execStates: Record<string, ExecState>;
|
||||
projectFrames: Record<string, ProjectFrame>;
|
||||
}
|
||||
|
||||
// ── External Time Log (backlog-md integration) ──
|
||||
|
|
@ -174,6 +189,7 @@ export const tasksSchema: DocSchema<TasksDoc> = {
|
|||
tasks: {},
|
||||
connections: {},
|
||||
execStates: {},
|
||||
projectFrames: {},
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue