Skip to content

Commit c7fc724

Browse files
Merge branch 'main' into implementation/ecdf-basic/pygal
2 parents 1b59a26 + 41681bd commit c7fc724

12 files changed

Lines changed: 1266 additions & 579 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// anyplot.ai
2+
// ecdf-basic: Basic ECDF Plot
3+
// Library: d3 7.9.0 | JavaScript 22.22.3
4+
// Quality: 90/100 | Created: 2026-06-25
5+
6+
//# anyplot-orientation: landscape
7+
8+
const t = window.ANYPLOT_TOKENS;
9+
const { width, height } = window.ANYPLOT_SIZE;
10+
const margin = { top: 80, right: 130, bottom: 80, left: 90 };
11+
const iw = width - margin.left - margin.right;
12+
const ih = height - margin.top - margin.bottom;
13+
14+
// Deterministic LCG + Box-Muller for reproducible normal samples
15+
function makeLCG(seed) {
16+
let s = seed >>> 0;
17+
return () => { s = (Math.imul(1664525, s) + 1013904223) >>> 0; return s / 0x100000000; };
18+
}
19+
20+
function normalSamples(n, mean, std, rng) {
21+
const out = [];
22+
for (let i = 0; i < n; i++) {
23+
const u1 = Math.max(1e-12, rng());
24+
const u2 = rng();
25+
out.push(mean + std * Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2));
26+
}
27+
return out.sort((a, b) => a - b);
28+
}
29+
30+
const expressDelivery = normalSamples(160, 3.2, 0.6, makeLCG(42));
31+
const standardDelivery = normalSamples(160, 4.8, 1.3, makeLCG(137));
32+
33+
// Build ECDF step points: prepend (xStart, 0) so the line starts from 0 at left
34+
function ecdfPoints(sorted, xStart, xEnd) {
35+
const n = sorted.length;
36+
return [
37+
{ x: xStart, y: 0 },
38+
...sorted.map((v, i) => ({ x: v, y: (i + 1) / n })),
39+
{ x: xEnd, y: 1 },
40+
];
41+
}
42+
43+
const allValues = [...expressDelivery, ...standardDelivery];
44+
const pad = (d3.max(allValues) - d3.min(allValues)) * 0.04;
45+
const xDomMin = d3.min(allValues) - pad;
46+
const xDomMax = d3.max(allValues) + pad;
47+
48+
const expressECDF = ecdfPoints(expressDelivery, xDomMin, xDomMax);
49+
const standardECDF = ecdfPoints(standardDelivery, xDomMin, xDomMax);
50+
51+
// SVG mount
52+
const svg = d3.select("#container").append("svg").attr("width", width).attr("height", height);
53+
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
54+
55+
// Scales
56+
const x = d3.scaleLinear().domain([xDomMin, xDomMax]).range([0, iw]);
57+
const y = d3.scaleLinear().domain([0, 1]).range([ih, 0]);
58+
59+
// Horizontal grid lines at quartile proportions
60+
g.selectAll(".hgrid").data([0.25, 0.5, 0.75]).join("line")
61+
.attr("x1", 0).attr("x2", iw)
62+
.attr("y1", d => y(d)).attr("y2", d => y(d))
63+
.attr("stroke", t.grid).attr("stroke-width", 1);
64+
65+
// Axes
66+
const xAxis = g.append("g").attr("transform", `translate(0,${ih})`)
67+
.call(d3.axisBottom(x).ticks(8).tickSize(5));
68+
const yAxis = g.append("g")
69+
.call(d3.axisLeft(y).tickValues([0, 0.25, 0.5, 0.75, 1]).tickFormat(d3.format(".0%")).tickSize(5));
70+
71+
for (const ax of [xAxis, yAxis]) {
72+
ax.selectAll("text").attr("fill", t.inkSoft).style("font-size", "14px");
73+
ax.selectAll(".tick line").attr("stroke", t.inkSoft);
74+
ax.select(".domain").attr("stroke", t.inkSoft);
75+
}
76+
77+
// ECDF step lines — curveStepAfter matches ECDF semantics: step up AT each observation
78+
const line = d3.line().x(d => x(d.x)).y(d => y(d.y)).curve(d3.curveStepAfter);
79+
80+
g.append("path").datum(expressECDF)
81+
.attr("fill", "none").attr("stroke", t.palette[0]).attr("stroke-width", 3).attr("d", line);
82+
83+
g.append("path").datum(standardECDF)
84+
.attr("fill", "none").attr("stroke", t.palette[1]).attr("stroke-width", 3).attr("d", line);
85+
86+
// Axis labels
87+
svg.append("text")
88+
.attr("x", margin.left + iw / 2).attr("y", height - 18)
89+
.attr("text-anchor", "middle").attr("fill", t.inkSoft).style("font-size", "16px")
90+
.text("Delivery Time (days)");
91+
92+
svg.append("text")
93+
.attr("transform", "rotate(-90)")
94+
.attr("x", -(margin.top + ih / 2)).attr("y", 22)
95+
.attr("text-anchor", "middle").attr("fill", t.inkSoft).style("font-size", "16px")
96+
.text("Cumulative Proportion");
97+
98+
// Legend (top-left inside plot area — region near (xMin, y=1) is empty for ECDF)
99+
[
100+
{ label: "Express Shipping (n=160)", color: t.palette[0] },
101+
{ label: "Standard Shipping (n=160)", color: t.palette[1] },
102+
].forEach(({ label, color }, i) => {
103+
const ly = 24 + i * 32;
104+
g.append("line").attr("x1", 20).attr("x2", 58).attr("y1", ly).attr("y2", ly)
105+
.attr("stroke", color).attr("stroke-width", 3);
106+
g.append("text").attr("x", 68).attr("y", ly + 5)
107+
.attr("fill", t.inkSoft).style("font-size", "14px").text(label);
108+
});
109+
110+
// Title
111+
svg.append("text")
112+
.attr("x", width / 2).attr("y", 44).attr("text-anchor", "middle")
113+
.attr("fill", t.ink).style("font-size", "22px").style("font-weight", "600")
114+
.text("ecdf-basic · javascript · d3 · anyplot.ai");
Lines changed: 99 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
""" anyplot.ai
22
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
55
"""
66

77
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
816

917
import altair as alt
1018
import numpy as np
1119
import pandas as pd
20+
from PIL import Image
1221

1322

1423
# Theme tokens
@@ -17,56 +26,126 @@
1726
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
1827
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
1928
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
2131

2232
# Data: API response latency from a production web service
2333
np.random.seed(42)
2434
response_times_ms = np.random.normal(loc=120, scale=35, size=250)
2535
response_times_ms = np.clip(response_times_ms, 20, None)
2636

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})
2939

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+
)
3151

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 = (
3458
alt.Chart(df)
59+
.transform_window(ecdf="cume_dist()", sort=[alt.SortField("latency_ms")])
3560
.mark_line(interpolate="step-after", strokeWidth=3.5, color=BRAND)
3661
.encode(
3762
x=alt.X("latency_ms:Q", title="API Response Time (ms)", scale=alt.Scale(nice=True)),
3863
y=alt.Y(
39-
"cumulative:Q",
64+
"ecdf:Q",
4065
title="Cumulative Proportion",
4166
scale=alt.Scale(domain=[0, 1]),
4267
axis=alt.Axis(format=".0%", tickCount=11),
4368
),
4469
tooltip=[
4570
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"),
4772
],
4873
)
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()
49114
.properties(
50-
width=1600,
51-
height=900,
115+
width=620,
116+
height=320,
52117
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),
54120
)
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)
57122
.configure_axis(
58123
domainColor=INK_SOFT,
59124
tickColor=INK_SOFT,
60-
gridColor=INK,
61-
gridOpacity=0.10,
62125
labelColor=INK_SOFT,
63126
titleColor=INK,
64-
labelFontSize=18,
65-
titleFontSize=22,
127+
labelFontSize=10,
128+
titleFontSize=12,
66129
)
130+
.configure_axisX(grid=False)
131+
.configure_axisY(gridColor=INK, gridOpacity=0.13)
67132
.configure_title(color=INK)
68133
)
69134

70135
# Save
71-
chart.save(f"plot-{THEME}.png", scale_factor=3.0)
136+
chart.save(f"plot-{THEME}.png", scale_factor=4.0)
72137
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

Comments
 (0)