diff --git a/src/components/DataRoom/CryptoPunks/Content.tsx b/src/components/DataRoom/CryptoPunks/Content.tsx new file mode 100644 index 00000000..033ccda1 --- /dev/null +++ b/src/components/DataRoom/CryptoPunks/Content.tsx @@ -0,0 +1,58 @@ +import { Typography } from '@mui/material' +import type { BaseBlock } from '@/components/Home/types' +import type { MotionValue } from 'framer-motion' +import { motion, useTransform } from 'framer-motion' +import dynamic from 'next/dynamic' +import LinksWrapper from '../LinksWrapper' +import css from './styles.module.css' +import { useSafeDataRoomStats } from '@/hooks/useSafeDataRoomStats' +import { useIsMediumScreen } from '@/hooks/useMaxWidth' + +const PunksGrid = dynamic(() => import('./PunksGrid')) +const SlidingPanel = dynamic(() => import('@/components/common/SlidingPanel')) + +const CRYPTOPUNKS_TOTAL = 10000 +const CRYPTOPUNKS_PERCENTAGE_STORED_FALLBACK = 0.092 + +const Content = ({ scrollYProgress, title, text, link }: BaseBlock & { scrollYProgress: MotionValue }) => { + const { cryptoPunksStoredPercentage } = useSafeDataRoomStats() + const isMobile = useIsMediumScreen() + + const opacityParams = isMobile ? [0.4, 0.45, 0.65, 0.66] : [0.25, 0.35, 0.65, 0.7] + const opacity = useTransform(scrollYProgress, opacityParams, [0, 1, 1, 0]) + + const percentageValue = cryptoPunksStoredPercentage || CRYPTOPUNKS_PERCENTAGE_STORED_FALLBACK + // Converts to percentage with 1 decimal place + const percentageToDisplay = (percentageValue * 100).toFixed(1) + '%' + + const cryptoPunksStored = (percentageValue * CRYPTOPUNKS_TOTAL).toFixed(0) + const fractionToDisplay = `${cryptoPunksStored}/${CRYPTOPUNKS_TOTAL}` + + return ( + + + {text} + + + {title} + +
+ + {percentageToDisplay} + + + {fractionToDisplay} + +
+ + {link && } +
+ ) +} + +export default Content diff --git a/src/components/DataRoom/CryptoPunks/PunksGrid.tsx b/src/components/DataRoom/CryptoPunks/PunksGrid.tsx new file mode 100644 index 00000000..f3340dea --- /dev/null +++ b/src/components/DataRoom/CryptoPunks/PunksGrid.tsx @@ -0,0 +1,53 @@ +import { getRandomColor } from '@/components/DataRoom/CryptoPunks/utils/getRandomColor' +import CryptoPunk from '@/public/images/DataRoom/cryptopunk-silhouette.svg' +import type { MotionValue } from 'framer-motion' +import { motion, useTransform } from 'framer-motion' +import css from './styles.module.css' + +const CRYPTOPUNK_ROWS_NR = 8 +const CRYPTOPUNK_COLUMNS_NR = 24 + +const PunksGrid = ({ scrollYProgress, isMobile }: { scrollYProgress: MotionValue; isMobile: boolean }) => { + const translateParams = isMobile ? [0, 1] : [0.25, 0.75] + const opacity = useTransform(scrollYProgress, [0, 0.25, 0.7, 0.75], [0, 1, 1, 0]) + const translateLTR = useTransform(scrollYProgress, translateParams, ['-50%', '0%']) + const translateRTL = useTransform(scrollYProgress, translateParams, ['0%', '-50%']) + + const translateDirection = (index: number) => (index % 2 === 1 ? translateLTR : translateRTL) + + return ( + + {Array.from({ length: CRYPTOPUNK_ROWS_NR }).map((_, outerIndex) => ( + + {Array.from({ length: CRYPTOPUNK_COLUMNS_NR }).map((_, innerIndex) => ( + + + + ))} + + ))} + + ) +} + +export default PunksGrid diff --git a/src/components/DataRoom/CryptoPunks/index.tsx b/src/components/DataRoom/CryptoPunks/index.tsx index 87ca400d..974265c6 100644 --- a/src/components/DataRoom/CryptoPunks/index.tsx +++ b/src/components/DataRoom/CryptoPunks/index.tsx @@ -1,25 +1,15 @@ -import { Typography } from '@mui/material' import type { BaseBlock } from '@/components/Home/types' +import { useIsMediumScreen } from '@/hooks/useMaxWidth' +import { useScroll } from 'framer-motion' +import dynamic from 'next/dynamic' import { useRef } from 'react' -import { useScroll, motion, useTransform } from 'framer-motion' -import type { ReactNode } from 'react' -import type { MotionValue } from 'framer-motion' import css from './styles.module.css' -import LinksWrapper from '../LinksWrapper' -import { getRandomColor } from '@/components/DataRoom/CryptoPunks/utils/getRandomColor' -import CryptoPunk from '@/public/images/DataRoom/cryptopunk-silhouette.svg' -import { useIsMediumScreen } from '@/hooks/useMaxWidth' -import { useSafeDataRoomStats } from '@/hooks/useSafeDataRoomStats' - -const CRYPTOPUNKS_TOTAL = 10000 -const CRYPTOPUNKS_PERCENTAGE_STORED_FALLBACK = 0.092 -const CRYPTOPUNK_ROWS_NR = 8 -const CRYPTOPUNK_COLUMNS_NR = 24 +const PunksGrid = dynamic(() => import('./PunksGrid')) +const Content = dynamic(() => import('./Content')) +const SlidingPanel = dynamic(() => import('@/components/common/SlidingPanel')) const CryptoPunks = ({ title, text, link }: BaseBlock) => { - const { cryptoPunksStoredPercentage } = useSafeDataRoomStats() - const backgroundRef = useRef(null) const isMobile = useIsMediumScreen() @@ -28,113 +18,19 @@ const CryptoPunks = ({ title, text, link }: BaseBlock) => { offset: ['start end', 'end start'], }) - const percentageValue = cryptoPunksStoredPercentage || CRYPTOPUNKS_PERCENTAGE_STORED_FALLBACK - // Converts to percentage with 1 decimal place - const percentageToDisplay = (percentageValue * 100).toFixed(1) + '%' - - const cryptoPunksStored = (percentageValue * CRYPTOPUNKS_TOTAL).toFixed(0) - const fractionToDisplay = `${cryptoPunksStored}/${CRYPTOPUNKS_TOTAL}` - return (
- - - - {text} - - - {title} - -
- - {percentageToDisplay} - - - {fractionToDisplay} - -
- - {link && } -
-
-
- ) -} - -const LeftPanel = ({ scrollYProgress, isMobile }: { scrollYProgress: MotionValue; isMobile: boolean }) => { - const translateParams = isMobile ? [0, 1] : [0.25, 0.75] - const opacity = useTransform(scrollYProgress, [0, 0.25, 0.7, 0.75], [0, 1, 1, 0]) - const translateLTR = useTransform(scrollYProgress, translateParams, ['-50%', '0%']) - const translateRTL = useTransform(scrollYProgress, translateParams, ['0%', '-50%']) - - const translateDirection = (index: number) => (index % 2 === 1 ? translateLTR : translateRTL) - - return ( - - {Array.from({ length: CRYPTOPUNK_ROWS_NR }).map((_, outerIndex) => ( - + - {Array.from({ length: CRYPTOPUNK_COLUMNS_NR }).map((_, innerIndex) => ( - - - - ))} - - ))} - - ) -} - -const RightPanel = ({ - scrollYProgress, - children, - isMobile, -}: { - scrollYProgress: MotionValue - children: ReactNode - isMobile: boolean -}) => { - const opacityParams = isMobile ? [0.4, 0.45, 0.65, 0.66] : [0.25, 0.35, 0.65, 0.7] - const translateParams = isMobile ? [0.4, 0.45, 0.65, 0.7] : [0.25, 0.35, 0.65, 0.75] - const opacity = useTransform(scrollYProgress, opacityParams, [0, 1, 1, 0]) - const bgTranslate = useTransform(scrollYProgress, translateParams, ['100%', '0%', '0%', '100%']) - - return ( -
- - {children} - - + + +
) } diff --git a/src/components/DataRoom/CryptoPunks/styles.module.css b/src/components/DataRoom/CryptoPunks/styles.module.css index d8fc804a..61dbb322 100644 --- a/src/components/DataRoom/CryptoPunks/styles.module.css +++ b/src/components/DataRoom/CryptoPunks/styles.module.css @@ -11,19 +11,7 @@ display: flex; } -.rightPanelContainer { - width: 50%; - height: 100%; - position: absolute; - right: 0; - color: var(--mui-palette-text-dark); - overflow-x: hidden; - display: flex; - align-items: flex-end; - padding-top: 64px; -} - -.rightPanelContent { +.slidingPanelContent { z-index: 20; display: flex; flex-direction: column; @@ -33,14 +21,6 @@ gap: 30px; } -.rightPanelBG { - position: absolute; - inset: 0; - background-color: var(--mui-palette-primary-main); - z-index: 10; - margin-top: 72px; -} - .percentage { font-size: 120px; line-height: 120px; @@ -65,7 +45,7 @@ align-items: baseline; } -.leftPanelContainer { +.punksGrid { width: 100%; height: 100%; overflow: hidden; @@ -94,39 +74,25 @@ flex-direction: column-reverse; } - .leftPanelContainer { + .punksGrid { padding-top: 72px; width: 100%; height: 100%; + overflow: hidden; } - .rightPanelContainer { - width: 100%; - height: 100%; - position: absolute; - bottom: 0; - } - - .rightPanelContent { + .slidingPanelContent { justify-content: end; padding: 30px; gap: 30px; } - .rightPanelBG { - margin-top: 64px; - } - .statsContainer { display: flex; flex-direction: column; gap: 30px; } - .leftPanelContainer { - overflow: hidden; - } - .cryptopunk { width: 60px; } diff --git a/src/components/DataRoom/IndustryComparison/Content.tsx b/src/components/DataRoom/IndustryComparison/Content.tsx new file mode 100644 index 00000000..702cc5f3 --- /dev/null +++ b/src/components/DataRoom/IndustryComparison/Content.tsx @@ -0,0 +1,38 @@ +import type { BaseBlock } from '@/components/Home/types' +import { Typography } from '@mui/material' +import type { MotionValue } from 'framer-motion' +import { motion, useTransform } from 'framer-motion' +import DotGrid from './DotGrid' +import css from './styles.module.css' +import { useIsMediumScreen } from '@/hooks/useMaxWidth' +import type { RefObject } from 'react' + +const Content = ({ + scrollYProgress, + title, + containerRef, +}: { + title: BaseBlock['title'] + scrollYProgress: MotionValue + containerRef: RefObject +}) => { + const isMobile = useIsMediumScreen() + + const scrollParams = [0.25, 0.35, 0.65, 0.75] + const opacityParams = [0, 1, 1, 0] + const opacity = useTransform(scrollYProgress, scrollParams, opacityParams) + + return ( + + {title} + + + ) +} + +export default Content diff --git a/src/components/DataRoom/IndustryComparison/DotGrid.tsx b/src/components/DataRoom/IndustryComparison/DotGrid.tsx new file mode 100644 index 00000000..0f6b3a6e --- /dev/null +++ b/src/components/DataRoom/IndustryComparison/DotGrid.tsx @@ -0,0 +1,47 @@ +import { useEffect, useRef, useCallback } from 'react' +import { createDots } from './utils/createDots' +import { drawDots } from './utils/drawDots' +import { useIsMediumScreen } from '@/hooks/useMaxWidth' +import css from './styles.module.css' +import useContainerSize from '@/hooks/useContainerSize' +import { updateCanvas } from './utils/updateCanvas' + +export default function DotGrid({ containerRef }: { containerRef: React.RefObject }) { + const canvasRef = useRef(null) + const isMobile = useIsMediumScreen() + const dimensions = useContainerSize(containerRef) + + const prevRenderStateRef = useRef({ dimensions }) + const dotsRef = useRef | null>(null) + const animationFrameId = useRef() + + const renderFrame = useCallback(() => { + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + if (!canvas || !ctx || dimensions.width <= 0 || dimensions.height <= 0) return + + const currentRenderState = { dimensions } + const prevRenderState = prevRenderStateRef.current + + if (!dotsRef.current || currentRenderState.dimensions !== prevRenderState.dimensions) { + dotsRef.current = createDots(dimensions, isMobile) + updateCanvas(canvas, ctx, dimensions.width, dimensions.height) + // Draw dots immediately after creating or updating them + // This draw call ensure dots are already visible when canvas scrolls into view + drawDots(ctx, dotsRef.current) + } + + prevRenderStateRef.current = currentRenderState + + animationFrameId.current = requestAnimationFrame(renderFrame) + }, [dimensions, isMobile]) + + useEffect(() => { + renderFrame() + return () => { + if (animationFrameId.current) cancelAnimationFrame(animationFrameId.current) + } + }, [renderFrame]) + + return +} diff --git a/src/components/DataRoom/IndustryComparison/index.tsx b/src/components/DataRoom/IndustryComparison/index.tsx new file mode 100644 index 00000000..625b014f --- /dev/null +++ b/src/components/DataRoom/IndustryComparison/index.tsx @@ -0,0 +1,36 @@ +import type { BaseBlock } from '@/components/Home/types' +import { useScroll } from 'framer-motion' +import dynamic from 'next/dynamic' +import React, { useRef } from 'react' +import css from './styles.module.css' +import { useIsMediumScreen } from '@/hooks/useMaxWidth' + +const Content = dynamic(() => import('./Content')) +const SlidingPanel = dynamic(() => import('@/components/common/SlidingPanel')) + +const IndustryComparison = ({ title }: BaseBlock) => { + const backgroundRef = useRef(null) + const gridContainerRef = useRef(null) + const isMobile = useIsMediumScreen() + + const { scrollYProgress } = useScroll({ + target: backgroundRef, + offset: ['start end', 'end start'], + }) + + return ( +
+
+ + + +
+
+ ) +} + +export default IndustryComparison diff --git a/src/components/DataRoom/IndustryComparison/styles.module.css b/src/components/DataRoom/IndustryComparison/styles.module.css new file mode 100644 index 00000000..12378e99 --- /dev/null +++ b/src/components/DataRoom/IndustryComparison/styles.module.css @@ -0,0 +1,43 @@ +.sectionContainer { + height: 300vh; + display: flex; +} + +.stickyContainer { + position: sticky; + top: 0; + width: 100%; + height: 100dvh; + display: flex; +} + +.slidingPanelContent { + z-index: 20; + display: flex; + justify-items: center; + align-items: center; + width: 100%; + padding: 64px; + height: 100%; + gap: 30px; +} + +.canvasStyles { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + margin-top: 72px; +} + +@media (max-width: 900px) { + .slidingPanelContent { + padding-left: 32px; + padding-right: 108px; + } + + .canvasStyles { + margin-top: 64px; + } +} diff --git a/src/components/DataRoom/IndustryComparison/utils/createDots.ts b/src/components/DataRoom/IndustryComparison/utils/createDots.ts new file mode 100644 index 00000000..70facd02 --- /dev/null +++ b/src/components/DataRoom/IndustryComparison/utils/createDots.ts @@ -0,0 +1,23 @@ +const ROWS = 30 +const MOBILE_COLS = 15 +const DESKTOP_COLS = 60 + +/** + * Creates an array of dot coordinates based on given dimensions and device type. + * @param dimensions - The width and height of the container. + * @param isMobile - Boolean indicating if the device is mobile. + * @returns An array of objects containing x and y coordinates for each dot. + */ +export const createDots = (dimensions: { width: number; height: number }, isMobile: boolean) => { + const cols = isMobile ? MOBILE_COLS : DESKTOP_COLS + const newDots = [] + for (let row = 0; row < ROWS; row++) { + for (let col = 0; col < cols; col++) { + const x = (dimensions.width / (cols - 1)) * col + dimensions.width / cols / 2 + const y = (dimensions.height / (ROWS - 1)) * row + dimensions.height / ROWS / 2 + newDots.push({ x, y }) + } + } + + return newDots +} diff --git a/src/components/DataRoom/IndustryComparison/utils/drawDots.ts b/src/components/DataRoom/IndustryComparison/utils/drawDots.ts new file mode 100644 index 00000000..14de42e5 --- /dev/null +++ b/src/components/DataRoom/IndustryComparison/utils/drawDots.ts @@ -0,0 +1,12 @@ +type Position = { x: number; y: number } + +const DOT_COLOR = '#121312' + +export const drawDots = (ctx: CanvasRenderingContext2D, dots: Position[]) => { + ctx.fillStyle = DOT_COLOR + dots.forEach((dot) => { + ctx.beginPath() + ctx.arc(dot.x, dot.y, 1, 0, 2 * Math.PI) + ctx.fill() + }) +} diff --git a/src/components/DataRoom/IndustryComparison/utils/updateCanvas.ts b/src/components/DataRoom/IndustryComparison/utils/updateCanvas.ts new file mode 100644 index 00000000..38ac16b9 --- /dev/null +++ b/src/components/DataRoom/IndustryComparison/utils/updateCanvas.ts @@ -0,0 +1,15 @@ +/** + * Updates the canvas size and scale to account for device pixel ratio. + * @param canvas - The HTML canvas element to update + * @param ctx - The 2D rendering context of the canvas + * @param width - The desired width of the canvas in CSS pixels + * @param height - The desired height of the canvas in CSS pixels + */ +export function updateCanvas(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D, width: number, height: number) { + const dpr = window.devicePixelRatio || 1 + canvas.width = width * dpr + canvas.height = height * dpr + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + ctx.scale(dpr, dpr) +} diff --git a/src/components/common/SlidingPanel/index.tsx b/src/components/common/SlidingPanel/index.tsx new file mode 100644 index 00000000..ba5ff04b --- /dev/null +++ b/src/components/common/SlidingPanel/index.tsx @@ -0,0 +1,39 @@ +import type { MotionValue } from 'framer-motion' +import { motion, useTransform } from 'framer-motion' +import type { ReactNode } from 'react' +import css from './styles.module.css' + +const SlidingPanel = ({ + scrollYProgress, + children, + scrollParams, + translateParams, + panelWidth = '100%', +}: { + scrollYProgress: MotionValue + children: ReactNode + scrollParams: number[] + translateParams: string[] + panelWidth?: string +}) => { + const bgTranslate = useTransform(scrollYProgress, scrollParams, translateParams) + + return ( +
+ {children} + +
+ ) +} + +export default SlidingPanel diff --git a/src/components/common/SlidingPanel/styles.module.css b/src/components/common/SlidingPanel/styles.module.css new file mode 100644 index 00000000..e5131c6d --- /dev/null +++ b/src/components/common/SlidingPanel/styles.module.css @@ -0,0 +1,31 @@ +.slidingPanelContainer { + height: 100%; + position: absolute; + right: 0; + color: var(--mui-palette-text-dark); + overflow: hidden; + display: flex; + align-items: flex-end; + padding-top: 64px; +} + +.slidingPanelBG { + position: absolute; + inset: 0; + background-color: var(--mui-palette-primary-main); + z-index: 10; + margin-top: 72px; +} + +@media (max-width: 900px) { + .slidingPanelContainer { + width: 100%; + height: 100%; + position: absolute; + bottom: 0; + } + + .slidingPanelBG { + margin-top: 0px; + } +} diff --git a/src/content/dataroom.json b/src/content/dataroom.json index 4c56d1f4..7c13f4a6 100644 --- a/src/content/dataroom.json +++ b/src/content/dataroom.json @@ -59,6 +59,10 @@ "href": "https://dune.com/queries/3737066" } }, + { + "component": "DataRoom/IndustryComparison", + "title": "How we compare
to others industry leaders" + }, { "component": "DataRoom/AssetsSecured", "caption": "CEX", diff --git a/src/hooks/useContainerSize.ts b/src/hooks/useContainerSize.ts new file mode 100644 index 00000000..c7efe298 --- /dev/null +++ b/src/hooks/useContainerSize.ts @@ -0,0 +1,23 @@ +import { useState, useEffect, type RefObject } from 'react' + +function useContainerSize(ref: RefObject): { width: number; height: number } { + const [size, setSize] = useState({ width: 0, height: 0 }) + + useEffect(() => { + if (!ref.current) return + + const updateSize = (entries: ResizeObserverEntry[]) => { + const { width, height } = entries[0].contentRect + setSize({ width, height }) + } + + const resizeObserver = new ResizeObserver(updateSize) + resizeObserver.observe(ref.current) + + return () => resizeObserver.disconnect() + }, [ref]) + + return size +} + +export default useContainerSize