bubble-chart-js 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,739 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>bubble-chart-js V2 Demo</title>
8
+ <style>
9
+ *,
10
+ *::before,
11
+ *::after {
12
+ box-sizing: border-box;
13
+ margin: 0;
14
+ padding: 0;
15
+ }
16
+
17
+ body {
18
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
19
+ background: #0f1117;
20
+ color: #e2e8f0;
21
+ min-height: 100vh;
22
+ padding: 2rem;
23
+ }
24
+
25
+ header {
26
+ text-align: center;
27
+ margin-bottom: 2rem;
28
+ }
29
+
30
+ header h1 {
31
+ font-size: 1.8rem;
32
+ font-weight: 700;
33
+ letter-spacing: -0.02em;
34
+ }
35
+
36
+ header p {
37
+ color: #64748b;
38
+ margin-top: .4rem;
39
+ font-size: .9rem;
40
+ }
41
+
42
+ header code {
43
+ background: #1e2432;
44
+ border-radius: 4px;
45
+ padding: 2px 6px;
46
+ font-family: "Fira Code", monospace;
47
+ font-size: .85rem;
48
+ color: #7dd3fc;
49
+ }
50
+
51
+ .grid {
52
+ display: grid;
53
+ grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
54
+ gap: 1.5rem;
55
+ max-width: 1200px;
56
+ margin: 0 auto;
57
+ }
58
+
59
+ .card {
60
+ background: #1a1f2e;
61
+ border: 1px solid #2d3748;
62
+ border-radius: 12px;
63
+ overflow: hidden;
64
+ }
65
+
66
+ .card-header {
67
+ padding: .75rem 1rem;
68
+ border-bottom: 1px solid #2d3748;
69
+ display: flex;
70
+ align-items: center;
71
+ justify-content: space-between;
72
+ }
73
+
74
+ .card-header h2 {
75
+ font-size: .9rem;
76
+ font-weight: 600;
77
+ }
78
+
79
+ .badge {
80
+ font-size: .7rem;
81
+ font-weight: 600;
82
+ padding: 2px 8px;
83
+ border-radius: 20px;
84
+ letter-spacing: .04em;
85
+ text-transform: uppercase;
86
+ }
87
+
88
+ .badge-svg {
89
+ background: #1a3a5c;
90
+ color: #7dd3fc;
91
+ }
92
+
93
+ .badge-canvas {
94
+ background: #3a1a5c;
95
+ color: #c4b5fd;
96
+ }
97
+
98
+ .badge-glass {
99
+ background: #1a3a2a;
100
+ color: #6ee7b7;
101
+ }
102
+
103
+ .badge-physics {
104
+ background: #3a2a1a;
105
+ color: #fbbf24;
106
+ }
107
+
108
+ .chart-wrap {
109
+ position: relative;
110
+ height: 320px;
111
+ }
112
+
113
+ .controls {
114
+ padding: .75rem 1rem;
115
+ border-top: 1px solid #2d3748;
116
+ display: flex;
117
+ gap: .5rem;
118
+ flex-wrap: wrap;
119
+ align-items: center;
120
+ }
121
+
122
+ button {
123
+ background: #2d3748;
124
+ border: 1px solid #4a5568;
125
+ color: #e2e8f0;
126
+ border-radius: 6px;
127
+ padding: .35rem .75rem;
128
+ font-size: .8rem;
129
+ cursor: pointer;
130
+ transition: background .15s;
131
+ }
132
+
133
+ button:hover {
134
+ background: #374151;
135
+ }
136
+
137
+ .stat-row {
138
+ padding: .5rem 1rem;
139
+ border-top: 1px solid #2d3748;
140
+ font-size: .75rem;
141
+ color: #64748b;
142
+ font-family: "Fira Code", monospace;
143
+ min-height: 28px;
144
+ }
145
+
146
+ /* Wide demo */
147
+ .full-width {
148
+ grid-column: 1 / -1;
149
+ }
150
+
151
+ .full-width .chart-wrap {
152
+ height: 360px;
153
+ }
154
+
155
+ /* topN demo table */
156
+ .topn-demo {
157
+ padding: 1rem;
158
+ font-size: .8rem;
159
+ font-family: "Fira Code", monospace;
160
+ }
161
+
162
+ .topn-demo table {
163
+ width: 100%;
164
+ border-collapse: collapse;
165
+ }
166
+
167
+ .topn-demo th,
168
+ .topn-demo td {
169
+ text-align: left;
170
+ padding: .3rem .5rem;
171
+ border-bottom: 1px solid #2d3748;
172
+ }
173
+
174
+ .topn-demo th {
175
+ color: #64748b;
176
+ font-weight: 600;
177
+ font-size: .7rem;
178
+ text-transform: uppercase;
179
+ }
180
+
181
+ .dot {
182
+ width: 10px;
183
+ height: 10px;
184
+ border-radius: 50%;
185
+ display: inline-block;
186
+ margin-right: 6px;
187
+ }
188
+
189
+ .slider-controls {
190
+ padding: .6rem 1rem .75rem;
191
+ border-top: 1px solid #2d3748;
192
+ display: flex;
193
+ flex-direction: column;
194
+ gap: .55rem;
195
+ }
196
+
197
+ .slider-row {
198
+ display: grid;
199
+ grid-template-columns: 1fr auto;
200
+ align-items: center;
201
+ gap: .5rem 1rem;
202
+ }
203
+
204
+ .slider-row label {
205
+ font-size: .75rem;
206
+ color: #94a3b8;
207
+ white-space: nowrap;
208
+ display: flex;
209
+ align-items: center;
210
+ gap: .4rem;
211
+ }
212
+
213
+ .slider-row label .val {
214
+ color: #e2e8f0;
215
+ font-family: "Fira Code", monospace;
216
+ min-width: 2.8rem;
217
+ text-align: right;
218
+ }
219
+
220
+ .slider-row label small {
221
+ color: #475569;
222
+ font-size: .68rem;
223
+ }
224
+
225
+ .slider-row input[type=range] {
226
+ -webkit-appearance: none;
227
+ appearance: none;
228
+ width: 100%;
229
+ height: 4px;
230
+ border-radius: 2px;
231
+ background: #2d3748;
232
+ outline: none;
233
+ cursor: pointer;
234
+ grid-column: 1 / -1;
235
+ }
236
+
237
+ .slider-row input[type=range]::-webkit-slider-thumb {
238
+ -webkit-appearance: none;
239
+ appearance: none;
240
+ width: 14px;
241
+ height: 14px;
242
+ border-radius: 50%;
243
+ background: #6ee7b7;
244
+ cursor: pointer;
245
+ border: 2px solid #1a1f2e;
246
+ transition: background .15s;
247
+ }
248
+
249
+ .slider-row input[type=range]::-webkit-slider-thumb:hover {
250
+ background: #a7f3d0;
251
+ }
252
+
253
+ .slider-row input[type=range]:disabled {
254
+ opacity: .35;
255
+ cursor: not-allowed;
256
+ }
257
+
258
+ .slider-row input[type=range]:disabled::-webkit-slider-thumb {
259
+ background: #4a5568;
260
+ cursor: not-allowed;
261
+ }
262
+ </style>
263
+ </head>
264
+
265
+ <body>
266
+
267
+ <header>
268
+ <h1>bubble-chart-js <span style="color:#7dd3fc">V2</span></h1>
269
+ <p>Dual renderer · Physics layout · Layer hooks · Event bus · <code>topN()</code> · Glass theme</p>
270
+ </header>
271
+
272
+ <div class="grid">
273
+
274
+ <!-- 1. SVG flat (default) -->
275
+ <div class="card">
276
+ <div class="card-header">
277
+ <h2>SVG Renderer — Flat Theme</h2>
278
+ <span class="badge badge-svg">SVG · auto</span>
279
+ </div>
280
+ <div class="chart-wrap" id="chart-svg-flat"></div>
281
+ <div class="controls">
282
+ <button onclick="addItem('svg-flat')">+ Add bubble</button>
283
+ <button onclick="removeItem('svg-flat')">− Remove</button>
284
+ <button onclick="shuffleValues('svg-flat')">⟳ Shuffle values</button>
285
+ </div>
286
+ <div class="stat-row" id="stat-svg-flat">hover a bubble…</div>
287
+ </div>
288
+
289
+ <!-- 2. SVG glass -->
290
+ <div class="card">
291
+ <div class="card-header">
292
+ <h2>SVG Renderer — Glass Theme</h2>
293
+ <span class="badge badge-glass" id="badge-glass">SVG · GLASS · SAFE</span>
294
+ </div>
295
+ <div class="chart-wrap" id="chart-svg-glass"></div>
296
+ <div class="controls">
297
+ <button onclick="toggleGlassHint()" id="btn-glass-hint">Switch to full glass</button>
298
+ </div>
299
+ <div class="slider-controls">
300
+ <div class="slider-row">
301
+ <label>
302
+ Glow Intensity
303
+ <span class="val" id="val-glow">0.65</span>
304
+ </label>
305
+ <input type="range" id="slider-glow" min="0" max="1" step="0.05" value="0.65" oninput="onGlassSlider()">
306
+ </div>
307
+ <div class="slider-row">
308
+ <label>
309
+ Blur Radius
310
+ <span class="val" id="val-blur">12</span>
311
+ <small>(full mode only)</small>
312
+ </label>
313
+ <input type="range" id="slider-blur" min="4" max="24" step="1" value="12" id="slider-blur" oninput="onGlassSlider()" disabled>
314
+ </div>
315
+ </div>
316
+ <div class="stat-row" id="stat-svg-glass">glassPerformanceHint: "safe"</div>
317
+ </div>
318
+
319
+ <!-- 3. Physics layout -->
320
+ <div class="card">
321
+ <div class="card-header">
322
+ <h2>Physics Layout (static → physics)</h2>
323
+ <span class="badge badge-physics">physics · SVG</span>
324
+ </div>
325
+ <div class="chart-wrap" id="chart-physics"></div>
326
+ <div class="controls">
327
+ <button onclick="resetPhysics()">↺ Reset simulation</button>
328
+ <button onclick="addPhysicsBubble()">+ Bubble</button>
329
+ </div>
330
+ <div class="stat-row" id="stat-physics">alpha: — · ticks: —</div>
331
+ </div>
332
+
333
+ <!-- 4. Canvas (25+ items) -->
334
+ <div class="card">
335
+ <div class="card-header">
336
+ <h2>Canvas Renderer (8 items → auto)</h2>
337
+ <span class="badge badge-canvas">Canvas · auto</span>
338
+ </div>
339
+ <div class="chart-wrap" id="chart-canvas"></div>
340
+ <div class="controls">
341
+ <button onclick="chart_canvas.update(generateCanvasData(8))">8 items</button>
342
+ <button onclick="chart_canvas.update(generateCanvasData(50))">50 items</button>
343
+ <button onclick="chart_canvas.update(generateCanvasData(100))">100 items</button>
344
+ </div>
345
+ <div class="stat-row" id="stat-canvas">—</div>
346
+ </div>
347
+
348
+ <!-- 5. layer hooks demo (full width) -->
349
+ <div class="card full-width">
350
+ <div class="card-header">
351
+ <h2>Layer Hooks — Custom overlay drawn after built-in bubble pass</h2>
352
+ <span class="badge badge-svg">SVG · hooks</span>
353
+ </div>
354
+ <div class="chart-wrap" id="chart-hooks"></div>
355
+ <div class="controls">
356
+ <button onclick="toggleHook()">Toggle custom overlay hook</button>
357
+ <button onclick="replaceBuiltin()">Replace builtin:bubbles</button>
358
+ <button onclick="restoreBuiltin()">Restore builtin:bubbles</button>
359
+ </div>
360
+ <div class="stat-row" id="stat-hooks">—</div>
361
+ </div>
362
+
363
+ </div>
364
+
365
+ <!-- Load the V2 UMD bundle -->
366
+ <script src="../dist/bubbleChart.umd.js"></script>
367
+ <script>
368
+ // ────────────────────────────────────────────────────────────────────────────
369
+ // Shared helpers
370
+ // ────────────────────────────────────────────────────────────────────────────
371
+
372
+ const COLORS = [
373
+ '#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6',
374
+ '#06B6D4', '#F97316', '#14B8A6', '#EC4899', '#6366F1',
375
+ '#84CC16', '#0EA5E9', '#A855F7', '#FB923C', '#22C55E',
376
+ ];
377
+
378
+ let idCounter = 1000;
379
+ function uid() { return `id_${idCounter++}`; }
380
+
381
+ function rndValue(min = 10, max = 200) {
382
+ return Math.floor(Math.random() * (max - min)) + min;
383
+ }
384
+
385
+ // ────────────────────────────────────────────────────────────────────────────
386
+ // 1. SVG Flat demo
387
+ // ────────────────────────────────────────────────────────────────────────────
388
+
389
+ let svgFlatData = [
390
+ { id: 'rev', label: 'Revenue', value: 280, bubbleColor: '#3B82F6' },
391
+ { id: 'cst', label: 'Customers', value: 195, bubbleColor: '#10B981' },
392
+ { id: 'mrk', label: 'Marketing', value: 140, bubbleColor: '#F59E0B' },
393
+ { id: 'eng', label: 'Engineering', value: 210, bubbleColor: '#8B5CF6' },
394
+ { id: 'sup', label: 'Support', value: 90, bubbleColor: '#06B6D4' },
395
+ { id: 'hr', label: 'HR', value: 60, bubbleColor: '#EC4899' },
396
+ ];
397
+
398
+ const chart_svg_flat = initializeChart({
399
+ canvasContainerId: 'chart-svg-flat',
400
+ data: svgFlatData,
401
+ render: { mode: 'auto', theme: 'flat' },
402
+ layout: { type: 'static' },
403
+ interaction: { hoverScale: 1.08 },
404
+ showToolTip: true,
405
+ defaultFontColor: '#ffffff',
406
+ defaultFontFamily: 'system-ui',
407
+ fontSize: 13,
408
+ });
409
+
410
+ chart_svg_flat.on('bubble:hover', item => {
411
+ const el = document.getElementById('stat-svg-flat');
412
+ el.textContent = item
413
+ ? `hovered: ${item.label} · value: ${item.value} · id: ${item.id}`
414
+ : 'hover a bubble…';
415
+ });
416
+
417
+ chart_svg_flat.on('bubble:click', ({ item }) => {
418
+ alert(`Clicked: ${item.label} (${item.value})`);
419
+ });
420
+
421
+ function addItem(key) {
422
+ if (key !== 'svg-flat') return;
423
+ const label = ['Sales', 'Ops', 'Finance', 'Legal', 'R&D', 'QA', 'DevRel'][Math.floor(Math.random() * 7)];
424
+ svgFlatData = topN([
425
+ ...svgFlatData,
426
+ { id: uid(), label, value: rndValue(), bubbleColor: COLORS[svgFlatData.length % COLORS.length] }
427
+ ], 25);
428
+ chart_svg_flat.update(svgFlatData);
429
+ }
430
+
431
+ function removeItem(key) {
432
+ if (key !== 'svg-flat' || svgFlatData.length < 2) return;
433
+ svgFlatData = svgFlatData.slice(0, -1);
434
+ chart_svg_flat.update(svgFlatData);
435
+ }
436
+
437
+ function shuffleValues(key) {
438
+ if (key !== 'svg-flat') return;
439
+ svgFlatData = svgFlatData.map(d => ({ ...d, value: rndValue(30, 300) }));
440
+ chart_svg_flat.update(svgFlatData);
441
+ }
442
+
443
+ // ────────────────────────────────────────────────────────────────────────────
444
+ // 2. SVG Glass demo
445
+ // ────────────────────────────────────────────────────────────────────────────
446
+
447
+ const glassData = [
448
+ { id: 'a', label: 'Growth', value: 340, bubbleColor: '#6366F1' },
449
+ { id: 'b', label: 'Profit', value: 240, bubbleColor: '#8B5CF6' },
450
+ { id: 'c', label: 'Users', value: 180, bubbleColor: '#A855F7' },
451
+ { id: 'd', label: 'Churn', value: 90, bubbleColor: '#EC4899' },
452
+ { id: 'e', label: 'NPS', value: 130, bubbleColor: '#3B82F6' },
453
+ ];
454
+
455
+ let glassHint = 'safe';
456
+ let chart_glass_instance = null;
457
+
458
+ function reinitGlass() {
459
+ const intensity = parseFloat(document.getElementById('slider-glow').value);
460
+ const blurRadius = parseInt(document.getElementById('slider-blur').value, 10);
461
+
462
+ if (chart_glass_instance) {
463
+ chart_glass_instance.destroy();
464
+ document.getElementById('chart-svg-glass').innerHTML = '';
465
+ }
466
+
467
+ chart_glass_instance = initializeChart({
468
+ canvasContainerId: 'chart-svg-glass',
469
+ data: glassData,
470
+ render: {
471
+ mode: 'svg',
472
+ theme: 'glass',
473
+ glassPerformanceHint: glassHint,
474
+ glassOptions: { glowIntensity: intensity, blurRadius },
475
+ },
476
+ layout: { type: 'static' },
477
+ defaultFontColor: '#ffffff',
478
+ defaultFontFamily: 'system-ui',
479
+ fontSize: 13,
480
+ showToolTip: true,
481
+ });
482
+
483
+ chart_glass_instance.on('bubble:hover', item => {
484
+ document.getElementById('stat-svg-glass').textContent =
485
+ item ? `${item.label}: ${item.value}` : glassStatText();
486
+ });
487
+
488
+ // Blur radius slider only has effect in full mode
489
+ document.getElementById('slider-blur').disabled = (glassHint === 'safe');
490
+
491
+ document.getElementById('badge-glass').textContent =
492
+ `SVG · GLASS · ${glassHint.toUpperCase()}`;
493
+ document.getElementById('btn-glass-hint').textContent =
494
+ glassHint === 'safe' ? 'Switch to full glass' : 'Switch to safe glass';
495
+ document.getElementById('stat-svg-glass').textContent = glassStatText();
496
+ }
497
+
498
+ function glassStatText() {
499
+ const intensity = document.getElementById('slider-glow').value;
500
+ const blurRadius = document.getElementById('slider-blur').value;
501
+ return glassHint === 'safe'
502
+ ? `safe · glowIntensity: ${intensity} (CSS drop-shadow, no feGaussianBlur)`
503
+ : `full · glowIntensity: ${intensity} · blurRadius: ${blurRadius} (feGaussianBlur)`;
504
+ }
505
+
506
+ function toggleGlassHint() {
507
+ glassHint = glassHint === 'safe' ? 'full' : 'safe';
508
+ reinitGlass();
509
+ }
510
+
511
+ function onGlassSlider() {
512
+ document.getElementById('val-glow').textContent =
513
+ parseFloat(document.getElementById('slider-glow').value).toFixed(2);
514
+ document.getElementById('val-blur').textContent =
515
+ document.getElementById('slider-blur').value;
516
+ reinitGlass();
517
+ }
518
+
519
+ // Initial render
520
+ reinitGlass();
521
+
522
+ // ────────────────────────────────────────────────────────────────────────────
523
+ // 3. Physics layout demo
524
+ // ────────────────────────────────────────────────────────────────────────────
525
+
526
+ let physicsData = [
527
+ { id: 'p1', label: 'Alpha', value: 300, bubbleColor: '#F59E0B' },
528
+ { id: 'p2', label: 'Beta', value: 200, bubbleColor: '#EF4444' },
529
+ { id: 'p3', label: 'Gamma', value: 150, bubbleColor: '#10B981' },
530
+ { id: 'p4', label: 'Delta', value: 100, bubbleColor: '#3B82F6' },
531
+ { id: 'p5', label: 'Epsilon', value: 70, bubbleColor: '#8B5CF6' },
532
+ ];
533
+
534
+ const chart_physics = initializeChart({
535
+ canvasContainerId: 'chart-physics',
536
+ data: physicsData,
537
+ render: { mode: 'svg', theme: 'flat' },
538
+ layout: {
539
+ type: 'physics',
540
+ physics: { seed: 42, centerStrength: 0.015, velocityDecay: 0.80 }
541
+ },
542
+ defaultFontColor: '#ffffff',
543
+ defaultFontFamily: 'system-ui',
544
+ fontSize: 13,
545
+ showToolTip: true,
546
+ });
547
+
548
+ chart_physics.on('simulation:tick', snap => {
549
+ document.getElementById('stat-physics').textContent =
550
+ `alpha: ${snap.alpha.toFixed(4)} · ticks: ${snap.tickCount}`;
551
+ });
552
+ chart_physics.on('simulation:settled', snap => {
553
+ document.getElementById('stat-physics').textContent =
554
+ `settled ✓ · ticks: ${snap.tickCount}`;
555
+ });
556
+
557
+ function resetPhysics() {
558
+ physicsData = physicsData.map(d => ({ ...d, value: rndValue(50, 350) }));
559
+ chart_physics.update(physicsData);
560
+ }
561
+
562
+ function addPhysicsBubble() {
563
+ physicsData = [
564
+ ...physicsData,
565
+ { id: uid(), label: 'New', value: rndValue(40, 200), bubbleColor: COLORS[physicsData.length % COLORS.length] }
566
+ ];
567
+ chart_physics.update(physicsData);
568
+ }
569
+
570
+ // ────────────────────────────────────────────────────────────────────────────
571
+ // 4. Canvas (auto-resolved for 26+ items)
572
+ // ────────────────────────────────────────────────────────────────────────────
573
+
574
+ function generateCanvasData(n) {
575
+ return Array.from({ length: n }, (_, i) => ({
576
+ id: `item_${i}`,
577
+ label: `Item ${i + 1}`,
578
+ value: rndValue(10, 300),
579
+ bubbleColor: COLORS[i % COLORS.length],
580
+ }));
581
+ }
582
+
583
+ let chart_canvas;
584
+ (function () {
585
+ const data = generateCanvasData(8);
586
+ chart_canvas = initializeChart({
587
+ canvasContainerId: 'chart-canvas',
588
+ data,
589
+ render: { mode: 'auto', theme: 'flat' },
590
+ layout: { type: 'static' },
591
+ defaultFontColor: '#ffffff',
592
+ defaultFontFamily: 'system-ui',
593
+ fontSize: 11,
594
+ onRendererResolved: (resolved, reason) => {
595
+ document.getElementById('stat-canvas').textContent =
596
+ `renderer resolved to "${resolved}" (${reason}) for ${data.length} items`;
597
+ },
598
+ });
599
+ })();
600
+
601
+ // ────────────────────────────────────────────────────────────────────────────
602
+ // 5. Layer hooks demo
603
+ // ────────────────────────────────────────────────────────────────────────────
604
+
605
+ const hooksData = [
606
+ { id: 'h1', label: 'Core', value: 250, bubbleColor: '#3B82F6' },
607
+ { id: 'h2', label: 'Plugin', value: 180, bubbleColor: '#10B981' },
608
+ { id: 'h3', label: 'Hook', value: 130, bubbleColor: '#F59E0B' },
609
+ { id: 'h4', label: 'Custom', value: 90, bubbleColor: '#8B5CF6' },
610
+ { id: 'h5', label: 'Overlay', value: 60, bubbleColor: '#EC4899' },
611
+ ];
612
+
613
+ let chart_hooks = initializeChart({
614
+ canvasContainerId: 'chart-hooks',
615
+ data: hooksData,
616
+ render: { mode: 'svg', theme: 'flat' },
617
+ layout: { type: 'static' },
618
+ defaultFontColor: '#ffffff',
619
+ defaultFontFamily: 'system-ui',
620
+ fontSize: 13,
621
+ });
622
+
623
+ // Add a custom overlay hook: draws a pulsing ring around the largest bubble
624
+ let overlayHookId = null;
625
+ let customBuiltinHookId = null;
626
+
627
+ function toggleHook() {
628
+ if (overlayHookId) {
629
+ chart_hooks.removeLayerHook(overlayHookId);
630
+ overlayHookId = null;
631
+ document.getElementById('stat-hooks').textContent = 'overlay hook removed';
632
+ } else {
633
+ let phase = 0;
634
+ overlayHookId = chart_hooks.addLayerHook({
635
+ layer: 'overlay',
636
+ priority: 1,
637
+ fn: ({ type, svg }, bubbles) => {
638
+ if (type !== 'svg' || !svg) return;
639
+ // Find the largest bubble by renderRadius
640
+ const largest = [...bubbles].sort((a, b) => b.renderRadius - a.renderRadius)[0];
641
+ if (!largest) return;
642
+
643
+ phase = (phase + 0.03) % (Math.PI * 2);
644
+ const pulse = 1 + Math.sin(phase) * 0.12;
645
+
646
+ const NS = 'http://www.w3.org/2000/svg';
647
+ const ring = document.createElementNS(NS, 'circle');
648
+ ring.setAttribute('cx', String(largest.renderX));
649
+ ring.setAttribute('cy', String(largest.renderY));
650
+ ring.setAttribute('r', String(largest.renderRadius * largest.renderScale * pulse + 8));
651
+ ring.setAttribute('fill', 'none');
652
+ ring.setAttribute('stroke', '#fbbf24');
653
+ ring.setAttribute('stroke-width', '2.5');
654
+ ring.setAttribute('stroke-dasharray', '6 4');
655
+ ring.setAttribute('opacity', String(0.5 + Math.sin(phase) * 0.4));
656
+ svg.appendChild(ring);
657
+
658
+ // Label
659
+ const text = document.createElementNS(NS, 'text');
660
+ text.setAttribute('x', String(largest.renderX));
661
+ text.setAttribute('y', String(largest.renderY - largest.renderRadius * largest.renderScale - 18));
662
+ text.setAttribute('text-anchor', 'middle');
663
+ text.setAttribute('fill', '#fbbf24');
664
+ text.setAttribute('font-size', '11');
665
+ text.setAttribute('font-family', 'system-ui');
666
+ text.textContent = '★ largest';
667
+ svg.appendChild(text);
668
+ }
669
+ });
670
+ document.getElementById('stat-hooks').textContent =
671
+ `overlay hook registered (id: ${overlayHookId}) — animating ring around largest bubble`;
672
+ // Wake the loop so it keeps ticking for the animation
673
+ // (In a real app, use requestAnimationFrame separately for pure UI overlays)
674
+ }
675
+ }
676
+
677
+ function replaceBuiltin() {
678
+ chart_hooks.removeLayerHook('builtin:bubbles');
679
+ const NS = 'http://www.w3.org/2000/svg';
680
+ customBuiltinHookId = chart_hooks.addLayerHook({
681
+ layer: 'bubbles',
682
+ priority: 0,
683
+ fn: ({ type, svg }, bubbles) => {
684
+ if (type !== 'svg' || !svg) return;
685
+ for (const b of bubbles) {
686
+ const r = b.renderRadius * b.renderScale;
687
+ // Draw a square instead of a circle
688
+ const rect = document.createElementNS(NS, 'rect');
689
+ rect.setAttribute('x', String(b.renderX - r));
690
+ rect.setAttribute('y', String(b.renderY - r));
691
+ rect.setAttribute('width', String(r * 2));
692
+ rect.setAttribute('height', String(r * 2));
693
+ rect.setAttribute('rx', String(r * 0.25));
694
+ rect.setAttribute('fill', b.color);
695
+ svg.appendChild(rect);
696
+ }
697
+ }
698
+ });
699
+ document.getElementById('stat-hooks').textContent =
700
+ 'builtin:bubbles replaced — squares instead of circles (custom hook at priority 0)';
701
+ }
702
+
703
+ function restoreBuiltin() {
704
+ if (customBuiltinHookId) {
705
+ chart_hooks.removeLayerHook(customBuiltinHookId);
706
+ customBuiltinHookId = null;
707
+ }
708
+ // Destroy and reinit only this chart to restore the builtin:bubbles pass
709
+ chart_hooks.destroy();
710
+ const container = document.getElementById('chart-hooks');
711
+ container.innerHTML = '';
712
+ chart_hooks = initializeChart({
713
+ canvasContainerId: 'chart-hooks',
714
+ data: hooksData,
715
+ render: { mode: 'svg', theme: 'flat' },
716
+ layout: { type: 'static' },
717
+ defaultFontColor: '#ffffff',
718
+ defaultFontFamily: 'system-ui',
719
+ fontSize: 13,
720
+ });
721
+ overlayHookId = null;
722
+ document.getElementById('stat-hooks').textContent = 'builtin:bubbles restored';
723
+ }
724
+
725
+ // ────────────────────────────────────────────────────────────────────────────
726
+ // topN() live demo in console (open DevTools to see)
727
+ // ────────────────────────────────────────────────────────────────────────────
728
+ console.group('topN() demo — bubble-chart-js V2');
729
+ const bigDataset = Array.from({ length: 50 }, (_, i) => ({
730
+ id: `item_${i}`, label: `Item ${i}`, value: Math.floor(Math.random() * 500)
731
+ }));
732
+ const top5 = topN(bigDataset, 5);
733
+ console.table(top5.map(({ id, label, value }) => ({ id, label, value })));
734
+ console.groupEnd();
735
+ </script>
736
+
737
+ </body>
738
+
739
+ </html>