|
| 1 | +export const boardingPass = (ctx, width, height, scale, data) => { |
| 2 | + const { |
| 3 | + primaryColor = '#6366f1', |
| 4 | + secondaryColor = '#22d3ee', |
| 5 | + bgColor = '#0f172a', |
| 6 | + repoOwner = '', |
| 7 | + repoName = '', |
| 8 | + description = '', |
| 9 | + language = '', |
| 10 | + stars = '', |
| 11 | + forks = '', |
| 12 | + } = data || {}; |
| 13 | + |
| 14 | + const mono = '"JetBrains Mono", ui-monospace, monospace'; |
| 15 | + const sans = 'system-ui, -apple-system, "Segoe UI", sans-serif'; |
| 16 | + |
| 17 | + // Deterministic ticket data derived from a stable seed |
| 18 | + const seed = seedFrom(repoName || repoOwner || 'flight'); |
| 19 | + const flightNo = 1000 + Math.floor(mulberry(seed + 1) * 9000); // 4-digit |
| 20 | + const flightCode = `GH ${flightNo}`; |
| 21 | + const gateLetter = 'ABCDEF'[Math.floor(mulberry(seed + 2) * 6)]; |
| 22 | + const gateDigit = 1 + Math.floor(mulberry(seed + 3) * 9); |
| 23 | + const gate = `${gateLetter}${gateDigit}`; |
| 24 | + const bh = String(Math.floor(mulberry(seed + 4) * 24)).padStart(2, '0'); |
| 25 | + const bm = String(Math.floor(mulberry(seed + 5) * 60)).padStart(2, '0'); |
| 26 | + const boardTime = `${bh}:${bm}`; |
| 27 | + const seatRow = 1 + Math.floor(mulberry(seed + 6) * 30); |
| 28 | + const seatCol = 'ABCDEF'[Math.floor(mulberry(seed + 7) * 6)]; |
| 29 | + const seat = `${seatRow}${seatCol}`; |
| 30 | + |
| 31 | + const ink = '#1A1A1A'; |
| 32 | + const muted = '#8A8A8A'; |
| 33 | + const paper = '#F7F5F0'; |
| 34 | + const onPrimary = readableOn(primaryColor); |
| 35 | + |
| 36 | + // --- Ground: bgColor with subtle vertical gradient --- |
| 37 | + const grad = ctx.createLinearGradient(0, 0, 0, height); |
| 38 | + grad.addColorStop(0, bgColor); |
| 39 | + grad.addColorStop(1, lighten(bgColor, 0.04)); |
| 40 | + ctx.fillStyle = grad; |
| 41 | + ctx.fillRect(0, 0, width, height); |
| 42 | + |
| 43 | + // Decorative dotted flight arcs (low alpha) |
| 44 | + ctx.save(); |
| 45 | + ctx.globalAlpha = 0.06; |
| 46 | + ctx.strokeStyle = lighten(bgColor, 0.5); |
| 47 | + ctx.lineWidth = 1.5 * scale; |
| 48 | + ctx.setLineDash([3 * scale, 7 * scale]); |
| 49 | + for (let a = 0; a < 3; a++) { |
| 50 | + ctx.beginPath(); |
| 51 | + const baseY = (0.18 + a * 0.34) * height; |
| 52 | + for (let x = 0; x <= width; x += 4 * scale) { |
| 53 | + const y = baseY + Math.sin(x / (160 * scale) + a) * 60 * scale; |
| 54 | + if (x === 0) ctx.moveTo(x, y); |
| 55 | + else ctx.lineTo(x, y); |
| 56 | + } |
| 57 | + ctx.stroke(); |
| 58 | + } |
| 59 | + ctx.restore(); |
| 60 | + |
| 61 | + // Faint plane silhouettes |
| 62 | + ctx.save(); |
| 63 | + ctx.globalAlpha = 0.05; |
| 64 | + ctx.fillStyle = lighten(bgColor, 0.6); |
| 65 | + drawPlane(ctx, width * 0.12, height * 0.16, 70 * scale, -0.25); |
| 66 | + drawPlane(ctx, width * 0.86, height * 0.86, 90 * scale, 2.9); |
| 67 | + ctx.restore(); |
| 68 | + |
| 69 | + // --- Card geometry --- |
| 70 | + const cardMargin = 90 * scale; |
| 71 | + const cardW = width - cardMargin * 2; |
| 72 | + const cardH = 360 * scale; |
| 73 | + const cardX = cardMargin; |
| 74 | + const cardY = (height - cardH) / 2; |
| 75 | + const cardR = 22 * scale; |
| 76 | + |
| 77 | + // Soft drop shadow (translucent offset rounded rect + canvas blur) |
| 78 | + ctx.save(); |
| 79 | + ctx.shadowColor = 'rgba(0,0,0,0.35)'; |
| 80 | + ctx.shadowBlur = 40 * scale; |
| 81 | + ctx.shadowOffsetY = 18 * scale; |
| 82 | + ctx.fillStyle = 'rgba(0,0,0,0.25)'; |
| 83 | + roundRectPath(ctx, cardX, cardY, cardW, cardH, cardR); |
| 84 | + ctx.fill(); |
| 85 | + ctx.restore(); |
| 86 | + |
| 87 | + // Card paper |
| 88 | + ctx.save(); |
| 89 | + roundRectPath(ctx, cardX, cardY, cardW, cardH, cardR); |
| 90 | + ctx.fillStyle = paper; |
| 91 | + ctx.fill(); |
| 92 | + ctx.restore(); |
| 93 | + |
| 94 | + // Split: main body (left ~72%) | stub (right ~28%) |
| 95 | + const stubW = cardW * 0.28; |
| 96 | + const perfX = cardX + cardW - stubW; |
| 97 | + const bodyX = cardX; |
| 98 | + const bodyW = perfX - bodyX; |
| 99 | + |
| 100 | + // Clip everything to the card for clean edges |
| 101 | + ctx.save(); |
| 102 | + roundRectPath(ctx, cardX, cardY, cardW, cardH, cardR); |
| 103 | + ctx.clip(); |
| 104 | + |
| 105 | + // --- Header band across the main body --- |
| 106 | + const headerH = 56 * scale; |
| 107 | + ctx.fillStyle = primaryColor; |
| 108 | + ctx.fillRect(bodyX, cardY, bodyW, headerH); |
| 109 | + |
| 110 | + ctx.fillStyle = onPrimary; |
| 111 | + ctx.textBaseline = 'middle'; |
| 112 | + ctx.textAlign = 'left'; |
| 113 | + ctx.font = `700 ${22 * scale}px ${sans}`; |
| 114 | + ctx.fillText('✈ GITHUB AIRWAYS', bodyX + 28 * scale, cardY + headerH / 2); |
| 115 | + |
| 116 | + ctx.textAlign = 'right'; |
| 117 | + ctx.font = `700 ${16 * scale}px ${mono}`; |
| 118 | + ctx.fillText('B O A R D I N G P A S S', perfX - 26 * scale, cardY + headerH / 2); |
| 119 | + ctx.textAlign = 'left'; |
| 120 | + |
| 121 | + // --- Body field grid --- |
| 122 | + const padL = bodyX + 28 * scale; |
| 123 | + const fieldsY = cardY + headerH + 36 * scale; |
| 124 | + const fields = [ |
| 125 | + ['PASSENGER', truncate(ctx, (repoOwner || '—').toUpperCase(), bodyW * 0.34, `700 ${22 * scale}px ${mono}`)], |
| 126 | + ['FLIGHT', flightCode], |
| 127 | + ['GATE', gate], |
| 128 | + ['BOARDING', boardTime], |
| 129 | + ]; |
| 130 | + const colW = (bodyW - 56 * scale) / 4; |
| 131 | + fields.forEach((f, i) => { |
| 132 | + const fx = padL + i * colW; |
| 133 | + ctx.textAlign = 'left'; |
| 134 | + ctx.textBaseline = 'alphabetic'; |
| 135 | + ctx.fillStyle = muted; |
| 136 | + ctx.font = `700 ${12 * scale}px ${mono}`; |
| 137 | + ctx.fillText(letterSpace(f[0]), fx, fieldsY); |
| 138 | + ctx.fillStyle = ink; |
| 139 | + ctx.font = `700 ${22 * scale}px ${mono}`; |
| 140 | + ctx.fillText(f[1], fx, fieldsY + 28 * scale); |
| 141 | + }); |
| 142 | + |
| 143 | + // --- Route line: MAIN -> repoName --- |
| 144 | + const routeY = fieldsY + 78 * scale; |
| 145 | + ctx.textBaseline = 'middle'; |
| 146 | + ctx.textAlign = 'left'; |
| 147 | + ctx.fillStyle = ink; |
| 148 | + ctx.font = `700 ${30 * scale}px ${sans}`; |
| 149 | + ctx.fillText('MAIN', padL, routeY); |
| 150 | + const originW = ctx.measureText('MAIN').width; |
| 151 | + |
| 152 | + // Destination (big repoName) |
| 153 | + ctx.font = `800 ${42 * scale}px ${sans}`; |
| 154 | + const destMaxW = bodyW - (padL - bodyX) - originW - 120 * scale; |
| 155 | + const destText = truncate(ctx, repoName || 'repository', destMaxW, `800 ${42 * scale}px ${sans}`); |
| 156 | + const destW = ctx.measureText(destText).width; |
| 157 | + const destX = perfX - 26 * scale - destW; |
| 158 | + |
| 159 | + // Dotted connector with a plane glyph in secondaryColor |
| 160 | + const connStart = padL + originW + 18 * scale; |
| 161 | + const connEnd = destX - 18 * scale; |
| 162 | + if (connEnd > connStart) { |
| 163 | + ctx.save(); |
| 164 | + ctx.strokeStyle = mix(ink, paper, 0.55); |
| 165 | + ctx.lineWidth = 2 * scale; |
| 166 | + ctx.setLineDash([2 * scale, 6 * scale]); |
| 167 | + ctx.beginPath(); |
| 168 | + ctx.moveTo(connStart, routeY); |
| 169 | + ctx.lineTo(connEnd, routeY); |
| 170 | + ctx.stroke(); |
| 171 | + ctx.restore(); |
| 172 | + ctx.fillStyle = secondaryColor; |
| 173 | + ctx.font = `${24 * scale}px ${sans}`; |
| 174 | + ctx.textAlign = 'center'; |
| 175 | + ctx.fillText('✈', (connStart + connEnd) / 2, routeY - 1 * scale); |
| 176 | + } |
| 177 | + ctx.textAlign = 'left'; |
| 178 | + ctx.fillStyle = ink; |
| 179 | + ctx.font = `800 ${42 * scale}px ${sans}`; |
| 180 | + ctx.fillText(destText, destX, routeY); |
| 181 | + |
| 182 | + // --- Meta row: stars / forks / language pill --- |
| 183 | + const metaY = routeY + 52 * scale; |
| 184 | + let metaX = padL; |
| 185 | + ctx.textBaseline = 'middle'; |
| 186 | + ctx.textAlign = 'left'; |
| 187 | + if (has(stars)) { |
| 188 | + ctx.fillStyle = ink; |
| 189 | + ctx.font = `700 ${20 * scale}px ${mono}`; |
| 190 | + const t = `★ ${String(stars)}`; |
| 191 | + ctx.fillText(t, metaX, metaY); |
| 192 | + metaX += ctx.measureText(t).width + 30 * scale; |
| 193 | + } |
| 194 | + if (has(forks)) { |
| 195 | + ctx.fillStyle = secondaryColor; |
| 196 | + ctx.font = `700 ${20 * scale}px ${mono}`; |
| 197 | + ctx.fillText('⑂', metaX, metaY); |
| 198 | + const gw = ctx.measureText('⑂').width + 8 * scale; |
| 199 | + ctx.fillStyle = ink; |
| 200 | + ctx.fillText(String(forks), metaX + gw, metaY); |
| 201 | + metaX += gw + ctx.measureText(String(forks)).width + 30 * scale; |
| 202 | + } |
| 203 | + if (has(language)) { |
| 204 | + ctx.font = `700 ${16 * scale}px ${mono}`; |
| 205 | + const lang = language.toUpperCase(); |
| 206 | + const lw = ctx.measureText(lang).width; |
| 207 | + const pillH = 32 * scale; |
| 208 | + const pillW = lw + 28 * scale; |
| 209 | + roundRectPath(ctx, metaX, metaY - pillH / 2, pillW, pillH, pillH / 2); |
| 210 | + ctx.strokeStyle = primaryColor; |
| 211 | + ctx.lineWidth = 2 * scale; |
| 212 | + ctx.stroke(); |
| 213 | + ctx.fillStyle = primaryColor; |
| 214 | + ctx.textAlign = 'left'; |
| 215 | + ctx.fillText(lang, metaX + 14 * scale, metaY + 1 * scale); |
| 216 | + } |
| 217 | + |
| 218 | + // --- Description: single muted truncated line --- |
| 219 | + if (has(description)) { |
| 220 | + const descY = metaY + 34 * scale; |
| 221 | + ctx.fillStyle = muted; |
| 222 | + ctx.font = `400 ${16 * scale}px ${sans}`; |
| 223 | + ctx.textBaseline = 'middle'; |
| 224 | + ctx.textAlign = 'left'; |
| 225 | + const desc = truncate(ctx, description, bodyW - 56 * scale, `400 ${16 * scale}px ${sans}`); |
| 226 | + ctx.fillText(desc, padL, descY); |
| 227 | + } |
| 228 | + |
| 229 | + // --- Horizontal barcode along bottom of main body --- |
| 230 | + const barAreaH = 40 * scale; |
| 231 | + const barY = cardY + cardH - barAreaH - 34 * scale; |
| 232 | + let bx = padL; |
| 233 | + const barMaxX = perfX - 26 * scale; |
| 234 | + ctx.fillStyle = ink; |
| 235 | + let bi = 0; |
| 236 | + while (bx < barMaxX - 2 * scale) { |
| 237 | + const bw = (1 + Math.floor(mulberry(seed + 100 + bi) * 4)) * scale; |
| 238 | + if (mulberry(seed + 200 + bi) > 0.32) { |
| 239 | + ctx.fillRect(bx, barY, bw, barAreaH); |
| 240 | + } |
| 241 | + bx += bw + (1 + Math.floor(mulberry(seed + 300 + bi) * 2)) * scale; |
| 242 | + bi++; |
| 243 | + } |
| 244 | + ctx.fillStyle = muted; |
| 245 | + ctx.font = `400 ${13 * scale}px ${mono}`; |
| 246 | + ctx.textAlign = 'left'; |
| 247 | + ctx.textBaseline = 'top'; |
| 248 | + ctx.fillText(`${flightCode} • GATE ${gate} • ${boardTime}`, padL, barY + barAreaH + 8 * scale); |
| 249 | + |
| 250 | + // ====================== STUB ====================== |
| 251 | + // Stub mini header |
| 252 | + ctx.fillStyle = primaryColor; |
| 253 | + ctx.fillRect(perfX, cardY, stubW, headerH); |
| 254 | + ctx.fillStyle = onPrimary; |
| 255 | + ctx.font = `700 ${13 * scale}px ${mono}`; |
| 256 | + ctx.textAlign = 'center'; |
| 257 | + ctx.textBaseline = 'middle'; |
| 258 | + ctx.fillText(letterSpace('SEAT'), perfX + stubW / 2, cardY + headerH / 2); |
| 259 | + |
| 260 | + // Big SEAT value |
| 261 | + const stubCx = perfX + stubW / 2; |
| 262 | + ctx.fillStyle = ink; |
| 263 | + ctx.font = `800 ${64 * scale}px ${sans}`; |
| 264 | + ctx.textAlign = 'center'; |
| 265 | + ctx.textBaseline = 'middle'; |
| 266 | + ctx.fillText(seat, stubCx - 4 * scale, cardY + headerH + 70 * scale); |
| 267 | + // secondaryColor accent underline |
| 268 | + ctx.fillStyle = secondaryColor; |
| 269 | + ctx.fillRect(stubCx - 44 * scale, cardY + headerH + 116 * scale, 88 * scale, 4 * scale); |
| 270 | + |
| 271 | + // GATE + FLIGHT repeated on stub |
| 272 | + const stubInfoY = cardY + headerH + 156 * scale; |
| 273 | + const stubInfo = [ |
| 274 | + ['GATE', gate], |
| 275 | + ['FLIGHT', flightCode], |
| 276 | + ['BOARD', boardTime], |
| 277 | + ]; |
| 278 | + stubInfo.forEach((f, i) => { |
| 279 | + const sy = stubInfoY + i * 42 * scale; |
| 280 | + ctx.fillStyle = muted; |
| 281 | + ctx.font = `700 ${11 * scale}px ${mono}`; |
| 282 | + ctx.textAlign = 'center'; |
| 283 | + ctx.textBaseline = 'alphabetic'; |
| 284 | + ctx.fillText(letterSpace(f[0]), stubCx, sy); |
| 285 | + ctx.fillStyle = ink; |
| 286 | + ctx.font = `700 ${20 * scale}px ${mono}`; |
| 287 | + ctx.fillText(f[1], stubCx, sy + 22 * scale); |
| 288 | + }); |
| 289 | + |
| 290 | + // Vertical barcode along stub's right edge |
| 291 | + const vbX = cardX + cardW - 26 * scale; |
| 292 | + let vy = cardY + 80 * scale; |
| 293 | + const vbMaxY = cardY + cardH - 26 * scale; |
| 294 | + ctx.fillStyle = ink; |
| 295 | + let vi = 0; |
| 296 | + const vbW = 22 * scale; |
| 297 | + while (vy < vbMaxY - 2 * scale) { |
| 298 | + const bh2 = (1 + Math.floor(mulberry(seed + 400 + vi) * 4)) * scale; |
| 299 | + if (mulberry(seed + 500 + vi) > 0.32) { |
| 300 | + ctx.fillRect(vbX - vbW, vy, vbW, bh2); |
| 301 | + } |
| 302 | + vy += bh2 + (1 + Math.floor(mulberry(seed + 600 + vi) * 2)) * scale; |
| 303 | + vi++; |
| 304 | + } |
| 305 | + |
| 306 | + ctx.restore(); // end card clip |
| 307 | + |
| 308 | + // --- Perforation: dashed vertical line + punched notch circles --- |
| 309 | + ctx.save(); |
| 310 | + ctx.strokeStyle = mix(ink, paper, 0.6); |
| 311 | + ctx.lineWidth = 2 * scale; |
| 312 | + ctx.setLineDash([6 * scale, 6 * scale]); |
| 313 | + ctx.beginPath(); |
| 314 | + ctx.moveTo(perfX, cardY + 14 * scale); |
| 315 | + ctx.lineTo(perfX, cardY + cardH - 14 * scale); |
| 316 | + ctx.stroke(); |
| 317 | + ctx.restore(); |
| 318 | + |
| 319 | + // Punched notches at top & bottom (filled with the ground gradient so they read as cut-outs) |
| 320 | + const notchR = 14 * scale; |
| 321 | + ctx.save(); |
| 322 | + ctx.fillStyle = grad; |
| 323 | + ctx.beginPath(); |
| 324 | + ctx.arc(perfX, cardY, notchR, 0, Math.PI * 2); |
| 325 | + ctx.fill(); |
| 326 | + ctx.beginPath(); |
| 327 | + ctx.arc(perfX, cardY + cardH, notchR, 0, Math.PI * 2); |
| 328 | + ctx.fill(); |
| 329 | + ctx.restore(); |
| 330 | +}; |
| 331 | + |
| 332 | +// ---------------- helpers (hoisted) ---------------- |
| 333 | +function letterSpace(s) { |
| 334 | + return String(s || '').split('').join(' '); |
| 335 | +} |
| 336 | + |
| 337 | +function drawPlane(ctx, cx, cy, size, rot) { |
| 338 | + ctx.save(); |
| 339 | + ctx.translate(cx, cy); |
| 340 | + ctx.rotate(rot); |
| 341 | + const s = size; |
| 342 | + ctx.beginPath(); |
| 343 | + ctx.moveTo(0.5 * s, 0); |
| 344 | + ctx.lineTo(-0.1 * s, 0.1 * s); |
| 345 | + ctx.lineTo(-0.35 * s, 0.1 * s); |
| 346 | + ctx.lineTo(-0.18 * s, 0.32 * s); |
| 347 | + ctx.lineTo(-0.34 * s, 0.32 * s); |
| 348 | + ctx.lineTo(-0.5 * s, 0.06 * s); |
| 349 | + ctx.lineTo(-0.5 * s, -0.06 * s); |
| 350 | + ctx.lineTo(-0.34 * s, -0.32 * s); |
| 351 | + ctx.lineTo(-0.18 * s, -0.32 * s); |
| 352 | + ctx.lineTo(-0.35 * s, -0.1 * s); |
| 353 | + ctx.lineTo(-0.1 * s, -0.1 * s); |
| 354 | + ctx.closePath(); |
| 355 | + ctx.fill(); |
| 356 | + ctx.restore(); |
| 357 | +} |
| 358 | + |
| 359 | +function parseHex(hex){const h=(hex||'#000').replace('#','');const f=h.length===3?h.split('').map(c=>c+c).join(''):h;return{r:parseInt(f.slice(0,2),16)||0,g:parseInt(f.slice(2,4),16)||0,b:parseInt(f.slice(4,6),16)||0};} |
| 360 | +function rgbToHex(r,g,b){const h=n=>Math.max(0,Math.min(255,Math.round(n))).toString(16).padStart(2,'0');return '#'+h(r)+h(g)+h(b);} |
| 361 | +function mix(a,b,t){const x=parseHex(a),y=parseHex(b);return rgbToHex(x.r+(y.r-x.r)*t,x.g+(y.g-x.g)*t,x.b+(y.b-x.b)*t);} |
| 362 | +function lighten(hex,a){const{r,g,b}=parseHex(hex);return rgbToHex(r+255*a,g+255*a,b+255*a);} |
| 363 | +function readableOn(hex){const{r,g,b}=parseHex(hex);return (0.299*r+0.587*g+0.114*b)>150?'#111111':'#ffffff';} |
| 364 | +function mulberry(seed){let t=(seed*1831565813)|0;t=(t+0x6D2B79F5)|0;let r=Math.imul(t^(t>>>15),1|t);r^=r+Math.imul(r^(r>>>7),61|r);return ((r^(r>>>14))>>>0)/4294967296;} |
| 365 | +function seedFrom(str){let s=0;for(const c of (str||'x'))s=(s*31+c.charCodeAt(0))|0;return Math.abs(s)||1;} |
| 366 | +function has(v){return v!==undefined&&v!==null&&String(v).trim()!=='';} |
| 367 | +function truncate(ctx,text,maxW,font){ctx.font=font;if(ctx.measureText(text).width<=maxW)return text;let t=text;while(t.length>1&&ctx.measureText(t+'…').width>maxW)t=t.slice(0,-1);return t+'…';} |
| 368 | +function roundRectPath(ctx,x,y,w,h,r){const rr=Math.min(r,w/2,h/2);ctx.beginPath();ctx.moveTo(x+rr,y);ctx.arcTo(x+w,y,x+w,y+h,rr);ctx.arcTo(x+w,y+h,x,y+h,rr);ctx.arcTo(x,y+h,x,y,rr);ctx.arcTo(x,y,x+w,y,rr);ctx.closePath();} |
0 commit comments