Skip to content

Commit fa41112

Browse files
feat(chartjs): implement residual-plot (#11591)
## Implementation: `residual-plot` - javascript/chartjs Implements the **javascript/chartjs** version of `residual-plot`. **File:** `plots/residual-plot/implementations/javascript/chartjs.js` **Parent Issue:** #2332 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/33965741710)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent 5101d3d commit fa41112

2 files changed

Lines changed: 439 additions & 0 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// anyplot.ai
2+
// residual-plot: Residual Plot
3+
// Library: chartjs 4.4.7 | JavaScript 22.23.2
4+
// Quality: 94/100 | Created: 2026-09-05
5+
6+
const t = window.ANYPLOT_TOKENS;
7+
8+
// --- Data (in-memory, deterministic LCG) ------------------------------------
9+
// Simulated linear-regression diagnostics: fitted house-price predictions
10+
// (in $1000s) vs. residuals, with mild heteroscedasticity (variance grows
11+
// with fitted value) so the fan-out pattern is visible.
12+
let seed = 42;
13+
function lcg() {
14+
seed = (seed * 1664525 + 1013904223) % 4294967296;
15+
return seed / 4294967296;
16+
}
17+
function gaussian() {
18+
const u1 = 1 - lcg();
19+
const u2 = lcg();
20+
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
21+
}
22+
function hexToRgba(hex, alpha) {
23+
const h = hex.replace("#", "");
24+
const r = parseInt(h.substring(0, 2), 16);
25+
const g = parseInt(h.substring(2, 4), 16);
26+
const b = parseInt(h.substring(4, 6), 16);
27+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
28+
}
29+
30+
const n = 220;
31+
const fitted = [];
32+
const residuals = [];
33+
for (let i = 0; i < n; i++) {
34+
const value = 150 + lcg() * 450; // fitted price, $150k-$600k
35+
const noiseScale = 8 + (value - 150) * 0.05; // heteroscedastic spread
36+
fitted.push(value);
37+
residuals.push(gaussian() * noiseScale);
38+
}
39+
40+
const mean = residuals.reduce((a, b) => a + b, 0) / n;
41+
const variance = residuals.reduce((a, b) => a + (b - mean) ** 2, 0) / n;
42+
const stdDev = Math.sqrt(variance);
43+
const threshold = 2 * stdDev;
44+
45+
const normalPoints = [];
46+
const outlierPoints = [];
47+
for (let i = 0; i < n; i++) {
48+
const point = { x: fitted[i], y: residuals[i] };
49+
if (Math.abs(residuals[i]) > threshold) {
50+
outlierPoints.push(point);
51+
} else {
52+
normalPoints.push(point);
53+
}
54+
}
55+
56+
const xMin = Math.min(...fitted);
57+
const xMax = Math.max(...fitted);
58+
59+
// Rolling-mean smoothing trend (sorted by fitted value) to surface any
60+
// residual non-linearity — optional per spec, adds diagnostic value.
61+
const sortedIdx = fitted.map((_, i) => i).sort((a, b) => fitted[a] - fitted[b]);
62+
const sortedX = sortedIdx.map((i) => fitted[i]);
63+
const sortedY = sortedIdx.map((i) => residuals[i]);
64+
const windowSize = Math.max(15, Math.round(n * 0.12));
65+
const trendPoints = sortedX.map((x, i) => {
66+
const lo = Math.max(0, i - Math.floor(windowSize / 2));
67+
const hi = Math.min(n, i + Math.ceil(windowSize / 2));
68+
const slice = sortedY.slice(lo, hi);
69+
const avg = slice.reduce((a, b) => a + b, 0) / slice.length;
70+
return { x, y: avg };
71+
});
72+
73+
// --- Mount -------------------------------------------------------------------
74+
const canvas = document.createElement("canvas");
75+
document.getElementById("container").appendChild(canvas);
76+
77+
// --- Chart ---------------------------------------------------------------------
78+
new Chart(canvas, {
79+
type: "scatter",
80+
data: {
81+
datasets: [
82+
{
83+
label: "±2σ band",
84+
data: [
85+
{ x: xMin, y: threshold },
86+
{ x: xMax, y: threshold },
87+
],
88+
showLine: true,
89+
borderColor: t.amber,
90+
borderWidth: 1.5,
91+
borderDash: [6, 4],
92+
pointRadius: 0,
93+
fill: "+2",
94+
backgroundColor:
95+
t.pageBg === "#1A1A17" ? "rgba(240,239,232,0.06)" : "rgba(26,26,23,0.04)",
96+
},
97+
{
98+
label: "Zero reference",
99+
data: [
100+
{ x: xMin, y: 0 },
101+
{ x: xMax, y: 0 },
102+
],
103+
showLine: true,
104+
borderColor: t.ink,
105+
borderWidth: 2,
106+
pointRadius: 0,
107+
},
108+
{
109+
label: "−2σ band",
110+
data: [
111+
{ x: xMin, y: -threshold },
112+
{ x: xMax, y: -threshold },
113+
],
114+
showLine: true,
115+
borderColor: t.amber,
116+
borderWidth: 1.5,
117+
borderDash: [6, 4],
118+
pointRadius: 0,
119+
},
120+
{
121+
label: "Residuals",
122+
data: normalPoints,
123+
backgroundColor: hexToRgba(t.palette[0], 0.7),
124+
borderColor: t.pageBg,
125+
borderWidth: 1,
126+
pointRadius: 6,
127+
pointHoverRadius: 7,
128+
},
129+
{
130+
label: "Smoothed trend",
131+
data: trendPoints,
132+
showLine: true,
133+
borderColor: t.palette[1],
134+
borderWidth: 2,
135+
borderDash: [3, 3],
136+
pointRadius: 0,
137+
fill: false,
138+
tension: 0.3,
139+
},
140+
{
141+
label: "Outliers (>2σ)",
142+
data: outlierPoints,
143+
backgroundColor: t.palette[4],
144+
borderColor: t.pageBg,
145+
borderWidth: 1,
146+
pointRadius: 7,
147+
pointStyle: "triangle",
148+
pointHoverRadius: 8,
149+
},
150+
],
151+
},
152+
options: {
153+
responsive: true,
154+
maintainAspectRatio: false,
155+
animation: false,
156+
plugins: {
157+
title: {
158+
display: true,
159+
text: "residual-plot · javascript · chartjs · anyplot.ai",
160+
color: t.ink,
161+
font: { size: 22, weight: "500" },
162+
padding: { bottom: 20 },
163+
},
164+
legend: {
165+
labels: {
166+
color: t.inkSoft,
167+
font: { size: 14 },
168+
filter: (item) => item.text !== "±2σ band" && item.text !== "−2σ band",
169+
},
170+
},
171+
tooltip: { enabled: false },
172+
},
173+
scales: {
174+
x: {
175+
type: "linear",
176+
title: { display: true, text: "Fitted Value ($1,000s)", color: t.ink, font: { size: 16 } },
177+
ticks: { color: t.inkSoft, font: { size: 14 } },
178+
grid: { color: t.grid },
179+
border: { color: t.inkSoft },
180+
},
181+
y: {
182+
title: { display: true, text: "Residual ($1,000s)", color: t.ink, font: { size: 16 } },
183+
ticks: { color: t.inkSoft, font: { size: 14 } },
184+
grid: { color: t.grid },
185+
border: { color: t.inkSoft },
186+
},
187+
},
188+
},
189+
});

0 commit comments

Comments
 (0)