import React, { useEffect } from "react"; import { View, type GestureResponderEvent } from "react-native"; import Svg, { Path } from "react-native-svg"; import Animated, { Easing, ReduceMotion, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming, type SharedValue, } from "react-native-reanimated"; import { useColorScheme } from "nativewind"; import { ENTROPY_MARK_ASPECT, ENTROPY_MARK_PATH, ENTROPY_MARK_STEP, ENTROPY_MARK_VIEWBOX, } from "./entropy-mark-geometry"; // React Native port of the website's animated company mark // (website/src/components/logo.tsx). Same motion model, hover → touch: // // • At rest the mark is FLAT — sharp source `>_` only, echoes collapsed // underneath it (invisible). // • While pressed, `open` eases 0→1: the three grey time-echoes fade in and // drift apart, each breathing on its own period/phase so they never move // in lockstep (the endless, unevenly-spaced cascade). // • Parallax: while pressed, the echoes lean toward the finger (deeper // echoes shift more = depth), gated by `open` so it fades with collapse. // • On release, `open` eases 1→0 and the echoes resolve cleanly back into // the source — no snap. // • The sharp source NEVER moves (anchored), and each echo lives on its own // Animated.View layer so the source is never re-rasterized mid-animation // (the compositor lesson from the web version). // // Honors the system reduce-motion setting via ReduceMotion.System. // echo keyed by depth (3 = furthest/dimmest .. 1 = nearest the source) const PALETTE = { light: { src: "#000000", echo: { 3: "#f5f5f5", 2: "#e2e2e2", 1: "#cbcbcb" }, cyan: "#0096aa", red: "#c82837", }, dark: { src: "#ffffff", echo: { 3: "#373737", 2: "#5f5f5f", 1: "#8c8c8c" }, cyan: "#268ca0", red: "#a53440", }, } as const; // Matches the web component: breathing reach per echo depth, parallax px per // depth-step, breathing period 2.6+i*0.7s with staggered phase. const AMP: Record<1 | 2 | 3, number> = { 3: 1.55, 2: 1.4, 1: 1.6 }; const PX = 9; const PY = 7; export interface EntropyMarkAnimatedProps { /** Rendered height in px. Width follows the ~1.63:1 aspect. */ size?: number; /** "mono" = grey echoes only; "chroma" = adds the faint cyan/red fringe. */ variant?: "mono" | "chroma"; /** Override the theme; defaults to the active color scheme. */ scheme?: "light" | "dark"; /** Force the drifted-apart state (e.g. boot splash drives this). */ active?: boolean; /** Drift apart while touched (default true). */ animateOnPress?: boolean; } function Echo({ depth, fill, open, px, py, width, height, }: { depth: 1 | 2 | 3; fill: string; open: SharedValue; px: SharedValue; py: SharedValue; width: number; height: number; }) { // Breathing oscillator 0..1, own period + phase per depth → desynced. const breath = useSharedValue(0); useEffect(() => { const half = ((2.6 + depth * 0.7) * 1000) / 2; const ease = Easing.inOut(Easing.sin); breath.value = withDelay( depth * 400, withRepeat( withSequence( withTiming(1, { duration: half, easing: ease, reduceMotion: ReduceMotion.System }), withTiming(0, { duration: half, easing: ease, reduceMotion: ReduceMotion.System }) ), -1, false, undefined, ReduceMotion.System ) ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [depth]); const style = useAnimatedStyle(() => { // Offset = base drift * breathing multiplier (1..AMP) + parallax lean, // all scaled by `open` so rest = exactly collapsed under the source. const m = 1 + (AMP[depth] - 1) * breath.value; const tx = depth * ENTROPY_MARK_STEP.x * open.value * m + depth * PX * px.value * open.value; const ty = depth * ENTROPY_MARK_STEP.y * open.value * m + depth * PY * py.value * open.value; return { opacity: open.value, transform: [{ translateX: tx }, { translateY: ty }], }; }); return ( ); } /** * Touch-interactive company `>_` mark — flat at rest, echo-cascade + parallax * while pressed, clean collapse on release. The RN twin of the website logo. */ export function EntropyMarkAnimated({ size = 28, variant = "mono", scheme, active = false, animateOnPress = true, }: EntropyMarkAnimatedProps) { const { colorScheme } = useColorScheme(); const p = PALETTE[scheme ?? (colorScheme === "dark" ? "dark" : "light")]; const width = Math.round(size * ENTROPY_MARK_ASPECT); const open = useSharedValue(active ? 1 : 0); const px = useSharedValue(0); const py = useSharedValue(0); useEffect(() => { open.value = withTiming(active ? 1 : 0, { duration: 450, easing: Easing.out(Easing.cubic), reduceMotion: ReduceMotion.System, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [active]); const track = (e: GestureResponderEvent) => { // Normalize finger position over the mark to -1..1 (parallax input). const nx = Math.max(-1, Math.min(1, (e.nativeEvent.locationX / width - 0.5) * 2)); const ny = Math.max(-1, Math.min(1, (e.nativeEvent.locationY / size - 0.5) * 2)); px.value = withTiming(nx, { duration: 80, reduceMotion: ReduceMotion.System }); py.value = withTiming(ny, { duration: 80, reduceMotion: ReduceMotion.System }); }; const press = (down: boolean) => (e: GestureResponderEvent) => { if (!animateOnPress) { return; } if (down) { track(e); open.value = withTiming(1, { duration: 450, easing: Easing.out(Easing.cubic), reduceMotion: ReduceMotion.System, }); } else { // Release: echoes resolve back into the source, parallax recenters. open.value = withTiming(active ? 1 : 0, { duration: 500, easing: Easing.inOut(Easing.cubic), reduceMotion: ReduceMotion.System, }); px.value = withTiming(0, { duration: 500, reduceMotion: ReduceMotion.System }); py.value = withTiming(0, { duration: 500, reduceMotion: ReduceMotion.System }); } }; const chromaStyle = useAnimatedStyle(() => ({ opacity: open.value })); return ( animateOnPress} onResponderGrant={press(true)} onResponderMove={track} onResponderRelease={press(false)} onResponderTerminate={press(false)} > {/* echoes: far (dim) → near, each on its own compositor layer */} {([3, 2, 1] as const).map((i) => ( ))} {/* chroma fringe — rides `open` (sharp offset; RN SVG has no cheap blur) */} {variant === "chroma" ? ( ) : null} {/* sharp source — always visible, never moves (anchored). Absolutely positioned + last in the tree so it paints ON TOP of the absolutely- positioned echo/chroma layers on web too: CSS paints positioned siblings above static ones regardless of DOM order, so a static source would otherwise be buried under the echoes on web. */} ); } export default EntropyMarkAnimated;