Skip to content

Commit 9fd330b

Browse files
github-actions[bot]claudeMarkusNeusinger
authored
feat(pygal): implement quiver-basic (#5564)
## Implementation: `quiver-basic` - python/pygal Implements the **python/pygal** version of `quiver-basic`. **File:** `plots/quiver-basic/implementations/python/pygal.py` **Parent Issue:** #1014 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/25137241599)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent 892cb50 commit 9fd330b

2 files changed

Lines changed: 314 additions & 96 deletions

File tree

Lines changed: 79 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,109 +1,118 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
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
55
"""
66

7+
import importlib
8+
import os
9+
import sys
10+
711
import numpy as np
8-
import pygal
9-
from pygal.style import Style
1012

1113

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)
1431
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)
1835
X, Y = np.meshgrid(x_range, y_range)
1936
x_flat = X.flatten()
2037
y_flat = Y.flatten()
2138

22-
# Circular rotation field simulating counterclockwise wind around low pressure
23-
# u = -y, v = x creates the rotation pattern
2439
U = -y_flat
2540
V = x_flat
26-
27-
# Calculate magnitude - represents wind speed (stronger further from center)
2841
magnitude = np.sqrt(U**2 + V**2)
2942
max_mag = magnitude.max()
30-
31-
# Normalize magnitude to 0-1 range for color mapping
3243
norm_mag = magnitude / max_mag
3344

34-
# Scale vectors for visual clarity
35-
arrow_scale = 0.12
45+
arrow_scale = 0.22
3646
U_scaled = U * arrow_scale
3747
V_scaled = V * arrow_scale
3848

39-
# Arrowhead parameters
40-
head_ratio = 0.4
49+
head_ratio = 0.40
4150
head_angle = 0.55
4251

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]
5155

56+
# Build each arrow as an isolated 9-item segment group, collected per bin
57+
arrow_bins = [[] for _ in range(num_bins)]
5258
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:
5560
continue
56-
5761
x1, y1 = x_flat[i], y_flat[i]
5862
x2, y2 = x1 + U_scaled[i], y1 + V_scaled[i]
59-
60-
# Calculate arrowhead size based on arrow length
6163
arrow_len = np.sqrt(U_scaled[i] ** 2 + V_scaled[i] ** 2)
6264
head_size = arrow_len * head_ratio
63-
64-
# Calculate arrowhead points
6565
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)
7270
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])
7373

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
8087
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,
93100
)
94101

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
96104
chart = pygal.XY(
97105
style=custom_style,
98106
width=4800,
99107
height=2700,
100108
stroke=True,
101-
stroke_style={"width": 20},
102-
show_dots=False,
109+
stroke_style={"width": 10},
110+
show_dots=True,
111+
dot_size=4,
103112
show_legend=True,
104113
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",
107116
x_title="Longitude (degrees)",
108117
y_title="Latitude (degrees)",
109118
show_x_guides=True,
@@ -112,11 +121,11 @@
112121
xrange=(-3.8, 3.8),
113122
)
114123

115-
# Add each magnitude group as a separate series with its color
116124
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)
119127

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

Comments
 (0)