|
1 | 1 | """ anyplot.ai |
2 | 2 | ecdf-basic: Basic ECDF Plot |
3 | | -Library: altair 6.1.0 | Python 3.14.4 |
4 | | -Quality: 86/100 | Updated: 2026-04-24 |
| 3 | +Library: altair 6.2.2 | Python 3.13.14 |
| 4 | +Quality: 90/100 | Updated: 2026-06-25 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import os |
| 8 | +import sys |
| 9 | + |
| 10 | + |
| 11 | +# The file is named altair.py; remove its own directory from sys.path so |
| 12 | +# `import altair` resolves to the library, not this script. |
| 13 | +_HERE = os.path.dirname(os.path.abspath(__file__)) |
| 14 | +sys.path = [p for p in sys.path if not p or os.path.abspath(p) != _HERE] |
| 15 | +os.chdir(_HERE) # saves (plot-*.png, plot-*.html) land in the implementations dir |
8 | 16 |
|
9 | 17 | import altair as alt |
10 | 18 | import numpy as np |
11 | 19 | import pandas as pd |
| 20 | +from PIL import Image |
12 | 21 |
|
13 | 22 |
|
14 | 23 | # Theme tokens |
|
17 | 26 | ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420" |
18 | 27 | INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
19 | 28 | INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" |
20 | | -BRAND = "#009E73" |
| 29 | +INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F" |
| 30 | +BRAND = "#009E73" # Imprint palette position 1 — always first series |
21 | 31 |
|
22 | 32 | # Data: API response latency from a production web service |
23 | 33 | np.random.seed(42) |
24 | 34 | response_times_ms = np.random.normal(loc=120, scale=35, size=250) |
25 | 35 | response_times_ms = np.clip(response_times_ms, 20, None) |
26 | 36 |
|
27 | | -sorted_latency = np.sort(response_times_ms) |
28 | | -cumulative_proportion = np.arange(1, len(sorted_latency) + 1) / len(sorted_latency) |
| 37 | +# Raw data frame — ECDF computed declaratively via Vega-Lite window transform |
| 38 | +df = pd.DataFrame({"latency_ms": response_times_ms}) |
29 | 39 |
|
30 | | -df = pd.DataFrame({"latency_ms": sorted_latency, "cumulative": cumulative_proportion}) |
| 40 | +# Reference values at quartiles for focal emphasis and text annotations |
| 41 | +p25_ms = float(np.percentile(response_times_ms, 25)) |
| 42 | +p50_ms = float(np.median(response_times_ms)) |
| 43 | +p75_ms = float(np.percentile(response_times_ms, 75)) |
| 44 | +ref_df = pd.DataFrame( |
| 45 | + { |
| 46 | + "latency_ms": [p25_ms, p50_ms, p75_ms], |
| 47 | + "cumulative": [0.25, 0.50, 0.75], |
| 48 | + "label": [f"~{p25_ms:.0f}ms", f"~{p50_ms:.0f}ms", f"~{p75_ms:.0f}ms"], |
| 49 | + } |
| 50 | +) |
31 | 51 |
|
32 | | -# Chart |
33 | | -chart = ( |
| 52 | +# Title |
| 53 | +title_str = "ecdf-basic · python · altair · anyplot.ai" |
| 54 | + |
| 55 | +# ECDF step function — cume_dist() window transform computes the ECDF declaratively |
| 56 | +# in Vega-Lite without numpy preprocessing; step-after gives the correct step shape |
| 57 | +ecdf_line = ( |
34 | 58 | alt.Chart(df) |
| 59 | + .transform_window(ecdf="cume_dist()", sort=[alt.SortField("latency_ms")]) |
35 | 60 | .mark_line(interpolate="step-after", strokeWidth=3.5, color=BRAND) |
36 | 61 | .encode( |
37 | 62 | x=alt.X("latency_ms:Q", title="API Response Time (ms)", scale=alt.Scale(nice=True)), |
38 | 63 | y=alt.Y( |
39 | | - "cumulative:Q", |
| 64 | + "ecdf:Q", |
40 | 65 | title="Cumulative Proportion", |
41 | 66 | scale=alt.Scale(domain=[0, 1]), |
42 | 67 | axis=alt.Axis(format=".0%", tickCount=11), |
43 | 68 | ), |
44 | 69 | tooltip=[ |
45 | 70 | alt.Tooltip("latency_ms:Q", title="Latency (ms)", format=".1f"), |
46 | | - alt.Tooltip("cumulative:Q", title="Proportion", format=".3f"), |
| 71 | + alt.Tooltip("ecdf:Q", title="Proportion", format=".3f"), |
47 | 72 | ], |
48 | 73 | ) |
| 74 | +) |
| 75 | + |
| 76 | +# Dashed reference lines spanning the full axes at Q1, median, Q3 |
| 77 | +h_rules = ( |
| 78 | + alt.Chart(ref_df) |
| 79 | + .mark_rule(strokeDash=[5, 4], strokeWidth=1.5, color=INK_MUTED, opacity=0.75) |
| 80 | + .encode(y="cumulative:Q") |
| 81 | +) |
| 82 | + |
| 83 | +v_rules = ( |
| 84 | + alt.Chart(ref_df) |
| 85 | + .mark_rule(strokeDash=[5, 4], strokeWidth=1.5, color=INK_MUTED, opacity=0.75) |
| 86 | + .encode(x="latency_ms:Q") |
| 87 | +) |
| 88 | + |
| 89 | +# Focal markers at quartile intersections on the ECDF |
| 90 | +focal_pts = ( |
| 91 | + alt.Chart(ref_df) |
| 92 | + .mark_point(size=120, filled=True, color=BRAND, opacity=1.0) |
| 93 | + .encode( |
| 94 | + x="latency_ms:Q", |
| 95 | + y="cumulative:Q", |
| 96 | + tooltip=[ |
| 97 | + alt.Tooltip("latency_ms:Q", title="Latency (ms)", format=".1f"), |
| 98 | + alt.Tooltip("cumulative:Q", title="Quartile", format=".0%"), |
| 99 | + ], |
| 100 | + ) |
| 101 | +) |
| 102 | + |
| 103 | +# Text annotations at focal points for at-a-glance percentile reading without hover |
| 104 | +focal_labels = ( |
| 105 | + alt.Chart(ref_df) |
| 106 | + .mark_text(align="left", dx=8, dy=-5, fontSize=9, color=INK_SOFT, fontWeight="bold") |
| 107 | + .encode(x="latency_ms:Q", y="cumulative:Q", text="label:N") |
| 108 | +) |
| 109 | + |
| 110 | +# Compose layers and configure theme-adaptive chrome |
| 111 | +chart = ( |
| 112 | + alt.layer(ecdf_line, h_rules, v_rules, focal_pts, focal_labels) |
| 113 | + .interactive() |
49 | 114 | .properties( |
50 | | - width=1600, |
51 | | - height=900, |
| 115 | + width=620, |
| 116 | + height=320, |
52 | 117 | background=PAGE_BG, |
53 | | - title=alt.Title("ecdf-basic · altair · anyplot.ai", fontSize=28, color=INK), |
| 118 | + padding={"left": 0, "right": 0, "top": 0, "bottom": 0}, |
| 119 | + title=alt.Title(title_str, fontSize=16, color=INK), |
54 | 120 | ) |
55 | | - .interactive() |
56 | | - .configure_view(fill=PAGE_BG, strokeWidth=0) |
| 121 | + .configure_view(fill=PAGE_BG, stroke=INK_SOFT, strokeWidth=0, continuousWidth=620, continuousHeight=320) |
57 | 122 | .configure_axis( |
58 | 123 | domainColor=INK_SOFT, |
59 | 124 | tickColor=INK_SOFT, |
60 | | - gridColor=INK, |
61 | | - gridOpacity=0.10, |
62 | 125 | labelColor=INK_SOFT, |
63 | 126 | titleColor=INK, |
64 | | - labelFontSize=18, |
65 | | - titleFontSize=22, |
| 127 | + labelFontSize=10, |
| 128 | + titleFontSize=12, |
66 | 129 | ) |
| 130 | + .configure_axisX(grid=False) |
| 131 | + .configure_axisY(gridColor=INK, gridOpacity=0.13) |
67 | 132 | .configure_title(color=INK) |
68 | 133 | ) |
69 | 134 |
|
70 | 135 | # Save |
71 | | -chart.save(f"plot-{THEME}.png", scale_factor=3.0) |
| 136 | +chart.save(f"plot-{THEME}.png", scale_factor=4.0) |
72 | 137 | chart.save(f"plot-{THEME}.html") |
| 138 | + |
| 139 | +# Canvas: pad to exactly 3200×1800 with PAGE_BG (vl-convert inner-view padding lands short) |
| 140 | +TW, TH = 3200, 1800 |
| 141 | +_img = Image.open(f"plot-{THEME}.png").convert("RGB") |
| 142 | +_w, _h = _img.size |
| 143 | +if _w > TW or _h > TH: |
| 144 | + raise SystemExit( |
| 145 | + f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. " |
| 146 | + f"Shrink chart .properties(width=, height=) values and re-render." |
| 147 | + ) |
| 148 | +if _w < TW or _h < TH: |
| 149 | + _canvas = Image.new("RGB", (TW, TH), PAGE_BG) |
| 150 | + _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2)) |
| 151 | + _canvas.save(f"plot-{THEME}.png") |
0 commit comments