|
const h = document.documentElement.clientHeight; |
|
const w = h; |
|
|
|
const deg2rad = Math.PI / 180; |
|
|
|
const color = d3.interpolateRgb('steelblue', 'brown'); |
|
|
|
// canvas |
|
const svg = d3.select('body') |
|
.append('svg') |
|
.attr('width', w) |
|
.attr('height', h) |
|
.attr('viewBox', [-w/2, -h/2, w, h].join(' ')) |
|
.append('g'); |
|
|
|
let circles, texts; |
|
|
|
var C = [ 0, 0]; |
|
var N = 40; |
|
var GAP = 2; |
|
var R; |
|
var sizes, angles; |
|
var X = []; |
|
var Y = []; |
|
|
|
|
|
// render nodes |
|
function render () { |
|
circles |
|
.attr('cx', (d, i) => X[i]) |
|
.attr('cy', (d, i) => Y[i]) |
|
.attr('r', (d) => d) |
|
.attr('fill', (d, i) => color(i / N)); |
|
|
|
texts |
|
.attr('dx', (d, i) => X[i] - (i < 10 ? 3 : 6)) |
|
.attr('dy', (d, i) => Y[i] + 4) |
|
.text((d, i) => i); |
|
} |
|
|
|
|
|
/** |
|
* Sort points clockwise around a center point |
|
* @param {number} ax |
|
* @param {number} ay |
|
* @param {number} bx |
|
* @param {number} by |
|
* @param {number} cx |
|
* @param {number} cy |
|
* @return {number} |
|
*/ |
|
function compare (ax, ay, bx, by, cx, cy) { |
|
if (ax - cx >= 0 && bx - cx < 0) return 1; |
|
if (ax - cx < 0 && bx - cx >= 0) return -1; |
|
if (ax - cx === 0 && bx - cx === 0) { |
|
return (ay - by >= 0 || by - cy >= 0) ? (ay - by) : (by - ay); |
|
} |
|
|
|
// compute the cross product of vectors (center -> a) x (center -> b) |
|
var det = (ax - cx) * (by - cy) - (bx - cx) * (ay - cy); |
|
if (det < 0) return 1; |
|
if (det > 0) return -1; |
|
|
|
// points a and b are on the same line from the center |
|
// check which point is closer to the center |
|
var d1 = (ax - cx) * (ax - cx) + (ay - cy) * (ay - cy); |
|
var d2 = (bx - cx) * (bx - cx) + (by - cy) * (by - cy); |
|
|
|
return d1 - d2; |
|
} |
|
|
|
|
|
function crossProduct (ax, ay, bx, by, cx, cy) { |
|
// compute the cross product of vectors (center -> a) x (center -> b) |
|
var det = (ax - cx) * (by - cy) - (bx - cx) * (ay - cy); |
|
if (det < 0) return 1; |
|
if (det > 0) return -1; |
|
return 0; // same dir |
|
} |
|
|
|
function dotProduct (ax, ay, bx, by, cx, cy) { |
|
return (ax - cx) * (bx - cx) + (ay - cy) * (by - cy); |
|
} |
|
|
|
function distance (ax, ay, bx, by) { |
|
var dx = ax - bx; |
|
var dy = ay - by; |
|
return Math.sqrt(dx * dx + dy * dy); |
|
} |
|
|
|
/** |
|
* Intersection point of 2 circles |
|
* |
|
* http://paulbourke.net/geometry/circlesphere/ |
|
* |
|
* @param {number} x0 |
|
* @param {number} y0 |
|
* @param {number} r0 |
|
* @param {number} x1 |
|
* @param {number} y1 |
|
* @param {number} r1 |
|
* @return {null|Array.<Array.<number>>} |
|
*/ |
|
function circleCircleIntersection (x0, y0, r0, x1, y1, r1) { |
|
let a, dx, dy, d, h, rx, ry; |
|
let x2, y2; |
|
|
|
// dx and dy are the vertical and horizontal distances between |
|
// the circle centers. |
|
dx = x1 - x0; |
|
dy = y1 - y0; |
|
|
|
// Determine the straight-line distance between the centers. */ |
|
d = Math.sqrt(dx * dx + dy * dy); |
|
|
|
// no solution. circles do not intersect. |
|
if (d > (r0 + r1)) return null; |
|
// no solution. one circle is contained in the other |
|
if (d < Math.abs(r0 - r1)) return null; |
|
|
|
// 'point 2' is the point where the line through the circle |
|
// intersection points crosses the line between the circle |
|
// centers. |
|
|
|
// Determine the distance from point 0 to point 2. |
|
a = ((r0 * r0) - (r1 * r1) + (d * d)) / (2 * d); |
|
|
|
// Determine the coordinates of point 2. |
|
x2 = x0 + (dx * a / d); |
|
y2 = y0 + (dy * a / d); |
|
|
|
// Determine the distance from point 2 to either of the |
|
// intersection points. |
|
h = Math.sqrt((r0 * r0) - (a * a)); |
|
|
|
// Now determine the offsets of the intersection points from point 2. |
|
rx = -dy * (h / d); |
|
ry = dx * (h / d); |
|
|
|
// Determine the absolute intersection points |
|
return [ |
|
[x2 + rx, y2 + ry], |
|
[x2 - rx, y2 - ry] |
|
]; |
|
} |
|
|
|
|
|
function relax ({ X, Y, sizes, indexes, R, N, center, gap }) { |
|
N = indexes.length; |
|
let I = N; |
|
let cx = center[0], |
|
cy = center[1]; |
|
let sorted = indexes.sort((a, b) => { |
|
return compare(X[a], Y[a], X[b], Y[b], cx, cy); |
|
}); |
|
|
|
console.log(sorted, center, sorted.length, N); |
|
|
|
let start = sorted[N - 1]; |
|
let x0 = X[start], y0 = Y[start]; // fix start point |
|
|
|
let hop = false; |
|
for (var i = -1; i < I - 1; i++) { |
|
var a = i % N, b = (i + 1) % N; |
|
if (i === -1) { |
|
a = 0; |
|
b = N - 1; |
|
} |
|
a = sorted[a]; |
|
b = sorted[b]; |
|
|
|
var ax = X[a], ay = Y[a]; |
|
var bx = X[b], by = Y[b]; |
|
var as = sizes[a], bs = sizes[b]; |
|
|
|
var minDist = as + bs + gap; |
|
var shift = minDist - distance(ax, ay, bx, by); |
|
var dir = compare(ax, ay, bx, by, cx, cy); |
|
|
|
if (dir === -1 && hop) dir = 1; |
|
|
|
// if order was broken or distance is too small |
|
if (dir === 1 || shift > 1e-3) { |
|
var shifted = circleCircleIntersection(cx, cy, R, ax, ay, minDist); |
|
var target = shifted[0]; |
|
|
|
hop = (bx > cx && target[0] < cx); // 0 degrees hop |
|
|
|
bx = X[b] = target[0]; |
|
by = Y[b] = target[1]; |
|
} else { |
|
hop = false; |
|
} |
|
|
|
// render (); |
|
// debugger; |
|
|
|
if (i % N === N - 2) { |
|
var last = N - 1; |
|
// only I and IV quaters of the circle, crossed the sorting 0 angle |
|
if (Y[sorted[last]] > cy && Y[sorted[0]] > cy && |
|
X[sorted[last]] - sizes[sorted[last]] < X[sorted[0]] + sizes[sorted[0]]) { |
|
var x = 0; |
|
I = Math.min(N * 10, I + N); |
|
while (Y[sorted[last]] > cy && Y[sorted[0]] > cy && |
|
X[sorted[last]] < X[sorted[0]] && ++x < N) { |
|
sorted.unshift(sorted.pop()); |
|
} |
|
} |
|
} |
|
} |
|
|
|
// rotate the whole circle back to compensate the shift |
|
var ax = x0 - cx, ay = y0 - cy, |
|
bx = X[start] - cx, by = Y[start] - cy; |
|
|
|
// can be NaN if the vector didn't move |
|
var theta = ax === bx ? 0 : Math.abs(Math.acos( |
|
(ax * bx + ay * by) / |
|
(Math.sqrt(ax * ax + ay * ay) * Math.sqrt(bx * bx + by * by)) |
|
)); |
|
|
|
if (theta !== 0) { |
|
var sin = Math.sin(-theta); |
|
var cos = Math.cos(-theta); |
|
var x, y, index; |
|
|
|
for (var i = 0; i < N; i++) { |
|
index = sorted[i]; |
|
// translate point back to origin: |
|
x = X[index] - cx; |
|
y = Y[index] - cy; |
|
|
|
// rotate and translate back |
|
X[index] = (x * cos - y * sin) + cx; |
|
Y[index] = (x * sin + y * cos) + cy; |
|
} |
|
} |
|
} |
|
|
|
function rotate (angle) { |
|
angle = angle * Math.PI / 180; |
|
var cx = C[0], cy = C[1]; |
|
var sin = Math.sin(angle); |
|
var cos = Math.cos(angle); |
|
var x, y; |
|
|
|
for (var i = 0; i < N; i++) { |
|
index = indexes[i]; |
|
// translate point back to origin: |
|
x = X[index] - cx; |
|
y = Y[index] - cy; |
|
|
|
// rotate and translate back |
|
X[index] = (x * cos - y * sin) + cx; |
|
Y[index] = (x * sin + y * cos) + cy; |
|
} |
|
|
|
render(); |
|
} |
|
|
|
function generate () { |
|
N = parseInt(document.querySelector('#n').value); |
|
sizes = new Array(N).fill(30).map(m => m - Math.random() * m); |
|
angles = new Array(N).fill(359).map(a => (0 + a * Math.random()) % 360); |
|
|
|
GAP = parseInt(document.querySelector('#gap').value); |
|
R = (sizes.reduce((a, b) => a + b * 2, 0) + GAP * 3 * (sizes.length - 1)) / (2 * Math.PI); |
|
|
|
angles.forEach((a, i) => { |
|
X[i] = C[0] + R * Math.sin(a * deg2rad); |
|
Y[i] = C[1] - R * Math.cos(a * deg2rad); |
|
}); |
|
|
|
// X = [ |
|
// 75.82254791259766, |
|
// 43.08230972290039, |
|
// 26.89663314819336, |
|
// 19.295494079589844, |
|
// 21.451080322265625, |
|
// 33.03097915649414, |
|
// 52.24942398071289, |
|
// 76.14268493652344, |
|
// 101.02611541748047, |
|
// 123.0624008178711, |
|
// 138.85324096679688, |
|
// 145.96351623535156, |
|
// 143.2967071533203, |
|
// 131.2640838623047, |
|
// 111.72122955322266, |
|
// 100.71785736083984 |
|
// ]; |
|
// Y = [ |
|
// 66.19557189941406, |
|
// 52.83225631713867, |
|
// 33.77907180786133, |
|
// 9.962634086608887, |
|
// -14.944262504577637, |
|
// -37.10065841674805, |
|
// -53.08976364135742, |
|
// -60.44585418701172, |
|
// -58.03452682495117, |
|
// -46.22765350341797, |
|
// -26.845979690551758, |
|
// -2.8784143924713135, |
|
// 21.97894287109375, |
|
// 43.89277267456055, |
|
// 59.48369216918945, |
|
// 63.91008758544922 |
|
// ]; |
|
// sizes = [ |
|
// 10, |
|
// 10, |
|
// 10, |
|
// 10, |
|
// 10, |
|
// 10, |
|
// 10, |
|
// 10, |
|
// 10, |
|
// 10, |
|
// 10, |
|
// 10, |
|
// 10, |
|
// 10, |
|
// 10, |
|
// 10 |
|
// ]; |
|
// C = [ |
|
// 82.56354522705078, |
|
// 2.8914947509765625 |
|
// ]; |
|
// R = 63.661979412695295; |
|
|
|
svg.selectAll('circle').remove(); |
|
svg.selectAll('text').remove(); |
|
|
|
circles = svg.selectAll('circle') |
|
.data(sizes) |
|
.enter() |
|
.append('circle'); |
|
|
|
svg |
|
.append('circle') |
|
.attr('class', 'center') |
|
.attr('cx', C[0]).attr('cy', C[1]) |
|
.attr('r', 2); |
|
|
|
texts = svg.selectAll('text') |
|
.data(sizes) |
|
.enter() |
|
.append('text'); |
|
|
|
render(); |
|
} |
|
|
|
generate(); |
|
|
|
document.querySelector('#generate').addEventListener('click', generate, false); |
|
document.querySelector('#relax').addEventListener('click', () => { |
|
GAP = parseInt(document.querySelector('#gap').value) |
|
console.time('relax'); |
|
relax({ |
|
X, Y, sizes, |
|
indexes: window.indexes || sizes.map((s, i) => i), |
|
R, N, gap: GAP, |
|
center: C |
|
}); |
|
console.timeEnd('relax'); |
|
render(); |
|
}, false); |