Hero: replace particle field with cobe globe (Capricorn view, multi-axis drag, reduced-motion safe), telemetry HUD with pointer parallax, particle text build effect, Space Grotesk display font. SEO: short titles with template, PT-BR accents, twitter cards on dynamic cases, escaped JSON-LD via lib/jsonld.ts, CSP report-only, 25 service FAQs answer-first. Design: fix TW4 layered reset killing spacing utilities, literal @theme tokens, mobile hero overflow, header overlap, empty case cards. Remove framer-motion (unified on motion/react), header version badge, phone number from hero CTA. Production build verified (24 static pages).
71 lines
1.8 KiB
TypeScript
71 lines
1.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
export default function FilmGrain() {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const reducedMotion = window.matchMedia(
|
|
"(prefers-reduced-motion: reduce)",
|
|
).matches;
|
|
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
|
if (reducedMotion || coarsePointer) return;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
let animId: number;
|
|
let lastFrame = 0;
|
|
const fps = 24; // Cinematic film fps
|
|
const frameInterval = 1000 / fps;
|
|
|
|
const resize = () => {
|
|
canvas.width = window.innerWidth / 2;
|
|
canvas.height = window.innerHeight / 2;
|
|
};
|
|
resize();
|
|
window.addEventListener("resize", resize);
|
|
|
|
const render = (timestamp: number) => {
|
|
animId = requestAnimationFrame(render);
|
|
|
|
const delta = timestamp - lastFrame;
|
|
if (delta < frameInterval) return;
|
|
lastFrame = timestamp - (delta % frameInterval);
|
|
|
|
const { width, height } = canvas;
|
|
const imageData = ctx.createImageData(width, height);
|
|
const data = imageData.data;
|
|
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
const noise = Math.random() * 20;
|
|
data[i] = noise;
|
|
data[i + 1] = noise;
|
|
data[i + 2] = noise;
|
|
data[i + 3] = 12; // Very subtle
|
|
}
|
|
|
|
ctx.putImageData(imageData, 0, 0);
|
|
};
|
|
|
|
animId = requestAnimationFrame(render);
|
|
|
|
return () => {
|
|
cancelAnimationFrame(animId);
|
|
window.removeEventListener("resize", resize);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="film-grain pointer-events-none fixed inset-0 z-[9990] h-full w-full opacity-[0.35] mix-blend-overlay"
|
|
style={{ imageRendering: "pixelated" }}
|
|
/>
|
|
);
|
|
}
|