rspace-online/modules/rswag/components/folk-revenue-sankey.ts

215 lines
9.7 KiB
TypeScript

/**
* <folk-revenue-sankey> — Interactive Sankey diagram showing revenue flow.
*
* Visualizes $29.99 example: Printer cost -> Creator share -> Community share.
* Draggable split sliders, animated flow lines, key metrics.
*/
class FolkRevenueSankey extends HTMLElement {
private shadow: ShadowRoot;
private totalPrice = 29.99;
private providerPct = 50;
private creatorPct = 35;
private communityPct = 15;
private dragging: "creator" | "community" | null = null;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
if (this.hasAttribute("price")) {
this.totalPrice = parseFloat(this.getAttribute("price") || "29.99");
}
this.render();
}
private render() {
const providerAmt = (this.totalPrice * this.providerPct / 100).toFixed(2);
const creatorAmt = (this.totalPrice * this.creatorPct / 100).toFixed(2);
const communityAmt = (this.totalPrice * this.communityPct / 100).toFixed(2);
this.shadow.innerHTML = `
<style>
:host { display: block; }
.sankey-container { background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, #334155); border-radius: 12px; padding: 1.5rem; }
.sankey-title { font-size: 1rem; font-weight: 600; color: var(--rs-text-primary, #e2e8f0); margin: 0 0 0.25rem; }
.sankey-subtitle { font-size: 0.8125rem; color: var(--rs-text-secondary, #94a3b8); margin: 0 0 1.25rem; }
/* Flow diagram */
.flow { position: relative; height: 160px; margin-bottom: 1rem; }
.flow svg { width: 100%; height: 100%; }
/* Split bar */
.split-bar-container { margin-bottom: 1rem; }
.split-bar { display: flex; height: 40px; border-radius: 10px; overflow: hidden; font-size: 0.75rem; font-weight: 600; cursor: pointer; position: relative; }
.split-seg { display: flex; align-items: center; justify-content: center; gap: 0.25rem; color: #fff; transition: flex 0.15s; user-select: none; flex-direction: column; line-height: 1.2; }
.split-seg .seg-pct { font-size: 0.875rem; font-weight: 700; }
.split-seg .seg-amt { font-size: 0.625rem; opacity: 0.85; }
.seg-provider { background: #16a34a; }
.seg-creator { background: #4f46e5; }
.seg-community { background: #d97706; }
/* Labels */
.split-labels { display: flex; justify-content: space-between; margin-top: 0.5rem; }
.split-label { font-size: 0.6875rem; color: var(--rs-text-muted, #64748b); display: flex; align-items: center; gap: 0.375rem; }
.label-dot { width: 8px; height: 8px; border-radius: 50%; }
/* Drag hint */
.drag-hint { text-align: center; font-size: 0.6875rem; color: var(--rs-text-muted); margin-bottom: 1rem; }
/* Metrics */
.metrics { display: flex; gap: 1rem; justify-content: center; }
.metric { text-align: center; }
.metric-value { font-size: 1.25rem; font-weight: 700; color: var(--rs-text-primary); }
.metric-label { font-size: 0.6875rem; color: var(--rs-text-muted); }
.metric-highlight { color: #4ade80; }
/* Sliders */
.slider-row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
.slider-label { font-size: 0.75rem; color: var(--rs-text-secondary); min-width: 80px; font-weight: 500; }
.slider-range { flex: 1; accent-color: var(--rs-primary, #6366f1); }
.slider-value { font-size: 0.75rem; color: var(--rs-text-primary); font-weight: 600; min-width: 40px; text-align: right; }
</style>
<div class="sankey-container">
<div class="sankey-title">Revenue Flow</div>
<div class="sankey-subtitle">How $${this.totalPrice.toFixed(2)} flows from customer to community</div>
<!-- Animated flow SVG -->
<div class="flow">
<svg viewBox="0 0 600 140" xmlns="http://www.w3.org/2000/svg">
<!-- Source: Customer -->
<rect x="0" y="20" width="100" height="100" rx="8" fill="#334155" stroke="#475569" stroke-width="1"/>
<text x="50" y="65" text-anchor="middle" fill="#e2e8f0" font-size="11" font-weight="600">Customer</text>
<text x="50" y="82" text-anchor="middle" fill="#94a3b8" font-size="9">$${this.totalPrice.toFixed(2)}</text>
<!-- Provider flow -->
<path d="M100,50 C200,50 200,30 320,30" fill="none" stroke="#16a34a" stroke-width="${Math.max(2, this.providerPct / 3)}" opacity="0.7">
<animate attributeName="stroke-dashoffset" from="20" to="0" dur="1.5s" repeatCount="indefinite"/>
</path>
<rect x="320" y="10" width="120" height="40" rx="6" fill="#16a34a" opacity="0.15" stroke="#16a34a" stroke-width="1"/>
<text x="380" y="28" text-anchor="middle" fill="#4ade80" font-size="9" font-weight="600">Print Provider</text>
<text x="380" y="42" text-anchor="middle" fill="#4ade80" font-size="11" font-weight="700">${this.providerPct}% / $${providerAmt}</text>
<!-- Creator flow -->
<path d="M100,70 C200,70 200,80 320,80" fill="none" stroke="#4f46e5" stroke-width="${Math.max(2, this.creatorPct / 3)}" opacity="0.7">
<animate attributeName="stroke-dashoffset" from="20" to="0" dur="1.8s" repeatCount="indefinite"/>
</path>
<rect x="320" y="60" width="120" height="40" rx="6" fill="#4f46e5" opacity="0.15" stroke="#4f46e5" stroke-width="1"/>
<text x="380" y="78" text-anchor="middle" fill="#a5b4fc" font-size="9" font-weight="600">Design Creator</text>
<text x="380" y="92" text-anchor="middle" fill="#a5b4fc" font-size="11" font-weight="700">${this.creatorPct}% / $${creatorAmt}</text>
<!-- Community flow -->
<path d="M100,90 C200,90 200,125 320,125" fill="none" stroke="#d97706" stroke-width="${Math.max(2, this.communityPct / 3)}" opacity="0.7">
<animate attributeName="stroke-dashoffset" from="20" to="0" dur="2.1s" repeatCount="indefinite"/>
</path>
<rect x="320" y="108" width="120" height="32" rx="6" fill="#d97706" opacity="0.15" stroke="#d97706" stroke-width="1"/>
<text x="380" y="123" text-anchor="middle" fill="#fbbf24" font-size="9" font-weight="600">Community</text>
<text x="380" y="136" text-anchor="middle" fill="#fbbf24" font-size="11" font-weight="700">${this.communityPct}% / $${communityAmt}</text>
<!-- Platform fee -->
<text x="540" y="75" text-anchor="middle" fill="#4ade80" font-size="14" font-weight="700">$0</text>
<text x="540" y="90" text-anchor="middle" fill="#94a3b8" font-size="8">platform fee</text>
</svg>
</div>
<!-- Split bar -->
<div class="split-bar-container">
<div class="split-bar">
<div class="split-seg seg-provider" style="flex:${this.providerPct}">
<span class="seg-pct">${this.providerPct}%</span>
<span class="seg-amt">$${providerAmt}</span>
</div>
<div class="split-seg seg-creator" style="flex:${this.creatorPct}">
<span class="seg-pct">${this.creatorPct}%</span>
<span class="seg-amt">$${creatorAmt}</span>
</div>
<div class="split-seg seg-community" style="flex:${this.communityPct}">
<span class="seg-pct">${this.communityPct}%</span>
<span class="seg-amt">$${communityAmt}</span>
</div>
</div>
</div>
<!-- Sliders -->
<div class="slider-row">
<span class="slider-label" style="color:#4ade80">Provider</span>
<input type="range" class="slider-range" id="provider-slider" min="20" max="80" value="${this.providerPct}">
<span class="slider-value">${this.providerPct}%</span>
</div>
<div class="slider-row">
<span class="slider-label" style="color:#a5b4fc">Creator</span>
<input type="range" class="slider-range" id="creator-slider" min="5" max="60" value="${this.creatorPct}">
<span class="slider-value">${this.creatorPct}%</span>
</div>
<div class="drag-hint">Adjust sliders to explore different revenue splits</div>
<!-- Key metrics -->
<div class="metrics">
<div class="metric">
<div class="metric-value metric-highlight">$0</div>
<div class="metric-label">Platform Fee</div>
</div>
<div class="metric">
<div class="metric-value">100%</div>
<div class="metric-label">To Community</div>
</div>
<div class="metric">
<div class="metric-value">${this.creatorPct + this.communityPct}%</div>
<div class="metric-label">Creator + Commons</div>
</div>
</div>
<!-- Labels -->
<div class="split-labels">
<span class="split-label"><span class="label-dot" style="background:#16a34a"></span> Print Provider (production + shipping)</span>
<span class="split-label"><span class="label-dot" style="background:#4f46e5"></span> Design Creator</span>
<span class="split-label"><span class="label-dot" style="background:#d97706"></span> Community Treasury</span>
</div>
</div>
`;
this.bindEvents();
}
private bindEvents() {
const providerSlider = this.shadow.getElementById("provider-slider") as HTMLInputElement;
const creatorSlider = this.shadow.getElementById("creator-slider") as HTMLInputElement;
providerSlider?.addEventListener("input", () => {
this.providerPct = parseInt(providerSlider.value, 10);
this.rebalance("provider");
this.render();
});
creatorSlider?.addEventListener("input", () => {
this.creatorPct = parseInt(creatorSlider.value, 10);
this.rebalance("creator");
this.render();
});
}
private rebalance(changed: "provider" | "creator") {
if (changed === "provider") {
// Adjust creator and community proportionally
const remaining = 100 - this.providerPct;
const ratio = this.creatorPct / (this.creatorPct + this.communityPct) || 0.7;
this.creatorPct = Math.round(remaining * ratio);
this.communityPct = remaining - this.creatorPct;
} else {
// Adjust community from remaining
this.communityPct = 100 - this.providerPct - this.creatorPct;
}
// Clamp
this.communityPct = Math.max(0, this.communityPct);
if (this.providerPct + this.creatorPct + this.communityPct !== 100) {
this.communityPct = 100 - this.providerPct - this.creatorPct;
}
}
}
customElements.define("folk-revenue-sankey", FolkRevenueSankey);