- 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.
261 lines
8.1 KiB
TypeScript
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;
|