From 37702f5fd3b48bf557f545d3ed6c46edc8660892 Mon Sep 17 00:00:00 2001 From: luisdralves Date: Thu, 5 Mar 2026 10:10:59 +0000 Subject: [PATCH] Add geometric element --- src/app/globals.css | 4 + src/app/page.tsx | 2 +- .../landing/geometric-element/animation.ts | 260 ++++++++++++++++++ .../landing/geometric-element/constants.ts | 14 + .../geometric-element/dynamic-canvas.tsx | 96 +++++++ .../landing/geometric-element/index.tsx | 26 ++ .../landing/geometric-element/points.ts | 39 +++ .../geometric-element/static-fallback.tsx | 58 ++++ .../landing/geometric-element/types.ts | 32 +++ src/app/sections/landing/index.tsx | 9 +- .../{landing-client.tsx => logotype.tsx} | 10 +- .../radial-reveal.tsx | 45 +++ 12 files changed, 586 insertions(+), 9 deletions(-) create mode 100644 src/app/sections/landing/geometric-element/animation.ts create mode 100644 src/app/sections/landing/geometric-element/constants.ts create mode 100644 src/app/sections/landing/geometric-element/dynamic-canvas.tsx create mode 100644 src/app/sections/landing/geometric-element/index.tsx create mode 100644 src/app/sections/landing/geometric-element/points.ts create mode 100644 src/app/sections/landing/geometric-element/static-fallback.tsx create mode 100644 src/app/sections/landing/geometric-element/types.ts rename src/app/sections/landing/{landing-client.tsx => logotype.tsx} (81%) diff --git a/src/app/globals.css b/src/app/globals.css index dbdfafa..5a248a9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -18,6 +18,10 @@ --font-cursive: var(--font-cursive); } +html { + overflow-y: overlay; +} + body { background: var(--background); color: var(--foreground); diff --git a/src/app/page.tsx b/src/app/page.tsx index 98d5d58..b1449fd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,7 +10,7 @@ export default function Home() {
-
+

About

diff --git a/src/app/sections/landing/geometric-element/animation.ts b/src/app/sections/landing/geometric-element/animation.ts new file mode 100644 index 0000000..e88963e --- /dev/null +++ b/src/app/sections/landing/geometric-element/animation.ts @@ -0,0 +1,260 @@ +import { + BLUE, + CYAN, + DRIFT_AMOUNT, + EDGE_APPEAR_SPEED, + EDGE_DISAPPEAR_SPEED, + NEIGHBORS, + PROXIMITY_THRESHOLD, + REPEL_STRENGTH, + SPRING_DAMPING, + SPRING_MASS, + SPRING_STIFFNESS, + VIOLET, +} from "./constants"; +import { type Color, EdgePhase, type EdgeState, type Point } from "./types"; + +type Cursor = { x: number; y: number }; + +export const updatePoints = ( + points: Point[], + elapsed: number, + deltaTime: number, + cursor: Cursor, + rect: DOMRect, + scaleX: number, + scaleY: number, + isTouchDevice: boolean, +) => { + for (const point of points) { + const driftX = Math.sin(elapsed * point.driftSpeedX + point.driftPhaseX) * DRIFT_AMOUNT; + const driftY = Math.sin(elapsed * point.driftSpeedY + point.driftPhaseY) * DRIFT_AMOUNT; + + point.targetOffsetX = 0; + point.targetOffsetY = 0; + + if (!isTouchDevice && cursor.x !== 0) { + const pointScreenX = rect.left + (point.baseX + driftX) * scaleX; + const pointScreenY = rect.top + (point.baseY + driftY) * scaleY; + const dx = cursor.x - pointScreenX; + const dy = cursor.y - pointScreenY; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < PROXIMITY_THRESHOLD && distance > 0) { + const influence = (1 - distance / PROXIMITY_THRESHOLD) ** 2; + point.targetOffsetX = -(dx / distance) * influence * REPEL_STRENGTH; + point.targetOffsetY = -(dy / distance) * influence * REPEL_STRENGTH; + } + } + + const forceX = (point.targetOffsetX - point.currentOffsetX) * SPRING_STIFFNESS; + const forceY = (point.targetOffsetY - point.currentOffsetY) * SPRING_STIFFNESS; + const dampingX = point.velocityX * SPRING_DAMPING; + const dampingY = point.velocityY * SPRING_DAMPING; + + point.velocityX += ((forceX - dampingX) / SPRING_MASS) * deltaTime; + point.velocityY += ((forceY - dampingY) / SPRING_MASS) * deltaTime; + point.currentOffsetX += point.velocityX * deltaTime; + point.currentOffsetY += point.velocityY * deltaTime; + + point.x = point.baseX + driftX + point.currentOffsetX; + point.y = point.baseY + driftY + point.currentOffsetY; + } +}; + +export const findCurrentEdges = (points: Point[]): Set => { + const currentEdges = new Set(); + + for (const point of points) { + const distances = points + .filter((p) => p.id !== point.id) + .map((other) => ({ + other, + dist: Math.sqrt((point.x - other.x) ** 2 + (point.y - other.y) ** 2), + })) + .sort((a, b) => a.dist - b.dist); + + for (let i = 0; i < Math.min(NEIGHBORS, distances.length); i++) { + const other = distances[i].other; + const edgeKey = point.id < other.id ? `${point.id}-${other.id}` : `${other.id}-${point.id}`; + currentEdges.add(edgeKey); + } + } + + return currentEdges; +}; + +export const updateEdges = ( + edges: Map, + currentEdges: Set, + deltaTime: number, +) => { + // Mark edges that should disappear + for (const [key, edge] of edges) { + if (!currentEdges.has(key) && edge.phase !== EdgePhase.Disappearing) { + edge.phase = EdgePhase.Disappearing; + edge.progress = 0; + } + } + + // Add new edges or resurrect disappearing ones + for (const key of currentEdges) { + const existing = edges.get(key); + if (!existing) { + const [id1, id2] = key.split("-").map(Number); + edges.set(key, { id1, id2, phase: EdgePhase.Appearing, progress: 0 }); + } else if (existing.phase === EdgePhase.Disappearing) { + existing.phase = EdgePhase.Appearing; + existing.progress = 1 - existing.progress; + } + } + + // Animate edges and remove fully disappeared ones + const toRemove: string[] = []; + for (const [key, edge] of edges) { + if (edge.phase === EdgePhase.Appearing) { + edge.progress += EDGE_APPEAR_SPEED * deltaTime; + if (edge.progress >= 1) { + edge.progress = 1; + edge.phase = EdgePhase.Visible; + } + } else if (edge.phase === EdgePhase.Disappearing) { + edge.progress += EDGE_DISAPPEAR_SPEED * deltaTime; + if (edge.progress >= 1) { + toRemove.push(key); + } + } + } + for (const key of toRemove) { + edges.delete(key); + } +}; + +const getEdgeColor = (id1: number, id2: number): Color => { + const colorIndex = (id1 + id2) % 3; + return colorIndex === 0 ? CYAN : colorIndex === 1 ? VIOLET : BLUE; +}; + +const drawLine = ( + ctx: CanvasRenderingContext2D, + x1: number, + y1: number, + x2: number, + y2: number, + color: Color, + opacity: number, +) => { + ctx.strokeStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${opacity})`; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); +}; + +export const drawEdges = ( + ctx: CanvasRenderingContext2D, + edges: Map, + points: Point[], + scaleX: number, + scaleY: number, +) => { + ctx.lineWidth = 1; + + for (const [, edge] of edges) { + const p1 = points[edge.id1]; + const p2 = points[edge.id2]; + + const x1 = p1.x * scaleX; + const y1 = p1.y * scaleY; + const x2 = p2.x * scaleX; + const y2 = p2.y * scaleY; + + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + + const dist = Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2); + const maxDist = 200; + const baseOpacity = Math.max(0, 1 - dist / maxDist) * 0.6; + const color = getEdgeColor(edge.id1, edge.id2); + + if (edge.phase === EdgePhase.Appearing) { + const t = edge.progress; + + // First half: draw from p1 towards center + const seg1End = Math.min(t * 2, 1); + drawLine(ctx, x1, y1, x1 + (cx - x1) * seg1End, y1 + (cy - y1) * seg1End, color, baseOpacity); + + // Second half: draw from p2 towards center + if (t > 0.5) { + const seg2End = (t - 0.5) * 2; + drawLine( + ctx, + x2, + y2, + x2 + (cx - x2) * (1 - seg2End), + y2 + (cy - y2) * (1 - seg2End), + color, + baseOpacity, + ); + } + } else if (edge.phase === EdgePhase.Disappearing) { + const t = edge.progress; + const len = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); + const dirX = len > 0 ? (x2 - x1) / len : 0; + const dirY = len > 0 ? (y2 - y1) / len : 0; + + if (t < 0.5) { + // First half: gap grows from center + const gapSize = t * 2; + const halfLen = Math.sqrt((cx - x1) ** 2 + (cy - y1) ** 2); + const gapHalf = halfLen * gapSize; + + drawLine(ctx, x1, y1, cx - dirX * gapHalf, cy - dirY * gapHalf, color, baseOpacity); + drawLine(ctx, cx + dirX * gapHalf, cy + dirY * gapHalf, x2, y2, color, baseOpacity); + } else { + // Second half: segments shrink and fade + const shrink = (t - 0.5) * 2; + const fadeOpacity = baseOpacity * (1 - shrink); + + drawLine( + ctx, + x1, + y1, + x1 + (cx - x1) * (1 - shrink), + y1 + (cy - y1) * (1 - shrink), + color, + fadeOpacity, + ); + drawLine( + ctx, + x2 + (cx - x2) * (1 - shrink), + y2 + (cy - y2) * (1 - shrink), + x2, + y2, + color, + fadeOpacity, + ); + } + } else { + // Visible - draw full line + drawLine(ctx, x1, y1, x2, y2, color, baseOpacity); + } + } +}; + +export const drawPoints = ( + ctx: CanvasRenderingContext2D, + points: Point[], + scaleX: number, + scaleY: number, +) => { + for (const point of points) { + const x = point.x * scaleX; + const y = point.y * scaleY; + const color = point.id % 2 === 0 ? CYAN : VIOLET; + ctx.fillStyle = `rgb(${color.r}, ${color.g}, ${color.b})`; + ctx.beginPath(); + ctx.arc(x, y, 2, 0, Math.PI * 2); + ctx.fill(); + } +}; diff --git a/src/app/sections/landing/geometric-element/constants.ts b/src/app/sections/landing/geometric-element/constants.ts new file mode 100644 index 0000000..0d78492 --- /dev/null +++ b/src/app/sections/landing/geometric-element/constants.ts @@ -0,0 +1,14 @@ +import type { Color } from "./types"; + +export const DRIFT_AMOUNT = 25; +export const NEIGHBORS = 3; +export const PROXIMITY_THRESHOLD = 250; +export const REPEL_STRENGTH = 80; +export const SPRING_STIFFNESS = 180; +export const SPRING_DAMPING = 12; +export const SPRING_MASS = 1; +export const EDGE_APPEAR_SPEED = 4; +export const EDGE_DISAPPEAR_SPEED = 5; +export const CYAN: Color = { r: 68, g: 211, b: 220 }; +export const VIOLET: Color = { r: 145, g: 82, b: 211 }; +export const BLUE: Color = { r: 107, g: 147, b: 216 }; diff --git a/src/app/sections/landing/geometric-element/dynamic-canvas.tsx b/src/app/sections/landing/geometric-element/dynamic-canvas.tsx new file mode 100644 index 0000000..21c0a6f --- /dev/null +++ b/src/app/sections/landing/geometric-element/dynamic-canvas.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { drawEdges, drawPoints, findCurrentEdges, updateEdges, updatePoints } from "./animation"; +import { INITIAL_POINTS } from "./points"; +import type { EdgeState, Point } from "./types"; + +export const DynamicCanvas = () => { + const [isTouchDevice, setIsTouchDevice] = useState(false); + const canvasRef = useRef(null); + const pointsRef = useRef(INITIAL_POINTS.map((p) => ({ ...p }))); + const edgesRef = useRef>(new Map()); + const cursorRef = useRef({ x: 0, y: 0 }); + const lastTimeRef = useRef(0); + + useEffect(() => { + if (typeof window !== "undefined") { + setIsTouchDevice(window.matchMedia("(pointer: coarse)").matches); + } + }, []); + + useEffect(() => { + if (isTouchDevice) return; + + const handleMouseMove = (e: MouseEvent) => { + cursorRef.current.x = e.clientX; + cursorRef.current.y = e.clientY; + }; + + window.addEventListener("mousemove", handleMouseMove); + return () => window.removeEventListener("mousemove", handleMouseMove); + }, [isTouchDevice]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let animationId: number; + const startTime = performance.now(); + lastTimeRef.current = startTime; + + const animate = (time: number) => { + const elapsed = time - startTime; + const deltaTime = Math.min((time - lastTimeRef.current) / 1000, 0.1); + lastTimeRef.current = time; + + const points = pointsRef.current; + const edges = edgesRef.current; + + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const targetWidth = Math.round(rect.width * dpr); + const targetHeight = Math.round(rect.height * dpr); + + if (canvas.width !== targetWidth || canvas.height !== targetHeight) { + canvas.width = targetWidth; + canvas.height = targetHeight; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + + const scaleX = rect.width / 1000; + const scaleY = rect.height / 500; + + updatePoints( + points, + elapsed, + deltaTime, + cursorRef.current, + rect, + scaleX, + scaleY, + isTouchDevice, + ); + + const currentEdges = findCurrentEdges(points); + updateEdges(edges, currentEdges, deltaTime); + + ctx.clearRect(0, 0, rect.width, rect.height); + drawEdges(ctx, edges, points, scaleX, scaleY); + drawPoints(ctx, points, scaleX, scaleY); + + animationId = requestAnimationFrame(animate); + }; + + animationId = requestAnimationFrame(animate); + + return () => { + cancelAnimationFrame(animationId); + }; + }, [isTouchDevice]); + + return ; +}; diff --git a/src/app/sections/landing/geometric-element/index.tsx b/src/app/sections/landing/geometric-element/index.tsx new file mode 100644 index 0000000..08215b3 --- /dev/null +++ b/src/app/sections/landing/geometric-element/index.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useReducedMotion } from "motion/react"; +import { DynamicCanvas } from "./dynamic-canvas"; +import { StaticFallback } from "./static-fallback"; + +type Props = { + wrapperHeight?: string; + canvasHeight?: string; +}; + +export const GeometricElement = ({ wrapperHeight = "0px", canvasHeight = "100vh" }: Props) => { + const shouldReduceMotion = useReducedMotion(); + const isReducedMotion = shouldReduceMotion ?? false; + + return ( +
+
+ {isReducedMotion ? : } +
+
+ ); +}; diff --git a/src/app/sections/landing/geometric-element/points.ts b/src/app/sections/landing/geometric-element/points.ts new file mode 100644 index 0000000..0a56edb --- /dev/null +++ b/src/app/sections/landing/geometric-element/points.ts @@ -0,0 +1,39 @@ +import type { Point } from "./types"; + +const seededRandom = (seed: number) => { + const x = Math.sin(seed * 9999) * 10000; + return x - Math.floor(x); +}; + +export const generatePoints = (): Point[] => { + const points: Point[] = []; + const numPoints = 40; + + for (let i = 0; i < numPoints; i++) { + const rand = (offset: number) => seededRandom(i * 100 + offset); + const baseX = 50 + rand(1) * 900; + const baseY = 30 + rand(2) * 440; + + points.push({ + id: i, + baseX, + baseY, + driftPhaseX: rand(3) * Math.PI * 2, + driftPhaseY: rand(4) * Math.PI * 2, + driftSpeedX: 0.00015 + rand(5) * 0.00025, + driftSpeedY: 0.00015 + rand(6) * 0.00025, + x: baseX, + y: baseY, + velocityX: 0, + velocityY: 0, + targetOffsetX: 0, + targetOffsetY: 0, + currentOffsetX: 0, + currentOffsetY: 0, + }); + } + + return points; +}; + +export const INITIAL_POINTS = generatePoints(); diff --git a/src/app/sections/landing/geometric-element/static-fallback.tsx b/src/app/sections/landing/geometric-element/static-fallback.tsx new file mode 100644 index 0000000..d404c40 --- /dev/null +++ b/src/app/sections/landing/geometric-element/static-fallback.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { NEIGHBORS } from "./constants"; +import { INITIAL_POINTS } from "./points"; +import type { Point } from "./types"; + +export const StaticFallback = () => { + const points = INITIAL_POINTS; + const edges: Array<{ p1: Point; p2: Point }> = []; + + for (const point of points) { + const distances = points + .filter((p) => p.id !== point.id) + .map((other) => ({ + other, + dist: Math.sqrt((point.baseX - other.baseX) ** 2 + (point.baseY - other.baseY) ** 2), + })) + .sort((a, b) => a.dist - b.dist); + + for (let i = 0; i < Math.min(NEIGHBORS, distances.length); i++) { + const other = distances[i].other; + if (point.id < other.id) { + edges.push({ p1: point, p2: other }); + } + } + } + + return ( + + ); +}; diff --git a/src/app/sections/landing/geometric-element/types.ts b/src/app/sections/landing/geometric-element/types.ts new file mode 100644 index 0000000..826cbd3 --- /dev/null +++ b/src/app/sections/landing/geometric-element/types.ts @@ -0,0 +1,32 @@ +export type Point = { + id: number; + baseX: number; + baseY: number; + driftPhaseX: number; + driftPhaseY: number; + driftSpeedX: number; + driftSpeedY: number; + x: number; + y: number; + velocityX: number; + velocityY: number; + targetOffsetX: number; + targetOffsetY: number; + currentOffsetX: number; + currentOffsetY: number; +}; + +export enum EdgePhase { + Appearing = 0, + Visible = 1, + Disappearing = 2, +} + +export type EdgeState = { + id1: number; + id2: number; + phase: EdgePhase; + progress: number; +}; + +export type Color = { r: number; g: number; b: number }; diff --git a/src/app/sections/landing/index.tsx b/src/app/sections/landing/index.tsx index e1bd3b1..364b323 100644 --- a/src/app/sections/landing/index.tsx +++ b/src/app/sections/landing/index.tsx @@ -1,4 +1,5 @@ -import { LandingClient } from "./landing-client"; +import { GeometricElement } from "./geometric-element"; +import { Logotype } from "./logotype"; const letterSegments: Record = { l: [ @@ -111,8 +112,10 @@ const allPaths = letterLayout.reduce<{ d: string; key: string }[]>( export const Landing = () => { return ( -
- +
+ + +
); }; diff --git a/src/app/sections/landing/landing-client.tsx b/src/app/sections/landing/logotype.tsx similarity index 81% rename from src/app/sections/landing/landing-client.tsx rename to src/app/sections/landing/logotype.tsx index 11de950..abb3a15 100644 --- a/src/app/sections/landing/landing-client.tsx +++ b/src/app/sections/landing/logotype.tsx @@ -13,7 +13,7 @@ const LogotypePath = ({ d: string; scrollProgress: MotionValue; }) => { - const strokeDashoffset = useTransform(scrollProgress, [0, 0.6], [0, 1]); + const strokeDashoffset = useTransform(scrollProgress, [0, 0.5], [0, 1]); return ( { - const containerRef = useRef(null); +export const Logotype = ({ paths }: Props) => { + const ref = useRef(null); const { scrollYProgress } = useScroll({ - target: containerRef, + target: ref, offset: ["start start", "end start"], }); return ( -
+
{ overlayRef.current.innerHTML = ""; overlayRef.current.appendChild(clone); + setIsReady(true); }, [targetSection]); + // Continuously sync canvas content during the reveal animation + useEffect(() => { + if (!isReady || hasCompleted) return; + + const overlay = overlayRef.current; + if (!overlay) return; + + // Cache canvas pairs once at effect start + const allCanvases = document.querySelectorAll("canvas"); + const originalCanvases: HTMLCanvasElement[] = []; + for (const canvas of allCanvases) { + if (!overlay.contains(canvas)) { + originalCanvases.push(canvas as HTMLCanvasElement); + } + } + const clonedCanvases = Array.from(overlay.querySelectorAll("canvas")) as HTMLCanvasElement[]; + + let animationId: number; + + const syncCanvases = () => { + originalCanvases.forEach((original, index) => { + const cloned = clonedCanvases[index]; + if (!cloned || original.width === 0 || original.height === 0) return; + + const clonedCtx = cloned.getContext("2d"); + if (!clonedCtx) return; + + if (cloned.width !== original.width) cloned.width = original.width; + if (cloned.height !== original.height) cloned.height = original.height; + + clonedCtx.clearRect(0, 0, cloned.width, cloned.height); + clonedCtx.drawImage(original, 0, 0); + }); + + animationId = requestAnimationFrame(syncCanvases); + }; + + animationId = requestAnimationFrame(syncCanvases); + + return () => { + cancelAnimationFrame(animationId); + }; + }, [isReady, hasCompleted]); + const maxRadius = typeof window !== "undefined" ? Math.hypot(