From 510e4bd7383af7ac595f31cbe11eebbde5073792 Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:31:45 +1000 Subject: [PATCH] feat(backgrounds): add constellation background --- .../ui/backgrounds/constellation.tsx | 127 ++++++++++++++++++ src/components/ui/backgrounds/registry.ts | 35 +++++ src/components/ui/backgrounds/types.ts | 1 + src/locales/en.json | 3 + src/locales/fa.json | 3 + src/locales/ru.json | 3 + src/locales/zh.json | 3 + src/utils/backgroundConfig.ts | 1 + 8 files changed, 176 insertions(+) create mode 100644 src/components/ui/backgrounds/constellation.tsx diff --git a/src/components/ui/backgrounds/constellation.tsx b/src/components/ui/backgrounds/constellation.tsx new file mode 100644 index 0000000..756d333 --- /dev/null +++ b/src/components/ui/backgrounds/constellation.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef } from 'react'; +import { sanitizeColor, clampNumber } from './types'; +import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop'; + +interface Props { + settings: Record; +} + +interface Particle { + x: number; + y: number; + vx: number; + vy: number; +} + +interface ConstellationState { + ctx: CanvasRenderingContext2D; + particles: Particle[]; + w: number; + h: number; + dpr: number; +} + +export default function ConstellationBackground({ settings }: Props) { + const canvasRef = useRef(null); + const stateRef = useRef(null); + + const particleColor = sanitizeColor(settings.particleColor, '#818cf8'); + const lineColor = sanitizeColor(settings.lineColor, '#818cf8'); + const count = clampNumber(settings.count, 10, 200, 60); + const linkDistance = clampNumber(settings.linkDistance, 40, 300, 120); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const dpr = getMobileDpr(); + const parent = canvas.parentElement; + const w = parent?.offsetWidth ?? window.innerWidth; + const h = parent?.offsetHeight ?? window.innerHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const particles: Particle[] = Array.from({ length: Math.floor(count) }, () => ({ + x: Math.random() * w, + y: Math.random() * h, + vx: (Math.random() - 0.5) * 0.5, + vy: (Math.random() - 0.5) * 0.5, + })); + + stateRef.current = { ctx, particles, w, h, dpr }; + + const onResize = () => { + const nw = parent?.offsetWidth ?? window.innerWidth; + const nh = parent?.offsetHeight ?? window.innerHeight; + canvas.width = nw * dpr; + canvas.height = nh * dpr; + canvas.style.width = `${nw}px`; + canvas.style.height = `${nh}px`; + if (stateRef.current) { + stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + stateRef.current.w = nw; + stateRef.current.h = nh; + } + }; + + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [count]); + + useAnimationLoop(() => { + const state = stateRef.current; + if (!state) return; + + const { ctx, particles, w, h } = state; + const linkDistanceSq = linkDistance * linkDistance; + + ctx.clearRect(0, 0, w, h); + + for (const p of particles) { + p.x += p.vx; + p.y += p.vy; + + if (p.x < 0 || p.x > w) p.vx = -p.vx; + if (p.y < 0 || p.y > h) p.vy = -p.vy; + } + + ctx.strokeStyle = lineColor; + ctx.lineWidth = 0.8; + + for (let i = 0; i < particles.length; i++) { + for (let j = i + 1; j < particles.length; j++) { + const dx = particles[i].x - particles[j].x; + const dy = particles[i].y - particles[j].y; + const distSq = dx * dx + dy * dy; + + if (distSq < linkDistanceSq) { + const dist = Math.sqrt(distSq); + ctx.globalAlpha = (1 - dist / linkDistance) * 0.35; + ctx.beginPath(); + ctx.moveTo(particles[i].x, particles[i].y); + ctx.lineTo(particles[j].x, particles[j].y); + ctx.stroke(); + } + } + } + + ctx.globalAlpha = 0.8; + ctx.fillStyle = particleColor; + + for (const p of particles) { + ctx.beginPath(); + ctx.arc(p.x, p.y, 1.8, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.globalAlpha = 1; + }, [particleColor, lineColor, count, linkDistance]); + + return ; +} diff --git a/src/components/ui/backgrounds/registry.ts b/src/components/ui/backgrounds/registry.ts index 3c17771..3605d00 100644 --- a/src/components/ui/backgrounds/registry.ts +++ b/src/components/ui/backgrounds/registry.ts @@ -23,6 +23,7 @@ const backgroundImports: Record, () => Promise import('./starfield'), 'matrix-rain': () => import('./matrix-rain'), 'liquid-gradient': () => import('./liquid-gradient'), + constellation: () => import('./constellation'), }; /** Prefetch the JS chunk for a background type (call early to avoid lazy-load delay) */ @@ -57,6 +58,7 @@ export const backgroundComponents: Record< starfield: lazy(() => import('./starfield')), 'matrix-rain': lazy(() => import('./matrix-rain')), 'liquid-gradient': lazy(() => import('./liquid-gradient')), + constellation: lazy(() => import('./constellation')), }; // Registry of all background definitions with settings for the editor @@ -757,4 +759,37 @@ export const backgroundRegistry: BackgroundDefinition[] = [ }, ], }, + { + type: 'constellation', + labelKey: 'admin.backgrounds.constellation', + descriptionKey: 'admin.backgrounds.constellationDesc', + category: 'canvas', + settings: [ + { + key: 'particleColor', + label: 'admin.backgrounds.particleColor', + type: 'color', + default: '#818cf8', + }, + { key: 'lineColor', label: 'admin.backgrounds.lineColor', type: 'color', default: '#818cf8' }, + { + key: 'count', + label: 'admin.backgrounds.count', + type: 'number', + min: 10, + max: 200, + step: 5, + default: 60, + }, + { + key: 'linkDistance', + label: 'admin.backgrounds.linkDistance', + type: 'number', + min: 40, + max: 300, + step: 10, + default: 120, + }, + ], + }, ]; diff --git a/src/components/ui/backgrounds/types.ts b/src/components/ui/backgrounds/types.ts index 6e501a0..fa88a78 100644 --- a/src/components/ui/backgrounds/types.ts +++ b/src/components/ui/backgrounds/types.ts @@ -19,6 +19,7 @@ export type BackgroundType = | 'starfield' | 'matrix-rain' | 'liquid-gradient' + | 'constellation' | 'none'; export interface AnimationConfig { diff --git a/src/locales/en.json b/src/locales/en.json index b01f604..3646c83 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1163,6 +1163,8 @@ "matrixRainDesc": "Falling columns of glyphs", "liquidGradient": "Liquid Gradient", "liquidGradientDesc": "Blurred mesh gradient blobs", + "constellation": "Constellation", + "constellationDesc": "Particles linked by lines", "geminiEffect": "Gemini Effect", "geminiEffectDesc": "SVG effect like Google Gemini", "speed": "Speed", @@ -1186,6 +1188,7 @@ "lineColor": "Line Color", "wind": "Wind", "charset": "Charset", + "linkDistance": "Link Distance", "bgColor": "Background Color", "fillColor": "Fill Color", "color1": "Color 1", diff --git a/src/locales/fa.json b/src/locales/fa.json index 22ad4a7..bfac9e6 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1045,6 +1045,8 @@ "matrixRainDesc": "ستون‌های نمادهای در حال سقوط", "liquidGradient": "گرادیان مایع", "liquidGradientDesc": "حباب‌های گرادیان مات و روان", + "constellation": "صورت فلکی", + "constellationDesc": "ذرات متصل با خطوط", "geminiEffect": "افکت Gemini", "geminiEffectDesc": "افکت SVG مشابه Google Gemini", "speed": "سرعت", @@ -1068,6 +1070,7 @@ "lineColor": "رنگ خطوط", "wind": "باد", "charset": "مجموعه نویسه‌ها", + "linkDistance": "فاصله اتصال", "bgColor": "رنگ پس‌زمینه", "fillColor": "رنگ پر کردن", "color1": "رنگ ۱", diff --git a/src/locales/ru.json b/src/locales/ru.json index f610091..dcf9302 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1193,6 +1193,8 @@ "matrixRainDesc": "Падающие колонки символов", "liquidGradient": "Liquid Gradient", "liquidGradientDesc": "Жидкий mesh-градиент из размытых блобов", + "constellation": "Constellation", + "constellationDesc": "Частицы, связанные линиями", "geminiEffect": "Gemini Effect", "geminiEffectDesc": "SVG-эффект как на Google Gemini", "speed": "Скорость", @@ -1216,6 +1218,7 @@ "lineColor": "Цвет линий", "wind": "Ветер", "charset": "Набор символов", + "linkDistance": "Дистанция связи", "bgColor": "Цвет фона", "fillColor": "Цвет заливки", "color1": "Цвет 1", diff --git a/src/locales/zh.json b/src/locales/zh.json index f99f47b..f90e762 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1045,6 +1045,8 @@ "matrixRainDesc": "下落的字符列", "liquidGradient": "流体渐变", "liquidGradientDesc": "模糊的网格渐变色块", + "constellation": "星座", + "constellationDesc": "由线条连接的粒子", "geminiEffect": "Gemini效果", "geminiEffectDesc": "类似Google Gemini的SVG效果", "speed": "速度", @@ -1068,6 +1070,7 @@ "lineColor": "线条颜色", "wind": "风力", "charset": "字符集", + "linkDistance": "连接距离", "bgColor": "背景颜色", "fillColor": "填充颜色", "color1": "颜色1", diff --git a/src/utils/backgroundConfig.ts b/src/utils/backgroundConfig.ts index 49cfc92..0563623 100644 --- a/src/utils/backgroundConfig.ts +++ b/src/utils/backgroundConfig.ts @@ -24,6 +24,7 @@ const VALID_TYPES: ReadonlySet = new Set([ 'starfield', 'matrix-rain', 'liquid-gradient', + 'constellation', 'none', ]);