diff --git a/src/components/ParticleField/field.test.ts b/src/components/ParticleField/field.test.ts index 7e732a5..7893bd3 100644 --- a/src/components/ParticleField/field.test.ts +++ b/src/components/ParticleField/field.test.ts @@ -5,7 +5,6 @@ import { createFrame, createGroups, createMotion, - edgeRadius, envelope, follow, frameGap, @@ -18,12 +17,10 @@ import { layoutParticles, LEVELS, levelStyle, - ringIntensity, + ringBase, ringRadius, spacingFor, stepMotion, - TAU, - wavePhase, } from './field'; const WIDTH = 1200; @@ -58,42 +55,39 @@ describe('粒子光环计算', () => { expect(layoutParticles(3840, 2160, s).count).toBeLessThan(2500); }); - it('软边在半径附近起伏,被搅动时运动反方向拉长', () => { + it('环半径只随时间呼吸(±5% 内),不随指针速度变化', () => { const r = 300; expect(envelope(0, r)).toBe(1); expect(envelope(r * 0.6, r)).toBeLessThan(0.1); expect(ringRadius(100, 100)).toBe(120); expect(ringRadius(4000, 3000)).toBe(340); - for (let theta = 0; theta < TAU; theta += 0.2) { - const calm = edgeRadius(theta, 3, r, 0, 0); - expect(calm).toBeGreaterThan(r * 0.88); - expect(calm).toBeLessThan(r * 1.12); + for (let t = 0; t < 20; t += 0.05) { + expect(ringBase(r, t)).toBeGreaterThan(r * 0.95); + expect(ringBase(r, t)).toBeLessThan(r * 1.05); } - // 向右移动(heading = 0):左侧(尾迹)比静止时更长,右侧(迎面)更短 - expect(edgeRadius(Math.PI, 0, r, 1, 0)).toBeGreaterThan(edgeRadius(Math.PI, 0, r, 0, 0)); - expect(edgeRadius(0, 0, r, 1, 0)).toBeLessThan(edgeRadius(0, 0, r, 0, 0)); + expect(ringBase(r, 0)).toBe(r * (1 + 0.02)); }); - it('静态帧:边缘附近最亮,远处不画;线段大致沿径向', () => { + it('静态帧:环带附近最亮,远处只剩基础明暗的淡点;线段大致沿径向', () => { const { layout, frame, radius } = setup(); const motion = createMotion(WIDTH / 2, HEIGHT / 2); const visible = computeFrame(layout, frame, motion, radius, 0, 0); - expect(visible).toBeGreaterThan(0); - expect(visible).toBeLessThan(layout.count); + // 基础明暗让全场隐约可见(对齐参考站的 ambient 项) + expect(visible).toBe(layout.count); let nearSum = 0; let nearCount = 0; let innerSum = 0; let innerCount = 0; - let farDrawn = 0; + let farMaxLevel = 0; let maxTilt = 0; for (let i = 0; i < layout.count; i++) { const dx = layout.x[i] - motion.x; const dy = layout.y[i] - motion.y; const d = Math.hypot(dx, dy); - const u = d - edgeRadius(Math.atan2(dy, dx), 0, radius, 0, 0); + const u = d - ringBase(radius, 0); const level = levelOf(frame.bucket[i]); - if (d > radius * 4 && level >= 0) farDrawn++; + if (d > radius * 4) farMaxLevel = Math.max(farMaxLevel, level); if (Math.abs(u) < 10) { nearSum += Math.max(level, 0); nearCount++; @@ -110,50 +104,24 @@ describe('粒子光环计算', () => { maxTilt = Math.max(maxTilt, Math.abs(sx * dy - sy * dx) / (Math.hypot(sx, sy) * d)); } } - expect(farDrawn).toBe(0); + // 远处最多到「淡点」档,不会画出亮线 + expect(farMaxLevel).toBeLessThanOrEqual(1); expect(nearSum / nearCount).toBeGreaterThan(innerSum / innerCount + 2); - // 摆动上限:浪坡 0.22 + 自身晃动 0.06 rad - expect(maxTilt).toBeLessThanOrEqual(Math.sin(0.28) + 1e-4); + // 摆动上限:自身晃动 0.08 rad + expect(maxTilt).toBeLessThanOrEqual(Math.sin(0.08) + 1e-4); }); - it('浪峰随时间向外推进,搅动时浪更急、反差更大', () => { - const r = 300; - const at = (u: number, t: number, energy = 0) => - ringIntensity(envelope(u, r), Math.sin(wavePhase(u, r, t, energy)), energy); - // 某一时刻离边缘最近的亮度局部极大值 - const crestNear = (from: number, t: number) => { - let best = Infinity; - let pos = from; - for (let u = -r * 0.5; u <= r * 0.5; u += 0.5) { - const isPeak = at(u, t) > at(u - 0.5, t) && at(u, t) >= at(u + 0.5, t); - if (isPeak && Math.abs(u - from) < best) { - best = Math.abs(u - from); - pos = u; - } - } - return pos; - }; - const first = crestNear(0, 0); - expect(crestNear(first, 0.2)).toBeGreaterThan(first); - // 同一位置,扰动能量让浪推进得更快 - expect(wavePhase(0, r, 1, 1)).toBeLessThan(wavePhase(0, r, 1, 0)); - // 浪谷(crest = -1)在平静时仍保留大部分亮度,搅动时更暗 - expect(ringIntensity(1, -1, 0)).toBeCloseTo(0.65); - expect(ringIntensity(1, -1, 1)).toBeLessThan(ringIntensity(1, -1, 0)); - expect(ringIntensity(1, 1, 1)).toBe(1); - }); - - it('弹簧:位移平滑追上目标并带一点回弹;亮度快起慢落', () => { + it('位移指数回归:环移走后单调衰减、不回弹;亮度快起慢落', () => { const { layout, frame, radius } = setup(); const motion = createMotion(WIDTH / 2, HEIGHT / 2); computeFrame(layout, frame, motion, radius, 0, 0); - // 找一个正好在边缘、正右方的粒子 + // 找一个正好在环带、正右方的粒子 let probe = -1; let best = Infinity; for (let i = 0; i < layout.count; i++) { const dx = layout.x[i] - motion.x; const dy = layout.y[i] - motion.y; - const u = Math.abs(Math.hypot(dx, dy) - edgeRadius(Math.atan2(dy, dx), 0, radius, 0, 0)); + const u = Math.abs(Math.hypot(dx, dy) - ringBase(radius, 0)); if (dx > 0 && Math.abs(dy) < 20 && u < best) { best = u; probe = i; @@ -164,7 +132,7 @@ describe('粒子光环计算', () => { const glowOn = frame.glow[probe]; expect(glowOn).toBeGreaterThan(0.5); - // 光环瞬间移走:位移不会立刻归零,而是被弹簧拉回、越过零点后再回来 + // 环瞬间移走:位移被指数拉回,只是变慢,不会越过零点(无弹簧回弹) motion.x = -5000; let t = 0; for (let n = 0; n < 6; n++) computeFrame(layout, frame, motion, radius, (t += STEP), STEP); @@ -177,24 +145,17 @@ describe('粒子光环计算', () => { computeFrame(layout, frame, motion, radius, (t += STEP), STEP); minOffset = Math.min(minOffset, frame.ox[probe]); } - expect(minOffset).toBeLessThan(0); - expect(Math.abs(frame.ox[probe])).toBeLessThan(0.5); - expect(frame.glow[probe]).toBeLessThan(0.05); + expect(minOffset).toBeGreaterThanOrEqual(0); + expect(frame.ox[probe]).toBeLessThan(0.5); + expect(frame.glow[probe]).toBeLessThan(0.15); }); - it('中心移动越快扰动能量越高,静止后平息', () => { + it('环心按速率缓动跟随,dt <= 0 不改变状态', () => { const motion = createMotion(0, 0); - let x = 0; - for (let n = 0; n < 60; n++) stepMotion(motion, (x += 30), 0, STEP, 30); - expect(motion.energy).toBeGreaterThan(0.6); - expect(motion.vx).toBeGreaterThan(0); - const stirred = motion.energy; - for (let n = 0; n < 60; n++) stepMotion(motion, x, 0, STEP, 30); - expect(motion.energy).toBeLessThan(stirred); - expect(motion.energy).toBeGreaterThan(stirred * 0.3); - for (let n = 0; n < 600; n++) stepMotion(motion, x, 0, STEP, 30); - expect(motion.energy).toBeLessThan(0.01); - // dt <= 0 不改变状态 + // 有效速率 ≈1.2/s:一秒后走掉大半段,但还留着明显的「慢半拍」 + for (let n = 0; n < 60; n++) stepMotion(motion, 100, 0, STEP, 1.3); + expect(motion.x).toBeGreaterThan(60); + expect(motion.x).toBeLessThan(95); const before = { ...motion }; stepMotion(motion, 9999, 9999, 0, 30); expect(motion).toEqual(before); @@ -215,10 +176,10 @@ describe('粒子光环计算', () => { } }); - it('缓动与帧率无关、游走点留在视口内、色环与档位合法', () => { + it('缓动与帧率无关、游走点留在视口内、补丁色板与档位合法', () => { // 两个半帧与一个整帧落到同一位置 - const half = follow(follow(0, 100, 0.008, 5), 100, 0.008, 5); - expect(half).toBeCloseTo(follow(0, 100, 0.016, 5), 10); + const half = follow(follow(0, 100, 0.008, 1.3), 100, 0.008, 1.3); + expect(half).toBeCloseTo(follow(0, 100, 0.016, 1.3), 10); for (let t = 0; t < 120; t += 0.7) { const [x, y] = idleFocus(t, 1000, 600); expect(x).toBeGreaterThan(0); @@ -229,9 +190,18 @@ describe('粒子光环计算', () => { const [, top] = idleFocus(t, 390, 844); expect(top).toBeLessThan(844 * 0.22); } - const palette = buildPalette(); - expect(palette).toHaveLength(HUES); - palette.forEach((c) => expect(c).toMatch(/^rgb\(\d{1,3},\d{1,3},\d{1,3}\)$/)); + // 三色补丁(对齐参考站):暗色从 #7189ff 起步到黑;亮色蓝→红→黄 + const dark = buildPalette('dark'); + const light = buildPalette('light'); + expect(dark).toHaveLength(HUES); + expect(light).toHaveLength(HUES); + dark.forEach((c) => expect(c).toMatch(/^rgb\(\d{1,3},\d{1,3},\d{1,3}\)$/)); + expect(dark[0]).toBe('rgb(113,137,255)'); + expect(dark[HUES - 1]).toBe('rgb(0,0,0)'); + expect(light[0]).toBe('rgb(44,100,237)'); + // 中段落在红补丁附近(允许插值的少量偏移) + expect(light[Math.floor(HUES / 2)]).toMatch(/^rgb\(2[0-9]{2},(?:[6-9][0-9]),[0-9]{1,2}\)$/); + expect(light[HUES - 1]).toBe('rgb(255,207,3)'); expect(levelStyle(LEVELS - 1, false).alpha).toBeCloseTo(1); expect(levelStyle(0, true).alpha).toBeGreaterThan(levelStyle(0, false).alpha); }); diff --git a/src/components/ParticleField/field.ts b/src/components/ParticleField/field.ts index bf142c2..c26bec5 100644 --- a/src/components/ParticleField/field.ts +++ b/src/components/ParticleField/field.ts @@ -1,17 +1,16 @@ /** - * 跟随指针的粒子光环 —— 纯计算部分(不碰 DOM,便于单测;绘制在同目录 index.tsx)。 + * 跟随指针的粒子场 —— 纯计算部分(不碰 DOM,便于单测;绘制在同目录 index.tsx)。 * - * 观感参照 antigravity.google 首屏的鼠标跟随粒子,按拆解出的规律自行实现,未复用其代码: + * 观感对齐 antigravity.google 的 MainParticles(three.js GPGPU 原版,2026-10 拆解),未复用其代码: * 1. 全屏铺一层带抖动的网格粒子,每个粒子是一段圆头短线(胶囊),沿径向取向; - * 2. 光环中心以指数缓动追随指针;粒子按到光环边缘的距离落在一条高斯包络里,越近越长、越粗、越亮; - * 3. 水面感来自四件事叠加: - * - 浪峰:包络里有一列向外传播的正弦波,浪峰经过时粒子变亮并被托起,落下后回到原位; - * - 软边:边缘半径随方位角由几组慢速谐波起伏,光环不是死板的正圆; - * - 扰动能量:光环中心移动越快能量越高 —— 浪更急更高、光环更大、运动反方向拖出尾迹;静止后慢慢平息; - * - 惯性:每个粒子的位移挂在一根欠阻尼弹簧上(滞后、轻微回弹),亮度快起慢落,扫过之处留下余晖。 - * 4. 颜色按方位角在一圈渐变色上取值,整圈缓慢旋转,浪峰处再轻微偏色。 + * 2. 环心以慢速指数缓动追随指针(≈1.2/s,明显慢半拍的漂浮感);粒子按到环的距离落在高斯包络里, + * 越近越长、越粗、越亮;环外是宽高斯衰减的淡点,全场还有一层基础明暗,远处的粒子也隐约可见; + * 3. 环半径只随时间呼吸(±3~5%),不随指针速度变化;粒子位移沿径向向外推开,指数回归、无回弹; + * 位置上叠一层细微的高频涟漪,亮度快起慢落,扫过之处留下余晖; + * 4. 颜色是三色补丁的噪声混合(对齐参考站 shader 的 noiseColor),随「家位置」缓慢流动—— + * 暗色主题 蓝蓝黑(黑色粒子沉进底色形成暗补丁),亮色主题 蓝/红/黄;没有色环、不随方位角变色。 * - * 性能:每帧一次 O(n) 计算,再按「色相 × 强度档」分桶,每个桶一条 path、一次 stroke / fill, + * 性能:每帧一次 O(n) 计算,再按「补丁色 × 强度档」分桶,每个桶一条 path、一次 stroke / fill, * 1080p 下两千来个粒子也只有几十次绘制调用。 */ @@ -19,49 +18,52 @@ export const TAU = Math.PI * 2; /** 强度分档数:档位决定线宽、长度与不透明度 */ export const LEVELS = 6; -/** 色环取样数 */ +/** 补丁渐变取样数 */ export const HUES = 48; /** 最低的这几档几乎是一个点:画成小方块填充,比圆头描边便宜得多(软件渲染下差距明显) */ export const DOT_LEVELS = 2; /** 粒子总数上限:大屏改为放大间距,避免 4K 外接屏上每帧上万个粒子 */ const MAX_PARTICLES = 2200; -/** 低于此可见度的粒子不画(光环远处) */ +/** 低于此可见度的粒子不画(环远处) */ const VISIBLE_MIN = 0.05; /** 环外淡点相对环带的亮度上限 */ const HALO_FLOOR = 0.24; +/** 全场基础明暗:参考站的 ambient 项,让整片纹理隐约可见(暗斑随 shim 起伏) */ +const AMBIENT_BASE = 0.06; +const AMBIENT_SHIM = 0.05; /** 环带上的粒子沿径向被推开的距离(px) */ const PUSH = 12; const MIN_LEN = 0.6; const MAX_LEN = 9; /** 包络宽度(相对半径) */ -const ENVELOPE = 0.36; -/** 浪长(相对半径):约三道浪同时出现在包络里 */ -const WAVELENGTH = 0.42; -/** 弹簧刚度与阻尼:固有频率约 1Hz、阻尼比 0.45,跟得上又带一点回弹 */ -const SPRING_K = 38; -const SPRING_C = 2 * 0.45 * Math.sqrt(SPRING_K); +const ENVELOPE = 0.34; +/** 位移的指数回归速率(1/s):对齐参考站的逐帧衰减,无弹簧回弹 */ +const RELAX = 8; /** 亮度的起落速率(1/s):快起慢落,扫过的地方留一道余晖 */ const GLOW_RISE = 9; const GLOW_FALL = 2.2; -/** 光环中心速度达到它时扰动能量饱和(px/s) */ -const ENERGY_SPEED = 900; -/** 入场:光环从小半径像一圈涟漪扩散到正常大小所用的秒数 */ +/** 高频位置涟漪:波数与幅度都很小,只是让环带微微「呼吸出行走的纹理」 */ +const RIPPLE_K = 0.07; +const RIPPLE_AMP = 1.6; +/** 入场:环从小半径像一圈涟漪扩散到正常大小所用的秒数 */ export const INTRO_SECONDS = 1.6; /** 指针与键盘静止这么久(ms)后降到 30 帧 */ export const IDLE_MS = 8000; /** 安静模式(用户正在输入)下整体压暗的比例 */ export const QUIET_DIM = 0.4; -/** 品牌蓝起步的循环色环(首尾相接,插值时回到起点) */ -const STOPS: [number, number, number][] = [ - [22, 119, 255], // 蓝(与主色同源) - [114, 46, 209], // 紫 - [235, 47, 150], // 洋红 - [245, 34, 45], // 红 - [250, 140, 22], // 橙 - [250, 173, 20], // 琥珀 - [19, 194, 194], // 青 +/** 三色补丁(对齐参考站 shader 的 colorControls):暗色主题 蓝蓝黑,黑色粒子沉进底色形成暗补丁 */ +const STOPS_DARK: [number, number, number][] = [ + [113, 137, 255], // #7189ff + [48, 116, 249], // #3074f9 + [0, 0, 0], // #000000 +]; +/** 亮色主题:蓝 / 红 / 黄 */ +const STOPS_LIGHT: [number, number, number][] = [ + [44, 100, 237], // #2c64ed + [248, 66, 66], // #f84242 + [255, 207, 3], // #ffcf03 ]; export interface Layout { @@ -74,13 +76,11 @@ export interface Layout { export interface Frame { /** 每个粒子一条线段 x1, y1, x2, y2 */ seg: Float32Array; - /** 所属绘制桶(hue * LEVELS + level),-1 表示本帧不可见 */ + /** 所属绘制桶(patch * LEVELS + level),-1 表示本帧不可见 */ bucket: Int16Array; - /** 弹簧状态:相对网格原位的位移与速度 */ + /** 平滑后的径向位移(相对网格原位,指数回归无回弹) */ ox: Float32Array; oy: Float32Array; - vx: Float32Array; - vy: Float32Array; /** 平滑后的亮度(0..1) */ glow: Float32Array; } @@ -96,15 +96,10 @@ export interface Groups { cursor: Uint32Array; } -/** 光环中心的运动状态 */ +/** 环心的运动状态:只有位置——参考站没有速度响应,环的形态不随指针快慢变化 */ export interface Motion { x: number; y: number; - /** 平滑后的中心速度(px/s) */ - vx: number; - vy: number; - /** 扰动能量 0..1:移动越快越高,静止后缓慢平息 */ - energy: number; } /** 确定性伪随机(mulberry32):同一尺寸的布局每次一致,刷新页面不会「换一张脸」 */ @@ -147,8 +142,6 @@ export const createFrame = (count: number): Frame => ({ bucket: new Int16Array(count), ox: new Float32Array(count), oy: new Float32Array(count), - vx: new Float32Array(count), - vy: new Float32Array(count), glow: new Float32Array(count), }); @@ -159,39 +152,22 @@ export const createGroups = (count: number): Groups => ({ cursor: new Uint32Array(HUES * LEVELS), }); -export const createMotion = (x: number, y: number): Motion => ({ x, y, vx: 0, vy: 0, energy: 0 }); +export const createMotion = (x: number, y: number): Motion => ({ x, y }); -/** 光环半径随视口缩放,并夹在可读范围内 */ +/** 环半径随视口缩放,并夹在可读范围内 */ export const ringRadius = (width: number, height: number) => Math.min(340, Math.max(120, Math.min(width, height) * 0.3)); /** - * 软边:方位角 theta 处的边缘半径。几组不同阶数、不同转速的谐波叠加,轮廓缓慢起伏(静止时约 ±11%); - * 有扰动能量时运动反方向(heading + π)再拉长最多 12%,像船尾的尾迹。 + * 环半径随时间的呼吸(对齐参考站 uRingRadius 的更新公式):±3~5%,两个不同频率的谐波叠加。 + * 与旧版不同,不再随指针速度胀大,也没有软边起伏——参考站的环是正圆 + 呼吸。 */ -export const edgeRadius = (theta: number, t: number, base: number, energy: number, heading: number) => - base * - (1 + - 0.05 * Math.sin(3 * theta + 0.6 * t) + - 0.035 * Math.sin(5 * theta - 0.9 * t + 1.3) + - 0.025 * Math.sin(2 * theta + 0.35 * t + 2.1) - - 0.12 * energy * Math.cos(theta - heading)); +export const ringBase = (radius: number, t: number) => radius * (1 + 0.03 * Math.sin(t) + 0.02 * Math.cos(3 * t)); -/** 包络:以边缘为中心的高斯(1 = 正好在边缘上) */ +/** 包络:以环带为中心的高斯(1 = 正好在环上) */ export const envelope = (offset: number, radius: number) => Math.exp(-((offset / (radius * ENVELOPE)) ** 2)); -/** 浪的相位 k·u − ωt(k 为波数,ω 为角频率):相位恒定处 u 随 t 增大,即浪峰向外推进;搅动越强浪越急 */ -export const wavePhase = (offset: number, radius: number, t: number, energy: number) => - (TAU / (radius * WAVELENGTH)) * offset - (1.6 + 1.2 * energy) * t; - -/** 边缘附近的亮度:包络 × 浪(浪峰更亮);搅动越强,浪峰与浪谷的反差越大 */ -export const ringIntensity = (env: number, crest: number, energy: number) => { - const amp = 0.35 + 0.35 * energy; - const lift = 0.5 + 0.5 * crest; - return env * (1 - amp + amp * lift * lift); -}; - -/** 外围淡点的衰减:以中心为原点的宽高斯,决定光环能「照亮」多远(约两倍半径外不再绘制) */ +/** 外围淡点的衰减:以环心为原点的宽高斯,决定环能「照亮」多远(约两倍半径外基本只剩基础明暗) */ export const haloFalloff = (distance: number, radius: number) => Math.exp(-((distance / (radius * 1.8)) ** 2)); /** 帧率无关的指数缓动:rate 越大越跟手 */ @@ -199,25 +175,20 @@ export const follow = (current: number, target: number, dt: number, rate: number current + (target - current) * (1 - Math.exp(-rate * dt)); /** - * 推进光环中心一步:缓动追向目标,并由中心速度更新扰动能量(快起慢落)。dt <= 0 时不变。 - * rate 决定跟手程度:跟指针时大,自行游走时小。 + * 推进环心一步。rate 决定跟手程度:参考站的逐帧 lerp 0.02(60fps 下有效速率 ≈1.2/s), + * 指针态取 1.3 保持「慢半拍」的漂浮感,空闲游走更慢一些。 + * dt <= 0 时不变。 */ export const stepMotion = (motion: Motion, targetX: number, targetY: number, dt: number, rate: number) => { if (dt <= 0) return motion; - const px = motion.x; - const py = motion.y; - motion.x = follow(px, targetX, dt, rate); - motion.y = follow(py, targetY, dt, rate); - motion.vx = follow(motion.vx, (motion.x - px) / dt, dt, 6); - motion.vy = follow(motion.vy, (motion.y - py) / dt, dt, 6); - const target = Math.min(1, Math.hypot(motion.vx, motion.vy) / ENERGY_SPEED); - motion.energy = follow(motion.energy, target, dt, target > motion.energy ? 5 : 0.8); + motion.x = follow(motion.x, targetX, dt, rate); + motion.y = follow(motion.y, targetY, dt, rate); return motion; }; /** - * 没有指针(首屏、触屏、指针移出窗口)时光环沿一条缓慢的利萨如曲线游走。 - * 窄屏上登录框几乎占满视口,光环改在顶部标题一带游走,不然整圈都藏在面板后面 + * 没有指针(首屏、触屏、指针移出窗口)时环心沿一条缓慢的利萨如曲线游走(等价参考站的噪声游走)。 + * 窄屏上登录框几乎占满视口,环改在顶部标题一带游走,不然整圈都藏在面板后面 */ export const idleFocus = (t: number, width: number, height: number): [number, number] => { const narrow = width < 640; @@ -236,6 +207,7 @@ export const introScale = (t: number) => { /** * 两帧之间的最小间隔(ms):窗口失焦(用户在看别的窗口)降到 20 帧,长时间无操作降到 30 帧,其余满帧。 * 慢速游走在 30 帧下看不出差别,笔记本停在登录页时风扇与电量都更省。 + * (参考站另有「任意输入后暂停渲染 600ms」保护输入延迟;这里用安静模式压暗代替,登录表单场景更合适。) */ export const frameGap = (blurred: boolean, idleMs: number) => (blurred ? 50 : idleMs > IDLE_MS ? 33 : 0); @@ -248,83 +220,69 @@ export const levelStyle = (level: number, dark: boolean) => { }; }; -/** 把循环色环插值成 steps 个 canvas 可用的颜色串 */ -export const buildPalette = (steps = HUES) => - Array.from({ length: steps }, (_, i) => { - const pos = (i / steps) * STOPS.length; +/** 把三色补丁插值成 steps 个 canvas 可用的颜色串(首尾不接环,是渐变的两端) */ +export const buildPalette = (kind: 'dark' | 'light' = 'dark', steps = HUES) => { + const stops = kind === 'dark' ? STOPS_DARK : STOPS_LIGHT; + return Array.from({ length: steps }, (_, i) => { + const pos = (i / (steps - 1)) * (stops.length - 1); const k = Math.floor(pos); const f = pos - k; - const a = STOPS[k % STOPS.length]; - const b = STOPS[(k + 1) % STOPS.length]; + const a = stops[k]; + const b = stops[k + 1] ?? stops[k]; const mix = (j: number) => Math.round(a[j] + (b[j] - a[j]) * f); return `rgb(${mix(0)},${mix(1)},${mix(2)})`; }); +}; /** - * 推进一帧:更新每个粒子的弹簧与亮度,写出线段端点与绘制桶,返回本帧可见粒子数。 + * 推进一帧:更新每个粒子的位移与亮度,写出线段端点与绘制桶,返回本帧可见粒子数。 * t 为累计秒数,dt 为本帧时长;dt <= 0 时直接落到目标状态(静态画面、减少动态效果)。 */ export const computeFrame = (layout: Layout, frame: Frame, motion: Motion, radius: number, t: number, dt: number) => { const { x, y, phase, count } = layout; - const { seg, bucket, ox, oy, vx, vy, glow } = frame; - const { energy } = motion; + const { seg, bucket, ox, oy, glow } = frame; const snap = dt <= 0; - const heading = Math.atan2(motion.vy, motion.vx); - // 整圈缓慢呼吸,被搅动时再胀大一些 - const base = radius * (1 + 0.03 * Math.sin(t * 0.8) + 0.08 * energy); - // 浪被托起的高度(px);平静时一道浪约 4 秒,搅动时约 2 秒(见 wavePhase) - const bob = 4 + 6 * energy; - const spin = t * 0.035; - // 全场一道很慢的斜向涌浪,只影响外围淡点的明暗 - const swellK = TAU / 900; + const base = ringBase(radius, t); const rise = snap ? 1 : 1 - Math.exp(-GLOW_RISE * dt); const fall = snap ? 1 : 1 - Math.exp(-GLOW_FALL * dt); + const relax = snap ? 1 : 1 - Math.exp(-RELAX * dt); let visible = 0; for (let i = 0; i < count; i++) { const dx = x[i] - motion.x; const dy = y[i] - motion.y; const d = Math.hypot(dx, dy) || 1e-6; - const theta = Math.atan2(dy, dx); - const u = d - edgeRadius(theta, t, base, energy, heading); - const env = envelope(u, radius); - const wave = wavePhase(u, radius, t, energy); - const crest = Math.sin(wave); - const ring = ringIntensity(env, crest, energy); - const swell = 0.7 + 0.3 * Math.sin(swellK * (0.8 * x[i] + 0.6 * y[i]) - 0.5 * t); - const target = Math.max(ring, haloFalloff(d, base) * HALO_FLOOR * swell); + const env = envelope(d - base, radius); + // 场上慢速明暗斑(参考站的中尺度噪声):补丁明暗与基础可见度共用它 + const shim = 0.5 + 0.5 * Math.sin(x[i] * 0.02 + t * 0.5 + phase[i]); + const target = Math.max( + env * (0.82 + 0.18 * shim), + haloFalloff(d, base) * HALO_FLOOR, + AMBIENT_BASE + AMBIENT_SHIM * shim, + ); const g = glow[i] + (target - glow[i]) * (target > glow[i] ? rise : fall); glow[i] = g; - // 位移目标:沿径向推开,浪峰经过时再被托起一点 + // 位移目标:沿径向向外推开;实际位移指数回归,无回弹(对齐参考站) const ux = dx / d; const uy = dy / d; - const push = env * (PUSH + crest * bob); - const tx = ux * push; - const ty = uy * push; - if (snap) { - ox[i] = tx; - oy[i] = ty; - vx[i] = 0; - vy[i] = 0; - } else { - // 半隐式欧拉:先更新速度再更新位移,dt ≤ 0.05 时稳定 - vx[i] += (SPRING_K * (tx - ox[i]) - SPRING_C * vx[i]) * dt; - vy[i] += (SPRING_K * (ty - oy[i]) - SPRING_C * vy[i]) * dt; - ox[i] += vx[i] * dt; - oy[i] += vy[i] * dt; - } + const push = env * PUSH; + ox[i] += (ux * push - ox[i]) * relax; + oy[i] += (uy * push - oy[i]) * relax; if (g < VISIBLE_MIN) { bucket[i] = -1; continue; } visible++; - const cx = x[i] + ox[i]; - const cy = y[i] + oy[i]; + // 高频位置涟漪:波数与幅度都很小,环带附近才有(对齐参考站 disp 的 sin 项) + const rippleX = Math.sin(x[i] * RIPPLE_K + t * 2) * RIPPLE_AMP * env; + const rippleY = Math.cos(y[i] * RIPPLE_K + t * 1.6) * RIPPLE_AMP * env; + const cx = x[i] + ox[i] + rippleX; + const cy = y[i] + oy[i] + rippleY; const level = Math.min(LEVELS - 1, Math.floor(g ** 0.85 * LEVELS)); const half = (MIN_LEN + (level / (LEVELS - 1)) * (MAX_LEN - MIN_LEN)) / 2; - // 随浪的坡度轻轻摇摆,再叠一点各自的小幅晃动 - const tilt = Math.cos(wave) * 0.22 * env + Math.sin(t * 0.9 + phase[i]) * 0.06; + // 取向沿径向,再叠一点各自的小幅晃动 + const tilt = Math.sin(t * 0.9 + phase[i]) * 0.08; const cos = Math.cos(tilt); const sin = Math.sin(tilt); const ax = (ux * cos - uy * sin) * half; @@ -334,10 +292,9 @@ export const computeFrame = (layout: Layout, frame: Frame, motion: Motion, radiu seg[o + 1] = cy - ay; seg[o + 2] = cx + ax; seg[o + 3] = cy + ay; - // 方位角决定色相;离边缘越远、越靠浪峰,色相再偏移一点 - let hue = theta / TAU + spin + (1 - env) * 0.18 + crest * env * 0.02; - hue -= Math.floor(hue); - bucket[i] = (Math.floor(hue * HUES) % HUES) * LEVELS + level; + // 补丁混色索引:按「家位置」的低频噪声取样,随时间缓慢流动(参考站 noiseColor 的近似) + const patch = 0.5 + 0.5 * Math.sin(x[i] * 0.006 + y[i] * 0.004 + t * 0.15 + phase[i] * 0.7); + bucket[i] = Math.min(HUES - 1, Math.floor(patch * HUES)) * LEVELS + level; } return visible; }; diff --git a/src/components/ParticleField/index.tsx b/src/components/ParticleField/index.tsx index d482953..0dbea96 100644 --- a/src/components/ParticleField/index.tsx +++ b/src/components/ParticleField/index.tsx @@ -31,7 +31,7 @@ export interface ParticleFieldProps { className?: string; } -const PALETTE = buildPalette(); +const PALETTES = { dark: buildPalette('dark'), light: buildPalette('light') }; /** * 跟随指针的粒子光环背景(Canvas 2D,无第三方依赖)。算法与参数见 ./field.ts。 @@ -98,7 +98,7 @@ const ParticleField: React.FC = ({ dark = false, quiet = fal if (!n) continue; const level = b % LEVELS; const style = levelStyle(level, isDark); - const color = PALETTE[(b - level) / LEVELS]; + const color = PALETTES[isDark ? 'dark' : 'light'][(b - level) / LEVELS]; const end = starts[b] + n; ctx.globalAlpha = style.alpha * dim; ctx.beginPath(); @@ -142,8 +142,6 @@ const ParticleField: React.FC = ({ dark = false, quiet = fal radius = ringRadius(width, height); if (!started) { [motion.x, motion.y] = idleFocus(time, width, height); - // 入场带一点初始扰动:第一圈浪更活泼,随后自然平息 - if (!still) motion.energy = 0.5; started = true; } if (still) draw(); @@ -158,11 +156,9 @@ const ParticleField: React.FC = ({ dark = false, quiet = fal last = now; time += dt; if (!pointerActive) [targetX, targetY] = idleFocus(time, width, height); - // 跟指针时跟手一些,回到游走时慢慢滑过去;中心移动的快慢决定这一刻的浪有多大 - stepMotion(motion, targetX, targetY, dt, pointerActive ? 5 : 1.4); + // 指针态也保持「慢半拍」的漂浮感(对齐参考站 ≈1.2/s 的有效速率),回到游走时更慢 + stepMotion(motion, targetX, targetY, dt, pointerActive ? 1.3 : 0.9); quietLevel = follow(quietLevel, quietRef.current ? 1 : 0, dt, 3); - // 安静模式:扰动能量较快衰减,浪回到最平缓的状态 - if (quietLevel > 0) motion.energy *= 1 - Math.min(1, quietLevel * dt * 3); draw(dt); }; diff --git a/src/locales/en-US/framework.ts b/src/locales/en-US/framework.ts index be1c193..c5e6561 100644 --- a/src/locales/en-US/framework.ts +++ b/src/locales/en-US/framework.ts @@ -45,16 +45,25 @@ export default { 'login.headline': 'An admin template that tells a coherent story', 'login.subhead': 'A fictional IoT monitoring operation: devices, alarms, tickets, people and schedules that all line up.', - 'login.highlight.dashboard': 'Dashboard numbers aggregated live from the records, refreshed every minute', - 'login.highlight.system': 'Users, roles, audit log and route / button / data level access control', - 'login.highlight.maps': 'AMap, OpenLayers and six Cesium 3D scenes', - 'login.highlight.i18n': 'Chinese and English, light and dark, works on phones', + 'login.badge': 'IoT monitoring ops · demo dataset', + 'login.feature.live': 'Live aggregation', + 'login.feature.access': 'Route / button / data access', + 'login.feature.maps3d': 'Six Cesium 3D scenes', + 'login.feature.i18n': 'Bilingual · themes', 'login.asAdmin': 'Try as administrator', - 'login.asAdminDesc': 'Every page available', + 'login.demoGroup': 'Demo · quick entry', + 'login.demoNote': 'All five roles share the same demo data, stored only in this browser', + 'login.secure': 'Sessions protected by HttpOnly cookies', + 'login.sso.github': 'GitHub account', + 'login.sso.enterprise': 'Enterprise SSO', + 'login.sso.note': 'Identity federation provisioned by your admin', + 'login.sso.placeholder': 'Connect SSO to your provider when deploying this template', + 'login.stats.online': 'Devices online', + 'login.stats.alarm': 'Alarms today', + 'login.stats.sla': 'Tickets on time', 'login.asGuest': 'Try as viewer', 'login.asGuestDesc': 'Restricted: see 403s and masking', - 'login.or': 'or sign in with any username', - 'login.demoHint': 'Demo environment: any username / password will work', + 'login.or': 'or sign in with credentials', 'login.username': 'Username', 'login.usernameRequired': 'Please enter your username', 'login.password': 'Password', @@ -87,6 +96,7 @@ export default { 'error.webgl.title': 'The 3D scene failed to start', 'error.webgl.subTitle': 'WebGL is unavailable or this browser cannot render Cesium', 'footer.build': 'Built {time} · {target} ·', + 'footer.buildShort': 'Build {target} ·', 'tour.menu': 'Guided tour', 'tour.nav.title': 'Menus grouped by domain', 'tour.nav.desc': @@ -114,7 +124,6 @@ export default { 'login.as.operator': 'Sign in as operator', 'login.as.analyst': 'Sign in as analyst', 'login.as.guest': 'Sign in as guest', - 'login.asRolesDesc': 'All five roles share the same demo data; see the role matrix for their permissions', 'login.returnTo': 'You will return to {page} after signing in.', 'login.expired': 'Your session has expired. Please sign in again.', 'login.capsLock': 'Caps Lock is on', diff --git a/src/locales/zh-CN/framework.ts b/src/locales/zh-CN/framework.ts index 774b9e4..8aa1edd 100644 --- a/src/locales/zh-CN/framework.ts +++ b/src/locales/zh-CN/framework.ts @@ -43,16 +43,25 @@ export default { 'login.subtitle': '欢迎回来,登录后继续', 'login.headline': '一个讲得通的后台模板', 'login.subhead': '虚构的物联网监测运营平台:设备、告警、工单、人员与日程互相关联,数字经得起推敲。', - 'login.highlight.dashboard': '工作台指标由明细实时聚合,每分钟刷新', - 'login.highlight.system': '用户、角色、日志与路由 / 按钮 / 数据三级权限', - 'login.highlight.maps': '高德、OpenLayers 与六个 Cesium 三维场景', - 'login.highlight.i18n': '中英双语、明暗主题、手机端可用', + 'login.badge': 'IoT 监测运营平台 · 演示数据集', + 'login.feature.live': '明细实时聚合', + 'login.feature.access': '路由 / 按钮 / 数据三级权限', + 'login.feature.maps3d': '六个 Cesium 三维场景', + 'login.feature.i18n': '中英双语 · 明暗主题', 'login.asAdmin': '以管理员体验', - 'login.asAdminDesc': '可访问全部页面', + 'login.demoGroup': '演示模式 · 快速体验', + 'login.demoNote': '五种角色共用同一份演示数据,仅存于当前浏览器', + 'login.secure': '会话由 HttpOnly Cookie 加密保护', + 'login.sso.github': 'GitHub 账号', + 'login.sso.enterprise': '企业 SSO', + 'login.sso.note': '由管理员开通的统一身份入口', + 'login.sso.placeholder': 'SSO 入口由部署方接入,模板不内置', + 'login.stats.online': '在线设备', + 'login.stats.alarm': '今日告警', + 'login.stats.sla': '工单时限达成', 'login.asGuest': '以访客体验', 'login.asGuestDesc': '受限权限,体验 403 与脱敏', - 'login.or': '或使用任意用户名登录', - 'login.demoHint': '演示环境:任意用户名 / 密码均可登录', + 'login.or': '或使用账号登录', 'login.username': '用户名', 'login.usernameRequired': '请输入用户名', 'login.password': '密码', @@ -84,6 +93,7 @@ export default { 'error.webgl.title': '三维场景初始化失败', 'error.webgl.subTitle': 'WebGL 不可用或当前浏览器不支持 Cesium 渲染', 'footer.build': '构建于 {time} · {target} ·', + 'footer.buildShort': '构建 {target} ·', 'tour.menu': '功能导览', 'tour.nav.title': '按业务分组的菜单', 'tour.nav.desc': '工作台、系统管理、设备运维、地图、组件、多媒体、文档、异常页。分组内的页面在左侧菜单。', @@ -109,7 +119,6 @@ export default { 'login.as.operator': '运维工程师体验', 'login.as.analyst': '数据分析员体验', 'login.as.guest': '访客体验', - 'login.asRolesDesc': '五种角色共用同一份演示数据,权限差异见角色矩阵', 'login.returnTo': '登录后将回到「{page}」', 'login.expired': '登录已过期,请重新登录', 'login.capsLock': '大写锁定已开启', diff --git a/src/pages/Login/index.test.tsx b/src/pages/Login/index.test.tsx index d9831a3..fe5c28f 100644 --- a/src/pages/Login/index.test.tsx +++ b/src/pages/Login/index.test.tsx @@ -65,7 +65,9 @@ describe('sign-in mode entry points', () => { expect(html).toContain('login.as.operator'); expect(html).toContain('login.as.analyst'); expect(html).toContain('login.as.guest'); - expect(html).toContain('login.demoHint'); + expect(html).toContain('login.demoGroup'); + expect(html).toContain('login.demoNote'); + expect(html).toContain('login.secure'); expect(html).toContain('value="admin"'); expect(html).toContain('value="demo"'); // 演示站:源码入口;任意密码都能登录,不出现记住账号 / 找回密码 @@ -89,7 +91,7 @@ describe('sign-in mode entry points', () => { it('footer shows the build the page was served from', { timeout: 20_000 }, async () => { const html = await renderLogin(true); - expect(html).toContain('footer.build'); + expect(html).toContain('footer.buildShort'); expect(html).toContain('0123456'); expect(html).toContain('/commit/0123456789abcdef'); }); diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index 7b7aae5..2fcf62f 100644 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -8,9 +8,9 @@ import { readLoginReason } from '@/utils/Auth/loginUrl'; import { safeRedirect } from '@/utils/Auth/safeRedirect'; import { UserInfo } from '@/utils/Auth/userInfo'; import { BizError } from '@/utils/BizError'; -import { formatDateTime } from '@/utils/format'; import { menuKeyOf } from '@/utils/pageTitle'; import { + ApartmentOutlined, EyeOutlined, GithubOutlined, LockOutlined, @@ -40,13 +40,15 @@ import { import { useEffect, useMemo, useRef, useState } from 'react'; import { readRememberedName, writeRememberedName } from './rememberedName'; -// 亮点前的小圆点取自粒子色环,与背景呼应 -const HIGHLIGHTS = [ - { key: 'dashboard', color: '#1677ff' }, - { key: 'system', color: '#722ed1' }, - { key: 'maps', color: '#eb2f96' }, - { key: 'i18n', color: '#fa8c16' }, -]; +// 功能层的特性标签:短关键词而非长句——形式要匹配「清单」的语义 +const FEATURES = ['live', 'access', 'maps3d', 'i18n'] as const; + +/** 产品时刻:演示数据集口径的三个数字(README 同源的故事常量,非实时值) */ +const STATS = [ + { key: 'online', value: '236/240' }, + { key: 'alarm', value: '12' }, + { key: 'sla', value: '96.4%' }, +] as const; /** 一键体验的四个非管理员角色(管理员单独一个主按钮) */ const DEMO_ROLES = ['lead', 'operator', 'analyst', 'guest'] as const; @@ -122,7 +124,7 @@ const Login: React.FC = () => { const busy = (source: Source) => ({ loading: pending === source, disabled: !!pending && pending !== source }); const onCapsLock = (event: React.KeyboardEvent) => setCapsLock(event.getModifierState('CapsLock')); - const { sha, time, target } = BUILD_INFO; + const { sha, target } = BUILD_INFO; return ( // 底色与文字色用 antd token(登录页在 umi 包裹的 antd 容器内),暗色模式随算法切换 @@ -178,37 +180,80 @@ const Login: React.FC = () => { -
+
-

+ {/* 叙述层:徽章给视线入口,标题/副标题说明「这是什么」 */} + + {t('login.badge')} + +

{t('login.headline')}

- {/* 窄屏只保留标题,副标题与亮点从平板宽度起出现,不把登录框挤到首屏之外 */} - + {/* 窄屏只保留标题,副标题与特性从平板宽度起出现,不把登录框挤到首屏之外 */} + {t('login.subhead')} -
    - {HIGHLIGHTS.map(({ key, color }) => ( -
  • - - {t(`login.highlight.${key}`)} -
  • + {/* 功能层:短标签组成的清单,宽度自然收拢,左列右缘呈阶梯递减 */} +
    + {FEATURES.map((key) => ( + + {t(`login.feature.${key}`)} + ))} -
+
+ {/* 证据层:锚在左下角,与右下角的登录卡共用同一条地平线(三锚点:左上叙事 / 左下证据 / 右下行动) */} +
+ {STATS.map(({ key, value }) => ( + + {t(`login.stats.${key}`)} + {value} + + ))} +
setTyping(event.target instanceof HTMLInputElement)} onBlur={(event) => { @@ -223,11 +268,12 @@ const Login: React.FC = () => { {(error || reason === 'expired') && ( )} + {/* 演示快速体验整体降为次操作:主操作只留给表单登录;说明压成一句 */} {DEMO_MODE && ( -
+
+ {t('login.demoGroup')} - - {t('login.asAdminDesc')} - - {/* 五种角色的快捷体验(F02):一次点击即以对应角色进入;悬停看该角色能做什么(口径见角色矩阵) */} + {/* 四个非管理员角色:一次点击即以对应角色进入;悬停看该角色能做什么(口径见角色矩阵) */}
{DEMO_ROLES.map((name) => ( @@ -253,19 +296,33 @@ const Login: React.FC = () => { ))}
- - {t('login.asRolesDesc')} + + {t('login.demoNote')}
)} - {/* 分隔说明是辅助信息,用次要文字色,不和上下两组操作抢视线 */} - {DEMO_MODE && ( - - - {t('login.or')} + {!DEMO_MODE && ( +
+ {/* 企业登录的占位入口:模板不内置任何 SSO,点了给明确提示而不是死链接 */} +
+ + +
+ + {t('login.sso.note')} - +
)} + {/* 分隔说明是辅助信息,用次要文字色,不和上下两组操作抢视线 */} + + + {t('login.or')} + + form={form} initialValues={ @@ -327,30 +384,34 @@ const Login: React.FC = () => {
)} - {/* 演示模式的主操作是上方的一键体验,这里退为次要按钮;真实登录时它就是唯一的主操作 */} - - {DEMO_MODE && ( - - {t('login.demoHint')} - - )} + + {t('login.secure')} +
diff --git a/tailwind.css b/tailwind.css index 52c3746..6374c46 100644 --- a/tailwind.css +++ b/tailwind.css @@ -27,6 +27,7 @@ */ @theme inline { --color-text: var(--ant-color-text); + --color-text-secondary: var(--ant-color-text-secondary); --color-text-tertiary: var(--ant-color-text-tertiary); --color-fill-tertiary: var(--ant-color-fill-tertiary); --color-success: var(--ant-color-success); @@ -36,6 +37,14 @@ --color-bg-layout: var(--ant-color-bg-layout); } +/* 登录页卡片的「落地」:视口够高时贴到与左下角文案块同一条地平线; + 矮屏回到垂直居中,避免顶穿 header。见 Login/index.tsx */ +@media (width >= 1024px) and (height >= 880px) { + .lg-self-end-tall { + align-self: flex-end; + } +} + /* * preflight 已去掉,但 border 系工具类依赖 preflight 的边框基线: * .border 只设 border-width,缺了 border-style: solid 时边框根本不显示。