|
| 1 | +""" |
| 2 | +bullet-basic: Basic Bullet Chart |
| 3 | +Library: pygal |
| 4 | +
|
| 5 | +Pygal lacks a native bullet chart type. We use HorizontalStackedBar for qualitative |
| 6 | +ranges, then inject custom SVG elements for actual value bars and target markers. |
| 7 | +""" |
| 8 | + |
| 9 | +import cairosvg |
| 10 | +import pygal |
| 11 | +from pygal.style import Style |
| 12 | + |
| 13 | + |
| 14 | +# Data - Sales KPIs showing actual vs target with qualitative ranges |
| 15 | +metrics = [ |
| 16 | + {"label": "Revenue", "actual": 275, "target": 250, "max": 300, "unit": "$K"}, |
| 17 | + {"label": "Profit", "actual": 85, "target": 100, "max": 120, "unit": "$K"}, |
| 18 | + {"label": "New Orders", "actual": 320, "target": 350, "max": 400, "unit": ""}, |
| 19 | + {"label": "Customers", "actual": 1450, "target": 1400, "max": 1600, "unit": ""}, |
| 20 | + {"label": "Satisfaction", "actual": 4.2, "target": 4.5, "max": 5.0, "unit": "/5"}, |
| 21 | +] |
| 22 | + |
| 23 | +# Qualitative range thresholds as percentage of max |
| 24 | +POOR_PCT = 50 |
| 25 | +SATISFACTORY_PCT = 75 |
| 26 | + |
| 27 | +# Custom style for bullet chart with grayscale bands |
| 28 | +custom_style = Style( |
| 29 | + background="white", |
| 30 | + plot_background="white", |
| 31 | + foreground="#333333", |
| 32 | + foreground_strong="#333333", |
| 33 | + foreground_subtle="#666666", |
| 34 | + colors=( |
| 35 | + "#E0E0E0", # Poor range (lightest) |
| 36 | + "#B8B8B8", # Satisfactory range |
| 37 | + "#909090", # Good range (darkest) |
| 38 | + "#306998", # Actual value (Python blue) |
| 39 | + "#1a1a1a", # Target marker (black) |
| 40 | + ), |
| 41 | + title_font_size=72, |
| 42 | + label_font_size=42, |
| 43 | + major_label_font_size=40, |
| 44 | + legend_font_size=36, |
| 45 | + value_font_size=32, |
| 46 | + tooltip_font_size=32, |
| 47 | +) |
| 48 | + |
| 49 | +# Create horizontal stacked bar chart for range bands |
| 50 | +chart = pygal.HorizontalStackedBar( |
| 51 | + width=4800, |
| 52 | + height=2700, |
| 53 | + title="bullet-basic · pygal · pyplots.ai", |
| 54 | + style=custom_style, |
| 55 | + show_legend=True, |
| 56 | + legend_at_bottom=True, |
| 57 | + legend_box_size=28, |
| 58 | + print_values=False, |
| 59 | + show_y_guides=False, |
| 60 | + show_x_guides=True, |
| 61 | + x_label_rotation=0, |
| 62 | + margin=80, |
| 63 | + spacing=40, |
| 64 | + x_title="Performance (% of Maximum)", |
| 65 | + range=(0, 100), |
| 66 | +) |
| 67 | + |
| 68 | +# Build labels and range data |
| 69 | +labels = [] |
| 70 | +poor_vals = [] |
| 71 | +satisfactory_vals = [] |
| 72 | +good_vals = [] |
| 73 | +actual_pcts = [] |
| 74 | +target_pcts = [] |
| 75 | + |
| 76 | +for m in metrics: |
| 77 | + actual_pct = (m["actual"] / m["max"]) * 100 |
| 78 | + target_pct = (m["target"] / m["max"]) * 100 |
| 79 | + labels.append(f"{m['label']} ({m['actual']}{m['unit']})") |
| 80 | + # Stacked segments for qualitative ranges |
| 81 | + poor_vals.append(POOR_PCT) |
| 82 | + satisfactory_vals.append(SATISFACTORY_PCT - POOR_PCT) |
| 83 | + good_vals.append(100 - SATISFACTORY_PCT) |
| 84 | + actual_pcts.append(actual_pct) |
| 85 | + target_pcts.append(target_pct) |
| 86 | + |
| 87 | +chart.x_labels = labels |
| 88 | +chart.add("Poor (0-50%)", poor_vals) |
| 89 | +chart.add("Satisfactory (50-75%)", satisfactory_vals) |
| 90 | +chart.add("Good (75-100%)", good_vals) |
| 91 | + |
| 92 | +# Render base chart to SVG, then inject actual bars and target markers |
| 93 | +svg_string = chart.render().decode("utf-8") |
| 94 | + |
| 95 | +# Pygal plot coordinates (from SVG analysis): |
| 96 | +# Plot area: transform="translate(624, 192)", width=4096, height=2104 |
| 97 | +# Bars start at x=78.77 within plot (total from origin: 624 + 78.77 = 702.77) |
| 98 | +# X-axis 0 maps to ~78.77, 100 maps to ~4017.23 (range of ~3938.46 pixels) |
| 99 | +PLOT_OFFSET_X = 624 |
| 100 | +PLOT_OFFSET_Y = 192 |
| 101 | +BAR_START_X = 78.77 # Within plot coordinates |
| 102 | +X_SCALE = 39.3846 # pixels per percentage point (3938.46 / 100) |
| 103 | + |
| 104 | +# Row Y positions (from SVG: y values in <desc> tags) - relative to plot origin |
| 105 | +ROW_Y_CENTERS = [1861.23, 1456.62, 1052.0, 647.38, 242.77] # Revenue, Profit, Orders, Customers, Satisfaction |
| 106 | + |
| 107 | +# Build custom SVG elements for actual bars and target markers |
| 108 | +custom_elements = [] |
| 109 | +for i, (actual_pct, target_pct) in enumerate(zip(actual_pcts, target_pcts, strict=True)): |
| 110 | + # Calculate positions in plot coordinates |
| 111 | + y_center = PLOT_OFFSET_Y + ROW_Y_CENTERS[i] |
| 112 | + x_start = PLOT_OFFSET_X + BAR_START_X |
| 113 | + |
| 114 | + # Actual value bar - narrower bar centered on row |
| 115 | + actual_width = actual_pct * X_SCALE |
| 116 | + bar_height = 100 # Thinner than the range bands |
| 117 | + custom_elements.append( |
| 118 | + f'<rect x="{x_start}" y="{y_center - bar_height / 2}" ' |
| 119 | + f'width="{actual_width}" height="{bar_height}" fill="#306998"/>' |
| 120 | + ) |
| 121 | + |
| 122 | + # Target marker - thin vertical line extending beyond the bar |
| 123 | + target_x = x_start + target_pct * X_SCALE |
| 124 | + marker_height = 180 |
| 125 | + marker_width = 12 |
| 126 | + custom_elements.append( |
| 127 | + f'<rect x="{target_x - marker_width / 2}" y="{y_center - marker_height / 2}" ' |
| 128 | + f'width="{marker_width}" height="{marker_height}" fill="#1a1a1a"/>' |
| 129 | + ) |
| 130 | + |
| 131 | +# Add legend entries for Actual and Target (positioned after existing legend items) |
| 132 | +legend_y = 2574 # Same line as existing legend |
| 133 | +legend_x_actual = 3200 |
| 134 | +legend_x_target = 3700 |
| 135 | +custom_elements.append(f'<rect x="{legend_x_actual}" y="{legend_y}" width="28" height="28" fill="#306998"/>') |
| 136 | +custom_elements.append( |
| 137 | + f'<text x="{legend_x_actual + 40}" y="{legend_y + 24}" ' |
| 138 | + f'font-family="Consolas, monospace" font-size="36" fill="#333">Actual</text>' |
| 139 | +) |
| 140 | +custom_elements.append(f'<rect x="{legend_x_target}" y="{legend_y}" width="28" height="28" fill="#1a1a1a"/>') |
| 141 | +custom_elements.append( |
| 142 | + f'<text x="{legend_x_target + 40}" y="{legend_y + 24}" ' |
| 143 | + f'font-family="Consolas, monospace" font-size="36" fill="#333">Target</text>' |
| 144 | +) |
| 145 | + |
| 146 | +# Inject custom elements before closing </svg> |
| 147 | +injection = "\n".join(custom_elements) |
| 148 | +svg_output = svg_string.replace("</svg>", f"{injection}\n</svg>") |
| 149 | + |
| 150 | +# Save SVG and PNG |
| 151 | +with open("plot.html", "w") as f: |
| 152 | + f.write(svg_output) |
| 153 | + |
| 154 | +cairosvg.svg2png(bytestring=svg_output.encode(), write_to="plot.png") |
0 commit comments