|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<rect width="16" height="16" rx="2" fill="#000" />
|
||||
<path
|
||||
d="M2.147 9.383c-.027-.114.109-.186.192-.103L6.72 13.66c.083.083.011.219-.103.192a6.015 6.015 0 0 1-4.47-4.47ZM2 7.627a.119.119 0 0 0 .035.091l6.247 6.247a.119.119 0 0 0 .091.035c.285-.018.564-.055.836-.111a.117.117 0 0 0 .057-.198L2.31 6.734a.117.117 0 0 0-.198.057 6.007 6.007 0 0 0-.11.836ZM2.505 5.565a.119.119 0 0 0 .025.132l7.773 7.773a.118.118 0 0 0 .132.025c.215-.096.422-.203.623-.322a.118.118 0 0 0 .022-.185L3.012 4.92a.118.118 0 0 0-.185.022c-.119.2-.226.408-.322.623ZM3.519 4.169a.118.118 0 0 1-.005-.163 6.006 6.006 0 1 1 8.48 8.48.118.118 0 0 1-.163-.005L3.52 4.169Z"
|
||||
fill="#FFF"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 760 B |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
>
|
||||
<rect width="40" height="40" rx="8" fill="#000000" />
|
||||
<path
|
||||
d="M6.822 14.174c0-2.435 1.996-4.411 4.456-4.411h8.574c.125 0 .241.075.293.19a.31.31 0 0 1-.056.344l-1.88 2.023c-.326.35-.787.552-1.27.552H11.3c-.738 0-1.338.594-1.338 1.325v3.336a.78.78 0 0 1-.783.777H7.61a.78.78 0 0 1-.783-.777v-3.36zM33.5 25.553c0 2.434-1.996 4.411-4.456 4.411h-3.642c-2.46 0-4.454-1.977-4.454-4.411v-6.315c0-.43.16-.842.456-1.16l2.124-2.285a.33.33 0 0 1 .355-.081.32.32 0 0 1 .205.295v9.527c0 .73.598 1.322 1.337 1.322h3.6a1.33 1.33 0 0 0 1.337-1.322V14.197c0-.73-.599-1.325-1.337-1.325H24.84c-.481 0-.938.201-1.265.547L11.088 26.856h7.503a.78.78 0 0 1 .784.778v1.552a.78.78 0 0 1-.784.778H8.481a1.655 1.655 0 0 1-1.662-1.644v-.824c0-.412.156-.809.44-1.114l13.999-15.06a4.9 4.9 0 0 1 3.594-1.56h4.189c2.46 0 4.454 1.977 4.454 4.412v11.379z"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 968 B |
@@ -18,6 +18,8 @@ export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
|
||||
body: {
|
||||
text: "Twenty makes it simple. It's clean, intuitive, and built to feel like Notion.",
|
||||
},
|
||||
backgroundImageSrc:
|
||||
'/images/home/three-cards-feature/familiar-interface-gradient.webp',
|
||||
icon: 'users-group',
|
||||
illustration: 'familiar-interface',
|
||||
},
|
||||
@@ -26,6 +28,8 @@ export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
|
||||
body: {
|
||||
text: 'Everything updates in real time, with AI chat always ready to help you move faster.',
|
||||
},
|
||||
backgroundImageSrc:
|
||||
'/images/home/three-cards-feature/live-data-gradient.webp',
|
||||
icon: 'live-data',
|
||||
illustration: 'live-data',
|
||||
},
|
||||
@@ -34,6 +38,8 @@ export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
|
||||
body: {
|
||||
text: 'Smart patterns, shortcuts, and layouts make everyday tasks faster and easier to execute.',
|
||||
},
|
||||
backgroundImageSrc:
|
||||
'/images/home/three-cards-feature/fast-path-gradient.webp',
|
||||
icon: 'fast-path',
|
||||
illustration: 'fast-path',
|
||||
},
|
||||
|
||||
@@ -265,8 +265,9 @@ const IMAGE_HOVER_FADE_IN = 18;
|
||||
const IMAGE_HOVER_FADE_OUT = 7;
|
||||
const MAX_PREVIEW_PIXEL_RATIO = 2;
|
||||
|
||||
const CanvasMount = styled.div<{ $background: string }>`
|
||||
background: ${(props) => props.$background};
|
||||
const CanvasMount = styled.div<{ $background: string; $transparent: boolean }>`
|
||||
background: ${(props) =>
|
||||
props.$transparent ? 'transparent' : props.$background};
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
@@ -1618,10 +1619,10 @@ export function HalftoneCanvas({
|
||||
-interaction.pointerVelocityY * logicalHeight,
|
||||
);
|
||||
halftoneMaterial.uniforms.dragOffset.value.set(0, 0);
|
||||
halftoneMaterial.uniforms.hoverHalftoneActive.value =
|
||||
activeSettings.animation.hoverHalftoneEnabled
|
||||
? interaction.hoverStrength
|
||||
: 0;
|
||||
halftoneMaterial.uniforms.hoverHalftoneActive.value = activeSettings
|
||||
.animation.hoverHalftoneEnabled
|
||||
? interaction.hoverStrength
|
||||
: 0;
|
||||
halftoneMaterial.uniforms.hoverHalftonePowerShift.value =
|
||||
activeSettings.animation.hoverHalftoneEnabled
|
||||
? activeSettings.animation.hoverHalftonePowerShift
|
||||
@@ -1632,11 +1633,11 @@ export function HalftoneCanvas({
|
||||
activeSettings.animation.hoverHalftoneEnabled
|
||||
? activeSettings.animation.hoverHalftoneWidthShift
|
||||
: 0;
|
||||
halftoneMaterial.uniforms.hoverLightStrength.value =
|
||||
activeSettings.animation.hoverLightEnabled
|
||||
? activeSettings.animation.hoverLightIntensity *
|
||||
interaction.hoverStrength
|
||||
: 0;
|
||||
halftoneMaterial.uniforms.hoverLightStrength.value = activeSettings
|
||||
.animation.hoverLightEnabled
|
||||
? activeSettings.animation.hoverLightIntensity *
|
||||
interaction.hoverStrength
|
||||
: 0;
|
||||
halftoneMaterial.uniforms.hoverLightRadius.value =
|
||||
activeSettings.animation.hoverLightRadius;
|
||||
halftoneMaterial.uniforms.hoverFlowStrength.value = 0;
|
||||
@@ -2031,6 +2032,7 @@ export function HalftoneCanvas({
|
||||
return (
|
||||
<CanvasMount
|
||||
$background={settings.background.color}
|
||||
$transparent={settings.background.transparent}
|
||||
aria-hidden
|
||||
ref={mountReference}
|
||||
/>
|
||||
|
||||
@@ -225,12 +225,12 @@ export const SALESFORCE_DATA: SalesforceDataType = {
|
||||
productIconAlt: 'Retro help document icon',
|
||||
productIconSrc: '/images/pricing/salesforce/help-icon.webp',
|
||||
priceSuffix: ' / seat / month - billed yearly',
|
||||
productTitle: 'Salesforce Pro',
|
||||
productTitle: 'Salesfarce Pro',
|
||||
secondaryCtaNote: 'More options available!',
|
||||
secondaryCtaHref:
|
||||
'https://www.salesforce.com/en-us/wp-content/uploads/sites/4/documents/pricing/all-add-ons.pdf',
|
||||
secondaryCtaLabel: 'Check more add-ons',
|
||||
totalPriceLabel: 'total per month with fixed cost',
|
||||
windowTitle: 'Salesforce Pro pricing',
|
||||
windowTitle: 'Salesfarce Add-on Center',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { HalftoneCanvas } from '@/app/halftone/_components/HalftoneCanvas';
|
||||
import {
|
||||
DEFAULT_GLASS_ANIMATION_SETTINGS,
|
||||
DEFAULT_GLASS_BACKGROUND_SETTINGS,
|
||||
DEFAULT_GLASS_MATERIAL_SETTINGS,
|
||||
DEFAULT_SHAPE_HALFTONE_SETTINGS,
|
||||
DEFAULT_SOLID_ANIMATION_SETTINGS,
|
||||
DEFAULT_SOLID_BACKGROUND_SETTINGS,
|
||||
DEFAULT_SOLID_MATERIAL_SETTINGS,
|
||||
type HalftoneMaterialSurface,
|
||||
type HalftoneStudioSettings,
|
||||
} from '@/app/halftone/_lib/state';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import * as THREE from 'three';
|
||||
@@ -17,6 +29,7 @@ const MIN_FOOTPRINT_SCALE = 0.001;
|
||||
|
||||
type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
|
||||
type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
|
||||
type HelpedHalftoneRenderer = 'legacy' | 'studio';
|
||||
|
||||
type ViewRect = {
|
||||
x: number;
|
||||
@@ -47,13 +60,20 @@ export type HelpedHalftoneSettings = {
|
||||
material: {
|
||||
roughness: number;
|
||||
metalness: number;
|
||||
surface?: HalftoneMaterialSurface;
|
||||
color?: string;
|
||||
thickness?: number;
|
||||
refraction?: number;
|
||||
environmentPower?: number;
|
||||
};
|
||||
halftone: {
|
||||
enabled: boolean;
|
||||
scale: number;
|
||||
power: number;
|
||||
width: number;
|
||||
imageContrast?: number;
|
||||
dashColor: string;
|
||||
hoverDashColor?: string;
|
||||
};
|
||||
animation: {
|
||||
autoRotateEnabled: boolean;
|
||||
@@ -91,6 +111,10 @@ export type HelpedHalftoneSettings = {
|
||||
springDamping: number;
|
||||
springReturnEnabled: boolean;
|
||||
springStrength: number;
|
||||
hoverHalftoneEnabled?: boolean;
|
||||
hoverHalftonePowerShift?: number;
|
||||
hoverHalftoneRadius?: number;
|
||||
hoverHalftoneWidthShift?: number;
|
||||
hoverLightIntensity: number;
|
||||
hoverLightRadius: number;
|
||||
dragFlowDecay: number;
|
||||
@@ -533,10 +557,7 @@ function getFootprintScaleFromRects(
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
Math.sqrt(currentArea / referenceArea),
|
||||
MIN_FOOTPRINT_SCALE,
|
||||
);
|
||||
return Math.max(Math.sqrt(currentArea / referenceArea), MIN_FOOTPRINT_SCALE);
|
||||
}
|
||||
|
||||
function projectBox3ToViewport({
|
||||
@@ -552,11 +573,7 @@ function projectBox3ToViewport({
|
||||
viewportHeight: number;
|
||||
viewportWidth: number;
|
||||
}) {
|
||||
if (
|
||||
localBounds.isEmpty() ||
|
||||
viewportWidth <= 0 ||
|
||||
viewportHeight <= 0
|
||||
) {
|
||||
if (localBounds.isEmpty() || viewportWidth <= 0 || viewportHeight <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -728,6 +745,57 @@ const StyledVisualMount = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const NOOP = () => {};
|
||||
|
||||
function getStudioMaterialSurface(
|
||||
settings: HelpedHalftoneSettings,
|
||||
): HalftoneMaterialSurface {
|
||||
return settings.material.surface === 'glass' ? 'glass' : 'solid';
|
||||
}
|
||||
|
||||
function createStudioSettings(
|
||||
settings: HelpedHalftoneSettings,
|
||||
): HalftoneStudioSettings {
|
||||
const surface = getStudioMaterialSurface(settings);
|
||||
const defaultMaterial =
|
||||
surface === 'glass'
|
||||
? DEFAULT_GLASS_MATERIAL_SETTINGS
|
||||
: DEFAULT_SOLID_MATERIAL_SETTINGS;
|
||||
const defaultAnimation =
|
||||
surface === 'glass'
|
||||
? DEFAULT_GLASS_ANIMATION_SETTINGS
|
||||
: DEFAULT_SOLID_ANIMATION_SETTINGS;
|
||||
const defaultBackground =
|
||||
surface === 'glass'
|
||||
? DEFAULT_GLASS_BACKGROUND_SETTINGS
|
||||
: DEFAULT_SOLID_BACKGROUND_SETTINGS;
|
||||
|
||||
return {
|
||||
sourceMode: 'shape',
|
||||
shapeKey: 'helped',
|
||||
lighting: { ...settings.lighting },
|
||||
material: {
|
||||
...defaultMaterial,
|
||||
...settings.material,
|
||||
surface,
|
||||
},
|
||||
halftone: {
|
||||
...DEFAULT_SHAPE_HALFTONE_SETTINGS,
|
||||
...settings.halftone,
|
||||
hoverDashColor:
|
||||
settings.halftone.hoverDashColor ?? settings.halftone.dashColor,
|
||||
imageContrast:
|
||||
settings.halftone.imageContrast ??
|
||||
DEFAULT_SHAPE_HALFTONE_SETTINGS.imageContrast,
|
||||
},
|
||||
background: defaultBackground,
|
||||
animation: {
|
||||
...defaultAnimation,
|
||||
...settings.animation,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type HelpedHalftoneCanvasProps = {
|
||||
geometry: THREE.BufferGeometry;
|
||||
initialPose: HelpedHalftonePose;
|
||||
@@ -770,7 +838,9 @@ function HelpedHalftoneCanvas({
|
||||
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
|
||||
|
||||
const canvas = renderer.domElement;
|
||||
canvas.style.cursor = settings.animation.followDragEnabled ? 'grab' : 'default';
|
||||
canvas.style.cursor = settings.animation.followDragEnabled
|
||||
? 'grab'
|
||||
: 'default';
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.touchAction = 'none';
|
||||
@@ -855,7 +925,9 @@ function HelpedHalftoneCanvas({
|
||||
dashColor: { value: new THREE.Color(settings.halftone.dashColor) },
|
||||
time: { value: 0 },
|
||||
waveAmount: {
|
||||
value: settings.animation.waveEnabled ? settings.animation.waveAmount : 0,
|
||||
value: settings.animation.waveEnabled
|
||||
? settings.animation.waveAmount
|
||||
: 0,
|
||||
},
|
||||
waveSpeed: { value: settings.animation.waveSpeed },
|
||||
footprintScale: { value: 1.0 },
|
||||
@@ -1073,8 +1145,7 @@ function HelpedHalftoneCanvas({
|
||||
const floatPhase = elapsedTime * settings.animation.floatSpeed;
|
||||
const driftAmount = (settings.animation.driftAmount * Math.PI) / 180;
|
||||
|
||||
meshOffsetY +=
|
||||
Math.sin(floatPhase) * settings.animation.floatAmplitude;
|
||||
meshOffsetY += Math.sin(floatPhase) * settings.animation.floatAmplitude;
|
||||
baseRotationX += Math.sin(floatPhase * 0.72) * driftAmount * 0.45;
|
||||
baseRotationZ += Math.cos(floatPhase * 0.93) * driftAmount * 0.3;
|
||||
}
|
||||
@@ -1089,13 +1160,15 @@ function HelpedHalftoneCanvas({
|
||||
if (rotateEnabled) {
|
||||
interaction.rotateElapsed += delta;
|
||||
const rotateProgress = settings.animation.rotatePingPong
|
||||
? Math.sin(interaction.rotateElapsed * settings.animation.rotateSpeed) *
|
||||
Math.PI
|
||||
? Math.sin(
|
||||
interaction.rotateElapsed * settings.animation.rotateSpeed,
|
||||
) * Math.PI
|
||||
: interaction.rotateElapsed * settings.animation.rotateSpeed;
|
||||
|
||||
if (settings.animation.rotatePreset === 'axis') {
|
||||
const axisDirection =
|
||||
settings.animation.rotateAxis.startsWith('-') ? -1 : 1;
|
||||
const axisDirection = settings.animation.rotateAxis.startsWith('-')
|
||||
? -1
|
||||
: 1;
|
||||
const axisProgress = rotateProgress * axisDirection;
|
||||
|
||||
if (
|
||||
@@ -1256,8 +1329,7 @@ function HelpedHalftoneCanvas({
|
||||
const orbitPitch = centeredY * cameraRange * 0.7;
|
||||
const horizontalRadius = Math.cos(orbitPitch) * baseCameraDistance;
|
||||
const targetCameraX = Math.sin(orbitYaw) * horizontalRadius;
|
||||
const targetCameraY =
|
||||
Math.sin(orbitPitch) * baseCameraDistance * 0.85;
|
||||
const targetCameraY = Math.sin(orbitPitch) * baseCameraDistance * 0.85;
|
||||
const targetCameraZ = Math.cos(orbitYaw) * horizontalRadius;
|
||||
|
||||
camera.position.x += (targetCameraX - camera.position.x) * cameraEase;
|
||||
@@ -1266,8 +1338,7 @@ function HelpedHalftoneCanvas({
|
||||
} else {
|
||||
camera.position.x += (0 - camera.position.x) * 0.12;
|
||||
camera.position.y += (0 - camera.position.y) * 0.12;
|
||||
camera.position.z +=
|
||||
(baseCameraDistance - camera.position.z) * 0.12;
|
||||
camera.position.z += (baseCameraDistance - camera.position.z) * 0.12;
|
||||
}
|
||||
|
||||
lookAtTarget.set(0, meshOffsetY * 0.2, 0);
|
||||
@@ -1329,11 +1400,37 @@ function HelpedHalftoneCanvas({
|
||||
return <StyledVisualMount aria-hidden ref={mountReference} />;
|
||||
}
|
||||
|
||||
function StudioHelpedHalftoneCanvas({
|
||||
geometry,
|
||||
initialPose,
|
||||
previewDistance,
|
||||
settings,
|
||||
}: HelpedHalftoneCanvasProps) {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
geometry.dispose();
|
||||
};
|
||||
}, [geometry]);
|
||||
|
||||
return (
|
||||
<HalftoneCanvas
|
||||
geometry={geometry}
|
||||
imageElement={null}
|
||||
initialPose={initialPose}
|
||||
onFirstInteraction={NOOP}
|
||||
onPoseChange={NOOP}
|
||||
previewDistance={previewDistance}
|
||||
settings={createStudioSettings(settings)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type HelpedHalftoneModelProps = {
|
||||
initialPose: HelpedHalftonePose;
|
||||
label: string;
|
||||
modelUrl: string;
|
||||
previewDistance: number;
|
||||
renderer?: HelpedHalftoneRenderer;
|
||||
settings: HelpedHalftoneSettings;
|
||||
};
|
||||
|
||||
@@ -1342,6 +1439,7 @@ export function HelpedHalftoneModel({
|
||||
label,
|
||||
modelUrl,
|
||||
previewDistance,
|
||||
renderer = 'legacy',
|
||||
settings,
|
||||
}: HelpedHalftoneModelProps) {
|
||||
const [geometry, setGeometry] = useState<THREE.BufferGeometry | null>(null);
|
||||
@@ -1371,6 +1469,17 @@ export function HelpedHalftoneModel({
|
||||
return <StyledVisualMount aria-hidden />;
|
||||
}
|
||||
|
||||
if (renderer === 'studio') {
|
||||
return (
|
||||
<StudioHelpedHalftoneCanvas
|
||||
geometry={geometry}
|
||||
initialPose={initialPose}
|
||||
previewDistance={previewDistance}
|
||||
settings={settings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HelpedHalftoneCanvas
|
||||
geometry={geometry}
|
||||
|
||||
@@ -11,26 +11,33 @@ const MONEY_PREVIEW_DISTANCE = 5;
|
||||
|
||||
const MONEY_SETTINGS: HelpedHalftoneSettings = {
|
||||
lighting: {
|
||||
intensity: 1,
|
||||
intensity: 3,
|
||||
fillIntensity: 0,
|
||||
ambientIntensity: 0,
|
||||
angleDegrees: 103,
|
||||
height: -3.6,
|
||||
angleDegrees: 47,
|
||||
height: 1.4,
|
||||
},
|
||||
material: {
|
||||
roughness: 0.32,
|
||||
metalness: 0.98,
|
||||
surface: 'glass',
|
||||
color: '#7d7d7d',
|
||||
roughness: 0.26,
|
||||
metalness: 0.15,
|
||||
thickness: 15.58,
|
||||
refraction: 3,
|
||||
environmentPower: 5,
|
||||
},
|
||||
halftone: {
|
||||
enabled: true,
|
||||
scale: 14.97,
|
||||
power: -0.25,
|
||||
width: 1.4,
|
||||
dashColor: '#FDFE98',
|
||||
scale: 25.64,
|
||||
power: -1.24,
|
||||
width: 0.5,
|
||||
imageContrast: 1,
|
||||
dashColor: '#FEFFB7',
|
||||
hoverDashColor: '#4A38F5',
|
||||
},
|
||||
animation: {
|
||||
autoRotateEnabled: false,
|
||||
breatheEnabled: false,
|
||||
breatheEnabled: true,
|
||||
cameraParallaxEnabled: false,
|
||||
followHoverEnabled: false,
|
||||
followDragEnabled: true,
|
||||
@@ -39,31 +46,35 @@ const MONEY_SETTINGS: HelpedHalftoneSettings = {
|
||||
dragFlowEnabled: false,
|
||||
lightSweepEnabled: false,
|
||||
rotateEnabled: false,
|
||||
autoSpeed: 0.1,
|
||||
autoWobble: 0,
|
||||
breatheAmount: 0.04,
|
||||
breatheSpeed: 0.8,
|
||||
autoSpeed: 0.15,
|
||||
autoWobble: 0.3,
|
||||
breatheAmount: 0.015,
|
||||
breatheSpeed: 0.25,
|
||||
cameraParallaxAmount: 0.3,
|
||||
cameraParallaxEase: 0.08,
|
||||
driftAmount: 8,
|
||||
hoverRange: 24,
|
||||
hoverEase: 0.16,
|
||||
hoverRange: 25,
|
||||
hoverEase: 0.08,
|
||||
hoverReturn: true,
|
||||
dragSens: 0.008,
|
||||
dragFriction: 0.08,
|
||||
dragMomentum: true,
|
||||
rotateAxis: '-xy',
|
||||
rotateAxis: 'y',
|
||||
rotatePreset: 'axis',
|
||||
rotateSpeed: 0.1,
|
||||
rotatePingPong: false,
|
||||
floatAmplitude: 0.16,
|
||||
floatSpeed: 0.8,
|
||||
lightSweepHeightRange: 0.5,
|
||||
lightSweepHeightRange: 0,
|
||||
lightSweepRange: 28,
|
||||
lightSweepSpeed: 0.2,
|
||||
springDamping: 0.4,
|
||||
lightSweepSpeed: 0.25,
|
||||
springDamping: 0.5,
|
||||
springReturnEnabled: true,
|
||||
springStrength: 0.06,
|
||||
springStrength: 0.1,
|
||||
hoverHalftoneEnabled: false,
|
||||
hoverHalftonePowerShift: 0.42,
|
||||
hoverHalftoneRadius: 0.2,
|
||||
hoverHalftoneWidthShift: -0.18,
|
||||
hoverLightIntensity: 0.8,
|
||||
hoverLightRadius: 0.2,
|
||||
dragFlowDecay: 0.08,
|
||||
@@ -79,14 +90,14 @@ const MONEY_SETTINGS: HelpedHalftoneSettings = {
|
||||
};
|
||||
|
||||
const MONEY_INITIAL_POSE: HelpedHalftonePose = {
|
||||
autoElapsed: 0.05,
|
||||
autoElapsed: 9.604400000000004,
|
||||
rotateElapsed: 0,
|
||||
rotationX: 2.0543643190124016e-89,
|
||||
rotationY: 1.3695919155712744e-87,
|
||||
rotationZ: 0,
|
||||
rotationX: 9.425994949837918e-229,
|
||||
rotationY: 2.798289108321203e-229,
|
||||
rotationZ: -2.301463116952276e-231,
|
||||
targetRotationX: 0,
|
||||
targetRotationY: 0,
|
||||
timeElapsed: 482.5497999999506,
|
||||
timeElapsed: 303.7692999998331,
|
||||
};
|
||||
|
||||
export function Money() {
|
||||
@@ -96,6 +107,7 @@ export function Money() {
|
||||
label="money.glb"
|
||||
modelUrl={GLB_URL}
|
||||
previewDistance={MONEY_PREVIEW_DISTANCE}
|
||||
renderer="studio"
|
||||
settings={MONEY_SETTINGS}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -11,26 +11,33 @@ const SPACESHIP_PREVIEW_DISTANCE = 3.5;
|
||||
|
||||
const SPACESHIP_SETTINGS: HelpedHalftoneSettings = {
|
||||
lighting: {
|
||||
intensity: 1,
|
||||
intensity: 3,
|
||||
fillIntensity: 0,
|
||||
ambientIntensity: 0,
|
||||
angleDegrees: 103,
|
||||
height: -3.6,
|
||||
angleDegrees: 47,
|
||||
height: 1.4,
|
||||
},
|
||||
material: {
|
||||
roughness: 0.32,
|
||||
metalness: 0.98,
|
||||
surface: 'glass',
|
||||
color: '#7d7d7d',
|
||||
roughness: 0.26,
|
||||
metalness: 0.15,
|
||||
thickness: 15.58,
|
||||
refraction: 3,
|
||||
environmentPower: 5,
|
||||
},
|
||||
halftone: {
|
||||
enabled: true,
|
||||
scale: 14.97,
|
||||
power: -0.25,
|
||||
width: 1.4,
|
||||
scale: 16.64,
|
||||
power: -1.24,
|
||||
width: 0.5,
|
||||
imageContrast: 1,
|
||||
dashColor: '#89FC9A',
|
||||
hoverDashColor: '#89FC9A',
|
||||
},
|
||||
animation: {
|
||||
autoRotateEnabled: false,
|
||||
breatheEnabled: false,
|
||||
breatheEnabled: true,
|
||||
cameraParallaxEnabled: false,
|
||||
followHoverEnabled: false,
|
||||
followDragEnabled: true,
|
||||
@@ -39,31 +46,35 @@ const SPACESHIP_SETTINGS: HelpedHalftoneSettings = {
|
||||
dragFlowEnabled: false,
|
||||
lightSweepEnabled: false,
|
||||
rotateEnabled: false,
|
||||
autoSpeed: 0.1,
|
||||
autoWobble: 0,
|
||||
breatheAmount: 0.04,
|
||||
breatheSpeed: 0.8,
|
||||
autoSpeed: 0.15,
|
||||
autoWobble: 0.3,
|
||||
breatheAmount: 0.015,
|
||||
breatheSpeed: 0.25,
|
||||
cameraParallaxAmount: 0.3,
|
||||
cameraParallaxEase: 0.08,
|
||||
driftAmount: 8,
|
||||
hoverRange: 24,
|
||||
hoverEase: 0.16,
|
||||
hoverRange: 25,
|
||||
hoverEase: 0.08,
|
||||
hoverReturn: true,
|
||||
dragSens: 0.008,
|
||||
dragFriction: 0.08,
|
||||
dragMomentum: true,
|
||||
rotateAxis: '-xy',
|
||||
rotateAxis: 'y',
|
||||
rotatePreset: 'axis',
|
||||
rotateSpeed: 0.1,
|
||||
rotatePingPong: false,
|
||||
floatAmplitude: 0.16,
|
||||
floatSpeed: 0.8,
|
||||
lightSweepHeightRange: 0.5,
|
||||
lightSweepHeightRange: 0,
|
||||
lightSweepRange: 28,
|
||||
lightSweepSpeed: 0.2,
|
||||
springDamping: 0.4,
|
||||
lightSweepSpeed: 0.25,
|
||||
springDamping: 0.5,
|
||||
springReturnEnabled: true,
|
||||
springStrength: 0.06,
|
||||
springStrength: 0.1,
|
||||
hoverHalftoneEnabled: false,
|
||||
hoverHalftonePowerShift: 0.42,
|
||||
hoverHalftoneRadius: 0.2,
|
||||
hoverHalftoneWidthShift: -0.18,
|
||||
hoverLightIntensity: 0.8,
|
||||
hoverLightRadius: 0.2,
|
||||
dragFlowDecay: 0.08,
|
||||
@@ -96,6 +107,7 @@ export function Spaceship() {
|
||||
label="spaceship.glb"
|
||||
modelUrl={GLB_URL}
|
||||
previewDistance={SPACESHIP_PREVIEW_DISTANCE}
|
||||
renderer="studio"
|
||||
settings={SPACESHIP_SETTINGS}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -11,26 +11,33 @@ const TARGET_PREVIEW_DISTANCE = 4;
|
||||
|
||||
const TARGET_SETTINGS: HelpedHalftoneSettings = {
|
||||
lighting: {
|
||||
intensity: 1,
|
||||
intensity: 3,
|
||||
fillIntensity: 0,
|
||||
ambientIntensity: 0,
|
||||
angleDegrees: 103,
|
||||
height: -3.6,
|
||||
angleDegrees: 47,
|
||||
height: 1.4,
|
||||
},
|
||||
material: {
|
||||
roughness: 0.32,
|
||||
metalness: 0.98,
|
||||
surface: 'glass',
|
||||
color: '#7d7d7d',
|
||||
roughness: 0.26,
|
||||
metalness: 0.15,
|
||||
thickness: 15.58,
|
||||
refraction: 3,
|
||||
environmentPower: 5,
|
||||
},
|
||||
halftone: {
|
||||
enabled: true,
|
||||
scale: 14.97,
|
||||
power: -0.25,
|
||||
width: 1.4,
|
||||
scale: 25.64,
|
||||
power: -1.24,
|
||||
width: 0.5,
|
||||
imageContrast: 1,
|
||||
dashColor: '#ED87FC',
|
||||
hoverDashColor: '#ED87FC',
|
||||
},
|
||||
animation: {
|
||||
autoRotateEnabled: false,
|
||||
breatheEnabled: false,
|
||||
breatheEnabled: true,
|
||||
cameraParallaxEnabled: false,
|
||||
followHoverEnabled: false,
|
||||
followDragEnabled: true,
|
||||
@@ -39,31 +46,35 @@ const TARGET_SETTINGS: HelpedHalftoneSettings = {
|
||||
dragFlowEnabled: false,
|
||||
lightSweepEnabled: false,
|
||||
rotateEnabled: false,
|
||||
autoSpeed: 0.1,
|
||||
autoWobble: 0,
|
||||
breatheAmount: 0.04,
|
||||
breatheSpeed: 0.8,
|
||||
autoSpeed: 0.15,
|
||||
autoWobble: 0.3,
|
||||
breatheAmount: 0.015,
|
||||
breatheSpeed: 0.25,
|
||||
cameraParallaxAmount: 0.3,
|
||||
cameraParallaxEase: 0.08,
|
||||
driftAmount: 8,
|
||||
hoverRange: 24,
|
||||
hoverEase: 0.16,
|
||||
hoverRange: 25,
|
||||
hoverEase: 0.08,
|
||||
hoverReturn: true,
|
||||
dragSens: 0.008,
|
||||
dragFriction: 0.08,
|
||||
dragMomentum: true,
|
||||
rotateAxis: '-xy',
|
||||
rotateAxis: 'y',
|
||||
rotatePreset: 'axis',
|
||||
rotateSpeed: 0.1,
|
||||
rotatePingPong: false,
|
||||
floatAmplitude: 0.16,
|
||||
floatSpeed: 0.8,
|
||||
lightSweepHeightRange: 0.5,
|
||||
lightSweepHeightRange: 0,
|
||||
lightSweepRange: 28,
|
||||
lightSweepSpeed: 0.2,
|
||||
springDamping: 0.4,
|
||||
lightSweepSpeed: 0.25,
|
||||
springDamping: 0.5,
|
||||
springReturnEnabled: true,
|
||||
springStrength: 0.06,
|
||||
springStrength: 0.1,
|
||||
hoverHalftoneEnabled: false,
|
||||
hoverHalftonePowerShift: 0.42,
|
||||
hoverHalftoneRadius: 0.2,
|
||||
hoverHalftoneWidthShift: -0.18,
|
||||
hoverLightIntensity: 0.8,
|
||||
hoverLightRadius: 0.2,
|
||||
dragFlowDecay: 0.08,
|
||||
@@ -96,6 +107,7 @@ export function Target() {
|
||||
label="target.glb"
|
||||
modelUrl={GLB_URL}
|
||||
previewDistance={TARGET_PREVIEW_DISTANCE}
|
||||
renderer="studio"
|
||||
settings={TARGET_SETTINGS}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
export const SHARED_COMPANY_LOGO_URLS = {
|
||||
airbnb: '/images/shared/companies/logos/airbnb.png',
|
||||
anthropic: '/images/shared/companies/logos/anthropic.png',
|
||||
cursor: '/images/shared/companies/logos/cursor.png',
|
||||
figma: '/images/shared/companies/logos/figma.png',
|
||||
github: '/images/shared/companies/logos/github.png',
|
||||
linear: '/images/shared/companies/logos/linear.svg',
|
||||
notion: '/images/shared/companies/logos/notion.png',
|
||||
sequoia: '/images/shared/companies/logos/sequoia.png',
|
||||
slack: '/images/shared/companies/logos/slack.png',
|
||||
twenty: '/images/shared/companies/logos/twenty.svg',
|
||||
} as const;
|
||||
|
||||
export const SHARED_PEOPLE_AVATAR_URLS = {
|
||||
|
||||
@@ -15,25 +15,24 @@ const HOVER_FADE_OUT = 7;
|
||||
|
||||
const HALFTONE_SETTINGS = {
|
||||
animation: {
|
||||
hoverHalftoneEnabled: true,
|
||||
hoverHalftonePowerShift: 0.62,
|
||||
hoverHalftoneRadius: 0.6,
|
||||
hoverHalftoneEnabled: false,
|
||||
hoverHalftonePowerShift: 0.42,
|
||||
hoverHalftoneRadius: 0.45,
|
||||
hoverHalftoneWidthShift: -0.18,
|
||||
hoverLightEnabled: false,
|
||||
hoverLightIntensity: 0.12,
|
||||
hoverLightRadius: 0.8,
|
||||
hoverLightEnabled: true,
|
||||
hoverLightIntensity: 0.35,
|
||||
hoverLightRadius: 0.42,
|
||||
waveAmount: 2,
|
||||
waveEnabled: false,
|
||||
waveSpeed: 1,
|
||||
},
|
||||
halftone: {
|
||||
dashColor: '#dddddd',
|
||||
hoverDashColor: '#FFF',
|
||||
imageContrast: 1.12,
|
||||
minimumTone: 0.26,
|
||||
power: 0.18,
|
||||
scale: 12,
|
||||
width: 0.72,
|
||||
dashColor: '#868686',
|
||||
hoverDashColor: '#F5F5F5',
|
||||
imageContrast: 1,
|
||||
power: 0.5,
|
||||
scale: 8,
|
||||
width: 0.3,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -63,7 +62,6 @@ const imagePassthroughFragmentShader = /* glsl */ `
|
||||
|
||||
vec2 uv = vUv;
|
||||
|
||||
// Cover: match the underlying NextImage background crop.
|
||||
if (imageAspect > viewAspect) {
|
||||
float scale = viewAspect / imageAspect;
|
||||
uv.x = (uv.x - 0.5) * scale + 0.5;
|
||||
@@ -95,7 +93,6 @@ const halftoneFragmentShader = /* glsl */ `
|
||||
uniform float s_4;
|
||||
uniform vec3 dashColor;
|
||||
uniform vec3 hoverDashColor;
|
||||
uniform float minimumTone;
|
||||
uniform float time;
|
||||
uniform float waveAmount;
|
||||
uniform float waveSpeed;
|
||||
@@ -209,17 +206,15 @@ const halftoneFragmentShader = /* glsl */ `
|
||||
);
|
||||
float lightLift =
|
||||
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
|
||||
float tonalAverage = (
|
||||
float bandRadius = clamp(
|
||||
(
|
||||
sceneSample.r +
|
||||
sceneSample.g +
|
||||
sceneSample.b +
|
||||
localPower * length(vec2(0.5))
|
||||
) *
|
||||
(1.0 / 3.0)
|
||||
) + lightLift;
|
||||
float bandRadius = clamp(
|
||||
max(tonalAverage, minimumTone),
|
||||
(1.0 / 3.0) +
|
||||
lightLift,
|
||||
0.0,
|
||||
1.0
|
||||
) * 1.86 * 0.5;
|
||||
@@ -391,7 +386,6 @@ async function mountHalftoneCanvas({
|
||||
logicalResolution: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
minimumTone: { value: HALFTONE_SETTINGS.halftone.minimumTone },
|
||||
s_3: { value: HALFTONE_SETTINGS.halftone.power },
|
||||
s_4: { value: HALFTONE_SETTINGS.halftone.width },
|
||||
tScene: { value: sceneTarget.texture },
|
||||
|
||||
@@ -6,7 +6,8 @@ import { StepperVisualFrame } from '../StepperVisualFrame/StepperVisualFrame';
|
||||
import { StepperBackgroundHalftone } from './StepperBackgroundHalftone';
|
||||
import { StepperLottie } from './StepperLottie';
|
||||
|
||||
const HOME_STEPPER_BACKGROUND = '/images/home/stepper/gears.jpg';
|
||||
const HOME_STEPPER_BACKGROUND =
|
||||
'/images/home/stepper/download-worker.webp';
|
||||
const HOME_STEPPER_SHAPE = '/images/home/stepper/background-shape.webp';
|
||||
|
||||
type VisualProps = {
|
||||
@@ -16,7 +17,7 @@ type VisualProps = {
|
||||
export function Visual({ scrollProgress }: VisualProps) {
|
||||
return (
|
||||
<StepperVisualFrame
|
||||
backgroundColor="#F5F5F5"
|
||||
backgroundColor="#424242"
|
||||
backgroundSrc={HOME_STEPPER_BACKGROUND}
|
||||
backgroundOverlay={
|
||||
<VisibleWhenTabActive>
|
||||
|
||||
@@ -86,7 +86,7 @@ export function Root(props: RootProps) {
|
||||
<SignoffShape fillColor={shapeFillColor} />
|
||||
) : null}
|
||||
{page === Pages.Partner ? (
|
||||
<GuideCrosshair crossX="calc(50% - 334px)" crossY="198px" />
|
||||
<GuideCrosshair crossX="calc(50% + 334px)" crossY="198px" />
|
||||
) : null}
|
||||
<StyledContainer>{children}</StyledContainer>
|
||||
</StyledSection>
|
||||
|
||||
@@ -0,0 +1,921 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { type RefObject, useEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
const DEFAULT_IMAGE_URL =
|
||||
'/images/home/three-cards-feature/familiar-interface-gradient.webp';
|
||||
const VIRTUAL_RENDER_HEIGHT = 800;
|
||||
const REFERENCE_PREVIEW_DISTANCE = 4;
|
||||
const PREVIEW_DISTANCE = REFERENCE_PREVIEW_DISTANCE;
|
||||
const MIN_FOOTPRINT_SCALE = 0.001;
|
||||
const HALFTONE_SCALE = 18;
|
||||
const HALFTONE_POWER = -0.3;
|
||||
const HALFTONE_WIDTH = 0.3;
|
||||
const IMAGE_CONTRAST = 0.7;
|
||||
const DASH_COLOR = '#777';
|
||||
const HOVER_DASH_COLOR = '#777';
|
||||
const HOVER_LIGHT_INTENSITY = 0.85;
|
||||
const HOVER_LIGHT_RADIUS = 0.6;
|
||||
const HOVER_HALFTONE_RADIUS = 0.45;
|
||||
const IMAGE_POINTER_FOLLOW = 0.38;
|
||||
const IMAGE_POINTER_VELOCITY_DAMPING = 0.82;
|
||||
const IMAGE_HOVER_FADE_IN = 18;
|
||||
const IMAGE_HOVER_FADE_OUT = 7;
|
||||
|
||||
export type HalftoneBackdropConfig = {
|
||||
activeHoverX?: number;
|
||||
activeHoverY?: number;
|
||||
dashColor?: string;
|
||||
halftonePower?: number;
|
||||
halftoneScale?: number;
|
||||
halftoneWidth?: number;
|
||||
hoverDashColor?: string;
|
||||
hoverHalftoneRadius?: number;
|
||||
hoverLightIntensity?: number;
|
||||
hoverLightRadius?: number;
|
||||
imageContrast?: number;
|
||||
previewDistance?: number;
|
||||
};
|
||||
|
||||
type ResolvedHalftoneBackdropConfig = Required<HalftoneBackdropConfig>;
|
||||
|
||||
const DEFAULT_HALFTONE_BACKDROP_CONFIG: ResolvedHalftoneBackdropConfig = {
|
||||
activeHoverX: 0.16,
|
||||
activeHoverY: 0.46,
|
||||
dashColor: DASH_COLOR,
|
||||
halftonePower: HALFTONE_POWER,
|
||||
halftoneScale: HALFTONE_SCALE,
|
||||
halftoneWidth: HALFTONE_WIDTH,
|
||||
hoverDashColor: HOVER_DASH_COLOR,
|
||||
hoverHalftoneRadius: HOVER_HALFTONE_RADIUS,
|
||||
hoverLightIntensity: HOVER_LIGHT_INTENSITY,
|
||||
hoverLightRadius: HOVER_LIGHT_RADIUS,
|
||||
imageContrast: IMAGE_CONTRAST,
|
||||
previewDistance: PREVIEW_DISTANCE,
|
||||
};
|
||||
|
||||
const passThroughVertexShader = `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const blurFragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tInput;
|
||||
uniform vec2 dir;
|
||||
uniform vec2 res;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec4 sum = vec4(0.0);
|
||||
vec2 px = dir / res;
|
||||
|
||||
float w[5];
|
||||
w[0] = 0.227027;
|
||||
w[1] = 0.1945946;
|
||||
w[2] = 0.1216216;
|
||||
w[3] = 0.054054;
|
||||
w[4] = 0.016216;
|
||||
|
||||
sum += texture2D(tInput, vUv) * w[0];
|
||||
|
||||
for (int i = 1; i < 5; i++) {
|
||||
float fi = float(i) * 3.0;
|
||||
sum += texture2D(tInput, vUv + px * fi) * w[i];
|
||||
sum += texture2D(tInput, vUv - px * fi) * w[i];
|
||||
}
|
||||
|
||||
gl_FragColor = sum;
|
||||
}
|
||||
`;
|
||||
|
||||
const halftoneFragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tScene;
|
||||
uniform vec2 effectResolution;
|
||||
uniform vec2 logicalResolution;
|
||||
uniform float tile;
|
||||
uniform float s_3;
|
||||
uniform float s_4;
|
||||
uniform vec3 dashColor;
|
||||
uniform vec3 hoverDashColor;
|
||||
uniform float footprintScale;
|
||||
uniform vec2 interactionUv;
|
||||
uniform vec2 interactionVelocity;
|
||||
uniform float hoverLightStrength;
|
||||
uniform float hoverLightRadius;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
float distSegment(in vec2 p, in vec2 a, in vec2 b) {
|
||||
vec2 pa = p - a;
|
||||
vec2 ba = b - a;
|
||||
float denom = max(dot(ba, ba), 0.000001);
|
||||
float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);
|
||||
return length(pa - ba * h);
|
||||
}
|
||||
|
||||
float lineSimpleEt(in vec2 p, in float r, in float thickness) {
|
||||
vec2 a = vec2(0.5) + vec2(-r, 0.0);
|
||||
vec2 b = vec2(0.5) + vec2(r, 0.0);
|
||||
float distToSegment = distSegment(p, a, b);
|
||||
float halfThickness = thickness * r;
|
||||
return distToSegment - halfThickness;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 fragCoord =
|
||||
(gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;
|
||||
float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);
|
||||
vec2 pointerPx = interactionUv * logicalResolution;
|
||||
vec2 fragDelta = fragCoord - pointerPx;
|
||||
float fragDist = length(fragDelta);
|
||||
vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
|
||||
float velocityMagnitude = length(interactionVelocity);
|
||||
vec2 motionDir = velocityMagnitude > 0.001
|
||||
? interactionVelocity / velocityMagnitude
|
||||
: vec2(0.0, 0.0);
|
||||
float motionBias = velocityMagnitude > 0.001
|
||||
? dot(-radialDir, motionDir) * 0.5 + 0.5
|
||||
: 0.5;
|
||||
|
||||
float hoverLightMask = 0.0;
|
||||
if (hoverLightStrength > 0.0) {
|
||||
float lightRadiusPx = hoverLightRadius * logicalResolution.y;
|
||||
hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
|
||||
}
|
||||
|
||||
vec2 effectCoord = fragCoord;
|
||||
|
||||
vec2 cellIndex = floor(effectCoord / halftoneSize);
|
||||
vec2 sampleUv = clamp(
|
||||
(cellIndex + 0.5) * halftoneSize / logicalResolution,
|
||||
vec2(0.0),
|
||||
vec2(1.0)
|
||||
);
|
||||
vec2 cellUv = fract(effectCoord / halftoneSize);
|
||||
|
||||
vec4 sceneSample = texture2D(tScene, sampleUv);
|
||||
float mask = smoothstep(0.02, 0.08, sceneSample.a);
|
||||
float lightLift =
|
||||
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
|
||||
float bandRadius = clamp(
|
||||
(
|
||||
(
|
||||
sceneSample.r +
|
||||
sceneSample.g +
|
||||
sceneSample.b +
|
||||
s_3 * length(vec2(0.5))
|
||||
) *
|
||||
(1.0 / 3.0)
|
||||
) + lightLift,
|
||||
0.0,
|
||||
1.0
|
||||
) * 1.86 * 0.5;
|
||||
|
||||
float alpha = 0.0;
|
||||
if (bandRadius > 0.0001) {
|
||||
float signedDistance = lineSimpleEt(cellUv, bandRadius, s_4);
|
||||
float edge = 0.02;
|
||||
alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;
|
||||
}
|
||||
|
||||
float hoverStrength = clamp(hoverLightMask * hoverLightStrength, 0.0, 1.0);
|
||||
vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverStrength);
|
||||
vec3 color = activeDashColor * alpha;
|
||||
gl_FragColor = vec4(color, alpha);
|
||||
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`;
|
||||
|
||||
const imagePassthroughFragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tImage;
|
||||
uniform vec2 imageSize;
|
||||
uniform vec2 viewportSize;
|
||||
uniform float zoom;
|
||||
uniform float contrast;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
float imageAspect = imageSize.x / imageSize.y;
|
||||
float viewAspect = viewportSize.x / viewportSize.y;
|
||||
|
||||
vec2 uv = vUv;
|
||||
|
||||
if (imageAspect > viewAspect) {
|
||||
float scale = viewAspect / imageAspect;
|
||||
uv.x = (uv.x - 0.5) * scale + 0.5;
|
||||
} else {
|
||||
float scale = imageAspect / viewAspect;
|
||||
uv.y = (uv.y - 0.5) * scale + 0.5;
|
||||
}
|
||||
|
||||
uv = (uv - 0.5) / zoom + 0.5;
|
||||
uv.y = 1.0 - uv.y;
|
||||
|
||||
float inBounds = step(0.0, uv.x) * step(uv.x, 1.0)
|
||||
* step(0.0, uv.y) * step(uv.y, 1.0);
|
||||
|
||||
vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));
|
||||
vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);
|
||||
|
||||
gl_FragColor = vec4(contrastColor, inBounds);
|
||||
}
|
||||
`;
|
||||
|
||||
type Rect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type InteractionState = {
|
||||
hoverStrength: number;
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
pointerInside: boolean;
|
||||
pointerVelocityX: number;
|
||||
pointerVelocityY: number;
|
||||
smoothedMouseX: number;
|
||||
smoothedMouseY: number;
|
||||
};
|
||||
|
||||
function clampRectToViewport(
|
||||
rect: Rect,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
) {
|
||||
const minX = Math.max(rect.x, 0);
|
||||
const minY = Math.max(rect.y, 0);
|
||||
const maxX = Math.min(rect.x + rect.width, viewportWidth);
|
||||
const maxY = Math.min(rect.y + rect.height, viewportHeight);
|
||||
|
||||
if (maxX <= minX || maxY <= minY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
}
|
||||
|
||||
function getRectArea(rect: Rect | null) {
|
||||
if (!rect) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(rect.width, 0) * Math.max(rect.height, 0);
|
||||
}
|
||||
|
||||
function getImagePreviewZoom(previewDistance: number) {
|
||||
return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001);
|
||||
}
|
||||
|
||||
function getContainedImageRect({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
zoom,
|
||||
}: {
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
viewportHeight: number;
|
||||
viewportWidth: number;
|
||||
zoom: number;
|
||||
}) {
|
||||
if (
|
||||
imageWidth <= 0 ||
|
||||
imageHeight <= 0 ||
|
||||
viewportWidth <= 0 ||
|
||||
viewportHeight <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageAspect = imageWidth / imageHeight;
|
||||
const viewAspect = viewportWidth / viewportHeight;
|
||||
|
||||
let fittedWidth = viewportWidth;
|
||||
let fittedHeight = viewportHeight;
|
||||
|
||||
if (imageAspect > viewAspect) {
|
||||
fittedHeight = viewportWidth / imageAspect;
|
||||
} else {
|
||||
fittedWidth = viewportHeight * imageAspect;
|
||||
}
|
||||
|
||||
const scaledWidth = fittedWidth * zoom;
|
||||
const scaledHeight = fittedHeight * zoom;
|
||||
|
||||
return clampRectToViewport(
|
||||
{
|
||||
x: (viewportWidth - scaledWidth) * 0.5,
|
||||
y: (viewportHeight - scaledHeight) * 0.5,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
},
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
);
|
||||
}
|
||||
|
||||
function getFootprintScaleFromRects(
|
||||
currentRect: Rect | null,
|
||||
referenceRect: Rect | null,
|
||||
) {
|
||||
const currentArea = getRectArea(currentRect);
|
||||
const referenceArea = getRectArea(referenceRect);
|
||||
|
||||
if (currentArea <= 0 || referenceArea <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(Math.sqrt(currentArea / referenceArea), MIN_FOOTPRINT_SCALE);
|
||||
}
|
||||
|
||||
function getImageFootprintScale({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
previewDistance,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
}: {
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
previewDistance: number;
|
||||
viewportHeight: number;
|
||||
viewportWidth: number;
|
||||
}) {
|
||||
const currentRect = getContainedImageRect({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
zoom: getImagePreviewZoom(previewDistance),
|
||||
});
|
||||
const referenceRect = getContainedImageRect({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
zoom: 1,
|
||||
});
|
||||
|
||||
return getFootprintScaleFromRects(currentRect, referenceRect);
|
||||
}
|
||||
|
||||
function createRenderTarget(width: number, height: number) {
|
||||
return new THREE.WebGLRenderTarget(width, height, {
|
||||
minFilter: THREE.LinearFilter,
|
||||
magFilter: THREE.LinearFilter,
|
||||
format: THREE.RGBAFormat,
|
||||
});
|
||||
}
|
||||
|
||||
function createInteractionState(): InteractionState {
|
||||
return {
|
||||
hoverStrength: 0,
|
||||
mouseX: 0.5,
|
||||
mouseY: 0.5,
|
||||
pointerInside: false,
|
||||
pointerVelocityX: 0,
|
||||
pointerVelocityY: 0,
|
||||
smoothedMouseX: 0.5,
|
||||
smoothedMouseY: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
function loadImage(imageUrl: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`Failed to load image: ${imageUrl}`));
|
||||
image.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
async function mountFamiliarInterfaceGradient({
|
||||
container,
|
||||
config,
|
||||
imageUrl,
|
||||
isExternallyActive,
|
||||
pointerTarget,
|
||||
}: {
|
||||
container: HTMLDivElement;
|
||||
config: ResolvedHalftoneBackdropConfig;
|
||||
imageUrl: string;
|
||||
isExternallyActive: () => boolean;
|
||||
pointerTarget?: HTMLElement | null;
|
||||
}) {
|
||||
const HOVER_STRENGTH_STOP_EPSILON = 0.001;
|
||||
const POINTER_POSITION_STOP_EPSILON = 0.001;
|
||||
const POINTER_VELOCITY_STOP_EPSILON = 0.0001;
|
||||
const image = await loadImage(imageUrl);
|
||||
const getWidth = () => Math.max(container.clientWidth, 1);
|
||||
const getHeight = () => Math.max(container.clientHeight, 1);
|
||||
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
|
||||
const getVirtualWidth = () =>
|
||||
Math.max(
|
||||
Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))),
|
||||
1,
|
||||
);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
|
||||
|
||||
const canvas = renderer.domElement;
|
||||
canvas.style.cursor = 'default';
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.touchAction = 'none';
|
||||
canvas.style.width = '100%';
|
||||
container.appendChild(canvas);
|
||||
const trackingElement = pointerTarget ?? canvas;
|
||||
|
||||
const imageTexture = new THREE.Texture(image);
|
||||
imageTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
imageTexture.needsUpdate = true;
|
||||
|
||||
const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
|
||||
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const imageMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
contrast: { value: config.imageContrast },
|
||||
imageSize: { value: new THREE.Vector2(image.width, image.height) },
|
||||
tImage: { value: imageTexture },
|
||||
viewportSize: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
zoom: { value: getImagePreviewZoom(config.previewDistance) },
|
||||
},
|
||||
fragmentShader: imagePassthroughFragmentShader,
|
||||
vertexShader: passThroughVertexShader,
|
||||
});
|
||||
|
||||
const blurHorizontalMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
dir: { value: new THREE.Vector2(1, 0) },
|
||||
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
|
||||
tInput: { value: null },
|
||||
},
|
||||
fragmentShader: blurFragmentShader,
|
||||
vertexShader: passThroughVertexShader,
|
||||
});
|
||||
|
||||
const blurVerticalMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
dir: { value: new THREE.Vector2(0, 1) },
|
||||
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
|
||||
tInput: { value: null },
|
||||
},
|
||||
fragmentShader: blurFragmentShader,
|
||||
vertexShader: passThroughVertexShader,
|
||||
});
|
||||
|
||||
const halftoneMaterial = new THREE.ShaderMaterial({
|
||||
fragmentShader: halftoneFragmentShader,
|
||||
transparent: true,
|
||||
uniforms: {
|
||||
dashColor: { value: new THREE.Color(config.dashColor) },
|
||||
effectResolution: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
footprintScale: { value: 1 },
|
||||
hoverDashColor: { value: new THREE.Color(config.hoverDashColor) },
|
||||
hoverLightRadius: { value: config.hoverLightRadius },
|
||||
hoverLightStrength: { value: 0 },
|
||||
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
|
||||
interactionVelocity: { value: new THREE.Vector2(0, 0) },
|
||||
logicalResolution: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
s_3: { value: config.halftonePower },
|
||||
s_4: { value: config.halftoneWidth },
|
||||
tScene: { value: sceneTarget.texture },
|
||||
tile: { value: config.halftoneScale },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
});
|
||||
|
||||
const fullScreenMesh = new THREE.Mesh(fullScreenGeometry, imageMaterial);
|
||||
const blurHorizontalMesh = new THREE.Mesh(
|
||||
fullScreenGeometry,
|
||||
blurHorizontalMaterial,
|
||||
);
|
||||
const blurVerticalMesh = new THREE.Mesh(
|
||||
fullScreenGeometry,
|
||||
blurVerticalMaterial,
|
||||
);
|
||||
const postProcessMesh = new THREE.Mesh(fullScreenGeometry, halftoneMaterial);
|
||||
|
||||
const imageScene = new THREE.Scene();
|
||||
imageScene.add(fullScreenMesh);
|
||||
|
||||
const blurHorizontalScene = new THREE.Scene();
|
||||
blurHorizontalScene.add(blurHorizontalMesh);
|
||||
|
||||
const blurVerticalScene = new THREE.Scene();
|
||||
blurVerticalScene.add(blurVerticalMesh);
|
||||
|
||||
const postScene = new THREE.Scene();
|
||||
postScene.add(postProcessMesh);
|
||||
|
||||
const updateViewportUniforms = (
|
||||
logicalWidth: number,
|
||||
logicalHeight: number,
|
||||
effectWidth: number,
|
||||
effectHeight: number,
|
||||
) => {
|
||||
blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
|
||||
blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
|
||||
halftoneMaterial.uniforms.effectResolution.value.set(
|
||||
effectWidth,
|
||||
effectHeight,
|
||||
);
|
||||
halftoneMaterial.uniforms.logicalResolution.value.set(
|
||||
logicalWidth,
|
||||
logicalHeight,
|
||||
);
|
||||
imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight);
|
||||
};
|
||||
|
||||
const getHalftoneScale = () =>
|
||||
getImageFootprintScale({
|
||||
imageHeight: image.height,
|
||||
imageWidth: image.width,
|
||||
previewDistance: config.previewDistance,
|
||||
viewportHeight: getVirtualHeight(),
|
||||
viewportWidth: getVirtualWidth(),
|
||||
});
|
||||
|
||||
const interaction = createInteractionState();
|
||||
let animationFrameId: number | null = null;
|
||||
let lastFrameTime: number | null = null;
|
||||
|
||||
const renderBackdrop = (deltaSeconds: number) => {
|
||||
const externalActive = isExternallyActive();
|
||||
const isHoverActive = externalActive || interaction.pointerInside;
|
||||
const targetHoverStrength = isHoverActive ? 1 : 0;
|
||||
const hoverEasing =
|
||||
1 -
|
||||
Math.exp(
|
||||
-deltaSeconds *
|
||||
(isHoverActive ? IMAGE_HOVER_FADE_IN : IMAGE_HOVER_FADE_OUT),
|
||||
);
|
||||
|
||||
interaction.hoverStrength +=
|
||||
(targetHoverStrength - interaction.hoverStrength) * hoverEasing;
|
||||
|
||||
if (
|
||||
Math.abs(targetHoverStrength - interaction.hoverStrength) <=
|
||||
HOVER_STRENGTH_STOP_EPSILON
|
||||
) {
|
||||
interaction.hoverStrength = targetHoverStrength;
|
||||
}
|
||||
|
||||
const targetMouseX =
|
||||
externalActive && !interaction.pointerInside
|
||||
? config.activeHoverX
|
||||
: interaction.mouseX;
|
||||
const targetMouseY =
|
||||
externalActive && !interaction.pointerInside
|
||||
? config.activeHoverY
|
||||
: interaction.mouseY;
|
||||
|
||||
interaction.smoothedMouseX +=
|
||||
(targetMouseX - interaction.smoothedMouseX) * IMAGE_POINTER_FOLLOW;
|
||||
interaction.smoothedMouseY +=
|
||||
(targetMouseY - interaction.smoothedMouseY) * IMAGE_POINTER_FOLLOW;
|
||||
|
||||
if (
|
||||
Math.abs(targetMouseX - interaction.smoothedMouseX) <=
|
||||
POINTER_POSITION_STOP_EPSILON
|
||||
) {
|
||||
interaction.smoothedMouseX = targetMouseX;
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(targetMouseY - interaction.smoothedMouseY) <=
|
||||
POINTER_POSITION_STOP_EPSILON
|
||||
) {
|
||||
interaction.smoothedMouseY = targetMouseY;
|
||||
}
|
||||
|
||||
interaction.pointerVelocityX *= IMAGE_POINTER_VELOCITY_DAMPING;
|
||||
interaction.pointerVelocityY *= IMAGE_POINTER_VELOCITY_DAMPING;
|
||||
|
||||
if (
|
||||
Math.abs(interaction.pointerVelocityX) <= POINTER_VELOCITY_STOP_EPSILON
|
||||
) {
|
||||
interaction.pointerVelocityX = 0;
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(interaction.pointerVelocityY) <= POINTER_VELOCITY_STOP_EPSILON
|
||||
) {
|
||||
interaction.pointerVelocityY = 0;
|
||||
}
|
||||
|
||||
halftoneMaterial.uniforms.interactionUv.value.set(
|
||||
interaction.smoothedMouseX,
|
||||
1 - interaction.smoothedMouseY,
|
||||
);
|
||||
halftoneMaterial.uniforms.interactionVelocity.value.set(
|
||||
interaction.pointerVelocityX * getVirtualWidth(),
|
||||
-interaction.pointerVelocityY * getVirtualHeight(),
|
||||
);
|
||||
halftoneMaterial.uniforms.hoverLightStrength.value =
|
||||
config.hoverLightIntensity * interaction.hoverStrength;
|
||||
halftoneMaterial.uniforms.hoverLightRadius.value =
|
||||
config.hoverHalftoneRadius + config.hoverLightRadius * 0.5;
|
||||
halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale();
|
||||
imageMaterial.uniforms.zoom.value = getImagePreviewZoom(
|
||||
config.previewDistance,
|
||||
);
|
||||
|
||||
renderer.setRenderTarget(sceneTarget);
|
||||
renderer.render(imageScene, orthographicCamera);
|
||||
|
||||
blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
|
||||
renderer.setRenderTarget(blurTargetA);
|
||||
renderer.render(blurHorizontalScene, orthographicCamera);
|
||||
|
||||
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
|
||||
renderer.setRenderTarget(blurTargetB);
|
||||
renderer.render(blurVerticalScene, orthographicCamera);
|
||||
|
||||
blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
|
||||
renderer.setRenderTarget(blurTargetA);
|
||||
renderer.render(blurHorizontalScene, orthographicCamera);
|
||||
|
||||
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
|
||||
renderer.setRenderTarget(blurTargetB);
|
||||
renderer.render(blurVerticalScene, orthographicCamera);
|
||||
|
||||
renderer.setRenderTarget(null);
|
||||
renderer.clear();
|
||||
renderer.render(postScene, orthographicCamera);
|
||||
|
||||
return (
|
||||
isHoverActive ||
|
||||
interaction.hoverStrength !== targetHoverStrength ||
|
||||
interaction.smoothedMouseX !== targetMouseX ||
|
||||
interaction.smoothedMouseY !== targetMouseY ||
|
||||
interaction.pointerVelocityX !== 0 ||
|
||||
interaction.pointerVelocityY !== 0
|
||||
);
|
||||
};
|
||||
|
||||
const runFrame = (timestamp: number) => {
|
||||
animationFrameId = null;
|
||||
|
||||
const deltaSeconds =
|
||||
lastFrameTime === null
|
||||
? 1 / 60
|
||||
: Math.min((timestamp - lastFrameTime) / 1000, 0.1);
|
||||
|
||||
lastFrameTime = timestamp;
|
||||
|
||||
if (renderBackdrop(deltaSeconds)) {
|
||||
animationFrameId = window.requestAnimationFrame(runFrame);
|
||||
return;
|
||||
}
|
||||
|
||||
lastFrameTime = null;
|
||||
};
|
||||
|
||||
const ensureAnimationLoop = () => {
|
||||
if (animationFrameId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(runFrame);
|
||||
};
|
||||
|
||||
const syncSize = () => {
|
||||
const virtualWidth = getVirtualWidth();
|
||||
const virtualHeight = getVirtualHeight();
|
||||
|
||||
renderer.setSize(virtualWidth, virtualHeight, false);
|
||||
sceneTarget.setSize(virtualWidth, virtualHeight);
|
||||
blurTargetA.setSize(virtualWidth, virtualHeight);
|
||||
blurTargetB.setSize(virtualWidth, virtualHeight);
|
||||
updateViewportUniforms(
|
||||
virtualWidth,
|
||||
virtualHeight,
|
||||
virtualWidth,
|
||||
virtualHeight,
|
||||
);
|
||||
|
||||
if (animationFrameId === null) {
|
||||
renderBackdrop(0);
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(syncSize);
|
||||
resizeObserver.observe(container);
|
||||
syncSize();
|
||||
|
||||
const updatePointerPosition = (
|
||||
event: PointerEvent,
|
||||
options?: { resetVelocity?: boolean },
|
||||
) => {
|
||||
const rect = trackingElement.getBoundingClientRect();
|
||||
const width = Math.max(rect.width, 1);
|
||||
const height = Math.max(rect.height, 1);
|
||||
const nextMouseX = THREE.MathUtils.clamp(
|
||||
(event.clientX - rect.left) / width,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const nextMouseY = THREE.MathUtils.clamp(
|
||||
(event.clientY - rect.top) / height,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const deltaX = nextMouseX - interaction.mouseX;
|
||||
const deltaY = nextMouseY - interaction.mouseY;
|
||||
|
||||
interaction.mouseX = nextMouseX;
|
||||
interaction.mouseY = nextMouseY;
|
||||
interaction.pointerInside =
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom;
|
||||
|
||||
if (options?.resetVelocity) {
|
||||
interaction.pointerVelocityX = 0;
|
||||
interaction.pointerVelocityY = 0;
|
||||
interaction.smoothedMouseX = nextMouseX;
|
||||
interaction.smoothedMouseY = nextMouseY;
|
||||
return;
|
||||
}
|
||||
|
||||
interaction.pointerVelocityX = deltaX;
|
||||
interaction.pointerVelocityY = deltaY;
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const shouldReset = !interaction.pointerInside;
|
||||
updatePointerPosition(
|
||||
event,
|
||||
shouldReset ? { resetVelocity: true } : undefined,
|
||||
);
|
||||
ensureAnimationLoop();
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
interaction.pointerInside = false;
|
||||
interaction.pointerVelocityX = 0;
|
||||
interaction.pointerVelocityY = 0;
|
||||
ensureAnimationLoop();
|
||||
};
|
||||
|
||||
trackingElement.addEventListener('pointermove', handlePointerMove);
|
||||
trackingElement.addEventListener('pointerleave', handlePointerLeave);
|
||||
|
||||
if (isExternallyActive()) {
|
||||
ensureAnimationLoop();
|
||||
}
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
if (animationFrameId !== null) {
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
|
||||
resizeObserver.disconnect();
|
||||
trackingElement.removeEventListener('pointermove', handlePointerMove);
|
||||
trackingElement.removeEventListener('pointerleave', handlePointerLeave);
|
||||
blurHorizontalMaterial.dispose();
|
||||
blurVerticalMaterial.dispose();
|
||||
halftoneMaterial.dispose();
|
||||
imageMaterial.dispose();
|
||||
imageTexture.dispose();
|
||||
fullScreenGeometry.dispose();
|
||||
sceneTarget.dispose();
|
||||
blurTargetA.dispose();
|
||||
blurTargetB.dispose();
|
||||
renderer.dispose();
|
||||
|
||||
if (canvas.parentNode === container) {
|
||||
container.removeChild(canvas);
|
||||
}
|
||||
},
|
||||
wake: ensureAnimationLoop,
|
||||
};
|
||||
}
|
||||
|
||||
const VisualMount = styled.div`
|
||||
background: transparent;
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type FamiliarInterfaceGradientBackdropProps = {
|
||||
active?: boolean;
|
||||
config?: HalftoneBackdropConfig;
|
||||
imageUrl?: string;
|
||||
pointerTargetRef?: RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
export function FamiliarInterfaceGradientBackdrop({
|
||||
active = false,
|
||||
config,
|
||||
imageUrl = DEFAULT_IMAGE_URL,
|
||||
pointerTargetRef,
|
||||
}: FamiliarInterfaceGradientBackdropProps) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const activeRef = useRef(active);
|
||||
const wakeRef = useRef<VoidFunction | null>(null);
|
||||
const resolvedConfig = {
|
||||
...DEFAULT_HALFTONE_BACKDROP_CONFIG,
|
||||
...config,
|
||||
};
|
||||
|
||||
activeRef.current = active;
|
||||
|
||||
useEffect(() => {
|
||||
const container = mountRef.current;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dispose: VoidFunction | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
void mountFamiliarInterfaceGradient({
|
||||
config: resolvedConfig,
|
||||
container,
|
||||
imageUrl,
|
||||
isExternallyActive: () => activeRef.current,
|
||||
pointerTarget: pointerTargetRef?.current,
|
||||
})
|
||||
.then((mountedGradient) => {
|
||||
if (cancelled) {
|
||||
mountedGradient.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
wakeRef.current = mountedGradient.wake;
|
||||
dispose = mountedGradient.dispose;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
wakeRef.current = null;
|
||||
dispose?.();
|
||||
};
|
||||
}, [
|
||||
imageUrl,
|
||||
resolvedConfig.dashColor,
|
||||
resolvedConfig.halftonePower,
|
||||
resolvedConfig.halftoneScale,
|
||||
resolvedConfig.halftoneWidth,
|
||||
resolvedConfig.hoverDashColor,
|
||||
resolvedConfig.hoverHalftoneRadius,
|
||||
resolvedConfig.hoverLightIntensity,
|
||||
resolvedConfig.hoverLightRadius,
|
||||
resolvedConfig.imageContrast,
|
||||
resolvedConfig.previewDistance,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
wakeRef.current?.();
|
||||
}, [active]);
|
||||
|
||||
return <VisualMount aria-hidden ref={mountRef} />;
|
||||
}
|
||||
@@ -9,10 +9,13 @@ import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { FamiliarInterfaceGradientBackdrop } from './FamiliarInterfaceGradientBackdrop';
|
||||
import {
|
||||
IconBuildingSkyscraper,
|
||||
IconCalendarEvent,
|
||||
@@ -40,6 +43,8 @@ const FIGMA_FIELD_STACK_GAP = 1.839;
|
||||
const FIGMA_CARD_RADIUS = 3.677;
|
||||
const FIGMA_CARD_SHADOW =
|
||||
'0px 0px 3.677px rgba(0, 0, 0, 0.08), 0px 1.839px 3.677px rgba(0, 0, 0, 0.04)';
|
||||
const FIGMA_COLUMN_GAP = 8;
|
||||
const FIGMA_COLUMN_SIDE_PADDING = 5.516;
|
||||
const FIGMA_HEADER_PADDING_X = 5.516;
|
||||
const FIGMA_HEADER_PADDING_TOP = 7.354;
|
||||
const FIGMA_HEADER_PADDING_BOTTOM = 3.677;
|
||||
@@ -66,15 +71,20 @@ type CardPlacement =
|
||||
| { laneIndex: LaneIndex; type: 'lane' };
|
||||
type DropTarget = { cardIndex: number; laneIndex: LaneIndex };
|
||||
type LaneCards = [CardId[], CardId[]];
|
||||
type FamiliarInterfaceVisualProps = { active?: boolean };
|
||||
type FamiliarInterfaceVisualProps = {
|
||||
active?: boolean;
|
||||
backgroundImageRotationDeg?: number;
|
||||
backgroundImageSrc?: string;
|
||||
pointerTargetRef?: RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
const COLORS = {
|
||||
backdrop: '#1b1b1b',
|
||||
backdropStripe: 'rgba(255, 255, 255, 0.28)',
|
||||
border: '#ebebeb',
|
||||
borderLight: '#f1f1f1',
|
||||
borderStrong: '#d6d6d6',
|
||||
boardSurface: '#ffffff',
|
||||
cardSurface: '#fcfcfc',
|
||||
activeCardBorder: '#b5ccff',
|
||||
activeCardSurface: '#e8f1ff',
|
||||
imageAreaSurface: '#f5f5f3',
|
||||
@@ -288,7 +298,7 @@ const SceneViewport = styled.div`
|
||||
height: ${SCENE_HEIGHT}px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
top: 0;
|
||||
transform: translateX(-50%) scale(${SCENE_SCALE});
|
||||
transform-origin: top center;
|
||||
width: ${SCENE_WIDTH}px;
|
||||
@@ -309,35 +319,20 @@ const SceneFrame = styled.div`
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 411px;
|
||||
`;
|
||||
|
||||
&::before {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 1px 1px,
|
||||
rgba(255, 255, 255, 0.09) 1px,
|
||||
transparent 0
|
||||
)
|
||||
right top / 12px 12px repeat,
|
||||
linear-gradient(rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.02));
|
||||
content: '';
|
||||
inset: 0;
|
||||
opacity: 0.42;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: repeating-linear-gradient(
|
||||
180deg,
|
||||
transparent 0 11px,
|
||||
${COLORS.backdropStripe} 11px 15px,
|
||||
transparent 15px 27px
|
||||
);
|
||||
content: '';
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
const SceneBackdrop = styled.div<{
|
||||
$backgroundImageRotationDeg?: number;
|
||||
}>`
|
||||
background-color: ${COLORS.backdrop};
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transform: rotate(
|
||||
${({ $backgroundImageRotationDeg = 0 }) => $backgroundImageRotationDeg}deg
|
||||
);
|
||||
transform-origin: center center;
|
||||
`;
|
||||
|
||||
const BoardGroup = styled.div<{ $active: boolean }>`
|
||||
@@ -434,10 +429,13 @@ const BoardTitleCount = styled.span`
|
||||
`;
|
||||
|
||||
const ColumnsHeaderGrid = styled.div`
|
||||
background: ${COLORS.boardSurface};
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 186.355px);
|
||||
justify-content: center;
|
||||
min-height: 29.354px;
|
||||
padding-left: 7.354px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const LaneHeader = styled.div`
|
||||
@@ -446,7 +444,7 @@ const LaneHeader = styled.div`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-height: 29.354px;
|
||||
padding: 7.354px 7.354px 0;
|
||||
padding: 7.354px ${FIGMA_COLUMN_SIDE_PADDING}px 0;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
@@ -480,18 +478,18 @@ const LaneCount = styled.span`
|
||||
const ColumnsGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 186.355px);
|
||||
justify-content: center;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding-left: 7.354px;
|
||||
`;
|
||||
|
||||
const LaneBody = styled.div`
|
||||
border-right: 1px solid ${COLORS.borderLight};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7.354px;
|
||||
gap: ${FIGMA_COLUMN_GAP}px;
|
||||
min-height: 0;
|
||||
padding: 7.354px 7.354px 8px;
|
||||
padding: 7.354px ${FIGMA_COLUMN_SIDE_PADDING}px 8px;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
@@ -511,7 +509,7 @@ const AddCardRow = styled.div`
|
||||
|
||||
const OpportunityCard = styled.div<{ $variant?: 'active' | 'board' }>`
|
||||
background: ${({ $variant }) =>
|
||||
$variant === 'active' ? COLORS.activeCardSurface : COLORS.boardSurface};
|
||||
$variant === 'active' ? COLORS.activeCardSurface : COLORS.cardSurface};
|
||||
border: ${({ $variant }) => ($variant === 'active' ? '0.919px' : '1px')} solid
|
||||
${({ $variant }) =>
|
||||
$variant === 'active' ? COLORS.activeCardBorder : COLORS.border};
|
||||
@@ -519,7 +517,26 @@ const OpportunityCard = styled.div<{ $variant?: 'active' | 'board' }>`
|
||||
box-shadow: ${FIGMA_CARD_SHADOW};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: background-color 120ms ease;
|
||||
width: ${FIGMA_CARD_WIDTH}px;
|
||||
|
||||
&::after {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
content: '';
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover::after {
|
||||
opacity: ${({ $variant }) => ($variant === 'board' ? 1 : 0)};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CardHeader = styled.div`
|
||||
@@ -1163,7 +1180,11 @@ function OpportunityPreviewCard({
|
||||
|
||||
export function FamiliarInterfaceVisual({
|
||||
active = false,
|
||||
backgroundImageRotationDeg,
|
||||
backgroundImageSrc,
|
||||
pointerTargetRef,
|
||||
}: FamiliarInterfaceVisualProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const sceneFrameRef = useRef<HTMLDivElement>(null);
|
||||
const interactionLayerRef = useRef<HTMLDivElement>(null);
|
||||
const laneBodyRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
@@ -1187,8 +1208,11 @@ export function FamiliarInterfaceVisual({
|
||||
const [draggedCardId, setDraggedCardId] = useState<CardId | null>(null);
|
||||
const [hasDraggedCard, setHasDraggedCard] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const activePointerIdRef = useRef<number | null>(null);
|
||||
const dragStateRef = useRef<{
|
||||
cardId: CardId;
|
||||
lastClientX: number;
|
||||
lastClientY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
minX: number;
|
||||
@@ -1272,6 +1296,51 @@ export function FamiliarInterfaceVisual({
|
||||
pendingCardAnimationRectsRef.current = nextCardRects;
|
||||
};
|
||||
|
||||
const releaseActivePointerCapture = () => {
|
||||
const activePointerId = activePointerIdRef.current;
|
||||
const interactionLayer = interactionLayerRef.current;
|
||||
|
||||
if (
|
||||
activePointerId !== null &&
|
||||
interactionLayer?.hasPointerCapture(activePointerId)
|
||||
) {
|
||||
interactionLayer.releasePointerCapture(activePointerId);
|
||||
}
|
||||
|
||||
activePointerIdRef.current = null;
|
||||
};
|
||||
|
||||
const clearDragState = () => {
|
||||
dragStateRef.current = null;
|
||||
setActiveCardId(null);
|
||||
setDraggedCardId(null);
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const updateDragOffsetFromPointer = (clientX: number, clientY: number) => {
|
||||
const currentDragState = dragStateRef.current;
|
||||
|
||||
if (currentDragState === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentDragState.lastClientX = clientX;
|
||||
currentDragState.lastClientY = clientY;
|
||||
|
||||
const nextX = clamp(
|
||||
currentDragState.originX + clientX - currentDragState.pointerX,
|
||||
currentDragState.minX,
|
||||
currentDragState.maxX,
|
||||
);
|
||||
const nextY = clamp(
|
||||
currentDragState.originY + clientY - currentDragState.pointerY,
|
||||
currentDragState.minY,
|
||||
currentDragState.maxY,
|
||||
);
|
||||
|
||||
setDragOffset({ x: nextX, y: nextY });
|
||||
};
|
||||
|
||||
const getDropTarget = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
@@ -1332,6 +1401,10 @@ export function FamiliarInterfaceVisual({
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragStateRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasDraggedCard(true);
|
||||
setActiveCardId(cardId);
|
||||
setDraggedCardId(cardId);
|
||||
@@ -1347,6 +1420,8 @@ export function FamiliarInterfaceVisual({
|
||||
|
||||
dragStateRef.current = {
|
||||
cardId,
|
||||
lastClientX: event.clientX,
|
||||
lastClientY: event.clientY,
|
||||
maxX: interactionLayerRect.width - cardRect.width,
|
||||
maxY: interactionLayerRect.height - cardRect.height,
|
||||
minX: 0,
|
||||
@@ -1359,37 +1434,11 @@ export function FamiliarInterfaceVisual({
|
||||
};
|
||||
setDragOffset({ x: originX, y: originY });
|
||||
setIsDragging(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
activePointerIdRef.current = event.pointerId;
|
||||
interactionLayerRef.current?.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (dragStateRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const nextX = clamp(
|
||||
dragStateRef.current.originX +
|
||||
event.clientX -
|
||||
dragStateRef.current.pointerX,
|
||||
dragStateRef.current.minX,
|
||||
dragStateRef.current.maxX,
|
||||
);
|
||||
const nextY = clamp(
|
||||
dragStateRef.current.originY +
|
||||
event.clientY -
|
||||
dragStateRef.current.pointerY,
|
||||
dragStateRef.current.minY,
|
||||
dragStateRef.current.maxY,
|
||||
);
|
||||
|
||||
setDragOffset({ x: nextX, y: nextY });
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const finishDragAtPosition = (clientX: number, clientY: number) => {
|
||||
const currentDragState = dragStateRef.current;
|
||||
|
||||
if (currentDragState === null) {
|
||||
@@ -1398,22 +1447,18 @@ export function FamiliarInterfaceVisual({
|
||||
|
||||
const releasedOffset = {
|
||||
x: clamp(
|
||||
currentDragState.originX + event.clientX - currentDragState.pointerX,
|
||||
currentDragState.originX + clientX - currentDragState.pointerX,
|
||||
currentDragState.minX,
|
||||
currentDragState.maxX,
|
||||
),
|
||||
y: clamp(
|
||||
currentDragState.originY + event.clientY - currentDragState.pointerY,
|
||||
currentDragState.originY + clientY - currentDragState.pointerY,
|
||||
currentDragState.minY,
|
||||
currentDragState.maxY,
|
||||
),
|
||||
};
|
||||
|
||||
const dropTarget = getDropTarget(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
currentDragState.cardId,
|
||||
);
|
||||
const dropTarget = getDropTarget(clientX, clientY, currentDragState.cardId);
|
||||
|
||||
if (dropTarget !== null) {
|
||||
captureCardAnimationRects(currentDragState.cardId);
|
||||
@@ -1442,14 +1487,72 @@ export function FamiliarInterfaceVisual({
|
||||
setFloatingPosition(releasedOffset);
|
||||
}
|
||||
|
||||
dragStateRef.current = null;
|
||||
setDraggedCardId(null);
|
||||
setIsDragging(false);
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
clearDragState();
|
||||
};
|
||||
|
||||
const handleCapturedPointerMove = (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (event.pointerId !== activePointerIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
updateDragOffsetFromPointer(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const handleCapturedPointerUp = (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (event.pointerId !== activePointerIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
finishDragAtPosition(event.clientX, event.clientY);
|
||||
releaseActivePointerCapture();
|
||||
};
|
||||
|
||||
const handleCapturedPointerCancel = (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (event.pointerId !== activePointerIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const currentDragState = dragStateRef.current;
|
||||
|
||||
if (currentDragState !== null) {
|
||||
finishDragAtPosition(
|
||||
currentDragState.lastClientX,
|
||||
currentDragState.lastClientY,
|
||||
);
|
||||
}
|
||||
|
||||
releaseActivePointerCapture();
|
||||
};
|
||||
|
||||
const handleLostPointerCapture = () => {
|
||||
const currentDragState = dragStateRef.current;
|
||||
|
||||
if (currentDragState !== null) {
|
||||
finishDragAtPosition(
|
||||
currentDragState.lastClientX,
|
||||
currentDragState.lastClientY,
|
||||
);
|
||||
}
|
||||
|
||||
activePointerIdRef.current = null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
releaseActivePointerCapture();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showHandCursor =
|
||||
!hasDraggedCard && !isDragging && activeCardId === null;
|
||||
|
||||
@@ -1468,9 +1571,6 @@ export function FamiliarInterfaceVisual({
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, cardId, { laneIndex, type: 'lane' });
|
||||
}}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
style={isDraggedCard ? { visibility: 'hidden' } : undefined}
|
||||
>
|
||||
<OpportunityPreviewCard
|
||||
@@ -1482,9 +1582,18 @@ export function FamiliarInterfaceVisual({
|
||||
};
|
||||
|
||||
return (
|
||||
<VisualRoot aria-hidden="true">
|
||||
<VisualRoot aria-hidden="true" ref={rootRef}>
|
||||
<SceneViewport>
|
||||
<SceneFrame ref={sceneFrameRef}>
|
||||
<SceneBackdrop
|
||||
$backgroundImageRotationDeg={backgroundImageRotationDeg}
|
||||
>
|
||||
<FamiliarInterfaceGradientBackdrop
|
||||
active={active}
|
||||
imageUrl={backgroundImageSrc}
|
||||
pointerTargetRef={pointerTargetRef ?? rootRef}
|
||||
/>
|
||||
</SceneBackdrop>
|
||||
<BoardGroup $active={active}>
|
||||
<BoardSurface>
|
||||
<BoardTitleRow>
|
||||
@@ -1559,7 +1668,13 @@ export function FamiliarInterfaceVisual({
|
||||
</ColumnsGrid>
|
||||
</BoardSurface>
|
||||
|
||||
<InteractionLayer ref={interactionLayerRef}>
|
||||
<InteractionLayer
|
||||
ref={interactionLayerRef}
|
||||
onLostPointerCapture={handleLostPointerCapture}
|
||||
onPointerCancel={handleCapturedPointerCancel}
|
||||
onPointerMove={handleCapturedPointerMove}
|
||||
onPointerUp={handleCapturedPointerUp}
|
||||
>
|
||||
{showHandCursor ? (
|
||||
<DragCursor $active={active}>
|
||||
<DragCursorInner $active={active}>
|
||||
@@ -1578,9 +1693,6 @@ export function FamiliarInterfaceVisual({
|
||||
type: 'floating',
|
||||
});
|
||||
}}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
style={{
|
||||
transform: `translate3d(${floatingPosition.x}px, ${floatingPosition.y}px, 0)`,
|
||||
visibility:
|
||||
|
||||
@@ -0,0 +1,924 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { type RefObject, useEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
const DEFAULT_IMAGE_URL =
|
||||
'/images/home/three-cards-feature/fast-path-gradient.webp';
|
||||
const VIRTUAL_RENDER_HEIGHT = 1200;
|
||||
const REFERENCE_PREVIEW_DISTANCE = 4;
|
||||
const PREVIEW_DISTANCE = REFERENCE_PREVIEW_DISTANCE;
|
||||
const MIN_FOOTPRINT_SCALE = 0.001;
|
||||
const HALFTONE_SCALE = 28;
|
||||
const HALFTONE_POWER = 0.3;
|
||||
const HALFTONE_WIDTH = 0.3;
|
||||
const IMAGE_CONTRAST = 1;
|
||||
const DASH_COLOR = '#777';
|
||||
const HOVER_DASH_COLOR = '#777';
|
||||
const HOVER_LIGHT_INTENSITY = 0.85;
|
||||
const HOVER_LIGHT_RADIUS = 0.6;
|
||||
const HOVER_HALFTONE_RADIUS = 0.45;
|
||||
const IMAGE_POINTER_FOLLOW = 0.38;
|
||||
const IMAGE_POINTER_VELOCITY_DAMPING = 0.82;
|
||||
const IMAGE_HOVER_FADE_IN = 18;
|
||||
const IMAGE_HOVER_FADE_OUT = 7;
|
||||
|
||||
export type HalftoneBackdropConfig = {
|
||||
activeHoverX?: number;
|
||||
activeHoverY?: number;
|
||||
dashColor?: string;
|
||||
halftonePower?: number;
|
||||
halftoneScale?: number;
|
||||
halftoneWidth?: number;
|
||||
hoverDashColor?: string;
|
||||
hoverHalftoneRadius?: number;
|
||||
hoverLightIntensity?: number;
|
||||
hoverLightRadius?: number;
|
||||
imageContrast?: number;
|
||||
previewDistance?: number;
|
||||
};
|
||||
|
||||
type ResolvedHalftoneBackdropConfig = Required<HalftoneBackdropConfig>;
|
||||
|
||||
const DEFAULT_HALFTONE_BACKDROP_CONFIG: ResolvedHalftoneBackdropConfig = {
|
||||
activeHoverX: 0.16,
|
||||
activeHoverY: 0.46,
|
||||
dashColor: DASH_COLOR,
|
||||
halftonePower: HALFTONE_POWER,
|
||||
halftoneScale: HALFTONE_SCALE,
|
||||
halftoneWidth: HALFTONE_WIDTH,
|
||||
hoverDashColor: HOVER_DASH_COLOR,
|
||||
hoverHalftoneRadius: HOVER_HALFTONE_RADIUS,
|
||||
hoverLightIntensity: HOVER_LIGHT_INTENSITY,
|
||||
hoverLightRadius: HOVER_LIGHT_RADIUS,
|
||||
imageContrast: IMAGE_CONTRAST,
|
||||
previewDistance: PREVIEW_DISTANCE,
|
||||
};
|
||||
|
||||
const passThroughVertexShader = `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const blurFragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tInput;
|
||||
uniform vec2 dir;
|
||||
uniform vec2 res;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec4 sum = vec4(0.0);
|
||||
vec2 px = dir / res;
|
||||
|
||||
float w[5];
|
||||
w[0] = 0.227027;
|
||||
w[1] = 0.1945946;
|
||||
w[2] = 0.1216216;
|
||||
w[3] = 0.054054;
|
||||
w[4] = 0.016216;
|
||||
|
||||
sum += texture2D(tInput, vUv) * w[0];
|
||||
|
||||
for (int i = 1; i < 5; i++) {
|
||||
float fi = float(i) * 3.0;
|
||||
sum += texture2D(tInput, vUv + px * fi) * w[i];
|
||||
sum += texture2D(tInput, vUv - px * fi) * w[i];
|
||||
}
|
||||
|
||||
gl_FragColor = sum;
|
||||
}
|
||||
`;
|
||||
|
||||
const halftoneFragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tScene;
|
||||
uniform vec2 effectResolution;
|
||||
uniform vec2 logicalResolution;
|
||||
uniform float tile;
|
||||
uniform float s_3;
|
||||
uniform float s_4;
|
||||
uniform vec3 dashColor;
|
||||
uniform vec3 hoverDashColor;
|
||||
uniform float footprintScale;
|
||||
uniform vec2 interactionUv;
|
||||
uniform vec2 interactionVelocity;
|
||||
uniform float hoverLightStrength;
|
||||
uniform float hoverLightRadius;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
float distSegment(in vec2 p, in vec2 a, in vec2 b) {
|
||||
vec2 pa = p - a;
|
||||
vec2 ba = b - a;
|
||||
float denom = max(dot(ba, ba), 0.000001);
|
||||
float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);
|
||||
return length(pa - ba * h);
|
||||
}
|
||||
|
||||
float lineSimpleEt(in vec2 p, in float r, in float thickness) {
|
||||
vec2 a = vec2(0.5) + vec2(-r, 0.0);
|
||||
vec2 b = vec2(0.5) + vec2(r, 0.0);
|
||||
float distToSegment = distSegment(p, a, b);
|
||||
float halfThickness = thickness * r;
|
||||
return distToSegment - halfThickness;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 fragCoord =
|
||||
(gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;
|
||||
float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);
|
||||
vec2 pointerPx = interactionUv * logicalResolution;
|
||||
vec2 fragDelta = fragCoord - pointerPx;
|
||||
float fragDist = length(fragDelta);
|
||||
vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
|
||||
float velocityMagnitude = length(interactionVelocity);
|
||||
vec2 motionDir = velocityMagnitude > 0.001
|
||||
? interactionVelocity / velocityMagnitude
|
||||
: vec2(0.0, 0.0);
|
||||
float motionBias = velocityMagnitude > 0.001
|
||||
? dot(-radialDir, motionDir) * 0.5 + 0.5
|
||||
: 0.5;
|
||||
|
||||
float hoverLightMask = 0.0;
|
||||
if (hoverLightStrength > 0.0) {
|
||||
float lightRadiusPx = hoverLightRadius * logicalResolution.y;
|
||||
hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
|
||||
}
|
||||
|
||||
vec2 effectCoord = fragCoord;
|
||||
|
||||
vec2 cellIndex = floor(effectCoord / halftoneSize);
|
||||
vec2 sampleUv = clamp(
|
||||
(cellIndex + 0.5) * halftoneSize / logicalResolution,
|
||||
vec2(0.0),
|
||||
vec2(1.0)
|
||||
);
|
||||
vec2 cellUv = fract(effectCoord / halftoneSize);
|
||||
|
||||
vec4 sceneSample = texture2D(tScene, sampleUv);
|
||||
float mask = smoothstep(0.02, 0.08, sceneSample.a);
|
||||
float lightLift =
|
||||
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
|
||||
float bandRadius = clamp(
|
||||
(
|
||||
(
|
||||
sceneSample.r +
|
||||
sceneSample.g +
|
||||
sceneSample.b +
|
||||
s_3 * length(vec2(0.5))
|
||||
) *
|
||||
(1.0 / 3.0)
|
||||
) + lightLift,
|
||||
0.0,
|
||||
1.0
|
||||
) * 1.86 * 0.5;
|
||||
|
||||
float alpha = 0.0;
|
||||
if (bandRadius > 0.0001) {
|
||||
float signedDistance = lineSimpleEt(cellUv, bandRadius, s_4);
|
||||
float edge = 0.02;
|
||||
alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;
|
||||
}
|
||||
|
||||
float hoverStrength = clamp(hoverLightMask * hoverLightStrength, 0.0, 1.0);
|
||||
vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverStrength);
|
||||
vec3 color = activeDashColor * alpha;
|
||||
gl_FragColor = vec4(color, alpha);
|
||||
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`;
|
||||
|
||||
const imagePassthroughFragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tImage;
|
||||
uniform vec2 imageSize;
|
||||
uniform vec2 viewportSize;
|
||||
uniform float zoom;
|
||||
uniform float contrast;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
float imageAspect = imageSize.x / imageSize.y;
|
||||
float viewAspect = viewportSize.x / viewportSize.y;
|
||||
|
||||
vec2 uv = vUv;
|
||||
|
||||
if (imageAspect > viewAspect) {
|
||||
float scale = viewAspect / imageAspect;
|
||||
uv.x = (uv.x - 0.5) * scale + 0.5;
|
||||
} else {
|
||||
float scale = imageAspect / viewAspect;
|
||||
uv.y = (uv.y - 0.5) * scale + 0.5;
|
||||
}
|
||||
|
||||
uv = (uv - 0.5) / zoom + 0.5;
|
||||
uv.y = 1.0 - uv.y;
|
||||
|
||||
float inBounds = step(0.0, uv.x) * step(uv.x, 1.0)
|
||||
* step(0.0, uv.y) * step(uv.y, 1.0);
|
||||
|
||||
vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));
|
||||
vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);
|
||||
|
||||
gl_FragColor = vec4(contrastColor, inBounds);
|
||||
}
|
||||
`;
|
||||
|
||||
type Rect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type InteractionState = {
|
||||
hoverStrength: number;
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
pointerInside: boolean;
|
||||
pointerVelocityX: number;
|
||||
pointerVelocityY: number;
|
||||
smoothedMouseX: number;
|
||||
smoothedMouseY: number;
|
||||
};
|
||||
|
||||
function clampRectToViewport(
|
||||
rect: Rect,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
) {
|
||||
const minX = Math.max(rect.x, 0);
|
||||
const minY = Math.max(rect.y, 0);
|
||||
const maxX = Math.min(rect.x + rect.width, viewportWidth);
|
||||
const maxY = Math.min(rect.y + rect.height, viewportHeight);
|
||||
|
||||
if (maxX <= minX || maxY <= minY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
}
|
||||
|
||||
function getRectArea(rect: Rect | null) {
|
||||
if (!rect) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(rect.width, 0) * Math.max(rect.height, 0);
|
||||
}
|
||||
|
||||
function getImagePreviewZoom(previewDistance: number) {
|
||||
return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001);
|
||||
}
|
||||
|
||||
function getContainedImageRect({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
zoom,
|
||||
}: {
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
viewportHeight: number;
|
||||
viewportWidth: number;
|
||||
zoom: number;
|
||||
}) {
|
||||
if (
|
||||
imageWidth <= 0 ||
|
||||
imageHeight <= 0 ||
|
||||
viewportWidth <= 0 ||
|
||||
viewportHeight <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageAspect = imageWidth / imageHeight;
|
||||
const viewAspect = viewportWidth / viewportHeight;
|
||||
|
||||
let fittedWidth = viewportWidth;
|
||||
let fittedHeight = viewportHeight;
|
||||
|
||||
if (imageAspect > viewAspect) {
|
||||
fittedHeight = viewportWidth / imageAspect;
|
||||
} else {
|
||||
fittedWidth = viewportHeight * imageAspect;
|
||||
}
|
||||
|
||||
const scaledWidth = fittedWidth * zoom;
|
||||
const scaledHeight = fittedHeight * zoom;
|
||||
|
||||
return clampRectToViewport(
|
||||
{
|
||||
x: (viewportWidth - scaledWidth) * 0.5,
|
||||
y: (viewportHeight - scaledHeight) * 0.5,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
},
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
);
|
||||
}
|
||||
|
||||
function getFootprintScaleFromRects(
|
||||
currentRect: Rect | null,
|
||||
referenceRect: Rect | null,
|
||||
) {
|
||||
const currentArea = getRectArea(currentRect);
|
||||
const referenceArea = getRectArea(referenceRect);
|
||||
|
||||
if (currentArea <= 0 || referenceArea <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
Math.sqrt(currentArea / referenceArea),
|
||||
MIN_FOOTPRINT_SCALE,
|
||||
);
|
||||
}
|
||||
|
||||
function getImageFootprintScale({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
previewDistance,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
}: {
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
previewDistance: number;
|
||||
viewportHeight: number;
|
||||
viewportWidth: number;
|
||||
}) {
|
||||
const currentRect = getContainedImageRect({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
zoom: getImagePreviewZoom(previewDistance),
|
||||
});
|
||||
const referenceRect = getContainedImageRect({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
zoom: 1,
|
||||
});
|
||||
|
||||
return getFootprintScaleFromRects(currentRect, referenceRect);
|
||||
}
|
||||
|
||||
function createRenderTarget(width: number, height: number) {
|
||||
return new THREE.WebGLRenderTarget(width, height, {
|
||||
minFilter: THREE.LinearFilter,
|
||||
magFilter: THREE.LinearFilter,
|
||||
format: THREE.RGBAFormat,
|
||||
});
|
||||
}
|
||||
|
||||
function createInteractionState(): InteractionState {
|
||||
return {
|
||||
hoverStrength: 0,
|
||||
mouseX: 0.5,
|
||||
mouseY: 0.5,
|
||||
pointerInside: false,
|
||||
pointerVelocityX: 0,
|
||||
pointerVelocityY: 0,
|
||||
smoothedMouseX: 0.5,
|
||||
smoothedMouseY: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
function loadImage(imageUrl: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`Failed to load image: ${imageUrl}`));
|
||||
image.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
async function mountFastPathGradient({
|
||||
container,
|
||||
config,
|
||||
imageUrl,
|
||||
isExternallyActive,
|
||||
pointerTarget,
|
||||
}: {
|
||||
container: HTMLDivElement;
|
||||
config: ResolvedHalftoneBackdropConfig;
|
||||
imageUrl: string;
|
||||
isExternallyActive: () => boolean;
|
||||
pointerTarget?: HTMLElement | null;
|
||||
}) {
|
||||
const HOVER_STRENGTH_STOP_EPSILON = 0.001;
|
||||
const POINTER_POSITION_STOP_EPSILON = 0.001;
|
||||
const POINTER_VELOCITY_STOP_EPSILON = 0.0001;
|
||||
const image = await loadImage(imageUrl);
|
||||
const getWidth = () => Math.max(container.clientWidth, 1);
|
||||
const getHeight = () => Math.max(container.clientHeight, 1);
|
||||
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
|
||||
const getVirtualWidth = () =>
|
||||
Math.max(
|
||||
Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))),
|
||||
1,
|
||||
);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
|
||||
|
||||
const canvas = renderer.domElement;
|
||||
canvas.style.cursor = 'default';
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.touchAction = 'none';
|
||||
canvas.style.width = '100%';
|
||||
container.appendChild(canvas);
|
||||
const trackingElement = pointerTarget ?? canvas;
|
||||
|
||||
const imageTexture = new THREE.Texture(image);
|
||||
imageTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
imageTexture.needsUpdate = true;
|
||||
|
||||
const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
|
||||
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const imageMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
contrast: { value: config.imageContrast },
|
||||
imageSize: { value: new THREE.Vector2(image.width, image.height) },
|
||||
tImage: { value: imageTexture },
|
||||
viewportSize: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
zoom: { value: getImagePreviewZoom(config.previewDistance) },
|
||||
},
|
||||
fragmentShader: imagePassthroughFragmentShader,
|
||||
vertexShader: passThroughVertexShader,
|
||||
});
|
||||
|
||||
const blurHorizontalMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
dir: { value: new THREE.Vector2(1, 0) },
|
||||
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
|
||||
tInput: { value: null },
|
||||
},
|
||||
fragmentShader: blurFragmentShader,
|
||||
vertexShader: passThroughVertexShader,
|
||||
});
|
||||
|
||||
const blurVerticalMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
dir: { value: new THREE.Vector2(0, 1) },
|
||||
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
|
||||
tInput: { value: null },
|
||||
},
|
||||
fragmentShader: blurFragmentShader,
|
||||
vertexShader: passThroughVertexShader,
|
||||
});
|
||||
|
||||
const halftoneMaterial = new THREE.ShaderMaterial({
|
||||
fragmentShader: halftoneFragmentShader,
|
||||
transparent: true,
|
||||
uniforms: {
|
||||
dashColor: { value: new THREE.Color(config.dashColor) },
|
||||
effectResolution: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
footprintScale: { value: 1 },
|
||||
hoverDashColor: { value: new THREE.Color(config.hoverDashColor) },
|
||||
hoverLightRadius: { value: config.hoverLightRadius },
|
||||
hoverLightStrength: { value: 0 },
|
||||
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
|
||||
interactionVelocity: { value: new THREE.Vector2(0, 0) },
|
||||
logicalResolution: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
s_3: { value: config.halftonePower },
|
||||
s_4: { value: config.halftoneWidth },
|
||||
tScene: { value: sceneTarget.texture },
|
||||
tile: { value: config.halftoneScale },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
});
|
||||
|
||||
const fullScreenMesh = new THREE.Mesh(fullScreenGeometry, imageMaterial);
|
||||
const blurHorizontalMesh = new THREE.Mesh(
|
||||
fullScreenGeometry,
|
||||
blurHorizontalMaterial,
|
||||
);
|
||||
const blurVerticalMesh = new THREE.Mesh(
|
||||
fullScreenGeometry,
|
||||
blurVerticalMaterial,
|
||||
);
|
||||
const postProcessMesh = new THREE.Mesh(fullScreenGeometry, halftoneMaterial);
|
||||
|
||||
const imageScene = new THREE.Scene();
|
||||
imageScene.add(fullScreenMesh);
|
||||
|
||||
const blurHorizontalScene = new THREE.Scene();
|
||||
blurHorizontalScene.add(blurHorizontalMesh);
|
||||
|
||||
const blurVerticalScene = new THREE.Scene();
|
||||
blurVerticalScene.add(blurVerticalMesh);
|
||||
|
||||
const postScene = new THREE.Scene();
|
||||
postScene.add(postProcessMesh);
|
||||
|
||||
const updateViewportUniforms = (
|
||||
logicalWidth: number,
|
||||
logicalHeight: number,
|
||||
effectWidth: number,
|
||||
effectHeight: number,
|
||||
) => {
|
||||
blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
|
||||
blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
|
||||
halftoneMaterial.uniforms.effectResolution.value.set(
|
||||
effectWidth,
|
||||
effectHeight,
|
||||
);
|
||||
halftoneMaterial.uniforms.logicalResolution.value.set(
|
||||
logicalWidth,
|
||||
logicalHeight,
|
||||
);
|
||||
imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight);
|
||||
};
|
||||
|
||||
const getHalftoneScale = () =>
|
||||
getImageFootprintScale({
|
||||
imageHeight: image.height,
|
||||
imageWidth: image.width,
|
||||
previewDistance: config.previewDistance,
|
||||
viewportHeight: getVirtualHeight(),
|
||||
viewportWidth: getVirtualWidth(),
|
||||
});
|
||||
|
||||
const interaction = createInteractionState();
|
||||
let animationFrameId: number | null = null;
|
||||
let lastFrameTime: number | null = null;
|
||||
|
||||
const renderBackdrop = (deltaSeconds: number) => {
|
||||
const externalActive = isExternallyActive();
|
||||
const isHoverActive = externalActive || interaction.pointerInside;
|
||||
const targetHoverStrength = isHoverActive ? 1 : 0;
|
||||
const hoverEasing =
|
||||
1 -
|
||||
Math.exp(
|
||||
-deltaSeconds *
|
||||
(isHoverActive ? IMAGE_HOVER_FADE_IN : IMAGE_HOVER_FADE_OUT),
|
||||
);
|
||||
|
||||
interaction.hoverStrength +=
|
||||
(targetHoverStrength - interaction.hoverStrength) * hoverEasing;
|
||||
|
||||
if (
|
||||
Math.abs(targetHoverStrength - interaction.hoverStrength) <=
|
||||
HOVER_STRENGTH_STOP_EPSILON
|
||||
) {
|
||||
interaction.hoverStrength = targetHoverStrength;
|
||||
}
|
||||
|
||||
const targetMouseX =
|
||||
externalActive && !interaction.pointerInside
|
||||
? config.activeHoverX
|
||||
: interaction.mouseX;
|
||||
const targetMouseY =
|
||||
externalActive && !interaction.pointerInside
|
||||
? config.activeHoverY
|
||||
: interaction.mouseY;
|
||||
|
||||
interaction.smoothedMouseX +=
|
||||
(targetMouseX - interaction.smoothedMouseX) * IMAGE_POINTER_FOLLOW;
|
||||
interaction.smoothedMouseY +=
|
||||
(targetMouseY - interaction.smoothedMouseY) * IMAGE_POINTER_FOLLOW;
|
||||
|
||||
if (
|
||||
Math.abs(targetMouseX - interaction.smoothedMouseX) <=
|
||||
POINTER_POSITION_STOP_EPSILON
|
||||
) {
|
||||
interaction.smoothedMouseX = targetMouseX;
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(targetMouseY - interaction.smoothedMouseY) <=
|
||||
POINTER_POSITION_STOP_EPSILON
|
||||
) {
|
||||
interaction.smoothedMouseY = targetMouseY;
|
||||
}
|
||||
|
||||
interaction.pointerVelocityX *= IMAGE_POINTER_VELOCITY_DAMPING;
|
||||
interaction.pointerVelocityY *= IMAGE_POINTER_VELOCITY_DAMPING;
|
||||
|
||||
if (
|
||||
Math.abs(interaction.pointerVelocityX) <= POINTER_VELOCITY_STOP_EPSILON
|
||||
) {
|
||||
interaction.pointerVelocityX = 0;
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(interaction.pointerVelocityY) <= POINTER_VELOCITY_STOP_EPSILON
|
||||
) {
|
||||
interaction.pointerVelocityY = 0;
|
||||
}
|
||||
|
||||
halftoneMaterial.uniforms.interactionUv.value.set(
|
||||
interaction.smoothedMouseX,
|
||||
1 - interaction.smoothedMouseY,
|
||||
);
|
||||
halftoneMaterial.uniforms.interactionVelocity.value.set(
|
||||
interaction.pointerVelocityX * getVirtualWidth(),
|
||||
-interaction.pointerVelocityY * getVirtualHeight(),
|
||||
);
|
||||
halftoneMaterial.uniforms.hoverLightStrength.value =
|
||||
config.hoverLightIntensity * interaction.hoverStrength;
|
||||
halftoneMaterial.uniforms.hoverLightRadius.value =
|
||||
config.hoverHalftoneRadius + config.hoverLightRadius * 0.5;
|
||||
halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale();
|
||||
imageMaterial.uniforms.zoom.value = getImagePreviewZoom(
|
||||
config.previewDistance,
|
||||
);
|
||||
|
||||
renderer.setRenderTarget(sceneTarget);
|
||||
renderer.render(imageScene, orthographicCamera);
|
||||
|
||||
blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
|
||||
renderer.setRenderTarget(blurTargetA);
|
||||
renderer.render(blurHorizontalScene, orthographicCamera);
|
||||
|
||||
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
|
||||
renderer.setRenderTarget(blurTargetB);
|
||||
renderer.render(blurVerticalScene, orthographicCamera);
|
||||
|
||||
blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
|
||||
renderer.setRenderTarget(blurTargetA);
|
||||
renderer.render(blurHorizontalScene, orthographicCamera);
|
||||
|
||||
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
|
||||
renderer.setRenderTarget(blurTargetB);
|
||||
renderer.render(blurVerticalScene, orthographicCamera);
|
||||
|
||||
renderer.setRenderTarget(null);
|
||||
renderer.clear();
|
||||
renderer.render(postScene, orthographicCamera);
|
||||
|
||||
return (
|
||||
isHoverActive ||
|
||||
interaction.hoverStrength !== targetHoverStrength ||
|
||||
interaction.smoothedMouseX !== targetMouseX ||
|
||||
interaction.smoothedMouseY !== targetMouseY ||
|
||||
interaction.pointerVelocityX !== 0 ||
|
||||
interaction.pointerVelocityY !== 0
|
||||
);
|
||||
};
|
||||
|
||||
const runFrame = (timestamp: number) => {
|
||||
animationFrameId = null;
|
||||
|
||||
const deltaSeconds =
|
||||
lastFrameTime === null
|
||||
? 1 / 60
|
||||
: Math.min((timestamp - lastFrameTime) / 1000, 0.1);
|
||||
|
||||
lastFrameTime = timestamp;
|
||||
|
||||
if (renderBackdrop(deltaSeconds)) {
|
||||
animationFrameId = window.requestAnimationFrame(runFrame);
|
||||
return;
|
||||
}
|
||||
|
||||
lastFrameTime = null;
|
||||
};
|
||||
|
||||
const ensureAnimationLoop = () => {
|
||||
if (animationFrameId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(runFrame);
|
||||
};
|
||||
|
||||
const syncSize = () => {
|
||||
const virtualWidth = getVirtualWidth();
|
||||
const virtualHeight = getVirtualHeight();
|
||||
|
||||
renderer.setSize(virtualWidth, virtualHeight, false);
|
||||
sceneTarget.setSize(virtualWidth, virtualHeight);
|
||||
blurTargetA.setSize(virtualWidth, virtualHeight);
|
||||
blurTargetB.setSize(virtualWidth, virtualHeight);
|
||||
updateViewportUniforms(
|
||||
virtualWidth,
|
||||
virtualHeight,
|
||||
virtualWidth,
|
||||
virtualHeight,
|
||||
);
|
||||
|
||||
if (animationFrameId === null) {
|
||||
renderBackdrop(0);
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(syncSize);
|
||||
resizeObserver.observe(container);
|
||||
syncSize();
|
||||
|
||||
const updatePointerPosition = (
|
||||
event: PointerEvent,
|
||||
options?: { resetVelocity?: boolean },
|
||||
) => {
|
||||
const rect = trackingElement.getBoundingClientRect();
|
||||
const width = Math.max(rect.width, 1);
|
||||
const height = Math.max(rect.height, 1);
|
||||
const nextMouseX = THREE.MathUtils.clamp(
|
||||
(event.clientX - rect.left) / width,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const nextMouseY = THREE.MathUtils.clamp(
|
||||
(event.clientY - rect.top) / height,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const deltaX = nextMouseX - interaction.mouseX;
|
||||
const deltaY = nextMouseY - interaction.mouseY;
|
||||
|
||||
interaction.mouseX = nextMouseX;
|
||||
interaction.mouseY = nextMouseY;
|
||||
interaction.pointerInside =
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom;
|
||||
|
||||
if (options?.resetVelocity) {
|
||||
interaction.pointerVelocityX = 0;
|
||||
interaction.pointerVelocityY = 0;
|
||||
interaction.smoothedMouseX = nextMouseX;
|
||||
interaction.smoothedMouseY = nextMouseY;
|
||||
return;
|
||||
}
|
||||
|
||||
interaction.pointerVelocityX = deltaX;
|
||||
interaction.pointerVelocityY = deltaY;
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const shouldReset = !interaction.pointerInside;
|
||||
updatePointerPosition(
|
||||
event,
|
||||
shouldReset ? { resetVelocity: true } : undefined,
|
||||
);
|
||||
ensureAnimationLoop();
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
interaction.pointerInside = false;
|
||||
interaction.pointerVelocityX = 0;
|
||||
interaction.pointerVelocityY = 0;
|
||||
ensureAnimationLoop();
|
||||
};
|
||||
|
||||
trackingElement.addEventListener('pointermove', handlePointerMove);
|
||||
trackingElement.addEventListener('pointerleave', handlePointerLeave);
|
||||
|
||||
if (isExternallyActive()) {
|
||||
ensureAnimationLoop();
|
||||
}
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
if (animationFrameId !== null) {
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
|
||||
resizeObserver.disconnect();
|
||||
trackingElement.removeEventListener('pointermove', handlePointerMove);
|
||||
trackingElement.removeEventListener('pointerleave', handlePointerLeave);
|
||||
blurHorizontalMaterial.dispose();
|
||||
blurVerticalMaterial.dispose();
|
||||
halftoneMaterial.dispose();
|
||||
imageMaterial.dispose();
|
||||
imageTexture.dispose();
|
||||
fullScreenGeometry.dispose();
|
||||
sceneTarget.dispose();
|
||||
blurTargetA.dispose();
|
||||
blurTargetB.dispose();
|
||||
renderer.dispose();
|
||||
|
||||
if (canvas.parentNode === container) {
|
||||
container.removeChild(canvas);
|
||||
}
|
||||
},
|
||||
wake: ensureAnimationLoop,
|
||||
};
|
||||
}
|
||||
|
||||
const VisualMount = styled.div`
|
||||
background: transparent;
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type FastPathGradientBackdropProps = {
|
||||
active?: boolean;
|
||||
config?: HalftoneBackdropConfig;
|
||||
imageUrl?: string;
|
||||
pointerTargetRef?: RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
export function FastPathGradientBackdrop({
|
||||
active = false,
|
||||
config,
|
||||
imageUrl = DEFAULT_IMAGE_URL,
|
||||
pointerTargetRef,
|
||||
}: FastPathGradientBackdropProps) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const activeRef = useRef(active);
|
||||
const wakeRef = useRef<VoidFunction | null>(null);
|
||||
const resolvedConfig = {
|
||||
...DEFAULT_HALFTONE_BACKDROP_CONFIG,
|
||||
...config,
|
||||
};
|
||||
|
||||
activeRef.current = active;
|
||||
|
||||
useEffect(() => {
|
||||
const container = mountRef.current;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dispose: VoidFunction | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
void mountFastPathGradient({
|
||||
config: resolvedConfig,
|
||||
container,
|
||||
imageUrl,
|
||||
isExternallyActive: () => activeRef.current,
|
||||
pointerTarget: pointerTargetRef?.current,
|
||||
})
|
||||
.then((mountedGradient) => {
|
||||
if (cancelled) {
|
||||
mountedGradient.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
wakeRef.current = mountedGradient.wake;
|
||||
dispose = mountedGradient.dispose;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
wakeRef.current = null;
|
||||
dispose?.();
|
||||
};
|
||||
}, [
|
||||
imageUrl,
|
||||
resolvedConfig.dashColor,
|
||||
resolvedConfig.halftonePower,
|
||||
resolvedConfig.halftoneScale,
|
||||
resolvedConfig.halftoneWidth,
|
||||
resolvedConfig.hoverDashColor,
|
||||
resolvedConfig.hoverHalftoneRadius,
|
||||
resolvedConfig.hoverLightIntensity,
|
||||
resolvedConfig.hoverLightRadius,
|
||||
resolvedConfig.imageContrast,
|
||||
resolvedConfig.previewDistance,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
wakeRef.current?.();
|
||||
}, [active]);
|
||||
|
||||
return <VisualMount aria-hidden ref={mountRef} />;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { IconUsersGroup } from '@tabler/icons-react';
|
||||
import Image from 'next/image';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { type ReactNode, useRef, useState } from 'react';
|
||||
import { FastPathVisual } from './FastPathVisual';
|
||||
import { FamiliarInterfaceVisual } from './FamiliarInterfaceVisual';
|
||||
import { LiveDataVisual } from './LiveDataVisual';
|
||||
@@ -16,7 +16,7 @@ const FeatureCardContainer = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
background-color: ${theme.colors.primary.background[100]};
|
||||
background-color: ${theme.colors.secondary.background[5]};
|
||||
border: 1px solid ${theme.colors.primary.border[20]};
|
||||
border-radius: ${theme.radius(2)};
|
||||
overflow: hidden;
|
||||
@@ -26,18 +26,26 @@ const FeatureCardContainer = styled.div`
|
||||
|
||||
const CardImage = styled.div`
|
||||
width: 100%;
|
||||
height: 508px;
|
||||
height: 524px;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 8px 0;
|
||||
`;
|
||||
|
||||
const CardImageFrame = styled.div`
|
||||
background-color: ${theme.colors.primary.border[10]};
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
height: 100%;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const CardContent = styled.div`
|
||||
background-color: ${theme.colors.secondary.background[5]};
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: ${theme.spacing(3)};
|
||||
padding: ${theme.spacing(3.5)} ${theme.spacing(4)} ${theme.spacing(4)};
|
||||
row-gap: 8px;
|
||||
padding: ${theme.spacing(3)} ${theme.spacing(4)} ${theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const CardTitleRow = styled.div`
|
||||
@@ -111,18 +119,40 @@ function renderFeatureCardIcon(icon: ThreeCardsFeatureCardType['icon']) {
|
||||
|
||||
export function FeatureCard({ featureCard }: FeatureCardProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const imageFrameRef = useRef<HTMLDivElement>(null);
|
||||
let visual: ReactNode;
|
||||
|
||||
if ('illustration' in featureCard) {
|
||||
switch (featureCard.illustration) {
|
||||
case 'familiar-interface':
|
||||
visual = <FamiliarInterfaceVisual active={isHovered} />;
|
||||
visual = (
|
||||
<FamiliarInterfaceVisual
|
||||
active={isHovered}
|
||||
backgroundImageRotationDeg={featureCard.backgroundImageRotationDeg}
|
||||
backgroundImageSrc={featureCard.backgroundImageSrc}
|
||||
pointerTargetRef={imageFrameRef}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'fast-path':
|
||||
visual = <FastPathVisual />;
|
||||
visual = (
|
||||
<FastPathVisual
|
||||
active={isHovered}
|
||||
backgroundImageRotationDeg={featureCard.backgroundImageRotationDeg}
|
||||
backgroundImageSrc={featureCard.backgroundImageSrc}
|
||||
pointerTargetRef={imageFrameRef}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'live-data':
|
||||
visual = <LiveDataVisual active={isHovered} />;
|
||||
visual = (
|
||||
<LiveDataVisual
|
||||
active={isHovered}
|
||||
backgroundImageRotationDeg={featureCard.backgroundImageRotationDeg}
|
||||
backgroundImageSrc={featureCard.backgroundImageSrc}
|
||||
pointerTargetRef={imageFrameRef}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
@@ -145,7 +175,9 @@ export function FeatureCard({ featureCard }: FeatureCardProps) {
|
||||
setIsHovered(false);
|
||||
}}
|
||||
>
|
||||
<CardImage>{visual}</CardImage>
|
||||
<CardImage>
|
||||
<CardImageFrame ref={imageFrameRef}>{visual}</CardImageFrame>
|
||||
</CardImage>
|
||||
<CardContent>
|
||||
<CardTitleRow>
|
||||
<CardIcon>{renderFeatureCardIcon(featureCard.icon)}</CardIcon>
|
||||
|
||||
@@ -0,0 +1,923 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { type RefObject, useEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
const DEFAULT_IMAGE_URL =
|
||||
'/images/home/three-cards-feature/live-data-gradient.webp';
|
||||
const VIRTUAL_RENDER_HEIGHT = 1200;
|
||||
const REFERENCE_PREVIEW_DISTANCE = 4;
|
||||
const PREVIEW_DISTANCE = REFERENCE_PREVIEW_DISTANCE;
|
||||
const MIN_FOOTPRINT_SCALE = 0.001;
|
||||
const HALFTONE_SCALE = 28;
|
||||
const HALFTONE_POWER = -0.2;
|
||||
const HALFTONE_WIDTH = 0.4;
|
||||
const IMAGE_CONTRAST = 1;
|
||||
const DASH_COLOR = '#777';
|
||||
const HOVER_DASH_COLOR = '#777';
|
||||
const HOVER_LIGHT_INTENSITY = 0.85;
|
||||
const HOVER_LIGHT_RADIUS = 0.6;
|
||||
const HOVER_HALFTONE_RADIUS = 0.45;
|
||||
const IMAGE_POINTER_FOLLOW = 0.38;
|
||||
const IMAGE_POINTER_VELOCITY_DAMPING = 0.82;
|
||||
const IMAGE_HOVER_FADE_IN = 18;
|
||||
const IMAGE_HOVER_FADE_OUT = 7;
|
||||
|
||||
export type HalftoneBackdropConfig = {
|
||||
activeHoverX?: number;
|
||||
activeHoverY?: number;
|
||||
dashColor?: string;
|
||||
halftonePower?: number;
|
||||
halftoneScale?: number;
|
||||
halftoneWidth?: number;
|
||||
hoverDashColor?: string;
|
||||
hoverHalftoneRadius?: number;
|
||||
hoverLightIntensity?: number;
|
||||
hoverLightRadius?: number;
|
||||
imageContrast?: number;
|
||||
previewDistance?: number;
|
||||
};
|
||||
|
||||
type ResolvedHalftoneBackdropConfig = Required<HalftoneBackdropConfig>;
|
||||
|
||||
const DEFAULT_HALFTONE_BACKDROP_CONFIG: ResolvedHalftoneBackdropConfig = {
|
||||
activeHoverX: 0.16,
|
||||
activeHoverY: 0.46,
|
||||
dashColor: DASH_COLOR,
|
||||
halftonePower: HALFTONE_POWER,
|
||||
halftoneScale: HALFTONE_SCALE,
|
||||
halftoneWidth: HALFTONE_WIDTH,
|
||||
hoverDashColor: HOVER_DASH_COLOR,
|
||||
hoverHalftoneRadius: HOVER_HALFTONE_RADIUS,
|
||||
hoverLightIntensity: HOVER_LIGHT_INTENSITY,
|
||||
hoverLightRadius: HOVER_LIGHT_RADIUS,
|
||||
imageContrast: IMAGE_CONTRAST,
|
||||
previewDistance: PREVIEW_DISTANCE,
|
||||
};
|
||||
|
||||
const passThroughVertexShader = `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const blurFragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tInput;
|
||||
uniform vec2 dir;
|
||||
uniform vec2 res;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec4 sum = vec4(0.0);
|
||||
vec2 px = dir / res;
|
||||
|
||||
float w[5];
|
||||
w[0] = 0.227027;
|
||||
w[1] = 0.1945946;
|
||||
w[2] = 0.1216216;
|
||||
w[3] = 0.054054;
|
||||
w[4] = 0.016216;
|
||||
|
||||
sum += texture2D(tInput, vUv) * w[0];
|
||||
|
||||
for (int i = 1; i < 5; i++) {
|
||||
float fi = float(i) * 3.0;
|
||||
sum += texture2D(tInput, vUv + px * fi) * w[i];
|
||||
sum += texture2D(tInput, vUv - px * fi) * w[i];
|
||||
}
|
||||
|
||||
gl_FragColor = sum;
|
||||
}
|
||||
`;
|
||||
|
||||
const halftoneFragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tScene;
|
||||
uniform vec2 effectResolution;
|
||||
uniform vec2 logicalResolution;
|
||||
uniform float tile;
|
||||
uniform float s_3;
|
||||
uniform float s_4;
|
||||
uniform vec3 dashColor;
|
||||
uniform vec3 hoverDashColor;
|
||||
uniform float footprintScale;
|
||||
uniform vec2 interactionUv;
|
||||
uniform vec2 interactionVelocity;
|
||||
uniform float hoverLightStrength;
|
||||
uniform float hoverLightRadius;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
float distSegment(in vec2 p, in vec2 a, in vec2 b) {
|
||||
vec2 pa = p - a;
|
||||
vec2 ba = b - a;
|
||||
float denom = max(dot(ba, ba), 0.000001);
|
||||
float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);
|
||||
return length(pa - ba * h);
|
||||
}
|
||||
|
||||
float lineSimpleEt(in vec2 p, in float r, in float thickness) {
|
||||
vec2 a = vec2(0.5) + vec2(-r, 0.0);
|
||||
vec2 b = vec2(0.5) + vec2(r, 0.0);
|
||||
float distToSegment = distSegment(p, a, b);
|
||||
float halfThickness = thickness * r;
|
||||
return distToSegment - halfThickness;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 fragCoord =
|
||||
(gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;
|
||||
float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);
|
||||
vec2 pointerPx = interactionUv * logicalResolution;
|
||||
vec2 fragDelta = fragCoord - pointerPx;
|
||||
float fragDist = length(fragDelta);
|
||||
vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
|
||||
float velocityMagnitude = length(interactionVelocity);
|
||||
vec2 motionDir = velocityMagnitude > 0.001
|
||||
? interactionVelocity / velocityMagnitude
|
||||
: vec2(0.0, 0.0);
|
||||
float motionBias = velocityMagnitude > 0.001
|
||||
? dot(-radialDir, motionDir) * 0.5 + 0.5
|
||||
: 0.5;
|
||||
|
||||
float hoverLightMask = 0.0;
|
||||
if (hoverLightStrength > 0.0) {
|
||||
float lightRadiusPx = hoverLightRadius * logicalResolution.y;
|
||||
hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
|
||||
}
|
||||
|
||||
vec2 effectCoord = fragCoord;
|
||||
|
||||
vec2 cellIndex = floor(effectCoord / halftoneSize);
|
||||
vec2 sampleUv = clamp(
|
||||
(cellIndex + 0.5) * halftoneSize / logicalResolution,
|
||||
vec2(0.0),
|
||||
vec2(1.0)
|
||||
);
|
||||
vec2 cellUv = fract(effectCoord / halftoneSize);
|
||||
|
||||
vec4 sceneSample = texture2D(tScene, sampleUv);
|
||||
float mask = smoothstep(0.02, 0.08, sceneSample.a);
|
||||
float lightLift =
|
||||
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
|
||||
float bandRadius = clamp(
|
||||
(
|
||||
(
|
||||
sceneSample.r +
|
||||
sceneSample.g +
|
||||
sceneSample.b +
|
||||
s_3 * length(vec2(0.5))
|
||||
) *
|
||||
(1.0 / 3.0)
|
||||
) + lightLift,
|
||||
0.0,
|
||||
1.0
|
||||
) * 1.86 * 0.5;
|
||||
|
||||
float alpha = 0.0;
|
||||
if (bandRadius > 0.0001) {
|
||||
float signedDistance = lineSimpleEt(cellUv, bandRadius, s_4);
|
||||
float edge = 0.02;
|
||||
alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;
|
||||
}
|
||||
|
||||
float hoverStrength = clamp(hoverLightMask * hoverLightStrength, 0.0, 1.0);
|
||||
vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverStrength);
|
||||
vec3 color = activeDashColor * alpha;
|
||||
gl_FragColor = vec4(color, alpha);
|
||||
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`;
|
||||
|
||||
const imagePassthroughFragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tImage;
|
||||
uniform vec2 imageSize;
|
||||
uniform vec2 viewportSize;
|
||||
uniform float zoom;
|
||||
uniform float contrast;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
float imageAspect = imageSize.x / imageSize.y;
|
||||
float viewAspect = viewportSize.x / viewportSize.y;
|
||||
|
||||
vec2 uv = vUv;
|
||||
|
||||
if (imageAspect > viewAspect) {
|
||||
float scale = viewAspect / imageAspect;
|
||||
uv.x = (uv.x - 0.5) * scale + 0.5;
|
||||
} else {
|
||||
float scale = imageAspect / viewAspect;
|
||||
uv.y = (uv.y - 0.5) * scale + 0.5;
|
||||
}
|
||||
|
||||
uv = (uv - 0.5) / zoom + 0.5;
|
||||
|
||||
float inBounds = step(0.0, uv.x) * step(uv.x, 1.0)
|
||||
* step(0.0, uv.y) * step(uv.y, 1.0);
|
||||
|
||||
vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));
|
||||
vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);
|
||||
|
||||
gl_FragColor = vec4(contrastColor, inBounds);
|
||||
}
|
||||
`;
|
||||
|
||||
type Rect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type InteractionState = {
|
||||
hoverStrength: number;
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
pointerInside: boolean;
|
||||
pointerVelocityX: number;
|
||||
pointerVelocityY: number;
|
||||
smoothedMouseX: number;
|
||||
smoothedMouseY: number;
|
||||
};
|
||||
|
||||
function clampRectToViewport(
|
||||
rect: Rect,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
) {
|
||||
const minX = Math.max(rect.x, 0);
|
||||
const minY = Math.max(rect.y, 0);
|
||||
const maxX = Math.min(rect.x + rect.width, viewportWidth);
|
||||
const maxY = Math.min(rect.y + rect.height, viewportHeight);
|
||||
|
||||
if (maxX <= minX || maxY <= minY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
}
|
||||
|
||||
function getRectArea(rect: Rect | null) {
|
||||
if (!rect) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(rect.width, 0) * Math.max(rect.height, 0);
|
||||
}
|
||||
|
||||
function getImagePreviewZoom(previewDistance: number) {
|
||||
return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001);
|
||||
}
|
||||
|
||||
function getContainedImageRect({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
zoom,
|
||||
}: {
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
viewportHeight: number;
|
||||
viewportWidth: number;
|
||||
zoom: number;
|
||||
}) {
|
||||
if (
|
||||
imageWidth <= 0 ||
|
||||
imageHeight <= 0 ||
|
||||
viewportWidth <= 0 ||
|
||||
viewportHeight <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageAspect = imageWidth / imageHeight;
|
||||
const viewAspect = viewportWidth / viewportHeight;
|
||||
|
||||
let fittedWidth = viewportWidth;
|
||||
let fittedHeight = viewportHeight;
|
||||
|
||||
if (imageAspect > viewAspect) {
|
||||
fittedHeight = viewportWidth / imageAspect;
|
||||
} else {
|
||||
fittedWidth = viewportHeight * imageAspect;
|
||||
}
|
||||
|
||||
const scaledWidth = fittedWidth * zoom;
|
||||
const scaledHeight = fittedHeight * zoom;
|
||||
|
||||
return clampRectToViewport(
|
||||
{
|
||||
x: (viewportWidth - scaledWidth) * 0.5,
|
||||
y: (viewportHeight - scaledHeight) * 0.5,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
},
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
);
|
||||
}
|
||||
|
||||
function getFootprintScaleFromRects(
|
||||
currentRect: Rect | null,
|
||||
referenceRect: Rect | null,
|
||||
) {
|
||||
const currentArea = getRectArea(currentRect);
|
||||
const referenceArea = getRectArea(referenceRect);
|
||||
|
||||
if (currentArea <= 0 || referenceArea <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
Math.sqrt(currentArea / referenceArea),
|
||||
MIN_FOOTPRINT_SCALE,
|
||||
);
|
||||
}
|
||||
|
||||
function getImageFootprintScale({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
previewDistance,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
}: {
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
previewDistance: number;
|
||||
viewportHeight: number;
|
||||
viewportWidth: number;
|
||||
}) {
|
||||
const currentRect = getContainedImageRect({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
zoom: getImagePreviewZoom(previewDistance),
|
||||
});
|
||||
const referenceRect = getContainedImageRect({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
zoom: 1,
|
||||
});
|
||||
|
||||
return getFootprintScaleFromRects(currentRect, referenceRect);
|
||||
}
|
||||
|
||||
function createRenderTarget(width: number, height: number) {
|
||||
return new THREE.WebGLRenderTarget(width, height, {
|
||||
minFilter: THREE.LinearFilter,
|
||||
magFilter: THREE.LinearFilter,
|
||||
format: THREE.RGBAFormat,
|
||||
});
|
||||
}
|
||||
|
||||
function createInteractionState(): InteractionState {
|
||||
return {
|
||||
hoverStrength: 0,
|
||||
mouseX: 0.5,
|
||||
mouseY: 0.5,
|
||||
pointerInside: false,
|
||||
pointerVelocityX: 0,
|
||||
pointerVelocityY: 0,
|
||||
smoothedMouseX: 0.5,
|
||||
smoothedMouseY: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
function loadImage(imageUrl: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`Failed to load image: ${imageUrl}`));
|
||||
image.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
async function mountLiveDataGradient({
|
||||
container,
|
||||
config,
|
||||
imageUrl,
|
||||
isExternallyActive,
|
||||
pointerTarget,
|
||||
}: {
|
||||
container: HTMLDivElement;
|
||||
config: ResolvedHalftoneBackdropConfig;
|
||||
imageUrl: string;
|
||||
isExternallyActive: () => boolean;
|
||||
pointerTarget?: HTMLElement | null;
|
||||
}) {
|
||||
const HOVER_STRENGTH_STOP_EPSILON = 0.001;
|
||||
const POINTER_POSITION_STOP_EPSILON = 0.001;
|
||||
const POINTER_VELOCITY_STOP_EPSILON = 0.0001;
|
||||
const image = await loadImage(imageUrl);
|
||||
const getWidth = () => Math.max(container.clientWidth, 1);
|
||||
const getHeight = () => Math.max(container.clientHeight, 1);
|
||||
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
|
||||
const getVirtualWidth = () =>
|
||||
Math.max(
|
||||
Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))),
|
||||
1,
|
||||
);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
|
||||
|
||||
const canvas = renderer.domElement;
|
||||
canvas.style.cursor = 'default';
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.touchAction = 'none';
|
||||
canvas.style.width = '100%';
|
||||
container.appendChild(canvas);
|
||||
const trackingElement = pointerTarget ?? canvas;
|
||||
|
||||
const imageTexture = new THREE.Texture(image);
|
||||
imageTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
imageTexture.needsUpdate = true;
|
||||
|
||||
const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
|
||||
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const imageMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
contrast: { value: config.imageContrast },
|
||||
imageSize: { value: new THREE.Vector2(image.width, image.height) },
|
||||
tImage: { value: imageTexture },
|
||||
viewportSize: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
zoom: { value: getImagePreviewZoom(config.previewDistance) },
|
||||
},
|
||||
fragmentShader: imagePassthroughFragmentShader,
|
||||
vertexShader: passThroughVertexShader,
|
||||
});
|
||||
|
||||
const blurHorizontalMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
dir: { value: new THREE.Vector2(1, 0) },
|
||||
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
|
||||
tInput: { value: null },
|
||||
},
|
||||
fragmentShader: blurFragmentShader,
|
||||
vertexShader: passThroughVertexShader,
|
||||
});
|
||||
|
||||
const blurVerticalMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
dir: { value: new THREE.Vector2(0, 1) },
|
||||
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
|
||||
tInput: { value: null },
|
||||
},
|
||||
fragmentShader: blurFragmentShader,
|
||||
vertexShader: passThroughVertexShader,
|
||||
});
|
||||
|
||||
const halftoneMaterial = new THREE.ShaderMaterial({
|
||||
fragmentShader: halftoneFragmentShader,
|
||||
transparent: true,
|
||||
uniforms: {
|
||||
dashColor: { value: new THREE.Color(config.dashColor) },
|
||||
effectResolution: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
footprintScale: { value: 1 },
|
||||
hoverDashColor: { value: new THREE.Color(config.hoverDashColor) },
|
||||
hoverLightRadius: { value: config.hoverLightRadius },
|
||||
hoverLightStrength: { value: 0 },
|
||||
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
|
||||
interactionVelocity: { value: new THREE.Vector2(0, 0) },
|
||||
logicalResolution: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
s_3: { value: config.halftonePower },
|
||||
s_4: { value: config.halftoneWidth },
|
||||
tScene: { value: sceneTarget.texture },
|
||||
tile: { value: config.halftoneScale },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
});
|
||||
|
||||
const fullScreenMesh = new THREE.Mesh(fullScreenGeometry, imageMaterial);
|
||||
const blurHorizontalMesh = new THREE.Mesh(
|
||||
fullScreenGeometry,
|
||||
blurHorizontalMaterial,
|
||||
);
|
||||
const blurVerticalMesh = new THREE.Mesh(
|
||||
fullScreenGeometry,
|
||||
blurVerticalMaterial,
|
||||
);
|
||||
const postProcessMesh = new THREE.Mesh(fullScreenGeometry, halftoneMaterial);
|
||||
|
||||
const imageScene = new THREE.Scene();
|
||||
imageScene.add(fullScreenMesh);
|
||||
|
||||
const blurHorizontalScene = new THREE.Scene();
|
||||
blurHorizontalScene.add(blurHorizontalMesh);
|
||||
|
||||
const blurVerticalScene = new THREE.Scene();
|
||||
blurVerticalScene.add(blurVerticalMesh);
|
||||
|
||||
const postScene = new THREE.Scene();
|
||||
postScene.add(postProcessMesh);
|
||||
|
||||
const updateViewportUniforms = (
|
||||
logicalWidth: number,
|
||||
logicalHeight: number,
|
||||
effectWidth: number,
|
||||
effectHeight: number,
|
||||
) => {
|
||||
blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
|
||||
blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
|
||||
halftoneMaterial.uniforms.effectResolution.value.set(
|
||||
effectWidth,
|
||||
effectHeight,
|
||||
);
|
||||
halftoneMaterial.uniforms.logicalResolution.value.set(
|
||||
logicalWidth,
|
||||
logicalHeight,
|
||||
);
|
||||
imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight);
|
||||
};
|
||||
|
||||
const getHalftoneScale = () =>
|
||||
getImageFootprintScale({
|
||||
imageHeight: image.height,
|
||||
imageWidth: image.width,
|
||||
previewDistance: config.previewDistance,
|
||||
viewportHeight: getVirtualHeight(),
|
||||
viewportWidth: getVirtualWidth(),
|
||||
});
|
||||
|
||||
const interaction = createInteractionState();
|
||||
let animationFrameId: number | null = null;
|
||||
let lastFrameTime: number | null = null;
|
||||
|
||||
const renderBackdrop = (deltaSeconds: number) => {
|
||||
const externalActive = isExternallyActive();
|
||||
const isHoverActive = externalActive || interaction.pointerInside;
|
||||
const targetHoverStrength = isHoverActive ? 1 : 0;
|
||||
const hoverEasing =
|
||||
1 -
|
||||
Math.exp(
|
||||
-deltaSeconds *
|
||||
(isHoverActive ? IMAGE_HOVER_FADE_IN : IMAGE_HOVER_FADE_OUT),
|
||||
);
|
||||
|
||||
interaction.hoverStrength +=
|
||||
(targetHoverStrength - interaction.hoverStrength) * hoverEasing;
|
||||
|
||||
if (
|
||||
Math.abs(targetHoverStrength - interaction.hoverStrength) <=
|
||||
HOVER_STRENGTH_STOP_EPSILON
|
||||
) {
|
||||
interaction.hoverStrength = targetHoverStrength;
|
||||
}
|
||||
|
||||
const targetMouseX =
|
||||
externalActive && !interaction.pointerInside
|
||||
? config.activeHoverX
|
||||
: interaction.mouseX;
|
||||
const targetMouseY =
|
||||
externalActive && !interaction.pointerInside
|
||||
? config.activeHoverY
|
||||
: interaction.mouseY;
|
||||
|
||||
interaction.smoothedMouseX +=
|
||||
(targetMouseX - interaction.smoothedMouseX) * IMAGE_POINTER_FOLLOW;
|
||||
interaction.smoothedMouseY +=
|
||||
(targetMouseY - interaction.smoothedMouseY) * IMAGE_POINTER_FOLLOW;
|
||||
|
||||
if (
|
||||
Math.abs(targetMouseX - interaction.smoothedMouseX) <=
|
||||
POINTER_POSITION_STOP_EPSILON
|
||||
) {
|
||||
interaction.smoothedMouseX = targetMouseX;
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(targetMouseY - interaction.smoothedMouseY) <=
|
||||
POINTER_POSITION_STOP_EPSILON
|
||||
) {
|
||||
interaction.smoothedMouseY = targetMouseY;
|
||||
}
|
||||
|
||||
interaction.pointerVelocityX *= IMAGE_POINTER_VELOCITY_DAMPING;
|
||||
interaction.pointerVelocityY *= IMAGE_POINTER_VELOCITY_DAMPING;
|
||||
|
||||
if (
|
||||
Math.abs(interaction.pointerVelocityX) <= POINTER_VELOCITY_STOP_EPSILON
|
||||
) {
|
||||
interaction.pointerVelocityX = 0;
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(interaction.pointerVelocityY) <= POINTER_VELOCITY_STOP_EPSILON
|
||||
) {
|
||||
interaction.pointerVelocityY = 0;
|
||||
}
|
||||
|
||||
halftoneMaterial.uniforms.interactionUv.value.set(
|
||||
interaction.smoothedMouseX,
|
||||
1 - interaction.smoothedMouseY,
|
||||
);
|
||||
halftoneMaterial.uniforms.interactionVelocity.value.set(
|
||||
interaction.pointerVelocityX * getVirtualWidth(),
|
||||
-interaction.pointerVelocityY * getVirtualHeight(),
|
||||
);
|
||||
halftoneMaterial.uniforms.hoverLightStrength.value =
|
||||
config.hoverLightIntensity * interaction.hoverStrength;
|
||||
halftoneMaterial.uniforms.hoverLightRadius.value =
|
||||
config.hoverHalftoneRadius + config.hoverLightRadius * 0.5;
|
||||
halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale();
|
||||
imageMaterial.uniforms.zoom.value = getImagePreviewZoom(
|
||||
config.previewDistance,
|
||||
);
|
||||
|
||||
renderer.setRenderTarget(sceneTarget);
|
||||
renderer.render(imageScene, orthographicCamera);
|
||||
|
||||
blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
|
||||
renderer.setRenderTarget(blurTargetA);
|
||||
renderer.render(blurHorizontalScene, orthographicCamera);
|
||||
|
||||
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
|
||||
renderer.setRenderTarget(blurTargetB);
|
||||
renderer.render(blurVerticalScene, orthographicCamera);
|
||||
|
||||
blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
|
||||
renderer.setRenderTarget(blurTargetA);
|
||||
renderer.render(blurHorizontalScene, orthographicCamera);
|
||||
|
||||
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
|
||||
renderer.setRenderTarget(blurTargetB);
|
||||
renderer.render(blurVerticalScene, orthographicCamera);
|
||||
|
||||
renderer.setRenderTarget(null);
|
||||
renderer.clear();
|
||||
renderer.render(postScene, orthographicCamera);
|
||||
|
||||
return (
|
||||
isHoverActive ||
|
||||
interaction.hoverStrength !== targetHoverStrength ||
|
||||
interaction.smoothedMouseX !== targetMouseX ||
|
||||
interaction.smoothedMouseY !== targetMouseY ||
|
||||
interaction.pointerVelocityX !== 0 ||
|
||||
interaction.pointerVelocityY !== 0
|
||||
);
|
||||
};
|
||||
|
||||
const runFrame = (timestamp: number) => {
|
||||
animationFrameId = null;
|
||||
|
||||
const deltaSeconds =
|
||||
lastFrameTime === null
|
||||
? 1 / 60
|
||||
: Math.min((timestamp - lastFrameTime) / 1000, 0.1);
|
||||
|
||||
lastFrameTime = timestamp;
|
||||
|
||||
if (renderBackdrop(deltaSeconds)) {
|
||||
animationFrameId = window.requestAnimationFrame(runFrame);
|
||||
return;
|
||||
}
|
||||
|
||||
lastFrameTime = null;
|
||||
};
|
||||
|
||||
const ensureAnimationLoop = () => {
|
||||
if (animationFrameId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(runFrame);
|
||||
};
|
||||
|
||||
const syncSize = () => {
|
||||
const virtualWidth = getVirtualWidth();
|
||||
const virtualHeight = getVirtualHeight();
|
||||
|
||||
renderer.setSize(virtualWidth, virtualHeight, false);
|
||||
sceneTarget.setSize(virtualWidth, virtualHeight);
|
||||
blurTargetA.setSize(virtualWidth, virtualHeight);
|
||||
blurTargetB.setSize(virtualWidth, virtualHeight);
|
||||
updateViewportUniforms(
|
||||
virtualWidth,
|
||||
virtualHeight,
|
||||
virtualWidth,
|
||||
virtualHeight,
|
||||
);
|
||||
|
||||
if (animationFrameId === null) {
|
||||
renderBackdrop(0);
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(syncSize);
|
||||
resizeObserver.observe(container);
|
||||
syncSize();
|
||||
|
||||
const updatePointerPosition = (
|
||||
event: PointerEvent,
|
||||
options?: { resetVelocity?: boolean },
|
||||
) => {
|
||||
const rect = trackingElement.getBoundingClientRect();
|
||||
const width = Math.max(rect.width, 1);
|
||||
const height = Math.max(rect.height, 1);
|
||||
const nextMouseX = THREE.MathUtils.clamp(
|
||||
(event.clientX - rect.left) / width,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const nextMouseY = THREE.MathUtils.clamp(
|
||||
(event.clientY - rect.top) / height,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const deltaX = nextMouseX - interaction.mouseX;
|
||||
const deltaY = nextMouseY - interaction.mouseY;
|
||||
|
||||
interaction.mouseX = nextMouseX;
|
||||
interaction.mouseY = nextMouseY;
|
||||
interaction.pointerInside =
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom;
|
||||
|
||||
if (options?.resetVelocity) {
|
||||
interaction.pointerVelocityX = 0;
|
||||
interaction.pointerVelocityY = 0;
|
||||
interaction.smoothedMouseX = nextMouseX;
|
||||
interaction.smoothedMouseY = nextMouseY;
|
||||
return;
|
||||
}
|
||||
|
||||
interaction.pointerVelocityX = deltaX;
|
||||
interaction.pointerVelocityY = deltaY;
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const shouldReset = !interaction.pointerInside;
|
||||
updatePointerPosition(
|
||||
event,
|
||||
shouldReset ? { resetVelocity: true } : undefined,
|
||||
);
|
||||
ensureAnimationLoop();
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
interaction.pointerInside = false;
|
||||
interaction.pointerVelocityX = 0;
|
||||
interaction.pointerVelocityY = 0;
|
||||
ensureAnimationLoop();
|
||||
};
|
||||
|
||||
trackingElement.addEventListener('pointermove', handlePointerMove);
|
||||
trackingElement.addEventListener('pointerleave', handlePointerLeave);
|
||||
|
||||
if (isExternallyActive()) {
|
||||
ensureAnimationLoop();
|
||||
}
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
if (animationFrameId !== null) {
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
|
||||
resizeObserver.disconnect();
|
||||
trackingElement.removeEventListener('pointermove', handlePointerMove);
|
||||
trackingElement.removeEventListener('pointerleave', handlePointerLeave);
|
||||
blurHorizontalMaterial.dispose();
|
||||
blurVerticalMaterial.dispose();
|
||||
halftoneMaterial.dispose();
|
||||
imageMaterial.dispose();
|
||||
imageTexture.dispose();
|
||||
fullScreenGeometry.dispose();
|
||||
sceneTarget.dispose();
|
||||
blurTargetA.dispose();
|
||||
blurTargetB.dispose();
|
||||
renderer.dispose();
|
||||
|
||||
if (canvas.parentNode === container) {
|
||||
container.removeChild(canvas);
|
||||
}
|
||||
},
|
||||
wake: ensureAnimationLoop,
|
||||
};
|
||||
}
|
||||
|
||||
const VisualMount = styled.div`
|
||||
background: transparent;
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type LiveDataGradientBackdropProps = {
|
||||
active?: boolean;
|
||||
config?: HalftoneBackdropConfig;
|
||||
imageUrl?: string;
|
||||
pointerTargetRef?: RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
export function LiveDataGradientBackdrop({
|
||||
active = false,
|
||||
config,
|
||||
imageUrl = DEFAULT_IMAGE_URL,
|
||||
pointerTargetRef,
|
||||
}: LiveDataGradientBackdropProps) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const activeRef = useRef(active);
|
||||
const wakeRef = useRef<VoidFunction | null>(null);
|
||||
const resolvedConfig = {
|
||||
...DEFAULT_HALFTONE_BACKDROP_CONFIG,
|
||||
...config,
|
||||
};
|
||||
|
||||
activeRef.current = active;
|
||||
|
||||
useEffect(() => {
|
||||
const container = mountRef.current;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dispose: VoidFunction | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
void mountLiveDataGradient({
|
||||
config: resolvedConfig,
|
||||
container,
|
||||
imageUrl,
|
||||
isExternallyActive: () => activeRef.current,
|
||||
pointerTarget: pointerTargetRef?.current,
|
||||
})
|
||||
.then((mountedGradient) => {
|
||||
if (cancelled) {
|
||||
mountedGradient.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
wakeRef.current = mountedGradient.wake;
|
||||
dispose = mountedGradient.dispose;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
wakeRef.current = null;
|
||||
dispose?.();
|
||||
};
|
||||
}, [
|
||||
imageUrl,
|
||||
resolvedConfig.dashColor,
|
||||
resolvedConfig.halftonePower,
|
||||
resolvedConfig.halftoneScale,
|
||||
resolvedConfig.halftoneWidth,
|
||||
resolvedConfig.hoverDashColor,
|
||||
resolvedConfig.hoverHalftoneRadius,
|
||||
resolvedConfig.hoverLightIntensity,
|
||||
resolvedConfig.hoverLightRadius,
|
||||
resolvedConfig.imageContrast,
|
||||
resolvedConfig.previewDistance,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
wakeRef.current?.();
|
||||
}, [active]);
|
||||
|
||||
return <VisualMount aria-hidden ref={mountRef} />;
|
||||
}
|
||||
@@ -15,15 +15,19 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { Chip, ChipVariant } from '@/sections/Hero/components/HomeVisual/homeVisualChip';
|
||||
import {
|
||||
Chip,
|
||||
ChipVariant,
|
||||
} from '@/sections/Hero/components/HomeVisual/homeVisualChip';
|
||||
import { VISUAL_TOKENS } from '@/sections/Hero/components/HomeVisual/homeVisualTokens';
|
||||
|
||||
const APP_FONT = VISUAL_TOKENS.font.family;
|
||||
const TABLE_CELL_HORIZONTAL_PADDING = 8;
|
||||
const TABLER_STROKE = 1.6;
|
||||
const DEFAULT_TABLE_WIDTH = 520;
|
||||
const ROW_ENTER_DURATION_MS = 760;
|
||||
const ROW_ENTER_STAGGER_MS = 160;
|
||||
|
||||
const COLORS = {
|
||||
accentBorder: VISUAL_TOKENS.border.color.blue,
|
||||
@@ -47,11 +51,25 @@ const COLORS = {
|
||||
|
||||
const TABLE_COLUMNS = [
|
||||
{ id: 'company', isFirstColumn: true, label: 'Companies', width: 180 },
|
||||
{ id: 'type', isFirstColumn: false, label: 'Type', width: 160 },
|
||||
{ id: 'type', isFirstColumn: false, label: 'Type', width: 132 },
|
||||
{ id: 'url', isFirstColumn: false, label: 'Domain', width: 150 },
|
||||
] as const;
|
||||
|
||||
const TABLE_ROWS = [
|
||||
type TableRow = {
|
||||
company: string;
|
||||
domain: string;
|
||||
isNew?: boolean;
|
||||
logoSrc: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const BASE_TABLE_ROWS: ReadonlyArray<TableRow> = [
|
||||
{
|
||||
company: 'Anthropic',
|
||||
domain: 'anthropic.com',
|
||||
logoSrc: SHARED_COMPANY_LOGO_URLS.anthropic,
|
||||
status: 'Customer',
|
||||
},
|
||||
{
|
||||
company: 'Slack',
|
||||
domain: 'slack.com',
|
||||
@@ -70,7 +88,31 @@ const TABLE_ROWS = [
|
||||
logoSrc: SHARED_COMPANY_LOGO_URLS.sequoia,
|
||||
status: 'Customer',
|
||||
},
|
||||
] as const;
|
||||
{
|
||||
company: 'Cursor',
|
||||
domain: 'cursor.com',
|
||||
logoSrc: SHARED_COMPANY_LOGO_URLS.cursor,
|
||||
status: 'Customer',
|
||||
},
|
||||
];
|
||||
|
||||
const EXPANDED_TABLE_ROWS: ReadonlyArray<TableRow> = [
|
||||
...BASE_TABLE_ROWS,
|
||||
{
|
||||
company: 'Twenty',
|
||||
domain: 'twenty.com',
|
||||
isNew: true,
|
||||
logoSrc: SHARED_COMPANY_LOGO_URLS.twenty,
|
||||
status: 'Customer',
|
||||
},
|
||||
{
|
||||
company: 'Linear',
|
||||
domain: 'linear.app',
|
||||
isNew: true,
|
||||
logoSrc: SHARED_COMPANY_LOGO_URLS.linear,
|
||||
status: 'Customer',
|
||||
},
|
||||
];
|
||||
|
||||
const TableShell = styled.div`
|
||||
display: flex;
|
||||
@@ -81,19 +123,6 @@ const TableShell = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const GripRail = styled.div`
|
||||
background: ${COLORS.background};
|
||||
display: grid;
|
||||
flex: 0 0 12px;
|
||||
grid-auto-rows: 32px;
|
||||
width: 12px;
|
||||
`;
|
||||
|
||||
const GripCell = styled.div`
|
||||
background: ${COLORS.background};
|
||||
border-bottom: 1px solid ${COLORS.borderLight};
|
||||
`;
|
||||
|
||||
const TableViewport = styled.div<{ $dragging: boolean }>`
|
||||
cursor: ${({ $dragging }) => ($dragging ? 'grabbing' : 'grab')};
|
||||
flex: 1 1 auto;
|
||||
@@ -126,6 +155,38 @@ const DataRow = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const RowMotion = styled.div<{ $delayMs?: number; $entering?: boolean }>`
|
||||
align-items: center;
|
||||
animation: ${({ $entering }) =>
|
||||
$entering
|
||||
? `live-data-row-enter ${ROW_ENTER_DURATION_MS}ms cubic-bezier(0.16, 1, 0.3, 1)`
|
||||
: 'none'};
|
||||
animation-delay: ${({ $delayMs = 0 }) => `${$delayMs}ms`};
|
||||
animation-fill-mode: both;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
will-change: opacity, transform;
|
||||
|
||||
@keyframes live-data-row-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 28px, 0);
|
||||
}
|
||||
|
||||
58% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 6px, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const FooterRow = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
@@ -312,7 +373,11 @@ const StatusChip = styled.div<{ $edited?: boolean; $hoveredByAlice?: boolean }>`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
function HeaderIcon({ columnId }: { columnId: (typeof TABLE_COLUMNS)[number]['id'] }) {
|
||||
function HeaderIcon({
|
||||
columnId,
|
||||
}: {
|
||||
columnId: (typeof TABLE_COLUMNS)[number]['id'];
|
||||
}) {
|
||||
if (columnId === 'url') {
|
||||
return (
|
||||
<IconLink
|
||||
@@ -367,13 +432,7 @@ function PlusMini({ size = 12 }: { size?: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CompanyCell({
|
||||
label,
|
||||
logoSrc,
|
||||
}: {
|
||||
label: string;
|
||||
logoSrc: string;
|
||||
}) {
|
||||
function CompanyCell({ label, logoSrc }: { label: string; logoSrc: string }) {
|
||||
return (
|
||||
<CellHoverAnchor>
|
||||
<CheckboxContainer>
|
||||
@@ -384,7 +443,12 @@ function CompanyCell({
|
||||
label={label}
|
||||
leftComponent={
|
||||
<LogoBase>
|
||||
<CompanyLogoImage alt="" decoding="async" loading="lazy" src={logoSrc} />
|
||||
<CompanyLogoImage
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
src={logoSrc}
|
||||
/>
|
||||
</LogoBase>
|
||||
}
|
||||
variant={ChipVariant.Highlighted}
|
||||
@@ -405,12 +469,14 @@ type LiveDataHeroTableProps = {
|
||||
editedStatusLabel: string;
|
||||
isFirstTagEdited: boolean;
|
||||
isFirstTagHoveredByAlice: boolean;
|
||||
showExtendedRows: boolean;
|
||||
};
|
||||
|
||||
export function LiveDataHeroTable({
|
||||
editedStatusLabel,
|
||||
isFirstTagEdited,
|
||||
isFirstTagHoveredByAlice,
|
||||
showExtendedRows,
|
||||
}: LiveDataHeroTableProps) {
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef({
|
||||
@@ -421,6 +487,7 @@ export function LiveDataHeroTable({
|
||||
});
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [hoveredRowIndex, setHoveredRowIndex] = useState<number | null>(null);
|
||||
const visibleRows = showExtendedRows ? EXPANDED_TABLE_ROWS : BASE_TABLE_ROWS;
|
||||
|
||||
const columnWidth = TABLE_COLUMNS.reduce(
|
||||
(sum, column) => sum + column.width,
|
||||
@@ -514,14 +581,6 @@ export function LiveDataHeroTable({
|
||||
|
||||
return (
|
||||
<TableShell>
|
||||
<GripRail aria-hidden="true">
|
||||
<GripCell />
|
||||
{TABLE_ROWS.map((row) => (
|
||||
<GripCell key={`grip-${row.company}`} />
|
||||
))}
|
||||
<GripCell />
|
||||
</GripRail>
|
||||
|
||||
<TableViewport
|
||||
ref={viewportRef}
|
||||
$dragging={dragging}
|
||||
@@ -571,8 +630,11 @@ export function LiveDataHeroTable({
|
||||
</EmptyFillCell>
|
||||
</HeaderRow>
|
||||
|
||||
{TABLE_ROWS.map((row, index) => {
|
||||
{visibleRows.map((row, index) => {
|
||||
const hovered = hoveredRowIndex === index;
|
||||
const enterDelayMs = row.isNew
|
||||
? ROW_ENTER_STAGGER_MS * (index - BASE_TABLE_ROWS.length + 1)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<DataRow
|
||||
@@ -589,22 +651,30 @@ export function LiveDataHeroTable({
|
||||
$sticky
|
||||
$width={TABLE_COLUMNS[0].width}
|
||||
>
|
||||
<CompanyCell label={row.company} logoSrc={row.logoSrc} />
|
||||
<RowMotion $delayMs={enterDelayMs} $entering={row.isNew}>
|
||||
<CompanyCell label={row.company} logoSrc={row.logoSrc} />
|
||||
</RowMotion>
|
||||
</TableCell>
|
||||
<TableCell $hovered={hovered} $width={TABLE_COLUMNS[1].width}>
|
||||
<StatusChip
|
||||
$edited={index === 0 && isFirstTagEdited}
|
||||
$hoveredByAlice={index === 0 && isFirstTagHoveredByAlice}
|
||||
>
|
||||
{index === 0 && isFirstTagEdited
|
||||
? editedStatusLabel
|
||||
: row.status}
|
||||
</StatusChip>
|
||||
<RowMotion $delayMs={enterDelayMs} $entering={row.isNew}>
|
||||
<StatusChip
|
||||
$edited={index === 0 && isFirstTagEdited}
|
||||
$hoveredByAlice={index === 0 && isFirstTagHoveredByAlice}
|
||||
>
|
||||
{index === 0 && isFirstTagEdited
|
||||
? editedStatusLabel
|
||||
: row.status}
|
||||
</StatusChip>
|
||||
</RowMotion>
|
||||
</TableCell>
|
||||
<TableCell $hovered={hovered} $width={TABLE_COLUMNS[2].width}>
|
||||
<LinkCell label={row.domain} />
|
||||
<RowMotion $delayMs={enterDelayMs} $entering={row.isNew}>
|
||||
<LinkCell label={row.domain} />
|
||||
</RowMotion>
|
||||
</TableCell>
|
||||
<EmptyFillCell $hovered={hovered} $width={fillerWidth} />
|
||||
<EmptyFillCell $hovered={hovered} $width={fillerWidth}>
|
||||
<RowMotion $delayMs={enterDelayMs} $entering={row.isNew} />
|
||||
</EmptyFillCell>
|
||||
</DataRow>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import {
|
||||
IconCheckbox,
|
||||
IconChevronDown,
|
||||
IconHeartHandshake,
|
||||
IconList,
|
||||
IconPlus,
|
||||
IconUser,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { type RefObject, useEffect, useRef, useState } from 'react';
|
||||
import { LiveDataGradientBackdrop } from './LiveDataGradientBackdrop';
|
||||
import { LiveDataHeroTable } from './LiveDataHeroTable';
|
||||
|
||||
const APP_FONT = `'Inter', ${theme.font.family.sans}`;
|
||||
@@ -18,18 +20,28 @@ const SCENE_WIDTH = 411;
|
||||
const SCENE_SCALE = 1;
|
||||
const SCENE_SCALE_MD = 0.86;
|
||||
const SCENE_SCALE_SM = 0.74;
|
||||
const TABLE_PANEL_HOVER_SCALE = 1.012;
|
||||
const TABLER_STROKE = 1.65;
|
||||
const FILTER_ICON_STROKE = 1.33;
|
||||
const FILTER_ROW_GAP = 4;
|
||||
const BOB_READY_CURSOR_ROTATION = 135;
|
||||
const MARKER_CURSOR_HEIGHT = 32;
|
||||
const MARKER_CURSOR_WIDTH = 29;
|
||||
const BOB_MARKER_LEFT = 196;
|
||||
const BOB_MARKER_TOP = 372;
|
||||
const BOB_ARROW_LEFT_OFFSET = 20;
|
||||
const DEFAULT_ADD_FILTER_LEFTS = {
|
||||
docked: 152,
|
||||
parked: 276,
|
||||
};
|
||||
|
||||
const COLORS = {
|
||||
backdrop: '#1b1b1b',
|
||||
backdropDot: 'rgba(255, 255, 255, 0.14)',
|
||||
backdropStripe: 'rgba(255, 255, 255, 0.20)',
|
||||
black: '#1f1f1f',
|
||||
bobCursor: '#EDB7FF',
|
||||
blue: '#1961ed',
|
||||
blueBorder: '#edf2fe',
|
||||
blueSurface: '#f5f9fd',
|
||||
cardSurface: '#f5f5f3',
|
||||
green: '#18794e',
|
||||
greenSurface: '#ddf3e4',
|
||||
muted: '#999999',
|
||||
@@ -42,10 +54,19 @@ const COLORS = {
|
||||
yellow: '#fff6a5',
|
||||
} as const;
|
||||
|
||||
type AliceReturnPhase = 'return-to-start';
|
||||
type BobLiveDataPhase =
|
||||
| 'bob-ready'
|
||||
| 'move-to-filter'
|
||||
| 'remove-filter'
|
||||
| 'return-bob';
|
||||
|
||||
type LiveDataPhase =
|
||||
| 'idle'
|
||||
| 'move-to-tag'
|
||||
| 'rename-tag'
|
||||
| AliceReturnPhase
|
||||
| BobLiveDataPhase
|
||||
| 'settle';
|
||||
|
||||
const LIVE_DATA_SEQUENCE: Array<{
|
||||
@@ -54,25 +75,48 @@ const LIVE_DATA_SEQUENCE: Array<{
|
||||
}> = [
|
||||
{ delay: 0, phase: 'move-to-tag' },
|
||||
{ delay: 760, phase: 'rename-tag' },
|
||||
{ delay: 1860, phase: 'settle' },
|
||||
{ delay: 1700, phase: 'return-to-start' },
|
||||
{ delay: 2380, phase: 'bob-ready' },
|
||||
{ delay: 2620, phase: 'move-to-filter' },
|
||||
{ delay: 3260, phase: 'remove-filter' },
|
||||
{ delay: 3900, phase: 'return-bob' },
|
||||
{ delay: 4680, phase: 'settle' },
|
||||
];
|
||||
|
||||
const LIVE_DATA_LOOP_DURATION = 3200;
|
||||
const EDITED_TAG_LABEL = 'Priority';
|
||||
const TYPING_STEP_MS = 90;
|
||||
const ALICE_IDLE_MARKER = { left: 238, rotation: 0, top: 90 };
|
||||
const BOB_READY_MARKER = {
|
||||
bottom: SCENE_HEIGHT - (BOB_MARKER_TOP + MARKER_CURSOR_HEIGHT / 2),
|
||||
right:
|
||||
SCENE_WIDTH -
|
||||
(BOB_MARKER_LEFT + BOB_ARROW_LEFT_OFFSET + MARKER_CURSOR_WIDTH / 2),
|
||||
rotation: BOB_READY_CURSOR_ROTATION,
|
||||
};
|
||||
const BOB_FILTER_CURSOR_ROTATION = 132;
|
||||
const BOB_FILTER_MARKER = {
|
||||
bottom: 301,
|
||||
right: 50,
|
||||
rotation: BOB_FILTER_CURSOR_ROTATION,
|
||||
};
|
||||
|
||||
const LIVE_DATA_CURSOR_POSITIONS: Record<
|
||||
const ALICE_MARKER_POSITIONS: Record<
|
||||
LiveDataPhase,
|
||||
{ left: number; rotation: number; top: number }
|
||||
> = {
|
||||
idle: { left: 238, rotation: 0, top: 90 },
|
||||
'move-to-tag': { left: 286, rotation: -38, top: 272 },
|
||||
'rename-tag': { left: 279, rotation: -18, top: 273 },
|
||||
settle: { left: 293, rotation: -18, top: 277 },
|
||||
idle: ALICE_IDLE_MARKER,
|
||||
'move-to-tag': { left: 286, rotation: -38, top: 246 },
|
||||
'rename-tag': { left: 279, rotation: -18, top: 247 },
|
||||
'return-to-start': ALICE_IDLE_MARKER,
|
||||
'bob-ready': ALICE_IDLE_MARKER,
|
||||
'move-to-filter': ALICE_IDLE_MARKER,
|
||||
'remove-filter': ALICE_IDLE_MARKER,
|
||||
'return-bob': ALICE_IDLE_MARKER,
|
||||
settle: ALICE_IDLE_MARKER,
|
||||
};
|
||||
|
||||
const VisualRoot = styled.div`
|
||||
background: ${COLORS.cardSurface};
|
||||
background: ${COLORS.backdrop};
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
@@ -80,13 +124,13 @@ const VisualRoot = styled.div`
|
||||
`;
|
||||
|
||||
const SceneViewport = styled.div`
|
||||
height: ${SCENE_HEIGHT}px;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
top: 0;
|
||||
transform: translateX(-50%) scale(${SCENE_SCALE});
|
||||
transform-origin: top center;
|
||||
width: ${SCENE_WIDTH}px;
|
||||
width: 101%;
|
||||
|
||||
@media (max-width: ${theme.breakpoints.md - 1}px) {
|
||||
transform: translateX(-50%) scale(${SCENE_SCALE_MD});
|
||||
@@ -98,60 +142,53 @@ const SceneViewport = styled.div`
|
||||
`;
|
||||
|
||||
const SceneFrame = styled.div`
|
||||
background: ${COLORS.backdrop};
|
||||
border-radius: 2px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before {
|
||||
background: radial-gradient(
|
||||
circle at 1px 1px,
|
||||
${COLORS.backdropDot} 1px,
|
||||
transparent 1.2px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent 0 18px,
|
||||
${COLORS.backdropStripe} 18px 25px,
|
||||
transparent 25px 52px
|
||||
);
|
||||
background-size:
|
||||
12px 12px,
|
||||
100% 32px;
|
||||
content: '';
|
||||
inset: 0;
|
||||
opacity: 0.8;
|
||||
position: absolute;
|
||||
}
|
||||
`;
|
||||
|
||||
const AccentRail = styled.div`
|
||||
background: repeating-linear-gradient(
|
||||
180deg,
|
||||
${COLORS.backdropStripe} 0 4px,
|
||||
transparent 4px 11px
|
||||
);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0.9;
|
||||
const SceneBackdrop = styled.div<{
|
||||
$backgroundImageRotationDeg?: number;
|
||||
}>`
|
||||
background-color: ${COLORS.backdrop};
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 67%;
|
||||
width: 58px;
|
||||
`;
|
||||
|
||||
const TablePanel = styled.div`
|
||||
const SceneContent = styled.div`
|
||||
inset: 16px 0 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const TablePanel = styled.div<{ $active?: boolean }>`
|
||||
backface-visibility: hidden;
|
||||
background: ${COLORS.white};
|
||||
border-top-left-radius: ${theme.radius(1)};
|
||||
box-shadow: 0 0 0 1px rgba(241, 241, 241, 0.9);
|
||||
border-top-left-radius: 8px;
|
||||
box-shadow: ${({ $active }) =>
|
||||
$active
|
||||
? '0 0 0 1px rgba(241, 241, 241, 0.9), 0 14px 28px rgba(15, 23, 42, 0.08)'
|
||||
: '0 0 0 1px rgba(241, 241, 241, 0.9)'};
|
||||
contain: paint;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
height: 341px;
|
||||
left: 58px;
|
||||
height: 376px;
|
||||
bottom: -4px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
top: 151px;
|
||||
right: 0px;
|
||||
transform: ${({ $active }) =>
|
||||
`translate3d(0, 0, 0) scale(${
|
||||
$active ? TABLE_PANEL_HOVER_SCALE : 1
|
||||
})`};
|
||||
transform-origin: bottom right;
|
||||
transition:
|
||||
box-shadow 260ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: transform, box-shadow;
|
||||
width: 355px;
|
||||
`;
|
||||
|
||||
@@ -232,12 +269,34 @@ const FilterRow = styled.div`
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f1f1f1;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
gap: ${FILTER_ROW_GAP}px;
|
||||
height: 48px;
|
||||
padding: 0 12px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const FilterChip = styled.div<{ $pressed?: boolean }>`
|
||||
const FilterChipMotion = styled.div<{ $removing?: boolean; $visible: boolean }>`
|
||||
display: inline-flex;
|
||||
max-width: ${({ $visible }) => ($visible ? '172px' : '0')};
|
||||
opacity: ${({ $removing, $visible }) => ($visible || $removing ? 1 : 0)};
|
||||
overflow: ${({ $removing }) => ($removing ? 'visible' : 'hidden')};
|
||||
pointer-events: ${({ $removing, $visible }) =>
|
||||
$visible && !$removing ? 'auto' : 'none'};
|
||||
transform: ${({ $removing, $visible }) =>
|
||||
$visible || $removing
|
||||
? 'translateX(0) scale(1)'
|
||||
: 'translateX(-8px) scale(0.92)'};
|
||||
transform-origin: right center;
|
||||
transition:
|
||||
max-width 280ms cubic-bezier(0.22, 1, 0.36, 1)
|
||||
${({ $removing }) => ($removing ? '180ms' : '0ms')},
|
||||
opacity 180ms ease ${({ $removing }) => ($removing ? '180ms' : '0ms')},
|
||||
transform 280ms cubic-bezier(0.22, 1, 0.36, 1)
|
||||
${({ $removing }) => ($removing ? '180ms' : '0ms')};
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const FilterChip = styled.div<{ $pressed?: boolean; $removing?: boolean }>`
|
||||
align-items: center;
|
||||
background: ${COLORS.blueSurface};
|
||||
border: 1px solid ${COLORS.blueBorder};
|
||||
@@ -252,11 +311,36 @@ const FilterChip = styled.div<{ $pressed?: boolean }>`
|
||||
height: 24px;
|
||||
line-height: 1.4;
|
||||
padding: 2px 2px 2px 4px;
|
||||
opacity: 1;
|
||||
transform: ${({ $pressed }) => ($pressed ? 'scale(0.985)' : 'scale(1)')};
|
||||
transition:
|
||||
box-shadow 180ms ease,
|
||||
transform 180ms ease;
|
||||
white-space: nowrap;
|
||||
|
||||
animation: ${({ $removing }) =>
|
||||
$removing
|
||||
? 'employees-filter-pop-away 320ms cubic-bezier(0.18, 1, 0.32, 1) forwards'
|
||||
: 'none'};
|
||||
transform-origin: right center;
|
||||
will-change: opacity, transform;
|
||||
|
||||
@keyframes employees-filter-pop-away {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(0.985) translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
36% {
|
||||
opacity: 1;
|
||||
transform: scale(1.08) translate3d(2px, -1px, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.64) translate3d(18px, -6px, 0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const FilterChipLabel = styled.span`
|
||||
@@ -286,9 +370,10 @@ const FilterValue = styled.span`
|
||||
font-weight: ${theme.font.weight.regular};
|
||||
`;
|
||||
|
||||
const FilterCloseButton = styled.button`
|
||||
const FilterCloseButton = styled.button<{ $pressed?: boolean }>`
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
background: ${({ $pressed }) =>
|
||||
$pressed ? 'rgba(25, 97, 237, 0.08)' : 'transparent'};
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
color: ${COLORS.blue};
|
||||
@@ -298,7 +383,18 @@ const FilterCloseButton = styled.button`
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: background-color 140ms ease;
|
||||
width: 20px;
|
||||
|
||||
&:hover {
|
||||
background: ${({ $pressed }) =>
|
||||
$pressed ? 'rgba(25, 97, 237, 0.08)' : 'rgba(25, 97, 237, 0.06)'};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
background: rgba(25, 97, 237, 0.06);
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const AddFilter = styled.button`
|
||||
@@ -325,6 +421,19 @@ const AddFilter = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
const FloatingAddFilter = styled(AddFilter)<{
|
||||
$animated: boolean;
|
||||
$left: number;
|
||||
}>`
|
||||
left: ${({ $left }) => `${$left}px`};
|
||||
margin-left: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
transition: ${({ $animated }) =>
|
||||
$animated ? 'left 340ms cubic-bezier(0.22, 1, 0.36, 1)' : 'none'};
|
||||
`;
|
||||
|
||||
const TableBodyArea = styled.div`
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
@@ -354,25 +463,50 @@ const CursorGlyph = styled.svg<{
|
||||
width: ${({ $width }) => `${$width}px`};
|
||||
`;
|
||||
|
||||
const AnimatedCursorLayer = styled.div<{
|
||||
const AnimatedMarkerGroup = styled.div<{
|
||||
$left: number;
|
||||
$pressed?: boolean;
|
||||
$top: number;
|
||||
}>`
|
||||
animation: ${({ $pressed }) =>
|
||||
$pressed ? 'cursor-pop 240ms cubic-bezier(0.22, 1, 0.36, 1)' : 'none'};
|
||||
left: ${({ $left }) => `${$left}px`};
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: ${({ $top }) => `${$top}px`};
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
transition:
|
||||
left 620ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 180ms ease,
|
||||
top 620ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 140ms ease;
|
||||
top 620ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
z-index: 9;
|
||||
`;
|
||||
|
||||
const AnchoredMarkerGroup = styled.div<{
|
||||
$bottom: number;
|
||||
$right: number;
|
||||
}>`
|
||||
bottom: ${({ $bottom }) => `${$bottom}px`};
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: ${({ $right }) => `${$right}px`};
|
||||
transition:
|
||||
bottom 620ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
right 620ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
z-index: 9;
|
||||
`;
|
||||
|
||||
const MarkerCursorSlot = styled.div<{
|
||||
$pressed?: boolean;
|
||||
$visible?: boolean;
|
||||
}>`
|
||||
animation: ${({ $pressed, $visible }) =>
|
||||
$pressed && $visible
|
||||
? 'cursor-pop 240ms cubic-bezier(0.22, 1, 0.36, 1)'
|
||||
: 'none'};
|
||||
left: 0;
|
||||
opacity: ${({ $visible = true }) => ($visible ? 1 : 0)};
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 140ms ease;
|
||||
|
||||
@keyframes cursor-pop {
|
||||
0% {
|
||||
@@ -391,20 +525,25 @@ const AnimatedCursorLayer = styled.div<{
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const AnimatedNameTagLayer = styled.div<{
|
||||
$left: number;
|
||||
$top: number;
|
||||
}>`
|
||||
left: ${({ $left }) => `${$left}px`};
|
||||
pointer-events: none;
|
||||
const MarkerLabelTop = styled.div`
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: ${({ $top }) => `${$top}px`};
|
||||
transform: translate(-50%, calc(-100% - 18px));
|
||||
transition:
|
||||
left 620ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
top 620ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
top: 0;
|
||||
transform: translate(-50%, calc(-100% - 24px));
|
||||
z-index: 10;
|
||||
`;
|
||||
|
||||
const MarkerLabelBottom = styled.div`
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translate(-50%, 26px);
|
||||
z-index: 10;
|
||||
`;
|
||||
|
||||
@@ -423,7 +562,7 @@ const TomMarker = styled.div`
|
||||
left: 31px;
|
||||
pointer-events: auto;
|
||||
position: absolute;
|
||||
top: 306px;
|
||||
top: 271px;
|
||||
height: 112px;
|
||||
width: 92px;
|
||||
z-index: 8;
|
||||
@@ -474,11 +613,13 @@ const TomCursorGlyph = styled.svg`
|
||||
width: 41px;
|
||||
`;
|
||||
|
||||
const BobMarker = styled.div`
|
||||
pointer-events: auto;
|
||||
left: 214px;
|
||||
const BobMarker = styled.div<{ $hidden?: boolean }>`
|
||||
left: ${BOB_MARKER_LEFT}px;
|
||||
opacity: ${({ $hidden }) => ($hidden ? 0 : 1)};
|
||||
pointer-events: ${({ $hidden }) => ($hidden ? 'none' : 'auto')};
|
||||
position: absolute;
|
||||
top: 386px;
|
||||
top: ${BOB_MARKER_TOP}px;
|
||||
transition: opacity 180ms ease;
|
||||
height: 112px;
|
||||
width: 96px;
|
||||
z-index: 8;
|
||||
@@ -492,19 +633,21 @@ const BobMarkerMotion = styled.div<{ $hovered: boolean }>`
|
||||
`;
|
||||
|
||||
const BobArrow = styled.div`
|
||||
margin-left: 20px;
|
||||
height: ${MARKER_CURSOR_HEIGHT}px;
|
||||
margin-left: ${BOB_ARROW_LEFT_OFFSET}px;
|
||||
position: relative;
|
||||
width: ${MARKER_CURSOR_WIDTH}px;
|
||||
z-index: 2;
|
||||
`;
|
||||
|
||||
const BobLabel = styled.div`
|
||||
const BobLabel = styled.div<{ $withTopMargin?: boolean }>`
|
||||
align-items: center;
|
||||
background: ${COLORS.purple};
|
||||
background: ${COLORS.bobCursor};
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
height: 38px;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
margin-top: ${({ $withTopMargin }) => ($withTopMargin ? '10px' : '0')};
|
||||
min-width: 60px;
|
||||
padding: 0 12px;
|
||||
position: relative;
|
||||
@@ -556,13 +699,27 @@ function TomCursor() {
|
||||
|
||||
type LiveDataVisualProps = {
|
||||
active?: boolean;
|
||||
backgroundImageRotationDeg?: number;
|
||||
backgroundImageSrc?: string;
|
||||
pointerTargetRef?: RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
export function LiveDataVisual({ active = false }: LiveDataVisualProps) {
|
||||
export function LiveDataVisual({
|
||||
active = false,
|
||||
backgroundImageSrc,
|
||||
pointerTargetRef,
|
||||
}: LiveDataVisualProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const typeFilterRef = useRef<HTMLDivElement>(null);
|
||||
const employeesFilterRef = useRef<HTMLDivElement>(null);
|
||||
const [isBobHovered, setIsBobHovered] = useState(false);
|
||||
const [isTomHovered, setIsTomHovered] = useState(false);
|
||||
const [phase, setPhase] = useState<LiveDataPhase>('idle');
|
||||
const [typedTagLabel, setTypedTagLabel] = useState('');
|
||||
const [addFilterLefts, setAddFilterLefts] = useState<{
|
||||
docked: number;
|
||||
parked: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
@@ -570,36 +727,18 @@ export function LiveDataVisual({ active = false }: LiveDataVisualProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timeoutIds: number[] = [];
|
||||
setPhase('move-to-tag');
|
||||
|
||||
const runSequence = () => {
|
||||
setPhase('move-to-tag');
|
||||
for (const step of LIVE_DATA_SEQUENCE.slice(1)) {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setPhase(step.phase);
|
||||
}, step.delay);
|
||||
|
||||
for (const step of LIVE_DATA_SEQUENCE.slice(1)) {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
setPhase(step.phase);
|
||||
}
|
||||
}, step.delay);
|
||||
|
||||
timeoutIds.push(timeoutId);
|
||||
}
|
||||
|
||||
const loopTimeoutId = window.setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
runSequence();
|
||||
}
|
||||
}, LIVE_DATA_LOOP_DURATION);
|
||||
|
||||
timeoutIds.push(loopTimeoutId);
|
||||
};
|
||||
|
||||
runSequence();
|
||||
timeoutIds.push(timeoutId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
for (const timeoutId of timeoutIds) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
@@ -627,7 +766,14 @@ export function LiveDataVisual({ active = false }: LiveDataVisualProps) {
|
||||
};
|
||||
}
|
||||
|
||||
if (phase === 'settle') {
|
||||
if (
|
||||
phase === 'return-to-start' ||
|
||||
phase === 'bob-ready' ||
|
||||
phase === 'move-to-filter' ||
|
||||
phase === 'remove-filter' ||
|
||||
phase === 'return-bob' ||
|
||||
phase === 'settle'
|
||||
) {
|
||||
setTypedTagLabel(EDITED_TAG_LABEL);
|
||||
return;
|
||||
}
|
||||
@@ -635,160 +781,279 @@ export function LiveDataVisual({ active = false }: LiveDataVisualProps) {
|
||||
setTypedTagLabel('');
|
||||
}, [phase]);
|
||||
|
||||
const animatedCursor = LIVE_DATA_CURSOR_POSITIONS[phase];
|
||||
const isFirstTagRenamed = phase === 'rename-tag' || phase === 'settle';
|
||||
const isFirstTagEdited = phase === 'rename-tag' || phase === 'settle';
|
||||
const isFirstTagHoveredByAlice = phase !== 'idle';
|
||||
useEffect(() => {
|
||||
const measureAddFilterLefts = () => {
|
||||
const typeFilter = typeFilterRef.current;
|
||||
const employeesFilter = employeesFilterRef.current;
|
||||
|
||||
if (
|
||||
!typeFilter ||
|
||||
!employeesFilter ||
|
||||
employeesFilter.offsetWidth === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLefts = {
|
||||
docked: typeFilter.offsetLeft + typeFilter.offsetWidth + FILTER_ROW_GAP,
|
||||
parked:
|
||||
employeesFilter.offsetLeft +
|
||||
employeesFilter.offsetWidth +
|
||||
FILTER_ROW_GAP,
|
||||
};
|
||||
|
||||
setAddFilterLefts((current) =>
|
||||
current?.docked === nextLefts.docked &&
|
||||
current?.parked === nextLefts.parked
|
||||
? current
|
||||
: nextLefts,
|
||||
);
|
||||
};
|
||||
|
||||
const frameId = window.requestAnimationFrame(measureAddFilterLefts);
|
||||
window.addEventListener('resize', measureAddFilterLefts);
|
||||
void document.fonts?.ready.then(measureAddFilterLefts);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
window.removeEventListener('resize', measureAddFilterLefts);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const aliceMarker = ALICE_MARKER_POSITIONS[phase];
|
||||
const bobCursor = active
|
||||
? phase === 'move-to-filter' || phase === 'remove-filter'
|
||||
? BOB_FILTER_MARKER
|
||||
: BOB_READY_MARKER
|
||||
: null;
|
||||
const isAliceCursorVisible =
|
||||
phase === 'idle' ||
|
||||
phase === 'move-to-tag' ||
|
||||
phase === 'rename-tag' ||
|
||||
phase === 'return-to-start' ||
|
||||
phase === 'bob-ready' ||
|
||||
phase === 'move-to-filter' ||
|
||||
phase === 'remove-filter' ||
|
||||
phase === 'return-bob' ||
|
||||
phase === 'settle';
|
||||
const isBobCursorVisible = active;
|
||||
const isEmployeesFilterRemoving = phase === 'remove-filter';
|
||||
const isEmployeesFilterVisible =
|
||||
phase !== 'remove-filter' && phase !== 'return-bob' && phase !== 'settle';
|
||||
const hasEmployeesFilterBeenRemoved =
|
||||
phase === 'remove-filter' || phase === 'return-bob' || phase === 'settle';
|
||||
const isFirstTagRenamed =
|
||||
phase === 'rename-tag' ||
|
||||
phase === 'return-to-start' ||
|
||||
phase === 'bob-ready' ||
|
||||
phase === 'move-to-filter' ||
|
||||
phase === 'remove-filter' ||
|
||||
phase === 'return-bob' ||
|
||||
phase === 'settle';
|
||||
const isFirstTagEdited = isFirstTagRenamed;
|
||||
const isFirstTagHoveredByAlice =
|
||||
phase === 'move-to-tag' || phase === 'rename-tag';
|
||||
const isAddFilterDocked = phase === 'return-bob' || phase === 'settle';
|
||||
const addFilterLeft = isAddFilterDocked
|
||||
? (addFilterLefts?.docked ?? DEFAULT_ADD_FILTER_LEFTS.docked)
|
||||
: (addFilterLefts?.parked ?? DEFAULT_ADD_FILTER_LEFTS.parked);
|
||||
const viewCount = hasEmployeesFilterBeenRemoved ? 11 : 9;
|
||||
|
||||
return (
|
||||
<VisualRoot aria-hidden>
|
||||
<VisualRoot aria-hidden ref={rootRef}>
|
||||
<SceneViewport>
|
||||
<SceneFrame>
|
||||
<AccentRail />
|
||||
|
||||
<AnimatedNameTagLayer
|
||||
$left={animatedCursor.left}
|
||||
$top={animatedCursor.top}
|
||||
>
|
||||
<AliceLabel>
|
||||
<MarkerText>Alice</MarkerText>
|
||||
</AliceLabel>
|
||||
</AnimatedNameTagLayer>
|
||||
|
||||
<AnimatedCursorLayer
|
||||
$left={animatedCursor.left}
|
||||
$pressed={phase === 'rename-tag'}
|
||||
$top={animatedCursor.top}
|
||||
>
|
||||
<MarkerCursor
|
||||
color={COLORS.yellow}
|
||||
rotation={animatedCursor.rotation}
|
||||
<SceneBackdrop>
|
||||
<LiveDataGradientBackdrop
|
||||
active={active}
|
||||
imageUrl={backgroundImageSrc}
|
||||
pointerTargetRef={pointerTargetRef ?? rootRef}
|
||||
/>
|
||||
</AnimatedCursorLayer>
|
||||
</SceneBackdrop>
|
||||
<SceneContent>
|
||||
<AnimatedMarkerGroup
|
||||
$left={aliceMarker.left}
|
||||
$top={aliceMarker.top}
|
||||
>
|
||||
<MarkerLabelTop>
|
||||
<AliceLabel>
|
||||
<MarkerText>Alice</MarkerText>
|
||||
</AliceLabel>
|
||||
</MarkerLabelTop>
|
||||
<MarkerCursorSlot
|
||||
$pressed={phase === 'rename-tag'}
|
||||
$visible={isAliceCursorVisible}
|
||||
>
|
||||
<MarkerCursor
|
||||
color={COLORS.yellow}
|
||||
rotation={aliceMarker.rotation}
|
||||
/>
|
||||
</MarkerCursorSlot>
|
||||
</AnimatedMarkerGroup>
|
||||
{bobCursor ? (
|
||||
<AnchoredMarkerGroup
|
||||
$bottom={bobCursor.bottom}
|
||||
$right={bobCursor.right}
|
||||
>
|
||||
<MarkerCursorSlot
|
||||
$pressed={phase === 'remove-filter'}
|
||||
$visible
|
||||
>
|
||||
<MarkerCursor
|
||||
color={COLORS.bobCursor}
|
||||
rotation={bobCursor.rotation}
|
||||
/>
|
||||
</MarkerCursorSlot>
|
||||
<MarkerLabelBottom>
|
||||
<BobLabel>
|
||||
<MarkerText>Bob</MarkerText>
|
||||
</BobLabel>
|
||||
</MarkerLabelBottom>
|
||||
</AnchoredMarkerGroup>
|
||||
) : null}
|
||||
|
||||
<TablePanel>
|
||||
<ViewRow>
|
||||
<ViewSwitcher>
|
||||
<ViewSwitcherLeft>
|
||||
<ViewSwitcherIcon>
|
||||
<IconList
|
||||
<TablePanel $active={active}>
|
||||
<ViewRow>
|
||||
<ViewSwitcher>
|
||||
<ViewSwitcherLeft>
|
||||
<ViewSwitcherIcon>
|
||||
<IconList
|
||||
aria-hidden
|
||||
color={COLORS.textSecondary}
|
||||
size={16}
|
||||
stroke={TABLER_STROKE}
|
||||
/>
|
||||
</ViewSwitcherIcon>
|
||||
<ViewLabel>All</ViewLabel>
|
||||
<ViewDot />
|
||||
<ViewCount>{viewCount}</ViewCount>
|
||||
</ViewSwitcherLeft>
|
||||
<ViewSwitcherChevron>
|
||||
<IconChevronDown
|
||||
aria-hidden
|
||||
color={COLORS.textSecondary}
|
||||
size={16}
|
||||
color={COLORS.textLight}
|
||||
size={14}
|
||||
stroke={TABLER_STROKE}
|
||||
/>
|
||||
</ViewSwitcherIcon>
|
||||
<ViewLabel>All</ViewLabel>
|
||||
<ViewDot />
|
||||
<ViewCount>9</ViewCount>
|
||||
</ViewSwitcherLeft>
|
||||
<ViewSwitcherChevron>
|
||||
<IconChevronDown
|
||||
aria-hidden
|
||||
color={COLORS.textLight}
|
||||
size={14}
|
||||
stroke={TABLER_STROKE}
|
||||
/>
|
||||
</ViewSwitcherChevron>
|
||||
</ViewSwitcher>
|
||||
</ViewRow>
|
||||
</ViewSwitcherChevron>
|
||||
</ViewSwitcher>
|
||||
</ViewRow>
|
||||
|
||||
<FilterRow>
|
||||
<FilterChip>
|
||||
<FilterChipLabel>
|
||||
<FilterChipIcon>
|
||||
<IconCheckbox
|
||||
<FilterRow>
|
||||
<FilterChip ref={typeFilterRef}>
|
||||
<FilterChipLabel>
|
||||
<FilterChipIcon>
|
||||
<IconHeartHandshake
|
||||
aria-hidden
|
||||
color={COLORS.blue}
|
||||
size={14}
|
||||
stroke={FILTER_ICON_STROKE}
|
||||
/>
|
||||
</FilterChipIcon>
|
||||
<FilterName>Type</FilterName>
|
||||
</FilterChipLabel>
|
||||
<FilterValue>is Customer</FilterValue>
|
||||
<FilterCloseButton type="button">
|
||||
<IconX
|
||||
aria-hidden
|
||||
color={COLORS.blue}
|
||||
size={14}
|
||||
stroke={FILTER_ICON_STROKE}
|
||||
/>
|
||||
</FilterChipIcon>
|
||||
<FilterName>Type</FilterName>
|
||||
</FilterChipLabel>
|
||||
<FilterValue>is Customer</FilterValue>
|
||||
<FilterCloseButton type="button">
|
||||
<IconX
|
||||
aria-hidden
|
||||
color={COLORS.blue}
|
||||
size={14}
|
||||
stroke={FILTER_ICON_STROKE}
|
||||
/>
|
||||
</FilterCloseButton>
|
||||
</FilterChip>
|
||||
</FilterCloseButton>
|
||||
</FilterChip>
|
||||
|
||||
<FilterChip>
|
||||
<FilterChipLabel>
|
||||
<FilterChipMotion
|
||||
ref={employeesFilterRef}
|
||||
$removing={isEmployeesFilterRemoving}
|
||||
$visible={isEmployeesFilterVisible}
|
||||
>
|
||||
<FilterChip
|
||||
$pressed={phase === 'remove-filter'}
|
||||
$removing={isEmployeesFilterRemoving}
|
||||
>
|
||||
<FilterChipLabel>
|
||||
<FilterChipIcon>
|
||||
<IconUser
|
||||
aria-hidden
|
||||
color={COLORS.blue}
|
||||
size={14}
|
||||
stroke={FILTER_ICON_STROKE}
|
||||
/>
|
||||
</FilterChipIcon>
|
||||
<FilterName>Employees</FilterName>
|
||||
</FilterChipLabel>
|
||||
<FilterValue>{'>500'}</FilterValue>
|
||||
<FilterCloseButton
|
||||
$pressed={phase === 'remove-filter'}
|
||||
type="button"
|
||||
>
|
||||
<IconX
|
||||
aria-hidden
|
||||
color={COLORS.blue}
|
||||
size={14}
|
||||
stroke={FILTER_ICON_STROKE}
|
||||
/>
|
||||
</FilterCloseButton>
|
||||
</FilterChip>
|
||||
</FilterChipMotion>
|
||||
|
||||
<FloatingAddFilter
|
||||
$animated={addFilterLefts !== null}
|
||||
$left={addFilterLeft}
|
||||
type="button"
|
||||
>
|
||||
<FilterChipIcon>
|
||||
<IconCheckbox
|
||||
<IconPlus
|
||||
aria-hidden
|
||||
color={COLORS.blue}
|
||||
color={COLORS.muted}
|
||||
size={14}
|
||||
stroke={FILTER_ICON_STROKE}
|
||||
/>
|
||||
</FilterChipIcon>
|
||||
<FilterName>Employees</FilterName>
|
||||
</FilterChipLabel>
|
||||
<FilterValue>{'>500'}</FilterValue>
|
||||
<FilterCloseButton type="button">
|
||||
<IconX
|
||||
aria-hidden
|
||||
color={COLORS.blue}
|
||||
size={14}
|
||||
stroke={FILTER_ICON_STROKE}
|
||||
/>
|
||||
</FilterCloseButton>
|
||||
</FilterChip>
|
||||
Add filter
|
||||
</FloatingAddFilter>
|
||||
</FilterRow>
|
||||
|
||||
<AddFilter type="button">
|
||||
<FilterChipIcon>
|
||||
<IconPlus
|
||||
aria-hidden
|
||||
color={COLORS.muted}
|
||||
size={14}
|
||||
stroke={FILTER_ICON_STROKE}
|
||||
/>
|
||||
</FilterChipIcon>
|
||||
Add filter
|
||||
</AddFilter>
|
||||
</FilterRow>
|
||||
<TableBodyArea>
|
||||
<LiveDataHeroTable
|
||||
editedStatusLabel={isFirstTagRenamed ? typedTagLabel : ''}
|
||||
isFirstTagEdited={isFirstTagEdited}
|
||||
isFirstTagHoveredByAlice={isFirstTagHoveredByAlice}
|
||||
showExtendedRows={hasEmployeesFilterBeenRemoved}
|
||||
/>
|
||||
</TableBodyArea>
|
||||
</TablePanel>
|
||||
|
||||
<TableBodyArea>
|
||||
<LiveDataHeroTable
|
||||
editedStatusLabel={isFirstTagRenamed ? typedTagLabel : ''}
|
||||
isFirstTagEdited={isFirstTagEdited}
|
||||
isFirstTagHoveredByAlice={isFirstTagHoveredByAlice}
|
||||
/>
|
||||
</TableBodyArea>
|
||||
</TablePanel>
|
||||
<TomMarker
|
||||
onPointerEnter={() => setIsTomHovered(true)}
|
||||
onPointerLeave={() => setIsTomHovered(false)}
|
||||
>
|
||||
<TomMarkerMotion $hovered={isTomHovered}>
|
||||
<TomArrow>
|
||||
<TomCursor />
|
||||
</TomArrow>
|
||||
<TomLabel>
|
||||
<MarkerText>Tom</MarkerText>
|
||||
</TomLabel>
|
||||
</TomMarkerMotion>
|
||||
</TomMarker>
|
||||
|
||||
<TomMarker
|
||||
onPointerEnter={() => setIsTomHovered(true)}
|
||||
onPointerLeave={() => setIsTomHovered(false)}
|
||||
>
|
||||
<TomMarkerMotion $hovered={isTomHovered}>
|
||||
<TomArrow>
|
||||
<TomCursor />
|
||||
</TomArrow>
|
||||
<TomLabel>
|
||||
<MarkerText>Tom</MarkerText>
|
||||
</TomLabel>
|
||||
</TomMarkerMotion>
|
||||
</TomMarker>
|
||||
|
||||
<BobMarker
|
||||
onPointerEnter={() => setIsBobHovered(true)}
|
||||
onPointerLeave={() => setIsBobHovered(false)}
|
||||
>
|
||||
<BobMarkerMotion $hovered={isBobHovered}>
|
||||
<BobArrow>
|
||||
<MarkerCursor color={COLORS.purple} rotation={135} />
|
||||
</BobArrow>
|
||||
<BobLabel>
|
||||
<MarkerText>Bob</MarkerText>
|
||||
</BobLabel>
|
||||
</BobMarkerMotion>
|
||||
</BobMarker>
|
||||
<BobMarker
|
||||
$hidden={isBobCursorVisible}
|
||||
onPointerEnter={() => setIsBobHovered(true)}
|
||||
onPointerLeave={() => setIsBobHovered(false)}
|
||||
>
|
||||
<BobMarkerMotion $hovered={isBobHovered}>
|
||||
<BobArrow>
|
||||
<MarkerCursor color={COLORS.bobCursor} rotation={135} />
|
||||
</BobArrow>
|
||||
<BobLabel $withTopMargin>
|
||||
<MarkerText>Bob</MarkerText>
|
||||
</BobLabel>
|
||||
</BobMarkerMotion>
|
||||
</BobMarker>
|
||||
</SceneContent>
|
||||
</SceneFrame>
|
||||
</SceneViewport>
|
||||
</VisualRoot>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { ThreeCardsScrollLayoutEffect } from '@/sections/ThreeCards/effect-components/ThreeCardsScrollLayoutEffect';
|
||||
import { ThreeCardsFeatureCardType } from '@/sections/ThreeCards/types';
|
||||
import { type ThreeCardsScrollLayoutOptions } from '@/sections/ThreeCards/utils/three-cards-scroll-layout';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useRef } from 'react';
|
||||
@@ -23,6 +24,14 @@ const CardSlot = styled.div`
|
||||
will-change: transform, opacity;
|
||||
`;
|
||||
|
||||
const FEATURE_CARDS_SCROLL_LAYOUT_OPTIONS: ThreeCardsScrollLayoutOptions = {
|
||||
stagger: 0.12,
|
||||
endEdgeRatio: 0.3,
|
||||
initialTranslateY: 120,
|
||||
initialScale: 0.94,
|
||||
opacityRamp: 0.25,
|
||||
};
|
||||
|
||||
type FeatureCardsProps = { featureCards: ThreeCardsFeatureCardType[] };
|
||||
|
||||
export function FeatureCards({ featureCards }: FeatureCardsProps) {
|
||||
@@ -35,6 +44,7 @@ export function FeatureCards({ featureCards }: FeatureCardsProps) {
|
||||
cardCount={featureCards.length}
|
||||
cardRefs={cardRefs}
|
||||
gridRef={gridRef}
|
||||
layoutOptions={FEATURE_CARDS_SCROLL_LAYOUT_OPTIONS}
|
||||
/>
|
||||
{featureCards.map((featureCard, index) => (
|
||||
<CardSlot
|
||||
|
||||
@@ -5,16 +5,19 @@ import { useEffect } from 'react';
|
||||
import {
|
||||
applyThreeCardsScrollLayout,
|
||||
type ThreeCardsScrollLayoutRefs,
|
||||
type ThreeCardsScrollLayoutOptions,
|
||||
} from '@/sections/ThreeCards/utils/three-cards-scroll-layout';
|
||||
|
||||
type ThreeCardsScrollLayoutEffectProps = ThreeCardsScrollLayoutRefs & {
|
||||
cardCount: number;
|
||||
layoutOptions?: ThreeCardsScrollLayoutOptions;
|
||||
};
|
||||
|
||||
export function ThreeCardsScrollLayoutEffect({
|
||||
cardCount,
|
||||
cardRefs,
|
||||
gridRef,
|
||||
layoutOptions,
|
||||
}: ThreeCardsScrollLayoutEffectProps) {
|
||||
useEffect(() => {
|
||||
const refs: ThreeCardsScrollLayoutRefs = { cardRefs, gridRef };
|
||||
@@ -23,7 +26,7 @@ export function ThreeCardsScrollLayoutEffect({
|
||||
|
||||
const flushLayout = () => {
|
||||
rafId = null;
|
||||
applyThreeCardsScrollLayout(refs, cardCount);
|
||||
applyThreeCardsScrollLayout(refs, cardCount, layoutOptions);
|
||||
};
|
||||
|
||||
const scheduleLayout = () => {
|
||||
@@ -33,7 +36,7 @@ export function ThreeCardsScrollLayoutEffect({
|
||||
rafId = window.requestAnimationFrame(flushLayout);
|
||||
};
|
||||
|
||||
applyThreeCardsScrollLayout(refs, cardCount);
|
||||
applyThreeCardsScrollLayout(refs, cardCount, layoutOptions);
|
||||
window.addEventListener('scroll', scheduleLayout, { passive: true });
|
||||
window.addEventListener('resize', scheduleLayout);
|
||||
return () => {
|
||||
@@ -43,7 +46,7 @@ export function ThreeCardsScrollLayoutEffect({
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}, [cardCount, cardRefs, gridRef]);
|
||||
}, [cardCount, cardRefs, gridRef, layoutOptions]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { ImageType } from '@/design-system/components/Image/types/Image';
|
||||
type ThreeCardsFeatureCardBaseType = {
|
||||
heading: HeadingType;
|
||||
body: BodyType;
|
||||
backgroundImageSrc?: string;
|
||||
backgroundImageRotationDeg?: number;
|
||||
icon: 'fast-path' | 'live-data' | 'users-group';
|
||||
};
|
||||
|
||||
|
||||
@@ -15,12 +15,35 @@ export type ThreeCardsScrollLayoutRefs = {
|
||||
gridRef: RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
const STAGGER = 0.25;
|
||||
export type ThreeCardsScrollLayoutOptions = {
|
||||
endEdgeRatio?: number;
|
||||
initialScale?: number;
|
||||
initialTranslateY?: number;
|
||||
opacityRamp?: number;
|
||||
stagger?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_SCROLL_LAYOUT_OPTIONS: Required<ThreeCardsScrollLayoutOptions> = {
|
||||
endEdgeRatio: 0.2,
|
||||
initialScale: 0.88,
|
||||
initialTranslateY: 200,
|
||||
opacityRamp: 0.4,
|
||||
stagger: 0.25,
|
||||
};
|
||||
|
||||
export function applyThreeCardsScrollLayout(
|
||||
refs: ThreeCardsScrollLayoutRefs,
|
||||
cardCount: number,
|
||||
options: ThreeCardsScrollLayoutOptions = {},
|
||||
): void {
|
||||
const {
|
||||
endEdgeRatio,
|
||||
initialScale,
|
||||
initialTranslateY,
|
||||
opacityRamp,
|
||||
stagger,
|
||||
} = { ...DEFAULT_SCROLL_LAYOUT_OPTIONS, ...options };
|
||||
|
||||
const grid = refs.gridRef.current;
|
||||
if (!grid) {
|
||||
return;
|
||||
@@ -46,7 +69,7 @@ export function applyThreeCardsScrollLayout(
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
const startEdge = viewportHeight;
|
||||
const endEdge = viewportHeight * 0.2;
|
||||
const endEdge = viewportHeight * endEdgeRatio;
|
||||
const progress = clamp01((startEdge - rect.top) / (startEdge - endEdge));
|
||||
|
||||
for (let index = 0; index < cardCount; index++) {
|
||||
@@ -55,13 +78,15 @@ export function applyThreeCardsScrollLayout(
|
||||
continue;
|
||||
}
|
||||
|
||||
const delay = index * STAGGER;
|
||||
const localProgress = clamp01((progress - delay) / (1 - delay));
|
||||
const delay = index * stagger;
|
||||
const localProgress = clamp01(
|
||||
(progress - delay) / Math.max(1 - delay, Number.EPSILON),
|
||||
);
|
||||
const eased = easeOutQuint(localProgress);
|
||||
|
||||
const translateY = (1 - eased) * 200;
|
||||
const scale = 0.88 + eased * 0.12;
|
||||
const opacity = clamp01(localProgress / 0.4);
|
||||
const translateY = (1 - eased) * initialTranslateY;
|
||||
const scale = initialScale + eased * (1 - initialScale);
|
||||
const opacity = clamp01(localProgress / opacityRamp);
|
||||
|
||||
node.style.opacity = String(opacity);
|
||||
node.style.transform = `translateY(${translateY}px) scale(${scale})`;
|
||||
|
||||