215 lines
9.7 KiB
TypeScript
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);
|