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.
- package/.eslintrc.json +15 -0
- package/.gitattributes +2 -0
- package/.prettierrc +7 -0
- package/CHANGELOG.md +17 -0
- package/README.md +109 -0
- package/bubble-chart-js-1.0.0.tgz +0 -0
- package/dist/bundle.js +186 -0
- package/dist/canvas.d.ts +4 -0
- package/dist/constants/physics.d.ts +10 -0
- package/dist/core/renderer.d.ts +2 -0
- package/dist/features/textWrapper.d.ts +1 -0
- package/dist/features/tooltip.d.ts +3 -0
- package/dist/main.d.ts +1 -0
- package/dist/models/internal/dataItemInfo.d.ts +7 -0
- package/dist/models/public/configuration.d.ts +15 -0
- package/dist/models/public/dataItem.d.ts +4 -0
- package/dist/services/chartService.d.ts +5 -0
- package/dist/services/renderService.d.ts +3 -0
- package/dist/utils/config.d.ts +12 -0
- package/dist/utils/helper.d.ts +1 -0
- package/dist/utils/validation.d.ts +5 -0
- package/jest.config.js +5 -0
- package/package.json +35 -0
- package/src/canvas.ts +17 -0
- package/src/constants/physics.ts +10 -0
- package/src/core/renderer.ts +110 -0
- package/src/features/textWrapper.ts +168 -0
- package/src/features/tooltip.ts +69 -0
- package/src/main.ts +5 -0
- package/src/models/internal/dataItemInfo.ts +8 -0
- package/src/models/public/configuration.ts +16 -0
- package/src/models/public/dataItem.ts +4 -0
- package/src/services/chartService.ts +24 -0
- package/src/services/renderService.ts +262 -0
- package/src/utils/config.ts +33 -0
- package/src/utils/helper.ts +3 -0
- package/src/utils/validation.ts +18 -0
- package/tsconfig.json +15 -0
- 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,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,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
|
+
}
|