UI Components Hard
Sankey Chart
A complex flow diagram (Sankey) built with D3.js. Features splitting and merging flows, automatic node positioning, and interactive link highlighting. Perfect for visualizing income statements or resource allocations.
Open in Lab
MCP
vanilla-js d3 svg react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
:root {
--bg: #f8fafc;
--text: #1e293b;
--text-muted: #64748b;
--google-blue: #4285f4;
--google-red: #ea4335;
--google-yellow: #fbbc05;
--google-green: #34a853;
--border: #e2e8f0;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a;
--text: #f1f5f9;
--text-muted: #94a3b8;
--border: #1e293b;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
background-color: var(--bg);
color: var(--text);
padding: 2rem;
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
}
/* โโ Tablet (iPad) โโโโโโโโโโโโโโโโโโโโโโโโโโโ */
@media (max-width: 1024px) {
body {
padding: 1.25rem;
}
}
/* โโ Phone landscape / large phone โโโโโโโโโโโ */
@media (max-width: 768px) {
body {
padding: 1rem 0.75rem;
}
}
/* โโ Small phone โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
@media (max-width: 480px) {
body {
padding: 0.75rem 0.5rem;
}
}
.chart-container {
width: 100%;
max-width: 1200px;
background: rgba(255, 255, 255, 0.02);
backdrop-filter: blur(12px);
border: 1px solid var(--border);
border-radius: 24px;
padding: 3rem;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.1);
}
@media (max-width: 1024px) {
.chart-container {
padding: 2rem;
border-radius: 18px;
}
}
@media (max-width: 768px) {
.chart-container {
padding: 1.25rem 1rem;
border-radius: 14px;
}
}
@media (max-width: 480px) {
.chart-container {
padding: 1rem 0.75rem;
border-radius: 10px;
}
}
.chart-header {
margin-bottom: 2rem;
}
@media (max-width: 768px) {
.chart-header {
margin-bottom: 1.25rem;
}
}
.main-title {
font-size: 2.5rem;
font-weight: 800;
color: var(--google-blue);
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
@media (max-width: 1024px) {
.main-title {
font-size: 1.8rem;
}
}
@media (max-width: 768px) {
.main-title {
font-size: 1.35rem;
}
}
@media (max-width: 480px) {
.main-title {
font-size: 1.1rem;
}
}
.source-info {
font-size: 0.9rem;
color: var(--text-muted);
}
@media (max-width: 480px) {
.source-info {
font-size: 0.75rem;
}
}
.url {
color: var(--google-blue);
opacity: 0.8;
}
.sankey-wrapper {
margin: 2rem 0;
overflow: hidden;
}
@media (max-width: 768px) {
.sankey-wrapper {
margin: 1rem 0;
}
}
#sankey-svg {
width: 100%;
height: auto;
overflow: visible;
display: block;
}
.node-label {
font-size: 14px;
font-weight: 600;
fill: var(--text);
}
.node-value {
font-size: 16px;
font-weight: 700;
}
.node-change {
font-size: 12px;
font-weight: 500;
fill: var(--text-muted);
}
.node-label.revenue {
font-size: 18px;
fill: var(--google-blue);
}
.link {
transition: opacity 0.3s ease;
}
.chart-footer {
margin-top: 2rem;
border-top: 1px solid var(--border);
padding-top: 1.5rem;
display: flex;
justify-content: flex-end;
}
@media (max-width: 768px) {
.chart-footer {
margin-top: 1rem;
padding-top: 1rem;
}
}
.brand {
font-weight: 800;
font-size: 0.8rem;
letter-spacing: 0.1em;
color: var(--text-muted);
}
@media (max-width: 480px) {
.brand {
font-size: 0.65rem;
}
}
.brand .accent {
color: var(--google-blue);
}const nodes = [
{ name: "Search advertising", color: "#4285F4", val: "$48.5B", change: "+14% Y/Y", logo: "๐" },
{ name: "YouTube", color: "#FF0000", val: "$8.7B", change: "+13% Y/Y", logo: "๐บ" },
{ name: "Google AdMob", color: "#FBBC05", val: "$7.4B", change: "-5% Y/Y", logo: "๐ฑ" },
{ name: "Google Play", color: "#34A853", val: "$9.3B", change: "+14% Y/Y", logo: "โถ๏ธ" },
{ name: "Google Cloud", color: "#4285F4", val: "$10.3B", change: "+29% Y/Y", logo: "โ๏ธ" },
{ name: "Other", color: "#64748b", val: "$0.5B", change: "", logo: "โ" },
{ name: "Ad Revenue", color: "#4285F4", val: "$64.6B", change: "+11% Y/Y" },
{ name: "Revenue", color: "#4285F4", val: "$84.7B", change: "+14% Y/Y" },
{ name: "Gross profit", color: "#34A853", val: "$49.2B", change: "58% margin" },
{ name: "Cost of revenues", color: "#EA4335", val: "$35.5B", change: "" },
{ name: "Operating profit", color: "#34A853", val: "$27.4B", change: "32% margin" },
{ name: "Operating expenses", color: "#EA4335", val: "$21.8B", change: "" },
{ name: "Net profit", color: "#34A853", val: "$23.6B", change: "28% margin" },
{ name: "Tax", color: "#EA4335", val: "$3.9B", change: "" },
{ name: "Other (P/L)", color: "#1e293b", val: "$0.1B", change: "" },
{ name: "R&D", color: "#EA4335", val: "$11.9B", change: "14% of rev" },
{ name: "S&M", color: "#EA4335", val: "$6.8B", change: "8% of rev" },
{ name: "G&A", color: "#EA4335", val: "$3.1B", change: "4% of rev" },
];
const links = [
{ source: 0, target: 6, value: 48.5 },
{ source: 1, target: 6, value: 8.7 },
{ source: 2, target: 6, value: 7.4 },
{ source: 6, target: 7, value: 64.6 },
{ source: 3, target: 7, value: 9.3 },
{ source: 4, target: 7, value: 10.3 },
{ source: 5, target: 7, value: 0.5 },
{ source: 7, target: 8, value: 49.2 },
{ source: 7, target: 9, value: 35.5 },
{ source: 8, target: 10, value: 27.4 },
{ source: 8, target: 11, value: 21.8 },
{ source: 10, target: 12, value: 23.6 },
{ source: 10, target: 13, value: 3.7 },
{ source: 10, target: 14, value: 0.1 },
{ source: 11, target: 15, value: 11.9 },
{ source: 11, target: 16, value: 6.8 },
{ source: 11, target: 17, value: 3.1 },
];
function getBreakpoint() {
const w = window.innerWidth;
if (w < 480) return "xs"; // small phone
if (w < 768) return "sm"; // phone landscape / large phone
if (w < 1024) return "md"; // tablet / iPad
return "lg"; // desktop
}
function getConfig(bp) {
switch (bp) {
case "xs":
return {
marginH: 100,
marginV: 40,
totalH: 520,
labelGap: 8,
iconOffsetLeft: -70,
iconOffsetRight: 70,
labelFontSize: 9,
valueFontSize: 11,
changeFontSize: 9,
logoFontSize: 16,
nodeWidth: 10,
nodePadding: 20,
};
case "sm":
return {
marginH: 130,
marginV: 50,
totalH: 580,
labelGap: 10,
iconOffsetLeft: -90,
iconOffsetRight: 90,
labelFontSize: 10,
valueFontSize: 12,
changeFontSize: 10,
logoFontSize: 18,
nodeWidth: 12,
nodePadding: 28,
};
case "md":
return {
marginH: 150,
marginV: 55,
totalH: 640,
labelGap: 12,
iconOffsetLeft: -110,
iconOffsetRight: 110,
labelFontSize: 12,
valueFontSize: 14,
changeFontSize: 11,
logoFontSize: 20,
nodeWidth: 14,
nodePadding: 34,
};
default:
return {
marginH: 180,
marginV: 60,
totalH: 700,
labelGap: 10,
iconOffsetLeft: -130,
iconOffsetRight: 130,
labelFontSize: 14,
valueFontSize: 16,
changeFontSize: 12,
logoFontSize: 24,
nodeWidth: 16,
nodePadding: 40,
};
}
}
function initSankey() {
const bp = getBreakpoint();
const cfg = getConfig(bp);
const margin = { top: cfg.marginV, right: cfg.marginH, bottom: cfg.marginV, left: cfg.marginH };
const chartArea = document.querySelector(".sankey-wrapper");
const totalWidth = chartArea.clientWidth;
const width = totalWidth - margin.left - margin.right;
const height = cfg.totalH - margin.top - margin.bottom;
const svg = d3
.select("#sankey-svg")
.attr("viewBox", `0 0 ${totalWidth} ${cfg.totalH}`)
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const sankey = d3
.sankey()
.nodeWidth(cfg.nodeWidth)
.nodePadding(cfg.nodePadding)
.size([width, height]);
const graph = sankey({
nodes: nodes.map((d) => Object.assign({}, d)),
links: links.map((d) => Object.assign({}, d)),
});
// Links
svg
.append("g")
.selectAll(".link")
.data(graph.links)
.enter()
.append("path")
.attr("class", "link")
.attr("d", d3.sankeyLinkHorizontal())
.attr("stroke-width", (d) => Math.max(1, d.width))
.attr("fill", "none")
.attr("stroke", (d) => d.source.color)
.attr("opacity", 0.2)
.on("mouseenter", (e) => {
d3.select(e.target).attr("opacity", 0.5);
})
.on("mouseleave", (e) => {
d3.select(e.target).attr("opacity", 0.2);
});
// Node groups
const node = svg
.append("g")
.selectAll(".node")
.data(graph.nodes)
.enter()
.append("g")
.attr("class", "node")
.attr("transform", (d) => `translate(${d.x0},${d.y0})`);
node
.append("rect")
.attr("height", (d) => d.y1 - d.y0)
.attr("width", sankey.nodeWidth())
.attr("fill", (d) => d.color || "#666")
.attr("rx", 3);
// Labels
node.each(function (d) {
const group = d3.select(this);
const isLeft = d.x0 < width / 2;
const xPos = isLeft ? -cfg.labelGap : sankey.nodeWidth() + cfg.labelGap;
const align = isLeft ? "end" : "start";
const midY = (d.y1 - d.y0) / 2;
// Emoji icon
if (d.logo) {
group
.append("text")
.attr("x", isLeft ? cfg.iconOffsetLeft : sankey.nodeWidth() + cfg.iconOffsetRight)
.attr("y", midY)
.attr("dy", "0.35em")
.attr("text-anchor", "middle")
.attr("font-size", `${cfg.logoFontSize}px`)
.text(d.logo);
}
const labelGroup = group.append("g").attr("transform", `translate(${xPos}, ${midY})`);
labelGroup
.append("text")
.attr("text-anchor", align)
.attr("dy", "-1em")
.attr("class", "node-label")
.attr("font-size", `${cfg.labelFontSize}px`)
.text(d.name);
labelGroup
.append("text")
.attr("text-anchor", align)
.attr("dy", "0.4em")
.attr("class", "node-value")
.attr("font-size", `${cfg.valueFontSize}px`)
.attr("fill", d.color)
.text(d.val);
if (d.change) {
labelGroup
.append("text")
.attr("text-anchor", align)
.attr("dy", "1.6em")
.attr("class", "node-change")
.attr("font-size", `${cfg.changeFontSize}px`)
.text(d.change);
}
});
}
let resizeTimer;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
d3.select("#sankey-svg").selectAll("*").remove();
initSankey();
}, 100);
});
document.addEventListener("DOMContentLoaded", initSankey);<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Alphabet Income Statement - Sankey Chart</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="chart-container">
<div class="chart-header">
<h1 class="main-title">Alphabet Q2 FY24 Income Statement</h1>
<p class="source-info">Source: Quarterly results | <span class="url">appeconomyinsights.com</span></p>
</div>
<div id="sankey-chart" class="sankey-wrapper">
<svg id="sankey-svg"></svg>
</div>
<div class="chart-footer">
<div class="brand">APP ECONOMY <span class="accent">INSIGHTS</span></div>
</div>
</div>
<!-- D3.js via CDN -->
<script src="https://d3js.org/d3.v7.min.js"></script>
<!-- Sankey plugin for D3 -->
<script src="https://cdn.jsdelivr.net/npm/d3-sankey@0.12.3/dist/d3-sankey.min.js"></script>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
/* Simplified Sankey โ Google revenue flow (no D3, pure SVG) */
type SNode = {
id: number;
name: string;
color: string;
val: string;
change?: string;
logo?: string;
x: number;
y: number;
h: number;
};
type SLink = { source: number; target: number; value: number; color: string };
const RAW_NODES = [
{ name: "Search ads", color: "#4285F4", val: "$48.5B", change: "+14%", logo: "๐" },
{ name: "YouTube", color: "#FF0000", val: "$8.7B", change: "+13%", logo: "๐บ" },
{ name: "AdMob", color: "#FBBC05", val: "$7.4B", change: "-5%", logo: "๐ฑ" },
{ name: "Google Play", color: "#34A853", val: "$9.3B", change: "+14%", logo: "โถ๏ธ" },
{ name: "Google Cloud", color: "#4285F4", val: "$10.3B", change: "+29%", logo: "โ๏ธ" },
{ name: "Other", color: "#64748b", val: "$0.5B", logo: "+" },
{ name: "Ad Revenue", color: "#4285F4", val: "$64.6B", change: "+11%" },
{ name: "Revenue", color: "#4285F4", val: "$84.7B", change: "+14%" },
{ name: "Gross Profit", color: "#34A853", val: "$49.2B", change: "58% margin" },
{ name: "Cost of Rev", color: "#EA4335", val: "$35.5B" },
{ name: "Op. Profit", color: "#34A853", val: "$27.4B", change: "32% margin" },
{ name: "Op. Expenses", color: "#EA4335", val: "$21.8B" },
{ name: "Net Profit", color: "#34A853", val: "$23.6B", change: "28% margin" },
{ name: "Tax", color: "#EA4335", val: "$3.9B" },
{ name: "Other P/L", color: "#64748b", val: "$0.1B" },
{ name: "R&D", color: "#EA4335", val: "$11.9B" },
{ name: "S&M", color: "#EA4335", val: "$6.8B" },
{ name: "G&A", color: "#EA4335", val: "$3.1B" },
];
const RAW_LINKS = [
{ s: 0, t: 6, v: 48.5 },
{ s: 1, t: 6, v: 8.7 },
{ s: 2, t: 6, v: 7.4 },
{ s: 6, t: 7, v: 64.6 },
{ s: 3, t: 7, v: 9.3 },
{ s: 4, t: 7, v: 10.3 },
{ s: 5, t: 7, v: 0.5 },
{ s: 7, t: 8, v: 49.2 },
{ s: 7, t: 9, v: 35.5 },
{ s: 8, t: 10, v: 27.4 },
{ s: 8, t: 11, v: 21.8 },
{ s: 10, t: 12, v: 23.6 },
{ s: 10, t: 13, v: 3.7 },
{ s: 10, t: 14, v: 0.1 },
{ s: 11, t: 15, v: 11.9 },
{ s: 11, t: 16, v: 6.8 },
{ s: 11, t: 17, v: 3.1 },
];
// Manual column layout
const COLS = [
[0, 1, 2, 3, 4, 5], // col 0: sources
[6], // col 1: ad revenue
[7], // col 2: total revenue
[8, 9], // col 3: gross profit / cost
[10, 11], // col 4: op profit / op expenses
[12, 13, 14, 15, 16, 17], // col 5: net profit + breakdowns
];
function buildLayout(W: number, H: number) {
const NW = 14;
const colX = COLS.map((_, ci) => 60 + (ci / (COLS.length - 1)) * (W - 120));
const nodes: SNode[] = [];
const PAD = 10;
COLS.forEach((col, ci) => {
const totalVal = col.reduce((sum, ni) => {
const incoming = RAW_LINKS.filter((l) => l.t === ni).reduce((a, l) => a + l.v, 0);
const outgoing = RAW_LINKS.filter((l) => l.s === ni).reduce((a, l) => a + l.v, 0);
return sum + (incoming || outgoing || 1);
}, 0);
const availH = H - PAD * (col.length - 1);
let cy = 0;
col.forEach((ni) => {
const incoming = RAW_LINKS.filter((l) => l.t === ni).reduce((a, l) => a + l.v, 0);
const outgoing = RAW_LINKS.filter((l) => l.s === ni).reduce((a, l) => a + l.v, 0);
const val = incoming || outgoing || 1;
const nh = Math.max(20, (val / totalVal) * availH);
nodes[ni] = { ...RAW_NODES[ni], id: ni, x: colX[ci], y: cy, h: nh };
cy += nh + PAD;
});
});
const links: SLink[] = RAW_LINKS.map((l) => ({
source: l.s,
target: l.t,
value: l.v,
color: nodes[l.s]?.color || "#818cf8",
}));
return { nodes, links, NW };
}
export default function ChartSankeyRC() {
const [tooltip, setTooltip] = useState<{ text: string; x: number; y: number } | null>(null);
const [hovered, setHovered] = useState<number | null>(null);
const W = 700,
H = 500;
const { nodes, links, NW } = buildLayout(W, H);
// Source/target offsets for links
const srcOffset: Record<number, number> = {};
const tgtOffset: Record<number, number> = {};
nodes.forEach((n) => {
srcOffset[n.id] = 0;
tgtOffset[n.id] = 0;
});
const renderedLinks = links
.map((l) => {
const src = nodes[l.source],
tgt = nodes[l.target];
if (!src || !tgt) return null;
const totalOut = links
.filter((ll) => ll.source === l.source)
.reduce((a, ll) => a + ll.value, 0);
const totalIn = links
.filter((ll) => ll.target === l.target)
.reduce((a, ll) => a + ll.value, 0);
const lh = Math.max(2, (l.value / (totalOut || 1)) * src.h);
const lh2 = Math.max(2, (l.value / (totalIn || 1)) * tgt.h);
const sy = src.y + srcOffset[l.source] + lh / 2;
const ty = tgt.y + tgtOffset[l.target] + lh2 / 2;
srcOffset[l.source] = (srcOffset[l.source] || 0) + lh;
tgtOffset[l.target] = (tgtOffset[l.target] || 0) + lh2;
const sx = src.x + NW,
tx = tgt.x;
const cx = (sx + tx) / 2;
return {
path: `M${sx},${sy - lh / 2} C${cx},${sy - lh / 2} ${cx},${ty - lh2 / 2} ${tx},${ty - lh2 / 2} L${tx},${ty + lh2 / 2} C${cx},${ty + lh2 / 2} ${cx},${sy + lh / 2} ${sx},${sy + lh / 2} Z`,
color: l.color,
key: `${l.source}-${l.target}`,
label: `${nodes[l.source].name} โ ${nodes[l.target].name}: $${l.value}B`,
};
})
.filter(Boolean);
return (
<div className="min-h-screen bg-[#0d1117] p-4 overflow-x-auto">
<div style={{ minWidth: W }}>
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="w-full">
{/* Links */}
{renderedLinks.map(
(l) =>
l && (
<path
key={l.key}
d={l.path}
fill={l.color}
fillOpacity={hovered === null ? 0.18 : 0.08}
style={{ cursor: "pointer", transition: "fill-opacity 0.15s" }}
onMouseEnter={(e) => {
setTooltip({ text: l.label, x: e.clientX, y: e.clientY });
}}
onMouseMove={(e) =>
setTooltip((t) => (t ? { ...t, x: e.clientX, y: e.clientY } : null))
}
onMouseLeave={() => setTooltip(null)}
/>
)
)}
{/* Nodes */}
{nodes.map(
(n, i) =>
n && (
<g key={i} onMouseEnter={() => setHovered(i)} onMouseLeave={() => setHovered(null)}>
<rect
x={n.x}
y={n.y}
width={NW}
height={n.h}
rx={3}
fill={n.color}
opacity={hovered === null || hovered === i ? 1 : 0.5}
style={{ transition: "opacity 0.15s" }}
/>
<text
x={n.x > W / 2 ? n.x - 6 : n.x + NW + 6}
y={n.y + n.h / 2 - 4}
textAnchor={n.x > W / 2 ? "end" : "start"}
fill="#e6edf3"
fontSize={9}
fontWeight={600}
>
{n.name}
</text>
<text
x={n.x > W / 2 ? n.x - 6 : n.x + NW + 6}
y={n.y + n.h / 2 + 7}
textAnchor={n.x > W / 2 ? "end" : "start"}
fill={n.color}
fontSize={10}
fontWeight={700}
>
{n.val}
</text>
{n.change && (
<text
x={n.x > W / 2 ? n.x - 6 : n.x + NW + 6}
y={n.y + n.h / 2 + 19}
textAnchor={n.x > W / 2 ? "end" : "start"}
fill="#484f58"
fontSize={9}
>
{n.change}
</text>
)}
</g>
)
)}
</svg>
</div>
{tooltip && (
<div
className="fixed pointer-events-none bg-[#161b22] border border-[#30363d] rounded-lg px-3 py-2 text-[12px] shadow-lg z-50"
style={{ left: tooltip.x + 12, top: tooltip.y - 40 }}
>
<span className="text-[#e6edf3]">{tooltip.text}</span>
</div>
)}
</div>
);
}<script setup>
import { ref, computed } from "vue";
const RAW_NODES = [
{ name: "Search ads", color: "#4285F4", val: "$48.5B", change: "+14%" },
{ name: "YouTube", color: "#FF0000", val: "$8.7B", change: "+13%" },
{ name: "AdMob", color: "#FBBC05", val: "$7.4B", change: "-5%" },
{ name: "Google Play", color: "#34A853", val: "$9.3B", change: "+14%" },
{ name: "Google Cloud", color: "#4285F4", val: "$10.3B", change: "+29%" },
{ name: "Other", color: "#64748b", val: "$0.5B" },
{ name: "Ad Revenue", color: "#4285F4", val: "$64.6B", change: "+11%" },
{ name: "Revenue", color: "#4285F4", val: "$84.7B", change: "+14%" },
{ name: "Gross Profit", color: "#34A853", val: "$49.2B", change: "58% margin" },
{ name: "Cost of Rev", color: "#EA4335", val: "$35.5B" },
{ name: "Op. Profit", color: "#34A853", val: "$27.4B", change: "32% margin" },
{ name: "Op. Expenses", color: "#EA4335", val: "$21.8B" },
{ name: "Net Profit", color: "#34A853", val: "$23.6B", change: "28% margin" },
{ name: "Tax", color: "#EA4335", val: "$3.9B" },
{ name: "Other P/L", color: "#64748b", val: "$0.1B" },
{ name: "R&D", color: "#EA4335", val: "$11.9B" },
{ name: "S&M", color: "#EA4335", val: "$6.8B" },
{ name: "G&A", color: "#EA4335", val: "$3.1B" },
];
const RAW_LINKS = [
{ s: 0, t: 6, v: 48.5 },
{ s: 1, t: 6, v: 8.7 },
{ s: 2, t: 6, v: 7.4 },
{ s: 6, t: 7, v: 64.6 },
{ s: 3, t: 7, v: 9.3 },
{ s: 4, t: 7, v: 10.3 },
{ s: 5, t: 7, v: 0.5 },
{ s: 7, t: 8, v: 49.2 },
{ s: 7, t: 9, v: 35.5 },
{ s: 8, t: 10, v: 27.4 },
{ s: 8, t: 11, v: 21.8 },
{ s: 10, t: 12, v: 23.6 },
{ s: 10, t: 13, v: 3.7 },
{ s: 10, t: 14, v: 0.1 },
{ s: 11, t: 15, v: 11.9 },
{ s: 11, t: 16, v: 6.8 },
{ s: 11, t: 17, v: 3.1 },
];
const COLS = [[0, 1, 2, 3, 4, 5], [6], [7], [8, 9], [10, 11], [12, 13, 14, 15, 16, 17]];
const W = 700;
const H = 500;
const NW = 14;
function buildLayout() {
const colX = COLS.map((_, ci) => 60 + (ci / (COLS.length - 1)) * (W - 120));
const nodes = [];
const PAD = 10;
COLS.forEach((col, ci) => {
const totalVal = col.reduce((sum, ni) => {
const incoming = RAW_LINKS.filter((l) => l.t === ni).reduce((a, l) => a + l.v, 0);
const outgoing = RAW_LINKS.filter((l) => l.s === ni).reduce((a, l) => a + l.v, 0);
return sum + (incoming || outgoing || 1);
}, 0);
const availH = H - PAD * (col.length - 1);
let cy = 0;
col.forEach((ni) => {
const incoming = RAW_LINKS.filter((l) => l.t === ni).reduce((a, l) => a + l.v, 0);
const outgoing = RAW_LINKS.filter((l) => l.s === ni).reduce((a, l) => a + l.v, 0);
const val = incoming || outgoing || 1;
const nh = Math.max(20, (val / totalVal) * availH);
nodes[ni] = { ...RAW_NODES[ni], id: ni, x: colX[ci], y: cy, h: nh };
cy += nh + PAD;
});
});
const links = RAW_LINKS.map((l) => ({
source: l.s,
target: l.t,
value: l.v,
color: nodes[l.s]?.color || "#818cf8",
}));
return { nodes, links };
}
const layout = buildLayout();
const nodes = layout.nodes;
const links = layout.links;
const hovered = ref(null);
const tooltip = ref(null);
const renderedLinks = computed(() => {
const srcOffset = {};
const tgtOffset = {};
nodes.forEach((n) => {
srcOffset[n.id] = 0;
tgtOffset[n.id] = 0;
});
return links
.map((l) => {
const src = nodes[l.source];
const tgt = nodes[l.target];
if (!src || !tgt) return null;
const totalOut = links
.filter((ll) => ll.source === l.source)
.reduce((a, ll) => a + ll.value, 0);
const totalIn = links
.filter((ll) => ll.target === l.target)
.reduce((a, ll) => a + ll.value, 0);
const lh = Math.max(2, (l.value / (totalOut || 1)) * src.h);
const lh2 = Math.max(2, (l.value / (totalIn || 1)) * tgt.h);
const sy = src.y + srcOffset[l.source] + lh / 2;
const ty = tgt.y + tgtOffset[l.target] + lh2 / 2;
srcOffset[l.source] = (srcOffset[l.source] || 0) + lh;
tgtOffset[l.target] = (tgtOffset[l.target] || 0) + lh2;
const sx = src.x + NW;
const tx = tgt.x;
const cx = (sx + tx) / 2;
return {
path: `M${sx},${sy - lh / 2} C${cx},${sy - lh / 2} ${cx},${ty - lh2 / 2} ${tx},${ty - lh2 / 2} L${tx},${ty + lh2 / 2} C${cx},${ty + lh2 / 2} ${cx},${sy + lh / 2} ${sx},${sy + lh / 2} Z`,
color: l.color,
key: `${l.source}-${l.target}`,
label: `${nodes[l.source].name} \u2192 ${nodes[l.target].name}: $${l.value}B`,
};
})
.filter(Boolean);
});
function linkEnter(e, l) {
tooltip.value = { text: l.label, x: e.clientX, y: e.clientY };
}
function linkMove(e) {
if (tooltip.value) tooltip.value = { ...tooltip.value, x: e.clientX, y: e.clientY };
}
function linkLeave() {
tooltip.value = null;
}
function textAnchor(n) {
return n.x > W / 2 ? "end" : "start";
}
function textX(n) {
return n.x > W / 2 ? n.x - 6 : n.x + NW + 6;
}
</script>
<template>
<div class="page">
<div class="scroll-wrap" :style="{ minWidth: W + 'px' }">
<svg :width="W" :height="H" :viewBox="`0 0 ${W} ${H}`" class="chart-svg">
<!-- Links -->
<path v-for="l in renderedLinks" :key="l.key"
:d="l.path" :fill="l.color"
:fill-opacity="hovered === null ? 0.18 : 0.08"
style="cursor: pointer; transition: fill-opacity 0.15s;"
@mouseenter="linkEnter($event, l)"
@mousemove="linkMove"
@mouseleave="linkLeave"/>
<!-- Nodes -->
<g v-for="(n, i) in nodes" :key="i"
@mouseenter="hovered = i" @mouseleave="hovered = null">
<template v-if="n">
<rect :x="n.x" :y="n.y" :width="NW" :height="n.h" rx="3" :fill="n.color"
:opacity="hovered === null || hovered === i ? 1 : 0.5"
style="transition: opacity 0.15s;"/>
<text :x="textX(n)" :y="n.y + n.h/2 - 4" :text-anchor="textAnchor(n)"
fill="#e6edf3" font-size="9" font-weight="600">{{ n.name }}</text>
<text :x="textX(n)" :y="n.y + n.h/2 + 7" :text-anchor="textAnchor(n)"
:fill="n.color" font-size="10" font-weight="700">{{ n.val }}</text>
<text v-if="n.change" :x="textX(n)" :y="n.y + n.h/2 + 19" :text-anchor="textAnchor(n)"
fill="#484f58" font-size="9">{{ n.change }}</text>
</template>
</g>
</svg>
</div>
<div v-if="tooltip" class="tooltip-fixed"
:style="{ left: tooltip.x + 12 + 'px', top: tooltip.y - 40 + 'px' }">
<span class="tooltip-text">{{ tooltip.text }}</span>
</div>
</div>
</template>
<style scoped>
.page { min-height: 100vh; background: #0d1117; padding: 1rem; overflow-x: auto; font-family: system-ui, -apple-system, sans-serif; }
.scroll-wrap { min-width: 700px; }
.chart-svg { width: 100%; }
.tooltip-fixed { position: fixed; pointer-events: none; background: #161b22; border: 1px solid #30363d; border-radius: 0.5rem; padding: 0.5rem 0.75rem; font-size: 12px; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3); z-index: 50; }
.tooltip-text { color: #e6edf3; }
</style><script>
const RAW_NODES = [
{ name: "Search ads", color: "#4285F4", val: "$48.5B", change: "+14%" },
{ name: "YouTube", color: "#FF0000", val: "$8.7B", change: "+13%" },
{ name: "AdMob", color: "#FBBC05", val: "$7.4B", change: "-5%" },
{ name: "Google Play", color: "#34A853", val: "$9.3B", change: "+14%" },
{ name: "Google Cloud", color: "#4285F4", val: "$10.3B", change: "+29%" },
{ name: "Other", color: "#64748b", val: "$0.5B" },
{ name: "Ad Revenue", color: "#4285F4", val: "$64.6B", change: "+11%" },
{ name: "Revenue", color: "#4285F4", val: "$84.7B", change: "+14%" },
{ name: "Gross Profit", color: "#34A853", val: "$49.2B", change: "58% margin" },
{ name: "Cost of Rev", color: "#EA4335", val: "$35.5B" },
{ name: "Op. Profit", color: "#34A853", val: "$27.4B", change: "32% margin" },
{ name: "Op. Expenses", color: "#EA4335", val: "$21.8B" },
{ name: "Net Profit", color: "#34A853", val: "$23.6B", change: "28% margin" },
{ name: "Tax", color: "#EA4335", val: "$3.9B" },
{ name: "Other P/L", color: "#64748b", val: "$0.1B" },
{ name: "R&D", color: "#EA4335", val: "$11.9B" },
{ name: "S&M", color: "#EA4335", val: "$6.8B" },
{ name: "G&A", color: "#EA4335", val: "$3.1B" },
];
const RAW_LINKS = [
{ s: 0, t: 6, v: 48.5 },
{ s: 1, t: 6, v: 8.7 },
{ s: 2, t: 6, v: 7.4 },
{ s: 6, t: 7, v: 64.6 },
{ s: 3, t: 7, v: 9.3 },
{ s: 4, t: 7, v: 10.3 },
{ s: 5, t: 7, v: 0.5 },
{ s: 7, t: 8, v: 49.2 },
{ s: 7, t: 9, v: 35.5 },
{ s: 8, t: 10, v: 27.4 },
{ s: 8, t: 11, v: 21.8 },
{ s: 10, t: 12, v: 23.6 },
{ s: 10, t: 13, v: 3.7 },
{ s: 10, t: 14, v: 0.1 },
{ s: 11, t: 15, v: 11.9 },
{ s: 11, t: 16, v: 6.8 },
{ s: 11, t: 17, v: 3.1 },
];
const COLS = [[0, 1, 2, 3, 4, 5], [6], [7], [8, 9], [10, 11], [12, 13, 14, 15, 16, 17]];
const W = 700;
const H = 500;
const NW = 14;
let hovered = null;
let tooltip = null;
function buildLayout() {
const colX = COLS.map((_, ci) => 60 + (ci / (COLS.length - 1)) * (W - 120));
const nodes = [];
const PAD = 10;
COLS.forEach((col, ci) => {
const totalVal = col.reduce((sum, ni) => {
const incoming = RAW_LINKS.filter((l) => l.t === ni).reduce((a, l) => a + l.v, 0);
const outgoing = RAW_LINKS.filter((l) => l.s === ni).reduce((a, l) => a + l.v, 0);
return sum + (incoming || outgoing || 1);
}, 0);
const availH = H - PAD * (col.length - 1);
let cy = 0;
col.forEach((ni) => {
const incoming = RAW_LINKS.filter((l) => l.t === ni).reduce((a, l) => a + l.v, 0);
const outgoing = RAW_LINKS.filter((l) => l.s === ni).reduce((a, l) => a + l.v, 0);
const val = incoming || outgoing || 1;
const nh = Math.max(20, (val / totalVal) * availH);
nodes[ni] = { ...RAW_NODES[ni], id: ni, x: colX[ci], y: cy, h: nh };
cy += nh + PAD;
});
});
const links = RAW_LINKS.map((l) => ({
source: l.s,
target: l.t,
value: l.v,
color: nodes[l.s]?.color || "#818cf8",
}));
return { nodes, links };
}
const layout = buildLayout();
const nodes = layout.nodes;
const links = layout.links;
$: renderedLinks = (() => {
const srcOffset = {};
const tgtOffset = {};
nodes.forEach((n) => {
srcOffset[n.id] = 0;
tgtOffset[n.id] = 0;
});
return links
.map((l) => {
const src = nodes[l.source];
const tgt = nodes[l.target];
if (!src || !tgt) return null;
const totalOut = links
.filter((ll) => ll.source === l.source)
.reduce((a, ll) => a + ll.value, 0);
const totalIn = links
.filter((ll) => ll.target === l.target)
.reduce((a, ll) => a + ll.value, 0);
const lh = Math.max(2, (l.value / (totalOut || 1)) * src.h);
const lh2 = Math.max(2, (l.value / (totalIn || 1)) * tgt.h);
const sy = src.y + srcOffset[l.source] + lh / 2;
const ty = tgt.y + tgtOffset[l.target] + lh2 / 2;
srcOffset[l.source] = (srcOffset[l.source] || 0) + lh;
tgtOffset[l.target] = (tgtOffset[l.target] || 0) + lh2;
const sx = src.x + NW;
const tx = tgt.x;
const cx = (sx + tx) / 2;
return {
path: `M${sx},${sy - lh / 2} C${cx},${sy - lh / 2} ${cx},${ty - lh2 / 2} ${tx},${ty - lh2 / 2} L${tx},${ty + lh2 / 2} C${cx},${ty + lh2 / 2} ${cx},${sy + lh / 2} ${sx},${sy + lh / 2} Z`,
color: l.color,
key: `${l.source}-${l.target}`,
label: `${nodes[l.source].name} \u2192 ${nodes[l.target].name}: $${l.value}B`,
};
})
.filter(Boolean);
})();
function linkEnter(e, l) {
tooltip = { text: l.label, x: e.clientX, y: e.clientY };
}
function linkMove(e) {
if (tooltip) tooltip = { ...tooltip, x: e.clientX, y: e.clientY };
}
function linkLeave() {
tooltip = null;
}
function textAnchor(n) {
return n.x > W / 2 ? "end" : "start";
}
function textX(n) {
return n.x > W / 2 ? n.x - 6 : n.x + NW + 6;
}
</script>
<style>
.page { min-height: 100vh; background: #0d1117; padding: 1rem; overflow-x: auto; font-family: system-ui, -apple-system, sans-serif; }
.scroll-wrap { min-width: 700px; }
.chart-svg { width: 100%; }
.tooltip-fixed { position: fixed; pointer-events: none; background: #161b22; border: 1px solid #30363d; border-radius: 0.5rem; padding: 0.5rem 0.75rem; font-size: 12px; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3); z-index: 50; }
.tooltip-text { color: #e6edf3; }
</style>
<div class="page">
<div class="scroll-wrap">
<svg width={W} height={H} viewBox="0 0 {W} {H}" class="chart-svg">
<!-- Links -->
{#each renderedLinks as l}
<path d={l.path} fill={l.color}
fill-opacity={hovered === null ? 0.18 : 0.08}
style="cursor: pointer; transition: fill-opacity 0.15s;"
on:mouseenter={(e) => linkEnter(e, l)}
on:mousemove={linkMove}
on:mouseleave={linkLeave}/>
{/each}
<!-- Nodes -->
{#each nodes as n, i}
{#if n}
<g on:mouseenter={() => hovered = i} on:mouseleave={() => hovered = null}>
<rect x={n.x} y={n.y} width={NW} height={n.h} rx="3" fill={n.color}
opacity={hovered === null || hovered === i ? 1 : 0.5}
style="transition: opacity 0.15s;"/>
<text x={textX(n)} y={n.y + n.h/2 - 4} text-anchor={textAnchor(n)}
fill="#e6edf3" font-size="9" font-weight="600">{n.name}</text>
<text x={textX(n)} y={n.y + n.h/2 + 7} text-anchor={textAnchor(n)}
fill={n.color} font-size="10" font-weight="700">{n.val}</text>
{#if n.change}
<text x={textX(n)} y={n.y + n.h/2 + 19} text-anchor={textAnchor(n)}
fill="#484f58" font-size="9">{n.change}</text>
{/if}
</g>
{/if}
{/each}
</svg>
</div>
{#if tooltip}
<div class="tooltip-fixed" style="left:{tooltip.x + 12}px; top:{tooltip.y - 40}px;">
<span class="tooltip-text">{tooltip.text}</span>
</div>
{/if}
</div>Features
- Complex Flows โ visualize multiple stages of data splitting and merging
- D3.js Powered โ uses the industrial-standard D3-Sankey plugin for high performance
- Automatic Layout โ node positions and link widths are calculated mathematically
- Interactive Links โ hover over any branch to highlight the entire path
- Responsive โ automatic redraw on window resize with viewport scaling
- Dark Mode Support โ styled with CSS variables for seamless theme switching
How it works
- Data Prep โ Define an array of
nodes(entities) andlinks(connections withvalue) - Sankey Engine โ D3-Sankey computes the
x0, x1, y0, y1coordinates for every rectangle - SVG Paths โ
d3.sankeyLinkHorizontal()generates the smooth bezier curves between nodes - Color Mapping โ Nodes inject their specific brand colors into the outgoing links
- Dynamic Scaling โ The SVG
viewBoxensures the chart remains readable on any screen size
Live Example
The included snippet demonstrates a real-world use case: Alphabetโs (Google) Q2 FY24 Income Statement, showing exactly how revenue filters down through gross profit and operating expenses to net earnings.