feat: green flows, funnel coloring, full-page canvas, analytics popout
- All funding flow edges now use green shades (inflow/spending/overflow) - Funnel nodes colored by 3-state support level: critical (red), sustained (amber), overflow (green) - Removed tab system — canvas fills entire viewport with nav overlay - Added left-side analytics popout panel (overview + transactions sub-tabs) - Removed river tab code - Updated legend to reflect new color scheme Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
098c3db04d
commit
676aaa7b3a
|
|
@ -86,20 +86,7 @@
|
|||
|
||||
/* ── Detail view ─────────────────────────────────────── */
|
||||
.flows-detail { max-width: 1100px; margin: 0 auto; padding: 16px 20px 64px; }
|
||||
|
||||
/* ── Tabs ────────────────────────────────────────────── */
|
||||
.flows-tabs {
|
||||
display: flex; gap: 4px; border-bottom: 1px solid #1e293b; margin-bottom: 20px;
|
||||
}
|
||||
.flows-tab {
|
||||
padding: 8px 18px; border: none; border-bottom: 2px solid transparent;
|
||||
background: transparent; color: #64748b; font-size: 13px; font-weight: 500;
|
||||
cursor: pointer; transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
.flows-tab:hover { color: #e2e8f0; }
|
||||
.flows-tab--active { color: #e2e8f0; border-bottom-color: #6366f1; }
|
||||
|
||||
.flows-tab-content { min-height: 300px; }
|
||||
.flows-detail--fullpage { padding: 0; max-width: none; height: 100vh; }
|
||||
|
||||
/* ── Table tab — card grid ───────────────────────────── */
|
||||
.flows-table { }
|
||||
|
|
@ -159,6 +146,25 @@
|
|||
background: #0f172a; border-radius: 12px; border: 1px solid #334155;
|
||||
overflow: hidden; user-select: none; touch-action: none;
|
||||
}
|
||||
.flows-canvas-container--fullpage {
|
||||
height: 100%; border-radius: 0; border: none; min-height: unset;
|
||||
}
|
||||
|
||||
/* Compact nav overlay inside full-page canvas */
|
||||
.flows-nav-overlay {
|
||||
position: absolute; top: 0; left: 0; right: 0; z-index: 15;
|
||||
height: 44px; display: flex; align-items: center; padding: 0 16px; gap: 12px;
|
||||
background: linear-gradient(to bottom, rgba(15,23,42,0.9) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.flows-nav-overlay > * { pointer-events: auto; }
|
||||
.flows-nav-overlay .rapp-nav__back { color: #94a3b8; text-decoration: none; font-size: 13px; }
|
||||
.flows-nav-overlay .rapp-nav__back:hover { color: #e2e8f0; }
|
||||
.flows-nav-overlay .rapp-nav__title { color: #e2e8f0; font-size: 14px; font-weight: 600; }
|
||||
.flows-nav-overlay .rapp-nav__badge { font-size: 10px; color: #fbbf24; background: rgba(251,191,36,0.15); padding: 2px 8px; border-radius: 4px; }
|
||||
|
||||
/* Badge offset when nav overlay present */
|
||||
.flows-canvas-container--fullpage .flows-canvas-badge { top: 54px; }
|
||||
|
||||
.flows-canvas-svg {
|
||||
width: 100%; height: 100%; display: block;
|
||||
|
|
@ -214,6 +220,33 @@
|
|||
}
|
||||
.editor-close:hover { color: #e2e8f0; }
|
||||
|
||||
/* Analytics popout panel (left side) */
|
||||
.flows-analytics-panel {
|
||||
position: absolute; top: 0; left: 0; bottom: 0; width: 380px; z-index: 20;
|
||||
background: #1e293b; border-right: 1px solid #334155;
|
||||
transform: translateX(-100%); transition: transform 0.25s ease;
|
||||
overflow-y: auto; display: flex; flex-direction: column;
|
||||
}
|
||||
.flows-analytics-panel.open { transform: translateX(0); }
|
||||
|
||||
.analytics-header {
|
||||
display: flex; align-items: center; gap: 8px; padding: 12px 16px;
|
||||
border-bottom: 1px solid #334155; flex-shrink: 0;
|
||||
}
|
||||
.analytics-title { font-size: 14px; font-weight: 600; color: #e2e8f0; flex: 1; }
|
||||
.analytics-close {
|
||||
background: none; border: none; color: #94a3b8; font-size: 18px; cursor: pointer;
|
||||
}
|
||||
.analytics-close:hover { color: #e2e8f0; }
|
||||
.analytics-tabs { display: flex; gap: 4px; }
|
||||
.analytics-tab {
|
||||
padding: 4px 10px; border: 1px solid #334155; border-radius: 4px;
|
||||
background: transparent; color: #94a3b8; font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.analytics-tab:hover { background: #334155; color: #e2e8f0; }
|
||||
.analytics-tab--active { background: #334155; color: #e2e8f0; }
|
||||
.analytics-content { padding: 16px; flex: 1; overflow-y: auto; }
|
||||
|
||||
.editor-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.editor-label { font-size: 11px; color: #94a3b8; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.editor-input {
|
||||
|
|
@ -469,7 +502,7 @@
|
|||
|
||||
/* Funnel overflow lip glow when overflowing */
|
||||
.funnel-lip { transition: fill 0.3s, opacity 0.3s; }
|
||||
.funnel-lip--active { fill: #f59e0b; opacity: 0.8; }
|
||||
.funnel-lip--active { fill: #10b981; opacity: 0.8; }
|
||||
|
||||
/* Status badge in outcome inline edit */
|
||||
.inline-status-badge { cursor: pointer; transition: opacity 0.15s; }
|
||||
|
|
@ -485,13 +518,14 @@
|
|||
.flows-flows__grid { grid-template-columns: 1fr; }
|
||||
.flows-features__grid { grid-template-columns: 1fr; }
|
||||
.flows-cards { grid-template-columns: 1fr; }
|
||||
.flows-tabs { flex-wrap: wrap; }
|
||||
.flows-tab { min-height: 44px; min-width: 44px; display: flex; align-items: center; justify-content: center; }
|
||||
.flows-canvas-container { height: 50vh; min-height: 300px; }
|
||||
.flows-canvas-container--fullpage { height: 100%; min-height: unset; }
|
||||
.flows-canvas-toolbar { flex-wrap: wrap; gap: 3px; top: 6px; right: 6px; }
|
||||
.flows-canvas-btn { padding: 6px 10px; font-size: 11px; min-height: 44px; min-width: 44px; }
|
||||
.flows-editor-panel { width: 100%; }
|
||||
.flows-analytics-panel { width: 100%; }
|
||||
.flows-canvas-legend { font-size: 10px; gap: 8px; }
|
||||
.flows-landing { padding: 16px 12px 48px; }
|
||||
.flows-detail { padding: 12px 12px 48px; }
|
||||
.flows-detail--fullpage { padding: 0; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ interface Transaction {
|
|||
}
|
||||
|
||||
type View = "landing" | "detail";
|
||||
type Tab = "diagram" | "table" | "river" | "transactions";
|
||||
|
||||
// ─── Auth helpers (reads EncryptID session from localStorage) ──
|
||||
|
||||
|
|
@ -60,8 +59,9 @@ class FolkFlowsApp extends HTMLElement {
|
|||
private shadow: ShadowRoot;
|
||||
private space = "";
|
||||
private view: View = "landing";
|
||||
private tab: Tab = "diagram";
|
||||
private flowId = "";
|
||||
private analyticsOpen = false;
|
||||
private analyticsTab: "overview" | "transactions" = "overview";
|
||||
private isDemo = false;
|
||||
|
||||
private flows: FlowSummary[] = [];
|
||||
|
|
@ -357,38 +357,16 @@ class FolkFlowsApp extends HTMLElement {
|
|||
// ─── Detail view with tabs ─────────────────────────────
|
||||
|
||||
private renderDetail(): string {
|
||||
const backUrl = this.getApiBase()
|
||||
? `${this.getApiBase()}/`
|
||||
: "/rflows/";
|
||||
if (this.loading) {
|
||||
return '<div class="flows-loading">Loading...</div>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="flows-detail">
|
||||
<div class="rapp-nav">
|
||||
<a href="${this.esc(backUrl)}" class="rapp-nav__back">← Flows</a>
|
||||
<span class="rapp-nav__title">${this.esc(this.flowName || "Flow Detail")}</span>
|
||||
${this.isDemo ? '<span class="rapp-nav__badge">Demo</span>' : ""}
|
||||
</div>
|
||||
|
||||
<div class="flows-tabs">
|
||||
<button class="flows-tab ${this.tab === "diagram" ? "flows-tab--active" : ""}" data-tab="diagram">Diagram</button>
|
||||
<button class="flows-tab ${this.tab === "river" ? "flows-tab--active" : ""}" data-tab="river">River</button>
|
||||
<button class="flows-tab ${this.tab === "table" ? "flows-tab--active" : ""}" data-tab="table">Table</button>
|
||||
<button class="flows-tab ${this.tab === "transactions" ? "flows-tab--active" : ""}" data-tab="transactions">Transactions</button>
|
||||
</div>
|
||||
|
||||
<div class="flows-tab-content">
|
||||
${this.loading ? '<div class="flows-loading">Loading...</div>' : this.renderTab()}
|
||||
</div>
|
||||
<div class="flows-detail flows-detail--fullpage">
|
||||
${this.renderDiagramTab()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderTab(): string {
|
||||
if (this.tab === "diagram") return this.renderDiagramTab();
|
||||
if (this.tab === "river") return this.renderRiverTab();
|
||||
if (this.tab === "transactions") return this.renderTransactionsTab();
|
||||
return this.renderTableTab();
|
||||
}
|
||||
|
||||
// ─── Table tab ────────────────────────────────────────
|
||||
|
||||
private renderTableTab(): string {
|
||||
|
|
@ -562,12 +540,21 @@ class FolkFlowsApp extends HTMLElement {
|
|||
return '<div class="flows-loading">No nodes to display.</div>';
|
||||
}
|
||||
|
||||
const backUrl = this.getApiBase()
|
||||
? `${this.getApiBase()}/`
|
||||
: "/rflows/";
|
||||
|
||||
const score = computeSystemSufficiency(this.nodes);
|
||||
const scorePct = Math.round(score * 100);
|
||||
const scoreColor = scorePct >= 90 ? "#fbbf24" : scorePct >= 60 ? "#10b981" : scorePct >= 30 ? "#f59e0b" : "#ef4444";
|
||||
|
||||
return `
|
||||
<div class="flows-canvas-container" id="canvas-container">
|
||||
<div class="flows-canvas-container flows-canvas-container--fullpage" id="canvas-container">
|
||||
<div class="flows-nav-overlay">
|
||||
<a href="${this.esc(backUrl)}" class="rapp-nav__back">← Flows</a>
|
||||
<span class="rapp-nav__title">${this.esc(this.flowName || "Flow Detail")}</span>
|
||||
${this.isDemo ? '<span class="rapp-nav__badge">Demo</span>' : ""}
|
||||
</div>
|
||||
<div class="flows-canvas-badge" id="canvas-badge">
|
||||
<div>
|
||||
<div class="flows-canvas-badge__score" id="badge-score" style="color:${scoreColor}">${scorePct}%</div>
|
||||
|
|
@ -581,6 +568,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
<div class="flows-canvas-sep"></div>
|
||||
<button class="flows-canvas-btn" data-canvas-action="sim" id="sim-btn">${this.isSimulating ? "Pause" : "Play"}</button>
|
||||
<button class="flows-canvas-btn" data-canvas-action="fit">Fit</button>
|
||||
<button class="flows-canvas-btn ${this.analyticsOpen ? "flows-canvas-btn--active" : ""}" data-canvas-action="analytics">Analytics</button>
|
||||
<button class="flows-canvas-btn" data-canvas-action="share">Share</button>
|
||||
</div>
|
||||
<svg class="flows-canvas-svg" id="flow-canvas">
|
||||
|
|
@ -591,13 +579,14 @@ class FolkFlowsApp extends HTMLElement {
|
|||
</g>
|
||||
</svg>
|
||||
<div class="flows-editor-panel" id="editor-panel"></div>
|
||||
${this.renderAnalyticsPanel()}
|
||||
<div class="flows-canvas-legend">
|
||||
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#10b981"></span>Source</span>
|
||||
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#0ea5e9"></span>Funnel</span>
|
||||
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#f59e0b"></span>Overflow</span>
|
||||
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#8b5cf6"></span>Spending</span>
|
||||
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#3b82f6"></span>Outcome</span>
|
||||
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#fbbf24"></span>Sufficient</span>
|
||||
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#10b981"></span>Inflow</span>
|
||||
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#34d399"></span>Spending</span>
|
||||
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#6ee7b7"></span>Overflow</span>
|
||||
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#ef4444;border-radius:50%"></span>Critical</span>
|
||||
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#f59e0b;border-radius:50%"></span>Sustained</span>
|
||||
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:#10b981;border-radius:50%"></span>Thriving</span>
|
||||
</div>
|
||||
<div class="flows-canvas-zoom">
|
||||
<button class="flows-canvas-btn" data-canvas-action="zoom-in">+</button>
|
||||
|
|
@ -893,6 +882,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
else if (action === "add-outcome") this.addNode("outcome");
|
||||
else if (action === "sim") this.toggleSimulation();
|
||||
else if (action === "fit") this.fitView();
|
||||
else if (action === "analytics") this.toggleAnalytics();
|
||||
else if (action === "share") this.shareState();
|
||||
else if (action === "zoom-in") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); }
|
||||
else if (action === "zoom-out") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); }
|
||||
|
|
@ -989,6 +979,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
if (e.key === "Escape") {
|
||||
if (this.inlineEditNodeId) { this.exitInlineEdit(); return; }
|
||||
if (this.wiringActive) { this.cancelWiring(); return; }
|
||||
if (this.analyticsOpen) { this.toggleAnalytics(); return; }
|
||||
this.closeModal();
|
||||
this.closeEditor();
|
||||
}
|
||||
|
|
@ -1081,19 +1072,14 @@ class FolkFlowsApp extends HTMLElement {
|
|||
const d = n.data as FunnelNodeData;
|
||||
const s = this.getNodeSize(n);
|
||||
const x = n.position.x, y = n.position.y, w = s.w, h = s.h;
|
||||
const sufficiency = computeSufficiencyState(d);
|
||||
const isSufficient = sufficiency === "sufficient" || sufficiency === "abundant";
|
||||
const isAbundant = sufficiency === "abundant";
|
||||
const threshold = d.sufficientThreshold ?? d.maxThreshold;
|
||||
const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1));
|
||||
|
||||
const borderColor = d.currentValue > d.maxThreshold ? "#f59e0b"
|
||||
: d.currentValue < d.minThreshold ? "#ef4444"
|
||||
: isSufficient ? "#fbbf24" : "#0ea5e9";
|
||||
const isOverflow = d.currentValue > d.maxThreshold;
|
||||
const isCritical = d.currentValue < d.minThreshold;
|
||||
const borderColor = isCritical ? "#ef4444" : isOverflow ? "#10b981" : "#f59e0b";
|
||||
const fillColor = borderColor;
|
||||
const statusLabel = sufficiency === "abundant" ? "Abundant"
|
||||
: sufficiency === "sufficient" ? "Sufficient"
|
||||
: d.currentValue < d.minThreshold ? "Critical" : "Seeking";
|
||||
const statusLabel = isCritical ? "Critical" : isOverflow ? "Overflow" : "Sustained";
|
||||
|
||||
// Funnel shape parameters
|
||||
const r = 10; // corner radius
|
||||
|
|
@ -1154,13 +1140,14 @@ class FolkFlowsApp extends HTMLElement {
|
|||
const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : "";
|
||||
const satBarBorder = satOverflow ? `stroke="#fbbf24" stroke-width="1"` : "";
|
||||
|
||||
const glowClass = isSufficient ? " node-glow" : "";
|
||||
const glowStyle = isOverflow ? "filter: drop-shadow(0 0 6px rgba(16,185,129,0.5))"
|
||||
: !isCritical ? "filter: drop-shadow(0 0 6px rgba(245,158,11,0.4))" : "";
|
||||
|
||||
return `<g class="flow-node${glowClass} ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
|
||||
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})" style="${glowStyle}">
|
||||
<defs>
|
||||
<clipPath id="${clipId}"><path d="${funnelPath}"/></clipPath>
|
||||
</defs>
|
||||
${isSufficient ? `<path d="${funnelPath}" fill="none" stroke="#fbbf24" stroke-width="2" opacity="0.4" transform="translate(-2,-2) scale(${(w + 4) / w},${(h + 4) / h})"/>` : ""}
|
||||
${isOverflow ? `<path d="${funnelPath}" fill="none" stroke="#10b981" stroke-width="2" opacity="0.4" transform="translate(-2,-2) scale(${(w + 4) / w},${(h + 4) / h})"/>` : ""}
|
||||
<path class="node-bg" d="${funnelPath}" fill="#1e293b" stroke="${selected ? "#6366f1" : borderColor}" stroke-width="${selected ? 3 : 2}"/>
|
||||
<g clip-path="url(#${clipId})">
|
||||
<rect x="${-lipW}" y="${zoneTop + overflowH + healthyH}" width="${w + lipW * 2}" height="${drainH}" fill="#ef4444" opacity="0.08"/>
|
||||
|
|
@ -1168,10 +1155,10 @@ class FolkFlowsApp extends HTMLElement {
|
|||
<rect x="${-lipW}" y="${zoneTop}" width="${w + lipW * 2}" height="${overflowH}" fill="#f59e0b" opacity="0.06"/>
|
||||
<rect x="${-lipW}" y="${fillY}" width="${w + lipW * 2}" height="${totalFillH}" fill="${fillColor}" opacity="0.25"/>
|
||||
</g>
|
||||
<rect class="funnel-lip ${isAbundant ? "funnel-lip--active" : ""}" x="${-lipW}" y="${lipH}" width="${lipW}" height="${lipNotch}" rx="2" fill="${isAbundant ? "#f59e0b" : "#334155"}" opacity="${isAbundant ? 0.8 : 0.3}"/>
|
||||
<rect class="funnel-lip ${isAbundant ? "funnel-lip--active" : ""}" x="${w}" y="${lipH}" width="${lipW}" height="${lipNotch}" rx="2" fill="${isAbundant ? "#f59e0b" : "#334155"}" opacity="${isAbundant ? 0.8 : 0.3}"/>
|
||||
<rect class="funnel-lip ${isOverflow ? "funnel-lip--active" : ""}" x="${-lipW}" y="${lipH}" width="${lipW}" height="${lipNotch}" rx="2" fill="${isOverflow ? "#10b981" : "#334155"}" opacity="${isOverflow ? 0.8 : 0.3}"/>
|
||||
<rect class="funnel-lip ${isOverflow ? "funnel-lip--active" : ""}" x="${w}" y="${lipH}" width="${lipW}" height="${lipNotch}" rx="2" fill="${isOverflow ? "#10b981" : "#334155"}" opacity="${isOverflow ? 0.8 : 0.3}"/>
|
||||
<text x="${w / 2}" y="${lipH + 6}" text-anchor="middle" fill="#e2e8f0" font-size="13" font-weight="600">${this.esc(d.label)}</text>
|
||||
<text x="${w - 10}" y="${lipH + 6}" text-anchor="end" fill="${borderColor}" font-size="10" font-weight="500" class="${isSufficient ? "sufficiency-glow" : ""}">${statusLabel}</text>
|
||||
<text x="${w - 10}" y="${lipH + 6}" text-anchor="end" fill="${borderColor}" font-size="10" font-weight="500" class="${!isCritical ? "sufficiency-glow" : ""}">${statusLabel}</text>
|
||||
<rect x="20" y="${satBarY}" width="${satBarW}" height="6" rx="3" fill="#334155" opacity="0.3" class="satisfaction-bar-bg"/>
|
||||
<rect x="20" y="${satBarY}" width="${satFillW}" height="6" rx="3" fill="#10b981" class="satisfaction-bar-fill" ${satBarBorder}/>
|
||||
<text x="${w / 2}" y="${satBarY + 16}" text-anchor="middle" fill="#64748b" font-size="9">${satLabel}</text>
|
||||
|
|
@ -1265,7 +1252,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
const flowAmount = d.flowRate * (alloc.percentage / 100);
|
||||
edges.push({
|
||||
fromNode: n, toNode: target, fromPort: "outflow",
|
||||
color: alloc.color || "#10b981", flowAmount,
|
||||
color: "#10b981", flowAmount,
|
||||
pct: alloc.percentage, dashed: false,
|
||||
fromId: n.id, toId: alloc.targetId, edgeType: "source",
|
||||
});
|
||||
|
|
@ -1283,7 +1270,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
edges.push({
|
||||
fromNode: n, toNode: target, fromPort: "overflow",
|
||||
fromSide: side,
|
||||
color: alloc.color || "#f59e0b", flowAmount,
|
||||
color: "#6ee7b7", flowAmount,
|
||||
pct: alloc.percentage, dashed: true,
|
||||
fromId: n.id, toId: alloc.targetId, edgeType: "overflow",
|
||||
});
|
||||
|
|
@ -1300,7 +1287,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
const flowAmount = drain * (alloc.percentage / 100);
|
||||
edges.push({
|
||||
fromNode: n, toNode: target, fromPort: "spending",
|
||||
color: alloc.color || "#8b5cf6", flowAmount,
|
||||
color: "#34d399", flowAmount,
|
||||
pct: alloc.percentage, dashed: false,
|
||||
fromId: n.id, toId: alloc.targetId, edgeType: "spending",
|
||||
});
|
||||
|
|
@ -1317,7 +1304,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
const flowAmount = excess * (alloc.percentage / 100);
|
||||
edges.push({
|
||||
fromNode: n, toNode: target, fromPort: "overflow",
|
||||
color: alloc.color || "#f59e0b", flowAmount,
|
||||
color: "#6ee7b7", flowAmount,
|
||||
pct: alloc.percentage, dashed: true,
|
||||
fromId: n.id, toId: alloc.targetId, edgeType: "overflow",
|
||||
});
|
||||
|
|
@ -1446,11 +1433,9 @@ class FolkFlowsApp extends HTMLElement {
|
|||
if (n.type === "source") return "#10b981";
|
||||
if (n.type === "funnel") {
|
||||
const d = n.data as FunnelNodeData;
|
||||
const suf = computeSufficiencyState(d);
|
||||
const isSuf = suf === "sufficient" || suf === "abundant";
|
||||
return d.currentValue > d.maxThreshold ? "#f59e0b"
|
||||
: d.currentValue < d.minThreshold ? "#ef4444"
|
||||
: isSuf ? "#fbbf24" : "#0ea5e9";
|
||||
return d.currentValue < d.minThreshold ? "#ef4444"
|
||||
: d.currentValue > d.maxThreshold ? "#10b981"
|
||||
: "#f59e0b";
|
||||
}
|
||||
const d = n.data as OutcomeNodeData;
|
||||
return d.status === "completed" ? "#10b981" : d.status === "blocked" ? "#ef4444" : d.status === "in-progress" ? "#3b82f6" : "#64748b";
|
||||
|
|
@ -2723,28 +2708,62 @@ class FolkFlowsApp extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── River tab ────────────────────────────────────────
|
||||
// ─── Analytics popout panel ──────────────────────────
|
||||
|
||||
private renderRiverTab(): string {
|
||||
return `<div class="flows-river-container" id="river-mount"></div>`;
|
||||
private renderAnalyticsPanel(): string {
|
||||
return `
|
||||
<div class="flows-analytics-panel ${this.analyticsOpen ? "open" : ""}" id="analytics-panel">
|
||||
<div class="analytics-header">
|
||||
<span class="analytics-title">Analytics</span>
|
||||
<div class="analytics-tabs">
|
||||
<button class="analytics-tab ${this.analyticsTab === "overview" ? "analytics-tab--active" : ""}" data-analytics-tab="overview">Overview</button>
|
||||
<button class="analytics-tab ${this.analyticsTab === "transactions" ? "analytics-tab--active" : ""}" data-analytics-tab="transactions">Transactions</button>
|
||||
</div>
|
||||
<button class="analytics-close" data-analytics-close>×</button>
|
||||
</div>
|
||||
<div class="analytics-content">
|
||||
${this.analyticsTab === "overview" ? this.renderTableTab() : this.renderTransactionsTab()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private mountRiver() {
|
||||
const mount = this.shadow.getElementById("river-mount");
|
||||
if (!mount) return;
|
||||
private toggleAnalytics() {
|
||||
this.analyticsOpen = !this.analyticsOpen;
|
||||
if (this.analyticsOpen && this.analyticsTab === "transactions" && !this.txLoaded) {
|
||||
this.loadTransactions();
|
||||
}
|
||||
const panel = this.shadow.getElementById("analytics-panel");
|
||||
if (panel) {
|
||||
panel.classList.toggle("open", this.analyticsOpen);
|
||||
}
|
||||
const btn = this.shadow.querySelector('[data-canvas-action="analytics"]');
|
||||
if (btn) btn.classList.toggle("flows-canvas-btn--active", this.analyticsOpen);
|
||||
}
|
||||
|
||||
// Check if already mounted
|
||||
if (mount.querySelector("folk-flow-river")) return;
|
||||
private attachAnalyticsListeners() {
|
||||
const closeBtn = this.shadow.querySelector("[data-analytics-close]");
|
||||
closeBtn?.addEventListener("click", () => this.toggleAnalytics());
|
||||
|
||||
const river = document.createElement("folk-flow-river") as any;
|
||||
river.setAttribute("simulate", "true");
|
||||
mount.appendChild(river);
|
||||
|
||||
// Pass nodes after the element is connected
|
||||
requestAnimationFrame(() => {
|
||||
if (typeof river.setNodes === "function") {
|
||||
river.setNodes(this.nodes.map((n) => ({ ...n, data: { ...n.data } })));
|
||||
}
|
||||
this.shadow.querySelectorAll("[data-analytics-tab]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
const tab = (el as HTMLElement).dataset.analyticsTab as "overview" | "transactions";
|
||||
if (tab === this.analyticsTab) return;
|
||||
this.analyticsTab = tab;
|
||||
if (tab === "transactions" && !this.txLoaded) {
|
||||
this.loadTransactions();
|
||||
return;
|
||||
}
|
||||
const panel = this.shadow.getElementById("analytics-panel");
|
||||
if (panel) {
|
||||
const content = panel.querySelector(".analytics-content");
|
||||
if (content) {
|
||||
content.innerHTML = this.analyticsTab === "overview" ? this.renderTableTab() : this.renderTransactionsTab();
|
||||
}
|
||||
panel.querySelectorAll(".analytics-tab").forEach((t) => {
|
||||
t.classList.toggle("analytics-tab--active", (t as HTMLElement).dataset.analyticsTab === tab);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2808,30 +2827,10 @@ class FolkFlowsApp extends HTMLElement {
|
|||
// ─── Event listeners ──────────────────────────────────
|
||||
|
||||
private attachListeners() {
|
||||
// Tab switching
|
||||
this.shadow.querySelectorAll("[data-tab]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
const newTab = (el as HTMLElement).dataset.tab as Tab;
|
||||
if (newTab === this.tab) return;
|
||||
// Cleanup old canvas state
|
||||
if (this.tab === "diagram") this.cleanupCanvas();
|
||||
this.tab = newTab;
|
||||
this.render();
|
||||
|
||||
if (newTab === "transactions" && !this.txLoaded) {
|
||||
this.loadTransactions();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Mount river component when river tab is active
|
||||
if (this.tab === "river" && this.nodes.length > 0) {
|
||||
this.mountRiver();
|
||||
}
|
||||
|
||||
// Initialize interactive canvas when diagram tab is active
|
||||
if (this.tab === "diagram" && this.nodes.length > 0) {
|
||||
// Initialize interactive canvas when detail view is active
|
||||
if (this.view === "detail" && this.nodes.length > 0) {
|
||||
this.initCanvas();
|
||||
this.attachAnalyticsListeners();
|
||||
}
|
||||
|
||||
// Create flow button (landing page, auth-gated)
|
||||
|
|
|
|||
Loading…
Reference in New Issue