storytellurx-v1/app/components/utils/fish-eye.tsx
Elias Rhouzlane a82706a764 feat: Add Lottie to SVG conversion utility and integrate zipper animation
- Created a new utility script `lottie-to-svg.ts` to convert Lottie animations to SVG format using `bodymovin-to-smil`.
- Added a new Lottie animation file `zipper.json` for the zipper animation.
- Generated the corresponding SVG file `zipper.svg` from the Lottie animation.
- Updated `deno.json` to include new dependencies and references for the zipper animation.
- Modified `main.css` to introduce new color variables and styles for the zipper animation.
2025-07-01 04:23:37 +02:00

261 lines
8.1 KiB
TypeScript

"use client";
import { useEffect, useId, useRef, useState } from "react";
import { Slot } from "@radix-ui/react-slot"; // Requires: npm install @radix-ui/react-slot
interface FisheyeEffectProps {
asChild?: boolean;
/**
* Scale factor for the displacement map. Higher values create a more pronounced effect.
* Defaults to 10, matching the user's provided snippet.
*/
scale?: number;
}
const createMask = () => {
// --- Configuration ---
const size = 1024;
// --- Canvas Setup ---
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) {
console.error("Could not get 2D context from canvas.");
return;
}
// --- Logic for Creating the Barrel/Pinch Effect Mask ---
const imageData = ctx.createImageData(size, size);
const data = imageData.data;
const centerX = size / 2;
const centerY = size / 2;
const radius = size / 4;
// Iterate over every pixel
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const index = (y * size + x) * 4;
const distFromCenter = Math.sqrt(
Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2),
);
if (distFromCenter > radius) {
// Pixel is OUTSIDE the circle. Set to 50% gray (no displacement).
data[index] = 128;
data[index + 1] = 128;
data[index + 2] = 0;
data[index + 3] = 255;
} else {
// Pixel is INSIDE the circle.
// 1. Calculate the smooth, curved falloff ("barrel" effect).
// We use a cosine curve for a natural lens-like gradient.
const angle = (distFromCenter / radius) * (Math.PI / 2);
const intensity = Math.cos(angle) * 2; // intensity is 1 at center, 0 at edge
// 2. Determine the direction vector FROM the pixel TOWARDS the center.
// This is the key to pulling pixels inward.
const dx = (centerX - x) / radius; // Inward direction on X-axis
const dy = (centerY - y) / radius; // Inward direction on Y-axis
// 3. Calculate the final color based on direction and intensity.
// A value of 128 means no shift. We add the displacement vector scaled by intensity.
const red = 128 + dx * 127.5 * intensity;
const green = 128 + dy * 127.5 * intensity;
data[index] = red;
data[index + 1] = green;
data[index + 2] = 0; // Blue is not used
data[index + 3] = 255;
}
}
}
// Put the modified pixel data back onto the canvas
ctx.putImageData(imageData, 0, 0);
// --- Generate URL and Display the Image ---
return new Promise<string>((res, rej) =>
canvas.toBlob(
(blob) => blob ? res(URL.createObjectURL(blob)) : rej(),
"image/png",
)
);
};
export const FisheyeEffect: React.FC<FisheyeEffectProps> = ({
asChild,
scale: scaleProp,
...props
}) => {
const childRef = useRef<Element>(null); // Use Element for broader compatibility (HTML or SVG)
const [dimensions, setDimensions] = useState<
{ width: number; height: number } | null
>(null);
const filterIdBase = useId();
const filterId = Math.random().toString().slice(4, 8) ??
`fisheye-filter-${filterIdBase.replace(/:/g, "")}`;
const k = 1;
const animate = "once";
const demo = false;
const [scale, setScale] = useState(animate ? 0 : (scaleProp ?? 1));
const [maskUrl, setMaskUrl] = useState<string | null>(null);
useEffect(() => {
createMask()?.then(setMaskUrl);
}, []);
useEffect(() => {
if (!animate) return;
const id = setTimeout(
() =>
setScale((p) =>
animate === "once"
? Math.min(p + 0.5, scaleProp ?? 1)
: (p + 0.5) % (scaleProp ?? 1)
),
100,
);
return () => clearTimeout(id);
}, [scale, scaleProp]);
useEffect(() => {
const element = childRef.current;
if (!element) return;
const measure = () => {
const rect = element.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
setDimensions({ width: rect.width, height: rect.height });
}
};
measure(); // Initial measurement
const resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(element);
// Also listen to window resize as sometimes child dimensions are relative to viewport
globalThis.addEventListener("resize", measure);
return () => {
resizeObserver.unobserve(element);
resizeObserver.disconnect();
globalThis.removeEventListener("resize", measure);
};
}, []); // Empty dependency array: run once on mount and clean up on unmount
const Comp = asChild ? Slot : "div";
const maxScale = scaleProp;
const scaleRatio = scale / maxScale;
return (
<>
{/* Invisible SVG to define the filter */}
{/* Using 'visibility: hidden' and 'position: absolute' to ensure it's not affecting layout and not visible */}
{dimensions && maskUrl && (
<svg
role="graphics-symbol img"
className="absolute w-full h-full inset-0 pointer-events-none"
xmlns="http://www.w3.org/2000/svg"
width="24px"
height="24px"
viewBox="0 0 1000 1000"
>
<defs>
<filter
id={filterId}
x="-200%"
y="-200%"
width="1400%"
height="1400%"
colorInterpolationFilters="sRGB"
preserveAspectRatio="none"
>
<feImage
preserveAspectRatio="none"
result="FEIMG1"
x={-(k * scale) / 2} // Corrected: Reverted to 0 for proper centering
y={-(k * scale) / 2} // Corrected: Reverted to 0 for proper centering
width={dimensions.width + (k * scale)} // Mask covers the 200% filter area width
height={dimensions.height + (k * scale)} // Mask covers the 200% filter area height
href={maskUrl}
>
</feImage>
<feDisplacementMap
in="SourceGraphic"
in2="FEIMG1"
scale={k * scale}
xChannelSelector="R"
yChannelSelector="G"
>
</feDisplacementMap>
</filter>
</defs>
</svg>
)}
{demo
? (
<div
ref={childRef as React.Ref<HTMLElement>} // Cast needed as Slot expects HTMLElement ref by default
className="size-50"
style={dimensions
? {
filter: `url(#${filterId})`,
background:
"conic-gradient(from 90deg at 1px 1px, #0000 90deg, blue 0) 0 0 / 5px 5px",
}
: {}}
>
</div>
)
: (
<div className="relative w-full">
<div
className="-translate-y-(--ty) top-0 mx-auto w-full absolute"
style={{ "--ty": `${11 + scale * 0.35}px` }}
>
<svg
width="46"
height="11"
viewBox="0 0 46 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="h-[11px] mx-auto translate-x-1"
>
{scaleRatio > 0.25 && (
<path
d="M7.50017 10.3244L0.500126 2.99999"
stroke="currentColor"
/>
)}
{scaleRatio > 0.5 && (
<path d="M32 10.3244L45 3" stroke="currentColor" />
)}
{scaleRatio > 0.75 && (
<path d="M25 7.32443L29 0.499985" stroke="currentColor" />
)}
</svg>
</div>
<div
ref={childRef as React.Ref<HTMLElement>} // Cast needed as Slot expects HTMLElement ref by default
className="-ml-3 -mt-6 p-6 w-[calc(100%_+_var(--spacing)_*_3)] translate-x-2 relative"
style={dimensions ? { filter: `url(#${filterId})` } : {}}
>
<Comp
{...props}
/>
</div>
</div>
)}
</>
);
};
export default FisheyeEffect;