Files
mintel.me/apps/web/src/components/Marker.tsx

182 lines
5.7 KiB
TypeScript

"use client";
import React, { useId, useMemo } from "react";
import { motion } from "framer-motion";
import { cn } from "../utils/cn";
interface MarkerProps {
children: React.ReactNode;
delay?: number;
className?: string;
color?: string;
}
/**
* Copic Marker Component
*
* Uses hand-drawn filled polygon paths (NOT rects or strokes) for an
* organic, human feel. Each highlight band is a wobbly shape with
* bezier curves, not a perfect rectangle. Second strokes are offset
* to simulate imperfect re-highlighting.
*/
// Seeded PRNG for SSR-safe randomness
function createRng(seed: string) {
let s = 0;
for (let i = 0; i < seed.length; i++) {
s = ((s << 5) - s + seed.charCodeAt(i)) | 0;
}
return () => {
s = (s * 16807 + 0) % 2147483647;
return (s & 0x7fffffff) / 0x7fffffff;
};
}
/**
* Generate a hand-drawn highlight band path.
* Instead of a rectangle, we trace an organic polygon with wobbly edges.
*
* The shape goes: left edge → top edge (left to right) → right edge → bottom edge (right to left)
* Each edge has 1-2 control points for subtle curves.
*
* ViewBox: 0 0 100 100 with preserveAspectRatio="none"
* So x=0..100 maps to text width, y=0..100 maps to text height.
*/
function generateBandPath(rng: () => number, bandIndex: number) {
// Vertical position: center band in the text height
// First band centered around y=50, second shifted up or down
const yOffset = bandIndex === 0 ? 0 : (rng() - 0.5) * 20; // -10 to +10 shift
const yCenter = 50 + yOffset;
// Band thickness: covers ~40-50% of text height
const halfHeight = 20 + rng() * 8; // 20-28 units (40-56% of viewBox)
const yTop = yCenter - halfHeight;
const yBottom = yCenter + halfHeight;
// Horizontal extent: slight random overshoot/undershoot
const xStart = -2 + rng() * 3; // -2 to 1
const xEnd = 99 + rng() * 3; // 99 to 102
// Generate wobbly top edge points (left to right)
const topWobble1 = yTop + (rng() - 0.5) * 6;
const topWobble2 = yTop + (rng() - 0.5) * 6;
const topWobble3 = yTop + (rng() - 0.5) * 6;
// Generate wobbly bottom edge points (right to left)
const botWobble1 = yBottom + (rng() - 0.5) * 6;
const botWobble2 = yBottom + (rng() - 0.5) * 6;
const botWobble3 = yBottom + (rng() - 0.5) * 6;
// Slight angle on left/right edges
const leftTopY = yTop + (rng() - 0.5) * 4;
const leftBotY = yBottom + (rng() - 0.5) * 4;
const rightTopY = yTop + (rng() - 0.5) * 4;
const rightBotY = yBottom + (rng() - 0.5) * 4;
// Build the path:
// Start at top-left, trace top edge with curves, down right edge,
// trace bottom edge with curves back left, close.
const path = [
// Start top-left
`M ${xStart},${leftTopY}`,
// Top edge: 3 curve segments left→right
`C ${xStart + 15},${topWobble1} ${xStart + 30},${topWobble1} ${33},${topWobble2}`,
`C ${45},${topWobble2} ${55},${topWobble3} ${67},${topWobble3}`,
`C ${78},${topWobble3} ${xEnd - 10},${rightTopY} ${xEnd},${rightTopY}`,
// Right edge down
`L ${xEnd + (rng() - 0.5) * 2},${rightBotY}`,
// Bottom edge: 3 curve segments right→left
`C ${xEnd - 10},${botWobble1} ${78},${botWobble1} ${67},${botWobble2}`,
`C ${55},${botWobble2} ${45},${botWobble3} ${33},${botWobble3}`,
`C ${xStart + 30},${botWobble3} ${xStart + 15},${leftBotY} ${xStart},${leftBotY}`,
// Close
`Z`,
].join(" ");
return path;
}
export const Marker: React.FC<MarkerProps> = ({
children,
delay = 0,
className = "",
color = "rgba(250, 204, 21, 0.5)",
}) => {
const id = useId();
const filterId = `marker-rough-${id.replace(/:/g, "")}`;
const bands = useMemo(() => {
const rng = createRng(id);
const numBands = rng() > 0.5 ? 2 : 1;
const result = [];
for (let i = 0; i < numBands; i++) {
result.push({
d: generateBandPath(rng, i),
delay: delay + i * 0.15,
duration: 0.4 + rng() * 0.15,
});
}
return result;
}, [id, delay]);
return (
<span className={cn("relative inline px-1", className)}>
<svg
className="absolute inset-0 h-full w-full pointer-events-none z-[-1] overflow-visible mix-blend-multiply"
preserveAspectRatio="none"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<filter id={filterId} x="-5%" y="-5%" width="110%" height="110%">
{/* Very subtle edge wobble — alcohol ink bleed, not brush fraying */}
<feTurbulence
type="fractalNoise"
baseFrequency="0.04 0.3"
numOctaves="2"
seed={Math.abs(id.charCodeAt(1) || 42)}
result="noise"
/>
<feDisplacementMap
in="SourceGraphic"
in2="noise"
scale="1.5"
xChannelSelector="R"
yChannelSelector="G"
/>
</filter>
</defs>
{bands.map((band, i) => (
<motion.path
key={i}
d={band.d}
fill={color}
filter={`url(#${filterId})`}
initial={{ scaleX: 0, opacity: 0 }}
whileInView={{ scaleX: 1, opacity: 1 }}
viewport={{ once: true, margin: "-10%" }}
transition={{
scaleX: {
duration: band.duration,
delay: band.delay,
ease: [0.4, 0, 0.2, 1],
},
opacity: {
duration: 0.1,
delay: band.delay,
},
}}
style={{ transformOrigin: "left center" }}
/>
))}
</svg>
<span className="relative z-10 font-semibold" style={{ color: "#000" }}>
{children}
</span>
</span>
);
};