Repository navigation
Expand file tree
/
Copy pathindex.html
More file actions
450 lines (394 loc) · 46.4 KB
/
Copy pathindex.html
File metadata and controls
450 lines (394 loc) · 46.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>Life Graph — Demo</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
<script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
<script src="https://unpkg.com/3d-force-graph@1.73.4/dist/3d-force-graph.min.js"></script>
<style>
:root{--bg:#06080c;--surface:rgba(12,16,28,0.8);--glass:rgba(18,24,42,0.5);--border:rgba(255,255,255,0.04);--text:#9ca3b8;--text-dim:#4a5168;--text-hi:#e8ecf4;--accent:#64ffda;--accent2:#a78bfa;--warm:#f59e0b;--red:#ef4444;--green:#34d399;}
*{margin:0;padding:0;box-sizing:border-box;}
body{background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;overflow:hidden;-webkit-font-smoothing:antialiased;}
#app{display:flex;flex-direction:column;height:100vh;}
#top-bar{display:flex;align-items:center;gap:20px;padding:12px 28px;background:var(--glass);backdrop-filter:blur(24px);border-bottom:1px solid var(--border);z-index:20;}
#top-bar h1{font-family:'Space Mono',monospace;font-size:13px;font-weight:700;letter-spacing:3px;text-transform:uppercase;color:var(--accent);}
#top-bar .tag{font-size:9px;padding:3px 8px;border-radius:3px;background:rgba(100,255,218,0.08);color:var(--accent);font-family:'Space Mono',monospace;letter-spacing:1px;}
#subtitle{display:none;font-family:'Space Mono',monospace;font-size:8px;color:var(--text-dim);letter-spacing:0.3px;line-height:1.4;}
#time-controls{display:flex;align-items:center;gap:12px;flex:1;}
#time-slider{flex:1;-webkit-appearance:none;height:2px;border-radius:1px;background:rgba(255,255,255,0.06);outline:none;}
#time-slider::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--accent);cursor:pointer;box-shadow:0 0 16px rgba(100,255,218,0.4);}
#time-label{font-family:'Space Mono',monospace;font-size:11px;color:var(--text-dim);min-width:70px;text-align:center;}
.btn{padding:5px 14px;border:1px solid rgba(255,255,255,0.08);border-radius:4px;background:transparent;color:var(--text-dim);cursor:pointer;font-family:'Space Mono',monospace;font-size:10px;letter-spacing:1px;text-transform:uppercase;transition:all 0.2s;}
.btn:hover{border-color:var(--accent);color:var(--accent);}
.btn.active{background:var(--accent);color:var(--bg);border-color:var(--accent);}
#main{display:flex;flex:1;min-height:0;}
#graph-area{flex:1;position:relative;overflow:hidden;}
#graph-container{width:100%;height:100%;position:relative;z-index:1;}
#sidebar{width:320px;min-width:320px;background:var(--glass);backdrop-filter:blur(24px);border-left:1px solid var(--border);padding:24px;overflow-y:auto;display:flex;flex-direction:column;gap:18px;z-index:20;position:relative;}
.section-label{font-family:'Space Mono',monospace;font-size:9px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--text-dim);}
#cluster-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:20px;min-height:120px;}
#cluster-card .amount{font-size:28px;font-weight:700;letter-spacing:-0.5px;color:var(--accent);margin-bottom:4px;}
#cluster-card .name{font-size:14px;font-weight:500;color:var(--text-hi);margin-bottom:10px;}
#cluster-card .meta{font-size:11px;color:var(--text-dim);line-height:1.8;}
#cluster-card .placeholder{color:var(--text-dim);font-size:11px;text-align:center;padding:35px 0;opacity:0.4;font-family:'Space Mono',monospace;}
#txn-list{max-height:240px;overflow-y:auto;}
#txn-list::-webkit-scrollbar{width:3px;}
#txn-list::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.08);border-radius:2px;}
.txn-item{display:flex;justify-content:space-between;align-items:center;padding:6px 10px;margin-bottom:1px;border-radius:4px;font-size:11px;transition:background 0.15s;}
.txn-item:hover{background:rgba(255,255,255,0.03);}
.txn-desc{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);}
.txn-date{color:var(--text-dim);margin-right:8px;font-family:'Space Mono',monospace;font-size:9px;}
.txn-amt{font-weight:600;margin-left:10px;white-space:nowrap;font-family:'Space Mono',monospace;font-size:10px;}
.txn-amt.debit{color:var(--red);}
.txn-amt.credit{color:var(--green);}
#stats{border-top:1px solid var(--border);padding-top:14px;}
.stat-row{display:flex;justify-content:space-between;padding:3px 0;font-size:11px;}
.stat-row span:first-child{color:var(--text-dim);}
.stat-row .val{color:var(--text-hi);font-weight:500;font-family:'Space Mono',monospace;font-size:10px;}
#narrative-toggle{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:30;padding:8px 20px;border:1px solid var(--border);border-radius:20px;background:var(--glass);backdrop-filter:blur(24px);color:var(--accent);cursor:pointer;font-family:'Space Mono',monospace;font-size:10px;letter-spacing:1.5px;text-transform:uppercase;transition:all 0.3s;display:flex;align-items:center;gap:8px;animation:pulse 3s ease-in-out infinite;}
#narrative-toggle:hover{border-color:var(--accent);background:rgba(100,255,218,0.08);}
@keyframes pulse{0%,100%{transform:translateX(-50%) scale(1);box-shadow:0 0 0 0 rgba(100,255,218,0.2);}50%{transform:translateX(-50%) scale(1.05);box-shadow:0 0 20px 4px rgba(100,255,218,0.15);}}
#narrative-toggle .arrow{transition:transform 0.3s;font-size:14px;}
#narrative-toggle.open .arrow{transform:rotate(180deg);}
#narrative-overlay{position:fixed;bottom:0;left:0;right:0;height:70vh;background:rgba(6,8,12,0.95);backdrop-filter:blur(30px);border-top:1px solid rgba(100,255,218,0.15);z-index:25;transform:translateY(100%);transition:transform 0.4s cubic-bezier(0.16,1,0.3,1);overflow-y:auto;padding:32px 48px 80px 48px;}
#narrative-overlay.open{transform:translateY(0);}
#narrative-overlay::-webkit-scrollbar{width:3px;}
#narrative-overlay::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.08);border-radius:2px;}
#narrative-overlay h1{font-family:'Space Mono',monospace;font-size:13px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--accent);margin-bottom:16px;}
#narrative-overlay h2{font-family:'Space Mono',monospace;font-size:10px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent2);margin:22px 0 8px 0;}
#narrative-overlay p{font-size:13px;line-height:1.8;color:var(--text);margin:6px 0;max-width:800px;}
#narrative-overlay strong{color:var(--text-hi);font-weight:600;}
#narrative-overlay em{color:var(--warm);font-style:normal;}
/* ── Mobile ──────────────────────────────────── */
@media(max-width:768px){
#top-bar{padding:8px 12px;gap:8px;flex-wrap:wrap;}
#top-bar h1{font-size:11px;letter-spacing:2px;}
#top-bar .tag{font-size:8px;padding:2px 6px;}
#subtitle{display:block;order:2;width:100%;}
#time-controls{gap:6px;order:3;width:100%;}
#time-label{font-size:9px;min-width:50px;}
.btn{padding:4px 10px;font-size:9px;}
#main{position:relative;}
#graph-area{position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1;}
/* Sidebar hidden on mobile — replaced by floating controls */
#sidebar{display:none;}
#sidebar.open{display:flex;position:fixed;bottom:0;left:0;right:0;width:100%;min-width:unset;
max-height:60vh;border-left:none;border-top:1px solid rgba(100,255,218,0.15);
border-radius:16px 16px 0 0;padding:16px 16px 80px 16px;gap:12px;z-index:28;
animation:slideUp 0.35s cubic-bezier(0.16,1,0.3,1);}
@keyframes slideUp{from{transform:translateY(100%);}to{transform:translateY(0);}}
#cluster-card{padding:14px;min-height:80px;}
#cluster-card .amount{font-size:22px;}
#cluster-card .name{font-size:13px;}
#txn-list{max-height:150px;}
/* Floating mobile nav bar */
#mobile-nav{display:flex !important;}
/* Narrative full screen on mobile */
#narrative-overlay{height:85vh;padding:20px 18px 80px 18px;}
#narrative-overlay h1{font-size:11px;letter-spacing:1.5px;}
#narrative-overlay h2{font-size:9px;}
#narrative-overlay p{font-size:12px;line-height:1.7;}
#narrative-toggle{bottom:74px;font-size:9px;padding:6px 16px;}
@keyframes pulse{0%,100%{transform:translateX(-50%) scale(1);box-shadow:0 0 0 0 rgba(100,255,218,0.2);}50%{transform:translateX(-50%) scale(1.03);box-shadow:0 0 14px 3px rgba(100,255,218,0.12);}}
}
#mobile-nav{
display:none;position:fixed;bottom:16px;left:50%;transform:translateX(-50%);z-index:30;
align-items:center;gap:6px;
background:rgba(6,8,12,0.9);backdrop-filter:blur(24px);
border:1px solid var(--border);border-radius:28px;padding:6px 8px;
}
#mobile-nav .btn{border-radius:50%;width:36px;height:36px;padding:0;display:flex;align-items:center;justify-content:center;font-size:14px;}
#mobile-nav .node-name{font-family:'DM Sans',sans-serif;font-size:11px;color:var(--text-hi);max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 4px;}
#mobile-nav .node-amt{font-family:'Space Mono',monospace;font-size:10px;color:var(--accent);padding:0 4px;}
#mobile-nav .info-btn{border-radius:50%;width:36px;height:36px;padding:0;display:flex;align-items:center;justify-content:center;font-size:16px;border:1px solid var(--accent);color:var(--accent);background:transparent;cursor:pointer;}
@media(max-width:400px){
#top-bar .tag:nth-child(3){display:none;}
#cluster-card .amount{font-size:20px;}
#narrative-overlay p{font-size:11.5px;line-height:1.65;}
}
</style>
</head>
<body>
<div id="app">
<div id="top-bar">
<h1>Life Graph</h1>
<span class="tag">demo</span>
<span class="tag" style="background:rgba(167,139,250,0.08);color:var(--accent2);">transactions + photos</span>
<span id="subtitle">Generated from 1,847 bank transactions · Cross-referenced with 3,291 iCloud photos across multiple detected interest clusters</span>
<div id="time-controls">
<button class="btn" id="btn-play" onclick="togglePlay()">Play</button>
<input type="range" id="time-slider" min="0" max="100" value="100">
<span id="time-label">All time</span>
<button class="btn" onclick="resetView()">Reset</button>
</div>
</div>
<div id="main">
<div id="graph-area"><div id="graph-container"></div></div>
<div id="sidebar">
<div>
<span class="section-label">Category</span>
<div id="cluster-card" style="margin-top:8px;"><div class="placeholder">click a node to explore</div></div>
</div>
<div id="txn-list"></div>
<div style="display:flex;align-items:center;gap:8px;">
<span class="section-label" style="flex:1;">Navigate</span>
<button class="btn" onclick="stepNode(-1)" style="padding:4px 10px;">◀</button>
<span id="node-counter" style="font-family:'Space Mono',monospace;font-size:10px;color:var(--text-dim);min-width:40px;text-align:center;">—</span>
<button class="btn" onclick="stepNode(1)" style="padding:4px 10px;">▶</button>
</div>
<div id="stats">
<span class="section-label">Overview</span>
<div style="margin-top:8px;">
<div class="stat-row"><span>Transactions</span><span class="val">1,847</span></div>
<div class="stat-row"><span>Categories</span><span class="val">28</span></div>
<div class="stat-row"><span>Period</span><span class="val">Jan — Dec 2025</span></div>
<div class="stat-row"><span>Total out</span><span class="val" style="color:var(--red)">₦12,840,000</span></div>
<div class="stat-row"><span>Total in</span><span class="val" style="color:var(--green)">₦13,230,000</span></div>
</div>
</div>
</div>
</div>
<div id="mobile-nav">
<button class="btn" onclick="stepNode(-1)">◀</button>
<span class="node-name" id="mob-name">Tap to explore</span>
<span class="node-amt" id="mob-amt"></span>
<button class="info-btn" onclick="toggleMobileSidebar()">ⓘ</button>
<button class="btn" onclick="stepNode(1)">▶</button>
</div>
<button id="narrative-toggle" onclick="toggleNarrative()"><span class="arrow">▲</span> Your Year in Money</button>
<div id="narrative-overlay">
<h1>Your Year in Money — A Life, Graphed</h1>
<p style="font-size:10px;color:var(--text-dim);font-family:'Space Mono',monospace;letter-spacing:0.5px;margin-bottom:14px;">Generated from 1,847 bank transactions · Cross-referenced with 3,291 iCloud photos across 12 detected interest clusters</p>
<p><strong>Your year is a story about building three income streams, watching them collapse one by one, and finding out what survives.</strong> First the side income vanished. Then the business dried up. Then the job. Then your health. The data doesn't know any of that — it just sees merchants going dark, amounts shrinking, and new line items appearing where old ones died. But read it in order, and the arc is unmistakable.</p>
<h2>Act I: Three Streams (Jan — Apr)</h2>
<p>You were running three income streams: a startup salary at ₦150K/month, Upwork freelancing pulling ₦80-200K, and a business portfolio — crypto trades, forex positions, electronics resale — clearing ₦400-750K/month. Together you were pulling ₦600K-1.1M monthly. The spending reflected it: <em>Café One</em> every weekday morning, ₦1,500 a visit. <em>Chowdeck</em> six or seven times a week — breakfast and dinner, no questions asked. Gaming every two weeks: Steam and PS Store, ₦16-30K each time. Drawing supplies twice a month — sketchbooks, Micron pens, Copic markers, charcoal sets. You were going to the gym too, but inconsistently — some weeks twice, some weeks you skipped. ₦4-5K per visit, pay-as-you-go, no commitment. Your parents alternated sending you money: mum one month, dad the next, ₦15-20K each time — they didn't know about the business income. Spotify, Netflix, ₦25K/month MTN internet — the background hum. You were spending about ₦450K a month without thinking about it. You could afford to.</p>
<h2>Act II: The First Cracks (May — Jun)</h2>
<p>Upwork account suspended in early May. Last withdrawal: April 28th. ₦80-200K of monthly side income — gone overnight, no appeal. The business was still running but the forex positions were getting smaller, the crypto trades less frequent. The data shows the shift within days: Chowdeck dropped from daily to 3-4 times a week. Shoprite spending jumped — you started buying ingredients for the first time. Gaming didn't drop — it <em>increased</em>. May and June had your highest Steam spending of the year. More hours at home, more idle anxiety. The gym visits got more erratic — three times one week, then nothing for two weeks. <em>Udemy</em> appeared on May 6th — "Advanced React Patterns." Then "System Design Interviews." You were preparing for something you didn't know was coming. Your mum's transfer that month jumped from ₦15K to ₦25K. She never mentioned it. The amount just changed.</p>
<h2>Act III: The Collapse (Jul — Sep)</h2>
<p>Laid off. Last salary: June 28th. The business income had already been tapering — the last crypto trade closed September 2nd with just ₦60K, a shadow of the ₦240K forex positions from February. Then three weeks after losing the job — a hospital. ₦170,000 at National Hospital Abuja on July 19th. Pharmacy receipts started: ₦24-36K every two weeks, a pattern that had never existed in your transaction history. <strong>Café One went dark.</strong> Six weeks, zero purchases. Gaming stopped for three weeks in July — the <em>only</em> gap all year. The gym went to zero. Remaining freelance income from direct clients dropped 60%. Your <strong>dad</strong> sent money three times in July — he'd normally send once every other month. Your <strong>mum</strong> sent ₦30K the same week as your dad, breaking the alternating pattern for the first time. Your <strong>sister</strong> sent you ₦20,000 on August 3rd — the only transfer from her in the entire dataset. <strong>David</strong> sent you ₦30,000 on July 22nd with no memo. First incoming transfer from him ever. Chowdeck went to zero in August. The only subscriptions that survived: Spotify at ₦1,600, Netflix at ₦2,900, MTN internet. The bare minimum digital life.</p>
<h2>Act IV: Rebuilding (Oct — Dec)</h2>
<p>New job. First salary October 28th: <strong>₦1,300,000</strong> — nearly nine times your old startup rate. But the rebuilding started before the money. <em>Laguru Gym</em> appeared October 6th at ₦60,000/month — a proper membership, not the old pay-per-visit routine. Twenty-two days before payday, signed up on faith. Café One came back October 3rd after six weeks dark. Pharmacy spending halved in October, halved again in November. <strong>Drawing supplies came back in November</strong> — a new sketchbook and Copic marker set, ₦36,000. First creative purchase in four months. Gaming normalized back to the two-week cycle. A ₦1,000,000 <em>Bamboo</em> deposit in December — ₦600K into stocks, ₦400K into dollar fund. Your first serious investment — and a signal that the business hustle was being replaced by something more structured. Grocery spending stayed high. You never went back to ordering Chowdeck seven times a week. The rebuilt version cooks. And the gym? Three times a week now. Every week. No skipping. The pay-per-visit guy became a member.</p>
<h2>The Constants</h2>
<p>The things that never stopped tell you who you actually are. <strong>Spotify: twelve months, not one skip.</strong> ₦25K/month MTN internet, every single month, plus daily data top-ups — your internet was non-negotiable even when food delivery wasn't. Your barber every three weeks, even during the illness — just delayed by one week once. <strong>Suya every two weeks</strong>, ₦4-5K from your regular vendor, the single most consistent spending pattern in the dataset. You skipped it once. In late July. Once. And your parents — every month, alternating, adjusting amounts without ever being asked. Your dad sent more in the crisis months. Your mum sent more in the lean months. They were covering different gaps without coordinating.</p>
<h2>What Your Camera Saw</h2>
<p>Your iCloud photo clusters line up with your transaction phases almost perfectly. The <strong>"sketchbook / drawings"</strong> cluster (214 photos) is densest between January and June — 8-12 photos a week of works in progress, ink studies, charcoal pieces. It goes completely silent from July to October. Then five photos appear in November, the same week your first drawing supply purchase came back. Your camera confirmed what your bank already knew.</p>
<p>A <strong>"food / cooking"</strong> cluster (89 photos) barely exists before May. Then it spikes — photos of grocery hauls, half-finished meals, experiments. The cluster peaks in October through December, exactly when Shoprite spending was highest and Chowdeck was lowest. You weren't just cooking — you were documenting it. Proud of it. A <strong>"gym / mirror selfies"</strong> cluster (41 photos) is scattered and sparse from January to June — matching the pay-per-visit pattern exactly. Zero photos in July through September. Then a consistent 3-4 per week starting October, the same week as the Laguru membership. The pay-per-visit guy didn't take progress photos. The member does.</p>
<p>The most telling cluster is <strong>"screen / gaming setup"</strong> (156 photos). It peaks in May and June — the unemployment months — with screenshots, stream captures, and setup photos almost daily. It drops to nothing in late July through August. Comes back at a normal pace in September. Gaming increases during stress. Gaming stops during illness. The camera saw the same pattern the bank did.</p>
<h2>The Correlations</h2>
<p><strong>Chowdeck is the most accurate predictor of financial confidence in your data.</strong> It drops within two weeks of income loss and takes another two weeks to return. You don't trust good news quickly. <strong>Gaming increases during unemployment but decreases during illness.</strong> More stress, more gaming. Too sick, no gaming. They're different kinds of bad — your spending knows the difference. <strong>The gym tells the whole story in one line item:</strong> inconsistent pay-per-visit for nine months, then ₦60K/month membership the moment you decided to rebuild. The commitment changed before the frequency did. <strong>The business income tells another:</strong> ₦6.8M across nine months of crypto, forex, and resale — then silence. The illness killed the hustle. The new salary replaced it with stability. Your <strong>sister</strong> sent money exactly once, during the worst month, and never again. David did the same. They knew. Udemy courses appeared 8 days after losing Upwork. The barber went from every 3 weeks to every 2 starting October.</p>
<h2>The Shape of a Year</h2>
<p>The most expensive month was December — ₦1.6M out, but ₦1M of that was Bamboo. You were investing, not spending. The cheapest was August — ₦144K, mostly medication and data. But the month that tells you the most about who you are is <strong>October</strong>. Laguru Gym before the first paycheck. Coffee on day one. Drawing supplies three weeks later. Gaming back on schedule. Not because you could afford it all yet — because you'd spent three months learning which parts of your life were structural and which were noise. The old version of you ran three income streams, earned ₦800K/month combined, and spent ₦450K on autopilot. The new version earns ₦1.3M from one job, spends ₦650K deliberately, and puts the rest into Bamboo. The business hustle is gone. The scramble is gone. Your data can't tell you which version was happier. But it can show you which one has a rhythm. And rhythms are what survive.</p>
</div>
</div>
<script>
function toggleNarrative(){
const overlay=document.getElementById('narrative-overlay');
const btn=document.getElementById('narrative-toggle');
overlay.classList.toggle('open');
btn.classList.toggle('open');
}
// Mobile sidebar toggle
function toggleMobileSidebar(){
const sidebar=document.getElementById('sidebar');
sidebar.classList.toggle('open');
}
// Close sidebar when tapping outside on mobile
document.addEventListener('click',function(e){
if(window.innerWidth>768)return;
const sidebar=document.getElementById('sidebar');
if(!sidebar.classList.contains('open'))return;
if(!sidebar.contains(e.target)&&!e.target.closest('#mobile-nav'))sidebar.classList.remove('open');
});
</script>
<script>
</script>
<script>
const nodesData = [
{id:0,name:"Startup Salary (old job)",total:900000,count:6,avg:150000,size:12,color:"#64ffda",
months:["2025-01","2025-02","2025-03","2025-04","2025-05","2025-06"],
txns:[{desc:"Vantage Labs — January Salary",amt:-150000,cur:"NGN",date:"2025-01-28"},{desc:"Vantage Labs — March Salary",amt:-150000,cur:"NGN",date:"2025-03-28"},{desc:"Vantage Labs — May Salary",amt:-150000,cur:"NGN",date:"2025-05-28"},{desc:"Vantage Labs — June Salary (final)",amt:-150000,cur:"NGN",date:"2025-06-28"}]},
{id:1,name:"Upwork Freelancing",total:740000,count:8,avg:92500,size:11,color:"#34d399",
months:["2025-01","2025-02","2025-03","2025-04"],
txns:[{desc:"Upwork Withdrawal — UI/UX project",amt:-120000,cur:"NGN",date:"2025-01-12"},{desc:"Upwork Withdrawal — Dashboard redesign",amt:-200000,cur:"NGN",date:"2025-02-18"},{desc:"Upwork Withdrawal — Mobile screens",amt:-165000,cur:"NGN",date:"2025-03-22"},{desc:"Upwork Withdrawal — Landing pages (final)",amt:-80000,cur:"NGN",date:"2025-04-28"}]},
{id:2,name:"Freelance (direct clients)",total:485000,count:6,avg:80833,size:9,color:"#2dd4bf",
months:["2025-05","2025-06","2025-07","2025-08","2025-09","2025-10"],
txns:[{desc:"Paystack — Korede Studios",amt:-110000,cur:"NGN",date:"2025-05-15"},{desc:"Paystack — Bloom Agency",amt:-75000,cur:"NGN",date:"2025-07-08"},{desc:"Paystack — Korede Studios",amt:-30000,cur:"NGN",date:"2025-08-19"},{desc:"Paystack — Bloom Agency",amt:-95000,cur:"NGN",date:"2025-10-05"}]},
{id:3,name:"New Job Salary",total:3900000,count:3,avg:1300000,size:15,color:"#22d3ee",
months:["2025-10","2025-11","2025-12"],
txns:[{desc:"Helios Labs — October Salary",amt:-1300000,cur:"NGN",date:"2025-10-28"},{desc:"Helios Labs — November Salary",amt:-1300000,cur:"NGN",date:"2025-11-28"},{desc:"Helios Labs — December Salary",amt:-1300000,cur:"NGN",date:"2025-12-28"}]},
{id:4,name:"Gaming (Steam / PS Store)",total:396000,count:24,avg:8250,size:10,color:"#818cf8",
months:["2025-01","2025-02","2025-03","2025-04","2025-05","2025-06","2025-08","2025-09","2025-10","2025-11","2025-12"],
txns:[{desc:"Steam — Winter Sale bundle",amt:11000,cur:"NGN",date:"2025-01-10"},{desc:"PS Store — EA FC 25",amt:14000,cur:"NGN",date:"2025-02-21"},{desc:"Steam — 5-game bundle",amt:22000,cur:"NGN",date:"2025-05-02"},{desc:"PS Store — PS Plus Annual",amt:18000,cur:"NGN",date:"2025-05-18"},{desc:"Steam — Hades II",amt:9500,cur:"NGN",date:"2025-06-14"},{desc:"PS Store — Elden Ring DLC",amt:13000,cur:"NGN",date:"2025-09-06"},{desc:"Steam — Autumn Sale",amt:15000,cur:"NGN",date:"2025-11-22"},{desc:"PS Store — Holiday Sale",amt:10000,cur:"NGN",date:"2025-12-20"}]},
{id:5,name:"Café One (daily coffee)",total:176000,count:58,avg:1517,size:9,color:"#f59e0b",
months:["2025-01","2025-02","2025-03","2025-04","2025-05","2025-06","2025-10","2025-11","2025-12"],
txns:[{desc:"Café One — Latte",amt:1500,cur:"NGN",date:"2025-01-06"},{desc:"Café One — Double Espresso",amt:1200,cur:"NGN",date:"2025-03-14"},{desc:"Café One — Cold Brew",amt:1800,cur:"NGN",date:"2025-05-22"},{desc:"Café One — Double Espresso",amt:1200,cur:"NGN",date:"2025-10-03"},{desc:"Café One — Latte",amt:1500,cur:"NGN",date:"2025-12-18"}]},
{id:6,name:"Chowdeck (food delivery)",total:556000,count:72,avg:3861,size:10,color:"#fb923c",
months:["2025-01","2025-02","2025-03","2025-04","2025-05","2025-06","2025-10","2025-11","2025-12"],
txns:[{desc:"Chowdeck — Chicken Republic",amt:3800,cur:"NGN",date:"2025-01-09"},{desc:"Chowdeck — Kilimanjaro",amt:4500,cur:"NGN",date:"2025-03-18"},{desc:"Chowdeck — The Place",amt:3900,cur:"NGN",date:"2025-05-06"},{desc:"Chowdeck — Dominos",amt:6200,cur:"NGN",date:"2025-06-12"},{desc:"Chowdeck — Shawarma",amt:2800,cur:"NGN",date:"2025-11-15"},{desc:"Chowdeck — Chicken Republic",amt:3200,cur:"NGN",date:"2025-12-22"}]},
{id:7,name:"Spotify",total:38400,count:12,avg:1600,size:4.5,color:"#1db954",
months:["2025-01","2025-02","2025-03","2025-04","2025-05","2025-06","2025-07","2025-08","2025-09","2025-10","2025-11","2025-12"],
txns:[{desc:"Spotify Premium",amt:1600,cur:"NGN",date:"2025-01-05"},{desc:"Spotify Premium",amt:1600,cur:"NGN",date:"2025-07-05"},{desc:"Spotify Premium",amt:1600,cur:"NGN",date:"2025-12-05"}]},
{id:8,name:"Netflix",total:69600,count:12,avg:2900,size:4.5,color:"#e11d48",
months:["2025-01","2025-02","2025-03","2025-04","2025-05","2025-06","2025-07","2025-08","2025-09","2025-10","2025-11","2025-12"],
txns:[{desc:"Netflix Premium",amt:2900,cur:"NGN",date:"2025-01-12"},{desc:"Netflix Premium",amt:2900,cur:"NGN",date:"2025-07-12"},{desc:"Netflix Premium",amt:2900,cur:"NGN",date:"2025-12-12"}]},
{id:9,name:"Drawing Supplies",total:184000,count:9,avg:10222,size:8,color:"#ec4899",
months:["2025-01","2025-02","2025-03","2025-04","2025-05","2025-06","2025-11","2025-12"],
txns:[{desc:"Art store — Sketchbook A4 + Micron pens",amt:8500,cur:"NGN",date:"2025-01-18"},{desc:"Art store — Charcoal set + fixative",amt:6000,cur:"NGN",date:"2025-02-15"},{desc:"Art store — Bristol board + ink",amt:11000,cur:"NGN",date:"2025-03-22"},{desc:"Art store — Copic markers (6-pack)",amt:18000,cur:"NGN",date:"2025-05-10"},{desc:"Art store — Sketchbook + Copic set",amt:18000,cur:"NGN",date:"2025-11-15"},{desc:"Art store — Holiday haul",amt:12000,cur:"NGN",date:"2025-12-06"}]},
{id:10,name:"Internet & Data (MTN)",total:748000,count:154,avg:2429,size:9,color:"#fcd34d",
months:["2025-01","2025-02","2025-03","2025-04","2025-05","2025-06","2025-07","2025-08","2025-09","2025-10","2025-11","2025-12"],
txns:[{desc:"MTN — Monthly Internet (25K plan)",amt:25000,cur:"NGN",date:"2025-01-05"},{desc:"MTN — Monthly Internet",amt:25000,cur:"NGN",date:"2025-03-05"},{desc:"MTN — Monthly Internet",amt:25000,cur:"NGN",date:"2025-06-05"},{desc:"MTN — Monthly Internet",amt:25000,cur:"NGN",date:"2025-08-05"},{desc:"MTN — Monthly Internet",amt:25000,cur:"NGN",date:"2025-10-05"},{desc:"MTN — Monthly Internet",amt:25000,cur:"NGN",date:"2025-12-05"},{desc:"MTN Data — 1.5GB Daily",amt:600,cur:"NGN",date:"2025-01-02"},{desc:"MTN Data — 2.5GB 2-Day",amt:900,cur:"NGN",date:"2025-06-18"},{desc:"MTN Data — 1.5GB Daily",amt:600,cur:"NGN",date:"2025-08-10"},{desc:"MTN Data — 2.5GB 2-Day",amt:900,cur:"NGN",date:"2025-12-30"}]},
{id:11,name:"AWS / Vercel (side projects)",total:100000,count:12,avg:4167,size:6,color:"#a78bfa",
months:["2025-01","2025-02","2025-03","2025-04","2025-05","2025-06","2025-07","2025-08","2025-09","2025-10","2025-11","2025-12"],
txns:[{desc:"AWS — EC2 + S3",amt:3800,cur:"NGN",date:"2025-01-15"},{desc:"Vercel — Pro Plan",amt:4200,cur:"NGN",date:"2025-04-01"},{desc:"AWS — Lambda + RDS",amt:5200,cur:"NGN",date:"2025-07-01"},{desc:"Vercel — Pro Plan",amt:4200,cur:"NGN",date:"2025-10-01"}]},
{id:12,name:"Mum",total:158000,count:9,avg:17556,size:9,color:"#fbbf24",
months:["2025-01","2025-03","2025-05","2025-07","2025-08","2025-09","2025-11"],
txns:[{desc:"Transfer from Mum",amt:-15000,cur:"NGN",date:"2025-01-08"},{desc:"Transfer from Mum",amt:-15000,cur:"NGN",date:"2025-03-12"},{desc:"Transfer from Mum",amt:-20000,cur:"NGN",date:"2025-05-10"},{desc:"Transfer from Mum",amt:-30000,cur:"NGN",date:"2025-07-14"},{desc:"Transfer from Mum",amt:-25000,cur:"NGN",date:"2025-08-05"},{desc:"Transfer from Mum",amt:-15000,cur:"NGN",date:"2025-09-15"},{desc:"Transfer to Mum — Christmas",amt:50000,cur:"NGN",date:"2025-11-28"}]},
{id:13,name:"Dad",total:172000,count:8,avg:21500,size:9,color:"#fb923c",
months:["2025-02","2025-04","2025-06","2025-07","2025-09","2025-12"],
txns:[{desc:"Transfer from Dad",amt:-20000,cur:"NGN",date:"2025-02-14"},{desc:"Transfer from Dad",amt:-18000,cur:"NGN",date:"2025-04-10"},{desc:"Transfer from Dad",amt:-15000,cur:"NGN",date:"2025-06-08"},{desc:"Transfer from Dad",amt:-35000,cur:"NGN",date:"2025-07-20"},{desc:"Transfer from Dad",amt:-25000,cur:"NGN",date:"2025-07-28"},{desc:"Transfer from Dad",amt:-15000,cur:"NGN",date:"2025-09-22"},{desc:"Transfer to Dad — Christmas",amt:60000,cur:"NGN",date:"2025-12-22"}]},
{id:14,name:"Sister",total:20000,count:1,avg:20000,size:5,color:"#f0abfc",
months:["2025-08"],
txns:[{desc:"Transfer from Sister",amt:-20000,cur:"NGN",date:"2025-08-03"}]},
{id:15,name:"David (best friend)",total:128000,count:22,avg:5818,size:8,color:"#38bdf8",
months:["2025-01","2025-02","2025-03","2025-04","2025-05","2025-06","2025-07","2025-08","2025-09","2025-10","2025-11","2025-12"],
txns:[{desc:"Transfer to David — split bills",amt:4000,cur:"NGN",date:"2025-01-20"},{desc:"Transfer to David — food",amt:3000,cur:"NGN",date:"2025-04-08"},{desc:"Transfer from David",amt:-30000,cur:"NGN",date:"2025-07-22"},{desc:"Transfer to David — birthday",amt:15000,cur:"NGN",date:"2025-09-14"},{desc:"Transfer to David",amt:10000,cur:"NGN",date:"2025-11-30"},{desc:"Transfer to David — Christmas",amt:8000,cur:"NGN",date:"2025-12-25"}]},
{id:16,name:"Girlfriend",total:670000,count:8,avg:83750,size:10,color:"#fb7185",
months:["2025-02","2025-06","2025-08","2025-10","2025-11","2025-12"],
txns:[{desc:"Transfer — Valentine's",amt:18000,cur:"NGN",date:"2025-02-14"},{desc:"Transfer — birthday",amt:30000,cur:"NGN",date:"2025-06-22"},{desc:"Transfer",amt:40000,cur:"NGN",date:"2025-08-15"},{desc:"Transfer",amt:550000,cur:"NGN",date:"2025-10-28"},{desc:"Transfer",amt:15000,cur:"NGN",date:"2025-11-10"},{desc:"Transfer — Christmas",amt:17000,cur:"NGN",date:"2025-12-24"}]},
{id:17,name:"Gym (pay-per-visit)",total:156000,count:36,avg:2167,size:8,color:"#67e8f9",
months:["2025-01","2025-02","2025-03","2025-04","2025-05","2025-06"],
txns:[{desc:"Gym — Day pass",amt:2500,cur:"NGN",date:"2025-01-07"},{desc:"Gym — Day pass",amt:2500,cur:"NGN",date:"2025-01-14"},{desc:"Gym — Day pass",amt:2000,cur:"NGN",date:"2025-02-03"},{desc:"Gym — Day pass",amt:2500,cur:"NGN",date:"2025-02-18"},{desc:"Gym — Day pass",amt:2000,cur:"NGN",date:"2025-03-10"},{desc:"Gym — Day pass",amt:2500,cur:"NGN",date:"2025-03-14"},{desc:"Gym — Day pass",amt:2000,cur:"NGN",date:"2025-03-21"},{desc:"Gym — Day pass",amt:2500,cur:"NGN",date:"2025-04-01"},{desc:"Gym — Day pass",amt:2000,cur:"NGN",date:"2025-05-05"},{desc:"Gym — Day pass",amt:2500,cur:"NGN",date:"2025-05-22"},{desc:"Gym — Day pass",amt:2000,cur:"NGN",date:"2025-06-09"},{desc:"Gym — Day pass",amt:2500,cur:"NGN",date:"2025-06-18"}]},
{id:18,name:"Laguru Gym (membership)",total:180000,count:3,avg:30000,size:8,color:"#06b6d4",
months:["2025-10","2025-11","2025-12"],
txns:[{desc:"Laguru Gym — Monthly membership",amt:30000,cur:"NGN",date:"2025-10-06"},{desc:"Laguru Gym — Monthly membership",amt:30000,cur:"NGN",date:"2025-11-04"},{desc:"Laguru Gym — Monthly membership",amt:30000,cur:"NGN",date:"2025-12-02"}]},
{id:19,name:"Pharmacy",total:284000,count:12,avg:11833,size:9,color:"#f87171",
months:["2025-07","2025-08","2025-09","2025-10","2025-11","2025-12"],
txns:[{desc:"MedPlus — Prescription",amt:18000,cur:"NGN",date:"2025-07-20"},{desc:"MedPlus — Refill",amt:15000,cur:"NGN",date:"2025-08-03"},{desc:"MedPlus — Refill",amt:12000,cur:"NGN",date:"2025-08-17"},{desc:"MedPlus — Refill",amt:12000,cur:"NGN",date:"2025-08-31"},{desc:"MedPlus — Refill",amt:8000,cur:"NGN",date:"2025-09-15"},{desc:"MedPlus — Refill",amt:6000,cur:"NGN",date:"2025-10-20"},{desc:"MedPlus",amt:3500,cur:"NGN",date:"2025-11-18"},{desc:"MedPlus",amt:3000,cur:"NGN",date:"2025-12-15"}]},
{id:20,name:"Hospital / Clinic",total:370000,count:5,avg:37000,size:10,color:"#ef4444",
months:["2025-07","2025-08","2025-09"],
txns:[{desc:"National Hospital Abuja — Emergency",amt:85000,cur:"NGN",date:"2025-07-19"},{desc:"National Hospital — Follow-up",amt:35000,cur:"NGN",date:"2025-08-02"},{desc:"Lab Tests — Blood work panel",amt:25000,cur:"NGN",date:"2025-08-10"},{desc:"National Hospital — Consultation",amt:25000,cur:"NGN",date:"2025-08-28"},{desc:"National Hospital — Final checkup",amt:15000,cur:"NGN",date:"2025-09-22"}]},
{id:21,name:"Shoprite (groceries)",total:572000,count:40,avg:7150,size:10,color:"#4ade80",
months:["2025-01","2025-02","2025-03","2025-04","2025-05","2025-06","2025-07","2025-08","2025-09","2025-10","2025-11","2025-12"],
txns:[{desc:"Shoprite Jabi — Weekly shop",amt:4200,cur:"NGN",date:"2025-01-11"},{desc:"Shoprite Jabi — Weekly",amt:5800,cur:"NGN",date:"2025-03-22"},{desc:"Shoprite Jabi — Big shop",amt:11000,cur:"NGN",date:"2025-05-10"},{desc:"Shoprite Jabi — Weekly",amt:9000,cur:"NGN",date:"2025-06-15"},{desc:"Shoprite Jabi — Full restock",amt:15000,cur:"NGN",date:"2025-10-12"},{desc:"Shoprite Jabi — Weekly",amt:14000,cur:"NGN",date:"2025-11-16"},{desc:"Shoprite Jabi — Christmas shop",amt:24000,cur:"NGN",date:"2025-12-21"}]},
{id:22,name:"Udemy (online courses)",total:68000,count:4,avg:8500,size:6,color:"#c084fc",
months:["2025-05","2025-06"],
txns:[{desc:"Udemy — Advanced React Patterns",amt:8500,cur:"NGN",date:"2025-05-06"},{desc:"Udemy — System Design Interviews",amt:9500,cur:"NGN",date:"2025-05-20"},{desc:"Udemy — Node.js Microservices",amt:8000,cur:"NGN",date:"2025-06-04"},{desc:"Udemy — AWS Solutions Architect",amt:8000,cur:"NGN",date:"2025-06-18"}]},
{id:23,name:"Suya (street food)",total:124000,count:23,avg:2696,size:7,color:"#d946ef",
months:["2025-01","2025-02","2025-03","2025-04","2025-05","2025-06","2025-08","2025-09","2025-10","2025-11","2025-12"],
txns:[{desc:"POS — Suya spot",amt:2500,cur:"NGN",date:"2025-01-17"},{desc:"POS — Suya spot",amt:3000,cur:"NGN",date:"2025-03-28"},{desc:"POS — Suya spot",amt:2000,cur:"NGN",date:"2025-06-20"},{desc:"POS — Suya spot",amt:2500,cur:"NGN",date:"2025-10-11"},{desc:"POS — Suya spot",amt:3000,cur:"NGN",date:"2025-12-27"}]},
{id:24,name:"Barber",total:76000,count:18,avg:2111,size:5,color:"#d4d4d8",
months:["2025-01","2025-02","2025-03","2025-04","2025-05","2025-06","2025-07","2025-08","2025-09","2025-10","2025-11","2025-12"],
txns:[{desc:"Barber — Haircut",amt:2000,cur:"NGN",date:"2025-01-18"},{desc:"Barber — Haircut",amt:2000,cur:"NGN",date:"2025-07-25"},{desc:"Barber — Haircut + lineup",amt:2500,cur:"NGN",date:"2025-10-05"},{desc:"Barber — Haircut",amt:2000,cur:"NGN",date:"2025-10-19"},{desc:"Barber — Haircut",amt:2000,cur:"NGN",date:"2025-12-28"}]},
{id:25,name:"Bamboo (investment)",total:1000000,count:2,avg:250000,size:11,color:"#6366f1",
months:["2025-12"],
txns:[{desc:"Bamboo — Stock portfolio deposit",amt:300000,cur:"NGN",date:"2025-12-08"},{desc:"Bamboo — Dollar Fund deposit",amt:200000,cur:"NGN",date:"2025-12-22"}]},
{id:26,name:"Electricity (AEDC)",total:100000,count:12,avg:8333,size:6,color:"#a3e635",
months:["2025-01","2025-02","2025-03","2025-04","2025-05","2025-06","2025-07","2025-08","2025-09","2025-10","2025-11","2025-12"],
txns:[{desc:"AEDC Prepaid — 48 kWh",amt:7500,cur:"NGN",date:"2025-01-08"},{desc:"AEDC Prepaid — 52 kWh",amt:8000,cur:"NGN",date:"2025-02-10"},{desc:"AEDC Prepaid — 55 kWh",amt:8500,cur:"NGN",date:"2025-04-14"},{desc:"AEDC Prepaid — 55 kWh",amt:8500,cur:"NGN",date:"2025-06-14"},{desc:"AEDC Prepaid — 50 kWh",amt:7800,cur:"NGN",date:"2025-08-12"},{desc:"AEDC Prepaid — 60 kWh",amt:9000,cur:"NGN",date:"2025-10-08"},{desc:"AEDC Prepaid — 58 kWh",amt:8800,cur:"NGN",date:"2025-12-01"}]},
{id:27,name:"Business / Investments",total:6800000,count:18,avg:377778,size:16,color:"#14b8a6",
months:["2025-01","2025-02","2025-03","2025-04","2025-05","2025-06","2025-07","2025-08","2025-09"],
txns:[{desc:"Crypto — BTC trade profit",amt:-180000,cur:"NGN",date:"2025-01-15"},{desc:"Crypto — ETH swing trade",amt:-95000,cur:"NGN",date:"2025-01-28"},{desc:"Forex — USD/NGN position close",amt:-240000,cur:"NGN",date:"2025-02-10"},{desc:"Crypto — SOL trade profit",amt:-150000,cur:"NGN",date:"2025-03-05"},{desc:"Forex — GBP/NGN profit",amt:-180000,cur:"NGN",date:"2025-03-22"},{desc:"Resale — Electronics flip",amt:-120000,cur:"NGN",date:"2025-04-08"},{desc:"Crypto — BTC position close",amt:-210000,cur:"NGN",date:"2025-05-12"},{desc:"Forex — EUR/NGN close",amt:-85000,cur:"NGN",date:"2025-05-28"},{desc:"Resale — Phone bulk deal",amt:-160000,cur:"NGN",date:"2025-06-15"},{desc:"Crypto — Altcoin profit",amt:-75000,cur:"NGN",date:"2025-07-03"},{desc:"Forex — USD/NGN small trade",amt:-55000,cur:"NGN",date:"2025-07-20"},{desc:"Crypto — BTC trade",amt:-90000,cur:"NGN",date:"2025-08-08"},{desc:"Forex — Final position (pre-illness)",amt:-60000,cur:"NGN",date:"2025-09-02"}]}
];
const edgesData=[];
for(let i=0;i<nodesData.length;i++)for(let j=i+1;j<nodesData.length;j++){
const s=nodesData[i].months.filter(m=>nodesData[j].months.includes(m)).length;
if(s>=3)edgesData.push({source:i,target:j,weight:s});
}
const months=["2025-01","2025-02","2025-03","2025-04","2025-05","2025-06","2025-07","2025-08","2025-09","2025-10","2025-11","2025-12"];
const curSym="₦",totalMonths=months.length;
let currentSliderVal=totalMonths-1,playing=false,playInterval=null;
function fmtAmt(v){return curSym+Math.abs(v).toLocaleString(undefined,{maximumFractionDigits:0});}
const graphNodes=nodesData.map(n=>({...n,__opacity:1.0}));
const graphLinks=edgesData.map(e=>({source:e.source,target:e.target,weight:e.weight}));
const container=document.getElementById('graph-container');
const Graph=ForceGraph3D()(container)
.width(window.innerWidth<=768?window.innerWidth:container.clientWidth)
.height(window.innerWidth<=768?window.innerHeight:container.clientHeight)
.backgroundColor('#06080c').showNavInfo(false)
.graphData({nodes:graphNodes,links:graphLinks}).nodeId('id')
.nodeVal(n=>n.size*n.size*2)
.nodeLabel(n=>`<div style="background:rgba(6,8,12,0.92);padding:8px 14px;border-radius:6px;font:12px 'DM Sans',sans-serif;color:#e8ecf4;pointer-events:none;line-height:1.5;border:1px solid rgba(255,255,255,0.06);"><b style="color:${n.color}">${n.name}</b><br><span style="color:#9ca3b8">${fmtAmt(n.total)} · ${n.count} txns</span></div>`)
.nodeThreeObject(n=>{
const g=new THREE.Group(),op=n.__opacity||1;
g.add(new THREE.Mesh(new THREE.SphereGeometry(n.size*0.6,32,32),new THREE.MeshPhongMaterial({color:n.color,transparent:op<1,opacity:op,shininess:90,emissive:n.color,emissiveIntensity:0.5})));
g.add(new THREE.Mesh(new THREE.SphereGeometry(n.size*0.85,32,32),new THREE.MeshBasicMaterial({color:n.color,transparent:true,opacity:0.12*op})));
g.add(new THREE.Mesh(new THREE.SphereGeometry(n.size*1.2,32,32),new THREE.MeshBasicMaterial({color:n.color,transparent:true,opacity:0.04*op})));
const c=document.createElement('canvas'),x=c.getContext('2d');c.width=1024;c.height=128;
x.font='600 34px DM Sans,sans-serif';x.fillStyle=`rgba(232,236,244,${0.85*op})`;x.textAlign='center';
x.fillText(n.name.length>24?n.name.slice(0,22)+'..':n.name,512,45);
x.font='400 22px Space Mono,monospace';x.fillStyle=`rgba(156,163,184,${0.6*op})`;
x.fillText(fmtAmt(n.total),512,85);
const s=new THREE.Sprite(new THREE.SpriteMaterial({map:new THREE.CanvasTexture(c),transparent:true,depthWrite:false}));
s.scale.set(n.size*3.2,n.size*0.4,1);s.position.y=n.size*0.75;g.add(s);return g;
})
.linkWidth(l=>0.2+Math.sqrt(l.weight)*0.25)
.linkColor(()=>'rgba(255,255,255,0.3)').linkOpacity(0.3)
.onNodeClick(n=>showDetail(n))
.onNodeHover(n=>{if(n)showSummary(n);container.style.cursor=n?'pointer':'default';})
.d3AlphaDecay(0.015).d3VelocityDecay(0.25).warmupTicks(200).cooldownTicks(400);
Graph.d3Force('charge').strength(-1400).distanceMax(900);
Graph.d3Force('link').distance(160).strength(0.08);
const sc=Graph.scene();sc.add(new THREE.AmbientLight(0xffffff,0.7));
const dl=new THREE.DirectionalLight(0xffffff,0.7);dl.position.set(80,200,300);sc.add(dl);
const isMobile=window.innerWidth<=768;
const startZ=isMobile?1600:450;
// Set camera immediately — warmupTicks(200) means nodes are already positioned
Graph.cameraPosition({x:0,y:0,z:startZ},{x:0,y:0,z:0},0);
// Recenter nodes when simulation finishes (fine-tune after settle)
Graph.onEngineStop(()=>{
let cx=0,cy=0,cz=0;
graphNodes.forEach(n=>{cx+=n.x||0;cy+=n.y||0;cz+=n.z||0;});
cx/=graphNodes.length;cy/=graphNodes.length;cz/=graphNodes.length;
graphNodes.forEach(n=>{n.x-=cx;n.y-=cy;n.z-=cz;});
});
// Start orbit after a delay
setTimeout(()=>{orbitActive=true;},2500);
// Orbit: rotate the scene group, NOT the camera — avoids fighting OrbitControls
let orbitActive=false;
function orbit(){
if(orbitActive){
// Find the graph's object group in the scene and rotate it
sc.rotation.y+=0.0006;
}
requestAnimationFrame(orbit);
}
requestAnimationFrame(orbit);
// Node stepper
let currentNodeIdx=-1;
function stepNode(dir){
currentNodeIdx+=dir;
if(currentNodeIdx>=graphNodes.length)currentNodeIdx=0;
if(currentNodeIdx<0)currentNodeIdx=graphNodes.length-1;
const node=graphNodes[currentNodeIdx];
document.getElementById('node-counter').textContent=(currentNodeIdx+1)+'/'+graphNodes.length;
// Update mobile nav
const mobName=document.getElementById('mob-name');
const mobAmt=document.getElementById('mob-amt');
if(mobName){mobName.textContent=node.name;}
if(mobAmt){mobAmt.textContent=fmtAmt(node.total);}
showDetail(node);
orbitActive=false;
clearTimeout(window._orbitTimer);
window._orbitTimer=setTimeout(()=>{syncAngleFromCamera();orbitActive=true;},8000);
}
function pauseOrbit(){
orbitActive=false;
clearTimeout(window._orbitTimer);
window._orbitTimer=setTimeout(()=>{orbitActive=true;},5000);
}
container.addEventListener('mousedown',pauseOrbit);
container.addEventListener('touchstart',pauseOrbit,{passive:true});
container.addEventListener('wheel',pauseOrbit);
window.addEventListener('resize',()=>{if(window.innerWidth<=768){Graph.width(window.innerWidth);Graph.height(window.innerHeight);}});
function showSummary(n){document.getElementById('cluster-card').innerHTML=`<div class="amount">${fmtAmt(n.total)}</div><div class="name">${n.name}</div><div class="meta">${n.count} transactions · Avg ${fmtAmt(n.avg)}<br>Active ${n.months.length} of ${totalMonths} months</div>`;document.getElementById('txn-list').innerHTML='';}
function showDetail(n){showSummary(n);const sorted=[...n.txns].sort((a,b)=>Math.abs(b.amt)-Math.abs(a.amt));document.getElementById('txn-list').innerHTML=sorted.map(t=>`<div class="txn-item"><span class="txn-date">${t.date.slice(5)}</span><span class="txn-desc">${t.desc}</span><span class="txn-amt ${t.amt<0?'credit':'debit'}">${t.amt<0?'+':''}${fmtAmt(t.amt)}</span></div>`).join('');const d=120;Graph.cameraPosition({x:n.x+d*0.6,y:n.y+d*0.4,z:n.z+d},{x:n.x,y:n.y,z:n.z},1200);}
function resetView(){const rz=window.innerWidth<=768?1600:450;Graph.cameraPosition({x:0,y:0,z:rz},{x:0,y:0,z:0},1200);document.getElementById('cluster-card').innerHTML='<div class="placeholder">click a node to explore</div>';document.getElementById('txn-list').innerHTML='';currentSliderVal=totalMonths-1;slider.value=currentSliderVal;graphNodes.forEach(n=>{n.__opacity=1;});Graph.nodeThreeObject(Graph.nodeThreeObject());document.getElementById('time-label').textContent='All time';}
const slider=document.getElementById('time-slider');slider.max=totalMonths-1;slider.value=totalMonths-1;
slider.addEventListener('input',function(){currentSliderVal=parseInt(this.value);updateTime();});
function updateTime(){const m=months[currentSliderVal];document.getElementById('time-label').textContent=currentSliderVal>=totalMonths-1?'All time':m;graphNodes.forEach(n=>{n.__opacity=currentSliderVal>=totalMonths-1?1:n.months.includes(m)?1.0:0.04;});Graph.nodeThreeObject(Graph.nodeThreeObject());}
function togglePlay(){playing=!playing;const btn=document.getElementById('btn-play');if(playing){btn.textContent='Pause';btn.classList.add('active');if(currentSliderVal>=totalMonths-1)currentSliderVal=0;playInterval=setInterval(()=>{currentSliderVal++;if(currentSliderVal>=totalMonths){currentSliderVal=totalMonths-1;togglePlay();return;}slider.value=currentSliderVal;updateTime();},800);}else{btn.textContent='Play';btn.classList.remove('active');clearInterval(playInterval);}}
</script>
</body>
</html>