|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 2 | quiver-basic: Basic Quiver Plot |
3 | | -Library: pygal 3.1.0 | Python 3.13.11 |
4 | | -Quality: 82/100 | Created: 2025-12-23 |
| 3 | +Library: pygal 3.1.0 | Python 3.13.13 |
| 4 | +Quality: 77/100 | Updated: 2026-04-29 |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import importlib |
| 8 | +import os |
| 9 | +import sys |
| 10 | + |
7 | 11 | import numpy as np |
8 | | -import pygal |
9 | | -from pygal.style import Style |
10 | 12 |
|
11 | 13 |
|
12 | | -# Data - Wind flow pattern over a geographic area |
13 | | -# Simulating wind vectors that flow around a central low-pressure system |
| 14 | +# Remove script dir so 'pygal' resolves to the installed package, not this file |
| 15 | +_d = os.path.abspath(os.path.dirname(__file__)) |
| 16 | +sys.path = [p for p in sys.path if os.path.abspath(p) != _d] |
| 17 | +os.chdir(_d) |
| 18 | + |
| 19 | +pygal = importlib.import_module("pygal") |
| 20 | +Style = importlib.import_module("pygal.style").Style |
| 21 | + |
| 22 | +# Theme tokens |
| 23 | +THEME = os.getenv("ANYPLOT_THEME", "light") |
| 24 | +PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" |
| 25 | +INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
| 26 | +INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F" |
| 27 | + |
| 28 | +OKABE_ITO = ("#009E73", "#D55E00", "#0072B2", "#CC79A7", "#E69F00", "#56B4E9", "#F0E442") |
| 29 | + |
| 30 | +# Data — counterclockwise wind rotation around a low-pressure centre (u=-y, v=x) |
14 | 31 | np.random.seed(42) |
15 | | -grid_size = 12 |
16 | | -x_range = np.linspace(-3, 3, grid_size) # Longitude-like coordinates |
17 | | -y_range = np.linspace(-3, 3, grid_size) # Latitude-like coordinates |
| 32 | +grid_size = 8 # 8×8 = 64 arrows, well-spaced for discrete legibility |
| 33 | +x_range = np.linspace(-3, 3, grid_size) |
| 34 | +y_range = np.linspace(-3, 3, grid_size) |
18 | 35 | X, Y = np.meshgrid(x_range, y_range) |
19 | 36 | x_flat = X.flatten() |
20 | 37 | y_flat = Y.flatten() |
21 | 38 |
|
22 | | -# Circular rotation field simulating counterclockwise wind around low pressure |
23 | | -# u = -y, v = x creates the rotation pattern |
24 | 39 | U = -y_flat |
25 | 40 | V = x_flat |
26 | | - |
27 | | -# Calculate magnitude - represents wind speed (stronger further from center) |
28 | 41 | magnitude = np.sqrt(U**2 + V**2) |
29 | 42 | max_mag = magnitude.max() |
30 | | - |
31 | | -# Normalize magnitude to 0-1 range for color mapping |
32 | 43 | norm_mag = magnitude / max_mag |
33 | 44 |
|
34 | | -# Scale vectors for visual clarity |
35 | | -arrow_scale = 0.12 |
| 45 | +arrow_scale = 0.22 |
36 | 46 | U_scaled = U * arrow_scale |
37 | 47 | V_scaled = V * arrow_scale |
38 | 48 |
|
39 | | -# Arrowhead parameters |
40 | | -head_ratio = 0.4 |
| 49 | +head_ratio = 0.40 |
41 | 50 | head_angle = 0.55 |
42 | 51 |
|
43 | | -# Color palette for magnitude bins (blue to orange to red, colorblind-friendly) |
44 | | -# Low magnitude = blue/cyan, High magnitude = orange/red |
45 | | -num_bins = 5 |
46 | | -bin_colors = ["#2269a4", "#32b9b0", "#e6a020", "#e65020", "#ff3232"] |
47 | | -wind_labels = ["Calm", "Light", "Moderate", "Fresh", "Strong"] |
48 | | - |
49 | | -# Group arrows by magnitude ranges for color encoding |
50 | | -arrow_groups = {i: [] for i in range(num_bins)} |
| 52 | +num_bins = 3 |
| 53 | +wind_labels = ["Calm / Light", "Moderate", "Fresh / Strong"] |
| 54 | +bin_colors = OKABE_ITO[:num_bins] |
51 | 55 |
|
| 56 | +# Build each arrow as an isolated 9-item segment group, collected per bin |
| 57 | +arrow_bins = [[] for _ in range(num_bins)] |
52 | 58 | for i in range(len(x_flat)): |
53 | | - # Skip very weak vectors (near center) |
54 | | - if magnitude[i] < 0.05: |
| 59 | + if magnitude[i] < 0.01: |
55 | 60 | continue |
56 | | - |
57 | 61 | x1, y1 = x_flat[i], y_flat[i] |
58 | 62 | x2, y2 = x1 + U_scaled[i], y1 + V_scaled[i] |
59 | | - |
60 | | - # Calculate arrowhead size based on arrow length |
61 | 63 | arrow_len = np.sqrt(U_scaled[i] ** 2 + V_scaled[i] ** 2) |
62 | 64 | head_size = arrow_len * head_ratio |
63 | | - |
64 | | - # Calculate arrowhead points |
65 | 65 | angle = np.arctan2(V_scaled[i], U_scaled[i]) |
66 | | - x_left = x2 - head_size * np.cos(angle - head_angle) |
67 | | - y_left = y2 - head_size * np.sin(angle - head_angle) |
68 | | - x_right = x2 - head_size * np.cos(angle + head_angle) |
69 | | - y_right = y2 - head_size * np.sin(angle + head_angle) |
70 | | - |
71 | | - # Determine which bin this arrow belongs to |
| 66 | + xl = x2 - head_size * np.cos(angle - head_angle) |
| 67 | + yl = y2 - head_size * np.sin(angle - head_angle) |
| 68 | + xr = x2 - head_size * np.cos(angle + head_angle) |
| 69 | + yr = y2 - head_size * np.sin(angle + head_angle) |
72 | 70 | bin_idx = min(int(norm_mag[i] * num_bins), num_bins - 1) |
| 71 | + # Each arrow = shaft + two barb segments, each terminated with None |
| 72 | + arrow_bins[bin_idx].append([(x1, y1), (x2, y2), None, (x2, y2), (xl, yl), None, (x2, y2), (xr, yr), None]) |
73 | 73 |
|
74 | | - # Build arrow segments (shaft + arrowhead) |
75 | | - arrow_groups[bin_idx].extend([(x1, y1), (x2, y2), None]) |
76 | | - arrow_groups[bin_idx].extend([(x2, y2), (x_left, y_left), None]) |
77 | | - arrow_groups[bin_idx].extend([(x2, y2), (x_right, y_right), None]) |
78 | | - |
79 | | -# Custom style with larger fonts for readability |
| 74 | +# Shuffle arrow order within each bin to break the spatial row-order band patterns |
| 75 | +# that make consecutive arrows appear visually connected even with None breaks |
| 76 | +rng = np.random.RandomState(42) |
| 77 | +arrow_series = [] |
| 78 | +for i in range(num_bins): |
| 79 | + arrows = arrow_bins[i][:] |
| 80 | + rng.shuffle(arrows) |
| 81 | + flat = [] |
| 82 | + for arrow in arrows: |
| 83 | + flat.extend(arrow) |
| 84 | + arrow_series.append(flat) |
| 85 | + |
| 86 | +# Style |
80 | 87 | custom_style = Style( |
81 | | - background="white", |
82 | | - plot_background="white", |
83 | | - foreground="#333333", |
84 | | - foreground_strong="#333333", |
85 | | - foreground_subtle="#555555", |
86 | | - colors=tuple(bin_colors), |
87 | | - title_font_size=72, |
88 | | - label_font_size=48, |
89 | | - major_label_font_size=40, |
90 | | - legend_font_size=56, |
91 | | - value_font_size=32, |
92 | | - guide_stroke_color="#dddddd", |
| 88 | + background=PAGE_BG, |
| 89 | + plot_background=PAGE_BG, |
| 90 | + foreground=INK, |
| 91 | + foreground_strong=INK, |
| 92 | + foreground_subtle=INK_MUTED, |
| 93 | + colors=bin_colors, |
| 94 | + title_font_size=28, |
| 95 | + label_font_size=22, |
| 96 | + major_label_font_size=18, |
| 97 | + legend_font_size=16, |
| 98 | + value_font_size=14, |
| 99 | + stroke_width=3, |
93 | 100 | ) |
94 | 101 |
|
95 | | -# Create chart |
| 102 | +# Plot — thinner strokes (10 vs 20) + dot markers at each segment endpoint |
| 103 | +# clearly distinguish 64 discrete arrow positions rather than sweeping bands |
96 | 104 | chart = pygal.XY( |
97 | 105 | style=custom_style, |
98 | 106 | width=4800, |
99 | 107 | height=2700, |
100 | 108 | stroke=True, |
101 | | - stroke_style={"width": 20}, |
102 | | - show_dots=False, |
| 109 | + stroke_style={"width": 10}, |
| 110 | + show_dots=True, |
| 111 | + dot_size=4, |
103 | 112 | show_legend=True, |
104 | 113 | legend_at_bottom=True, |
105 | | - legend_at_bottom_columns=5, |
106 | | - title="quiver-basic · pygal · pyplots.ai", |
| 114 | + legend_at_bottom_columns=3, |
| 115 | + title="quiver-basic · pygal · anyplot.ai", |
107 | 116 | x_title="Longitude (degrees)", |
108 | 117 | y_title="Latitude (degrees)", |
109 | 118 | show_x_guides=True, |
|
112 | 121 | xrange=(-3.8, 3.8), |
113 | 122 | ) |
114 | 123 |
|
115 | | -# Add each magnitude group as a separate series with its color |
116 | 124 | for i in range(num_bins): |
117 | | - if arrow_groups[i]: |
118 | | - chart.add(wind_labels[i], arrow_groups[i]) |
| 125 | + if arrow_series[i]: |
| 126 | + chart.add(wind_labels[i], arrow_series[i], allow_interruptions=True) |
119 | 127 |
|
120 | | -# Save outputs |
121 | | -chart.render_to_png("plot.png") |
122 | | -chart.render_to_file("plot.html") |
| 128 | +# Save |
| 129 | +chart.render_to_png(f"plot-{THEME}.png") |
| 130 | +with open(f"plot-{THEME}.html", "wb") as f: |
| 131 | + f.write(chart.render()) |
0 commit comments