Skip to content

Commit 4b5b4d5

Browse files
Merge branch 'main' into implementation/cat-box-strip/highcharts
2 parents a76eb02 + f2c639a commit 4b5b4d5

10 files changed

Lines changed: 2002 additions & 0 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// anyplot.ai
2+
// cat-box-strip: Box Plot with Strip Overlay
3+
// Library: chartjs 4.4.7 | JavaScript 22.23.2
4+
// Quality: 88/100 | Created: 2026-09-02
5+
6+
const t = window.ANYPLOT_TOKENS;
7+
8+
// --- Data (in-memory, deterministic LCG + Box-Muller) -----------------------
9+
let lcgState = 42;
10+
function nextRandom() {
11+
lcgState = (lcgState * 1103515245 + 12345) % 2147483648;
12+
return lcgState / 2147483648;
13+
}
14+
function nextGaussian() {
15+
const u1 = Math.max(nextRandom(), 1e-9);
16+
const u2 = nextRandom();
17+
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
18+
}
19+
function hexToRgba(hex, alpha) {
20+
const r = parseInt(hex.slice(1, 3), 16);
21+
const g = parseInt(hex.slice(3, 5), 16);
22+
const b = parseInt(hex.slice(5, 7), 16);
23+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
24+
}
25+
function quantile(sortedValues, p) {
26+
const idx = p * (sortedValues.length - 1);
27+
const lower = Math.floor(idx);
28+
const upper = Math.ceil(idx);
29+
if (lower === upper) return sortedValues[lower];
30+
return (
31+
sortedValues[lower] +
32+
(sortedValues[upper] - sortedValues[lower]) * (idx - lower)
33+
);
34+
}
35+
36+
const categories = ["Low Light", "Medium Light", "High Light", "Full Sun"];
37+
const means = [12, 22, 34, 41];
38+
const sds = [3, 4, 5, 6];
39+
const sampleSize = 80;
40+
41+
const groupValues = categories.map((_, i) => {
42+
const values = [];
43+
for (let j = 0; j < sampleSize; j++) {
44+
values.push(Math.max(1, means[i] + sds[i] * nextGaussian()));
45+
}
46+
return values;
47+
});
48+
49+
const stats = groupValues.map((values) => {
50+
const sorted = [...values].sort((a, b) => a - b);
51+
const q1 = quantile(sorted, 0.25);
52+
const median = quantile(sorted, 0.5);
53+
const q3 = quantile(sorted, 0.75);
54+
const iqr = q3 - q1;
55+
const lowerFence = q1 - 1.5 * iqr;
56+
const upperFence = q3 + 1.5 * iqr;
57+
const withinLower = sorted.filter((v) => v >= lowerFence);
58+
const withinUpper = sorted.filter((v) => v <= upperFence);
59+
return {
60+
q1,
61+
median,
62+
q3,
63+
whiskerMin: withinLower.length ? withinLower[0] : sorted[0],
64+
whiskerMax: withinUpper.length
65+
? withinUpper[withinUpper.length - 1]
66+
: sorted[sorted.length - 1],
67+
};
68+
});
69+
70+
const globalMin = Math.min(...groupValues.flat());
71+
const globalMax = Math.max(...groupValues.flat());
72+
const medianEpsilon = (globalMax - globalMin) * 0.01;
73+
74+
// --- Mount -------------------------------------------------------------------
75+
const canvas = document.createElement("canvas");
76+
document.getElementById("container").appendChild(canvas);
77+
78+
// --- Chart (floating bars for box/whisker/median, scatter for strip) --------
79+
const whiskerDataset = {
80+
type: "bar",
81+
label: "Whisker range",
82+
data: stats.map((s, i) => ({ x: i, y: [s.whiskerMin, s.whiskerMax] })),
83+
backgroundColor: (ctx) =>
84+
hexToRgba(t.palette[ctx.dataIndex % t.palette.length], 0.5),
85+
barThickness: 6,
86+
borderWidth: 0,
87+
grouped: false,
88+
};
89+
90+
const boxDataset = {
91+
type: "bar",
92+
label: "Interquartile range",
93+
data: stats.map((s, i) => ({ x: i, y: [s.q1, s.q3] })),
94+
backgroundColor: (ctx) =>
95+
hexToRgba(t.palette[ctx.dataIndex % t.palette.length], 0.35),
96+
borderColor: (ctx) => t.palette[ctx.dataIndex % t.palette.length],
97+
borderWidth: 2,
98+
borderSkipped: false,
99+
barThickness: 90,
100+
grouped: false,
101+
};
102+
103+
const medianDataset = {
104+
type: "bar",
105+
label: "Median",
106+
data: stats.map((s, i) => ({
107+
x: i,
108+
y: [s.median - medianEpsilon, s.median + medianEpsilon],
109+
})),
110+
backgroundColor: t.ink,
111+
borderWidth: 0,
112+
borderSkipped: false,
113+
barThickness: 90,
114+
grouped: false,
115+
};
116+
117+
const stripDatasets = categories.map((category, i) => ({
118+
type: "scatter",
119+
label: category,
120+
data: groupValues[i].map((value) => ({
121+
x: i + (nextRandom() - 0.5) * 0.36,
122+
y: value,
123+
})),
124+
backgroundColor: hexToRgba(t.palette[i % t.palette.length], 0.45),
125+
pointRadius: 3.5,
126+
pointHoverRadius: 3.5,
127+
pointBorderWidth: 1,
128+
pointBorderColor: t.pageBg,
129+
}));
130+
131+
// Whisker end caps: short horizontal strokes at whiskerMin/whiskerMax so the
132+
// range reads unambiguously as a box-plot whisker rather than a plain bar.
133+
const capDataset = {
134+
type: "scatter",
135+
label: "Whisker caps",
136+
data: stats.flatMap((s, i) => [
137+
{ x: i, y: s.whiskerMin },
138+
{ x: i, y: s.whiskerMax },
139+
]),
140+
pointStyle: "line",
141+
pointRotation: 0,
142+
pointRadius: 20,
143+
pointBorderColor: (ctx) =>
144+
t.palette[Math.floor(ctx.dataIndex / 2) % t.palette.length],
145+
pointBorderWidth: 2.5,
146+
showLine: false,
147+
};
148+
149+
new Chart(canvas, {
150+
type: "bar",
151+
data: {
152+
datasets: [
153+
whiskerDataset,
154+
boxDataset,
155+
medianDataset,
156+
...stripDatasets,
157+
capDataset,
158+
],
159+
},
160+
options: {
161+
responsive: true,
162+
maintainAspectRatio: false,
163+
animation: false,
164+
plugins: {
165+
title: {
166+
display: true,
167+
text: "cat-box-strip · javascript · chartjs · anyplot.ai",
168+
color: t.ink,
169+
font: { size: 22 },
170+
},
171+
legend: { display: false },
172+
},
173+
scales: {
174+
x: {
175+
type: "linear",
176+
min: -0.6,
177+
max: categories.length - 1 + 0.6,
178+
ticks: {
179+
stepSize: 1,
180+
color: t.inkSoft,
181+
font: { size: 14 },
182+
callback: (value) =>
183+
categories[value] !== undefined ? categories[value] : "",
184+
},
185+
grid: { display: false },
186+
title: {
187+
display: true,
188+
text: "Light Condition",
189+
color: t.ink,
190+
font: { size: 16 },
191+
},
192+
},
193+
y: {
194+
ticks: { color: t.inkSoft, font: { size: 14 } },
195+
grid: { color: t.grid },
196+
title: {
197+
display: true,
198+
text: "Plant Height (cm)",
199+
color: t.ink,
200+
font: { size: 16 },
201+
},
202+
},
203+
},
204+
},
205+
});
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// anyplot.ai
2+
// cat-box-strip: Box Plot with Strip Overlay
3+
// Library: echarts 6.1.0 | JavaScript 22.23.2
4+
// Quality: 86/100 | Created: 2026-09-02
5+
6+
//# anyplot-orientation: landscape
7+
const t = window.ANYPLOT_TOKENS;
8+
9+
// --- Deterministic PRNG (LCG) + Box-Muller gaussian -------------------------
10+
let seed = 42;
11+
function lcg() {
12+
seed = (seed * 1664525 + 1013904223) % 4294967296;
13+
return seed / 4294967296;
14+
}
15+
function gaussian(mean, std) {
16+
const u1 = Math.max(lcg(), 1e-12);
17+
const u2 = lcg();
18+
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
19+
return mean + z * std;
20+
}
21+
22+
// --- Box-plot summary stats (Tukey whiskers, 1.5x IQR fence) ---------------
23+
function boxStats(values) {
24+
const sorted = [...values].sort((a, b) => a - b);
25+
const quantile = (p) => {
26+
const pos = (sorted.length - 1) * p;
27+
const lo = Math.floor(pos);
28+
const hi = Math.ceil(pos);
29+
return lo === hi
30+
? sorted[lo]
31+
: sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
32+
};
33+
const q1 = quantile(0.25);
34+
const median = quantile(0.5);
35+
const q3 = quantile(0.75);
36+
const iqr = q3 - q1;
37+
const lowerFence = q1 - 1.5 * iqr;
38+
const upperFence = q3 + 1.5 * iqr;
39+
const withinFence = sorted.filter((v) => v >= lowerFence && v <= upperFence);
40+
const whiskerMin = withinFence.length ? withinFence[0] : sorted[0];
41+
const whiskerMax = withinFence.length
42+
? withinFence[withinFence.length - 1]
43+
: sorted[sorted.length - 1];
44+
return [whiskerMin, q1, median, q3, whiskerMax];
45+
}
46+
47+
// --- Data: marathon finish times (minutes) by runner age group -------------
48+
const ageGroups = ["20-29", "30-39", "40-49", "50-59", "60+"];
49+
const meansByGroup = [245, 252, 268, 288, 315];
50+
const stdsByGroup = [28, 30, 32, 34, 36];
51+
const countsByGroup = [58, 65, 62, 45, 34];
52+
53+
const rawByGroup = ageGroups.map((_, i) => {
54+
const values = [];
55+
for (let j = 0; j < countsByGroup[i]; j++) {
56+
values.push(Math.max(150, gaussian(meansByGroup[i], stdsByGroup[i])));
57+
}
58+
return values;
59+
});
60+
61+
const boxData = rawByGroup.map(boxStats);
62+
const medians = boxData.map((d) => d[2]);
63+
const medianDelta = Math.round(medians[medians.length - 1] - medians[0]);
64+
65+
// Strip overlay: every individual runner, jittered around its category slot
66+
const stripPoints = [];
67+
rawByGroup.forEach((values, catIndex) => {
68+
values.forEach((v) => {
69+
const jitter = (lcg() - 0.5) * 0.72;
70+
stripPoints.push([catIndex + jitter, v]);
71+
});
72+
});
73+
74+
// --- Init --------------------------------------------------------------
75+
const chart = echarts.init(document.getElementById("container"));
76+
77+
// --- Option --------------------------------------------------------------
78+
chart.setOption({
79+
animation: false,
80+
color: t.palette,
81+
backgroundColor: "transparent",
82+
title: {
83+
text: "cat-box-strip · javascript · echarts · anyplot.ai",
84+
left: "center",
85+
textStyle: { color: t.ink, fontSize: 22, fontWeight: 500 },
86+
},
87+
legend: {
88+
data: ["Distribution", "Runners"],
89+
top: 50,
90+
textStyle: { color: t.inkSoft, fontSize: 14 },
91+
},
92+
grid: { left: 100, right: 60, top: 110, bottom: 90 },
93+
xAxis: {
94+
type: "category",
95+
data: ageGroups,
96+
name: "Runner Age Group",
97+
nameLocation: "middle",
98+
nameGap: 45,
99+
nameTextStyle: { color: t.ink, fontSize: 16 },
100+
axisLabel: { color: t.inkSoft, fontSize: 14 },
101+
axisLine: { lineStyle: { color: t.inkSoft } },
102+
axisTick: { show: false },
103+
splitLine: { show: false },
104+
},
105+
yAxis: {
106+
type: "value",
107+
scale: true,
108+
name: "Finish Time (minutes)",
109+
nameLocation: "middle",
110+
nameGap: 60,
111+
nameTextStyle: { color: t.ink, fontSize: 16 },
112+
axisLabel: { color: t.inkSoft, fontSize: 14 },
113+
axisLine: { lineStyle: { color: t.inkSoft } },
114+
splitLine: { lineStyle: { color: t.grid } },
115+
},
116+
series: [
117+
{
118+
name: "Distribution",
119+
type: "boxplot",
120+
data: boxData,
121+
boxWidth: [20, 40],
122+
itemStyle: {
123+
color: "transparent",
124+
borderColor: t.palette[0],
125+
borderWidth: 2.5,
126+
},
127+
markLine: {
128+
silent: true,
129+
symbol: ["none", "none"],
130+
lineStyle: { color: t.inkSoft, type: "dashed", width: 1.5 },
131+
label: {
132+
show: true,
133+
color: t.inkSoft,
134+
fontSize: 12,
135+
position: "middle",
136+
formatter: `Median +${medianDelta} min (20-29 → 60+)`,
137+
},
138+
data: [
139+
[
140+
{ coord: [ageGroups[0], medians[0]] },
141+
{ coord: [ageGroups[ageGroups.length - 1], medians[medians.length - 1]] },
142+
],
143+
],
144+
},
145+
z: 2,
146+
},
147+
{
148+
name: "Runners",
149+
type: "scatter",
150+
data: stripPoints,
151+
symbolSize: 7,
152+
itemStyle: { color: t.palette[0], opacity: 0.4 },
153+
z: 3,
154+
silent: true,
155+
},
156+
],
157+
});

0 commit comments

Comments
 (0)