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.
- py_vchart/__init__.py +9 -0
- py_vchart/_deps/__init__.py +0 -0
- py_vchart/_deps/vdataset/__init__.py +26 -0
- py_vchart/_deps/vdataset/correlation.py +56 -0
- py_vchart/_deps/vlayouts/__init__.py +0 -0
- py_vchart/_deps/vlayouts/circle_packing.py +197 -0
- py_vchart/_deps/vlayouts/geo.py +97 -0
- py_vchart/_deps/vlayouts/hierarchy.py +102 -0
- py_vchart/_deps/vlayouts/sankey.py +122 -0
- py_vchart/_deps/vlayouts/venn.py +168 -0
- py_vchart/_deps/vlayouts/venn_geometry.py +102 -0
- py_vchart/_deps/vlayouts/word_mask.py +450 -0
- py_vchart/_deps/vrender/__init__.py +226 -0
- py_vchart/_deps/vrender/_reference_text.py +106 -0
- py_vchart/_deps/vrender/_state.py +22 -0
- py_vchart/_deps/vrender/control_icons.py +3 -0
- py_vchart/_deps/vrender/controls.py +89 -0
- py_vchart/_deps/vrender/images.py +66 -0
- py_vchart/_deps/vrender/text.py +48 -0
- py_vchart/_deps/vscale/__init__.py +42 -0
- py_vchart/_deps/vscale/stat_colors.py +45 -0
- py_vchart/chart/__init__.py +0 -0
- py_vchart/chart/bar.py +66 -0
- py_vchart/chart/candlestick.py +45 -0
- py_vchart/chart/cartesian.py +238 -0
- py_vchart/chart/cells.py +108 -0
- py_vchart/chart/common.py +66 -0
- py_vchart/chart/correlation.py +96 -0
- py_vchart/chart/event_steps.py +116 -0
- py_vchart/chart/funnel.py +81 -0
- py_vchart/chart/hierarchy.py +78 -0
- py_vchart/chart/images.py +274 -0
- py_vchart/chart/interval.py +56 -0
- py_vchart/chart/layout_helpers.py +82 -0
- py_vchart/chart/line.py +32 -0
- py_vchart/chart/liquid.py +61 -0
- py_vchart/chart/map.py +63 -0
- py_vchart/chart/plot.py +22 -0
- py_vchart/chart/point.py +29 -0
- py_vchart/chart/polar.py +101 -0
- py_vchart/chart/radial.py +116 -0
- py_vchart/chart/ranking.py +57 -0
- py_vchart/chart/ranking_bar.py +123 -0
- py_vchart/chart/sankey.py +107 -0
- py_vchart/chart/scatter.py +24 -0
- py_vchart/chart/scatter_sequence.py +216 -0
- py_vchart/chart/sequence.py +165 -0
- py_vchart/chart/stat_intervals.py +154 -0
- py_vchart/chart/stat_matrix.py +149 -0
- py_vchart/chart/stat_panels.py +318 -0
- py_vchart/chart/statistical.py +129 -0
- py_vchart/chart/statplots.py +517 -0
- py_vchart/chart/storyline.py +165 -0
- py_vchart/chart/swarm.py +128 -0
- py_vchart/chart/timeline.py +53 -0
- py_vchart/chart/venn.py +99 -0
- py_vchart/chart/word_cloud.py +198 -0
- py_vchart/core/__init__.py +207 -0
- py_vchart/layouts.py +209 -0
- py_visactor_vchart-0.1.1.dist-info/METADATA +87 -0
- py_visactor_vchart-0.1.1.dist-info/RECORD +68 -0
- py_visactor_vchart-0.1.1.dist-info/WHEEL +5 -0
- py_visactor_vchart-0.1.1.dist-info/licenses/LICENSE +21 -0
- py_visactor_vchart-0.1.1.dist-info/licenses/LICENSE-VChart +21 -0
- py_visactor_vchart-0.1.1.dist-info/licenses/LICENSE-fmin +27 -0
- py_visactor_vchart-0.1.1.dist-info/licenses/LICENSE-statplot-palettes +388 -0
- py_visactor_vchart-0.1.1.dist-info/licenses/LICENSE-venn-js +7 -0
- py_visactor_vchart-0.1.1.dist-info/top_level.txt +1 -0
py_vchart/__init__.py
ADDED
|
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
|