Spaces:
Running
Running
bigwolfeman
commited on
Commit
·
990cf29
1
Parent(s):
e1e6f89
particles
Browse files
frontend/src/components/GlowParticleEffect.tsx
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* GlowParticleEffect Component
|
| 3 |
+
*
|
| 4 |
+
* A wrapper component that adds soft glowing particle effects to its children.
|
| 5 |
+
* Particles spawn at click locations and float upward while fading.
|
| 6 |
+
*
|
| 7 |
+
* VISUAL ARCHITECTURE:
|
| 8 |
+
* ┌──────────────────────────────────────────────────────────┐
|
| 9 |
+
* │ Wrapper Container (position: relative) │
|
| 10 |
+
* │ ┌────────────────────────────────────────────────────┐ │
|
| 11 |
+
* │ │ Children (note content, wikilinks, etc.) │ │
|
| 12 |
+
* │ │ │ │
|
| 13 |
+
* │ │ Some text with [[wikilink]] that you can click │ │
|
| 14 |
+
* │ │ ↑ │ │
|
| 15 |
+
* │ │ Click here │ │
|
| 16 |
+
* │ └────────────────────────────────────────────────────┘ │
|
| 17 |
+
* │ ┌────────────────────────────────────────────────────┐ │
|
| 18 |
+
* │ │ Canvas Overlay (position: absolute, pointer-events:│ │
|
| 19 |
+
* │ │ none) │ │
|
| 20 |
+
* │ │ ░░▒▓██▓▒░░ │ │
|
| 21 |
+
* │ │ ░░▒▓██▓▒░░ │ │
|
| 22 |
+
* │ │ ░░▒▓██▓▒░░ <- Particles float up │ │
|
| 23 |
+
* │ │ │ │
|
| 24 |
+
* │ └────────────────────────────────────────────────────┘ │
|
| 25 |
+
* └──────────────────────────────────────────────────────────┘
|
| 26 |
+
*
|
| 27 |
+
* VISUAL FLOW:
|
| 28 |
+
* 1. User clicks on wikilink (or any child element)
|
| 29 |
+
* 2. Click event bubbles up to wrapper
|
| 30 |
+
* 3. Particles spawn at click coordinates
|
| 31 |
+
* 4. Particles animate independently on canvas
|
| 32 |
+
* 5. Canvas has pointer-events: none, so clicks pass through
|
| 33 |
+
*
|
| 34 |
+
* ACCESSIBILITY:
|
| 35 |
+
* - Respects prefers-reduced-motion
|
| 36 |
+
* - Canvas is purely decorative (no interaction)
|
| 37 |
+
* - Does not interfere with screen readers
|
| 38 |
+
*/
|
| 39 |
+
|
| 40 |
+
import React, { useRef, useCallback, useEffect, useState } from 'react';
|
| 41 |
+
import { useGlowParticles } from '@/hooks/useGlowParticles';
|
| 42 |
+
import { useReducedMotion } from '@/hooks/useReducedMotion';
|
| 43 |
+
import type { ParticleConfig, ParticlePreset } from '@/types/particles';
|
| 44 |
+
import { DEFAULT_PARTICLE_CONFIG, PARTICLE_PRESETS } from '@/types/particles';
|
| 45 |
+
|
| 46 |
+
export interface GlowParticleEffectProps {
|
| 47 |
+
/**
|
| 48 |
+
* Content to wrap with particle effect
|
| 49 |
+
*/
|
| 50 |
+
children: React.ReactNode;
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Particle configuration (colors, sizes, speeds, etc.)
|
| 54 |
+
* Can be a preset name or custom config object
|
| 55 |
+
*/
|
| 56 |
+
config?: Partial<ParticleConfig> | ParticlePreset;
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* CSS class for the wrapper container
|
| 60 |
+
*/
|
| 61 |
+
className?: string;
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Whether particles are enabled
|
| 65 |
+
* Automatically disabled when prefers-reduced-motion is set
|
| 66 |
+
*/
|
| 67 |
+
enabled?: boolean;
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* Optional callback when particles are spawned
|
| 71 |
+
* Useful for analytics or sound effects
|
| 72 |
+
*/
|
| 73 |
+
onParticleBurst?: (x: number, y: number) => void;
|
| 74 |
+
|
| 75 |
+
/**
|
| 76 |
+
* Selector for elements that should trigger particles
|
| 77 |
+
* If not provided, ANY click in the container triggers particles
|
| 78 |
+
* Example: '.wikilink' to only trigger on wikilink clicks
|
| 79 |
+
*/
|
| 80 |
+
triggerSelector?: string;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/**
|
| 84 |
+
* Resolve config from preset name or partial config
|
| 85 |
+
*/
|
| 86 |
+
function resolveConfig(
|
| 87 |
+
configProp?: Partial<ParticleConfig> | ParticlePreset
|
| 88 |
+
): ParticleConfig {
|
| 89 |
+
if (!configProp) {
|
| 90 |
+
return DEFAULT_PARTICLE_CONFIG;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// If it's a string, look up the preset
|
| 94 |
+
if (typeof configProp === 'string') {
|
| 95 |
+
return PARTICLE_PRESETS[configProp] || DEFAULT_PARTICLE_CONFIG;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// Merge with defaults
|
| 99 |
+
return { ...DEFAULT_PARTICLE_CONFIG, ...configProp };
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* GlowParticleEffect Component
|
| 104 |
+
*
|
| 105 |
+
* Wraps children with an invisible canvas overlay that renders particles.
|
| 106 |
+
*
|
| 107 |
+
* USAGE:
|
| 108 |
+
* ```tsx
|
| 109 |
+
* <GlowParticleEffect config="elegant" triggerSelector=".wikilink">
|
| 110 |
+
* <div>Content with [[wikilinks]]</div>
|
| 111 |
+
* </GlowParticleEffect>
|
| 112 |
+
* ```
|
| 113 |
+
*/
|
| 114 |
+
export function GlowParticleEffect({
|
| 115 |
+
children,
|
| 116 |
+
config: configProp,
|
| 117 |
+
className = '',
|
| 118 |
+
enabled = true,
|
| 119 |
+
onParticleBurst,
|
| 120 |
+
triggerSelector,
|
| 121 |
+
}: GlowParticleEffectProps) {
|
| 122 |
+
// Check accessibility preference
|
| 123 |
+
const prefersReducedMotion = useReducedMotion();
|
| 124 |
+
|
| 125 |
+
// Resolve final configuration
|
| 126 |
+
const config = resolveConfig(configProp);
|
| 127 |
+
|
| 128 |
+
// Determine if particles should be active
|
| 129 |
+
const isDisabled = prefersReducedMotion || !enabled;
|
| 130 |
+
|
| 131 |
+
// Reference to wrapper for coordinate calculation
|
| 132 |
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
| 133 |
+
|
| 134 |
+
// Canvas dimensions state
|
| 135 |
+
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
| 136 |
+
|
| 137 |
+
// Initialize particle system
|
| 138 |
+
const { canvasRef, createParticleBurst } = useGlowParticles(config, isDisabled);
|
| 139 |
+
|
| 140 |
+
/**
|
| 141 |
+
* UPDATE CANVAS DIMENSIONS
|
| 142 |
+
*
|
| 143 |
+
* VISUAL PURPOSE: Canvas must match container size for correct positioning.
|
| 144 |
+
* If canvas is smaller than container, particles would be clipped.
|
| 145 |
+
* If larger, particles could render in invisible area.
|
| 146 |
+
*
|
| 147 |
+
* We use ResizeObserver to handle:
|
| 148 |
+
* - Initial mount
|
| 149 |
+
* - Window resize
|
| 150 |
+
* - Layout changes
|
| 151 |
+
*/
|
| 152 |
+
useEffect(() => {
|
| 153 |
+
const wrapper = wrapperRef.current;
|
| 154 |
+
if (!wrapper) return;
|
| 155 |
+
|
| 156 |
+
const updateDimensions = () => {
|
| 157 |
+
const rect = wrapper.getBoundingClientRect();
|
| 158 |
+
setDimensions({
|
| 159 |
+
width: rect.width,
|
| 160 |
+
height: rect.height,
|
| 161 |
+
});
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
// Initial measurement
|
| 165 |
+
updateDimensions();
|
| 166 |
+
|
| 167 |
+
// Watch for size changes
|
| 168 |
+
const resizeObserver = new ResizeObserver(updateDimensions);
|
| 169 |
+
resizeObserver.observe(wrapper);
|
| 170 |
+
|
| 171 |
+
return () => {
|
| 172 |
+
resizeObserver.disconnect();
|
| 173 |
+
};
|
| 174 |
+
}, []);
|
| 175 |
+
|
| 176 |
+
/**
|
| 177 |
+
* HANDLE CLICK FOR PARTICLE SPAWN
|
| 178 |
+
*
|
| 179 |
+
* VISUAL FLOW:
|
| 180 |
+
* 1. User clicks somewhere in the wrapper
|
| 181 |
+
* 2. We check if click target matches triggerSelector (if provided)
|
| 182 |
+
* 3. We calculate click position relative to canvas
|
| 183 |
+
* 4. We spawn particles at that position
|
| 184 |
+
*
|
| 185 |
+
* The particles appear exactly where the user clicked,
|
| 186 |
+
* creating a direct visual response to the interaction.
|
| 187 |
+
*/
|
| 188 |
+
const handleClick = useCallback(
|
| 189 |
+
(event: React.MouseEvent<HTMLDivElement>) => {
|
| 190 |
+
// Skip if disabled
|
| 191 |
+
if (isDisabled) return;
|
| 192 |
+
|
| 193 |
+
// Check if trigger selector is specified
|
| 194 |
+
if (triggerSelector) {
|
| 195 |
+
// Find if click target matches selector (or is inside matching element)
|
| 196 |
+
const target = event.target as HTMLElement;
|
| 197 |
+
const matchingElement = target.closest(triggerSelector);
|
| 198 |
+
if (!matchingElement) {
|
| 199 |
+
// Click was not on a triggering element
|
| 200 |
+
return;
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
const wrapper = wrapperRef.current;
|
| 205 |
+
if (!wrapper) return;
|
| 206 |
+
|
| 207 |
+
/**
|
| 208 |
+
* CALCULATE RELATIVE COORDINATES
|
| 209 |
+
*
|
| 210 |
+
* VISUAL: event.clientX/Y are viewport-relative
|
| 211 |
+
* We need coordinates relative to the canvas origin (top-left of wrapper)
|
| 212 |
+
*
|
| 213 |
+
* rect.left/top give us the wrapper's position in the viewport
|
| 214 |
+
* Subtracting gives us the click position within the wrapper
|
| 215 |
+
*/
|
| 216 |
+
const rect = wrapper.getBoundingClientRect();
|
| 217 |
+
const x = event.clientX - rect.left;
|
| 218 |
+
const y = event.clientY - rect.top;
|
| 219 |
+
|
| 220 |
+
// Spawn particles at click location
|
| 221 |
+
createParticleBurst(x, y);
|
| 222 |
+
|
| 223 |
+
// Notify callback if provided
|
| 224 |
+
onParticleBurst?.(x, y);
|
| 225 |
+
},
|
| 226 |
+
[isDisabled, triggerSelector, createParticleBurst, onParticleBurst]
|
| 227 |
+
);
|
| 228 |
+
|
| 229 |
+
return (
|
| 230 |
+
<div
|
| 231 |
+
ref={wrapperRef}
|
| 232 |
+
className={`relative ${className}`}
|
| 233 |
+
onClick={handleClick}
|
| 234 |
+
>
|
| 235 |
+
{/* Children (the actual content) */}
|
| 236 |
+
{children}
|
| 237 |
+
|
| 238 |
+
{/*
|
| 239 |
+
CANVAS OVERLAY
|
| 240 |
+
|
| 241 |
+
VISUAL ARCHITECTURE:
|
| 242 |
+
- Positioned absolutely to cover the entire wrapper
|
| 243 |
+
- pointer-events: none allows clicks to pass through to children
|
| 244 |
+
- z-index ensures particles render above content
|
| 245 |
+
|
| 246 |
+
VISUAL: The canvas is invisible until particles are drawn on it.
|
| 247 |
+
It acts as a transparent layer floating above the content.
|
| 248 |
+
*/}
|
| 249 |
+
{!isDisabled && (
|
| 250 |
+
<canvas
|
| 251 |
+
ref={canvasRef}
|
| 252 |
+
width={dimensions.width}
|
| 253 |
+
height={dimensions.height}
|
| 254 |
+
className="absolute inset-0 pointer-events-none z-10"
|
| 255 |
+
style={{
|
| 256 |
+
// Ensure canvas covers entire container
|
| 257 |
+
width: '100%',
|
| 258 |
+
height: '100%',
|
| 259 |
+
}}
|
| 260 |
+
// Accessibility: Mark as decorative/presentation
|
| 261 |
+
role="presentation"
|
| 262 |
+
aria-hidden="true"
|
| 263 |
+
/>
|
| 264 |
+
)}
|
| 265 |
+
</div>
|
| 266 |
+
);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
/**
|
| 270 |
+
* Re-export presets for convenience
|
| 271 |
+
*/
|
| 272 |
+
export { PARTICLE_PRESETS } from '@/types/particles';
|
| 273 |
+
export type { ParticleConfig, ParticlePreset } from '@/types/particles';
|
| 274 |
+
|
| 275 |
+
export default GlowParticleEffect;
|
frontend/src/components/NoteViewer.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
* T081-T082: Wikilink click handling and broken link styling
|
| 4 |
* T009: Font size buttons (A-, A, A+) for content adjustment
|
| 5 |
* T037-T052: Table of Contents panel integration
|
|
|
|
| 6 |
*/
|
| 7 |
import { useMemo, useEffect } from 'react';
|
| 8 |
import ReactMarkdown from 'react-markdown';
|
|
@@ -26,6 +27,7 @@ import { createWikilinkComponent, resetSlugCache } from '@/lib/markdown.tsx';
|
|
| 26 |
import { markdownToPlainText } from '@/lib/markdownToText';
|
| 27 |
import { useTableOfContents } from '@/hooks/useTableOfContents';
|
| 28 |
import { TableOfContents } from '@/components/TableOfContents';
|
|
|
|
| 29 |
|
| 30 |
type FontSizePreset = 'small' | 'medium' | 'large';
|
| 31 |
|
|
@@ -238,7 +240,24 @@ export function NoteViewer({
|
|
| 238 |
{/* Main content panel */}
|
| 239 |
<ResizablePanel defaultSize={isTocOpen ? 75 : 100}>
|
| 240 |
<ScrollArea className="h-full p-6">
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
<ReactMarkdown
|
| 243 |
remarkPlugins={[remarkGfm]}
|
| 244 |
components={markdownComponents}
|
|
@@ -246,7 +265,7 @@ export function NoteViewer({
|
|
| 246 |
>
|
| 247 |
{processedBody}
|
| 248 |
</ReactMarkdown>
|
| 249 |
-
</
|
| 250 |
|
| 251 |
<Separator className="my-8" />
|
| 252 |
|
|
|
|
| 3 |
* T081-T082: Wikilink click handling and broken link styling
|
| 4 |
* T009: Font size buttons (A-, A, A+) for content adjustment
|
| 5 |
* T037-T052: Table of Contents panel integration
|
| 6 |
+
* PARTICLE EFFECT: Soft glowing particles on wikilink clicks
|
| 7 |
*/
|
| 8 |
import { useMemo, useEffect } from 'react';
|
| 9 |
import ReactMarkdown from 'react-markdown';
|
|
|
|
| 27 |
import { markdownToPlainText } from '@/lib/markdownToText';
|
| 28 |
import { useTableOfContents } from '@/hooks/useTableOfContents';
|
| 29 |
import { TableOfContents } from '@/components/TableOfContents';
|
| 30 |
+
import { GlowParticleEffect } from '@/components/GlowParticleEffect';
|
| 31 |
|
| 32 |
type FontSizePreset = 'small' | 'medium' | 'large';
|
| 33 |
|
|
|
|
| 240 |
{/* Main content panel */}
|
| 241 |
<ResizablePanel defaultSize={isTocOpen ? 75 : 100}>
|
| 242 |
<ScrollArea className="h-full p-6">
|
| 243 |
+
{/*
|
| 244 |
+
PARTICLE EFFECT INTEGRATION
|
| 245 |
+
|
| 246 |
+
VISUAL: Wraps the markdown content with a canvas overlay.
|
| 247 |
+
When users click on wikilinks (.wikilink elements), soft
|
| 248 |
+
glowing particles spawn at the click location.
|
| 249 |
+
|
| 250 |
+
TRIGGER: Only wikilink clicks spawn particles (triggerSelector)
|
| 251 |
+
STYLE: "elegant" preset - balanced size, moderate glow, ~1 second fade
|
| 252 |
+
|
| 253 |
+
The particles float upward and fade out, creating a subtle
|
| 254 |
+
visual acknowledgment of the navigation action.
|
| 255 |
+
*/}
|
| 256 |
+
<GlowParticleEffect
|
| 257 |
+
config="elegant"
|
| 258 |
+
triggerSelector=".wikilink"
|
| 259 |
+
className="prose prose-slate dark:prose-invert max-w-none animate-fade-in-smooth"
|
| 260 |
+
>
|
| 261 |
<ReactMarkdown
|
| 262 |
remarkPlugins={[remarkGfm]}
|
| 263 |
components={markdownComponents}
|
|
|
|
| 265 |
>
|
| 266 |
{processedBody}
|
| 267 |
</ReactMarkdown>
|
| 268 |
+
</GlowParticleEffect>
|
| 269 |
|
| 270 |
<Separator className="my-8" />
|
| 271 |
|
frontend/src/hooks/useGlowParticles.ts
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* useGlowParticles Hook
|
| 3 |
+
*
|
| 4 |
+
* Core particle system hook that manages creation, animation, and rendering
|
| 5 |
+
* of soft glowing particles on a canvas element.
|
| 6 |
+
*
|
| 7 |
+
* VISUAL ARCHITECTURE:
|
| 8 |
+
* ┌─────────────────────────────────────────────────────┐
|
| 9 |
+
* │ Canvas (positioned absolutely over content) │
|
| 10 |
+
* │ │
|
| 11 |
+
* │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
|
| 12 |
+
* │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
|
| 13 |
+
* │ ░░░░░░░░░░░▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ <- Particles float
|
| 14 |
+
* │ ░░░░░░░░░░▓███▓░░░░░░░░░░▒▒▒░░░░░░░░░░░░░░░ │ upward and fade
|
| 15 |
+
* │ ░░░░░░░░░░░▓▓▓░░░░░░░░░░▒███▒░░░░░░░░░░░░░░ │
|
| 16 |
+
* │ ░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒░░░░░░░░░░░░░░░ │
|
| 17 |
+
* │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
|
| 18 |
+
* │ ↑ │
|
| 19 |
+
* │ Click here │
|
| 20 |
+
* │ [Wikilink Text] │
|
| 21 |
+
* └─────────────────────────────────────────────────────┘
|
| 22 |
+
*
|
| 23 |
+
* Each particle (▓██▓ above) consists of:
|
| 24 |
+
* - Bright center (█) - solid color
|
| 25 |
+
* - Gradient falloff (▓) - fading to transparent
|
| 26 |
+
* - Soft glow halo (▒) - shadowBlur effect
|
| 27 |
+
*/
|
| 28 |
+
|
| 29 |
+
import { useRef, useCallback, useEffect } from 'react';
|
| 30 |
+
import type { Particle, ParticleConfig } from '@/types/particles';
|
| 31 |
+
import { DEFAULT_PARTICLE_CONFIG } from '@/types/particles';
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* Parse hex color to RGB components
|
| 35 |
+
*
|
| 36 |
+
* VISUAL PURPOSE: We need RGB values to create gradient stops with
|
| 37 |
+
* varying alpha (transparency) values. Hex colors don't support alpha
|
| 38 |
+
* in the same way, so we convert to rgba() format.
|
| 39 |
+
*
|
| 40 |
+
* Example: '#6366f1' -> { r: 99, g: 102, b: 241 }
|
| 41 |
+
*/
|
| 42 |
+
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
| 43 |
+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
| 44 |
+
return result
|
| 45 |
+
? {
|
| 46 |
+
r: parseInt(result[1], 16),
|
| 47 |
+
g: parseInt(result[2], 16),
|
| 48 |
+
b: parseInt(result[3], 16),
|
| 49 |
+
}
|
| 50 |
+
: { r: 99, g: 102, b: 241 }; // Fallback to indigo
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/**
|
| 54 |
+
* Generate a random number within a range
|
| 55 |
+
*/
|
| 56 |
+
function randomBetween(min: number, max: number): number {
|
| 57 |
+
return Math.random() * (max - min) + min;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/**
|
| 61 |
+
* Unique ID generator for particles
|
| 62 |
+
*/
|
| 63 |
+
let particleIdCounter = 0;
|
| 64 |
+
|
| 65 |
+
export interface UseGlowParticlesReturn {
|
| 66 |
+
/**
|
| 67 |
+
* Ref to attach to the canvas element
|
| 68 |
+
* The canvas should be positioned absolutely over the content area
|
| 69 |
+
*/
|
| 70 |
+
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* Trigger a particle burst at specific coordinates
|
| 74 |
+
* @param x - X coordinate relative to canvas
|
| 75 |
+
* @param y - Y coordinate relative to canvas
|
| 76 |
+
*
|
| 77 |
+
* VISUAL: Creates particleCount particles at (x,y) that burst outward
|
| 78 |
+
* in random directions
|
| 79 |
+
*/
|
| 80 |
+
createParticleBurst: (x: number, y: number) => void;
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Check if animation loop is currently running
|
| 84 |
+
*/
|
| 85 |
+
isAnimating: boolean;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/**
|
| 89 |
+
* Main particle system hook
|
| 90 |
+
*
|
| 91 |
+
* @param config - Particle visual configuration (colors, sizes, speeds, etc.)
|
| 92 |
+
* @param disabled - If true, particles won't be created (accessibility)
|
| 93 |
+
* @returns Canvas ref and burst trigger function
|
| 94 |
+
*
|
| 95 |
+
* VISUAL LIFECYCLE OVERVIEW:
|
| 96 |
+
* 1. User clicks -> createParticleBurst(x, y) called
|
| 97 |
+
* 2. Particles spawn at click point with random velocities
|
| 98 |
+
* 3. Animation loop runs at 60 FPS:
|
| 99 |
+
* a. Clear previous frame
|
| 100 |
+
* b. Update each particle (position, life, size)
|
| 101 |
+
* c. Draw each particle (gradient + glow)
|
| 102 |
+
* d. Remove dead particles (life <= 0)
|
| 103 |
+
* 4. Loop stops when all particles are gone (performance optimization)
|
| 104 |
+
*/
|
| 105 |
+
export function useGlowParticles(
|
| 106 |
+
config: ParticleConfig = DEFAULT_PARTICLE_CONFIG,
|
| 107 |
+
disabled: boolean = false
|
| 108 |
+
): UseGlowParticlesReturn {
|
| 109 |
+
// Mutable refs that persist across renders without causing re-renders
|
| 110 |
+
const particlesRef = useRef<Particle[]>([]);
|
| 111 |
+
const animationFrameRef = useRef<number | null>(null);
|
| 112 |
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
| 113 |
+
const isAnimatingRef = useRef<boolean>(false);
|
| 114 |
+
|
| 115 |
+
/**
|
| 116 |
+
* CREATE PARTICLE BURST
|
| 117 |
+
*
|
| 118 |
+
* VISUAL EFFECT: When called, spawns N particles at the click location.
|
| 119 |
+
* Each particle:
|
| 120 |
+
* - Starts at (x, y)
|
| 121 |
+
* - Gets a random velocity in a random direction
|
| 122 |
+
* - Gets a random size and color from config
|
| 123 |
+
* - Has full life (1.0) at spawn
|
| 124 |
+
*
|
| 125 |
+
* The "burst" pattern comes from random angles - particles explode
|
| 126 |
+
* outward in all directions like a tiny firework.
|
| 127 |
+
*
|
| 128 |
+
* Visual analogy: Like tapping a dandelion and seeds flying off
|
| 129 |
+
*/
|
| 130 |
+
const createParticleBurst = useCallback(
|
| 131 |
+
(x: number, y: number) => {
|
| 132 |
+
// Skip if disabled (accessibility) or no canvas
|
| 133 |
+
if (disabled) return;
|
| 134 |
+
|
| 135 |
+
const newParticles: Particle[] = [];
|
| 136 |
+
|
| 137 |
+
for (let i = 0; i < config.particleCount; i++) {
|
| 138 |
+
/**
|
| 139 |
+
* RANDOM ANGLE for burst direction
|
| 140 |
+
*
|
| 141 |
+
* VISUAL: Math.random() * Math.PI * 2 gives angle from 0 to 2π
|
| 142 |
+
* This means particles fly in ALL directions (360 degrees)
|
| 143 |
+
*
|
| 144 |
+
* Alternative patterns:
|
| 145 |
+
* - Math.PI * 1.5 to Math.PI * 2.5 = upward-biased cone
|
| 146 |
+
* - Fixed angle = particles move in same direction
|
| 147 |
+
*/
|
| 148 |
+
const angle = Math.random() * Math.PI * 2;
|
| 149 |
+
|
| 150 |
+
/**
|
| 151 |
+
* RANDOM SPEED within configured range
|
| 152 |
+
*
|
| 153 |
+
* VISUAL: Variation in speed creates depth - some particles
|
| 154 |
+
* appear to move faster/closer while others drift slowly
|
| 155 |
+
*/
|
| 156 |
+
const speed = randomBetween(config.minSpeed, config.maxSpeed);
|
| 157 |
+
|
| 158 |
+
/**
|
| 159 |
+
* VELOCITY COMPONENTS from angle and speed
|
| 160 |
+
*
|
| 161 |
+
* VISUAL:
|
| 162 |
+
* - vx = speed * cos(angle): horizontal component
|
| 163 |
+
* - vy = speed * sin(angle): vertical component
|
| 164 |
+
*
|
| 165 |
+
* Together, these create the direction of movement
|
| 166 |
+
*/
|
| 167 |
+
const vx = Math.cos(angle) * speed;
|
| 168 |
+
const vy = Math.sin(angle) * speed;
|
| 169 |
+
|
| 170 |
+
/**
|
| 171 |
+
* RANDOM SIZE for visual variety
|
| 172 |
+
*
|
| 173 |
+
* VISUAL: Mix of sizes creates depth and interest.
|
| 174 |
+
* Smaller particles appear more distant/delicate.
|
| 175 |
+
*/
|
| 176 |
+
const size = randomBetween(config.minSize, config.maxSize);
|
| 177 |
+
|
| 178 |
+
/**
|
| 179 |
+
* RANDOM COLOR from palette
|
| 180 |
+
*
|
| 181 |
+
* VISUAL: Each particle gets one of the configured colors.
|
| 182 |
+
* The gradient will fade from this color (center) to transparent (edge).
|
| 183 |
+
*/
|
| 184 |
+
const color = config.colors[Math.floor(Math.random() * config.colors.length)];
|
| 185 |
+
|
| 186 |
+
newParticles.push({
|
| 187 |
+
x,
|
| 188 |
+
y,
|
| 189 |
+
vx,
|
| 190 |
+
vy,
|
| 191 |
+
size,
|
| 192 |
+
life: 1.0, // Full life at spawn (completely visible)
|
| 193 |
+
color,
|
| 194 |
+
id: particleIdCounter++,
|
| 195 |
+
});
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
// Add new particles to existing ones
|
| 199 |
+
particlesRef.current = [...particlesRef.current, ...newParticles];
|
| 200 |
+
|
| 201 |
+
// Start animation if not already running
|
| 202 |
+
if (!isAnimatingRef.current) {
|
| 203 |
+
isAnimatingRef.current = true;
|
| 204 |
+
animationFrameRef.current = requestAnimationFrame(animate);
|
| 205 |
+
}
|
| 206 |
+
},
|
| 207 |
+
[config, disabled]
|
| 208 |
+
);
|
| 209 |
+
|
| 210 |
+
/**
|
| 211 |
+
* DRAW SINGLE PARTICLE
|
| 212 |
+
*
|
| 213 |
+
* This is where the visual magic happens. Each particle is rendered as:
|
| 214 |
+
* 1. A radial gradient (bright center -> transparent edge)
|
| 215 |
+
* 2. With a soft glow effect (shadowBlur)
|
| 216 |
+
*
|
| 217 |
+
* VISUAL BREAKDOWN:
|
| 218 |
+
*
|
| 219 |
+
* ░░░░░░░░░░░░░░░ <- Glow halo (shadowBlur)
|
| 220 |
+
* ░░░░░░░░░░░░░░░░░
|
| 221 |
+
* ░░░░░▒▒▒▒▒▒▒░░░░░░ <- Outer gradient (50% opacity, fading)
|
| 222 |
+
* ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░
|
| 223 |
+
* ░░░░▒▒▒▓▓▓▓▓▒▒▒░░░░░ <- Mid gradient (70% opacity)
|
| 224 |
+
* ░░░░▒▒▓▓███▓▓▒▒░░░░░ <- Inner core (100% opacity)
|
| 225 |
+
* ░░░░▒▒▒▓▓▓▓▓▒▒▒░░░░░
|
| 226 |
+
* ░░░░▒▒▒▒▒▒▒▒▒░░░░░░
|
| 227 |
+
* ░░░░░▒▒▒▒▒▒▒░░░░░░
|
| 228 |
+
* ░░░░░░░░░░░░░░░░░
|
| 229 |
+
* ░░░░░░░░░░░░░░░
|
| 230 |
+
*/
|
| 231 |
+
const drawParticle = useCallback(
|
| 232 |
+
(ctx: CanvasRenderingContext2D, particle: Particle) => {
|
| 233 |
+
// Calculate current visual properties based on life
|
| 234 |
+
// As life decreases, particle becomes smaller and more transparent
|
| 235 |
+
const currentSize = particle.size * particle.life;
|
| 236 |
+
const currentOpacity = particle.life;
|
| 237 |
+
|
| 238 |
+
// Skip if too small to see
|
| 239 |
+
if (currentSize < 0.5) return;
|
| 240 |
+
|
| 241 |
+
// Parse the base color to RGB for gradient creation
|
| 242 |
+
const rgb = hexToRgb(particle.color);
|
| 243 |
+
|
| 244 |
+
/**
|
| 245 |
+
* CREATE RADIAL GRADIENT
|
| 246 |
+
*
|
| 247 |
+
* ctx.createRadialGradient(x0, y0, r0, x1, y1, r1)
|
| 248 |
+
*
|
| 249 |
+
* PARAMETERS EXPLAINED:
|
| 250 |
+
* - (x0, y0, r0): Inner circle - center of particle, radius 0
|
| 251 |
+
* - (x1, y1, r1): Outer circle - center of particle, radius = currentSize/2
|
| 252 |
+
*
|
| 253 |
+
* VISUAL: The gradient will interpolate colors from the inner circle
|
| 254 |
+
* to the outer circle. Since both circles share the same center,
|
| 255 |
+
* this creates a circular gradient radiating outward.
|
| 256 |
+
*
|
| 257 |
+
* Inner (r0=0) Outer (r1=radius)
|
| 258 |
+
* █ ░░░░░░░░░░
|
| 259 |
+
* ↓ ↓
|
| 260 |
+
* [solid] -> [transparent]
|
| 261 |
+
*/
|
| 262 |
+
const radius = currentSize / 2;
|
| 263 |
+
const gradient = ctx.createRadialGradient(
|
| 264 |
+
particle.x,
|
| 265 |
+
particle.y,
|
| 266 |
+
0, // Inner circle: center point, radius 0 (a dot)
|
| 267 |
+
particle.x,
|
| 268 |
+
particle.y,
|
| 269 |
+
radius // Outer circle: same center, radius = particle size
|
| 270 |
+
);
|
| 271 |
+
|
| 272 |
+
/**
|
| 273 |
+
* ADD COLOR STOPS
|
| 274 |
+
*
|
| 275 |
+
* Color stops define how the gradient transitions from center to edge.
|
| 276 |
+
*
|
| 277 |
+
* VISUAL EFFECT OF EACH STOP:
|
| 278 |
+
*
|
| 279 |
+
* Stop 0.0 (center): Full color, full opacity
|
| 280 |
+
* - This is the "hot spot" - brightest point
|
| 281 |
+
* - Like the white center of a candle flame
|
| 282 |
+
*
|
| 283 |
+
* Stop 0.4 (40% radius): Full color, 70% opacity
|
| 284 |
+
* - Still bright but starting to fade
|
| 285 |
+
* - Creates a solid-looking core
|
| 286 |
+
*
|
| 287 |
+
* Stop 0.7 (70% radius): Full color, 30% opacity
|
| 288 |
+
* - Clearly fading now
|
| 289 |
+
* - The "warm glow" zone
|
| 290 |
+
*
|
| 291 |
+
* Stop 1.0 (edge): Full color, 0% opacity
|
| 292 |
+
* - Completely transparent
|
| 293 |
+
* - No hard edge - particle "melts" into background
|
| 294 |
+
*
|
| 295 |
+
* The currentOpacity multiplier makes the whole particle
|
| 296 |
+
* more transparent as it ages (life decreases from 1 to 0)
|
| 297 |
+
*/
|
| 298 |
+
gradient.addColorStop(
|
| 299 |
+
0,
|
| 300 |
+
`rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${1.0 * currentOpacity})`
|
| 301 |
+
);
|
| 302 |
+
gradient.addColorStop(
|
| 303 |
+
0.4,
|
| 304 |
+
`rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${0.7 * currentOpacity})`
|
| 305 |
+
);
|
| 306 |
+
gradient.addColorStop(
|
| 307 |
+
0.7,
|
| 308 |
+
`rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${0.3 * currentOpacity})`
|
| 309 |
+
);
|
| 310 |
+
gradient.addColorStop(
|
| 311 |
+
1,
|
| 312 |
+
`rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0)`
|
| 313 |
+
);
|
| 314 |
+
|
| 315 |
+
/**
|
| 316 |
+
* APPLY GLOW EFFECT (shadowBlur)
|
| 317 |
+
*
|
| 318 |
+
* VISUAL: shadowBlur creates a soft halo around whatever we draw.
|
| 319 |
+
* Unlike the radial gradient (which IS the particle shape),
|
| 320 |
+
* the shadow extends BEYOND the particle boundary.
|
| 321 |
+
*
|
| 322 |
+
* Think of it like:
|
| 323 |
+
* - Radial gradient = the physical LED light bulb
|
| 324 |
+
* - shadowBlur = the glow you see around the bulb
|
| 325 |
+
*
|
| 326 |
+
* shadowBlur = 15 means:
|
| 327 |
+
* - A 15-pixel soft blur extends beyond the particle
|
| 328 |
+
* - Color matches shadowColor (the particle's color)
|
| 329 |
+
* - Intensity determined by currentOpacity
|
| 330 |
+
*
|
| 331 |
+
* As particle life decreases:
|
| 332 |
+
* - Glow intensity decreases proportionally
|
| 333 |
+
* - Creates the effect of "cooling down" or "fading away"
|
| 334 |
+
*/
|
| 335 |
+
ctx.shadowBlur = config.glowIntensity * currentOpacity;
|
| 336 |
+
ctx.shadowColor = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${0.6 * currentOpacity})`;
|
| 337 |
+
|
| 338 |
+
/**
|
| 339 |
+
* DRAW THE PARTICLE
|
| 340 |
+
*
|
| 341 |
+
* ctx.arc(x, y, radius, startAngle, endAngle)
|
| 342 |
+
* - Draws a circle (full arc = 0 to 2π)
|
| 343 |
+
* - Fill with our radial gradient
|
| 344 |
+
*
|
| 345 |
+
* VISUAL: The combination of radial gradient + shadowBlur creates:
|
| 346 |
+
* - Bright core (gradient center)
|
| 347 |
+
* - Soft falloff (gradient edge)
|
| 348 |
+
* - Extended glow (shadow blur)
|
| 349 |
+
*
|
| 350 |
+
* Together: a soft, glowing orb that looks like a tiny light source
|
| 351 |
+
*/
|
| 352 |
+
ctx.beginPath();
|
| 353 |
+
ctx.arc(particle.x, particle.y, radius, 0, Math.PI * 2);
|
| 354 |
+
ctx.fillStyle = gradient;
|
| 355 |
+
ctx.fill();
|
| 356 |
+
|
| 357 |
+
// Reset shadow to avoid affecting other drawings
|
| 358 |
+
ctx.shadowBlur = 0;
|
| 359 |
+
ctx.shadowColor = 'transparent';
|
| 360 |
+
},
|
| 361 |
+
[config.glowIntensity]
|
| 362 |
+
);
|
| 363 |
+
|
| 364 |
+
/**
|
| 365 |
+
* ANIMATION LOOP
|
| 366 |
+
*
|
| 367 |
+
* This runs at approximately 60 frames per second (synced with display).
|
| 368 |
+
* Each frame:
|
| 369 |
+
* 1. Clear the previous frame (canvas becomes transparent)
|
| 370 |
+
* 2. Update each particle's physics (position, life, size)
|
| 371 |
+
* 3. Draw each particle in its new state
|
| 372 |
+
* 4. Remove particles that have "died" (life <= 0)
|
| 373 |
+
* 5. Continue loop if particles remain, else stop
|
| 374 |
+
*
|
| 375 |
+
* VISUAL FLOW:
|
| 376 |
+
* Frame 0: Particles at spawn position, full brightness
|
| 377 |
+
* Frame 30: Particles have moved, 50% life remaining
|
| 378 |
+
* Frame 60: Particles nearly gone, very faint
|
| 379 |
+
* Frame 67: All particles dead, loop stops
|
| 380 |
+
*
|
| 381 |
+
* Performance note: Loop only runs when particles exist.
|
| 382 |
+
* When no particles remain, we stop to save CPU.
|
| 383 |
+
*/
|
| 384 |
+
const animate = useCallback(() => {
|
| 385 |
+
const canvas = canvasRef.current;
|
| 386 |
+
if (!canvas) {
|
| 387 |
+
isAnimatingRef.current = false;
|
| 388 |
+
return;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
const ctx = canvas.getContext('2d');
|
| 392 |
+
if (!ctx) {
|
| 393 |
+
isAnimatingRef.current = false;
|
| 394 |
+
return;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
/**
|
| 398 |
+
* CLEAR PREVIOUS FRAME
|
| 399 |
+
*
|
| 400 |
+
* VISUAL: Wipes the canvas completely transparent.
|
| 401 |
+
* Without this, particles would leave "trails" as they move.
|
| 402 |
+
*
|
| 403 |
+
* Alternative: Use ctx.fillRect with semi-transparent color
|
| 404 |
+
* for intentional trail/afterimage effects (not desired here).
|
| 405 |
+
*/
|
| 406 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 407 |
+
|
| 408 |
+
/**
|
| 409 |
+
* UPDATE AND DRAW EACH PARTICLE
|
| 410 |
+
*/
|
| 411 |
+
particlesRef.current = particlesRef.current.filter((particle) => {
|
| 412 |
+
/**
|
| 413 |
+
* UPDATE POSITION
|
| 414 |
+
*
|
| 415 |
+
* VISUAL: Move particle by its velocity
|
| 416 |
+
* - x += vx: horizontal movement
|
| 417 |
+
* - y += vy: vertical movement
|
| 418 |
+
*
|
| 419 |
+
* Result: Particle drifts in its assigned direction
|
| 420 |
+
*/
|
| 421 |
+
particle.x += particle.vx;
|
| 422 |
+
particle.y += particle.vy;
|
| 423 |
+
|
| 424 |
+
/**
|
| 425 |
+
* APPLY UPWARD DRIFT
|
| 426 |
+
*
|
| 427 |
+
* VISUAL: Adds small upward acceleration each frame
|
| 428 |
+
* Negative vy = moving up (canvas y increases downward)
|
| 429 |
+
*
|
| 430 |
+
* Effect: Particles gradually curve upward like:
|
| 431 |
+
* - Sparks rising from a fire
|
| 432 |
+
* - Bubbles floating in liquid
|
| 433 |
+
* - Heat shimmer rising
|
| 434 |
+
*/
|
| 435 |
+
particle.vy += config.upwardDrift;
|
| 436 |
+
|
| 437 |
+
/**
|
| 438 |
+
* UPDATE LIFE (opacity)
|
| 439 |
+
*
|
| 440 |
+
* VISUAL: Decrease life each frame
|
| 441 |
+
* life goes from 1.0 -> 0.0 over fadeRate * frames
|
| 442 |
+
*
|
| 443 |
+
* At 60 FPS with fadeRate 0.016:
|
| 444 |
+
* 1.0 / 0.016 = 62.5 frames = ~1 second to fade out
|
| 445 |
+
*/
|
| 446 |
+
particle.life -= config.fadeRate;
|
| 447 |
+
|
| 448 |
+
/**
|
| 449 |
+
* UPDATE SIZE (shrinking)
|
| 450 |
+
*
|
| 451 |
+
* VISUAL: Multiply size by shrinkRate each frame
|
| 452 |
+
* shrinkRate 0.985 means particle is 98.5% of previous size
|
| 453 |
+
*
|
| 454 |
+
* After 60 frames: 0.985^60 = 0.40 = 40% of original size
|
| 455 |
+
*
|
| 456 |
+
* Combined with fade, creates "dissipating" effect
|
| 457 |
+
*/
|
| 458 |
+
particle.size *= config.shrinkRate;
|
| 459 |
+
|
| 460 |
+
/**
|
| 461 |
+
* DRAW THE PARTICLE
|
| 462 |
+
*
|
| 463 |
+
* Only if still alive (visible)
|
| 464 |
+
*/
|
| 465 |
+
if (particle.life > 0) {
|
| 466 |
+
drawParticle(ctx, particle);
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
/**
|
| 470 |
+
* KEEP OR REMOVE
|
| 471 |
+
*
|
| 472 |
+
* Return true to keep particle in array, false to remove
|
| 473 |
+
* Remove when life <= 0 (completely faded out)
|
| 474 |
+
*/
|
| 475 |
+
return particle.life > 0;
|
| 476 |
+
});
|
| 477 |
+
|
| 478 |
+
/**
|
| 479 |
+
* CONTINUE OR STOP LOOP
|
| 480 |
+
*
|
| 481 |
+
* VISUAL: If particles remain, schedule next frame
|
| 482 |
+
* If no particles, stop the animation loop
|
| 483 |
+
*
|
| 484 |
+
* Performance: Prevents unnecessary CPU usage when idle
|
| 485 |
+
*/
|
| 486 |
+
if (particlesRef.current.length > 0) {
|
| 487 |
+
animationFrameRef.current = requestAnimationFrame(animate);
|
| 488 |
+
} else {
|
| 489 |
+
isAnimatingRef.current = false;
|
| 490 |
+
animationFrameRef.current = null;
|
| 491 |
+
}
|
| 492 |
+
}, [config.fadeRate, config.shrinkRate, config.upwardDrift, drawParticle]);
|
| 493 |
+
|
| 494 |
+
/**
|
| 495 |
+
* CLEANUP ON UNMOUNT
|
| 496 |
+
*
|
| 497 |
+
* Cancel any pending animation frame to prevent memory leaks
|
| 498 |
+
* and errors from drawing on unmounted canvas
|
| 499 |
+
*/
|
| 500 |
+
useEffect(() => {
|
| 501 |
+
return () => {
|
| 502 |
+
if (animationFrameRef.current !== null) {
|
| 503 |
+
cancelAnimationFrame(animationFrameRef.current);
|
| 504 |
+
}
|
| 505 |
+
};
|
| 506 |
+
}, []);
|
| 507 |
+
|
| 508 |
+
return {
|
| 509 |
+
canvasRef,
|
| 510 |
+
createParticleBurst,
|
| 511 |
+
isAnimating: isAnimatingRef.current,
|
| 512 |
+
};
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
export default useGlowParticles;
|
frontend/src/hooks/useReducedMotion.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* useReducedMotion Hook
|
| 3 |
+
*
|
| 4 |
+
* Accessibility hook that detects user's motion preference.
|
| 5 |
+
* When enabled, particle effects should be disabled or minimized.
|
| 6 |
+
*
|
| 7 |
+
* VISUAL ACCESSIBILITY CONTEXT:
|
| 8 |
+
* Some users experience motion sickness, vestibular disorders, or simply
|
| 9 |
+
* prefer reduced animation. The "prefers-reduced-motion" media query
|
| 10 |
+
* indicates this preference.
|
| 11 |
+
*
|
| 12 |
+
* When this hook returns true:
|
| 13 |
+
* - Particle effects should NOT be rendered
|
| 14 |
+
* - OR particles should appear statically (no animation)
|
| 15 |
+
* - This respects user autonomy and WCAG guidelines
|
| 16 |
+
*/
|
| 17 |
+
import { useState, useEffect } from 'react';
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Detects if the user prefers reduced motion
|
| 21 |
+
*
|
| 22 |
+
* @returns true if user prefers reduced motion, false otherwise
|
| 23 |
+
*
|
| 24 |
+
* USAGE:
|
| 25 |
+
* ```tsx
|
| 26 |
+
* const prefersReducedMotion = useReducedMotion();
|
| 27 |
+
*
|
| 28 |
+
* if (prefersReducedMotion) {
|
| 29 |
+
* // Skip particle animation entirely
|
| 30 |
+
* return null;
|
| 31 |
+
* }
|
| 32 |
+
* ```
|
| 33 |
+
*/
|
| 34 |
+
export function useReducedMotion(): boolean {
|
| 35 |
+
// Server-side rendering safety: default to false
|
| 36 |
+
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
| 37 |
+
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
// Check if matchMedia is available (browser environment)
|
| 40 |
+
if (typeof window === 'undefined' || !window.matchMedia) {
|
| 41 |
+
return;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Create media query matcher
|
| 45 |
+
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
| 46 |
+
|
| 47 |
+
// Set initial value
|
| 48 |
+
setPrefersReducedMotion(mediaQuery.matches);
|
| 49 |
+
|
| 50 |
+
// Listen for changes (user might toggle system preference)
|
| 51 |
+
const handleChange = (event: MediaQueryListEvent) => {
|
| 52 |
+
setPrefersReducedMotion(event.matches);
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
// Modern browsers use addEventListener
|
| 56 |
+
mediaQuery.addEventListener('change', handleChange);
|
| 57 |
+
|
| 58 |
+
// Cleanup listener on unmount
|
| 59 |
+
return () => {
|
| 60 |
+
mediaQuery.removeEventListener('change', handleChange);
|
| 61 |
+
};
|
| 62 |
+
}, []);
|
| 63 |
+
|
| 64 |
+
return prefersReducedMotion;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
export default useReducedMotion;
|
frontend/src/types/particles.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Particle System Types
|
| 3 |
+
*
|
| 4 |
+
* VISUAL OVERVIEW:
|
| 5 |
+
* These types define the data structures for creating soft, glowing particles
|
| 6 |
+
* that appear when users interact with elements (like wikilinks).
|
| 7 |
+
*
|
| 8 |
+
* Each particle is a small glowing orb (6-12px) with:
|
| 9 |
+
* - A radial gradient from bright center to transparent edge
|
| 10 |
+
* - A soft blur halo that extends beyond the particle boundary
|
| 11 |
+
* - Smooth animation: floating outward, shrinking, and fading
|
| 12 |
+
*
|
| 13 |
+
* Visual Analogy: Think of particles as tiny floating embers or LED lights
|
| 14 |
+
* viewed through frosted glass - soft, warm, and gradually fading.
|
| 15 |
+
*/
|
| 16 |
+
|
| 17 |
+
/**
|
| 18 |
+
* Individual Particle State
|
| 19 |
+
*
|
| 20 |
+
* Each particle is an autonomous visual element with its own position,
|
| 21 |
+
* velocity, appearance, and lifecycle state.
|
| 22 |
+
*/
|
| 23 |
+
export interface Particle {
|
| 24 |
+
/**
|
| 25 |
+
* Horizontal position in canvas coordinates (pixels from left edge)
|
| 26 |
+
* VISUAL: Determines where the glowing orb appears on screen
|
| 27 |
+
*/
|
| 28 |
+
x: number;
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* Vertical position in canvas coordinates (pixels from top edge)
|
| 32 |
+
* VISUAL: Combined with x, places the particle's center point
|
| 33 |
+
*/
|
| 34 |
+
y: number;
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* Horizontal velocity (pixels per animation frame)
|
| 38 |
+
* VISUAL: Positive = moving right, Negative = moving left
|
| 39 |
+
* Typical range: -3 to +3 for gentle floating movement
|
| 40 |
+
*/
|
| 41 |
+
vx: number;
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* Vertical velocity (pixels per animation frame)
|
| 45 |
+
* VISUAL: Positive = moving down, Negative = moving up
|
| 46 |
+
* Typically starts negative with small positive bias for upward drift
|
| 47 |
+
*/
|
| 48 |
+
vy: number;
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Particle diameter in pixels
|
| 52 |
+
* VISUAL: Determines the size of the glowing orb
|
| 53 |
+
* The radial gradient spans from center (0) to this radius (size/2)
|
| 54 |
+
* Typical range: 4-14 pixels for subtle effect
|
| 55 |
+
*/
|
| 56 |
+
size: number;
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* Lifecycle progress from 1.0 (birth) to 0.0 (death)
|
| 60 |
+
* VISUAL EFFECTS:
|
| 61 |
+
* - Multiplied with opacity: particle becomes more transparent as life decreases
|
| 62 |
+
* - Multiplied with glow intensity: halo shrinks as particle dies
|
| 63 |
+
* - When life <= 0, particle is removed from the system
|
| 64 |
+
*
|
| 65 |
+
* Visual analogy: Like a candle burning down - starts bright, gradually dims
|
| 66 |
+
*/
|
| 67 |
+
life: number;
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* Base color for the particle gradient (hex format: '#RRGGBB')
|
| 71 |
+
* VISUAL: This is the CENTER color of the radial gradient
|
| 72 |
+
* The edge fades to transparent using the same hue
|
| 73 |
+
*
|
| 74 |
+
* Palette typically includes:
|
| 75 |
+
* - '#6366f1' (indigo/blue) - cool, professional
|
| 76 |
+
* - '#a855f7' (purple) - rich, balanced
|
| 77 |
+
* - '#ec4899' (pink) - warm accent
|
| 78 |
+
*/
|
| 79 |
+
color: string;
|
| 80 |
+
|
| 81 |
+
/**
|
| 82 |
+
* Unique identifier for React reconciliation
|
| 83 |
+
* Not visually relevant, but necessary for efficient DOM updates
|
| 84 |
+
*/
|
| 85 |
+
id: number;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/**
|
| 89 |
+
* Particle System Configuration
|
| 90 |
+
*
|
| 91 |
+
* These settings control the overall visual behavior of the particle system.
|
| 92 |
+
* Adjusting these values changes the "feel" of the effect.
|
| 93 |
+
*/
|
| 94 |
+
export interface ParticleConfig {
|
| 95 |
+
/**
|
| 96 |
+
* Number of particles spawned per trigger (click/interaction)
|
| 97 |
+
* VISUAL IMPACT:
|
| 98 |
+
* - Low (5-10): Subtle, minimal effect - professional/elegant
|
| 99 |
+
* - Medium (12-20): Noticeable but not overwhelming
|
| 100 |
+
* - High (25+): Dense, celebratory - can feel tacky if overdone
|
| 101 |
+
*
|
| 102 |
+
* Recommendation: 12-15 for subtle elegance
|
| 103 |
+
*/
|
| 104 |
+
particleCount: number;
|
| 105 |
+
|
| 106 |
+
/**
|
| 107 |
+
* Minimum particle diameter in pixels
|
| 108 |
+
* VISUAL: Sets the lower bound for particle size variation
|
| 109 |
+
* Smaller particles appear more distant/delicate
|
| 110 |
+
* Typical value: 4-6 pixels
|
| 111 |
+
*/
|
| 112 |
+
minSize: number;
|
| 113 |
+
|
| 114 |
+
/**
|
| 115 |
+
* Maximum particle diameter in pixels
|
| 116 |
+
* VISUAL: Sets the upper bound for particle size variation
|
| 117 |
+
* Larger particles appear closer/more prominent
|
| 118 |
+
* Typical value: 8-12 pixels
|
| 119 |
+
*/
|
| 120 |
+
maxSize: number;
|
| 121 |
+
|
| 122 |
+
/**
|
| 123 |
+
* Minimum initial velocity magnitude (pixels per frame)
|
| 124 |
+
* VISUAL: How slow the slowest particles move
|
| 125 |
+
* Lower = more leisurely floating
|
| 126 |
+
* Typical value: 1.5-2.0
|
| 127 |
+
*/
|
| 128 |
+
minSpeed: number;
|
| 129 |
+
|
| 130 |
+
/**
|
| 131 |
+
* Maximum initial velocity magnitude (pixels per frame)
|
| 132 |
+
* VISUAL: How fast the fastest particles move
|
| 133 |
+
* Higher = more energetic burst
|
| 134 |
+
* Typical value: 3.0-4.5
|
| 135 |
+
*/
|
| 136 |
+
maxSpeed: number;
|
| 137 |
+
|
| 138 |
+
/**
|
| 139 |
+
* Glow halo intensity (shadowBlur value in pixels)
|
| 140 |
+
* VISUAL IMPACT:
|
| 141 |
+
* - 5-10: Subtle glow, barely perceptible halo
|
| 142 |
+
* - 12-18: Moderate glow, clearly visible soft halo
|
| 143 |
+
* - 20+: Strong glow, dramatic ethereal effect
|
| 144 |
+
*
|
| 145 |
+
* This creates the "frosted glass" effect around particles.
|
| 146 |
+
* The glow color matches the particle color.
|
| 147 |
+
*
|
| 148 |
+
* Visual analogy: Like the halo around a streetlight on a foggy night
|
| 149 |
+
*/
|
| 150 |
+
glowIntensity: number;
|
| 151 |
+
|
| 152 |
+
/**
|
| 153 |
+
* Life decrement per animation frame (typically at 60 FPS)
|
| 154 |
+
* VISUAL IMPACT:
|
| 155 |
+
* - 0.010: Slow fade (~1.7 seconds to disappear)
|
| 156 |
+
* - 0.015: Medium fade (~1.1 seconds) - RECOMMENDED
|
| 157 |
+
* - 0.025: Fast fade (~0.7 seconds)
|
| 158 |
+
*
|
| 159 |
+
* Controls how quickly particles become transparent and die.
|
| 160 |
+
* Lower values = longer-lasting particles
|
| 161 |
+
*/
|
| 162 |
+
fadeRate: number;
|
| 163 |
+
|
| 164 |
+
/**
|
| 165 |
+
* Size multiplier per animation frame (0 < shrinkRate < 1)
|
| 166 |
+
* VISUAL IMPACT:
|
| 167 |
+
* - 0.995: Very slow shrink, particles stay nearly same size
|
| 168 |
+
* - 0.985: Moderate shrink - RECOMMENDED (40% original size after 60 frames)
|
| 169 |
+
* - 0.970: Fast shrink, particles quickly become tiny
|
| 170 |
+
*
|
| 171 |
+
* Combined with fadeRate, creates the "dissipating" visual effect.
|
| 172 |
+
*/
|
| 173 |
+
shrinkRate: number;
|
| 174 |
+
|
| 175 |
+
/**
|
| 176 |
+
* Upward drift acceleration per frame (added to vy each frame)
|
| 177 |
+
* VISUAL: Negative values make particles float upward over time
|
| 178 |
+
* Creates the "rising heat" or "bubbles ascending" effect
|
| 179 |
+
*
|
| 180 |
+
* Typical value: -0.02 to -0.05
|
| 181 |
+
* 0 = no drift, particles move in straight lines
|
| 182 |
+
*/
|
| 183 |
+
upwardDrift: number;
|
| 184 |
+
|
| 185 |
+
/**
|
| 186 |
+
* Available colors for random selection
|
| 187 |
+
* VISUAL: Each spawned particle gets a random color from this array
|
| 188 |
+
* Colors should be harmonious for a cohesive effect.
|
| 189 |
+
*
|
| 190 |
+
* Default palette: blue (#6366f1), purple (#a855f7), pink (#ec4899)
|
| 191 |
+
* Matches Tailwind indigo-500, violet-500, pink-500
|
| 192 |
+
*/
|
| 193 |
+
colors: string[];
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
/**
|
| 197 |
+
* Default configuration for subtle, elegant particles
|
| 198 |
+
*
|
| 199 |
+
* VISUAL RESULT:
|
| 200 |
+
* - 12 small particles (6-10px) spawn per click
|
| 201 |
+
* - Move outward at moderate speed (2-3.5 px/frame)
|
| 202 |
+
* - Have a visible but not overwhelming glow (15px blur)
|
| 203 |
+
* - Fade and shrink over approximately 1 second
|
| 204 |
+
* - Float gently upward as they age
|
| 205 |
+
* - Colors: cool blue/purple/pink palette
|
| 206 |
+
*/
|
| 207 |
+
export const DEFAULT_PARTICLE_CONFIG: ParticleConfig = {
|
| 208 |
+
particleCount: 12,
|
| 209 |
+
minSize: 6,
|
| 210 |
+
maxSize: 10,
|
| 211 |
+
minSpeed: 2.0,
|
| 212 |
+
maxSpeed: 3.5,
|
| 213 |
+
glowIntensity: 15,
|
| 214 |
+
fadeRate: 0.016, // ~60 frames = 1 second to fade out
|
| 215 |
+
shrinkRate: 0.985, // ~40% original size after 60 frames
|
| 216 |
+
upwardDrift: -0.03, // Gentle upward float
|
| 217 |
+
colors: [
|
| 218 |
+
'#6366f1', // Indigo (blue-ish)
|
| 219 |
+
'#a855f7', // Violet (purple)
|
| 220 |
+
'#ec4899', // Pink
|
| 221 |
+
],
|
| 222 |
+
};
|
| 223 |
+
|
| 224 |
+
/**
|
| 225 |
+
* Pre-configured visual presets for different use cases
|
| 226 |
+
*
|
| 227 |
+
* Each preset creates a distinct visual "mood"
|
| 228 |
+
*/
|
| 229 |
+
export const PARTICLE_PRESETS = {
|
| 230 |
+
/**
|
| 231 |
+
* SUBTLE preset
|
| 232 |
+
* VISUAL: Small, quick, minimal particles
|
| 233 |
+
* USE CASE: Professional applications, frequent interactions
|
| 234 |
+
* IMPRESSION: Polished, refined, not distracting
|
| 235 |
+
*/
|
| 236 |
+
subtle: {
|
| 237 |
+
particleCount: 8,
|
| 238 |
+
minSize: 4,
|
| 239 |
+
maxSize: 7,
|
| 240 |
+
minSpeed: 2.0,
|
| 241 |
+
maxSpeed: 3.0,
|
| 242 |
+
glowIntensity: 10,
|
| 243 |
+
fadeRate: 0.020, // Faster fade (~0.8 seconds)
|
| 244 |
+
shrinkRate: 0.980,
|
| 245 |
+
upwardDrift: -0.02,
|
| 246 |
+
colors: ['#6366f1', '#a855f7', '#ec4899'],
|
| 247 |
+
} satisfies ParticleConfig,
|
| 248 |
+
|
| 249 |
+
/**
|
| 250 |
+
* ELEGANT preset (default)
|
| 251 |
+
* VISUAL: Balanced size and count, moderate glow
|
| 252 |
+
* USE CASE: Standard interactions, wikilink clicks
|
| 253 |
+
* IMPRESSION: Premium UI polish, noticeable but tasteful
|
| 254 |
+
*/
|
| 255 |
+
elegant: DEFAULT_PARTICLE_CONFIG,
|
| 256 |
+
|
| 257 |
+
/**
|
| 258 |
+
* VIBRANT preset
|
| 259 |
+
* VISUAL: More particles, larger, longer-lasting
|
| 260 |
+
* USE CASE: Celebrations, achievements, special actions
|
| 261 |
+
* IMPRESSION: Playful, energetic, celebratory
|
| 262 |
+
*/
|
| 263 |
+
vibrant: {
|
| 264 |
+
particleCount: 20,
|
| 265 |
+
minSize: 8,
|
| 266 |
+
maxSize: 14,
|
| 267 |
+
minSpeed: 2.5,
|
| 268 |
+
maxSpeed: 4.5,
|
| 269 |
+
glowIntensity: 20,
|
| 270 |
+
fadeRate: 0.012, // Slower fade (~1.4 seconds)
|
| 271 |
+
shrinkRate: 0.990,
|
| 272 |
+
upwardDrift: -0.04,
|
| 273 |
+
colors: ['#6366f1', '#a855f7', '#ec4899', '#f472b6'],
|
| 274 |
+
} satisfies ParticleConfig,
|
| 275 |
+
|
| 276 |
+
/**
|
| 277 |
+
* MINIMAL preset
|
| 278 |
+
* VISUAL: Very few, very small, very quick particles
|
| 279 |
+
* USE CASE: High-frequency interactions, accessibility considerations
|
| 280 |
+
* IMPRESSION: Almost imperceptible polish
|
| 281 |
+
*/
|
| 282 |
+
minimal: {
|
| 283 |
+
particleCount: 5,
|
| 284 |
+
minSize: 3,
|
| 285 |
+
maxSize: 5,
|
| 286 |
+
minSpeed: 2.5,
|
| 287 |
+
maxSpeed: 3.5,
|
| 288 |
+
glowIntensity: 8,
|
| 289 |
+
fadeRate: 0.025, // Fast fade (~0.7 seconds)
|
| 290 |
+
shrinkRate: 0.975,
|
| 291 |
+
upwardDrift: -0.01,
|
| 292 |
+
colors: ['#6366f1', '#a855f7'],
|
| 293 |
+
} satisfies ParticleConfig,
|
| 294 |
+
} as const;
|
| 295 |
+
|
| 296 |
+
export type ParticlePreset = keyof typeof PARTICLE_PRESETS;
|