|
| 1 | +import { wrapText } from '../utils'; |
| 2 | + |
| 3 | +// Mac Miller — a quiet tribute to the look and feel of Swimming (2018) |
| 4 | +// and Circles (2020). Soft sunset gradient (peach to dusty rose to faded |
| 5 | +// pool blue), concentric hand-drawn rings echoing the Circles cover, a |
| 6 | +// melancholic serif for the title, and warm muted mono crumbs underneath. |
| 7 | +// Lowercase by default — the late records didn't shout. |
| 8 | +export const macMiller = (ctx, width, height, scale, data) => { |
| 9 | + const { |
| 10 | + primaryColor, |
| 11 | + secondaryColor, |
| 12 | + repoOwner, |
| 13 | + repoName, |
| 14 | + description, |
| 15 | + language, |
| 16 | + stars, |
| 17 | + forks, |
| 18 | + supportUrl, |
| 19 | + } = data; |
| 20 | + |
| 21 | + // Palette — warm dusk over a still pool. The user's primary becomes the |
| 22 | + // ring accent; secondary nudges the sunset. |
| 23 | + const ringInk = primaryColor || '#C16E5C'; |
| 24 | + const sunsetTop = secondaryColor && isLight(secondaryColor) |
| 25 | + ? secondaryColor |
| 26 | + : '#F4C9A8'; |
| 27 | + const sunsetMid = '#E8A38E'; |
| 28 | + const sunsetBottom = '#A8B8C4'; |
| 29 | + const cream = '#F2E4D2'; |
| 30 | + const ink = '#2B2522'; |
| 31 | + const inkSoft = '#5C504A'; |
| 32 | + const inkFaint = '#8A7E76'; |
| 33 | + |
| 34 | + const serif = '"Cormorant Garamond", "Iowan Old Style", "EB Garamond", Georgia, serif'; |
| 35 | + const mono = '"JetBrains Mono", "Geist Mono", ui-monospace, monospace'; |
| 36 | + |
| 37 | + // 1. Sunset wash — vertical gradient, peach into dusty rose into pool blue. |
| 38 | + const wash = ctx.createLinearGradient(0, 0, 0, height); |
| 39 | + wash.addColorStop(0, sunsetTop); |
| 40 | + wash.addColorStop(0.55, sunsetMid); |
| 41 | + wash.addColorStop(1, sunsetBottom); |
| 42 | + ctx.fillStyle = wash; |
| 43 | + ctx.fillRect(0, 0, width, height); |
| 44 | + |
| 45 | + // 2. Soft horizon glow — radial bloom roughly where the sun would sit. |
| 46 | + const glow = ctx.createRadialGradient( |
| 47 | + width * 0.72, height * 0.42, 0, |
| 48 | + width * 0.72, height * 0.42, width * 0.55, |
| 49 | + ); |
| 50 | + glow.addColorStop(0, hexA('#FFE8C8', 0.55)); |
| 51 | + glow.addColorStop(1, hexA('#FFE8C8', 0)); |
| 52 | + ctx.fillStyle = glow; |
| 53 | + ctx.fillRect(0, 0, width, height); |
| 54 | + |
| 55 | + // 3. Grain — faint pastel film grain to take the digital edge off. |
| 56 | + ctx.save(); |
| 57 | + ctx.globalAlpha = 0.06; |
| 58 | + const grainSeed = (repoName || 'circles').length * 7 + 13; |
| 59 | + for (let i = 0; i < 1400; i++) { |
| 60 | + const gx = mulberry(grainSeed + i) * width; |
| 61 | + const gy = mulberry(grainSeed + i * 3) * height; |
| 62 | + const tone = mulberry(grainSeed + i * 5) > 0.5 ? '#FFFFFF' : '#1A0F0A'; |
| 63 | + ctx.fillStyle = tone; |
| 64 | + ctx.fillRect(gx, gy, 1.2 * scale, 1.2 * scale); |
| 65 | + } |
| 66 | + ctx.restore(); |
| 67 | + |
| 68 | + // 4. Concentric rings — the Circles motif, lower-right, hand-drawn-ish wobble. |
| 69 | + const ringCx = width * 0.78; |
| 70 | + const ringCy = height * 0.52; |
| 71 | + const ringMax = Math.min(width, height) * 0.42; |
| 72 | + ctx.save(); |
| 73 | + ctx.strokeStyle = ringInk; |
| 74 | + ctx.lineCap = 'round'; |
| 75 | + for (let r = ringMax; r > 18 * scale; r -= 22 * scale) { |
| 76 | + const wob = (mulberry(Math.floor(r) + grainSeed) - 0.5) * 6 * scale; |
| 77 | + ctx.globalAlpha = 0.18 + 0.55 * (1 - r / ringMax); |
| 78 | + ctx.lineWidth = (1 + 1.6 * (1 - r / ringMax)) * scale; |
| 79 | + ctx.beginPath(); |
| 80 | + // hand-drawn arc — 360° in 36 segments with slight noise |
| 81 | + const seg = 36; |
| 82 | + for (let s = 0; s <= seg; s++) { |
| 83 | + const ang = (s / seg) * Math.PI * 2; |
| 84 | + const noise = (mulberry(Math.floor(r) + s) - 0.5) * 2.4 * scale; |
| 85 | + const px = ringCx + Math.cos(ang) * (r + noise) + wob; |
| 86 | + const py = ringCy + Math.sin(ang) * (r + noise); |
| 87 | + if (s === 0) ctx.moveTo(px, py); |
| 88 | + else ctx.lineTo(px, py); |
| 89 | + } |
| 90 | + ctx.stroke(); |
| 91 | + } |
| 92 | + ctx.restore(); |
| 93 | + |
| 94 | + // 5. Tiny filled centre dot — the still point. |
| 95 | + ctx.fillStyle = ringInk; |
| 96 | + ctx.beginPath(); |
| 97 | + ctx.arc(ringCx, ringCy, 5 * scale, 0, Math.PI * 2); |
| 98 | + ctx.fill(); |
| 99 | + |
| 100 | + // 6. Lap lane — a single soft horizontal line near the bottom, swimming-pool |
| 101 | + // nod. Pale, feathered. |
| 102 | + ctx.save(); |
| 103 | + ctx.strokeStyle = hexA('#FFFFFF', 0.35); |
| 104 | + ctx.lineWidth = 2 * scale; |
| 105 | + ctx.setLineDash([14 * scale, 18 * scale]); |
| 106 | + ctx.beginPath(); |
| 107 | + ctx.moveTo(0, height * 0.86); |
| 108 | + ctx.lineTo(width, height * 0.86); |
| 109 | + ctx.stroke(); |
| 110 | + ctx.restore(); |
| 111 | + |
| 112 | + // 7. Type stack — left column. Lowercase by default; this isn't loud music. |
| 113 | + const padX = 64 * scale; |
| 114 | + |
| 115 | + // Tiny mono crumb at top |
| 116 | + ctx.fillStyle = inkFaint; |
| 117 | + ctx.font = `500 ${15 * scale}px ${mono}`; |
| 118 | + ctx.textAlign = 'left'; |
| 119 | + letterSpaced( |
| 120 | + ctx, |
| 121 | + `side a / ${(repoOwner || '').toLowerCase()}`, |
| 122 | + padX, |
| 123 | + 62 * scale, |
| 124 | + 2.6 * scale, |
| 125 | + ); |
| 126 | + |
| 127 | + // Track-number style label |
| 128 | + ctx.fillStyle = ringInk; |
| 129 | + ctx.font = `italic 600 ${16 * scale}px ${serif}`; |
| 130 | + ctx.fillText('track 01.', padX, 100 * scale); |
| 131 | + |
| 132 | + // The title — soft serif, italic, lowercase, generous size |
| 133 | + ctx.fillStyle = ink; |
| 134 | + const titleRaw = (repoName || '').toLowerCase(); |
| 135 | + const titleSize = titleRaw.length > 14 ? 96 * scale : 124 * scale; |
| 136 | + ctx.font = `italic 400 ${titleSize}px ${serif}`; |
| 137 | + ctx.textBaseline = 'alphabetic'; |
| 138 | + ctx.fillText(titleRaw, padX, height * 0.52); |
| 139 | + |
| 140 | + // A handwritten-feel attribution under the title |
| 141 | + ctx.fillStyle = inkSoft; |
| 142 | + ctx.font = `italic 400 ${28 * scale}px ${serif}`; |
| 143 | + ctx.fillText( |
| 144 | + `— a record by ${(repoOwner || '').toLowerCase()}`, |
| 145 | + padX, |
| 146 | + height * 0.52 + 46 * scale, |
| 147 | + ); |
| 148 | + |
| 149 | + // Liner-note description, narrow column |
| 150 | + if (description) { |
| 151 | + ctx.fillStyle = ink; |
| 152 | + ctx.font = `400 ${26 * scale}px ${serif}`; |
| 153 | + wrapText( |
| 154 | + ctx, |
| 155 | + description, |
| 156 | + padX, |
| 157 | + height * 0.52 + 110 * scale, |
| 158 | + width * 0.46, |
| 159 | + 38 * scale, |
| 160 | + ); |
| 161 | + } |
| 162 | + |
| 163 | + // 8. Bottom liner — mono uppercase, letter-spaced, faded. |
| 164 | + const linerY = height - 64 * scale; |
| 165 | + ctx.font = `600 ${14 * scale}px ${mono}`; |
| 166 | + let mx = padX; |
| 167 | + const writeMeta = (label, value) => { |
| 168 | + ctx.fillStyle = inkFaint; |
| 169 | + const lw = letterSpaced(ctx, label, mx, linerY, 2.4 * scale); |
| 170 | + ctx.fillStyle = ink; |
| 171 | + const vw = letterSpaced(ctx, ' ' + value, mx + lw, linerY, 2.4 * scale); |
| 172 | + mx += lw + vw + 36 * scale; |
| 173 | + }; |
| 174 | + if (stars !== undefined && stars !== null && stars !== '') writeMeta('LISTENS', String(stars)); |
| 175 | + if (forks !== undefined && forks !== null && forks !== '') writeMeta('PRESSED', String(forks)); |
| 176 | + if (language) writeMeta('IN', String(language).toUpperCase()); |
| 177 | + |
| 178 | + // 9. Corner stamp — a tiny dedication, low-contrast. |
| 179 | + ctx.textAlign = 'right'; |
| 180 | + ctx.fillStyle = inkFaint; |
| 181 | + ctx.font = `italic 400 ${15 * scale}px ${serif}`; |
| 182 | + ctx.fillText('for malcolm.', width - padX, linerY); |
| 183 | + |
| 184 | + if (supportUrl) { |
| 185 | + ctx.fillStyle = inkFaint; |
| 186 | + ctx.font = `500 ${12 * scale}px ${mono}`; |
| 187 | + ctx.fillText(supportUrl, width - padX, linerY + 22 * scale); |
| 188 | + } |
| 189 | + ctx.textAlign = 'left'; |
| 190 | +}; |
| 191 | + |
| 192 | +// ─── helpers ────────────────────────────────────────────────────────────── |
| 193 | + |
| 194 | +function letterSpaced(ctx, text, x, y, spacing) { |
| 195 | + let cx = x; |
| 196 | + for (const ch of text) { |
| 197 | + ctx.fillText(ch, cx, y); |
| 198 | + cx += ctx.measureText(ch).width + spacing; |
| 199 | + } |
| 200 | + return cx - x; |
| 201 | +} |
| 202 | + |
| 203 | +function mulberry(seed) { |
| 204 | + let t = (seed * 1831565813) | 0; |
| 205 | + t = (t + 0x6D2B79F5) | 0; |
| 206 | + let r = Math.imul(t ^ (t >>> 15), 1 | t); |
| 207 | + r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); |
| 208 | + return ((r ^ (r >>> 14)) >>> 0) / 4294967296; |
| 209 | +} |
| 210 | + |
| 211 | +function hexA(hex, alpha) { |
| 212 | + const { r, g, b } = parseHex(hex); |
| 213 | + return `rgba(${r}, ${g}, ${b}, ${alpha})`; |
| 214 | +} |
| 215 | + |
| 216 | +function isLight(hex) { |
| 217 | + const { r, g, b } = parseHex(hex); |
| 218 | + return 0.2126 * r + 0.7152 * g + 0.0722 * b > 170; |
| 219 | +} |
| 220 | + |
| 221 | +function parseHex(hex) { |
| 222 | + const h = (hex || '#000').replace('#', ''); |
| 223 | + const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h; |
| 224 | + return { |
| 225 | + r: parseInt(full.slice(0, 2), 16) || 0, |
| 226 | + g: parseInt(full.slice(2, 4), 16) || 0, |
| 227 | + b: parseInt(full.slice(4, 6), 16) || 0, |
| 228 | + }; |
| 229 | +} |
0 commit comments