Skip to content

Commit 5a049d2

Browse files
dtinthclaude[bot]
andcommitted
เพิ่มแผนภูมิ Sankey แสดงการไหลเวียนของเงิน
- สร้างคอมโพเนนต์ CashFlowChart แสดงการไหลเวียนของเงินจากแหล่งรายได้ไปยังค่าใช้จ่าย - ใช้ SVG สร้างเส้นโฟลวแบบ filled paths ที่มีความหนาสัมพันธ์กับจำนวนเงิน - เงินจากงานก่อนหน้า (From Previous Event) ใช้สีม่วงเพื่อแยกความแตกต่าง - จัดระยะห่างของแต่ละรายการตามสัดส่วนของจำนวนเงินเพื่อป้องกันการทับซ้อน - แสดงยอดรวมรายได้และค่าใช้จ่ายที่ด้านบนของแผนภูมิ - จัดรูปแบบตัวเลขให้แสดงทศนิยม 2 ตำแหน่งอย่างถูกต้อง Co-Authored-By: Claude <209825114+claude[bot]@users.noreply.github.com>
1 parent 7777af2 commit 5a049d2

3 files changed

Lines changed: 348 additions & 0 deletions

File tree

src/components/CashFlowChart.astro

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
---
2+
import manorahCappuccina from "../assets/manorah_cappuccina_blur.webp?url";
3+
import { statementOfActivities } from "../data/finance";
4+
5+
// Calculate totals
6+
const totalIncome = statementOfActivities.income.reduce(
7+
(sum, item) => sum + item.amount,
8+
0
9+
);
10+
const totalExpenses = statementOfActivities.expenses.reduce(
11+
(sum, item) => sum + item.amount,
12+
0
13+
);
14+
const leftover = totalIncome - totalExpenses;
15+
16+
// Sort income and expenses by amount (largest first)
17+
const sortedIncome = [...statementOfActivities.income].sort(
18+
(a, b) => b.amount - a.amount
19+
);
20+
const sortedExpenses = [...statementOfActivities.expenses].sort(
21+
(a, b) => b.amount - a.amount
22+
);
23+
24+
// Special handling for "From sht8" - move to top
25+
const fromSht8Index = sortedIncome.findIndex(
26+
(item) => item.label === "From Previous Event"
27+
);
28+
const fromSht8 = sortedIncome.splice(fromSht8Index, 1)[0];
29+
const incomeWithoutSht8 = sortedIncome;
30+
31+
// Chart dimensions
32+
const width = 800;
33+
const height = 640;
34+
const logoWidth = 220;
35+
const logoHeight = 240;
36+
const centerX = width / 2;
37+
const centerY = height / 2;
38+
---
39+
40+
<section class="py-16 bg-black" id="cash-flow-chart">
41+
<div class="container mx-auto px-4">
42+
<h2 class="text-4xl font-display text-center mb-12 text-white">
43+
💰 Cash Flow Overview
44+
</h2>
45+
46+
<div class="flex justify-center">
47+
<svg
48+
width={width}
49+
height={height}
50+
viewBox={`0 0 ${width} ${height}`}
51+
class="max-w-full h-auto"
52+
>
53+
<!-- Gradient definitions -->
54+
<defs>
55+
<linearGradient id="flowGradient" x1="0%" y1="0%" x2="100%" y2="0%">
56+
<stop offset="0%" style="stop-color:#19806f;stop-opacity:0.8"
57+
></stop>
58+
<stop offset="100%" style="stop-color:#19806f;stop-opacity:0.4"
59+
></stop>
60+
</linearGradient>
61+
<linearGradient id="sht8Gradient" x1="0%" y1="0%" x2="100%" y2="0%">
62+
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:0.8"
63+
></stop>
64+
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:0.4"
65+
></stop>
66+
</linearGradient>
67+
<filter id="glow">
68+
<feGaussianBlur stdDeviation="3" result="coloredBlur"
69+
></feGaussianBlur>
70+
<feMerge>
71+
<feMergeNode in="coloredBlur"></feMergeNode>
72+
<feMergeNode in="SourceGraphic"></feMergeNode>
73+
</feMerge>
74+
</filter>
75+
</defs>
76+
77+
<!-- Income flows (left side) -->
78+
{
79+
(() => {
80+
const allIncomeItems = [fromSht8, ...incomeWithoutSht8];
81+
const minItemHeight = 30; // Minimum for text readability
82+
const itemGap = 8;
83+
84+
// Calculate label positions based on proportional flow height + minimum
85+
const labelPositions = [];
86+
let currentLabelY = 64; // Start higher to avoid top edge
87+
88+
allIncomeItems.forEach((item, index) => {
89+
const flowHeight = (item.amount / totalIncome) * logoHeight;
90+
const itemHeight = Math.max(minItemHeight, flowHeight + 10); // Ensure enough space for text
91+
92+
labelPositions.push({
93+
item,
94+
flowHeight,
95+
labelY: currentLabelY,
96+
centerY: currentLabelY + itemHeight / 2,
97+
itemHeight,
98+
});
99+
currentLabelY += itemHeight + itemGap;
100+
});
101+
102+
// Calculate flow positions on the central box
103+
let currentBoxY = centerY - logoHeight / 2;
104+
105+
return labelPositions.map(
106+
({ item, flowHeight, labelY, centerY: itemCenterY }, index) => {
107+
const flowCenterY = currentBoxY + flowHeight / 2;
108+
const startX = 200;
109+
const endX = centerX - logoWidth / 2;
110+
const isFromSht8 = item.label === "From Previous Event";
111+
112+
// Create filled path with constant width equal to flowHeight
113+
const pathData = `
114+
M ${startX} ${itemCenterY - flowHeight / 2}
115+
C ${startX + 50} ${itemCenterY - flowHeight / 2} ${endX - 50} ${currentBoxY} ${endX} ${currentBoxY}
116+
L ${endX} ${currentBoxY + flowHeight}
117+
C ${endX - 50} ${currentBoxY + flowHeight} ${startX + 50} ${itemCenterY + flowHeight / 2} ${startX} ${itemCenterY + flowHeight / 2}
118+
Z
119+
`;
120+
121+
const result = (
122+
<g class="income-group">
123+
<text
124+
x="190"
125+
y={labelY + 12}
126+
class={`text-sm font-semibold text-end ${isFromSht8 ? "fill-purple-400" : "fill-white"}`}
127+
text-anchor="end"
128+
>
129+
{item.label}
130+
</text>
131+
<text
132+
x="190"
133+
y={labelY + 27}
134+
class={`text-xs text-end ${isFromSht8 ? "fill-purple-300" : "fill-gray-300"}`}
135+
text-anchor="end"
136+
>
137+
฿
138+
{item.amount.toLocaleString("en-US", {
139+
minimumFractionDigits: 2,
140+
maximumFractionDigits: 2,
141+
})}
142+
</text>
143+
144+
<path
145+
d={pathData}
146+
fill={
147+
isFromSht8 ? "url(#sht8Gradient)" : "url(#flowGradient)"
148+
}
149+
opacity="0.8"
150+
class="hover:opacity-1 transition-opacity"
151+
/>
152+
</g>
153+
);
154+
155+
currentBoxY += flowHeight;
156+
return result;
157+
}
158+
);
159+
})()
160+
}
161+
162+
<!-- Center box with manorah cappuccina -->
163+
<rect
164+
x={centerX - logoWidth / 2}
165+
y={centerY - logoHeight / 2}
166+
width={logoWidth}
167+
height={logoHeight}
168+
fill="#19806f"
169+
filter="url(#glow)"></rect>
170+
<image
171+
href={manorahCappuccina}
172+
x={centerX - logoWidth / 2}
173+
y={centerY - logoHeight / 2}
174+
width={logoWidth}
175+
height={logoHeight}
176+
style="object-fit: cover;"
177+
/>
178+
179+
<!-- Expense flows (right side) -->
180+
{
181+
(() => {
182+
const allExpenseItems = [
183+
...sortedExpenses,
184+
{ label: "For Future Events", amount: leftover },
185+
];
186+
const minItemHeight = 30; // Minimum for text readability
187+
const itemGap = 8;
188+
189+
// Calculate label positions based on proportional flow height + minimum
190+
const labelPositions = [];
191+
let currentLabelY = 64; // Start higher to avoid top edge
192+
193+
allExpenseItems.forEach((item, index) => {
194+
const totalAmount = totalExpenses + leftover;
195+
const flowHeight = (item.amount / totalAmount) * logoHeight;
196+
const itemHeight = Math.max(minItemHeight, flowHeight + 10); // Ensure enough space for text
197+
198+
labelPositions.push({
199+
item,
200+
flowHeight,
201+
labelY: currentLabelY,
202+
centerY: currentLabelY + itemHeight / 2,
203+
itemHeight,
204+
});
205+
currentLabelY += itemHeight + itemGap;
206+
});
207+
208+
// Calculate flow positions on the central box
209+
let currentBoxY = centerY - logoHeight / 2;
210+
211+
return labelPositions.map(
212+
({ item, flowHeight, labelY, centerY: itemCenterY }, index) => {
213+
const isLeftover = item.label === "For Future Events";
214+
const startX = centerX + logoWidth / 2;
215+
const endX = width - 200;
216+
217+
// Create filled path with constant width equal to flowHeight
218+
const pathData = `
219+
M ${startX} ${currentBoxY}
220+
C ${startX + 50} ${currentBoxY} ${endX - 50} ${itemCenterY - flowHeight / 2} ${endX} ${itemCenterY - flowHeight / 2}
221+
L ${endX} ${itemCenterY + flowHeight / 2}
222+
C ${endX - 50} ${itemCenterY + flowHeight / 2} ${startX + 50} ${currentBoxY + flowHeight} ${startX} ${currentBoxY + flowHeight}
223+
Z
224+
`;
225+
226+
const result = (
227+
<g class={isLeftover ? "leftover-group" : "expense-group"}>
228+
<text
229+
x={endX + 10}
230+
y={labelY + 12}
231+
class={`text-sm font-semibold ${isLeftover ? "fill-green-400" : "fill-white"}`}
232+
>
233+
{item.label}
234+
</text>
235+
<text
236+
x={endX + 10}
237+
y={labelY + 27}
238+
class={`text-xs ${isLeftover ? "fill-green-300" : "fill-gray-300"}`}
239+
>
240+
฿
241+
{item.amount.toLocaleString("en-US", {
242+
minimumFractionDigits: 2,
243+
maximumFractionDigits: 2,
244+
})}
245+
</text>
246+
247+
<path
248+
d={pathData}
249+
fill={isLeftover ? "#10b981" : "url(#flowGradient)"}
250+
opacity="0.8"
251+
class="hover:opacity-1 transition-opacity"
252+
/>
253+
</g>
254+
);
255+
256+
currentBoxY += flowHeight;
257+
return result;
258+
}
259+
);
260+
})()
261+
}
262+
263+
<!-- Total labels -->
264+
<text
265+
x="200"
266+
y="30"
267+
class="text-xs fill-gray-400 text-end"
268+
text-anchor="end"
269+
>
270+
Total Income: ฿{
271+
totalIncome.toLocaleString("en-US", {
272+
minimumFractionDigits: 2,
273+
maximumFractionDigits: 2,
274+
})
275+
}
276+
</text>
277+
<text x={width - 200} y="30" class="text-xs fill-gray-400">
278+
Total Expenses: ฿{
279+
totalExpenses.toLocaleString("en-US", {
280+
minimumFractionDigits: 2,
281+
maximumFractionDigits: 2,
282+
})
283+
}
284+
</text>
285+
</svg>
286+
</div>
287+
288+
<div class="text-center mt-8">
289+
<p class="text-gray-300 text-sm max-w-2xl mx-auto">
290+
This Sankey diagram shows the flow of funds for Stupido Hackettino ๙.
291+
Income sources on the left flow through our event operations to expenses
292+
on the right, with the remaining budget carrying forward to future
293+
Creatorsgarten events.
294+
</p>
295+
</div>
296+
</div>
297+
</section>
298+
299+
<style>
300+
.income-group:hover text,
301+
.expense-group:hover text,
302+
.leftover-group:hover text {
303+
fill: #19806f;
304+
}
305+
306+
svg text {
307+
font-family:
308+
system-ui,
309+
-apple-system,
310+
sans-serif;
311+
}
312+
313+
@media (max-width: 768px) {
314+
svg {
315+
font-size: 12px;
316+
}
317+
}
318+
</style>

src/data/finance.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export interface StatementOfActivitiesItem {
2+
label: string;
3+
amount: number;
4+
}
5+
6+
export const statementOfActivities: {
7+
income: StatementOfActivitiesItem[];
8+
expenses: StatementOfActivitiesItem[];
9+
} = {
10+
income: [
11+
{ label: "From Previous Event", amount: 1648.11 },
12+
{ label: "Corporate Sponsors", amount: 118444.44 },
13+
{ label: "Individual Sponsors", amount: 5617.33 },
14+
{ label: "Post-Event Donations", amount: 2191.0 },
15+
{ label: "Forfeited Deposit", amount: 1800.0 },
16+
],
17+
expenses: [
18+
{ label: "Venue & Accommodation", amount: 54656.02 },
19+
{ label: "Food & Beverages", amount: 33392.0 },
20+
{ label: "Prize", amount: 23645.68 },
21+
{ label: "Swag", amount: 8363.0 },
22+
{ label: "Props", amount: 2089.0 },
23+
{ label: "Parking", amount: 2020.0 },
24+
{ label: "Ticketing Platform", amount: 1230.0 },
25+
{ label: "Equipment", amount: 1018.0 },
26+
{ label: "Transportation", amount: 909.6 },
27+
],
28+
};

src/pages/index.astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
---
2+
import CashFlowChart from "../components/CashFlowChart.astro";
23
import CodeOfConductSection from "../components/CodeOfConductSection.astro";
34
import CreatoriGiardinoMarquee from "../components/CreatoriGiardinoMarquee.astro";
45
import EventAgenda from "../components/EventAgenda.astro";
@@ -29,5 +30,6 @@ import Layout from "../layouts/Layout.astro";
2930
<CodeOfConductSection />
3031
<SponsorsSection />
3132
<FundingSection />
33+
<CashFlowChart />
3234
<RegistrationSection />
3335
</Layout>

0 commit comments

Comments
 (0)