|
| 1 | +import { wrapText } from '../utils'; |
| 2 | + |
| 3 | +export const etherealGlow = (ctx, width, height, scale, data) => { |
| 4 | + const { |
| 5 | + primaryColor, |
| 6 | + secondaryColor, |
| 7 | + repoOwner, |
| 8 | + repoName, |
| 9 | + description, |
| 10 | + language, |
| 11 | + stars, |
| 12 | + forks, |
| 13 | + supportUrl, |
| 14 | + bgColor, |
| 15 | + showPattern, |
| 16 | + } = data; |
| 17 | + |
| 18 | + // ===== RICH GRADIENT BACKGROUND ===== |
| 19 | + const bgGrad = ctx.createLinearGradient(0, 0, width * 0.4, height); |
| 20 | + bgGrad.addColorStop(0, bgColor); |
| 21 | + bgGrad.addColorStop(0.5, bgColor); |
| 22 | + bgGrad.addColorStop(1, bgColor); |
| 23 | + ctx.fillStyle = bgGrad; |
| 24 | + ctx.fillRect(0, 0, width, height); |
| 25 | + |
| 26 | + // ===== LUMINOUS ORBS — layered, blurred, overlapping ===== |
| 27 | + const drawOrb = (ox, oy, or, color, opacity) => { |
| 28 | + ctx.save(); |
| 29 | + const orbGrad = ctx.createRadialGradient(ox, oy, 0, ox, oy, or); |
| 30 | + orbGrad.addColorStop(0, color); |
| 31 | + orbGrad.addColorStop(0.4, color); |
| 32 | + orbGrad.addColorStop(1, 'transparent'); |
| 33 | + ctx.globalAlpha = opacity; |
| 34 | + ctx.filter = `blur(${Math.round(or * 0.4)}px)`; |
| 35 | + ctx.fillStyle = orbGrad; |
| 36 | + ctx.beginPath(); |
| 37 | + ctx.arc(ox, oy, or, 0, Math.PI * 2); |
| 38 | + ctx.fill(); |
| 39 | + ctx.restore(); |
| 40 | + }; |
| 41 | + |
| 42 | + // Primary large orb — top right |
| 43 | + drawOrb(width * 0.78, height * 0.15, 350 * scale, primaryColor, 0.18); |
| 44 | + // Secondary orb — bottom left |
| 45 | + drawOrb(width * 0.18, height * 0.85, 300 * scale, secondaryColor, 0.15); |
| 46 | + // Accent orb — center left |
| 47 | + drawOrb(width * 0.35, height * 0.4, 200 * scale, primaryColor, 0.08); |
| 48 | + // Small warm orb — top left |
| 49 | + drawOrb(width * 0.08, height * 0.1, 150 * scale, secondaryColor, 0.1); |
| 50 | + // Deep orb — bottom right |
| 51 | + drawOrb(width * 0.88, height * 0.75, 250 * scale, primaryColor, 0.12); |
| 52 | + |
| 53 | + // ===== PRISMATIC LIGHT STREAKS ===== |
| 54 | + if (showPattern) { |
| 55 | + ctx.save(); |
| 56 | + // Diagonal light ray 1 |
| 57 | + ctx.globalAlpha = 0.04; |
| 58 | + const ray1Grad = ctx.createLinearGradient(width * 0.3, 0, width * 0.7, height); |
| 59 | + ray1Grad.addColorStop(0, 'transparent'); |
| 60 | + ray1Grad.addColorStop(0.3, primaryColor); |
| 61 | + ray1Grad.addColorStop(0.5, '#fff'); |
| 62 | + ray1Grad.addColorStop(0.7, secondaryColor); |
| 63 | + ray1Grad.addColorStop(1, 'transparent'); |
| 64 | + ctx.fillStyle = ray1Grad; |
| 65 | + ctx.beginPath(); |
| 66 | + ctx.moveTo(width * 0.45, 0); |
| 67 | + ctx.lineTo(width * 0.52, 0); |
| 68 | + ctx.lineTo(width * 0.22, height); |
| 69 | + ctx.lineTo(width * 0.15, height); |
| 70 | + ctx.closePath(); |
| 71 | + ctx.fill(); |
| 72 | + |
| 73 | + // Diagonal light ray 2 — thinner |
| 74 | + ctx.globalAlpha = 0.03; |
| 75 | + const ray2Grad = ctx.createLinearGradient(width * 0.5, 0, width * 0.8, height); |
| 76 | + ray2Grad.addColorStop(0, 'transparent'); |
| 77 | + ray2Grad.addColorStop(0.4, secondaryColor); |
| 78 | + ray2Grad.addColorStop(0.6, '#fff'); |
| 79 | + ray2Grad.addColorStop(1, 'transparent'); |
| 80 | + ctx.fillStyle = ray2Grad; |
| 81 | + ctx.beginPath(); |
| 82 | + ctx.moveTo(width * 0.6, 0); |
| 83 | + ctx.lineTo(width * 0.63, 0); |
| 84 | + ctx.lineTo(width * 0.33, height); |
| 85 | + ctx.lineTo(width * 0.3, height); |
| 86 | + ctx.closePath(); |
| 87 | + ctx.fill(); |
| 88 | + |
| 89 | + // Horizontal light bloom across middle |
| 90 | + ctx.globalAlpha = 0.025; |
| 91 | + const bloomGrad = ctx.createLinearGradient(0, height * 0.35, 0, height * 0.65); |
| 92 | + bloomGrad.addColorStop(0, 'transparent'); |
| 93 | + bloomGrad.addColorStop(0.5, '#fff'); |
| 94 | + bloomGrad.addColorStop(1, 'transparent'); |
| 95 | + ctx.fillStyle = bloomGrad; |
| 96 | + ctx.fillRect(0, height * 0.35, width, height * 0.3); |
| 97 | + ctx.restore(); |
| 98 | + |
| 99 | + // ===== SPARKLE PARTICLES ===== |
| 100 | + ctx.save(); |
| 101 | + for (let i = 0; i < 40; i++) { |
| 102 | + const sx = ((i * 173 + 29) % 127) / 127 * width; |
| 103 | + const sy = ((i * 89 + 47) % 113) / 113 * height; |
| 104 | + const ss = (0.8 + (i % 4) * 0.6) * scale; |
| 105 | + ctx.globalAlpha = 0.08 + (i % 6) * 0.04; |
| 106 | + ctx.fillStyle = '#fff'; |
| 107 | + |
| 108 | + // Four-point star sparkle |
| 109 | + ctx.beginPath(); |
| 110 | + ctx.moveTo(sx, sy - ss * 3); |
| 111 | + ctx.lineTo(sx + ss, sy); |
| 112 | + ctx.lineTo(sx, sy + ss * 3); |
| 113 | + ctx.lineTo(sx - ss, sy); |
| 114 | + ctx.closePath(); |
| 115 | + ctx.fill(); |
| 116 | + } |
| 117 | + ctx.restore(); |
| 118 | + } |
| 119 | + |
| 120 | + // ===== FROSTED GLASS CONTENT CARD ===== |
| 121 | + const cardPad = 70 * scale; |
| 122 | + const cardX = cardPad; |
| 123 | + const cardY = cardPad; |
| 124 | + const cardW = width - cardPad * 2; |
| 125 | + const cardH = height - cardPad * 2; |
| 126 | + const cardR = 24 * scale; |
| 127 | + |
| 128 | + // Card frosted background |
| 129 | + ctx.save(); |
| 130 | + ctx.beginPath(); |
| 131 | + ctx.roundRect(cardX, cardY, cardW, cardH, cardR); |
| 132 | + ctx.clip(); |
| 133 | + |
| 134 | + // Semi-transparent fill |
| 135 | + ctx.fillStyle = 'rgba(255, 255, 255, 0.06)'; |
| 136 | + ctx.fillRect(cardX, cardY, cardW, cardH); |
| 137 | + |
| 138 | + // Inner light gradient — top edge glow |
| 139 | + const innerGlow = ctx.createLinearGradient(cardX, cardY, cardX, cardY + 80 * scale); |
| 140 | + innerGlow.addColorStop(0, 'rgba(255, 255, 255, 0.08)'); |
| 141 | + innerGlow.addColorStop(1, 'rgba(255, 255, 255, 0)'); |
| 142 | + ctx.fillStyle = innerGlow; |
| 143 | + ctx.fillRect(cardX, cardY, cardW, 80 * scale); |
| 144 | + ctx.restore(); |
| 145 | + |
| 146 | + // Card border — subtle luminous stroke |
| 147 | + ctx.save(); |
| 148 | + ctx.beginPath(); |
| 149 | + ctx.roundRect(cardX, cardY, cardW, cardH, cardR); |
| 150 | + ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; |
| 151 | + ctx.lineWidth = 1.5 * scale; |
| 152 | + ctx.stroke(); |
| 153 | + ctx.restore(); |
| 154 | + |
| 155 | + // Top-edge highlight (glass refraction) |
| 156 | + ctx.save(); |
| 157 | + ctx.beginPath(); |
| 158 | + ctx.roundRect(cardX + 1, cardY + 1, cardW - 2, cardR * 2, [cardR, cardR, 0, 0]); |
| 159 | + ctx.clip(); |
| 160 | + const edgeGrad = ctx.createLinearGradient(cardX, cardY, cardX + cardW, cardY); |
| 161 | + edgeGrad.addColorStop(0, 'rgba(255,255,255,0)'); |
| 162 | + edgeGrad.addColorStop(0.3, 'rgba(255,255,255,0.08)'); |
| 163 | + edgeGrad.addColorStop(0.7, 'rgba(255,255,255,0.03)'); |
| 164 | + edgeGrad.addColorStop(1, 'rgba(255,255,255,0)'); |
| 165 | + ctx.fillStyle = edgeGrad; |
| 166 | + ctx.fillRect(cardX, cardY, cardW, cardR * 2); |
| 167 | + ctx.restore(); |
| 168 | + |
| 169 | + // ===== CONTENT ===== |
| 170 | + const cx = cardX + 50 * scale; |
| 171 | + const contentMaxW = cardW - 100 * scale; |
| 172 | + |
| 173 | + // Owner — delicate, airy |
| 174 | + ctx.fillStyle = primaryColor; |
| 175 | + ctx.globalAlpha = 0.7; |
| 176 | + ctx.font = `500 ${20 * scale}px "JetBrains Mono"`; |
| 177 | + ctx.textAlign = 'left'; |
| 178 | + ctx.textBaseline = 'top'; |
| 179 | + ctx.fillText(repoOwner, cx, cardY + 40 * scale); |
| 180 | + ctx.globalAlpha = 1; |
| 181 | + |
| 182 | + // Tiny accent dot after owner |
| 183 | + ctx.fillStyle = secondaryColor; |
| 184 | + ctx.globalAlpha = 0.5; |
| 185 | + const ownerW = ctx.measureText(repoOwner).width; |
| 186 | + ctx.beginPath(); |
| 187 | + ctx.arc(cx + ownerW + 12 * scale, cardY + 50 * scale, 3 * scale, 0, Math.PI * 2); |
| 188 | + ctx.fill(); |
| 189 | + ctx.globalAlpha = 1; |
| 190 | + |
| 191 | + // Repo name — large, elegant, white |
| 192 | + ctx.fillStyle = '#fff'; |
| 193 | + let nameSize = 72 * scale; |
| 194 | + ctx.font = `700 ${nameSize}px "Inter", sans-serif`; |
| 195 | + while (ctx.measureText(repoName).width > contentMaxW && nameSize > 36 * scale) { |
| 196 | + nameSize -= 3 * scale; |
| 197 | + ctx.font = `700 ${nameSize}px "Inter", sans-serif`; |
| 198 | + } |
| 199 | + const nameY = cardY + 72 * scale; |
| 200 | + ctx.fillText(repoName, cx, nameY); |
| 201 | + |
| 202 | + // Luminous gradient line under name |
| 203 | + const nameBottom = nameY + nameSize + 4 * scale; |
| 204 | + const lineGrad = ctx.createLinearGradient(cx, 0, cx + 280 * scale, 0); |
| 205 | + lineGrad.addColorStop(0, primaryColor); |
| 206 | + lineGrad.addColorStop(0.5, secondaryColor); |
| 207 | + lineGrad.addColorStop(1, 'transparent'); |
| 208 | + ctx.fillStyle = lineGrad; |
| 209 | + ctx.globalAlpha = 0.5; |
| 210 | + ctx.fillRect(cx, nameBottom, 280 * scale, 2.5 * scale); |
| 211 | + // Glow under line |
| 212 | + ctx.globalAlpha = 0.15; |
| 213 | + ctx.filter = `blur(${6 * scale}px)`; |
| 214 | + ctx.fillRect(cx, nameBottom - 2 * scale, 280 * scale, 8 * scale); |
| 215 | + ctx.filter = 'none'; |
| 216 | + ctx.globalAlpha = 1; |
| 217 | + |
| 218 | + // Description — soft, breathable |
| 219 | + const descY = nameBottom + 28 * scale; |
| 220 | + ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; |
| 221 | + ctx.font = `300 ${26 * scale}px "Inter", sans-serif`; |
| 222 | + wrapText(ctx, description, cx, descY, contentMaxW, 38 * scale); |
| 223 | + |
| 224 | + // ===== BOTTOM SECTION ===== |
| 225 | + const bottomY = cardY + cardH - 50 * scale; |
| 226 | + |
| 227 | + // Language pill with glow |
| 228 | + ctx.save(); |
| 229 | + ctx.globalAlpha = 0.1; |
| 230 | + ctx.fillStyle = primaryColor; |
| 231 | + ctx.filter = `blur(${10 * scale}px)`; |
| 232 | + ctx.beginPath(); |
| 233 | + ctx.roundRect(cx - 5 * scale, bottomY - 30 * scale, 120 * scale, 40 * scale, 20 * scale); |
| 234 | + ctx.fill(); |
| 235 | + ctx.filter = 'none'; |
| 236 | + ctx.restore(); |
| 237 | + // Language pill — custom drawn for vertical centering |
| 238 | + ctx.textAlign = 'left'; |
| 239 | + ctx.textBaseline = 'middle'; |
| 240 | + ctx.font = `bold ${20 * scale}px "JetBrains Mono"`; |
| 241 | + const pillPad = 20 * scale; |
| 242 | + const pillTextW = ctx.measureText(language).width; |
| 243 | + const pillW = pillTextW + pillPad * 2; |
| 244 | + const pillH = 40 * scale; |
| 245 | + const pillX = cx; |
| 246 | + const pillY = bottomY - pillH / 2 - 4 * scale; |
| 247 | + |
| 248 | + ctx.fillStyle = primaryColor; |
| 249 | + ctx.globalAlpha = 0.2; |
| 250 | + ctx.beginPath(); |
| 251 | + ctx.roundRect(pillX, pillY, pillW, pillH, pillH / 2); |
| 252 | + ctx.fill(); |
| 253 | + ctx.globalAlpha = 1; |
| 254 | + |
| 255 | + ctx.fillStyle = primaryColor; |
| 256 | + ctx.fillText(language, pillX + pillPad, pillY + pillH / 2); |
| 257 | + |
| 258 | + // Stats — soft, right-aligned |
| 259 | + ctx.textAlign = 'right'; |
| 260 | + let statX = cardX + cardW - 50 * scale; |
| 261 | + |
| 262 | + // Support URL |
| 263 | + if (supportUrl) { |
| 264 | + ctx.fillStyle = 'rgba(255, 255, 255, 0.25)'; |
| 265 | + ctx.font = `300 ${16 * scale}px "JetBrains Mono"`; |
| 266 | + ctx.textBaseline = 'bottom'; |
| 267 | + ctx.fillText(supportUrl, statX, bottomY); |
| 268 | + statX -= ctx.measureText(supportUrl).width + 36 * scale; |
| 269 | + } |
| 270 | + |
| 271 | + // Forks |
| 272 | + if (forks) { |
| 273 | + ctx.fillStyle = 'rgba(255, 255, 255, 0.45)'; |
| 274 | + ctx.font = `500 ${20 * scale}px "JetBrains Mono"`; |
| 275 | + ctx.textBaseline = 'bottom'; |
| 276 | + ctx.fillText(`${forks}`, statX, bottomY); |
| 277 | + const forksNumW = ctx.measureText(`${forks}`).width; |
| 278 | + |
| 279 | + ctx.fillStyle = 'rgba(255, 255, 255, 0.25)'; |
| 280 | + ctx.font = `300 ${14 * scale}px "Inter", sans-serif`; |
| 281 | + ctx.fillText(' forks', statX - forksNumW - 2 * scale, bottomY); |
| 282 | + statX -= forksNumW + ctx.measureText(' forks').width + 30 * scale; |
| 283 | + } |
| 284 | + |
| 285 | + // Stars with tiny glow |
| 286 | + if (stars) { |
| 287 | + ctx.fillStyle = 'rgba(255, 255, 255, 0.45)'; |
| 288 | + ctx.font = `500 ${20 * scale}px "JetBrains Mono"`; |
| 289 | + ctx.textBaseline = 'bottom'; |
| 290 | + ctx.fillText(`${stars}`, statX, bottomY); |
| 291 | + const starsNumW = ctx.measureText(`${stars}`).width; |
| 292 | + |
| 293 | + ctx.fillStyle = 'rgba(255, 255, 255, 0.25)'; |
| 294 | + ctx.font = `300 ${14 * scale}px "Inter", sans-serif`; |
| 295 | + ctx.fillText(' stars', statX - starsNumW - 2 * scale, bottomY); |
| 296 | + |
| 297 | + // Tiny star glow |
| 298 | + ctx.save(); |
| 299 | + ctx.globalAlpha = 0.2; |
| 300 | + ctx.fillStyle = secondaryColor; |
| 301 | + ctx.beginPath(); |
| 302 | + ctx.arc(statX - starsNumW - ctx.measureText(' stars').width - 14 * scale, bottomY - 8 * scale, 4 * scale, 0, Math.PI * 2); |
| 303 | + ctx.fill(); |
| 304 | + ctx.globalAlpha = 0.08; |
| 305 | + ctx.filter = `blur(${4 * scale}px)`; |
| 306 | + ctx.beginPath(); |
| 307 | + ctx.arc(statX - starsNumW - ctx.measureText(' stars').width - 14 * scale, bottomY - 8 * scale, 10 * scale, 0, Math.PI * 2); |
| 308 | + ctx.fill(); |
| 309 | + ctx.filter = 'none'; |
| 310 | + ctx.restore(); |
| 311 | + } |
| 312 | + |
| 313 | + // ===== OUTER AMBIENT GLOW on card edges ===== |
| 314 | + ctx.save(); |
| 315 | + // Bottom-right glow |
| 316 | + ctx.globalAlpha = 0.06; |
| 317 | + ctx.fillStyle = primaryColor; |
| 318 | + ctx.filter = `blur(${40 * scale}px)`; |
| 319 | + ctx.beginPath(); |
| 320 | + ctx.arc(cardX + cardW, cardY + cardH, 100 * scale, 0, Math.PI * 2); |
| 321 | + ctx.fill(); |
| 322 | + // Top-left glow |
| 323 | + ctx.fillStyle = secondaryColor; |
| 324 | + ctx.beginPath(); |
| 325 | + ctx.arc(cardX, cardY, 80 * scale, 0, Math.PI * 2); |
| 326 | + ctx.fill(); |
| 327 | + ctx.filter = 'none'; |
| 328 | + ctx.restore(); |
| 329 | +}; |
0 commit comments