Files
rhinelab-ui/verification/ARCHIVE-ARRAY.md
T

160 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 全量归档阵列(2026-09-12)⚠️ 已回退,仅存档
> **本文档描述的方案已于 2026-09-12 当天整体回退**,用户判定"索引完全改坏"。文中提到的
> `src/archive-array.ts` / `scripts/check-array.mjs` / `npm run check:array` / `loadArchiveDataset` /
> `searchDataset` / `laneCenter` / `rowPeriod` / `ROW_SNAP_DISTANCE` **都已不存在**,不要再按本文实现。
> 当前机制见 [`../AGENTS.md`](../AGENTS.md) 顶部「索引与阵列:当前机制(权威)」。保留本文只为记录当时的
> 设计与失败原因(列外条目错位、检索列表被 `00_索引` 元数据文件淹没)。
把三维档案阵列从**写死的 5 列 × 8 条(40 条精选档案)**改造为**全量归档驱动**:列 = 归档顶层内容分类,列内是该分类下的真实归档条目。条目简介由索引自动派生。
使用与安全边界见 [`../docs/LOCAL-ARCHIVE.md`](../docs/LOCAL-ARCHIVE.md)。
## 架构:两个数据集,一套寻址接口
| 数据集 | 来源 | 规模 | 作用 |
| --- | --- | ---: | --- |
| `curated` | `content/archives.json`(生成物,手写内容在 `content/curated.mjs`) | 5 列 / 40 条 | ① 模块加载时即刻可用,保证 `main.ts` 顶层的同步访问不炸 ② 归档索引不可用时降级 |
| `archive` | `public/archive-index.json`(生成物) | 7 列 / **32 张盘**(盘内 49,965 条) | `start()` 里由 `loadArchiveDataset()` 换上,阵列与检索的正式数据源 |
`data.ts` 的 `columnFiles` / `fileLocation` / `fileAtSlot` / `indexAtCell` 对两个数据集是同一套接口,因此阵列、循环、拖拽、收藏都不需要知道当前用的是哪一份。
## 为什么必须做性能改造
优化前后的实测(真实 49,997 条索引):
| 指标 | 旧实现 | 新实现 |
| --- | ---: | ---: |
| 2000 次寻址 | **38,365 ms** | **0.33 ms** |
| 单次寻址 | ~19 ms | ~0.0002 ms |
| 一百万个格子反查 | — | 23 ms |
| 建表(一次性) | — | 72 ms |
| 全量数据集载入 | — | 181–231 ms |
旧实现每次调用都 `.map().filter()` 扫全量再用 `.indexOf()` 定位。真实后果不是"每帧卡",而是**输入路径**卡:hover 每次进新格一次全扫(约 19 ms)、滚轮每步两次、**一次拖动最多 64 次(约 1.2 秒)**。
> 记录一条判断失误:我曾据基准推算出"旧写法每帧 19 秒",这是错的。渲染循环 `scene.update` 里根本不调用 `columnFiles` / `fileAtCell`(每帧 0 次),开销全在输入路径。
## 关键设计决策
### 1. 全局行周期(这是全量化最容易错的一点)
行坐标靠"减去周期整数倍"做归一化(rebase),而位移必须保持内容不变 —— 也就是必须是该列**取模周期的整数倍**。各列长度不同(2340 / 14797 / 20675 …)时**不存在公共周期**。
解法:引入**全局行周期 = 最长一列的条数**(`data.ts` 的 `rowPeriod()`)。`indexAtCell` 先用全局周期定位槽位、再落到该列自身长度上:
```
slot = wrap(row - ROW_BASE, rowPeriod)
item = members[slot % members.length]
```
于是任何全局周期整数倍的平移都不改变结果,rebase 成立。`curated` 下各列都是 8,与原行为逐位一致。
`scripts/check-array.mjs` 专门断言了这条不变性(±1 个周期与 +3 个周期)。
### 2. `fileAtSlot` 与 `indexAtCell` 语义不同,不能合并
- `fileAtSlot` —— **截断**(越界夹到首尾)。只服务 boot 与 `?time=`/`review=1` 逐帧对照的固定 160 布局,必须与原片一致。
- `indexAtCell` —— **循环取模**。服务交互阵列的无限循环。
中途我把 `fileAtSlot` 误写成取模,等价性检查立刻报出 110 处差异(每列 12 行 × 9 列边距)。已改回截断。
### 3. 展示字段惰性物化 + 有界缓存
五万条记录若都生成摘要文本,仅数据就约 35 MB。`data.ts` 用 `Proxy` 惰性物化:只给真正被读到的条目算文本(首读一条含派生摘要约 **0.008 ms**)。缓存上限 1024,防止日后有人全量遍历时几十 MB 常驻。
### 4. 检索在紧凑索引上,绝不遍历 `records`
`renderResults` 曾经 `records.map().filter()` —— 全量下会物化五万条。现在走 `searchDataset()`:只比对路径字符串(小写路径惰性建一次),命中后由调用方按需读那几条。实测路径检索 **9 ms**。
`scene.ts` 对 `records` 的引用数现在是 **0**;`main.ts` 只剩 4 处 `records.length`(O(1))与一处位于 curated 分支的 `forEach`。
### 5. 收藏改按路径键
编号是数组下标,归档新增文件会整体位移,按编号存会让旧收藏指到别的档案。改为 `recordKey()`(优先归档相对路径,退化为编号),`indexOfKey()` 用惰性 Map 反查。
### 6. 刻度条窗口化
`#file-ticks` 原先每条档案一个按钮;20,675 条会渲染两万个 DOM 节点。改为固定 8 个按钮的滑动窗口(`TICK_WINDOW`),随选中位置移动、按循环语义环绕。
### 7. 列数参数化
| 位置 | 改动 |
| --- | --- |
| `archive-loop.ts` | 新增 `configureLoop(columnCount)`;`LOOP_COLUMNS` / `POOL_LANES` 改为 live binding。前五项恒为真实列(列数不足时回绕),保证固定 160 布局永远成立 |
| `archive-visibility.ts:36-37,42` | `+2` / `(lane-2)` → `laneCenter()` |
| `scene.ts` | 四处 lane 原点改用 `laneCenter()`;rebase 周期 `(lane-2)/5` → `/(列数)`、`(row-12)/8` → `/rowPeriod()`;`laneFocus` 与 `selectedSlot` 初值随列数;`Math.floor(selectedSlot/32)` → `ROW_STRIDE` |
| `motion.ts` | `archiveWave` / `cinematicField` 增加 `laneOrigin` 可选参数(默认 2,保持签名兼容,也不把数据层拖进纯数学模块) |
| `main.ts` | `COLUMN 03 / 05` 的 /05 改为动态;`columnMemory` 改为可重建;新增 `applyColumnChrome()` |
`scene.ts:1596` 的 `fixed` 路径(boot + 逐帧对照)**保持不变**:仍是 `Array.from({length:160})` 的 5 列 × 32 行,因为它是原片对照基准。正常入场走的是动态路径(`responsiveOpening` 令 `fixed=false`),所以 boot → archive 之间不会跳变,只是入场画面从 5 列变成 7 列。
### 8. 远距离跳转直接落位
`selectionCell` 的 `nearestOccurrence` 会把目标行放到离当前位置最近的同现点,最大距离是**列长的一半**——最长列 20,675 条,即最远 10,337 行。照阻尼飞过去镜头会横穿整列。
`scene.ts` 因此加了 `ROW_SNAP_DISTANCE = 64`(可视窗口 32 行的两倍):行距超过它就认定是跳转而非移动,`shoulder` 与 `rail` 直接赋值、速度清零。
- 只作用于**行轴**。列轴最远只有半个列数(`nearestOccurrence` 对 7 列最多 3 列),原动画保留,不介入。
- 拖动、惯性、抽取期间由它们接管轨道,条件里显式排除,不干扰已确认的交互。
- 正常导航不会误触发:方向键与滚轮的连按滞后远小于 64 行(`check-array` 断言相邻移动的行距恒为 1)。
阈值两侧都由 `npm run check:array` 用真实数据钉住:相邻移动距离必须为 1,半列之外的跳转距离必须大于 64。
## 刻意没有改的东西
- **校验器 `archive-content.mjs` 与 `check-content.mjs` 未放宽**。因为 `content/archives.json` 仍是 curated 数据集、仍是 5 列 × 8 条 × 40 条,校验契约依旧成立 —— 原计划要放宽,架构定下来后发现没必要。
- **`export-records.mjs` / `build-pwa.mjs` 未改**。TXT 导出仍只针对 40 条精选档案(40 个文件),不会线性膨胀。
## 盘的粒度:一个项目一张盘(2026-09-12 修正)
第一版把**每个文件**都做成一张盘(49,997 张),这是错的。用户指出正确模型是"**一个项目就是一个 3D 盘,在盘内的页面可以自由访问所有文件**"。错误版本有两个可见后果:
1. 检索列表一打开就列 `records` 的前 60 条,而索引按路径排序,头 60 条正好是 `00_索引` 下的 CSV / MD / TXT 元数据文件;
2. 这些条目 `laneOf < 0`(不在任何列里),选中时 `fileLocation` 退回 `{lane: 0, row: 12}`,**抽出的是第一列的第一张盘**——完全错位。
现在的规则:
- **盘 = 分类下的第 1 层目录**(项目单元)。文件不是盘,更深的目录也不是盘。
- 列外条目不再参与 `searchDataset`,也不会出现在检索列表里;未输入关键字时列的是各列的盘。
- 详情页新增 **04 盘内文件** 页签:列当前盘的直接子项(目录在前),可逐层进入并带面包屑,文件可【打开】【定位】。
列分布:`4 / 2 / 11 / 3 / 4 / 4 / 4 = 32 张盘`,`unlisted` 49,965(盘内文件与更深结构)。
**副作用**:`ROW_SNAP_DISTANCE = 64` 在盘模型下**休眠**——最长列 11 张盘,最近同现点最远 5 行。阈值保留以应对粒度变化;`check-array` 会报告 `snapEngaged`,并在列长超过阈值时自动改断言为"必须触发"。
## 证据
```
$ npm run check:array
{
"entries": 49997,
"columns": 7,
"counts": [2340, 14797, 20675, 11901, 200, 15, 32],
"unlisted": 37,
"rowPeriod": 20675,
"roundTripsChecked": 89,
"searchMs": 9,
"addressMsPerMillion": 23,
"checks": "passed"
}
$ npx tsc --noEmit → exit 0
$ npm run check:content → 18 pass / 0 fail
$ node scripts/check-archive.mjs → checks: passed(40 条 / 每列 8 / 帧 760 波峰等高)
$ node scripts/check-loop.mjs → checks: passed(poolSize 288 / referenceInstances 160)
$ npm run build → exit 0,离线包 818 files / 33.9 MiB
```
行为等价性(对既有 40 条,与旧实现逐位对比):`fileLocation` / `fileAtSlot` / `columnFiles` 差异均为 **0**。
## 尚未验证与已知取舍
- **浏览器内的实际观感**:项目无 Playwright / Puppeteer,也没有无头 WebGL 环境。7 列下的构图、卡片观感、`archiveCandidates` / `archiveCulled` 的真实数值,都需要人工在 `npm run dev:archive` 下确认。剔除遥测可从
`JSON.parse(document.querySelector('#three-scene').dataset.renderStats)` 读取。
- 相机位置与 fov 是按 5 列调的,但交互阵列本就只显示约 5 列、靠 `columnCamera` 横向平移到达其余列,因此**预期**只是横向可视列数不变、需要平移才能看到边缘列,而非取景错误。这一点未经实机确认。
- **列极不均衡**:`03_代码与项目` 20,675 条 vs `06_下载与安装包` 15 条。列数已参数化,若要改成"大分区拆子列"只需换列规则。
- **行坐标量级**:rebase 前行可达 20,675,z 约 12,800 单位。相机相对矩阵下精度约 0.001(卡片尺寸约 5),预期无可见影响,但未实测。
- **旧收藏**:早先按 `X-0NN` 编号存的收藏不会解析到路径键,会从收藏列表消失(仍留在 localStorage)。按编号存的语义本就与全量归档冲突,取舍是明确的。