py-visactor-vchart 0.1.1__py3-none-any.whl

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 (68) hide show
  1. py_vchart/__init__.py +9 -0
  2. py_vchart/_deps/__init__.py +0 -0
  3. py_vchart/_deps/vdataset/__init__.py +26 -0
  4. py_vchart/_deps/vdataset/correlation.py +56 -0
  5. py_vchart/_deps/vlayouts/__init__.py +0 -0
  6. py_vchart/_deps/vlayouts/circle_packing.py +197 -0
  7. py_vchart/_deps/vlayouts/geo.py +97 -0
  8. py_vchart/_deps/vlayouts/hierarchy.py +102 -0
  9. py_vchart/_deps/vlayouts/sankey.py +122 -0
  10. py_vchart/_deps/vlayouts/venn.py +168 -0
  11. py_vchart/_deps/vlayouts/venn_geometry.py +102 -0
  12. py_vchart/_deps/vlayouts/word_mask.py +450 -0
  13. py_vchart/_deps/vrender/__init__.py +226 -0
  14. py_vchart/_deps/vrender/_reference_text.py +106 -0
  15. py_vchart/_deps/vrender/_state.py +22 -0
  16. py_vchart/_deps/vrender/control_icons.py +3 -0
  17. py_vchart/_deps/vrender/controls.py +89 -0
  18. py_vchart/_deps/vrender/images.py +66 -0
  19. py_vchart/_deps/vrender/text.py +48 -0
  20. py_vchart/_deps/vscale/__init__.py +42 -0
  21. py_vchart/_deps/vscale/stat_colors.py +45 -0
  22. py_vchart/chart/__init__.py +0 -0
  23. py_vchart/chart/bar.py +66 -0
  24. py_vchart/chart/candlestick.py +45 -0
  25. py_vchart/chart/cartesian.py +238 -0
  26. py_vchart/chart/cells.py +108 -0
  27. py_vchart/chart/common.py +66 -0
  28. py_vchart/chart/correlation.py +96 -0
  29. py_vchart/chart/event_steps.py +116 -0
  30. py_vchart/chart/funnel.py +81 -0
  31. py_vchart/chart/hierarchy.py +78 -0
  32. py_vchart/chart/images.py +274 -0
  33. py_vchart/chart/interval.py +56 -0
  34. py_vchart/chart/layout_helpers.py +82 -0
  35. py_vchart/chart/line.py +32 -0
  36. py_vchart/chart/liquid.py +61 -0
  37. py_vchart/chart/map.py +63 -0
  38. py_vchart/chart/plot.py +22 -0
  39. py_vchart/chart/point.py +29 -0
  40. py_vchart/chart/polar.py +101 -0
  41. py_vchart/chart/radial.py +116 -0
  42. py_vchart/chart/ranking.py +57 -0
  43. py_vchart/chart/ranking_bar.py +123 -0
  44. py_vchart/chart/sankey.py +107 -0
  45. py_vchart/chart/scatter.py +24 -0
  46. py_vchart/chart/scatter_sequence.py +216 -0
  47. py_vchart/chart/sequence.py +165 -0
  48. py_vchart/chart/stat_intervals.py +154 -0
  49. py_vchart/chart/stat_matrix.py +149 -0
  50. py_vchart/chart/stat_panels.py +318 -0
  51. py_vchart/chart/statistical.py +129 -0
  52. py_vchart/chart/statplots.py +517 -0
  53. py_vchart/chart/storyline.py +165 -0
  54. py_vchart/chart/swarm.py +128 -0
  55. py_vchart/chart/timeline.py +53 -0
  56. py_vchart/chart/venn.py +99 -0
  57. py_vchart/chart/word_cloud.py +198 -0
  58. py_vchart/core/__init__.py +207 -0
  59. py_vchart/layouts.py +209 -0
  60. py_visactor_vchart-0.1.1.dist-info/METADATA +87 -0
  61. py_visactor_vchart-0.1.1.dist-info/RECORD +68 -0
  62. py_visactor_vchart-0.1.1.dist-info/WHEEL +5 -0
  63. py_visactor_vchart-0.1.1.dist-info/licenses/LICENSE +21 -0
  64. py_visactor_vchart-0.1.1.dist-info/licenses/LICENSE-VChart +21 -0
  65. py_visactor_vchart-0.1.1.dist-info/licenses/LICENSE-fmin +27 -0
  66. py_visactor_vchart-0.1.1.dist-info/licenses/LICENSE-statplot-palettes +388 -0
  67. py_visactor_vchart-0.1.1.dist-info/licenses/LICENSE-venn-js +7 -0
  68. py_visactor_vchart-0.1.1.dist-info/top_level.txt +1 -0
py_vchart/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Python implementation of VisActor VChart."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("py-visactor-vchart")
6
+
7
+ from .core import VChart, supported_types
8
+
9
+ __all__ = ["VChart", "supported_types", "__version__"]
File without changes
@@ -0,0 +1,26 @@
1
+ """The single inline data view needed by the first Cartesian series."""
2
+
3
+ from math import isfinite
4
+
5
+
6
+ def inline_values(data, path):
7
+ views = data if isinstance(data, list) else [data]
8
+ if len(views) != 1 or not isinstance(views[0], dict):
9
+ raise ValueError(f"{path} requires one inline data view")
10
+ view = views[0]
11
+ if set(view) - {"id", "values"} or not isinstance(view.get("values"), list):
12
+ raise ValueError(f"{path}[0] requires inline values")
13
+ return view["values"]
14
+
15
+
16
+ def read_records(data, category_field, value_field, *, path="bar.data", allow_missing=False):
17
+ rows = []
18
+ for i, row in enumerate(inline_values(data, path)):
19
+ if not isinstance(row, dict) or category_field not in row or (value_field not in row and not allow_missing):
20
+ raise ValueError(f"{path}.values[{i}] is missing a required field")
21
+ category, value = row[category_field], row.get(value_field)
22
+ valid_number = type(value) in (int, float) and isfinite(value)
23
+ if type(category) not in (str, int, float) or not (valid_number or (allow_missing and value is None)):
24
+ raise ValueError(f"{path}.values[{i}] requires a scalar category and finite numeric value")
25
+ rows.append((category, value))
26
+ return rows
@@ -0,0 +1,56 @@
1
+ """Map records to correlation symbols in region-local coordinates."""
2
+
3
+ from math import ceil, cos, pi, sin
4
+
5
+
6
+ def angle_sequence(start, end):
7
+ slots = max(2, ceil(2*(end-start)/pi))
8
+ step = (end-start)/slots
9
+ origin, offset, direction = start, step/2, 1
10
+ generation = 0
11
+ while True:
12
+ for i in range(slots):
13
+ index = i//2 + (slots//2 if i % 2 else 0)
14
+ yield origin + index*step*direction
15
+ generation += 1
16
+ direction = -direction
17
+ origin = end-offset if direction < 0 else start+offset
18
+ offset /= 2
19
+ if generation >= 2:
20
+ slots *= 2
21
+ step /= 2
22
+
23
+
24
+ def map_values(values, limits, constant):
25
+ low, high = min(values), max(values)
26
+ if low == high:
27
+ return [constant]*len(values)
28
+ a, b = limits
29
+ return [a + (b-a)*(v-low)/(high-low) for v in values]
30
+
31
+
32
+ def positions(values, sizes, radii, size_range, angles, center, bounds, obstacles):
33
+ if not values:
34
+ return []
35
+ distances = map_values(values, radii, sum(radii)/2)
36
+ diameters = map_values(sizes, size_range, size_range[1])
37
+ start, end = sorted(angles)
38
+ cx, cy = center
39
+ width, height = bounds
40
+ step = (end-start)/60
41
+ result = []
42
+ blockers = []
43
+ for angle, distance, size, obstacle in zip(angle_sequence(start, end), distances, diameters, obstacles):
44
+ for _ in range(60):
45
+ x, y = cx+distance*cos(angle), cy+distance*sin(angle)
46
+ inside = size <= x <= width-size and size <= y <= height-size
47
+ if inside and not any((x-ox)**2+(y-oy)**2 < (size+r)**2 for ox, oy, r in blockers):
48
+ break
49
+ angle += step
50
+ if angle > end:
51
+ angle = start
52
+ result.append((x, y, size))
53
+ # The upstream transform checks the original x/y/size fields on prior records.
54
+ if obstacle is not None:
55
+ blockers.append(obstacle)
56
+ return result
File without changes
@@ -0,0 +1,197 @@
1
+ """Front-chain packing of weighted siblings in an enclosing disk."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from itertools import combinations
5
+ from math import hypot, sqrt
6
+ from random import Random
7
+
8
+
9
+ @dataclass
10
+ class Disk:
11
+ radius: float
12
+ x: float = 0
13
+ y: float = 0
14
+ key: object = None
15
+ children: list = field(default_factory=list)
16
+
17
+
18
+ def tangent(left, right, disk):
19
+ """Place disk on the clockwise side of the directed edge left -> right."""
20
+ dx, dy = right.x-left.x, right.y-left.y
21
+ squared = dx*dx + dy*dy
22
+ if not squared:
23
+ disk.x, disk.y = left.x + disk.radius, left.y
24
+ return
25
+ left_sq = (left.radius + disk.radius)**2
26
+ right_sq = (right.radius + disk.radius)**2
27
+ # Start from the smaller tangent distance for numerical stability.
28
+ if left_sq > right_sq:
29
+ along = (squared + right_sq - left_sq)/(2*squared)
30
+ across = sqrt(max(0, right_sq/squared - along*along))
31
+ disk.x = right.x - along*dx - across*dy
32
+ disk.y = right.y - along*dy + across*dx
33
+ else:
34
+ along = (squared + left_sq - right_sq)/(2*squared)
35
+ across = sqrt(max(0, left_sq/squared - along*along))
36
+ disk.x = left.x + along*dx - across*dy
37
+ disk.y = left.y + along*dy + across*dx
38
+
39
+
40
+ def contains(outer, inner):
41
+ return hypot(outer.x-inner.x, outer.y-inner.y) + inner.radius <= outer.radius + 1e-9*max(outer.radius, 1)
42
+
43
+
44
+ def pair_disk(a, b):
45
+ dx, dy = b.x-a.x, b.y-a.y
46
+ distance = hypot(dx, dy)
47
+ if distance <= abs(a.radius-b.radius):
48
+ return max((a, b), key=lambda d: d.radius)
49
+ radius = (distance+a.radius+b.radius)/2
50
+ ratio = (radius-a.radius)/distance
51
+ return Disk(radius, a.x+ratio*dx, a.y+ratio*dy)
52
+
53
+
54
+ def triple_disks(a, b, c):
55
+ # Subtract the first tangency equation from the other two. The center
56
+ # becomes an affine function of the enclosing radius, leaving one quadratic.
57
+ bx, by, cx, cy = b.x-a.x, b.y-a.y, c.x-a.x, c.y-a.y
58
+ determinant = bx*cy-by*cx
59
+ if not determinant:
60
+ return
61
+ def solve(p, q):
62
+ return (cy*p-by*q)/determinant, (bx*q-cx*p)/determinant
63
+ ux, uy = solve((bx*bx+by*by+a.radius**2-b.radius**2)/2,
64
+ (cx*cx+cy*cy+a.radius**2-c.radius**2)/2)
65
+ vx, vy = solve(b.radius-a.radius, c.radius-a.radius)
66
+ qa = vx*vx+vy*vy-1
67
+ qb = 2*(ux*vx+uy*vy+a.radius)
68
+ qc = ux*ux+uy*uy-a.radius**2
69
+ if abs(qa) < 1e-14:
70
+ radii = [-qc/qb] if qb else []
71
+ else:
72
+ disc = qb*qb-4*qa*qc
73
+ radii = [] if disc < 0 else [(-qb+sign*sqrt(disc))/(2*qa) for sign in (-1, 1)]
74
+ for radius in radii:
75
+ if radius >= max(a.radius, b.radius, c.radius):
76
+ yield Disk(radius, a.x+ux+radius*vx, a.y+uy+radius*vy)
77
+
78
+
79
+ def enclosing_disk(chain):
80
+ # A minimal enclosure is supported by at most three disks. When a disk
81
+ # falls outside, update the support and revisit the already seen disks.
82
+ disks = list(chain)
83
+ Random(0).shuffle(disks)
84
+ support = []
85
+ enclosure = None
86
+ index = 0
87
+ while index < len(disks):
88
+ disk = disks[index]
89
+ if enclosure is not None and contains(enclosure, disk):
90
+ index += 1
91
+ continue
92
+ previous = support + [disk]
93
+ options = [(disk, [disk])]
94
+ for a in support:
95
+ options.append((pair_disk(a, disk), [a, disk]))
96
+ for a, b in combinations(support, 2):
97
+ options.extend((circle, [a, b, disk]) for circle in triple_disks(a, b, disk))
98
+ enclosure, support = min(((circle, basis) for circle, basis in options
99
+ if all(contains(circle, p) for p in previous)), key=lambda item: item[0].radius)
100
+ index = 0
101
+ return enclosure
102
+
103
+
104
+ def edge_score(a, b):
105
+ weight = a.radius+b.radius
106
+ if not weight:
107
+ return a.x*a.x+a.y*a.y
108
+ x = (a.x*b.radius+b.x*a.radius)/weight
109
+ y = (a.y*b.radius+b.y*a.radius)/weight
110
+ return x*x+y*y
111
+
112
+
113
+ def pack(disks):
114
+ first = disks[0]
115
+ first.x = first.y = 0
116
+ if len(disks) == 1:
117
+ return first.radius
118
+ second = disks[1]
119
+ first.x, second.x, second.y = -second.radius, first.radius, 0
120
+ if len(disks) == 2:
121
+ return first.radius+second.radius
122
+ tangent(first, second, disks[2])
123
+ chain = disks[:3]
124
+ for disk in disks[3:]:
125
+ while True:
126
+ a, b = chain[:2]
127
+ tangent(b, a, disk)
128
+ ahead, behind = 2, len(chain)-1
129
+ forward, backward = b.radius, a.radius
130
+ while ahead <= behind:
131
+ scan_forward = forward <= backward
132
+ i = ahead if scan_forward else behind
133
+ other = chain[i]
134
+ if hypot(other.x-disk.x, other.y-disk.y) < other.radius+disk.radius-1e-6:
135
+ chain = [a]+chain[i:] if scan_forward else [other]+chain[1:i]
136
+ break
137
+ if scan_forward:
138
+ ahead += 1
139
+ forward += other.radius
140
+ else:
141
+ behind -= 1
142
+ backward += other.radius
143
+ else:
144
+ chain.insert(1, disk)
145
+ candidates = [0, *range(2, len(chain))]
146
+ start = min(candidates, key=lambda j: edge_score(chain[j], chain[(j+1) % len(chain)]))
147
+ chain = chain[start:]+chain[:start]
148
+ break
149
+ enclosure = enclosing_disk(chain)
150
+ for disk in disks:
151
+ disk.x -= enclosure.x
152
+ disk.y -= enclosure.y
153
+ return enclosure.radius
154
+
155
+
156
+ def packed_circles(nodes, plot, padding):
157
+ if not nodes:
158
+ return
159
+
160
+ def build(node, key):
161
+ children = [build(n, key) for n in sorted(node.children, key=lambda n: -n.weight)]
162
+ return Disk(sqrt(node.weight), key=key, children=children)
163
+
164
+ root = Disk(0, children=[build(n, n.key) for n in sorted(nodes, key=lambda n: -n.weight)])
165
+
166
+ def layout(node, level, scale):
167
+ if not node.children:
168
+ return
169
+ gap = padding(level)*scale
170
+ for child in node.children:
171
+ layout(child, level+1, scale)
172
+ child.radius += gap
173
+ node.radius = pack(node.children)+gap
174
+ for child in node.children:
175
+ child.radius -= gap
176
+
177
+ x, y, w, h = plot
178
+ size = min(w, h)
179
+ layout(root, 0, 0)
180
+ if not root.radius:
181
+ # VChart retains the marks but has no finite scale for an all-zero tree.
182
+ pending = list(reversed(root.children))
183
+ while pending:
184
+ node = pending.pop()
185
+ yield node.key, None, None, None
186
+ pending.extend(reversed(node.children))
187
+ return
188
+ layout(root, 0, root.radius/size)
189
+ scale = size/(2*root.radius)
190
+
191
+ def emit(parent, x, y):
192
+ for child in parent.children:
193
+ cx, cy = x+child.x*scale, y+child.y*scale
194
+ yield child.key, cx, cy, child.radius*scale
195
+ yield from emit(child, cx, cy)
196
+
197
+ yield from emit(root, x+w/2, y+h/2)
@@ -0,0 +1,97 @@
1
+ """Local-region Mercator paths for clockwise GeoJSON polygons."""
2
+
3
+ from math import asin, cos, hypot, isfinite, log, pi, radians, sin, tan, atan2
4
+
5
+
6
+
7
+ def _ring(raw, exterior):
8
+ if not isinstance(raw, list) or len(raw) < 4 or raw[0] != raw[-1]:
9
+ raise ValueError("map polygons require closed rings with at least three vertices")
10
+ points = []
11
+ for coord in raw[:-1]:
12
+ if not isinstance(coord, (list, tuple)) or len(coord) < 2:
13
+ raise ValueError("map requires longitude/latitude positions")
14
+ lon, lat = coord[:2]
15
+ if not all(type(n) in (int, float) and isfinite(n) for n in (lon, lat)):
16
+ raise ValueError("map requires finite coordinates")
17
+ if abs(lon) > 180 or abs(lat) >= 85:
18
+ raise ValueError("map supports local Mercator regions below 85 degrees latitude")
19
+ points.append((radians(lon), radians(lat)))
20
+ area = sum(a*d-b*c for (a,b),(c,d) in zip(points, points[1:] + points[:1]))
21
+ if (area >= 0 if exterior else area <= 0):
22
+ raise ValueError("map requires clockwise exterior rings and counterclockwise holes")
23
+ return points
24
+
25
+
26
+ def read_features(source):
27
+ if not isinstance(source, dict) or source.get("type") != "FeatureCollection" or not source.get("features"):
28
+ raise ValueError("map requires a nonempty GeoJSON FeatureCollection")
29
+ features = []
30
+ for f in source["features"]:
31
+ if not isinstance(f, dict) or f.get("type") != "Feature" or not isinstance(f.get("geometry"), dict):
32
+ raise ValueError("map requires Feature geometries")
33
+ geometry = f["geometry"]
34
+ polygons = geometry.get("coordinates")
35
+ if geometry.get("type") == "Polygon":
36
+ polygons = [polygons]
37
+ elif geometry.get("type") != "MultiPolygon":
38
+ raise ValueError("map supports Polygon and MultiPolygon")
39
+ if not isinstance(polygons, list) or not polygons or any(not isinstance(p, list) or not p for p in polygons):
40
+ raise ValueError("map requires nonempty polygons")
41
+ rings = [_ring(r, i == 0) for polygon in polygons for i, r in enumerate(polygon)]
42
+ properties = f.get("properties") or {}
43
+ if not isinstance(properties, dict):
44
+ raise ValueError("map requires object properties")
45
+ features.append((properties.copy(), rings))
46
+ return features
47
+
48
+
49
+ def _mercator(point):
50
+ lon, lat = point
51
+ return lon, -log(tan(pi / 4 + lat / 2))
52
+
53
+
54
+ def _sample(ring, scale):
55
+ # Subdivide a great-circle edge until its projected chord meets the
56
+ # native half-square-pixel tolerance. The closing edge participates too.
57
+ out = []
58
+ def edge(a, b, depth):
59
+ x, y = _mercator(a)
60
+ u, v = _mercator(b)
61
+ dx, dy = u-x, v-y
62
+ length2 = dx*dx + dy*dy
63
+ if depth == 0 or length2 * scale**2 <= 2:
64
+ return
65
+ ax, ay, az = cos(a[1])*cos(a[0]), cos(a[1])*sin(a[0]), sin(a[1])
66
+ bx, by, bz = cos(b[1])*cos(b[0]), cos(b[1])*sin(b[0]), sin(b[1])
67
+ sx, sy, sz = ax+bx, ay+by, az+bz
68
+ mid = atan2(sy, sx), asin(sz/hypot(sx, sy, sz))
69
+ mx, my = _mercator(mid)
70
+ rx, ry = mx-x, my-y
71
+ if ((dy*rx-dx*ry)**2 * scale**2 > 0.5*length2
72
+ or abs((dx*rx+dy*ry)/length2 - 0.5) > 0.3
73
+ or ax*bx+ay*by+az*bz < cos(pi/6)):
74
+ edge(a, mid, depth-1)
75
+ out.append((mx, my))
76
+ edge(mid, b, depth-1)
77
+
78
+ for i, a in enumerate(ring):
79
+ out.append(_mercator(a))
80
+ edge(a, ring[(i+1) % len(ring)], 16)
81
+ return out
82
+
83
+
84
+ def fit_paths(features, plot):
85
+ px, py, width, height = plot
86
+ rings = [ring for _, feature in features for ring in feature]
87
+ longitudes = [lon for ring in rings for lon, _ in ring]
88
+ if max(longitudes)-min(longitudes) >= pi:
89
+ raise ValueError("map requires a region spanning less than 180 degrees longitude")
90
+ points = [p for ring in rings for p in _sample(ring, 150)]
91
+ xs, ys = zip(*points)
92
+ left, right, top, bottom = min(xs), max(xs), min(ys), max(ys)
93
+ scale = min(width/(right-left), height/(bottom-top))
94
+ tx = px + (width-scale*(right+left))/2
95
+ ty = py + (height-scale*(bottom+top))/2
96
+ return [[[[tx+x*scale, ty+y*scale] for x,y in _sample(ring, scale)] for ring in rings]
97
+ for _, rings in features]
@@ -0,0 +1,102 @@
1
+ """Weighted hierarchy, rectangular partitions and concentric rings."""
2
+
3
+ from bisect import bisect_left
4
+ from dataclasses import dataclass
5
+ from itertools import accumulate
6
+ from math import isfinite
7
+
8
+
9
+ @dataclass
10
+ class Node:
11
+ key: object
12
+ weight: float
13
+ children: list
14
+ levels: int
15
+
16
+
17
+ def hierarchy(rows, key, value):
18
+ nodes = []
19
+ for row in rows:
20
+ if not isinstance(row, dict) or not isinstance(row.get(key), (int, float, str)):
21
+ raise ValueError("Hierarchy nodes require a scalar category")
22
+ children = row.get("children", [])
23
+ if not isinstance(children, list):
24
+ raise ValueError("Hierarchy children must be an array")
25
+ descendants = hierarchy(children, key, value)
26
+ weight = row.get(value, 0)
27
+ if type(weight) not in (int, float) or not isfinite(weight) or weight < 0:
28
+ raise ValueError("Hierarchy weights must be finite and nonnegative")
29
+ nodes.append(Node(row[key], max(weight, sum(n.weight for n in descendants)), descendants,
30
+ 1 + max((n.levels for n in descendants), default=0)))
31
+ return nodes
32
+
33
+
34
+ def binary_tiles(nodes, weight, box):
35
+ """Split the longer edge at the sibling boundary nearest half the weight."""
36
+ sums = list(accumulate((n.weight for n in nodes), initial=0))
37
+ pending = [(0, len(nodes), weight, box)] if nodes else []
38
+ while pending:
39
+ first, stop, total, bounds = pending.pop()
40
+ if stop == first + 1:
41
+ yield nodes[first], bounds
42
+ continue
43
+ target = sums[first] + total/2
44
+ cut = min(bisect_left(sums, target, first+1, stop), stop-1)
45
+ if cut > first+1 and target-sums[cut-1] < sums[cut]-target:
46
+ cut -= 1
47
+ part = sums[cut] - sums[first]
48
+ x, y, right, bottom = bounds
49
+ fraction = part / total if total else 1
50
+ if right-x > bottom-y:
51
+ split = x + (right-x)*fraction
52
+ left_box, right_box = (x,y,split,bottom), (split,y,right,bottom)
53
+ else:
54
+ split = y + (bottom-y)*fraction
55
+ left_box, right_box = (x,y,right,split), (x,split,right,bottom)
56
+ pending.append((cut, stop, total-part, right_box))
57
+ pending.append((first, cut, part, left_box))
58
+
59
+
60
+ def inset(box, amount):
61
+ x, y, right, bottom = box
62
+ dx = min(amount, (right-x)/2)
63
+ dy = min(amount, (bottom-y)/2)
64
+ return [x+dx, y+dy, right-dx, bottom-dy]
65
+
66
+
67
+ def treemap_tiles(roots, box, gap, padding):
68
+ total = sum(node.weight for node in roots)
69
+ area = (box[2]-box[0])*(box[3]-box[1])
70
+
71
+ def visible(node):
72
+ return total > 0 and node.weight*area/total >= 10
73
+
74
+ def visit(node, bounds, depth, key):
75
+ children = [child for child in node.children if visible(child)]
76
+ if not children:
77
+ yield key, bounds
78
+ return
79
+ space = padding(depth)
80
+ if 2*space < min(bounds[2]-bounds[0], bounds[3]-bounds[1]):
81
+ bounds = inset(bounds, space)
82
+ for child, tile in binary_tiles(children, node.weight, inset(bounds, -gap/2)):
83
+ yield from visit(child, inset(tile, gap/2), depth+1, key)
84
+
85
+ for node, tile in binary_tiles([n for n in roots if visible(n)], total, inset(box, -gap/2)):
86
+ yield from visit(node, inset(tile, gap/2), 0, node.key)
87
+
88
+
89
+ def sunburst_arcs(roots, angles, radii):
90
+ levels = max((node.levels for node in roots), default=1)
91
+ step = (radii[1]-radii[0])/levels
92
+
93
+ def visit(nodes, total, start, end, depth, root=None):
94
+ scale = (end-start)/total if total else 0
95
+ for node in nodes:
96
+ key = node.key if root is None else root
97
+ stop = start + node.weight*scale
98
+ yield key, (radii[0]+depth*step, radii[0]+(depth+1)*step, start, stop)
99
+ yield from visit(node.children, node.weight, start, stop, depth+1, key)
100
+ start = stop
101
+
102
+ yield from visit(roots, sum(n.weight for n in roots), *angles, 0)
@@ -0,0 +1,122 @@
1
+ """Layer and size an acyclic, weighted node-link graph in logical pixels."""
2
+
3
+ from collections import deque
4
+ from dataclasses import dataclass, field
5
+
6
+
7
+ @dataclass(eq=False)
8
+ class Node:
9
+ key: object
10
+ category: object
11
+ value: float
12
+ incoming: list = field(default_factory=list)
13
+ outgoing: list = field(default_factory=list)
14
+ depth: int = 0
15
+ tail: int = 0
16
+ x: float = 0
17
+ y: float = 0
18
+ height: float = 0
19
+
20
+
21
+ @dataclass(eq=False)
22
+ class Link:
23
+ source: Node
24
+ target: Node
25
+ value: float
26
+ thickness: float = 0
27
+ start: float = 0
28
+ end: float = 0
29
+
30
+ def __post_init__(self):
31
+ self.source.outgoing.append(self)
32
+ self.target.incoming.append(self)
33
+
34
+
35
+ def layers(nodes, align):
36
+ pending = {n: len(n.incoming) for n in nodes}
37
+ queue = deque(n for n in nodes if not pending[n])
38
+ ordered = []
39
+ while queue:
40
+ node = queue.popleft()
41
+ ordered.append(node)
42
+ for edge in node.outgoing:
43
+ child = edge.target
44
+ child.depth = max(child.depth, node.depth+1)
45
+ pending[child] -= 1
46
+ if not pending[child]:
47
+ queue.append(child)
48
+ if len(ordered) != len(nodes):
49
+ raise ValueError("sankey requires an acyclic graph")
50
+ last = max(n.depth for n in nodes)
51
+ for n in reversed(ordered):
52
+ n.tail = max((link.target.tail+1 for link in n.outgoing), default=0)
53
+ columns = [[] for _ in range(last+1)]
54
+ for n in nodes:
55
+ index = n.depth
56
+ if align in ("right", "end"):
57
+ index = last-n.tail
58
+ elif align == "justify" and not n.outgoing:
59
+ index = last
60
+ elif align == "center" and not n.incoming and n.outgoing:
61
+ index = min(link.target.depth for link in n.outgoing)-1
62
+ columns[index].append(n)
63
+ return columns
64
+
65
+
66
+ def assign_ports(nodes):
67
+ for node in nodes:
68
+ # Incoming bands follow the vertical order of their source nodes.
69
+ node.incoming.sort(key=lambda link: link.source.y)
70
+ for links, attr in [(node.outgoing, "start"), (node.incoming, "end")]:
71
+ offset = 0
72
+ for link in links:
73
+ offset = min(offset + link.thickness, node.height)
74
+ setattr(link, attr, node.y + offset-link.thickness/2)
75
+
76
+
77
+ def sankey_layout(nodes, length, breadth, width, gap, minimum, align, cross):
78
+ if not nodes:
79
+ return 0
80
+ columns = layers(nodes, align)
81
+ steps = len(columns)-1
82
+ if isinstance(width, str):
83
+ ratio = float(width[:-1])/100
84
+ step = length/(steps+ratio) if steps+ratio else 0
85
+ width = step*ratio
86
+ else:
87
+ step = (length-width)/steps if steps else float("inf")
88
+ for n in nodes:
89
+ n.value = max(n.value, sum(edge.value for edge in n.incoming), sum(edge.value for edge in n.outgoing))
90
+ max_step = breadth/max(map(len, columns))
91
+ gap = min(gap, max_step)
92
+ if minimum+gap > max_step:
93
+ gap = max_step/2 if minimum >= max_step else (max_step-minimum)/2
94
+ minimum = min(max_step-gap, minimum)
95
+ scales = []
96
+ for column in columns:
97
+ value = sum(n.value for n in column)
98
+ if value:
99
+ available = breadth-(len(column)-1)*max(gap, minimum)-minimum
100
+ scales.append(available/value)
101
+ scale = min(scales, default=float("inf"))
102
+ for i, column in enumerate(columns):
103
+ y = 0
104
+ for n in column:
105
+ n.x, n.y = i*step, y
106
+ raw_height = max(n.value*scale, 0)
107
+ n.height = max(raw_height, minimum)
108
+ for edge in n.outgoing:
109
+ edge.thickness = raw_height*(edge.value/n.value) if n.value else 0
110
+ y += n.height+gap
111
+ spare = breadth-y+gap
112
+ for j, n in enumerate(column):
113
+ if spare < 0 and len(column)>1:
114
+ correction = spare/(len(column)-1)
115
+ if gap+correction >= 0:
116
+ n.y += j*correction
117
+ elif spare > 0 and cross == "end":
118
+ n.y += spare
119
+ elif spare > 0 and cross == "middle":
120
+ n.y += spare/(len(column)+1)*(j+1)
121
+ assign_ports(nodes)
122
+ return width