Skip to content

Commit c075ab8

Browse files
committed
feat(thumbnail): add Squarified Treemap layout theme
1 parent a061079 commit c075ab8

3 files changed

Lines changed: 322 additions & 0 deletions

File tree

src/pages/apps/github-thumbnail/GithubThumbnailGeneratorPage.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ const THEME_OPTIONS = [
108108
{ value: 'illuminatedManuscript', label: 'Illuminated Manuscript' },
109109
{ value: 'charon', label: 'Charon Editorial' },
110110
{ value: 'macMiller', label: 'Mac Miller — Swimming in Circles' },
111+
{ value: 'squarified', label: 'Squarified Treemap' },
111112
];
112113

113114
const PALETTE_BANK = [

src/pages/apps/github-thumbnail/themes.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import { tarotCard } from './themes/tarotCard';
8080
import { illuminatedManuscript } from './themes/illuminatedManuscript';
8181
import { charon } from './themes/charon';
8282
import { macMiller } from './themes/macMiller';
83+
import { squarified } from './themes/squarified';
8384

8485
export const themeRenderers = {
8586
modern,
@@ -161,4 +162,5 @@ export const themeRenderers = {
161162
illuminatedManuscript,
162163
charon,
163164
macMiller,
165+
squarified,
164166
};
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import { wrapText } from '../utils';
2+
3+
// Squarified — the layout itself is a Bruls/Huijsmans/van Wijk (2000)
4+
// squarified treemap. Repo metadata is treated as a weighted set; the
5+
// algorithm packs them as nested rectangles with aspect ratios as close
6+
// to 1 as possible. Each tile carries one piece of information.
7+
export const squarified = (ctx, width, height, scale, data) => {
8+
const {
9+
primaryColor,
10+
secondaryColor,
11+
bgColor,
12+
repoOwner,
13+
repoName,
14+
description,
15+
language,
16+
stars,
17+
forks,
18+
supportUrl,
19+
} = data;
20+
21+
const ink = '#0E0E10';
22+
const inkSoft = 'rgba(14,14,16,0.62)';
23+
const grout = '#0E0E10';
24+
const card = '#F4EFE8';
25+
26+
const sans = '"Inter", "Helvetica Neue", system-ui, sans-serif';
27+
const serif = '"Cormorant Garamond", "EB Garamond", Georgia, serif';
28+
const mono = '"JetBrains Mono", "Geist Mono", ui-monospace, monospace';
29+
30+
// 1. Canvas — dark grout so the tile gaps read as clean lines.
31+
ctx.fillStyle = grout;
32+
ctx.fillRect(0, 0, width, height);
33+
34+
// 2. Build the weighted tile set. Largest weight = title, then a
35+
// descending tail. Skipping a slot drops it from the layout entirely.
36+
const accent = primaryColor || '#C8FF3D';
37+
const accent2 = secondaryColor || '#7EC8E3';
38+
const ground = bgColor || '#1F1B16';
39+
40+
const items = [
41+
{ id: 'title', weight: 56, fill: accent, ink, render: drawTitle },
42+
{
43+
id: 'desc',
44+
weight: 28,
45+
fill: card,
46+
ink,
47+
render: drawDescription,
48+
skip: !description,
49+
},
50+
{
51+
id: 'stars',
52+
weight: 12,
53+
fill: ground,
54+
ink: card,
55+
render: makeStat('STARS', String(stars ?? '')),
56+
skip: stars === undefined || stars === null || stars === '',
57+
},
58+
{
59+
id: 'forks',
60+
weight: 9,
61+
fill: accent2,
62+
ink,
63+
render: makeStat('FORKS', String(forks ?? '')),
64+
skip: forks === undefined || forks === null || forks === '',
65+
},
66+
{
67+
id: 'language',
68+
weight: 7,
69+
fill: card,
70+
ink,
71+
render: makeStat('LANG', String(language ?? '').toUpperCase()),
72+
skip: !language,
73+
},
74+
{
75+
id: 'owner',
76+
weight: 6,
77+
fill: ground,
78+
ink: card,
79+
render: drawOwner,
80+
},
81+
{ id: 'mark', weight: 4, fill: accent, ink, render: drawMark },
82+
].filter((it) => !it.skip);
83+
84+
// Re-normalise weights to the canvas area so the treemap fills exactly.
85+
const totalWeight = items.reduce((s, it) => s + it.weight, 0);
86+
const totalArea = width * height;
87+
for (const it of items) it.area = (it.weight / totalWeight) * totalArea;
88+
89+
// 3. Run the squarified algorithm. Returns a list of {item, x,y,w,h}.
90+
const tiles = squarify(items, { x: 0, y: 0, w: width, h: height });
91+
92+
// 4. Paint each tile with a small inset (grout gap) and dispatch to its
93+
// renderer for the typography pass.
94+
const gap = 4 * scale;
95+
for (const t of tiles) {
96+
const x = t.x + gap;
97+
const y = t.y + gap;
98+
const w = Math.max(0, t.w - gap * 2);
99+
const h = Math.max(0, t.h - gap * 2);
100+
ctx.fillStyle = t.item.fill;
101+
ctx.fillRect(x, y, w, h);
102+
t.item.render(ctx, { x, y, w, h }, scale, {
103+
ink: t.item.ink,
104+
inkSoft,
105+
sans,
106+
serif,
107+
mono,
108+
data,
109+
});
110+
}
111+
112+
// 5. Tiny attribution strip — keeps the academic origin visible without
113+
// competing with the layout.
114+
ctx.fillStyle = 'rgba(244,239,232,0.55)';
115+
ctx.font = `500 ${11 * scale}px ${mono}`;
116+
ctx.textAlign = 'right';
117+
ctx.textBaseline = 'alphabetic';
118+
ctx.fillText(
119+
supportUrl
120+
? `${supportUrl} · squarified treemap, bruls et al. 2000`
121+
: 'squarified treemap — bruls, huijsmans & van wijk, 2000',
122+
width - 14 * scale,
123+
height - 10 * scale,
124+
);
125+
ctx.textAlign = 'left';
126+
};
127+
128+
// ─── tile renderers ───────────────────────────────────────────────────────
129+
130+
function drawTitle(ctx, r, scale, env) {
131+
const pad = 28 * scale;
132+
ctx.fillStyle = env.ink;
133+
ctx.textBaseline = 'alphabetic';
134+
135+
ctx.font = `600 ${11 * scale}px ${env.mono}`;
136+
letterSpaced(ctx, 'REPOSITORY', r.x + pad, r.y + pad + 8 * scale, 2.6 * scale);
137+
138+
const name = env.data.repoName || '';
139+
const size = pickFontSize(ctx, name, r.w - pad * 2, [
140+
96, 84, 72, 60, 50, 40, 32,
141+
].map((n) => n * scale), `700 SIZEpx ${env.sans}`);
142+
ctx.font = `700 ${size}px ${env.sans}`;
143+
ctx.fillText(name, r.x + pad, r.y + r.h * 0.62);
144+
145+
ctx.font = `italic 400 ${20 * scale}px ${env.serif}`;
146+
ctx.fillText(
147+
`@${(env.data.repoOwner || '').toLowerCase()}`,
148+
r.x + pad,
149+
r.y + r.h * 0.62 + 32 * scale,
150+
);
151+
}
152+
153+
function drawDescription(ctx, r, scale, env) {
154+
if (!env.data.description) return;
155+
const pad = 22 * scale;
156+
ctx.fillStyle = env.ink;
157+
ctx.textBaseline = 'alphabetic';
158+
159+
ctx.font = `600 ${10 * scale}px ${env.mono}`;
160+
letterSpaced(ctx, 'ABSTRACT', r.x + pad, r.y + pad + 6 * scale, 2.4 * scale);
161+
162+
ctx.font = `400 ${Math.min(24, Math.max(14, r.h / 9)) * scale}px ${env.serif}`;
163+
wrapText(
164+
ctx,
165+
env.data.description,
166+
r.x + pad,
167+
r.y + pad + 32 * scale,
168+
r.w - pad * 2,
169+
Math.min(28, Math.max(18, r.h / 8)) * scale,
170+
);
171+
}
172+
173+
function makeStat(label, value) {
174+
return (ctx, r, scale, env) => {
175+
const pad = 18 * scale;
176+
ctx.fillStyle = env.ink;
177+
ctx.textBaseline = 'alphabetic';
178+
ctx.font = `600 ${11 * scale}px ${env.mono}`;
179+
letterSpaced(ctx, label, r.x + pad, r.y + pad + 6 * scale, 2.4 * scale);
180+
181+
const size = pickFontSize(
182+
ctx,
183+
value,
184+
r.w - pad * 2,
185+
[72, 60, 48, 40, 32, 24, 20].map((n) => n * scale),
186+
`700 SIZEpx ${env.sans}`,
187+
);
188+
ctx.font = `700 ${size}px ${env.sans}`;
189+
ctx.fillText(value, r.x + pad, r.y + r.h - pad);
190+
};
191+
}
192+
193+
function drawOwner(ctx, r, scale, env) {
194+
const pad = 16 * scale;
195+
ctx.fillStyle = env.ink;
196+
ctx.textBaseline = 'alphabetic';
197+
ctx.font = `600 ${10 * scale}px ${env.mono}`;
198+
letterSpaced(ctx, 'OWNER', r.x + pad, r.y + pad + 4 * scale, 2.2 * scale);
199+
200+
const owner = (env.data.repoOwner || '').toLowerCase();
201+
const size = pickFontSize(ctx, owner, r.w - pad * 2, [
202+
44, 36, 28, 22, 18, 14,
203+
].map((n) => n * scale), `italic 400 SIZEpx ${env.serif}`);
204+
ctx.font = `italic 400 ${size}px ${env.serif}`;
205+
ctx.fillText(owner, r.x + pad, r.y + r.h - pad);
206+
}
207+
208+
function drawMark(ctx, r, scale, env) {
209+
// Tiny solid square — an "emblem" tile, no text. Just a single dot.
210+
ctx.fillStyle = env.ink;
211+
const dot = Math.min(r.w, r.h) * 0.18;
212+
ctx.beginPath();
213+
ctx.arc(r.x + r.w / 2, r.y + r.h / 2, dot, 0, Math.PI * 2);
214+
ctx.fill();
215+
}
216+
217+
// ─── squarified treemap ───────────────────────────────────────────────────
218+
219+
function squarify(items, rect) {
220+
// Items are dispatched with their `area` already normalised to the rect.
221+
const queue = items.slice().sort((a, b) => b.area - a.area);
222+
const placed = [];
223+
let R = { ...rect };
224+
let row = [];
225+
226+
const shorter = (R) => Math.min(R.w, R.h);
227+
228+
const push = (it) => {
229+
const trial = [...row, it];
230+
if (row.length === 0 || worst(trial, shorter(R)) <= worst(row, shorter(R))) {
231+
row = trial;
232+
return true;
233+
}
234+
return false;
235+
};
236+
237+
while (queue.length) {
238+
const next = queue.shift();
239+
if (push(next)) continue;
240+
241+
// Lay out current row, then start a new one with `next`.
242+
const laid = layoutRow(row, R);
243+
placed.push(...laid.tiles);
244+
R = laid.remaining;
245+
row = [next];
246+
}
247+
if (row.length) {
248+
const laid = layoutRow(row, R);
249+
placed.push(...laid.tiles);
250+
}
251+
return placed;
252+
}
253+
254+
function worst(row, side) {
255+
if (row.length === 0) return Infinity;
256+
const s = row.reduce((acc, it) => acc + it.area, 0);
257+
let max = -Infinity;
258+
let min = Infinity;
259+
for (const it of row) {
260+
if (it.area > max) max = it.area;
261+
if (it.area < min) min = it.area;
262+
}
263+
// Bruls et al., equation (1).
264+
return Math.max(
265+
(side * side * max) / (s * s),
266+
(s * s) / (side * side * min),
267+
);
268+
}
269+
270+
function layoutRow(row, R) {
271+
const s = row.reduce((acc, it) => acc + it.area, 0);
272+
const tiles = [];
273+
if (R.w >= R.h) {
274+
// Stack the row vertically along the left edge; strip width = s / R.h.
275+
const stripW = s / R.h;
276+
let y = R.y;
277+
for (const it of row) {
278+
const tileH = (it.area / s) * R.h;
279+
tiles.push({ item: it, x: R.x, y, w: stripW, h: tileH });
280+
y += tileH;
281+
}
282+
return {
283+
tiles,
284+
remaining: { x: R.x + stripW, y: R.y, w: R.w - stripW, h: R.h },
285+
};
286+
}
287+
// Otherwise stack horizontally along the top edge.
288+
const stripH = s / R.w;
289+
let x = R.x;
290+
for (const it of row) {
291+
const tileW = (it.area / s) * R.w;
292+
tiles.push({ item: it, x, y: R.y, w: tileW, h: stripH });
293+
x += tileW;
294+
}
295+
return {
296+
tiles,
297+
remaining: { x: R.x, y: R.y + stripH, w: R.w, h: R.h - stripH },
298+
};
299+
}
300+
301+
// ─── helpers ──────────────────────────────────────────────────────────────
302+
303+
function letterSpaced(ctx, text, x, y, spacing) {
304+
let cx = x;
305+
for (const ch of text) {
306+
ctx.fillText(ch, cx, y);
307+
cx += ctx.measureText(ch).width + spacing;
308+
}
309+
return cx - x;
310+
}
311+
312+
function pickFontSize(ctx, text, maxWidth, ladder, fontTemplate) {
313+
for (const size of ladder) {
314+
ctx.font = fontTemplate.replace('SIZE', String(size));
315+
if (ctx.measureText(text).width <= maxWidth) return size;
316+
}
317+
return ladder[ladder.length - 1];
318+
}
319+

0 commit comments

Comments
 (0)