Files
portifolio-evandrodevbr/app/components/story/phoneScreenTexture.ts
T

184 lines
6.0 KiB
TypeScript

// Gera a textura "site vivo" da tela do celular: um mini-portfolio desenhado em
// canvas 2D, com os prints reais dos projetos em produção rolando verticalmente
// conforme o progresso do reel — como se o site estivesse aberto no aparelho.
// Fotos dos projetos: capturadas ao vivo dos deployments (headless Chrome).
// CC0 model: "Phone" by Quaternius (https://quaternius.com) via poly.pizza.
type Shot = { img: HTMLImageElement; name: string; tag: string };
const SHOTS: Array<{ src: string; name: string; tag: string }> = [
{ src: "/assets/prints/print-nutriscan.png", name: "NutriScan", tag: "Open Food Facts · PWA" },
{ src: "/assets/prints/print-scplus.png", name: "SCPLUS", tag: "Educação + IoT · Garuva-SC" },
{ src: "/assets/prints/print-sev.png", name: "SEV Moda Íntima", tag: "E-commerce · Next.js" },
{ src: "/assets/prints/print-asservicos.png", name: "AS Serviços", tag: "Institucional · SSR" },
];
export type ScreenPainter = {
canvas: HTMLCanvasElement;
/** desenha o frame para o progresso p∈[0,1] do reel; retorna true se mudou */
paint: (p: number, t: number) => boolean;
ready: () => boolean;
};
const W = 512;
const H = 1024;
const INK = "#0a0a0a";
const PAPER = "#f4f2ed";
const SLASH = "#ccff00";
export function createPhoneScreen(): ScreenPainter {
const canvas = document.createElement("canvas");
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext("2d");
const images: Shot[] = [];
let lastKey = "";
for (const s of SHOTS) {
const img = new Image();
img.decoding = "async";
img.onload = () => images.push({ img, name: s.name, tag: s.tag });
img.src = s.src;
}
function roundRect(x: number, y: number, w: number, h: number, r: number) {
if (!ctx) return;
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function wrap(text: string, x: number, y: number, maxW: number, lh: number) {
if (!ctx) return;
const words = text.split(" ");
let line = "";
let yy = y;
for (const word of words) {
const candidate = line ? `${line} ${word}` : word;
if (ctx.measureText(candidate).width > maxW) {
ctx.fillText(line, x, yy);
line = word;
yy += lh;
} else line = candidate;
}
if (line) ctx.fillText(line, x, yy);
}
function drawHero(offset: number, t: number) {
if (!ctx) return;
if (offset > H || offset < -H) return;
ctx.save();
ctx.translate(0, offset);
const pulse = 0.6 + 0.4 * Math.sin(t / 600);
ctx.fillStyle = SLASH;
ctx.globalAlpha = 0.14 + pulse * 0.06;
ctx.fillRect(0, 60, W, 2);
ctx.globalAlpha = 1;
ctx.fillStyle = "rgba(244,242,237,.55)";
ctx.font = "600 20px ui-monospace, monospace";
ctx.fillText("PORTFÓLIO · AO VIVO", 36, 150);
ctx.fillStyle = PAPER;
ctx.font = "800 72px system-ui, sans-serif";
ctx.fillText("Software", 36, 260);
ctx.fillText("que", 36, 340);
ctx.fillStyle = SLASH;
ctx.fillText("opera.", 36, 420);
ctx.fillStyle = "rgba(244,242,237,.66)";
ctx.font = "400 28px system-ui, sans-serif";
wrap("Engenharia de sistemas que precisam funcionar de verdade.", 36, 500, W - 72, 40);
ctx.fillStyle = SLASH;
roundRect(36, 700, 250, 72, 36);
ctx.fill();
ctx.fillStyle = INK;
ctx.font = "700 26px ui-monospace, monospace";
ctx.fillText("VER PROJETOS ↓", 60, 744);
ctx.restore();
}
function drawProject(shot: Shot, offset: number, i: number, t: number) {
if (!ctx) return;
ctx.save();
ctx.translate(0, offset);
ctx.fillStyle = "rgba(244,242,237,.5)";
ctx.font = "600 18px ui-monospace, monospace";
ctx.fillText(`EM PRODUÇÃO · 0${i + 1}`, 36, 110);
ctx.fillStyle = PAPER;
ctx.font = "800 46px system-ui, sans-serif";
ctx.fillText(shot.name, 36, 168);
ctx.fillStyle = "rgba(204,255,0,.85)";
ctx.font = "400 22px system-ui, sans-serif";
ctx.fillText(shot.tag, 36, 204);
// o print real, recortado, com leve auto-scroll dentro do card
const cardX = 36;
const cardY = 236;
const cardW = W - 72;
const cardH = 620;
ctx.save();
roundRect(cardX, cardY, cardW, cardH, 28);
ctx.clip();
ctx.fillStyle = "#111";
ctx.fillRect(cardX, cardY, cardW, cardH);
const scale = cardW / shot.img.width;
const drift =
(Math.sin(t / 1400 + i * 2.1) * 0.5 + 0.5) *
Math.max(0, shot.img.height * scale - cardH);
ctx.drawImage(shot.img, cardX, cardY - drift, cardW, shot.img.height * scale);
const grd = ctx.createLinearGradient(cardX, cardY, cardX, cardY + cardH);
grd.addColorStop(0, "rgba(255,255,255,.08)");
grd.addColorStop(0.12, "rgba(255,255,255,0)");
ctx.fillStyle = grd;
ctx.fillRect(cardX, cardY, cardW, cardH);
ctx.restore();
ctx.strokeStyle = "rgba(255,255,255,.12)";
ctx.lineWidth = 2;
roundRect(cardX, cardY, cardW, cardH, 28);
ctx.stroke();
ctx.restore();
}
function paint(p: number, t: number) {
if (!ctx || images.length === 0) return false;
const key = `${p.toFixed(4)}|${Math.floor(t / 250)}|${images.length}`;
if (key === lastKey) return false;
lastKey = key;
ctx.fillStyle = INK;
ctx.fillRect(0, 0, W, H);
// status bar do "aparelho"
ctx.fillStyle = PAPER;
ctx.font = "600 22px ui-monospace, monospace";
ctx.fillText("21:37", 28, 38);
ctx.fillStyle = SLASH;
ctx.fillRect(W - 96, 22, 44, 14);
ctx.fillStyle = PAPER;
ctx.font = "600 16px ui-monospace, monospace";
ctx.fillText("E:/", W - 40, 36);
// o mini-site rolando: hero + página por projeto
const pageH = H - 60;
const y = p * images.length * pageH;
drawHero(-y, t);
images.forEach((shot, i) => {
const top = (i + 1) * pageH - y;
if (top > H || top < -pageH) return;
drawProject(shot, top, i, t);
});
return true;
}
return {
canvas,
paint,
ready: () => images.length > 0,
};
}
export const PHONE_SCREEN_W = W;
export const PHONE_SCREEN_H = H;