|
| 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