daily preview renderer
This commit is contained in:
@@ -0,0 +1,197 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import * as api from '../api'
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ id: 'glyph', name: 'Glyph', desc: '5x5', tag: 'VERY EASY' },
|
||||||
|
{ id: 'rune', name: 'Rune', desc: '5x5', tag: 'EASY' },
|
||||||
|
{ id: 'scroll', name: 'Scroll', desc: '9x9', tag: 'MEDIUM' },
|
||||||
|
{ id: 'codex', name: 'Codex', desc: '15x15', tag: 'HARD' },
|
||||||
|
{ id: 'grimoire', name: 'Grimoire', desc: '21x21', tag: 'EXPERT' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const W = 2560
|
||||||
|
const H = 1440
|
||||||
|
|
||||||
|
interface GridData {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
cells: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid(ctx: CanvasRenderingContext2D, grid: GridData, x: number, y: number, maxW: number, maxH: number) {
|
||||||
|
const cellSize = Math.min(maxW / grid.width, maxH / grid.height)
|
||||||
|
const gridW = cellSize * grid.width
|
||||||
|
const gridH = cellSize * grid.height
|
||||||
|
const ox = x + (maxW - gridW) / 2
|
||||||
|
const oy = y + (maxH - gridH) / 2
|
||||||
|
|
||||||
|
for (let r = 0; r < grid.height; r++) {
|
||||||
|
for (let c = 0; c < grid.width; c++) {
|
||||||
|
const cx = ox + c * cellSize
|
||||||
|
const cy = oy + r * cellSize
|
||||||
|
const isBlack = grid.cells[r][c] === 'black'
|
||||||
|
ctx.fillStyle = isBlack ? '#090909' : '#1e1e1e'
|
||||||
|
ctx.fillRect(cx, cy, cellSize, cellSize)
|
||||||
|
ctx.strokeStyle = '#555555'
|
||||||
|
ctx.lineWidth = 0.5
|
||||||
|
ctx.strokeRect(cx, cy, cellSize, cellSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DailyPreviewRenderer() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
||||||
|
async function generateImage() {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
const dateDisplay = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }).toUpperCase()
|
||||||
|
|
||||||
|
const grids: (GridData | null)[] = await Promise.all(
|
||||||
|
PRESETS.map(async (p) => {
|
||||||
|
try {
|
||||||
|
const res = await api.fetchDaily(today, p.id)
|
||||||
|
return { width: res.puzzle.width, height: res.puzzle.height, cells: res.puzzle.cells }
|
||||||
|
} catch { return null }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const cardW = 400
|
||||||
|
const cardGap = 32
|
||||||
|
const headerH = 240
|
||||||
|
const gridPad = 24
|
||||||
|
const bottomPad = 24
|
||||||
|
const gridAreaW = cardW - gridPad * 2
|
||||||
|
const maxGridH = 600
|
||||||
|
|
||||||
|
let tallestCard = headerH + bottomPad
|
||||||
|
for (const grid of grids) {
|
||||||
|
if (grid) {
|
||||||
|
const cellSize = Math.min(gridAreaW / grid.width, maxGridH / grid.height)
|
||||||
|
const h = headerH + cellSize * grid.height + bottomPad
|
||||||
|
if (h > tallestCard) tallestCard = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const topRuleH = 3
|
||||||
|
const nameH = 190
|
||||||
|
const dateH = 120
|
||||||
|
const bottomRuleH = 3
|
||||||
|
const gapAfterRule = 80
|
||||||
|
const todaysH = 52
|
||||||
|
const gapBeforeCards = 40
|
||||||
|
const accentGap = 50
|
||||||
|
const accentH = 3
|
||||||
|
|
||||||
|
const totalContentH = topRuleH + nameH + dateH + bottomRuleH + gapAfterRule + todaysH + gapBeforeCards + tallestCard + accentGap + accentH
|
||||||
|
const offsetY = Math.max(0, (H - totalContentH) / 2)
|
||||||
|
|
||||||
|
ctx.fillStyle = '#111111'
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
|
||||||
|
let y = offsetY
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ededed'
|
||||||
|
ctx.fillRect(140, y, W - 280, topRuleH)
|
||||||
|
y += topRuleH
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ededed'
|
||||||
|
ctx.font = '900 180px "Playfair Display", Georgia, serif'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.letterSpacing = '-5px'
|
||||||
|
ctx.fillText('CRUCIVERB', W / 2, y + nameH)
|
||||||
|
y += nameH
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ababab'
|
||||||
|
ctx.font = '500 32px "JetBrains Mono", monospace'
|
||||||
|
ctx.letterSpacing = '8px'
|
||||||
|
ctx.fillText(dateDisplay, W / 2, y + 55)
|
||||||
|
y += dateH
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ededed'
|
||||||
|
ctx.fillRect(140, y, W - 280, bottomRuleH)
|
||||||
|
y += bottomRuleH + gapAfterRule
|
||||||
|
|
||||||
|
ctx.fillStyle = '#b5b5b5'
|
||||||
|
ctx.font = 'italic 500 52px "Playfair Display", Georgia, serif'
|
||||||
|
ctx.letterSpacing = '0px'
|
||||||
|
ctx.fillText("Today's puzzles", W / 2, y + todaysH * 0.75)
|
||||||
|
y += todaysH + gapBeforeCards
|
||||||
|
|
||||||
|
const cardY = y
|
||||||
|
const totalCardsW = 5 * cardW + 4 * cardGap
|
||||||
|
const startX = (W - totalCardsW) / 2
|
||||||
|
let maxCardBottom = cardY
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const p = PRESETS[i]
|
||||||
|
const grid = grids[i]
|
||||||
|
const cx = startX + i * (cardW + cardGap)
|
||||||
|
|
||||||
|
let cardH = headerH + bottomPad
|
||||||
|
let gridDrawH = 0
|
||||||
|
if (grid) {
|
||||||
|
const cellSize = Math.min(gridAreaW / grid.width, maxGridH / grid.height)
|
||||||
|
gridDrawH = cellSize * grid.height
|
||||||
|
cardH = headerH + gridDrawH + bottomPad
|
||||||
|
}
|
||||||
|
|
||||||
|
maxCardBottom = Math.max(maxCardBottom, cardY + cardH)
|
||||||
|
|
||||||
|
ctx.fillStyle = '#1a1a1a'
|
||||||
|
ctx.fillRect(cx, cardY, cardW, cardH)
|
||||||
|
ctx.strokeStyle = '#2a2a2a'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.strokeRect(cx, cardY, cardW, cardH)
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ededed'
|
||||||
|
ctx.font = '700 56px "Playfair Display", Georgia, serif'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.letterSpacing = '-1px'
|
||||||
|
ctx.fillText(p.name, cx + cardW / 2, cardY + 150)
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ababab'
|
||||||
|
ctx.font = '500 20px "JetBrains Mono", monospace'
|
||||||
|
ctx.letterSpacing = '3px'
|
||||||
|
ctx.fillText(`${p.desc} ${p.tag}`, cx + cardW / 2, cardY + 185)
|
||||||
|
|
||||||
|
if (grid) {
|
||||||
|
renderGrid(ctx, grid, cx + gridPad, cardY + headerH, gridAreaW, gridDrawH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = '#d4432f'
|
||||||
|
ctx.fillRect(140, maxCardBottom + accentGap, W - 280, accentH)
|
||||||
|
ctx.letterSpacing = '0px'
|
||||||
|
|
||||||
|
// convert to JPEG and upload
|
||||||
|
try {
|
||||||
|
const jpegData = canvas.toDataURL('image/jpeg', 0.8)
|
||||||
|
await fetch('/api/internal/daily-image', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ data: jpegData }),
|
||||||
|
})
|
||||||
|
document.title = 'daily-image-done'
|
||||||
|
} catch (e) {
|
||||||
|
console.error('failed to upload daily image', e)
|
||||||
|
document.title = 'daily-image-done'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
generateImage().catch(e => {
|
||||||
|
console.error('generateImage failed:', e)
|
||||||
|
document.title = 'daily-image-done'
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'absolute', left: '-9999px', top: '-9999px' }}>
|
||||||
|
<canvas ref={canvasRef} width={W} height={H} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user