Skip to content

Commit b289fad

Browse files
feat(highcharts): implement residual-plot (#11599)
## Implementation: `residual-plot` - javascript/highcharts Implements the **javascript/highcharts** version of `residual-plot`. **File:** `plots/residual-plot/implementations/javascript/highcharts.js` **Parent Issue:** #2332 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/33966218658)* --------- 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 f55197a commit b289fad

2 files changed

Lines changed: 427 additions & 0 deletions

File tree

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// anyplot.ai
2+
// residual-plot: Residual Plot
3+
// Library: highcharts 12.6.0 | JavaScript 22.23.2
4+
// Quality: 88/100 | Created: 2026-09-05
5+
6+
//# anyplot-orientation: landscape
7+
const t = window.ANYPLOT_TOKENS;
8+
9+
// --- Data (in-memory, deterministic LCG) ------------------------------------
10+
// Simulated house-price regression: fitted values (predicted price, $k) vs.
11+
// residuals (observed - predicted), with mild heteroscedasticity so the
12+
// diagnostic value of the plot is visible (funnel-shaped spread).
13+
function lcg(seed) {
14+
let state = seed;
15+
return () => {
16+
state = (state * 1664525 + 1013904223) % 4294967296;
17+
return state / 4294967296;
18+
};
19+
}
20+
function gaussian(rand) {
21+
const u1 = Math.max(rand(), 1e-9);
22+
const u2 = rand();
23+
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
24+
}
25+
26+
const rand = lcg(42);
27+
const n = 220;
28+
const fitted = [];
29+
const residuals = [];
30+
for (let i = 0; i < n; i++) {
31+
const price = 150 + rand() * 500; // fitted house price, $k
32+
const noiseScale = 8 + price * 0.08; // variance grows with fitted value
33+
const residual = gaussian(rand) * noiseScale;
34+
fitted.push(Number(price.toFixed(1)));
35+
residuals.push(Number(residual.toFixed(1)));
36+
}
37+
38+
const mean = residuals.reduce((a, b) => a + b, 0) / n;
39+
const variance = residuals.reduce((a, b) => a + (b - mean) ** 2, 0) / n;
40+
const sd = Math.sqrt(variance);
41+
const threshold = 2 * sd;
42+
43+
const inlierPoints = [];
44+
const outlierPoints = [];
45+
for (let i = 0; i < n; i++) {
46+
const point = { x: fitted[i], y: residuals[i] };
47+
if (Math.abs(residuals[i]) > threshold) {
48+
outlierPoints.push(point);
49+
} else {
50+
inlierPoints.push(point);
51+
}
52+
}
53+
54+
const xMin = Math.min(...fitted);
55+
const xMax = Math.max(...fitted);
56+
57+
// Binned-mean smoothing line: makes the heteroscedastic (funnel-shaped) trend
58+
// explicit without pulling in a full LOWESS implementation.
59+
const sortedPoints = fitted.map((x, i) => ({ x, y: residuals[i] })).sort((a, b) => a.x - b.x);
60+
const binCount = 10;
61+
const binWidth = (xMax - xMin) / binCount;
62+
const trendPoints = [];
63+
for (let b = 0; b < binCount; b++) {
64+
const lo = xMin + b * binWidth;
65+
const hi = lo + binWidth;
66+
const inBin = sortedPoints.filter((p) => p.x >= lo && (b === binCount - 1 ? p.x <= hi : p.x < hi));
67+
if (inBin.length === 0) continue;
68+
const meanX = inBin.reduce((a, p) => a + p.x, 0) / inBin.length;
69+
const meanY = inBin.reduce((a, p) => a + p.y, 0) / inBin.length;
70+
trendPoints.push([Number(meanX.toFixed(1)), Number(meanY.toFixed(2))]);
71+
}
72+
73+
// --- Chart -------------------------------------------------------------------
74+
Highcharts.chart("container", {
75+
chart: {
76+
type: "scatter",
77+
backgroundColor: "transparent",
78+
animation: false,
79+
style: { fontFamily: "inherit" },
80+
},
81+
credits: { enabled: false },
82+
colors: t.palette,
83+
title: {
84+
text: "residual-plot · javascript · highcharts · anyplot.ai",
85+
style: { color: t.ink, fontSize: "22px", fontWeight: "600" },
86+
},
87+
xAxis: {
88+
title: { text: "Fitted Value ($k)", style: { color: t.inkSoft, fontSize: "16px" } },
89+
lineColor: t.inkSoft,
90+
tickColor: t.inkSoft,
91+
gridLineColor: t.grid,
92+
gridLineWidth: 1,
93+
min: xMin - 20,
94+
max: xMax + 20,
95+
labels: { style: { color: t.inkSoft, fontSize: "14px" } },
96+
},
97+
yAxis: {
98+
title: { text: "Residual ($k)", style: { color: t.inkSoft, fontSize: "16px" } },
99+
lineColor: t.inkSoft,
100+
tickColor: t.inkSoft,
101+
gridLineColor: t.grid,
102+
gridLineWidth: 1,
103+
labels: { style: { color: t.inkSoft, fontSize: "14px" } },
104+
plotLines: [
105+
{
106+
value: 0,
107+
color: t.ink,
108+
width: 2,
109+
zIndex: 3,
110+
},
111+
],
112+
plotBands: [
113+
{
114+
from: -threshold,
115+
to: threshold,
116+
color: Highcharts.color(t.inkSoft).setOpacity(0.08).get("rgba"),
117+
zIndex: 0,
118+
},
119+
],
120+
},
121+
legend: {
122+
itemStyle: { color: t.inkSoft, fontSize: "14px" },
123+
itemHoverStyle: { color: t.ink },
124+
},
125+
tooltip: {
126+
formatter: function () {
127+
return `<b>Fitted:</b> $${this.x}k<br/><b>Residual:</b> $${this.y}k`;
128+
},
129+
},
130+
plotOptions: {
131+
series: { animation: false },
132+
scatter: {
133+
marker: { radius: 4, symbol: "circle" },
134+
},
135+
},
136+
series: [
137+
{
138+
name: "Residuals (within ±2 SD)",
139+
data: inlierPoints,
140+
color: t.palette[0],
141+
marker: { fillColor: Highcharts.color(t.palette[0]).setOpacity(0.5).get("rgba"), lineWidth: 0 },
142+
},
143+
{
144+
name: "Outliers (beyond ±2 SD)",
145+
data: outlierPoints,
146+
color: t.palette[4],
147+
marker: { radius: 6, lineColor: t.ink, lineWidth: 1 },
148+
dataLabels: {
149+
enabled: true,
150+
formatter: function () {
151+
return (this.y < 0 ? "-$" : "$") + Math.abs(this.y).toFixed(1) + "k";
152+
},
153+
y: -12,
154+
style: { color: t.ink, fontSize: "11px", fontWeight: "600", textOutline: "none" },
155+
},
156+
},
157+
{
158+
name: "Trend (binned mean)",
159+
type: "spline",
160+
data: trendPoints,
161+
color: t.palette[1],
162+
lineWidth: 2.5,
163+
dashStyle: "ShortDash",
164+
marker: { enabled: false },
165+
enableMouseTracking: false,
166+
zIndex: 2,
167+
},
168+
],
169+
});

0 commit comments

Comments
 (0)