live renderhtml
htmlbubble_pack3 2.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Bubble Chart – Native JS + Canvas + RK4 + Clusters</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: #fff;
overflow: hidden;
font-family: sans-serif;
}
canvas { display: block; }
</style>
</head>
<body>
<canvas id="bubbleCanvas"></canvas>
<script>
// ------------------------------
// 1. CSV Data and Parser
// ------------------------------
const csvData = `id,groupid,size,name
1,1,9080,α
2,9,4610,β
3,2,3810,γ
4,1,2990,δ
5,1,2820,ε
6,3,2430,ζ
7,6,2400,η
8,3,2090,θ
9,3,1580,ι
10,7,1290,κ
11,10,1230,λ
12,1,1210,μ
13,3,829,ν
14,3,768,ξ
15,1,745,ο
16,3,651,π
17,3,589,ρ
18,1,569,σ
19,2,502,τ
20,3,441,υ
21,2,425,φ
22,5,388,χ
23,11,378,ψ
24,11,373,ω
25,3,369,1
26,12,364,2
27,15,359,3
28,16,349,4
29,16,340,5
30,3,338,6
31,16,330,7
32,13,306,8
33,12,301,9
34,12,283,0
35,1,268
36,3,268
37,17,266
38,17,264
39,3,262
40,3,256
41,5,243
42,11,237
43,13,223
44,12,222
45,10,220
46,99,220
47,1,212
48,19,201
49,19,193
50,3,190
51,1,189
52,3,188
53,1,186
54,1,179
55,3,179
56,16,174
57,5,172
58,3,165
59,6,165
60,3,164
61,1,163
62,1,157
63,3,149
64,2,147
65,3,145
66,10,142
67,11,138
68,3,128
69,3,120
70,2,97`;
function parseCSV(str) {
const lines = str.trim().split(/\r?\n/);
const header = lines[0].split(",");
const data = [];
for (let i = 1; i < lines.length; i++) {
const row = lines[i].split(",");
const obj = {};
for (let j = 0; j < header.length; j++) {
const key = header[j].trim();
obj[key] = row[j] !== undefined ? row[j].trim() : "";
}
data.push(obj);
}
return data;
}
// ------------------------------
// 2. Utility Functions
// ------------------------------
function mapRange(value, inMin, inMax, outMin, outMax) {
return outMin + (outMax - outMin) * ((value - inMin) / (inMax - inMin));
}
// ------------------------------
// 3. Bubble Class
// ------------------------------
class Bubble {
constructor(d, radiusScale) {
this.id = +d.id;
this.groupid = d.groupid;
this.size = +d.size;
this.name = d.name || "";
this.radius = radiusScale(this.size);
// When dragging, fx and fy will hold fixed positions.
this.fx = null;
this.fy = null;
}
}
// ------------------------------
// 4. BubbleChart Class with RK4 Integration and Group Clustering
// ------------------------------
class BubbleChart {
constructor(data) {
this.data = data;
// Define group colors (mapping group id to a color)
var palette = [
["#1D1B33", "#2F4858", "#FFEDCB", "#4B8178", "#7197A8", "#362E37"],
["#F4B53F", "#2F4858", "#FFEDCB", "#4B8178", "#C1554E", "#6A4C57"],
["#453C4C", "#B3AA74", "#FFEDCB", "#9F9D8A", "#7A6154", "#204E5E"],
["#4C4459", "#82B59D", "#FFEDCB", "#F3AB4E", "#A44440", "#204E5E"],
["#203239", "#96977B", "#FFEDCB", "#DB986F", "#964744", "#2C3637"],
["#344C5C", "#698DA0", "#BFB8A6", "#E8CFC1", "#D96D59", "#2B3967"],
["#263A5B", "#61898D", "#B4C4C5", "#FAE3C0", "#7B4B5A", "#B3BC99"],
["#7A8671", "#D2B27C", "#E4CC91", "#BB605A", "#7B4B5A", "#F0E9A9"],
["#ffa943", "#2177f4", "#35fc93", "#f9cfd2", "#6eabf4", "#3714a1"],
["#ce2d42", "#7462f9", "#f4b53f", "#123676", "#9c223d", "#e6c7b4"],
["#06a0ba", "#6f3bff", "#f20a41", "#8777f7", "#4848c1", "#e6c7b4"],
["#71f2ff", "#81fcca", "#f91cb0", "#0239c1", "#05bdc6", "#f7f1b4"],
["#302D3B", "#DBF7BD", "#879369", "#9A5154", "#C3C590", "#CAA174"],
["#25164D", "#BFD4BF", "#316C6F", "#494190", "#D3B74F", "#ECE5DE"],
["#624565", "#9B9589", "#E49E81", "#DB6A60", "#FAB582", "#E3B69A"],
["#594C98", "#372B33", "#FE0878", "#82D6DB", "#92D0AF", "#721F4C"],
["#F0DEB4", "#A1A17A", "#5A8170", "#F4F3CC", "#4B8178", "#FFC7A1"],
["#A42534", "#3F352F", "#B74C3B", "#D4AA71", "#DCCFB2", "#693239"],
["#665B55", "#F5B488", "#B55053", "#8B2335", "#69837B", "#F0D2B1"],
["#313D51", "#FBE8AA", "#EB917B", "#B15552", "#809488", "#337F83"],
["#042882", "#81fcca", "#f91cb0", "#0239c1", "#8450d6", "#05bdc6"],
["#304B61", "#281733", "#377F86", "#D1D1AE", "#DB6D6A", "#9AC7C3"],
["#2F677E", "#B5B383", "#C35F4F", "#D2E1D9", "#7FD1AE", "#FAE7BF"],
["#2A2B41", "#673939", "#377F86", "#E3D5AE", "#EFC375", "#281733"],
["#FF7E42", "#2D2E3C", "#FFE1C8", "#4F9472", "#D1594D", "#384C7D"],
["#FF7306", "#C7B18E", "#FFE3A4", "#7F4E4D", "#233072", "#6B97A8"],
["#FF6705", "#ED9C7B", "#FFE1A2", "#7F4E4D", "#154150", "#BAC292"],
["#f2c079", "#3c3c67", "#f7edcf", "#84a0a4", "#d22f2f", "#cfd5ed"],
["#505978", "#8ab984", "#f7d8c6", "#7f655d", "#c6d8f7", "#78A39B"],
["#2F3C3E", "#7CAB93", "#B6CDA9", "#F4F3CF", "#666460", "#C9A889"],
["#C1554E", "#0C3E4D", "#076269", "#C5B65B", "#F7C862", "#22BB9B"],
["#C1554E", "#477F82", "#22BB9B", "#DFD2A8", "#F7C862", "#63AC9C"],
["#3E3649", "#6D7180", "#B0ACAD", "#DFD2A8", "#F7C862", "#A6BC99"],
["#44E2D2", "#2365B8", "#645EBA", "#664785", "#F38073", "#2E93B7"],
["#E7A564", "#AC966F", "#596358", "#234564", "#EB7952", "#F0BA81"],
["#131C3B", "#2B3C61", "#4F77C9", "#8FC2DE", "#D2DCC0", "#265670"],
["#D62A58", "#122959", "#4F77C9", "#8FC2DE", "#D2DCC0", "#466C88"],
["#DFE3DB", "#99BEA5", "#51525F", "#6C6A36", "#3F4159", "#572D54"],
["#066A74", "#352F51", "#601449", "#EC4E25", "#F7954A", "#792023"],
["#131133", "#0A405E", "#EF4F56", "#68C3A0", "#F3EFCA", "#A5282F"],
["#234357", "#33AFA6", "#8FE2AD", "#DBEFA9", "#EACF7B", "#408AA8"],
["#FDFCF8", "#A2A295", "#5A5F5F", "#2C3D3C", "#252929", "#31576E"],
["#2F2930", "#707485", "#99AABE", "#B6E6E8", "#FBF8F2", "#C4BEBE"],
["#F4B53F", "#6A4C57", "#DBE3AA", "#6EC699", "#3C4549", "#A66648"],
["#F4B53F", "#6A4C57", "#212D4E", "#274B64", "#628367", "#6B293C"],
["#F4B53F", "#6A4C57", "#21B29F", "#406B77", "#333F5B", "#3A8EA2"],
["#432D3A", "#42495F", "#6F7E67", "#C0B37A", "#E9C268", "#E0DEAB"],
["#432D3A", "#42495F", "#368991", "#E5DAB6", "#EDAB58", "#ED7B4A"],
["#F3E6CA", "#DCAF8A", "#A8AD75", "#40818B", "#32374A", "#96B7B7"],
["#492E1B", "#732737", "#5D5969", "#8F8E71", "#E8D993", "#B25963"],
["#25272C", "#FBEFBD", "#DCB26C", "#386B67", "#0D3844", "#497084"],
["#25272C", "#FBEFBD", "#AEAA9D", "#497084", "#303E61", "#3770A2"],
["#292127", "#9B464A", "#E0C985", "#2A979A", "#0D2F3C", "#ECEFDB"],
["#F4B53F", "#2F4858", "#0FB3BC", "#D6E3BD", "#C1554E", "#6A4C57"],
["#D4BE5B", "#A1D1B5", "#48ADB6", "#516C57", "#401D35", "#39447D"],
["#401D35", "#9E4557", "#F16E54", "#F2D89D", "#C4BB86", "#F9AD69"],
["#FEB613", "#DEE4D7", "#3EC2B2", "#356F8D", "#2E2A32", "#A5E1AC"],
["#8F4756", "#E7D7C4", "#A9A1A5", "#7A909D", "#352E3F", "#BA8B80"],
["#1A2739", "#223653", "#5C223D", "#CD253A", "#EA8353", "#15486B"],
["#385F8D", "#223653", "#E3564C", "#CD253A", "#764468", "#E1BAA9"],
["#BFE3D4", "#F1D499", "#6787A0", "#3E5277", "#341C33", "#9E667B"],
["#495069", "#87AD9F", "#D4C8AC", "#B67465", "#4B1F33", "#F28443"],
["#495069", "#528B8C", "#EBDCBE", "#F0B07D", "#BD5D60", "#82A2B9"],
["#507386", "#78B4AE", "#F2E1B9", "#C78379", "#8A6946", "#2D2543"],
];
this.groupColors = {};
let key = 1;
for (let i = 0; i < palette.length; i++) {
for (let j = 0; j < palette[i].length; j++) {
this.groupColors[key.toString()] = palette[i][j];
key++;
}
}
this.fallbackColor = "#FF851B";
// Determine maximum size from the CSV data
let maxSize = 0;
for (let d of data) {
let s = +d.size || 0;
if (s > maxSize) maxSize = s;
}
// Define a square-root scale for the bubble radius
this.radiusScale = (sz) => Math.sqrt(sz / maxSize) * 80;
// Create Bubble objects
this.bubbles = this.data.map(d => new Bubble(d, this.radiusScale));
// Initialize simulation state for each bubble (position and velocity)
this.state = [];
for (let i = 0; i < this.bubbles.length; i++) {
this.state.push({
x: Math.random() * window.innerWidth,
y: Math.random() * window.innerHeight,
vx: 0,
vy: 0
});
}
// Force parameters (tweak these values as needed)
this.groupForce = 0.0005; // pulls each bubble toward its group's cluster center
this.collisionPadding = 3; // extra space to avoid overlap
// Compute cluster centers for each group so that bubbles of the same group are attracted together.
this.computeClusterCenters();
// Setup canvas and event listeners
this.canvas = document.getElementById("bubbleCanvas");
this.ctx = this.canvas.getContext("2d");
this.onResize();
window.addEventListener("resize", () => this.onResize());
// Mouse dragging support
this.draggingIndex = -1;
this.dragOffset = { x: 0, y: 0 };
this.canvas.addEventListener("mousedown", (e) => this.onMouseDown(e));
this.canvas.addEventListener("mousemove", (e) => this.onMouseMove(e));
this.canvas.addEventListener("mouseup", (e) => this.onMouseUp(e));
// Start simulation loop
requestAnimationFrame(() => this.update());
}
// Compute distinct group IDs and assign each a cluster center arranged on a circle around the canvas center.
computeClusterCenters() {
let groups = {};
for (let bubble of this.bubbles) {
groups[bubble.groupid] = true;
}
const groupIds = Object.keys(groups).sort();
const numGroups = groupIds.length;
this.clusterCenters = {};
const rCluster = Math.min(window.innerWidth, window.innerHeight) * 0.3;
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
for (let i = 0; i < numGroups; i++) {
const angle = (2 * Math.PI * i) / numGroups;
const x = cx + rCluster * Math.cos(angle);
const y = cy + rCluster * Math.sin(angle);
this.clusterCenters[groupIds[i]] = { x: x, y: y };
}
}
onResize() {
this.width = window.innerWidth;
this.height = window.innerHeight;
this.canvas.width = this.width;
this.canvas.height = this.height;
this.computeClusterCenters();
}
colorForGroup(gid) {
return this.groupColors[gid] || this.fallbackColor;
}
// Find the bubble under the mouse (returns index or -1)
findBubbleAt(mx, my) {
for (let i = this.bubbles.length - 1; i >= 0; i--) {
const s = this.state[i];
const dx = mx - s.x, dy = my - s.y;
if (Math.sqrt(dx * dx + dy * dy) <= this.bubbles[i].radius) {
return i;
}
}
return -1;
}
onMouseDown(e) {
const rect = this.canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const idx = this.findBubbleAt(mx, my);
if (idx >= 0) {
this.draggingIndex = idx;
const s = this.state[idx];
this.dragOffset.x = mx - s.x;
this.dragOffset.y = my - s.y;
this.bubbles[idx].fx = s.x;
this.bubbles[idx].fy = s.y;
}
}
onMouseMove(e) {
if (this.draggingIndex >= 0) {
const rect = this.canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
this.bubbles[this.draggingIndex].fx = mx - this.dragOffset.x;
this.bubbles[this.draggingIndex].fy = my - this.dragOffset.y;
}
}
onMouseUp(e) {
if (this.draggingIndex >= 0) {
this.bubbles[this.draggingIndex].fx = null;
this.bubbles[this.draggingIndex].fy = null;
this.draggingIndex = -1;
}
}
// Compute the acceleration for bubble i. Here we add a force pulling the bubble toward its group's center.
computeAcceleration(i, stateArray) {
const bubble = this.bubbles[i];
const s = stateArray[i];
let ax = 0, ay = 0;
// If the bubble is pinned, no acceleration.
if (bubble.fx !== null || bubble.fy !== null) return { ax: 0, ay: 0 };
// Compute group clustering force
const groupTarget = this.clusterCenters[bubble.groupid];
if (groupTarget) {
ax += (groupTarget.x - s.x) * this.groupForce;
ay += (groupTarget.y - s.y) * this.groupForce;
}
return { ax, ay };
}
// Handle collisions between bubbles (adjust positions to resolve overlaps)
handleCollisions(stateArray) {
const n = this.bubbles.length;
for (let i = 0; i < n; i++) {
const bi = this.bubbles[i];
const si = stateArray[i];
for (let j = i + 1; j < n; j++) {
const bj = this.bubbles[j];
const sj = stateArray[j];
let dx = sj.x - si.x, dy = sj.y - si.y;
const rSquared = dx * dx + dy * dy;
const minDist = bi.radius + bj.radius + this.collisionPadding;
if (rSquared < minDist * minDist) {
const dist = Math.sqrt(rSquared) || 0.01;
const overlap = (minDist - dist) / (2 * dist);
dx *= overlap;
dy *= overlap;
if (bi.fx === null) { si.x -= dx; si.y -= dy; }
if (bj.fx === null) { sj.x += dx; sj.y += dy; }
}
}
}
}
// RK4 integration step for smooth simulation.
RK4Step(dt) {
const n = this.bubbles.length;
const k1 = [], k2 = [], k3 = [], k4 = [];
// k values: {dx, dy, dvx, dvy} for each bubble.
// Step 1: Compute k1 from the current state.
for (let i = 0; i < n; i++) {
const s = this.state[i];
const a = this.computeAcceleration(i, this.state);
if (this.bubbles[i].fx !== null || this.bubbles[i].fy !== null)
k1.push({ dx: 0, dy: 0, dvx: 0, dvy: 0 });
else
k1.push({ dx: s.vx, dy: s.vy, dvx: a.ax, dvy: a.ay });
}
// Step 2: Compute mid-state for k2.
const state2 = [];
for (let i = 0; i < n; i++) {
const s = this.state[i];
const k = k1[i];
state2.push({
x: s.x + 0.5 * dt * k.dx,
y: s.y + 0.5 * dt * k.dy,
vx: s.vx + 0.5 * dt * k.dvx,
vy: s.vy + 0.5 * dt * k.dvy
});
}
for (let i = 0; i < n; i++) {
const a = this.computeAcceleration(i, state2);
if (this.bubbles[i].fx !== null || this.bubbles[i].fy !== null)
k2.push({ dx: 0, dy: 0, dvx: 0, dvy: 0 });
else
k2.push({ dx: state2[i].vx, dy: state2[i].vy, dvx: a.ax, dvy: a.ay });
}
// Step 3: Compute mid-state for k3.
const state3 = [];
for (let i = 0; i < n; i++) {
const s = this.state[i];
const k = k2[i];
state3.push({
x: s.x + 0.5 * dt * k.dx,
y: s.y + 0.5 * dt * k.dy,
vx: s.vx + 0.5 * dt * k.dvx,
vy: s.vy + 0.5 * dt * k.dvy
});
}
for (let i = 0; i < n; i++) {
const a = this.computeAcceleration(i, state3);
if (this.bubbles[i].fx !== null || this.bubbles[i].fy !== null)
k3.push({ dx: 0, dy: 0, dvx: 0, dvy: 0 });
else
k3.push({ dx: state3[i].vx, dy: state3[i].vy, dvx: a.ax, dvy: a.ay });
}
// Step 4: Compute state for k4.
const state4 = [];
for (let i = 0; i < n; i++) {
const s = this.state[i];
const k = k3[i];
state4.push({
x: s.x + dt * k.dx,
y: s.y + dt * k.dy,
vx: s.vx + dt * k.dvx,
vy: s.vy + dt * k.dvy
});
}
for (let i = 0; i < n; i++) {
const a = this.computeAcceleration(i, state4);
if (this.bubbles[i].fx !== null || this.bubbles[i].fy !== null)
k4.push({ dx: 0, dy: 0, dvx: 0, dvy: 0 });
else
k4.push({ dx: state4[i].vx, dy: state4[i].vy, dvx: a.ax, dvy: a.ay });
}
// Combine k's to update state.
for (let i = 0; i < n; i++) {
const bubble = this.bubbles[i];
if (bubble.fx !== null || bubble.fy !== null) {
this.state[i].x = bubble.fx;
this.state[i].y = bubble.fy;
this.state[i].vx = 0;
this.state[i].vy = 0;
} else {
const dx = (k1[i].dx + 2 * k2[i].dx + 2 * k3[i].dx + k4[i].dx) / 6;
const dy = (k1[i].dy + 2 * k2[i].dy + 2 * k3[i].dy + k4[i].dy) / 6;
const dvx = (k1[i].dvx + 2 * k2[i].dvx + 2 * k3[i].dvx + k4[i].dvx) / 6;
const dvy = (k1[i].dvy + 2 * k2[i].dvy + 2 * k3[i].dvy + k4[i].dvy) / 6;
this.state[i].x += dx * dt;
this.state[i].y += dy * dt;
this.state[i].vx += dvx * dt;
this.state[i].vy += dvy * dt;
}
}
}
// Single simulation step: apply RK4, resolve collisions, and damp velocities.
simStep() {
this.RK4Step(0.9);
this.handleCollisions(this.state);
for (let i = 0; i < this.bubbles.length; i++) {
if (this.bubbles[i].fx === null) {
this.state[i].vx *= 0.98;
this.state[i].vy *= 0.98;
} else {
this.state[i].vx = 0;
this.state[i].vy = 0;
this.state[i].x = this.bubbles[i].fx;
this.state[i].y = this.bubbles[i].fy;
}
}
}
update() {
this.simStep();
this.draw();
requestAnimationFrame(() => this.update());
}
draw() {
const ctx = this.ctx;
ctx.clearRect(0, 0, this.width, this.height);
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "16px Helvetica Neue";
// Draw each bubble using the simulation state.
for (let i = 0; i < this.bubbles.length; i++) {
const b = this.bubbles[i];
const s = this.state[i];
ctx.fillStyle = this.colorForGroup(b.groupid);
ctx.beginPath();
ctx.arc(s.x, s.y, b.radius, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "#333";
ctx.lineWidth = 1;
ctx.stroke();
if (b.name) {
ctx.fillStyle = "#fff";
ctx.fillText(b.name, s.x, s.y);
}
}
}
}
// ------------------------------
// 5. Instantiate and run the chart
// ------------------------------
document.addEventListener("DOMContentLoaded", () => {
const data = parseCSV(csvData);
new BubbleChart(data);
});
</script>
</body>
</html>