Skip to content

Commit d04b9b1

Browse files
feat(highcharts): implement mosaic-categorical (#11052)
## Implementation: `mosaic-categorical` - javascript/highcharts Implements the **javascript/highcharts** version of `mosaic-categorical`. **File:** `plots/mosaic-categorical/implementations/javascript/highcharts.js` **Parent Issue:** #3650 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/33597666721)* --------- 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 762e838 commit d04b9b1

2 files changed

Lines changed: 453 additions & 0 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// anyplot.ai
2+
// mosaic-categorical: Mosaic Plot for Categorical Association Analysis
3+
// Library: highcharts 12.6.0 | JavaScript 22.23.2
4+
// Quality: 92/100 | Created: 2026-09-02
5+
6+
const t = window.ANYPLOT_TOKENS;
7+
8+
// --- Data (in-memory, deterministic) ----------------------------------------
9+
// Contingency table: performance rating counts by department.
10+
const departments = ["Engineering", "Sales", "Marketing", "Support"];
11+
const ratings = ["Exceeds", "Meets", "Below"];
12+
const counts = [
13+
[42, 58, 10], // Engineering
14+
[35, 70, 25], // Sales
15+
[20, 45, 15], // Marketing
16+
[15, 38, 12], // Support
17+
];
18+
19+
const colTotals = counts.map((row) => row.reduce((a, b) => a + b, 0));
20+
const grandTotal = colTotals.reduce((a, b) => a + b, 0);
21+
22+
// Column boundaries in raw-count data units — these back the real xAxis scale
23+
// (0..grandTotal) so tick centers and column widths both derive from it.
24+
let cum = 0;
25+
const colStart = [];
26+
const colEnd = [];
27+
colTotals.forEach((ct) => {
28+
colStart.push(cum);
29+
cum += ct;
30+
colEnd.push(cum);
31+
});
32+
const colCenters = colStart.map((s, i) => (s + colEnd[i]) / 2);
33+
34+
// Precomputed luminance-based text color per rating (a lookup, not a per-cell helper call).
35+
function luminance(hex) {
36+
const r = parseInt(hex.slice(1, 3), 16);
37+
const g = parseInt(hex.slice(3, 5), 16);
38+
const b = parseInt(hex.slice(5, 7), 16);
39+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
40+
}
41+
const textColors = ratings.map((_, j) => (luminance(t.palette[j]) > 0.6 ? "#1A1A17" : "#FFFFFF"));
42+
43+
// Standout cell for storytelling emphasis: department with the highest "Below" share.
44+
const belowIdx = ratings.length - 1;
45+
let standoutIdx = 0;
46+
let standoutRatio = -1;
47+
counts.forEach((row, i) => {
48+
const ratio = row[belowIdx] / colTotals[i];
49+
if (ratio > standoutRatio) {
50+
standoutRatio = ratio;
51+
standoutIdx = i;
52+
}
53+
});
54+
55+
const colGap = 6;
56+
const rowGap = 3;
57+
58+
// --- Chart -------------------------------------------------------------------
59+
Highcharts.chart("container", {
60+
chart: {
61+
backgroundColor: "transparent",
62+
animation: false,
63+
style: { fontFamily: "inherit" },
64+
events: {
65+
load() {
66+
const r = this.renderer;
67+
68+
// Draw on top of the real xAxis/yAxis coordinate system: Highcharts has
69+
// already reserved space for the title, subtitle, axis titles/labels and
70+
// legend, so this box is the actual plot area, not a hand-picked margin.
71+
const plotLeftPx = this.plotLeft;
72+
const plotTopPx = this.plotTop;
73+
const plotWidthPx = this.plotWidth;
74+
const plotHeightPx = this.plotHeight;
75+
76+
// Column x-boundaries: width proportional to department headcount share.
77+
const availableWidth = plotWidthPx - (departments.length - 1) * colGap;
78+
let cursorX = plotLeftPx;
79+
const colX = [];
80+
const colWidth = [];
81+
departments.forEach((_, i) => {
82+
const w = (colTotals[i] / grandTotal) * availableWidth;
83+
colX.push(cursorX);
84+
colWidth.push(w);
85+
cursorX += w + colGap;
86+
});
87+
88+
// Mosaic rectangles: column width ∝ department share, row height ∝ rating share.
89+
departments.forEach((dept, i) => {
90+
const availableHeight = plotHeightPx - (ratings.length - 1) * rowGap;
91+
let cursorY = plotTopPx;
92+
ratings.forEach((rating, j) => {
93+
const cellHeight = (counts[i][j] / colTotals[i]) * availableHeight;
94+
const isStandout = i === standoutIdx && j === belowIdx;
95+
r.rect(colX[i], cursorY, colWidth[i], cellHeight, 2)
96+
.attr({
97+
fill: t.palette[j],
98+
stroke: isStandout ? t.amber : t.pageBg,
99+
"stroke-width": isStandout ? 3 : 2,
100+
})
101+
.add();
102+
103+
if (colWidth[i] > 46 && cellHeight > 28) {
104+
r.text(String(counts[i][j]), colX[i] + colWidth[i] / 2, cursorY + cellHeight / 2 + 5)
105+
.attr({ align: "center" })
106+
.css({ color: textColors[j], fontSize: "14px", fontWeight: "600" })
107+
.add();
108+
}
109+
cursorY += cellHeight + rowGap;
110+
});
111+
});
112+
},
113+
},
114+
},
115+
credits: { enabled: false },
116+
colors: t.palette,
117+
title: {
118+
text: "mosaic-categorical · javascript · highcharts · anyplot.ai",
119+
style: { color: t.ink, fontSize: "22px", fontWeight: "600" },
120+
},
121+
subtitle: {
122+
text: `${departments[standoutIdx]} has the highest 'Below' share, at ${Math.round(standoutRatio * 100)}%`,
123+
style: { color: t.inkSoft, fontSize: "14px" },
124+
},
125+
xAxis: {
126+
type: "linear",
127+
min: 0,
128+
max: grandTotal,
129+
tickPositions: colCenters,
130+
lineWidth: 0,
131+
tickLength: 0,
132+
gridLineWidth: 0,
133+
labels: {
134+
formatter() {
135+
return departments[colCenters.indexOf(this.value)] ?? "";
136+
},
137+
style: { color: t.inkSoft, fontSize: "14px" },
138+
},
139+
title: {
140+
text: "Department · column width ∝ headcount",
141+
style: { color: t.inkSoft, fontSize: "16px" },
142+
},
143+
},
144+
yAxis: {
145+
type: "linear",
146+
min: 0,
147+
max: 100,
148+
tickPositions: [0, 25, 50, 75, 100],
149+
lineWidth: 0,
150+
gridLineColor: t.grid,
151+
labels: {
152+
formatter() {
153+
return `${this.value}%`;
154+
},
155+
style: { color: t.inkSoft, fontSize: "14px" },
156+
},
157+
title: {
158+
text: "Rating share within department",
159+
style: { color: t.inkSoft, fontSize: "16px" },
160+
},
161+
},
162+
legend: {
163+
enabled: true,
164+
align: "right",
165+
verticalAlign: "middle",
166+
layout: "vertical",
167+
title: { text: "Performance rating", style: { color: t.inkSoft, fontSize: "14px", fontWeight: "600" } },
168+
itemStyle: { color: t.inkSoft, fontSize: "14px" },
169+
itemHoverStyle: { color: t.ink },
170+
symbolRadius: 2,
171+
},
172+
tooltip: { enabled: false },
173+
plotOptions: { series: { animation: false, enableMouseTracking: false } },
174+
series: ratings.map((rating, j) => ({
175+
type: "column",
176+
name: rating,
177+
data: [],
178+
color: t.palette[j],
179+
})),
180+
});

0 commit comments

Comments
 (0)