-
Notifications
You must be signed in to change notification settings - Fork 14
/
Copy pathcirclify.py
628 lines (508 loc) · 19.1 KB
/
circlify.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
#!/usr/bin/env python
# encoding: utf-8
"""Basic circle packing algorithm based on 2 algorithms.
Circles are first arranged via a version of A1.0 by Huang et al (see
https://home.mis.u-picardie.fr/~cli/Publis/circle.pdf for details) and then
enclosed in a circle created around them using Matoušek-Sharir-Welzl algorithm
used in d3js (see https://beta.observablehq.com/@mbostock/miniball,
http://www.inf.ethz.ch/personal/emo/PublFiles/SubexLinProg_ALG16_96.pdf, and
https://github.com/d3/d3-hierarchy/blob/master/src/pack/enclose.js)
"""
import collections
import itertools
import logging
import math
import sys
__version__ = "0.15.0"
log = logging.getLogger(__name__)
log.addHandler(logging.NullHandler())
log.addHandler(logging.StreamHandler(sys.stdout))
_eps = sys.float_info.epsilon
try: # pragma: no cover # noqa
import matplotlib.pyplot as plt
def get_default_label(count, circle):
"""Generates a default label."""
if circle.ex and "id" in circle.ex:
label = str(circle.ex["id"])
elif circle.ex and "datum" in circle.ex:
label = circle.ex["datum"]
elif circle.ex:
label = str(circle.ex)
else:
label = "#" + str(count)
return label
def _bubbles(circles, labels=None, lim=None):
"""Debugging function displays circles with matplotlib."""
if not labels:
labels = range(len(circles))
_, ax = plt.subplots(figsize=(8.0, 8.0))
for circle, label in zip(circles, labels):
x, y, r = circle
ax.add_patch(plt.Circle((x, y), r, alpha=0.2, linewidth=2, fill=False))
ax.text(x, y, label)
enclosure = enclose(circles)
n = len(circles)
if enclosure in circles:
n = n - 1
d = density(circles, enclosure)
title = "{} circles packed for density {:.4f}".format(n, d)
ax.set_title(title)
if lim is None:
lim = max(
max(
abs(circle.x) + circle.r,
abs(circle.y) + circle.r,
)
for circle in circles
)
plt.xlim(-lim, lim)
plt.ylim(-lim, lim)
plt.show()
def bubbles(circles, labels=None, lim=None):
if not labels:
labels = [get_default_label(i, c) for i, c in enumerate(circles)]
return _bubbles([c.circle for c in circles], labels, lim)
except ImportError: # pragma: no cover
pass
_Circle = collections.namedtuple("_Circle", ["x", "y", "r"])
Fields = collections.namedtuple("Fields", ["id", "datum", "children"])
class Circle:
"""Hierarchy element.
Used as an intermediate and output data structure.
"""
__slots__ = ["circle", "level", "ex"]
def __init__(self, x=0.0, y=0.0, r=1.0, level=1, ex=None):
"""Initialize Output data structure.
Args:
x (float): x coordinate of the circle center.
y (float): y coordinate of the circle center.
r (float): radius of the circle.
level (int): depth level of the data for hierarchy representation
where 0 is the root of the hierarchy.
ex (dict): additional data to be carried by the structure (e.g.
label, name, parent_id, ...)
"""
self.circle = _Circle(x, y, r)
self.level = level
self.ex = ex
def __lt__(self, other):
"""Reversed level order, then normal ordering on datum."""
return (self.level, self.r) < (other.level, other.r)
def __eq__(self, other):
"""Compare level and datum. No order on id, children and circle."""
return (self.level, self.circle, self.ex) == (
other.level,
other.circle,
other.ex,
)
def __repr__(self):
"""Representation of Output"""
return "{}(x={}, y={}, r={}, level={}, ex={!r})".format(
self.__class__.__name__, self.x, self.y, self.r, self.level, self.ex
)
def __iter__(self):
"""Convenience function to unpack circle in triple (x, y, r)"""
return [self.x, self.y, self.r].__iter__()
@property
def x(self):
return self.circle.x
@property
def y(self):
return self.circle.y
@property
def r(self):
return self.circle.r
def distance(circle1, circle2):
"""Compute distance between 2 cirlces."""
x1, y1, r1 = circle1
x2, y2, r2 = circle2
x = x2 - x1
y = y2 - y1
return math.sqrt(x * x + y * y) - r1 - r2
def get_intersection(circle1, circle2):
"""Calculate intersections of 2 circles
Based on https://gist.github.com/xaedes/974535e71009fa8f090e
Credit to http://stackoverflow.com/a/3349134/798588
Returns:
2 pairs of coordinates. Each pair can be None if there are no or just
one intersection.
"""
x1, y1, r1 = circle1
x2, y2, r2 = circle2
dx, dy = x2 - x1, y2 - y1
d = math.sqrt(dx * dx + dy * dy)
# Protect this part of the algo with try/except because edge cases
# can lead to divizion by 0 or sqrt of negative numbers. Those indicate
# that no intersection can be found and the debug log will show more info.
try:
a = (r1 * r1 - r2 * r2 + d * d) / (2 * d)
h = math.sqrt(r1 * r1 - a * a)
except (ValueError, ZeroDivisionError):
eps = 1e-9
if d > r1 + r2:
log.debug("no solution, the circles are separate: %s, %s", circle1, circle2)
if d < abs(r1 - r2) + eps:
log.debug(
"no solution, circles contained within each other: %s, %s",
circle1,
circle2,
)
if math.isclose(d, 0, abs_tol=eps) and math.isclose(
r1, r2, rel_tol=0.0, abs_tol=eps
):
log.debug("no solution, circles are coincident: %s, %s", circle1, circle2)
return None, None
xm = x1 + a * dx / d
ym = y1 + a * dy / d
xs1 = xm + h * dy / d
xs2 = xm - h * dy / d
ys1 = ym - h * dx / d
ys2 = ym + h * dx / d
if xs1 == xs2 and ys1 == ys2:
return (xs1, ys1), None
return (xs1, ys1), (xs2, ys2)
def get_placement_candidates(radius, c1, c2, margin):
"""Generate placement candidates for 2 existing placed circles.
Args:
radius: radius of the new circle to place.
c1: first existing placed circle.
c2: second existing placed circle.
margin: extra padding between the candidates and existing c1 and c2.
Returns:
A pair of candidate cirlces where one or both value can be None.
"""
margin = radius * _eps * 10.0
ic1 = _Circle(c1.x, c1.y, c1.r + (radius + margin))
ic2 = _Circle(c2.x, c2.y, c2.r + (radius + margin))
i1, i2 = get_intersection(ic1, ic2)
if i1 is None:
return None, None
i1_x, i1_y = i1
candidate1 = _Circle(i1_x, i1_y, radius)
if i2 is None:
return candidate1, None
i2_x, i2_y = i2
candidate2 = _Circle(i2_x, i2_y, radius)
return candidate1, candidate2
def get_hole_degree_radius_w(candidate, circles):
"""Calculate the hole degree of a candidate circle.
Args:
candidate: candidate circle.
circles: sequence of circles.
Returns:
Squared euclidian distance of the candidate to the circles in argument.
Each component of the distance is weighted by the inverse of the radius
of the other circle to tilt the choice towards bigger circles.
"""
return sum(distance(candidate, c) * c.r for c in circles)
def get_hole_degree_a1_0(candidate, circles):
"""Calculate the hole degree of a candidate circle.
Args:
candidate: candidate circle.
circles: sequence of circles.
Returns:
minimum distance between the candidate and the circles in argument.
In the paper, the hole degree defined as (1 - d_min / r_i) where d_min is
a minimum disance between the candidate and the circles other than the one
used to place the candidate and r_i the radius of the candidate.
"""
return min(distance(candidate, c) for c in circles)
def get_hole_degree_density(candidate, circles):
"""Calculate the hole degree of a candidate circle.
Args:
candidate: candidate circle.
circles: sequence of circles.
Returns:
One minus the density of the configuration. So the result should always
be between 0 and 1. See also density.
"""
return 1.0 - density(circles + [candidate])
def place_new_A1_0(radius, next_, const_placed_circles, get_hole_degree):
"""Place a new circle.
Args:
radius: value to be added.
next_: next value to be added after radius.
const_placed_circles: circles.
get_hole_degree: objective function to maximize.
Returns:
New configuration.
"""
placed_circles = const_placed_circles[:]
n_circles = len(placed_circles)
# If there are 2 or less, place circles on each side of (0, 0)
if n_circles <= 1:
x = radius if n_circles == 0 else -radius
circle = _Circle(x, float(0.0), radius)
placed_circles.append(circle)
return placed_circles
mhd = None
lead_candidate = None
for (c1, c2) in itertools.combinations(placed_circles, 2):
margin = radius * _eps * 10.0
# Placed circles other than the 2 circles used to find the
# candidate placement.
other_circles = [c for c in placed_circles if c not in (c1, c2)]
for cand in get_placement_candidates(radius, c1, c2, margin):
if cand is None:
continue
if not other_circles:
lead_candidate = cand
break
# If overlaps with any, skip this candidate.
if any(distance(c, cand) < 0.0 for c in other_circles):
continue
hd = get_hole_degree(cand, other_circles)
assert hd is not None, "hole degree should not be None!"
# If we were to use next_ we could use it here for look ahead.
if mhd is None or hd < mhd:
mhd = hd
lead_candidate = cand
if abs(mhd) < margin:
break
if lead_candidate is None:
# The radius is set to sqrt(value) in pack_A1_0
raise ValueError("cannot place circle for value " + str(radius**2))
placed_circles.append(lead_candidate)
return placed_circles
def pack_A1_0(data):
"""Pack circles whose area is proportional to the input data.
This algorithm is based on the Huang et al. heuristic.
Args:
data: sorted (descending) list of value to circlify.
Returns:
list of circlify.Output.
"""
min_max_ratio = min(data) / max(data)
if min_max_ratio < _eps:
log.warning(
"min to max ratio is too low at %f and it could cause algorithm stability issues. Try to remove insignificant data",
min_max_ratio,
)
assert data == sorted(data, reverse=True), "data must be sorted (desc)"
placed_circles = []
radiuses = [math.sqrt(value) for value in data]
for radius, next_ in look_ahead(radiuses):
placed_circles = place_new_A1_0(
radius, next_, placed_circles, get_hole_degree_radius_w
)
return placed_circles
def extendBasis(B, p):
"""Extend basis to ..."""
if enclosesWeakAll(p, B):
return [p]
# If we get here then B must have at least one element.
for bel in B:
if enclosesNot(p, bel) and enclosesWeakAll(encloseBasis2(bel, p), B):
return [bel, p]
# If we get here then B must have at least two elements.
for i in range(len(B) - 1):
for j in range(i + 1, len(B)):
if (
enclosesNot(encloseBasis2(B[i], B[j]), p)
and enclosesNot(encloseBasis2(B[i], p), B[j])
and enclosesNot(encloseBasis2(B[j], p), B[i])
and enclosesWeakAll(encloseBasis3(B[i], B[j], p), B)
):
return [B[i], B[j], p]
raise ValueError("If we get here then something is very wrong")
def enclosesNot(a, b):
dr = a.r - b.r
dx = b.x - a.x
dy = b.y - a.y
return dr < 0 or dr * dr < dx * dx + dy * dy
def enclosesWeak(a, b):
dr = a.r - b.r + 1e-6
dx = b.x - a.x
dy = b.y - a.y
return dr > 0 and dr * dr > dx * dx + dy * dy
def enclosesWeakAll(a, B):
for bel in B:
if not enclosesWeak(a, bel):
return False
return True
def encloseBasis(B):
if len(B) == 1:
return B[0]
elif len(B) == 2:
return encloseBasis2(B[0], B[1])
else:
return encloseBasis3(B[0], B[1], B[2])
def encloseBasis2(a, b):
x1, y1, r1 = a.x, a.y, a.r
x2, y2, r2 = b.x, b.y, b.r
x21 = x2 - x1
y21 = y2 - y1
r21 = r2 - r1
l21 = math.sqrt(x21 * x21 + y21 * y21)
return _Circle(
(x1 + x2 + x21 / l21 * r21) / 2,
(y1 + y2 + y21 / l21 * r21) / 2,
(l21 + r1 + r2) / 2,
)
def encloseBasis3(a, b, c):
x1, y1, r1 = a.x, a.y, a.r
x2, y2, r2 = b.x, b.y, b.r
x3, y3, r3 = c.x, c.y, c.r
a2 = x1 - x2
a3 = x1 - x3
b2 = y1 - y2
b3 = y1 - y3
c2 = r2 - r1
c3 = r3 - r1
d1 = x1 * x1 + y1 * y1 - r1 * r1
d2 = d1 - x2 * x2 - y2 * y2 + r2 * r2
d3 = d1 - x3 * x3 - y3 * y3 + r3 * r3
ab = a3 * b2 - a2 * b3
xa = (b2 * d3 - b3 * d2) / (ab * 2) - x1
xb = (b3 * c2 - b2 * c3) / ab
ya = (a3 * d2 - a2 * d3) / (ab * 2) - y1
yb = (a2 * c3 - a3 * c2) / ab
A = xb * xb + yb * yb - 1
B = 2 * (r1 + xa * xb + ya * yb)
C = xa * xa + ya * ya - r1 * r1
if A != 0.0:
r = -(B + math.sqrt(B * B - 4 * A * C)) / (2 * A)
else:
r = -C / B
return _Circle(x1 + xa + xb * r, y1 + ya + yb * r, r)
def enclose(circles):
"""Shamelessly adapted from d3js.
See https://github.com/d3/d3-hierarchy/blob/master/src/pack/enclose.js
"""
B = []
p, e = None, None
# random.shuffle(circles)
n = len(circles)
i = 0
while i < n:
p = circles[i]
if e is not None and enclosesWeak(e, p):
i = i + 1
else:
B = extendBasis(B, p)
e = encloseBasis(B)
i = 0
return e
def scale(circle, target, enclosure):
"""Scale circle in enclosure to fit in the target circle.
Args:
circle: Circle to scale.
target: target Circle to scale to.
enclosure: allows one to specify the enclosure.
Returns:
scaled circle
"""
r = target.r / enclosure.r
t_x, t_y = target.x, target.y
e_x, e_y = enclosure.x, enclosure.y
c_x, c_y, c_r = circle
return _Circle((c_x - e_x) * r + t_x, (c_y - e_y) * r + t_y, c_r * r)
def density(circles, enclosure=None):
"""Computes the relative density of te packed circles.
Args:
circles: packed list of circles.
Return:
Compute the enclosure if not passed as argument and calculates the
relative area of the sum of the inner cirlces to the area of the
enclosure.
"""
if not enclosure:
enclosure = enclose(circles)
return sum(c.r**2.0 for c in circles if c != enclosure) / enclosure.r**2.0
def look_ahead(iterable, n_elems=1):
"""Fetch look ahead elements of data
From https://stackoverflow.com/questions/4197805/python-for-loop-look-ahead
"""
items, nexts = itertools.tee(iterable, 2)
nexts = itertools.islice(nexts, n_elems, None)
return itertools.zip_longest(items, nexts)
def _handle(data, level, fields=None):
"""Convert possibly heterogeneous list of float or dict in list of Output.
Return:
list of list of Output. There is one list per level and the (level
specific) sub-list sorts data by descending order.
"""
if fields is None:
fields = Fields(None, None, None)
datum_field = fields.datum if fields.datum else "datum"
elements = []
for datum in data:
if isinstance(datum, dict):
value = datum[datum_field]
elements.append(Circle(r=value + 0, level=level, ex=datum))
continue
if datum <= 0.0:
raise ValueError("input data must be positive. Found " + str(datum))
if datum <= _eps:
log.warning(
"input data %f is small and could cause stability issues. Can you scale the data set up or drop insignificant elements?",
datum,
)
try:
elements.append(Circle(r=datum + 0, level=level, ex={"datum": datum}))
except TypeError: # if it fails, assume dict.
raise TypeError("dict or numeric value expected")
return sorted(elements, reverse=True)
def _circlify_level(data, target_enclosure, fields, level=1):
"""Pack and enclose circles whose radius is linked to the input data.
All the elements of data are expected to be for the same parent circle
called enclosure.
Args:
elements (list of Circle): structured data to be process.
target_enclosure (Circle): target enclosure to fit the cirlces into.
fields (Fields): field names.
level (int): level of depth in the hierarchy.
Returns:
list of circlify.Output as value for element of data.
"""
all_circles = []
if not data:
return all_circles
circles = _handle(data, 1, fields)
packed = pack_A1_0([circle.r for circle in circles])
enclosure = enclose(packed)
assert enclosure is not None
for circle, inner_circle in zip(circles, packed):
circle.level = level
circle.circle = scale(inner_circle, target_enclosure, enclosure)
if circle.ex and fields.children in circle.ex:
all_circles += _circlify_level(
circle.ex[fields.children], circle.circle, fields, level + 1
)
elif __debug__:
for key in circle.ex:
if key not in [fields.id, fields.datum, fields.children]:
log.warning("unexpected key '%s' in input is ignored", key)
all_circles.append(circle)
return all_circles
def circlify(
data,
target_enclosure=None,
show_enclosure=False,
datum_field="datum",
id_field="id",
children_field="children",
):
"""Pack and enclose circles.
Args:
data: sorted (descending) array of values.
target_enclosure: target circlify.Circle where circles should fit in.
Defaults to unit circle centered on (0, 0).
show_enclosure: insert the target enclosure to the output if True.
datum_field: field name that contains the float value when the element is
a dict.
id_field: field name that contains the id when the element is a dict.
children_field: field name that contains the children list when the
element is a dict.
Returns:
list of circlify.Circle whose *area* is proportional to the
corresponding input value. The list is sorted by ascending level
(root to leaf) and descending value (biggest circles first).
"""
fields = Fields(id=id_field, datum=datum_field, children=children_field)
if target_enclosure is None:
target_enclosure = Circle(level=0, x=0.0, y=0.0, r=1.0)
all_circles = _circlify_level(data, target_enclosure, fields)
if show_enclosure:
all_circles.append(target_enclosure)
return sorted(all_circles)