bubble-chart-js 1.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.
Files changed (39) hide show
  1. package/.eslintrc.json +15 -0
  2. package/.gitattributes +2 -0
  3. package/.prettierrc +7 -0
  4. package/CHANGELOG.md +17 -0
  5. package/README.md +109 -0
  6. package/bubble-chart-js-1.0.0.tgz +0 -0
  7. package/dist/bundle.js +186 -0
  8. package/dist/canvas.d.ts +4 -0
  9. package/dist/constants/physics.d.ts +10 -0
  10. package/dist/core/renderer.d.ts +2 -0
  11. package/dist/features/textWrapper.d.ts +1 -0
  12. package/dist/features/tooltip.d.ts +3 -0
  13. package/dist/main.d.ts +1 -0
  14. package/dist/models/internal/dataItemInfo.d.ts +7 -0
  15. package/dist/models/public/configuration.d.ts +15 -0
  16. package/dist/models/public/dataItem.d.ts +4 -0
  17. package/dist/services/chartService.d.ts +5 -0
  18. package/dist/services/renderService.d.ts +3 -0
  19. package/dist/utils/config.d.ts +12 -0
  20. package/dist/utils/helper.d.ts +1 -0
  21. package/dist/utils/validation.d.ts +5 -0
  22. package/jest.config.js +5 -0
  23. package/package.json +35 -0
  24. package/src/canvas.ts +17 -0
  25. package/src/constants/physics.ts +10 -0
  26. package/src/core/renderer.ts +110 -0
  27. package/src/features/textWrapper.ts +168 -0
  28. package/src/features/tooltip.ts +69 -0
  29. package/src/main.ts +5 -0
  30. package/src/models/internal/dataItemInfo.ts +8 -0
  31. package/src/models/public/configuration.ts +16 -0
  32. package/src/models/public/dataItem.ts +4 -0
  33. package/src/services/chartService.ts +24 -0
  34. package/src/services/renderService.ts +262 -0
  35. package/src/utils/config.ts +33 -0
  36. package/src/utils/helper.ts +3 -0
  37. package/src/utils/validation.ts +18 -0
  38. package/tsconfig.json +15 -0
  39. package/webpack.config.js +28 -0
@@ -0,0 +1,110 @@
1
+ import { Configuration } from "../models/public/configuration";
2
+ import { getWrappedLines } from "../features/textWrapper";
3
+ import { createTooltipElement, handleMouseMove } from "../features/tooltip";
4
+ import { validateConfig } from "../utils/validation";
5
+ import { createCanvas } from "../canvas";
6
+ import { getChartData } from "../services/renderService";
7
+ import { getFontSize } from "../utils/helper";
8
+
9
+ export function renderChart(config: Configuration) {
10
+ if (!validateConfig(config)) return;
11
+
12
+ // Create & setup canvas
13
+ let canvas = createCanvas(config.canvasContainerId);
14
+ if (!canvas) return;
15
+
16
+ const ctx = canvas.getContext("2d");
17
+
18
+ if (!ctx) {
19
+ console.error("Invalid context");
20
+ return;
21
+ }
22
+
23
+ const sortedData = getChartData(config, canvas, ctx);
24
+
25
+ function draw() {
26
+ if (!canvas || !ctx) {
27
+ console.warn("canvas or ctx is not valid");
28
+ return;
29
+ }
30
+
31
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
32
+
33
+ sortedData.forEach((item) => {
34
+ const color = config.colorMap[item.label] || config.defaultBubbleColor;
35
+
36
+ // Ensure radius is at least minRadius
37
+ const radius = Math.max(item.radius, config.minRadius);
38
+
39
+ ctx.beginPath();
40
+ ctx.arc(item.x, item.y, radius, 0, Math.PI * 2);
41
+ ctx.fillStyle = color;
42
+ ctx.fill();
43
+
44
+ ctx.strokeStyle = "black"; // Border color
45
+ ctx.lineWidth = 0.25; // Border thickness
46
+ ctx.stroke(); // Apply border
47
+
48
+ // Text styling
49
+ ctx.fillStyle = config.fontColor;
50
+ const fontSize = getFontSize(radius, config.fontSize);
51
+ ctx.font = `${fontSize}px ${config.fontFamily}`;
52
+ ctx.textAlign = "center";
53
+ ctx.textBaseline = "middle";
54
+
55
+ const padding = 5; // Padding around text
56
+ const maxWidth = radius * 1.5 - padding * 2; // Adjusted for padding
57
+
58
+ if (config.textWrap) {
59
+ // Calculate vertical position for lines
60
+ const lineHeight = fontSize * 1.2;
61
+
62
+ // Dynamically determine lines if maxLines is not set
63
+ const lines = getWrappedLines(
64
+ ctx,
65
+ item.label,
66
+ maxWidth,
67
+ config.maxLines,
68
+ radius
69
+ );
70
+ const startY = item.y - ((lines.length - 1) * lineHeight) / 2;
71
+
72
+ lines.forEach((line, index) => {
73
+ ctx.fillText(line, item.x, startY + index * lineHeight);
74
+ });
75
+ } else {
76
+ ctx.fillText(item.label, item.x, item.y);
77
+ }
78
+ });
79
+ }
80
+
81
+ // Robust approach that handles resizing:
82
+ function resizeCanvas() {
83
+ const canvasContainer = document.getElementById(config.canvasContainerId);
84
+ if (canvasContainer && canvas) {
85
+ canvas.width = canvasContainer.offsetWidth;
86
+ canvas.height = canvasContainer.offsetHeight;
87
+ draw(); // Call your drawing function
88
+ }
89
+ }
90
+
91
+ if (config.isResizeCanvasOnWindowSizeChange) {
92
+ resizeCanvas(); // Initial resize
93
+ window.addEventListener("resize", resizeCanvas); // Resize on window resize
94
+ }
95
+
96
+ // Initial draw
97
+ draw();
98
+
99
+ if (config.showToolTip) {
100
+ const tooltip = createTooltipElement();
101
+ let animationFrameId: number | null = null;
102
+ canvas.addEventListener("mousemove", (event) => {
103
+ if (animationFrameId) return; // Prevent excessive calls
104
+ animationFrameId = requestAnimationFrame(() => {
105
+ handleMouseMove(event, sortedData, canvas, tooltip);
106
+ animationFrameId = null; // Reset after execution
107
+ });
108
+ });
109
+ }
110
+ }
@@ -0,0 +1,168 @@
1
+ export function getWrappedLines(
2
+ ctx: CanvasRenderingContext2D,
3
+ text: string,
4
+ maxLineWidth: number,
5
+ maxAllowedLines: number | undefined,
6
+ circleRadius: number,
7
+ maxCharsPerWord: number | undefined = undefined
8
+ ): string[] {
9
+ if (!text || maxLineWidth <= 0) return [];
10
+
11
+ let words = text.split(/\s+/);
12
+
13
+ // Set default for maxAllowedLines based on available space
14
+ maxAllowedLines = determineMaxLines(
15
+ ctx,
16
+ maxAllowedLines,
17
+ circleRadius,
18
+ maxLineWidth
19
+ );
20
+
21
+ // Handle single-word case separately
22
+ if (words.length === 1) {
23
+ return [truncateTextToFit(ctx, words[0], maxLineWidth)];
24
+ }
25
+
26
+ if (maxCharsPerWord) {
27
+ // For Now dont allow default word truncation
28
+ // Set default for maxCharsPerWord if not provided
29
+ maxCharsPerWord = determineMaxCharsPerWord(
30
+ ctx,
31
+ maxCharsPerWord,
32
+ maxLineWidth
33
+ );
34
+
35
+ // Apply maxCharsPerWord truncation if needed
36
+ words = words.map((word) => truncateWord(word, maxCharsPerWord!));
37
+ }
38
+
39
+ return wrapTextIntoLines(ctx, words, maxLineWidth, maxAllowedLines);
40
+ }
41
+
42
+ /**
43
+ * Determines maxAllowedLines based on available space.
44
+ */
45
+ function determineMaxLines(
46
+ ctx: CanvasRenderingContext2D,
47
+ maxAllowedLines: number | undefined,
48
+ circleRadius: number, // TODO later account circleRadius to handleLabeltext-NoOfLines
49
+ maxLineWidth: number
50
+ ): number {
51
+ if (maxAllowedLines && maxAllowedLines > 0) return maxAllowedLines;
52
+
53
+ const fontSize = parseInt(ctx.font, 10) || 16;
54
+ const lineHeight = fontSize * 1.2;
55
+ return Math.floor(maxLineWidth / lineHeight) || 1; // Default: Fit within maxLineWidth
56
+ }
57
+
58
+ /**
59
+ * Determines maxCharsPerWord based on maxLineWidth.
60
+ */
61
+ function determineMaxCharsPerWord(
62
+ ctx: CanvasRenderingContext2D,
63
+ maxCharsPerWord: number | undefined,
64
+ maxLineWidth: number
65
+ ): number {
66
+ if (maxCharsPerWord && maxCharsPerWord > 0) return maxCharsPerWord;
67
+
68
+ const avgCharWidth = ctx.measureText("W").width || 8; // Approximate avg char width
69
+ return Math.floor(maxLineWidth / avgCharWidth); // Default: Fit within maxLineWidth
70
+ }
71
+
72
+ /**
73
+ * Wraps text into multiple lines within maxLineWidth.
74
+ */
75
+ function wrapTextIntoLines(
76
+ ctx: CanvasRenderingContext2D,
77
+ words: string[],
78
+ maxLineWidth: number,
79
+ maxAllowedLines: number
80
+ ): string[] {
81
+ const wrappedLines: string[] = [];
82
+ let currentLine = "";
83
+
84
+ for (const word of words) {
85
+ const testLine = currentLine ? `${currentLine} ${word}` : word;
86
+ const testWidth = ctx.measureText(testLine).width;
87
+
88
+ if (testWidth <= maxLineWidth) {
89
+ currentLine = testLine;
90
+ } else {
91
+ if (currentLine) wrappedLines.push(currentLine);
92
+ currentLine = word;
93
+ if (wrappedLines.length >= maxAllowedLines - 1) break;
94
+ }
95
+ }
96
+
97
+ if (currentLine) wrappedLines.push(currentLine);
98
+
99
+ return finalizeWrappedLines(ctx, wrappedLines, maxLineWidth, maxAllowedLines);
100
+ }
101
+
102
+ /**
103
+ * Ensures the final wrapped lines do not exceed maxAllowedLines.
104
+ */
105
+ function finalizeWrappedLines(
106
+ ctx: CanvasRenderingContext2D,
107
+ wrappedLines: string[],
108
+ maxLineWidth: number,
109
+ maxAllowedLines: number
110
+ ): string[] {
111
+ if (wrappedLines.length > maxAllowedLines) {
112
+ wrappedLines.length = maxAllowedLines;
113
+ }
114
+
115
+ // Truncate the last line if needed
116
+ if (wrappedLines.length === maxAllowedLines) {
117
+ wrappedLines[maxAllowedLines - 1] = truncateTextToFit(
118
+ ctx,
119
+ wrappedLines[maxAllowedLines - 1],
120
+ maxLineWidth
121
+ );
122
+ }
123
+
124
+ return wrappedLines.map((line) =>
125
+ ctx.measureText(line).width > maxLineWidth
126
+ ? truncateTextToFit(ctx, line, maxLineWidth)
127
+ : line
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Truncates text with an ellipsis if it exceeds maxLineWidth.
133
+ */
134
+ function truncateTextToFit(
135
+ ctx: CanvasRenderingContext2D,
136
+ text: string,
137
+ maxLineWidth: number
138
+ ): string {
139
+ text = text.trim();
140
+ if (!text) return ""; // Handle empty or whitespace-only input
141
+ if (ctx.measureText(text).width <= maxLineWidth) return text; // Return early if text fits
142
+
143
+ let left = 0,
144
+ right = text.length;
145
+
146
+ // binary search
147
+ while (left < right) {
148
+ const mid = Math.ceil((left + right) / 2);
149
+ const truncated = text.slice(0, mid) + "…";
150
+
151
+ if (ctx.measureText(truncated).width > maxLineWidth) {
152
+ right = mid - 1; // Reduce size
153
+ } else {
154
+ left = mid; // Expand size
155
+ }
156
+ }
157
+
158
+ return text.slice(0, left) + "…";
159
+ }
160
+
161
+ /**
162
+ * Truncates a word to maxCharsPerWord with an ellipsis.
163
+ */
164
+ function truncateWord(word: string, maxCharsPerWord: number): string {
165
+ return word.length > maxCharsPerWord
166
+ ? word.slice(0, maxCharsPerWord) + "…"
167
+ : word;
168
+ }
@@ -0,0 +1,69 @@
1
+ import { DataItemInfo } from "../models/internal/dataItemInfo";
2
+
3
+ // let hoveredItem: DataItemInfo | null = null;
4
+
5
+ export function createTooltipElement(): HTMLDivElement {
6
+ const tooltip = document.createElement("div") as HTMLDivElement;
7
+ tooltip.id = "tooltip";
8
+ tooltip.style.display = "none";
9
+ document.body.appendChild(tooltip);
10
+ return tooltip;
11
+ }
12
+
13
+ export function handleMouseMove(
14
+ event: MouseEvent,
15
+ data: DataItemInfo[],
16
+ canvas: HTMLCanvasElement,
17
+ tooltip: HTMLDivElement
18
+ ) {
19
+ const { mouseX, mouseY } = getMousePosition(event, canvas);
20
+ const hoveredItem = findHoveredItem(mouseX, mouseY, data);
21
+
22
+ if (hoveredItem) {
23
+ updateTooltip(event, hoveredItem, canvas, tooltip);
24
+ } else {
25
+ canvas.style.cursor = "default";
26
+ tooltip.style.display = "none";
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Gets the mouse position relative to the canvas.
32
+ */
33
+ function getMousePosition(event: MouseEvent, canvas: HTMLCanvasElement) {
34
+ const rect = canvas.getBoundingClientRect();
35
+ return {
36
+ mouseX: event.clientX - rect.left,
37
+ mouseY: event.clientY - rect.top,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Finds the hovered item based on proximity to circles.
43
+ */
44
+ function findHoveredItem(mouseX: number, mouseY: number, data: DataItemInfo[]) {
45
+ return (
46
+ data.find(
47
+ (item) => Math.hypot(mouseX - item.x, mouseY - item.y) < item.radius
48
+ ) || null
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Updates the tooltip and cursor based on the hovered item.
54
+ */
55
+ function updateTooltip(
56
+ event: MouseEvent,
57
+ hovered: DataItemInfo | null,
58
+ canvas: HTMLCanvasElement,
59
+ tooltip: HTMLDivElement
60
+ ) {
61
+ if (hovered?.label && hovered?.value && canvas && tooltip) {
62
+ canvas.style.cursor = "pointer";
63
+ tooltip.style.display = "block";
64
+ tooltip.innerHTML = `<div>${hovered.label}<br>Value: ${hovered.value}</div>`;
65
+ tooltip.style.left = `${event.pageX + 15}px`;
66
+ tooltip.style.top = `${event.pageY - 30}px`;
67
+ tooltip.style.zIndex = "9999";
68
+ }
69
+ }
package/src/main.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { initializeChart } from "./services/chartService";
2
+
3
+ // export { initializeChart };
4
+ // @ts-ignore (Ignore TypeScript error for missing window prop)
5
+ window.initializeChart = initializeChart as any;
@@ -0,0 +1,8 @@
1
+ import { DataItem } from "../public/dataItem";
2
+
3
+ export interface DataItemInfo extends DataItem {
4
+ radius: number;
5
+ x: number;
6
+ y: number;
7
+ fixed: boolean;
8
+ }
@@ -0,0 +1,16 @@
1
+ import { DataItem } from "./dataItem";
2
+
3
+ export interface Configuration {
4
+ canvasContainerId: string;
5
+ data: DataItem[];
6
+ colorMap: Record<string, string>;
7
+ defaultBubbleColor: string;
8
+ fontSize: number;
9
+ fontFamily: string;
10
+ fontColor: string;
11
+ minRadius: number;
12
+ maxLines: number;
13
+ textWrap: boolean;
14
+ isResizeCanvasOnWindowSizeChange: boolean;
15
+ showToolTip: boolean;
16
+ }
@@ -0,0 +1,4 @@
1
+ export interface DataItem {
2
+ label: string;
3
+ value: number;
4
+ }
@@ -0,0 +1,24 @@
1
+ import { Configuration } from "../models/public/configuration";
2
+ import { renderChart } from "../core/renderer";
3
+ import { mergeConfig } from "../utils/config";
4
+
5
+ /**
6
+ * Initializes the chart, but stops execution if no valid data is provided.
7
+ */
8
+ export function initializeChart(config: Partial<Configuration> = {}): void {
9
+ if (!config.data || config.data.length === 0) {
10
+ console.warn(
11
+ "initializeChart: No valid data provided. Chart initialization aborted."
12
+ );
13
+ return;
14
+ }
15
+
16
+ const safeConfig = {
17
+ canvasContainerId: config.canvasContainerId ?? "chart-container",
18
+ data: config.data ?? [],
19
+ ...config,
20
+ };
21
+
22
+ const finalConfig = mergeConfig(safeConfig);
23
+ renderChart(finalConfig);
24
+ }
@@ -0,0 +1,262 @@
1
+ import { PHYSICS } from "../constants/physics";
2
+ import { DataItemInfo } from "../models/internal/dataItemInfo";
3
+ import { Configuration } from "../models/public/configuration";
4
+
5
+ export function getChartData(
6
+ config: Configuration,
7
+ canvas: HTMLCanvasElement,
8
+ ctx: CanvasRenderingContext2D
9
+ ) {
10
+ // Add padding constant at the top
11
+ const CANVAS_PADDING = 5; // pixels of padding around all edges
12
+
13
+ // Calculate available space considering padding
14
+ const maxPossibleRadius = Math.min(
15
+ (canvas.width - CANVAS_PADDING * 2) / 2,
16
+ (canvas.height - CANVAS_PADDING * 2) / 2
17
+ );
18
+
19
+ // Calculate radii based on available space
20
+ // const availableWidth = canvas.width - CANVAS_PADDING * 2;
21
+ // const availableHeight = canvas.height - CANVAS_PADDING * 2;
22
+ // const canvasMinDimension = Math.min(availableWidth, availableHeight);
23
+
24
+ // Add this code for crisp rendering
25
+ const devicePixelRatio = window.devicePixelRatio || 1;
26
+ const rect = canvas.getBoundingClientRect();
27
+
28
+ // reduce width & height
29
+ // const rectWidth = rect.width - (rect.width / 100) * 10;
30
+ // const rectHeight = rect.height - (rect.height / 100) * 10;
31
+
32
+ canvas.width = rect.width * devicePixelRatio;
33
+ canvas.height = rect.height * devicePixelRatio;
34
+
35
+ canvas.style.width = rect.width + "px";
36
+ canvas.style.height = rect.height + "px";
37
+
38
+ ctx.scale(devicePixelRatio, devicePixelRatio);
39
+
40
+ // Modify center calculations
41
+ const centerX = rect.width / 2;
42
+ const centerY = rect.height / 2;
43
+
44
+ const sortedData: DataItemInfo[] = [...config.data]
45
+ .sort((a, b) => b.value - a.value)
46
+ .map((item) => ({
47
+ ...item,
48
+ radius: 0,
49
+ x: 0,
50
+ y: 0,
51
+ fixed: false,
52
+ }));
53
+
54
+ const maxValue = sortedData[0].value;
55
+
56
+ // Define radius range dynamically
57
+ // const internalMinRadius = canvasMinDimension * 0.25; // 5% of smallest dimension
58
+ // const internalMaxRadius = canvasMinDimension * 0.65; // 15% of smallest dimension
59
+
60
+ const internalMaxRadius = Math.min(
61
+ maxPossibleRadius * 1.0, // Use 80% of maximum possible space
62
+ 100 // Absolute maximum
63
+ );
64
+
65
+ const internalMinRadius = Math.max(
66
+ internalMaxRadius * 0.3, // Minimum 30% of max radius
67
+ 30 // Absolute minimum
68
+ );
69
+
70
+ // Value-based radius calculation with padding consideration
71
+ sortedData.forEach((item) => {
72
+ // Calculate radius proportional to value and canvas size
73
+ const valueRatio = item.value / maxValue;
74
+ item.radius =
75
+ internalMinRadius + valueRatio * (internalMaxRadius - internalMinRadius);
76
+
77
+ // Ensure radius respects padding
78
+ item.radius = Math.min(
79
+ item.radius,
80
+ (canvas.width - CANVAS_PADDING * 2) / 2,
81
+ (canvas.height - CANVAS_PADDING * 2) / 2
82
+ );
83
+ });
84
+
85
+ // Add aspect ratio preservation in bubble positioning
86
+ sortedData.forEach((item, index) => {
87
+ if (index === 0) {
88
+ item.x = centerX;
89
+ item.y = centerY;
90
+ item.fixed = true;
91
+ } else {
92
+ const baseDist = sortedData[0].radius + item.radius + 3;
93
+
94
+ // Replace with deterministic positioning using golden angle
95
+ const goldenAngle = Math.PI * (3 - Math.sqrt(5)); // ~137.5 degrees
96
+
97
+ // Calculate position with padding constraints
98
+ const maxX = canvas.width - CANVAS_PADDING - item.radius;
99
+ const maxY = canvas.height - CANVAS_PADDING - item.radius;
100
+
101
+ item.x = Math.min(
102
+ maxX,
103
+ Math.max(
104
+ CANVAS_PADDING + item.radius,
105
+ centerX + Math.cos(goldenAngle * index) * baseDist
106
+ )
107
+ );
108
+ item.y = Math.min(
109
+ maxY,
110
+ Math.max(
111
+ CANVAS_PADDING + item.radius,
112
+ centerY + Math.sin(goldenAngle * index) * baseDist
113
+ )
114
+ );
115
+
116
+ item.fixed = false;
117
+ }
118
+ });
119
+
120
+ // Physics simulation
121
+ // Adjust physics parameters for tighter packing
122
+
123
+ // Unified physics simulation and collision resolution
124
+ for (let i = 0; i < PHYSICS.iterations; i++) {
125
+ // Main physics simulation
126
+ sortedData.forEach((current, index) => {
127
+ // Apply special handling for center bubble
128
+ if (index === 0) {
129
+ // Soft center positioning with spring-like behavior
130
+ const dx = centerX - current.x;
131
+ const dy = centerY - current.y;
132
+ const dist = Math.hypot(dx, dy);
133
+
134
+ // Only apply correction if significantly off-center
135
+ if (dist > 2) {
136
+ current.x += dx * PHYSICS.centerDamping;
137
+ current.y += dy * PHYSICS.centerDamping;
138
+ }
139
+ return;
140
+ }
141
+
142
+ let dxTotal = 0;
143
+ let dyTotal = 0;
144
+
145
+ // 1. Boundary constraints
146
+ const boundaryPadding = current.radius + CANVAS_PADDING;
147
+ if (current.x < boundaryPadding) {
148
+ dxTotal += (boundaryPadding - current.x) * PHYSICS.boundaryForce;
149
+ } else if (current.x > canvas.width - boundaryPadding) {
150
+ dxTotal +=
151
+ (canvas.width - boundaryPadding - current.x) * PHYSICS.boundaryForce;
152
+ }
153
+
154
+ // 2. Bubble repulsion with tight spacing
155
+ sortedData.forEach((other) => {
156
+ if (current === other) return;
157
+
158
+ // Add additional center attraction
159
+ const dx = centerX - current.x;
160
+ const dy = centerY - current.y;
161
+ const distance = Math.hypot(dx, dy);
162
+
163
+ // Value-based attraction strength (weaker for smaller values)
164
+ const attractionStrength = 0.02 * (current.value / maxValue);
165
+
166
+ current.x += (dx / distance) * attractionStrength;
167
+ current.y += (dy / distance) * attractionStrength;
168
+ });
169
+
170
+ // 3. Strong center attraction with value-based weighting
171
+ const dxCenter = centerX - current.x;
172
+ const dyCenter = centerY - current.y;
173
+ const centerDist = Math.hypot(dxCenter, dyCenter);
174
+ const minCenterDist =
175
+ sortedData[0].radius + current.radius + PHYSICS.centerRadiusBuffer;
176
+ const attraction =
177
+ PHYSICS.centerForce * (1 - Math.pow(current.value / maxValue, 0.3));
178
+
179
+ // Value-based attraction strength
180
+ const attractionStrength =
181
+ PHYSICS.centerAttraction *
182
+ (1 - current.value / maxValue) *
183
+ (1 - Math.min(1, centerDist / minCenterDist));
184
+
185
+ current.x += dxCenter * attractionStrength;
186
+ current.y += dyCenter * attractionStrength;
187
+ });
188
+
189
+ // Combined collision resolution
190
+ sortedData.forEach((current, i) => {
191
+ sortedData.forEach((other, j) => {
192
+ if (i >= j) return;
193
+
194
+ // Special handling for center bubble collisions
195
+ if (i === 0 || j === 0) {
196
+ const centerBubble = i === 0 ? current : other;
197
+ const normalBubble = i === 0 ? other : current;
198
+
199
+ const dx = normalBubble.x - centerBubble.x;
200
+ const dy = normalBubble.y - centerBubble.y;
201
+ const distance = Math.hypot(dx, dy);
202
+ const minDistance = centerBubble.radius + normalBubble.radius + 2;
203
+
204
+ if (distance < minDistance) {
205
+ const overlap = minDistance - distance;
206
+ const angle = Math.atan2(dy, dx);
207
+
208
+ // Only move the normal bubble
209
+ normalBubble.x += Math.cos(angle) * overlap * 0.7;
210
+ normalBubble.y += Math.sin(angle) * overlap * 0.7;
211
+ }
212
+ return;
213
+ }
214
+
215
+ const dx = current.x - other.x;
216
+ const dy = current.y - other.y;
217
+ const distance = Math.hypot(dx, dy);
218
+ const minDistance = current.radius + other.radius - 5; // Allow 2px overlap
219
+
220
+ if (distance < minDistance) {
221
+ const overlap = (minDistance - distance) * 0.3; // Gentle correction
222
+ const angle = Math.atan2(dy, dx);
223
+
224
+ // Mass-weighted adjustment
225
+ const massRatio = other.radius / (current.radius + other.radius);
226
+ if (!current.fixed) {
227
+ current.x += Math.cos(angle) * overlap * massRatio;
228
+ current.y += Math.sin(angle) * overlap * massRatio;
229
+ }
230
+ if (!other.fixed) {
231
+ other.x -= Math.cos(angle) * overlap * (1 - massRatio);
232
+ other.y -= Math.sin(angle) * overlap * (1 - massRatio);
233
+ }
234
+ }
235
+ });
236
+ });
237
+ }
238
+
239
+ // Modify boundary clamping to include center bubble
240
+ sortedData.forEach((bubble) => {
241
+ const clampedX = Math.max(
242
+ CANVAS_PADDING + bubble.radius,
243
+ Math.min(canvas.width - CANVAS_PADDING - bubble.radius, bubble.x)
244
+ );
245
+
246
+ const clampedY = Math.max(
247
+ CANVAS_PADDING + bubble.radius,
248
+ Math.min(canvas.height - CANVAS_PADDING - bubble.radius, bubble.y)
249
+ );
250
+
251
+ // Only update position if not fixed or moved significantly
252
+ if (
253
+ !bubble.fixed ||
254
+ Math.hypot(bubble.x - clampedX, bubble.y - clampedY) > 2
255
+ ) {
256
+ bubble.x = clampedX;
257
+ bubble.y = clampedY;
258
+ }
259
+ });
260
+
261
+ return sortedData;
262
+ }