UI-компоненти Складна
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.
Відкрити в Lab
MCP
vanilla-js d3 svg react tailwind vue svelte
Цілі: TS JS HTML React Vue Svelte
Код
: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/[email protected]/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.