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