|
| 1 | +/** |
| 2 | + * 沉降深渊 2D 视图(深色主题形态) |
| 3 | + * Sinking Abyss 2D view (dark theme morph) |
| 4 | + * |
| 5 | + * @ai-context: 深色模式沉降深渊以 2D 实现(零 WebGL)——纵向滚动容器承载 |
| 6 | + * 非对称沉积的卡片流,滚轮下拉 = 原生滚动(触摸/键盘滚动条免费获得)。 |
| 7 | + * 遗忘雾双层(宪法映射「遗忘=混沌雾 ≤40%」实际渲染):①世界雾带——绝对 |
| 8 | + * 定位在深度坐标上(每 2.5 单位一条,透明度随深度 0.08→0.38 递增), |
| 9 | + * z-index 高于卡片,卡片滚动穿过时被真实遮挡(破雾感);②视口雾——顶部 |
| 10 | + * 海面光 + 底部渐暗的氛围层。卡片按时间衰减对数连续分布(浅密深疏)、 |
| 11 | + * 确定性非对称错落(浅层聚集、深层被洋流冲散)。 |
| 12 | + * @ai-context: Dark morph renders the abyss in pure 2D (no WebGL): a |
| 13 | + * vertical scroll container holds asymmetrically deposited cards; wheel |
| 14 | + * down = native scrolling. Forgetting mist (constitution: chaos mist ≤40%) |
| 15 | + * is truly rendered via world fog bands that occlude cards when scrolled |
| 16 | + * through, plus viewport ambience. |
| 17 | + */ |
| 18 | +import { useCallback, useMemo, useRef, useState } from 'react'; |
| 19 | +import { cn } from '@/lib/utils'; |
| 20 | +import { |
| 21 | + ABYSS_LAYERS, TEMPLATE_COLORS, TEMPLATE_FALLBACK, |
| 22 | + hashId, seeded, nodeBrightness, nodeSize, type ReefNote, |
| 23 | +} from './reefTypes'; |
| 24 | +import { ReefSelectedCard } from './ReefCards'; |
| 25 | +import { useReefKeyboard } from './useReefKeyboard'; |
| 26 | + |
| 27 | +/** 深度 → 像素换算(px/单位) */ |
| 28 | +const PX_PER_UNIT = 60; |
| 29 | +/** 最大深度(世界单位) */ |
| 30 | +const MAX_DEPTH = 22; |
| 31 | +/** 海面留白(px) */ |
| 32 | +const SURFACE_PAD = 160; |
| 33 | +/** 世界雾带间隔(世界单位) */ |
| 34 | +const FOG_INTERVAL = 2.5; |
| 35 | +/** 卡片宽度基准(px,随 wordCount 微调) */ |
| 36 | +const CARD_BASE_W = 200; |
| 37 | + |
| 38 | +interface CardPos { |
| 39 | + top: number; |
| 40 | + leftPct: number; |
| 41 | + scale: number; |
| 42 | + width: number; |
| 43 | +} |
| 44 | + |
| 45 | +/** 非对称沉积布局:时间衰减对数连续分布 + 确定性错落(浅层聚集、深层散开) */ |
| 46 | +function computePositions(notes: ReefNote[]): Map<string, CardPos> { |
| 47 | + const result = new Map<string, CardPos>(); |
| 48 | + const now = Date.now(); |
| 49 | + for (const note of notes) { |
| 50 | + const ageDays = Math.max(0, (now - new Date(note.updatedAt).getTime()) / 86400000); |
| 51 | + const y = Math.min(0, -Math.min(MAX_DEPTH, Math.log1p(ageDays) * 3.2) + (seeded(hashId(note.id) * 3) - 0.5) * 2.4); |
| 52 | + const depthRatio = Math.min(1, -y / MAX_DEPTH); |
| 53 | + // 浅层聚集(±25%)、深层散开(±60%)——洋流冲刷沉积 |
| 54 | + const spread = 0.25 + depthRatio * 0.35; |
| 55 | + const leftPct = 50 + (seeded(hashId(note.id) * 5) - 0.5) * 2 * spread * 100; |
| 56 | + result.set(note.id, { |
| 57 | + top: SURFACE_PAD + (-y) * PX_PER_UNIT, |
| 58 | + leftPct, |
| 59 | + scale: 1 - depthRatio * 0.35, |
| 60 | + width: CARD_BASE_W + nodeSize(note.wordCount) * 400, |
| 61 | + }); |
| 62 | + } |
| 63 | + return result; |
| 64 | +} |
| 65 | + |
| 66 | +interface AbyssView2DProps { |
| 67 | + notes: ReefNote[]; |
| 68 | + selectedId: string | null; |
| 69 | + hoveredId: string | null; |
| 70 | + highlightIds?: ReadonlySet<string> | null; |
| 71 | + focusFolderId?: string | null; |
| 72 | + onHover: (id: string | null) => void; |
| 73 | + onSelect: (id: string) => void; |
| 74 | + onOpen: (id: string) => void; |
| 75 | + onExit: () => void; |
| 76 | +} |
| 77 | + |
| 78 | +/** 沉降深渊(2D)/ Sinking Abyss (2D) */ |
| 79 | +export function AbyssView2D({ |
| 80 | + notes, selectedId, hoveredId, highlightIds, focusFolderId, |
| 81 | + onHover, onSelect, onOpen, onExit, |
| 82 | +}: AbyssView2DProps) { |
| 83 | + const scrollRef = useRef<HTMLDivElement>(null); |
| 84 | + const [scrollDepth, setScrollDepth] = useState(0); |
| 85 | + |
| 86 | + const positions = useMemo(() => computePositions(notes), [notes]); |
| 87 | + const sortedCards = useMemo( |
| 88 | + () => [...notes].sort((a, b) => b.wordCount - a.wordCount), |
| 89 | + [notes], |
| 90 | + ); |
| 91 | + const hoveredNote = useMemo( |
| 92 | + () => notes.find((n) => n.id === hoveredId) ?? null, |
| 93 | + [notes, hoveredId], |
| 94 | + ); |
| 95 | + const selectedNote = useMemo( |
| 96 | + () => notes.find((n) => n.id === selectedId) ?? null, |
| 97 | + [notes, selectedId], |
| 98 | + ); |
| 99 | + |
| 100 | + // 键盘导航:方向键切换选中后滚动到卡片可见;Enter 打开 |
| 101 | + const handleSelect = useCallback((id: string) => { |
| 102 | + onSelect(id); |
| 103 | + scrollRef.current?.querySelector(`[data-card-id="${id}"]`)?.scrollIntoView({ block: 'nearest' }); |
| 104 | + }, [onSelect]); |
| 105 | + useReefKeyboard({ |
| 106 | + cards: sortedCards, |
| 107 | + selectedId, |
| 108 | + onSelect: handleSelect, |
| 109 | + onOpen, |
| 110 | + enabled: notes.length > 0, |
| 111 | + }); |
| 112 | + |
| 113 | + // 滚动 → 深度(滚轮下拉 = 原生滚动) |
| 114 | + const handleScroll = useCallback(() => { |
| 115 | + const el = scrollRef.current; |
| 116 | + if (!el) return; |
| 117 | + setScrollDepth(Math.max(0, (el.scrollTop - SURFACE_PAD) / PX_PER_UNIT)); |
| 118 | + }, []); |
| 119 | + |
| 120 | + const currentLayer = Math.min( |
| 121 | + ABYSS_LAYERS.length - 1, |
| 122 | + Math.max(0, Math.round(scrollDepth / 5)), |
| 123 | + ); |
| 124 | + |
| 125 | + // 世界雾带:深度坐标固定,透明度随深度递增(宪法 ≤40%) |
| 126 | + const fogBands = useMemo(() => { |
| 127 | + const bands: Array<{ top: number; opacity: number }> = []; |
| 128 | + const count = Math.ceil(MAX_DEPTH / FOG_INTERVAL); |
| 129 | + for (let i = 0; i < count; i++) { |
| 130 | + bands.push({ |
| 131 | + top: SURFACE_PAD + i * FOG_INTERVAL * PX_PER_UNIT, |
| 132 | + opacity: 0.08 + ((i + 0.5) * FOG_INTERVAL / MAX_DEPTH) * 0.3, |
| 133 | + }); |
| 134 | + } |
| 135 | + return bands; |
| 136 | + }, []); |
| 137 | + |
| 138 | + // 层名刻度(海面/浅海/深海/海沟参考线) |
| 139 | + const layerMarks = useMemo( |
| 140 | + () => ABYSS_LAYERS.map((l) => ({ name: l.name, top: SURFACE_PAD + (-l.y) * PX_PER_UNIT })), |
| 141 | + [], |
| 142 | + ); |
| 143 | + |
| 144 | + if (notes.length === 0) { |
| 145 | + return ( |
| 146 | + <div className="h-full flex flex-col items-center justify-center gap-3"> |
| 147 | + <p className="text-b2 text-text-tertiary">深海寂静——还没有笔记</p> |
| 148 | + <button |
| 149 | + onClick={onExit} |
| 150 | + className="px-3 py-1.5 rounded-kb-full text-c1 font-medium text-brand-600 hover:bg-brand-500/10 transition-colors" |
| 151 | + > |
| 152 | + 返回列表创建 |
| 153 | + </button> |
| 154 | + </div> |
| 155 | + ); |
| 156 | + } |
| 157 | + |
| 158 | + return ( |
| 159 | + <div className="relative w-full h-full overflow-hidden"> |
| 160 | + {/* 纵向滚动容器(滚轮下拉 = 原生滚动) */} |
| 161 | + <div |
| 162 | + ref={scrollRef} |
| 163 | + onScroll={handleScroll} |
| 164 | + className="h-full overflow-y-auto [scrollbar-width:thin] scroll-smooth" |
| 165 | + > |
| 166 | + <div className="relative mx-auto" style={{ height: SURFACE_PAD + MAX_DEPTH * PX_PER_UNIT, maxWidth: 920 }}> |
| 167 | + {/* 深度背景渐变(深处渐暗) */} |
| 168 | + <div |
| 169 | + className="absolute inset-0 pointer-events-none" |
| 170 | + style={{ background: 'linear-gradient(to bottom, rgba(74,155,217,0.14), rgba(10,22,32,0) 14%, rgba(4,10,16,0.45) 85%, rgba(2,6,10,0.9))' }} |
| 171 | + aria-hidden="true" |
| 172 | + /> |
| 173 | + |
| 174 | + {/* 层名刻度 */} |
| 175 | + {layerMarks.map((m) => ( |
| 176 | + <div key={m.name} className="absolute left-3 z-[5] flex items-center gap-2 pointer-events-none" style={{ top: m.top }}> |
| 177 | + <span className="text-[10px] tracking-[0.25em] uppercase text-text-tertiary/60">{m.name}</span> |
| 178 | + <span className="h-px w-14 bg-border/30" aria-hidden="true" /> |
| 179 | + </div> |
| 180 | + ))} |
| 181 | + |
| 182 | + {/* 卡片层(非对称沉积) */} |
| 183 | + {sortedCards.map((note) => { |
| 184 | + const pos = positions.get(note.id); |
| 185 | + if (!pos) return null; |
| 186 | + const isSelected = note.id === selectedId; |
| 187 | + const isHovered = note.id === hoveredId; |
| 188 | + const color = TEMPLATE_COLORS.abyss[note.template] ?? TEMPLATE_FALLBACK.abyss; |
| 189 | + const dim = (focusFolderId != null && note.folderId !== focusFolderId) |
| 190 | + || (highlightIds != null && !highlightIds.has(note.id)); |
| 191 | + return ( |
| 192 | + <button |
| 193 | + key={note.id} |
| 194 | + data-card-id={note.id} |
| 195 | + onClick={() => onSelect(note.id)} |
| 196 | + onDoubleClick={() => onOpen(note.id)} |
| 197 | + onMouseEnter={() => onHover(note.id)} |
| 198 | + onMouseLeave={() => onHover(null)} |
| 199 | + className={cn( |
| 200 | + 'absolute z-10 -translate-x-1/2 text-left rounded-kb-lg overflow-hidden', |
| 201 | + 'bg-[rgba(13,26,38,0.92)] border backdrop-blur-sm', |
| 202 | + 'transition-all duration-300 cursor-pointer', |
| 203 | + isSelected |
| 204 | + ? 'border-brand-400/80 shadow-[0_0_24px_rgba(64,171,146,0.35)]' |
| 205 | + : isHovered |
| 206 | + ? 'border-cyber/50 shadow-[0_0_18px_rgba(74,155,217,0.28)]' |
| 207 | + : 'border-border/40 hover:border-cyber/40', |
| 208 | + )} |
| 209 | + style={{ |
| 210 | + top: pos.top, |
| 211 | + left: `${pos.leftPct}%`, |
| 212 | + width: pos.width, |
| 213 | + transform: `translateX(-50%) scale(${isHovered || isSelected ? pos.scale * 1.06 : pos.scale})`, |
| 214 | + opacity: dim ? 0.35 : 1, |
| 215 | + boxShadow: undefined, |
| 216 | + }} |
| 217 | + aria-label={`${note.title || '无标题'},${note.wordCount} 字${isSelected ? ',已选中' : ''}`} |
| 218 | + > |
| 219 | + {/* 模板色条 */} |
| 220 | + <span className="absolute left-0 top-0 bottom-0 w-[4px]" style={{ background: color }} aria-hidden="true" /> |
| 221 | + <span className="block px-3.5 py-2.5"> |
| 222 | + <span className="block text-[13px] font-medium text-[#E2EAF2] truncate"> |
| 223 | + {note.title || '无标题'} |
| 224 | + </span> |
| 225 | + <span className="block text-[11px] text-[#647B90] mt-1 truncate"> |
| 226 | + {note.wordCount} 字{note.tags[0] ? ` · #${note.tags[0]}` : ''} |
| 227 | + </span> |
| 228 | + </span> |
| 229 | + </button> |
| 230 | + ); |
| 231 | + })} |
| 232 | + |
| 233 | + {/* 世界雾带(z-20:真实遮挡下方卡片,滚动穿过有破雾感) */} |
| 234 | + {fogBands.map((band, i) => ( |
| 235 | + <div |
| 236 | + key={i} |
| 237 | + className="absolute left-0 right-0 z-20 pointer-events-none" |
| 238 | + style={{ |
| 239 | + top: band.top - FOG_INTERVAL * PX_PER_UNIT / 2, |
| 240 | + height: FOG_INTERVAL * PX_PER_UNIT, |
| 241 | + opacity: band.opacity, |
| 242 | + background: 'linear-gradient(to bottom, rgba(10,22,32,0), rgba(10,22,32,0.85) 50%, rgba(10,22,32,0))', |
| 243 | + }} |
| 244 | + aria-hidden="true" |
| 245 | + /> |
| 246 | + ))} |
| 247 | + </div> |
| 248 | + </div> |
| 249 | + |
| 250 | + {/* 视口雾(氛围:顶部海面光 + 底部渐暗,跟随视口) */} |
| 251 | + <div |
| 252 | + className="absolute inset-x-0 top-0 h-16 z-30 pointer-events-none" |
| 253 | + style={{ background: 'radial-gradient(ellipse at 50% 0%, rgba(74,155,217,0.16), transparent 70%)' }} |
| 254 | + aria-hidden="true" |
| 255 | + /> |
| 256 | + <div |
| 257 | + className="absolute inset-x-0 bottom-0 h-10 z-30 pointer-events-none" |
| 258 | + style={{ background: 'linear-gradient(to top, rgba(2,6,10,0.55), transparent)' }} |
| 259 | + aria-hidden="true" |
| 260 | + /> |
| 261 | + |
| 262 | + {/* 深度指示(左上) */} |
| 263 | + <div className="absolute left-4 top-4 z-40 pointer-events-none flex flex-col gap-1"> |
| 264 | + <span className="text-[11px] tracking-[0.3em] text-text-tertiary/70 uppercase">Depth</span> |
| 265 | + <span className="text-b1 font-semibold text-text-primary">{ABYSS_LAYERS[currentLayer].name}</span> |
| 266 | + <span className="text-c1 text-text-tertiary tabular-nums">{Math.round(scrollDepth)}m / {MAX_DEPTH}m</span> |
| 267 | + </div> |
| 268 | + |
| 269 | + {/* 操作提示(左下) */} |
| 270 | + <p className="absolute left-4 bottom-4 z-40 text-c1 text-text-tertiary/70 pointer-events-none"> |
| 271 | + 滚轮下潜 · 单击选中 · 双击/Enter 打开 · 方向键切换 |
| 272 | + </p> |
| 273 | + |
| 274 | + {/* 悬停小卡(右上) */} |
| 275 | + {hoveredNote && ( |
| 276 | + <div className="absolute right-4 top-4 z-40 px-3 py-2 rounded-kb-md bg-bg-elevated/90 backdrop-blur border border-border/40 text-c1 text-text-primary shadow-kb-sm pointer-events-none max-w-[220px]"> |
| 277 | + <span className="block truncate">{hoveredNote.title || '无标题'}</span> |
| 278 | + <span className="block text-c2 text-text-tertiary mt-0.5"> |
| 279 | + 亮度 {Math.round(nodeBrightness(hoveredNote) * 100)}% · {hoveredNote.tags[0] ? `#${hoveredNote.tags[0]}` : ''} |
| 280 | + </span> |
| 281 | + </div> |
| 282 | + )} |
| 283 | + |
| 284 | + {/* 选中详情卡 */} |
| 285 | + {selectedNote && <ReefSelectedCard note={selectedNote} onOpen={onOpen} />} |
| 286 | + </div> |
| 287 | + ); |
| 288 | +} |
| 289 | + |
| 290 | +export default AbyssView2D; |
0 commit comments