feat: shared supervisor timeline, story assignment and time-spiral view

Each source version is extracted once for every review scope; events carry spans and projects from their sources, and attention density is counted from messages. After publication, events are assigned to project features and sub-threads (cross-project stories only when enabled), with user rename, merge, remove, move and undo. The story graph gains a lazily loaded Three.js time-spiral view. Includes the 3D concept demo, real-data DeepSeek check scripts and docs.
This commit is contained in:
mesalogo
2026-10-02 15:21:48 +08:00
parent 3d4e9577b5
commit 5dc65457d9
53 changed files with 4051 additions and 83 deletions
@@ -20,6 +20,7 @@
| [技术设计](./technical-design.md) | Main/Renderer 分层、数据模型、IPC、安全边界和实施顺序 |
| [时态 Story Graph MCP 设计](./story-graph-mcp-design.md) | 只读工具、时间与版本语义、Runtime 接入,以及后续跨范围事实复用和记忆过渡合同 |
| [回顾分块调度设计](./review-scheduling-design.md) | 第 0 节定义生产分页、批次持久化、续跑、当前设置及剩余边界;其余章节保留改造前基线、目标设计和原型证据 |
| [故事线模型改造设计](./storyline-model-design.md) | 全局 › 项目 › 功能 › 子线索的故事层级、故事归属与经验抽取阶段、用户调整及实施顺序(目标设计) |
| [实施进度](./progress.md) | 已验证实现、验证证据和当前边界 |
| [故事线图谱设计](../story-graph/prd.md) | 图谱对象、环绕时间轴与实体交互 |
@@ -4,6 +4,98 @@
当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。
## 2026-10-02 故事线模型第 3 步:时间螺旋视图
对应[故事线模型改造设计](./storyline-model-design.md)第 3 步与 [3D 概念](../story-graph/3d-concept.md)。
实现:
- 图谱画布标题右侧新增“平铺 / 时间螺旋”切换。时间螺旋视图(`StoryGraph3D.tsx`)读取故事列表与图谱返回的 `attention`:高度为时间,半径为每圈的对话轮次(一圈一个半径,相邻圈差值受限),木片为故事起止,事件按屏幕距离聚合。层级为 全部 › 项目 › 功能 › 子线索,跨项目故事在根层;点击木片进入下一层并在右侧显示故事详情,单个事件选中后显示事件详情。斜俯视/侧视/俯视/自由视角,拖动、滚轮、方向键、数字键、Esc。
- 计算部分在 `story-graph-3d-model.ts`(树、角度、圈长、半径、聚合),渲染只负责显示。圈长从候选档位中选最接近 8 圈且不超过 14 圈的一档。
- Three.js `0.186.1` 作为依赖,组件通过 `lazy` 加载,只在打开时间螺旋时下载(生产构建中只出现在 `StoryGraph3D` 分块)。颜色取现有 `--graph-node-*` 令牌。WebGL 不可用时提示改用平铺视图;画布下方以列表重复当前层木片,供键盘与读屏使用。
验证:
- `story-graph-3d-model.test.ts` 4 项(树与跨度、圈长档位、每圈一个半径且变化有界、聚合);工作区、活动、设置组件测试通过;Node/Web 类型检查、相关 ESLint 通过;`electron-vite build` 确认 Three.js 只在懒加载分块中。
- Electron 实测(第 2 步跨项目运行的真实故事数据副本,生产组件):1440 浅色与 390 深色均无控制台错误、无横向溢出;画布非背景像素约 26%;根层 6 片(5 个项目、2 个跨项目故事中的果蝇神经仿真等),goodbuddy 15 个功能,监督者 9 条子线索;视角切换、Esc 返回、选中故事回调正常;旋转一帧约 1 ms。数据跨 13 天,一圈 1 天共 13.1 圈。
边界:时间切面(第 7 项)、展开视图(第 8 项)、原位分裂动画和桶外经验节点尚未移植;经验(W)要到第 4 步。Three.js 懒加载分块约 1.15 MB。
## 2026-10-02 故事线模型第 2 步:故事归属与用户调整
对应[故事线模型改造设计](./storyline-model-design.md) SL-1~SL-3、SL-5、SL-8 和第 2 步。
实现:
- `supervision-stories.ts`:新表 `supervision_stories`(功能、子线索、跨项目故事,状态、用户调整标记)、`supervision_event_stories`(主故事唯一,另可关联)、`supervision_story_assigned`、`supervision_story_edits`。Schema 54;升级后表为空,已有事件由下一次回顾归属,不重读来源。
- 回顾发布后在同一回顾名额内执行故事归属:只取本范围中尚未归属的当前事件,按项目分块,每块一次模型调用,输入是事件标题、描述和本项目已有故事摘要,不含原文。没有待归属事件时不调用模型。项目来自事件来源;功能、子线索由模型提出;允许“暂不归类”。
- Main 校验每个事件恰好分配一次、主故事属于本项目、子线索有上级功能、结束标记引用本次归入该故事的事件;任一项不符则整块不保存。功能事件数低于“子线索最少事件数”(设置,默认 20)时,子线索并入功能。
- 跨项目关联关闭时,提示词禁止跨项目故事,也不提供已有跨项目故事;开启时项目依次处理,跨项目故事只作关联,不影响主故事的长度和事件数。
- 用户调整:重命名、合并(同项目同层级)、移除(事件回到上级功能或变为暂不归类,不删除事件)、把事件移到同项目其他故事或暂不归类、撤销上一次调整。调整过的故事不会被自动整理改名或标记结束,移除的故事不会因同名再生成。
- 故事归属失败不影响已发布的回顾:活动记录显示“回顾已保存,故事整理失败”并可重新整理;下一次回顾(含无变化)也会补上。
- 界面:图谱左侧新增“故事”页签,按项目 › 功能 › 子线索、跨项目故事列出,显示暂不归类的事件数;选中故事在右侧查看起止、状态、事件,并可重命名、合并、移除、撤销;事件详情可改所属故事。设置新增“功能拆分子线索的最少事件数”。
验证:
- 新增 `supervision-stories.test.ts` 6 项:归属与无变化零调用、长时间空白后接回原故事、不完整/重复/虚构/证据不符的回答整块拒绝、小功能子线索并入、跨项目开关、调整保护与撤销、回顾内执行及失败后重试。`src/main/assistant` 全目录、Story Graph MCP 集成、监督相关 IPC、监督者工作区/活动/设置组件测试均通过;Node/Web 类型检查与相关 ESLint 通过。未运行全量测试。
- 真实数据(第 1 步升级后的本机数据副本,`scripts/stories-deepseek.mjs`,DeepSeek `deepseek-flash`):
- 跨项目关闭:437 个当前事件,11 次调用约 4.3 分钟;39 个故事(35 个功能、4 个子线索),396 个事件归入,41 个暂不归类;无事件有两个主故事、无主故事跨项目;11 个“已结束”均引用本故事内的事件;再次归属 0 次调用。
- 跨项目开启:11 次调用约 6.4 分钟;44 个故事,其中 1 个跨项目故事关联 dgx 与阿里云-国内的 7 个事件;其余检查同上。
观察到的问题(首轮):
- 功能粒度不稳定:goodbuddy 的发布被拆成 0.13.10、0.13.11、0.13.12、0.13.13 多个功能,两次运行切分不同。
- 监督者相关 77~85 个事件的功能只拆出 0~1 个子线索,模型很少主动建子线索。
- goodbuddy 的 fly-brain 仿真与微信 ClawBot 的果蝇开源项目在开启跨项目后仍未关联。
- 故事起止跨度可以从事件计算,但单个事件的跨度仍多为同一时刻(见第 1 步边界)。
针对上述问题的调整:
- 提示词写明粒度:功能是持续数周的模块、子系统或研究主题;发布、版本、单个缺陷、单日任务不是功能,同一项目的发布与版本同步归入一个功能,版本可作子线索;功能名不含版本号和日期;每个项目通常 3~12 个功能;功能达到“子线索最少事件数”后按子线索放置事件。
- 新功能或跨项目故事的名字去掉版本号、日期后与已有或同批故事相同时,并入已有故事(确定性规则);被并入那一方的“已结束”标记不生效,避免一个版本结束让整条发布线结束。
- 子线索拆分:每个项目归属完成后,自身事件数跨过“子线索最少事件数”下一个整数倍的功能(每项目最多 3 个)各做一次拆分调用,输入只有该功能名称、已有子线索和模型放置的事件标题与截断描述。只把事件从功能移到子线索;新子线索至少需要 max(3, 阈值/5) 个事件,否则不建;用户移动过的事件和用户调整过的功能不动;被用户移除的同名子线索不重建。功能没有跨过下一个整数倍时不再调用。
- 跨项目开启时,提示词附带其他项目的功能名称、描述和事件数(最多 60 个,不含事件内容),已有跨项目故事附带它已关联的功能名称;回答可用 `links` 把本项目功能和其他项目功能挂到同一个跨项目故事,关联该功能的全部当前事件(只作关联,不改主故事)。关闭时不提供,且 `links` 会被整块拒绝。
- 新故事键接受 `new_1_t1` 这类写法(真实模型在一次回答中这样写子线索键,原先整块被拒)。
调整后的真实数据(同一数据副本,DeepSeek `deepseek-flash`,每次各自从空故事开始):
| 运行 | 调用 | 功能 / 子线索 / 跨项目 | 归入 / 暂不归类 | 拆分移动 | 版本号命名的功能 |
| --- | --- | --- | --- | --- | --- |
| 首轮 关闭 | 11 | 35 / 4 / 0 | 396 / 41 | — | 有(0.13.10~0.13.13) |
| 首轮 开启 | 11 | 35 / 8 / 1 | 407 / 30 | — | 有 |
| 调整后 关闭 | 12(含 1 次拆分) | 19 / 23 / 0 | 372 / 65 | 18 | 0 |
| 调整后 开启 | 12(含 1 次拆分) | 21 / 23 / 2 | 388 / 49 | 20 | 0 |
- 改善:goodbuddy 发布收敛为一个“发布与版本”/“提交与发布”功能,版本工作成为其子线索;监督者功能拆出 9~11 个子线索(回顾整理与图谱、回顾算法与并发设置、故事线模型与 3D 图谱、活动记录页、自动监督开关与设置等),界面、笔记与画布也拆出子线索;开启跨项目后“果蝇神经仿真”关联了 goodbuddy“本地果蝇神经仿真”与微信 ClawBot“果蝇神经开源项目调研”(16 个事件),另有“Agent 实例生命周期与回收”关联 goodbuddy 与 dgx(24 个事件)。两次运行都无事件有两个主故事、无主故事跨项目、无小功能子线索,再次归属 0 次调用。
- 未改善或变差:暂不归类增多(关闭 41→65,开启 30→49),模型在功能变宽后把更多零散事件留空,未逐条核对是否合理。首轮的 dgx+阿里云跨项目故事这次未出现。子线索大多在归属调用中直接建出(关闭 18、开启 20 个事件由拆分调用移入子线索),拆分调用每次运行只触发 1 次。关闭、开启各只成功跑了一次,多次运行的切分是否一致未比较。
- 单元测试:`supervision-stories.test.ts` 新增 3 项(版本名功能合并且不误结束、拆分调用的触发上限与用户事件保护及整块拒绝、跨项目功能列表与 `links` 关联及关闭时拒绝),共 9 项;与 `supervision-timeline.test.ts` 合计 13 项通过;Node 类型检查与相关 ESLint 通过。
- 失败:另有两次运行中途失败、未完成。一次是开启跨项目时模型把“已结束”标记引用到未归入该故事的事件,整块被拒,导致整次归属失败(已在提示词中强调证据事件须归入同一故事,之后的开启运行通过);一次是上述 `new_1_t1` 键格式,已放宽校验并加单元测试,但放宽后未再用真实模型重跑。严格校验下单块被拒会让整次归属失败,仍需重试才能补上。
## 2026-10-02 故事线模型第 1 步:共享时间线
对应[故事线模型改造设计](./storyline-model-design.md) SL-6、SL-7 和第 1 步。
实现:
- 处理进度改为一条共享时间线:`review_checkpoints` 的监督阶段只用 `scope = 'timeline'`,项目、全局和多项目组合共用。同一来源版本在任一范围成功发布后,其他范围不再提取;范围只决定读取和展示哪些来源。旧的按范围进度在升级时合并,不重读已处理内容。
- 事件新增 `started_at`、`ended_at`、`project_id`、`superseded_by`,来源新增 `source_key`、`source_revision`(`supervision-timeline.ts`)。起止时间和所属项目按事件引用的来源记录计算,不由模型给出;来源跨多个项目时 `project_id` 为空。同一来源版本的重叠区间被后来的结果重新提取时,旧结果中的事件标记为已被替代,图谱和 MCP 只读当前事件,历史结果快照不变。
- 图谱按范围读取共享时间线上的当前事件、实体、关系和来源,并返回 `attention`:按小时统计的消息轮次与正文量,只表示投入集中程度。
- 新增设置“跨项目关联”(`supervisionReview.crossProject`,默认关闭)。关闭时,每批只把本项目已有的实体作为候选,不同项目的内容不会合并为同一实体;开启后候选来自全部项目。运行开始时冻结到本次配置。
- Story Graph MCP:全局范围可以读到各项目的当前事件;来源可从运行范围、全局或包含该来源项目的项目范围读取。
- Schema 53。
验证:
- 新增 `supervision-timeline.test.ts`:项目回顾后全局只提取其他项目的来源,相同项目再次回顾不调用模型;起止时间和项目来自来源;重新整理与项目回顾重叠后当前事件不重复;跨项目开关决定候选;Schema 52 升级合并进度和重复事件。`src/main/assistant` 全目录 28 个文件 324 项、Story Graph MCP 集成、监督相关 IPC 15 项、设置组件均通过。Node/Web 类型检查、相关 ESLint、`git diff --check` 通过。未运行全量测试。
- 真实数据(`scripts/timeline-deepseek.mjs`,用 SQLite 在线备份复制本机 2.2 GB 数据库,原库只读):升级 2.0 秒,`integrity_check` 为 ok。原有 423 个事件全部得到起止时间与项目;13 个两次回顾重复提取的事件被标记替代,当前 410 个(goodbuddy 358、默认项目 28、dgx 17、微信 ClawBot 5、阿里云-国内 2);全局图谱返回 67 个小时段、790 轮对话的密度。
- 真实模型(DeepSeek,`deepseek-flash`):对 goodbuddy 最近 2 小时的 37 个来源做项目回顾,4 个批次加 3 次导航汇总,共 7 次请求,新增 27 个事件,起止时间都在区间内,未关联到其他项目的实体;再次项目回顾和同区间全局回顾均为无变化,0 次请求,全局重新提取的项目来源为 0。
边界:
- 这个 2 小时区间没有其他项目的消息,所以“全局只补其他项目”只在合成测试中验证过。
- 模型通常让一个事件只引用一条消息,所以真实数据里的事件起止时间多数是同一时刻。按故事计算跨度要等第 2 步的故事归属。
- 测试脚本把单次输出上限设为 8,192 token 时,一个批次的输出被截断,导致 JSON 无效。上限调到 32,768 后通过。产品路径不设这个参数,具体由 Runtime 的提供商配置决定。
## 2026-09-30 心跳职责收敛与监督建议
对应 FR-S4、FR-S12、US-S32、US-S33,规则见[心跳职责与介入](./logic-design.md#心跳职责与介入目标设计)。
@@ -25,7 +117,7 @@
- 真实模型(DeepSeek,`deepseek-flash`,隔离 SQLite,`scripts/heartbeat-deepseek.mjs`):首次心跳 1 次回顾调用 + 1 次建议调用(建议输入约 1,150 字符,不含原文),生成分歧、修订、未决事项三条建议且依据均指向本次来源;无变化心跳与默认手动回顾均 0 次调用;追加一条消息后只处理 1 个来源,复用全部 5 个既有实体,再调用 1 次回顾、1 次建议。共 4 次付费请求。
- 未运行全量测试。
边界:主动介入(会话级实时)未实现,界面不显示;项目与全局仍各自维护处理进度(US-S31)。
边界:主动介入(会话级实时)未实现,界面不显示;项目与全局仍各自维护处理进度(US-S31),已由 2026-10-02 的共享时间线取代。
## 2026-09-28 Story Graph 只读工具
@@ -40,6 +40,8 @@ schema 47 增加运行配置、来源清单、成功批次、导航节点四张
| `supervisionReview.batchCharacters` | 8000 | 1000..16000 | 每批新正文 UTF-16 单元,不含提示、候选及 JSON |
| `supervisionReview.batchMessages` | 20 | 1..50 | 每批不同消息数;任务等无消息 ID 的来源单独计数 |
| `supervisionReview.executionSeconds` | 300 | 30..3600 | 旧设置兼容字段,执行时忽略,设置页不再展示;恢复旧 run 同样不按此值暂停 |
| `supervisionReview.crossProject` | 关闭 | 开关 | 创建回顾时冻结;关闭时每批只以本项目已有实体为候选,也不生成跨项目故事;开启后候选来自全部项目,故事归属可建立跨项目故事 |
| `supervisionReview.storyThreadEvents` | 20 | 4..500 | 创建回顾时冻结;功能的事件数低于此值时,模型提出的子线索并入功能 |
| `supervisorOrganizeTimeoutSeconds` | 240 | 30..600 | 创建回顾时冻结,节点获槽并解析 Runtime 后计时 |
| `heartbeatReportTimeoutSeconds` | 240 | 30..600 | 维持报告发起时冻结及既有租约规则 |
| `supervisorModelConcurrency` | 1 | 1..4 | 回顾保存接纳并发;共享池上限仍随应用设置实时变化,实际执行取较小值 |
@@ -0,0 +1,86 @@
# 故事线模型改造设计
状态:2026-10-02 已确认的目标设计。第 1 步(共享时间线、事件起止与密度、跨项目开关)、第 2 步(故事归属与用户调整)和第 3 步(时间螺旋视图)已实现,见[实施进度](./progress.md);第 4、5 步尚未实现。本文定义监督回顾如何产出下一版故事线(时间螺旋、分层故事木片、经验 W),是 [3D 故事线图谱概念](../story-graph/3d-concept.md) 的数据来源合同。现有回顾流程见[回顾分块调度设计](./review-scheduling-design.md),现有图谱工具见 [Story Graph MCP 设计](./story-graph-mcp-design.md)。
## 1. 结构总览
| 层 | 含义 | 来源 |
| --- | --- | --- |
| 全局(根) | 整条时间螺旋本身,即用户的全部经历;不是一片木片 | 所有来源 |
| 项目木片 | 按会话所属项目划分;无项目归属的内容进入“未归属项目” | 会话归属,确定性,不调用模型 |
| 跨项目故事 | 同时涉及多个项目或不属于任何项目的长期事情,例如长期偏好、学习方向 | 故事归属阶段 |
| 功能木片 | 项目内的长期功能或主题 | 故事归属阶段 |
| 子线索 | 事件量足够大的功能再拆分;小功能不拆 | 故事归属阶段 |
| 经验 W | 从故事中形成、可复用到其他故事的经验,默认是全局对象 | 经验抽取阶段 |
全局不作为单独的“全局记忆”重复处理:它是根视图,读取同一套事件、故事和经验。查看某个项目时,只是以该项目为过滤条件。
## 2. 已确认的决定
| 编号 | 决定 |
| --- | --- |
| SL-1 | 层级为 全局 › 项目 › 功能 › 子线索;子线索仅在功能事件量达到阈值时出现,阈值可在设置中调整 |
| SL-2 | 跨项目或无项目的长期事情,作为全局根下的“跨项目故事”,与项目木片并列 |
| SL-3 | 每个事件只归属一个主故事,可另外关联其他故事;关联不影响木片的长度和密度统计 |
| SL-4 | 经验自动生成即可供 Agent 读取,不设确认门槛;标记“自动归纳”,用户可以编辑、合并、删除 |
| SL-5 | 用户对故事和经验的调整(改名、合并、拆分、移动事件、编辑、删除)受保护,后续回顾不覆盖;删除的对象不会因同一证据再次生成 |
| SL-6 | 注意力密度按来源统计:时段内的消息轮次与正文量,不由模型判断;只表示投入集中程度 |
| SL-7 | 每个来源版本只提取一次,项目与全局共用提取结果和增量进度 |
| SL-8 | 设置提供“跨项目关联”,默认关闭。关闭时新内容只关联本项目已有的知识;开启后可关联其他项目中的同一件事,跨项目故事也只在开启时生成 |
## 3. 回顾流程改造
```text
采集来源(按来源版本增量)
↓
批次提取:事件带开始/结束时间(取原始消息时间)、来源引用
↓
故事归属(新增,不读原文)
· 项目:按会话归属确定
· 功能/子线索/跨项目:召回候选故事 → 一次模型调用判断归入已有、新建或暂不归类
↓
经验抽取(新增,低频,不读原文)
· 规则挑候选:有明确结果的决定、多个故事中重复出现的做法、说明原做法问题的修订
· 有候选时一次模型调用:经验内容、形成依据、适用条件、边界
· 记录后续应用及结果
↓
导航摘要 → 发布
```
规则:
- 故事跨回顾保持同一身份,沿用实体候选编号机制;长时间无事件的故事能被接回,不新建。
- 故事状态(进行中、已结束)必须有内容依据;长期无事件只标“暂无进展”,不推断结束。
- 允许“暂不归类”,不为凑层级强行归组;暂不归类的事件在项目下以淡色木片显示。
- 经验的每条依据必须对应到具体事件和来源,否则不保存;没有候选时不调用模型。
- 新增阶段失败时,已发布的提取结果和处理进度保留,可单独重试该阶段。
## 4. 用户调整
图谱是可编辑的结果,不需要逐条审批:
- 故事:改名、合并、拆分、调整层级、把事件移到其他故事。
- 经验:编辑内容与适用条件、合并重复、删除。
- 入口位于故事线图谱的详情面板与 3D 视图选中对象的详情中,共用同一组操作。
- 每次调整可撤销,并记录在活动记录的修改记录中。
## 5. 展示与使用
- 故事线图谱新增 3D 视图,与现有平铺视图并列,几何、时间尺度、聚合规则沿用 [3D 概念](../story-graph/3d-concept.md);Three.js 仅在打开 3D 视图时加载,WebGL 不可用时使用平铺视图。
- MCP 增加按故事、按经验查询;经验返回时附“自动归纳 / 用户编辑”标记和依据。
- 心跳建议增加“故事长期无进展”和“已有经验可能适用于当前故事”。
- 工作回顾按故事组织:推进了哪些故事、新出现哪些故事、形成或应用了哪些经验。
## 6. 存储
新增故事表(含层级与父故事)、事件与故事的归属/关联、经验表、经验依据与应用记录、用户调整记录;对应一次数据库升级。已有回顾结果在升级后按新流程重建,原始来源不变。
## 7. 实施顺序与验收
| 步骤 | 内容 | 验收(真实数据副本 + `.env.deepseek.local` 中的 DeepSeek) |
| --- | --- | --- |
| 1 | 统一时间线(SL-7)、事件时间跨度与密度(SL-6) | 重叠回顾不产生重复事件;项目与全局不重复处理同一来源 |
| 2 | 故事归属与用户调整(SL-1~SL-3、SL-5) | 多次增量回顾后故事不碎片化;间断后的事件接回原故事;调整不被覆盖 |
| 3 | 3D 视图接入真实数据 | 长期数据下时间尺度与聚合正常 |
| 4 | 经验抽取(SL-4) | 每条经验依据可追溯到原文;无候选时零额外调用 |
| 5 | MCP、心跳建议、工作回顾页 | Agent 能按故事接手工作、按经验给出建议 |
+41
View File
@@ -0,0 +1,41 @@
# 下一版 3D 故事线图谱概念
状态:已讨论的视觉方向,尚未实现。本文记录空间表达及概念图引用,不替代当前产品需求或技术设计。
## 时间、事件与故事
内部只有一条共同的时间螺旋。时间均匀推进,同一圈中交错发生不同事情;同一件事也可以在多个圈中间隔出现。
每圈的时长随查看范围变化,使螺旋保持约 8 圈、最多 14 圈:一天约 3 小时一圈,一周 1 天一圈,一个季度 1 个月一圈,几年 1 季度或 1 年一圈。用户可以手动指定,指定值会超过 14 圈时改用更粗的粒度。注意力密度按当前粒度重新统计,每圈 8 个时段。
同一故事中屏幕上过于接近的事件合并为一段,显示事件数量。点击聚合段,查看范围缩小到这段时间,事件随之拆开;小于约 6 小时仍无法拆开的事件(同一时刻多次提取),改为在右侧列出。
[3D 网页 Demo](3d-demo.html) 已按上述几何规则和时间尺度实现,数据为本机故事线图谱的只读导出。
螺旋半径可以随时间、随事件片段变化,表达注意力处理密度。时间轴不能随半径拉伸。密度的统计口径、归一化和缺失数据表现尚待定义,半径不代表效率、价值或成果。
外层以木桶木片般的曲面连接同一故事的事件。木片起止时间、长度各不相同,可以顺着事件位置弯曲;不能为保持规则桶形而强行等长、等径。多年持续的故事可能只有少数活跃阶段,事件之间保留空白,连接只表达同一故事的延续。未结束的故事使用开放末端,不虚构未来事件。
数据来源与层级(全局 › 项目 › 功能 › 子线索、跨项目故事、经验)由[故事线模型改造设计](../conversation-supervision/storyline-model-design.md)定义。
Demo 底部的“跨项目故事”开关默认关闭。打开后,根层在项目木片旁增加跨项目故事木片(示例:果蝇神经仿真、Agent 运行环境、国内服务器部署),由多个项目中的功能组成;事件仍归属原项目,跨项目木片只做关联。关闭时只显示同一项目内的经验连接。示例中的跨项目分组按功能名称手工对应,产品中应由故事归属阶段提出,并允许用户调整。
## DIKW 与阅读路径
| 层次 | 表达 |
| --- | --- |
| D | 可追溯的原始记录 |
| I | 时间螺旋上的事件片段 |
| K | 木片串联的故事及其形成的认识 |
| W | 从早期故事形成经验及适用条件,关联到后来其他故事中的应用 |
沿螺旋查看某阶段发生的事情;沿木片查看一件事如何跨阶段发展;跨木片查看经验如何被使用、验证或修正。普通相关性不能直接标为智慧。
## 概念图记录
- 基础图片引用:`0d644776-5ed2-42cc-86f9-67f8b0195c86`。
- 本次编辑任务:`b4e13361-acd6-49ba-9aaa-f37be837e257`。
- 会话:`95a52db9-fe02-4559-95de-4488f2163004`。
- 编辑要求:均匀时间轴、变半径单螺旋、不同起止与长度的故事木片、跨故事智慧复用。
提交绘图时任务仍在生成,尚未取得新图片文件。生成结果由会话图片卡片保存;本文只登记任务引用,不表示图片已下载到 Git 仓库。完成后应核验上述空间关系,再将导出的图片纳入设计资产。
File diff suppressed because one or more lines are too long
+608
View File
@@ -0,0 +1,608 @@
/* 3D Story Graph prototype on a real export, rendered with WebGL (Three.js r160, vendor/three.min.js).
* Same model as 3d-demo.js: height = time (uniform), helix radius = attention density, staves on a fixed outer
* cylinder at stable angles, events joined to their stave at the same height. The GPU depth buffer handles
* occlusion; names, counts and experience cards are drawn on a 2D overlay. Falls back to 3d-demo.js without WebGL. */
(() => {
const canvas = document.querySelector('#scene');
let renderer;
try {
if (!window.THREE) throw new Error('three.js missing');
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
} catch {
const fallback = document.createElement('script'); fallback.src = '3d-demo.js'; document.body.append(fallback);
document.querySelector('#renderer-note')?.replaceChildren('Canvas 渲染');
return;
}
const data = window.STORY_DATA;
const overlay = document.querySelector('#overlay');
const ctx = overlay.getContext('2d');
const tau = Math.PI * 2;
const palette = ['#367e70', '#bb8743', '#8675a6', '#b5604f', '#4f7fa8', '#7a8f3b', '#a35d86', '#3f8f97', '#9a7a55', '#5f6fb0', '#c07a3a', '#6b8d6f'];
const range = data.range.map(d => Date.parse(d + 'T00:00:00+08:00'));
const totalHours = (range[1] - range[0]) / 3600000;
const zoom = document.querySelector('#zoom');
const showWisdom = document.querySelector('#wisdom');
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
const BG = 0xfcfcf8;
// ---- model ---------------------------------------------------------------
const canonical = new Map();
data.projects.forEach(p => p.features.forEach(f => {
const seen = new Map();
f.events = f.events.filter(e => { const k = e.at + e.type; if (seen.has(k)) { canonical.set(e.id, seen.get(k)); return false; } seen.set(k, e.id); canonical.set(e.id, e.id); return true; });
}));
const canon = id => canonical.get(id) ?? id;
data.wisdom.forEach(w => { w.formedIn = [...new Set(w.formedIn.map(canon))]; w.appliedIn = [...new Set(w.appliedIn.map(canon))]; });
const root = { name: '全部工作', children: data.projects.map(p => ({
name: p.name, detailed: p.detailed, scattered: p.scattered,
children: p.features.map(f => ({ ...f, children: f.subThreads.map(s => ({ ...s,
events: [...new Set(s.eventIds.map(canon))].map(id => f.events.find(e => e.id === id)).filter(Boolean) })) }))
})) };
const eventIndex = new Map();
function finish(node, parent, depth) {
node.parent = parent; node.depth = depth;
(node.children ?? []).forEach(c => finish(c, node, depth + 1));
if (!node.events) node.events = [...new Map(node.children.flatMap(c => c.events).map(e => [e.id, e])).values()].sort((a, b) => a.t - b.t);
node.start = node.events[0]?.t ?? 0; node.end = node.events.at(-1)?.t ?? 0;
if (depth === 2 && !node.ref) node.events.forEach(e => eventIndex.set(e.id, { event: e, feature: node }));
}
finish(root, undefined, 0);
const projects = root.children;
projects.forEach((p, i) => { p.color = palette[i % palette.length]; });
// Cross-project stories (off by default): grouped from real feature names across projects. Their events still
// belong to the original project features; a cross-project stave only associates them.
const CROSS = [
['果蝇神经仿真', [['goodbuddy', '果蝇脑仿真'], ['微信 ClawBot', '果蝇开源调研'], ['微信 ClawBot', '脉冲脑模型'], ['微信 ClawBot', '视觉模型'], ['微信 ClawBot', '运动仿真']]],
['Agent 运行环境', [['goodbuddy', '远程Agent运行时'], ['goodbuddy', '模型与运行时'], ['dgx', 'Ollama 配置'], ['dgx', 'OCR 显存预留'], ['dgx', 'GBAgent 清理'], ['dgx', 'Agent 运行错误']]],
['国内服务器部署', [['默认项目', '国内节点隐藏IP'], ['阿里云-国内', '阿里云-国内']]],
];
const crossStories = CROSS.map(([name, members], i) => {
const children = members.map(([p, f]) => projects.find(x => x.name === p)?.children.find(x => x.name === f)).filter(Boolean)
.map(ref => ({ name: `${ref.parent.name} · ${ref.name}`, ref, entities: ref.entities, events: ref.events, children: [] }));
return { name, cross: true, color: palette[(projects.length + i * 2) % palette.length], children };
});
const crossToggle = document.querySelector('#cross');
const projectOfEvent = id => eventIndex.get(id)?.feature.parent;
const visibleWisdom = () => data.wisdom.filter(w => crossToggle.checked || projectOfEvent(w.formedIn[0]) === projectOfEvent(w.appliedIn.at(-1)));
function setCrossProject(on) {
root.children = on ? [...projects, ...crossStories] : projects;
root.events = undefined;
finish(root, undefined, 0);
}
let focus = root, selectedEvent, selectedWisdom, selectedCluster, hovered;
function layout(node, from, to) {
node.a0 = from; node.a1 = to;
const kids = (node.children ?? []).slice().sort((a, b) => a.start - b.start);
const weight = kids.map(k => Math.max(1.2, Math.sqrt(k.events.length)));
const total = weight.reduce((a, b) => a + b, 0);
let at = from;
kids.forEach((k, i) => { const w = (to - from) * weight[i] / total; layout(k, at, at + w); at += w; });
}
layout(root, 0, tau);
// ---- geometry ----------------------------------------------------------------
const HEIGHT = 430, STAVE_R = 188, WISDOM_R = 262, FLOOR = -HEIGHT / 2 - 34;
const TURN_STEPS = [3, 6, 12, 24, 168, 720, 2160, 8760];
const TARGET_TURNS = 8, MAX_TURNS = 14;
const win = { t0: 0, t1: 1, turnHours: 24 };
let turns = 8, bins = [], binMax = 1, smoothR = [];
const hoursOf = t => t * totalHours;
const clamp01 = t => Math.max(0, Math.min(1, t));
const inWindow = t => t >= win.t0 - 1e-9 && t <= win.t1 + 1e-9;
const uOf = t => (t - win.t0) / (win.t1 - win.t0);
const tAt = u => win.t0 + u * (win.t1 - win.t0);
function autoTurnHours(spanHours) { return TURN_STEPS.find(h => spanHours / h <= TARGET_TURNS) ?? TURN_STEPS.at(-1); }
const slotTimes = data.density.map((n, i) => ({ t: (i + .5) / data.density.length, n }));
function setWindow(t0, t1, turnHours) {
win.t0 = clamp01(t0); win.t1 = clamp01(t1);
const span = hoursOf(win.t1 - win.t0);
win.turnHours = turnHours ?? autoTurnHours(span);
turns = Math.max(.5, span / win.turnHours);
if (turns > MAX_TURNS) { win.turnHours = TURN_STEPS.find(h => span / h <= MAX_TURNS) ?? TURN_STEPS.at(-1); turns = span / win.turnHours; }
const count = Math.max(4, Math.round(turns * 8));
bins = new Array(count).fill(0);
slotTimes.forEach(s => { if (inWindow(s.t)) bins[Math.min(count - 1, Math.floor(uOf(s.t) * count))] += s.n; });
binMax = Math.max(1, ...bins);
// One radius per turn, from that turn's attention density, eased between turn centres with a cosine. Each
// turn reads as a circle; the helix swells or narrows only from turn to turn.
const n = Math.max(1, Math.ceil(turns - 1e-6)), perTurn = new Array(n).fill(0);
bins.forEach((v, i) => { perTurn[Math.min(n - 1, Math.floor((i + .5) / bins.length * turns))] += v; });
const top = Math.max(1e-6, ...perTurn), level = perTurn.map(v => Math.sqrt(v / top)); // sqrt keeps quiet turns visible
// Neighbouring turns differ by at most MAX_STEP of the radius range, so the coil never jumps between turns.
for (let pass = 0; pass < n; pass++) for (let d = 1; d < n; d++) {
if (level[d] - level[d - 1] > MAX_STEP) level[d - 1] = level[d] - MAX_STEP;
if (level[d - 1] - level[d] > MAX_STEP) level[d] = level[d - 1] - MAX_STEP;
}
// Each turn holds its radius (a circle) and changes only in a short eased zone around the turn boundary.
smoothR = Array.from({ length: R_SAMPLES + 1 }, (_, i) => {
const x = i / R_SAMPLES * turns, d = Math.min(n - 1, Math.floor(x)), f = x - d;
const half = TRANSITION / 2;
if (f < half && d > 0) { const k = (f + half) / TRANSITION; return level[d - 1] + (level[d] - level[d - 1]) * (1 - Math.cos(k * Math.PI)) / 2; }
if (f > 1 - half && d < n - 1) { const k = (f - 1 + half) / TRANSITION; return level[d] + (level[d + 1] - level[d]) * (1 - Math.cos(k * Math.PI)) / 2; }
return level[d];
});
}
const MAX_STEP = .22, TRANSITION = .3;
const R_SAMPLES = 600;
function density(u) {
const x = u * bins.length - .5, i = Math.floor(x), f = x - i, at = j => bins[Math.max(0, Math.min(bins.length - 1, j))] / binMax;
return at(i) * (1 - f) + at(i + 1) * f;
}
// Interpolated, never stepped, so the tube stays a smooth curve.
const radius = t => { const x = clamp01(uOf(t)) * R_SAMPLES, i = Math.floor(x), f = x - i; return 78 + 74 * (smoothR[i] * (1 - f) + smoothR[Math.min(R_SAMPLES, i + 1)] * f); };
const yOf = t => (uOf(t) - .5) * HEIGHT;
const helixAngle = t => uOf(t) * turns * tau;
const V = (x, y, z) => new THREE.Vector3(x, y, z);
const helixPoint = t => { const r = radius(t), a = helixAngle(t); return V(r * Math.cos(a), yOf(t), r * Math.sin(a)); };
const cylinder = (t, a, r = STAVE_R) => V(r * Math.cos(a), yOf(t), r * Math.sin(a));
const centerAngle = node => (node.a0 + node.a1) / 2;
// ---- scene -------------------------------------------------------------------
const scene = new THREE.Scene();
scene.background = new THREE.Color(BG);
scene.fog = new THREE.Fog(BG, 700, 1250); // far geometry fades toward the paper colour instead of gaining outlines
scene.add(new THREE.HemisphereLight(0xffffff, 0xc9cfc4, 2.2));
const sun = new THREE.DirectionalLight(0xffffff, 1.2); sun.position.set(-300, 500, 400); scene.add(sun);
const camera = new THREE.PerspectiveCamera(30, 1, 10, 4000);
let world = new THREE.Group(); scene.add(world);
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
const raycaster = new THREE.Raycaster();
// ---- camera ------------------------------------------------------------------
const VIEWS = { oblique: { yaw: -.4, tilt: .38 }, side: { tilt: 0 }, top: { tilt: Math.PI / 2 - 1e-3 } };
const cam = { yaw: -.4, tilt: .38, zoom: 1, pan: 0 };
let mode = 'oblique', tween, width = 0, height = 0;
const DIST = 900;
function placeCamera() {
const { yaw, tilt } = cam, ct = Math.cos(tilt), st = Math.sin(tilt);
const target = V(0, cam.pan, 0);
camera.position.copy(target).add(V(-Math.sin(yaw) * ct, st, Math.cos(yaw) * ct).multiplyScalar(DIST));
camera.up.set(Math.sin(yaw) * st, ct, -Math.cos(yaw) * st);
camera.lookAt(target);
// Same framing as the Canvas version: at zoom 1 the 500 × 560 box fits the viewport.
const visibleHeight = height / Math.min(width / 500, height / 560);
camera.fov = 2 * Math.atan(visibleHeight / 2 / DIST) * 180 / Math.PI;
camera.aspect = width / height; camera.zoom = cam.zoom; camera.updateProjectionMatrix();
}
function project(p) {
const v = p.clone().project(camera);
return { x: (v.x + 1) / 2 * width, y: (1 - v.y) / 2 * height, z: -v.z, behind: v.z > 1 };
}
const viewDir = () => camera.position.clone().sub(V(0, cam.pan, 0)).normalize();
const shortest = (from, to) => from + ((((to - from) % tau) + tau * 1.5) % tau - Math.PI);
function animateTo(target, nextMode = mode) {
mode = nextMode; syncViewButtons();
const goal = { ...cam, ...target };
goal.yaw = shortest(cam.yaw, goal.yaw);
if (reducedMotion) { Object.assign(cam, goal); syncZoom(); draw(); return; }
const from = { ...cam }, started = performance.now();
tween = { started };
const step = now => {
if (tween?.started !== started) return;
const k = Math.min(1, (now - started) / 480), e = k < .5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2;
for (const key of ['yaw', 'tilt', 'zoom', 'pan']) cam[key] = from[key] + (goal[key] - from[key]) * e;
syncZoom(); draw();
if (k < 1) requestAnimationFrame(step); else tween = undefined;
};
requestAnimationFrame(step);
}
function face(node) { if (node && node.depth > 0) animateTo({ yaw: centerAngle(node) - Math.PI / 2, pan: (yOf(Math.max(win.t0, node.start)) + yOf(Math.min(win.t1, node.end))) / 2 * .35 }); }
function freeView() { tween = undefined; if (mode !== 'free') { mode = 'free'; syncViewButtons(); } }
// ---- colour ------------------------------------------------------------------
const projectOf = n => { while (n.depth > 1) n = n.parent; return n; };
function colorOf(node) {
if (node.depth <= 1) return node.color ?? '#6f807b';
const p = projectOf(node);
return palette[(node.parent.children.indexOf(node) + root.children.indexOf(p) * 3) % palette.length];
}
function shown(node) { const set = visibleStaves(); for (let n = node; n; n = n.parent) if (set.includes(n)) return n; }
function level() { return focus.children?.length ? focus : focus.parent; }
function visibleStaves() { return level().children; }
function emphasisOf(s) { return focus === level() ? (s === hovered ? 'focus' : 'normal') : s === focus ? 'focus' : 'dim'; }
function connected() { return visibleStaves().filter(s => (focus !== level() && s === focus) || s === hovered); }
// ---- clustering --------------------------------------------------------------
const CLUSTER_PX = 9;
function scaleNow() { return Math.min(width / 500, height / 560) * cam.zoom; }
// Quantised to quarter-octaves so zooming re-clusters in steps rather than rebuilding geometry every frame.
function clusterSpan() { const raw = CLUSTER_PX / (HEIGHT * scaleNow()) * (win.t1 - win.t0); return 2 ** (Math.round(Math.log2(raw) * 4) / 4); }
function clusters(events) {
const span = clusterSpan(), out = [];
events.filter(e => inWindow(e.t)).forEach(e => {
const last = out.at(-1);
if (last && e.t - last.t0 <= span) { last.events.push(e); last.t1 = e.t; } else out.push({ t0: e.t, t1: e.t, events: [e] });
});
out.forEach(c => { c.t = (c.t0 + c.t1) / 2; c.n = c.events.length; });
return out;
}
const fmtSpan = c => c.n === 1 ? fmtTime(c.t) : `${fmtTime(c.t0)} – ${fmtTime(c.t1)}`;
// ---- building geometry -----------------------------------------------------------
// The scene is rebuilt only when what it shows changes; camera moves just re-render and redraw the overlay.
const mats = new Map();
function mat(key, make) { if (!mats.has(key)) mats.set(key, make()); return mats.get(key); }
const solid = (color, opacity = 1) => mat(`s${color}${opacity}`, () => new THREE.MeshStandardMaterial({ color, roughness: .65, metalness: 0, transparent: opacity < 1, opacity }));
const flat = (color, opacity) => mat(`f${color}${opacity}`, () => new THREE.MeshBasicMaterial({ color, transparent: true, opacity, side: THREE.DoubleSide, depthWrite: false }));
const lineMat = (color, opacity) => mat(`l${color}${opacity}`, () => new THREE.LineBasicMaterial({ color, transparent: opacity < 1, opacity }));
function tube(points, r, material, segments) {
const curve = new THREE.CatmullRomCurve3(points);
return new THREE.Mesh(new THREE.TubeGeometry(curve, segments ?? Math.max(8, points.length * 2), r, 8, false), material);
}
function polyline(points, material) { return new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material); }
let built = '', baseBuilt = '', staveMeshes = [], baseTicks = [], overlayItems = { labels: [], clusters: [], wisdom: [] };
let base = new THREE.Group(); scene.add(base);
let split; // in-place split / merge animation of staves between levels
function signature() {
return JSON.stringify([focus.name, focus.depth, hovered?.name, selectedEvent?.id, selectedWisdom?.lesson, win.t0, win.t1, win.turnHours, showWisdom.checked, clusterSpan(), split ? split.k.toFixed(3) : '']);
}
const dispose = group => group.traverse(o => o.geometry?.dispose());
// Geometry that depends only on the time window: floor, the grey helix and its turn ticks.
function rebuildBase() {
scene.remove(base); dispose(base); base = new THREE.Group(); scene.add(base); baseTicks = [];
const ring = []; for (let i = 0; i <= 96; i++) { const a = i / 96 * tau; ring.push(V(STAVE_R * Math.cos(a), FLOOR, STAVE_R * Math.sin(a))); }
base.add(polyline(ring, lineMat('#9aa69c', .35)));
const shadow = []; for (let i = 0; i <= 900; i++) { const p = helixPoint(tAt(i / 900)); p.y = FLOOR; shadow.push(p); }
base.add(tube(shadow, 1.4, flat('#8e9a90', .14), 900));
const helix = []; for (let i = 0; i <= 1400; i++) helix.push(helixPoint(tAt(i / 1400)));
base.add(tube(helix, 1.1, solid('#7d8a84'), 2800));
const every = Math.max(1, Math.ceil(turns / 10));
for (let d = 0; d <= Math.floor(turns + 1e-6); d++) {
const t = tAt(d / turns), p = helixPoint(t);
const tick = new THREE.Mesh(new THREE.SphereGeometry(2.2, 10, 8), solid('#3f514a')); tick.position.copy(p); base.add(tick);
if (d % every === 0 && d / turns < .999) baseTicks.push({ at: p, text: fmtTick(t) });
}
baseBuilt = JSON.stringify([win.t0, win.t1, win.turnHours]);
}
function rebuild() {
if (baseBuilt !== JSON.stringify([win.t0, win.t1, win.turnHours])) rebuildBase();
scene.remove(world); dispose(world);
world = new THREE.Group(); scene.add(world);
staveMeshes = []; overlayItems = { labels: [], clusters: [], wisdom: [] };
const staves = visibleStaves().filter(s => s.events.some(e => inWindow(e.t)) && s.a1 - s.a0 > 1e-3), links = split ? [] : connected();
const cl = new Map(staves.map(s => [s, clusters(s.events)]));
// Spans of the helix owned by visible stories are overlaid in the story colour.
const owner = new Array(600).fill(undefined);
staves.filter(s => emphasisOf(s) !== 'dim').forEach(s => cl.get(s).forEach(c => {
const i0 = Math.round(uOf(c.t0) * 599), i1 = Math.round(uOf(c.t1) * 599);
for (let i = i0 - 1; i <= i1 + 1; i++) if (i >= 0 && i < 600 && owner[i] === undefined) owner[i] = colorOf(s);
}));
for (let i = 0; i < 600;) {
const c = owner[i]; let j = i; while (j + 1 < 600 && owner[j + 1] === c) j++;
if (c) { const pts = []; const steps = Math.max(2, (j - i + 1) * 3); for (let k = 0; k <= steps; k++) pts.push(helixPoint(tAt((i + (j + 1 - i) * k / steps) / 600))); world.add(tube(pts, 2.2, solid(c), steps * 2)); }
i = j + 1;
}
// Staves: curved strips of the outer cylinder, from the story's first to last event in the window.
staves.forEach(s => {
const em = emphasisOf(s), color = colorOf(s), gap = Math.min(.014, (s.a1 - s.a0) * .08);
const inside = s.events.filter(e => inWindow(e.t)), pad = (win.t1 - win.t0) * .006;
const a0 = s.a0 + gap, a1 = Math.max(s.a0 + gap + 1e-3, s.a1 - gap);
const t0 = Math.max(win.t0, inside[0].t - pad), t1 = Math.min(win.t1, inside.at(-1).t + pad);
const segs = Math.max(2, Math.ceil((a1 - a0) / .05));
const geo = new THREE.BufferGeometry(), pos = [], idx = [];
for (let j = 0; j <= segs; j++) { const a = a0 + (a1 - a0) * j / segs; const lo = cylinder(t0, a), hi = cylinder(t1, a); pos.push(lo.x, lo.y, lo.z, hi.x, hi.y, hi.z); }
for (let j = 0; j < segs; j++) { const k = j * 2; idx.push(k, k + 2, k + 1, k + 1, k + 2, k + 3); }
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); geo.setIndex(idx);
const opacity = em === 'focus' ? .3 : em === 'dim' ? .05 : .15;
const mesh = new THREE.Mesh(geo, flat(color, opacity)); mesh.userData.node = s; mesh.renderOrder = 1;
world.add(mesh); staveMeshes.push(mesh);
if (em !== 'dim') [a0, a1].forEach(a => world.add(polyline([cylinder(t0, a), cylinder(t1, a)], lineMat(color, .7))));
// One band per cluster, thicker for more events; quiet periods stay as gaps.
cl.get(s).forEach(c => {
const arc = []; for (let i = 0; i <= 10; i++) arc.push(cylinder(c.t, a0 + (a1 - a0) * i / 10, STAVE_R + .6));
const active = c.events.includes(selectedEvent);
const r = (Math.min(6, 1.3 + Math.log2(c.n) * 1.1) + (active ? 2 : 0)) * .45;
world.add(tube(arc, r, solid(color, em === 'dim' ? .3 : .9), 20));
});
overlayItems.labels.push({ node: s, at: cylinder(t1, centerAngle(s), STAVE_R + 2), angle: centerAngle(s) });
});
// Connectors: helix -> stave at the same height, one per cluster.
links.filter(s => cl.has(s)).forEach(s => {
const color = colorOf(s), a = centerAngle(s);
cl.get(s).forEach(c => {
const p = helixPoint(c.t), q = cylinder(c.t, a), active = c.events.includes(selectedEvent);
world.add(tube([p, q], active ? .9 : .5, solid(color, active ? 1 : .6), 2));
const r = c.n === 1 ? (active ? 5.5 : 3.6) : Math.min(10, 5.5 + Math.log2(c.n) * 1.3);
const dot = new THREE.Mesh(new THREE.SphereGeometry(r * .6, 16, 12), solid(color)); dot.position.copy(p); world.add(dot);
if (active) { const halo = new THREE.Mesh(new THREE.TorusGeometry(r * .6 + 2, .5, 6, 32), solid('#273b36')); halo.position.copy(p); halo.lookAt(camera.position); world.add(halo); }
overlayItems.clusters.push({ cluster: c, node: s, at: p, r, clickable: focus.depth >= 2 || s === focus });
});
});
// Experience: a node on a ring outside the barrel. The forming stave reaches out to it, and an arrow
// runs from it to the applying stave, so the arc never crosses the helix inside.
if (showWisdom.checked && !split) visibleWisdom().forEach((w, i) => {
const a = staveFor(w.formedIn[0]), b = staveFor(w.appliedIn.at(-1)); if (!a || !b) return;
const sa = shown(a.node), sb = shown(b.node); if (!sa || !sb || sa === sb) return;
if (!inWindow(a.event.t) || !inWindow(b.event.t)) return;
const aa = centerAngle(sa), ab = centerAngle(sb), pa = cylinder(a.event.t, aa), pb = cylinder(b.event.t, ab);
const mean = Math.atan2(Math.sin(aa) + Math.sin(ab), Math.cos(aa) + Math.cos(ab));
const node = cylinder((a.event.t + b.event.t) / 2, mean, WISDOM_R), on = selectedWisdom === w;
const leg = (from, to, k) => { const c = from.clone().add(to).multiplyScalar(.5); const out = V(c.x, 0, c.z); if (out.length() > 1) c.add(out.normalize().multiplyScalar(k)); return new THREE.QuadraticBezierCurve3(from, c, to); };
const formed = leg(pa, node, 34), applied = leg(node, pb, 34);
[formed, applied].forEach(curve => {
const dashed = new THREE.Line(new THREE.BufferGeometry().setFromPoints(curve.getPoints(40)), mat('wd', () => new THREE.LineDashedMaterial({ color: 0xa47837, dashSize: 7, gapSize: 5 })));
dashed.computeLineDistances(); world.add(dashed);
if (on) world.add(tube(curve.getPoints(40), .8, solid('#a47837', .5), 40));
});
const tip = applied.getPoint(1), dir = tip.clone().sub(applied.getPoint(.94)).normalize();
const cone = new THREE.Mesh(new THREE.ConeGeometry(3.5, 10, 12), solid('#a47837')); cone.position.copy(tip).sub(dir.clone().multiplyScalar(5));
cone.quaternion.setFromUnitVectors(V(0, 1, 0), dir); world.add(cone);
const gem = new THREE.Mesh(new THREE.OctahedronGeometry(on ? 9 : 7), solid(on ? '#c08a3e' : '#d2a35e')); gem.position.copy(node); world.add(gem);
overlayItems.wisdom.push({ w, i, at: node });
});
built = signature();
}
function staveFor(eventId) { const hit = eventIndex.get(eventId); if (!hit) return; const sub = hit.feature.children.find(s => s.events.some(e => e.id === eventId)); return { event: hit.event, node: sub ?? hit.feature }; }
// ---- frame -----------------------------------------------------------------------
let hits = [];
function draw() {
if (!width) return;
placeCamera();
if (signature() !== built) rebuild();
renderer.render(scene, camera);
ctx.clearRect(0, 0, width, height); hits = [];
const v = viewDir();
ctx.font = '10px system-ui'; ctx.fillStyle = '#3f514a';
baseTicks.forEach(t => { const p = project(t.at); if (!p.behind) ctx.fillText(t.text, p.x + 6, p.y + 3); });
overlayItems.clusters.forEach(c => {
const p = project(c.at); if (p.behind) return;
if (c.cluster.n > 1) { ctx.fillStyle = '#fcfcf8'; ctx.font = '600 9px system-ui'; const label = String(c.cluster.n); ctx.fillText(label, p.x - ctx.measureText(label).width / 2, p.y + 3); }
if (c.clickable) hits.push({ kind: 'cluster', cluster: c.cluster, node: c.node, x: p.x, y: p.y, r: Math.max(9, c.r + 2), z: p.z });
});
drawLabels(v);
overlayItems.wisdom.forEach(w => drawWisdomCard(w));
}
// Labels: the focused or hovered stave always; otherwise only staves facing the viewer, without overlaps.
function drawLabels(v) {
const placed = [];
overlayItems.labels.map(l => ({ ...l, facing: Math.cos(l.angle) * v.x + Math.sin(l.angle) * v.z, em: emphasisOf(l.node) }))
.sort((a, b) => (b.em === 'focus') - (a.em === 'focus') || b.facing - a.facing)
.forEach(({ node: s, at, facing, em }) => {
if (em === 'dim' || (em !== 'focus' && facing < -.05)) return;
const p = project(at); if (p.behind) return;
ctx.font = (em === 'focus' ? '600 ' : '') + '11px system-ui';
const w = ctx.measureText(s.name).width, x = p.x - w / 2, y = p.y - 8, box = [x - 4, y - 12, w + 8, 16];
if (placed.some(b => box[0] < b[0] + b[2] && b[0] < box[0] + box[2] && box[1] < b[1] + b[3] && b[1] < box[1] + box[3])) return;
placed.push(box);
ctx.fillStyle = 'rgba(252,252,248,.85)'; ctx.fillRect(...box);
ctx.fillStyle = colorOf(s); ctx.globalAlpha = em === 'focus' ? 1 : .55 + .45 * Math.max(0, facing); ctx.fillText(s.name, x, y); ctx.globalAlpha = 1;
hits.push({ kind: 'label', node: s, rect: box });
});
}
function drawWisdomCard({ w, i, at }) {
const anchor = project(at), on = selectedWisdom === w, cw = Math.min(176, width - 24), ch = 44;
const x = Math.max(12, Math.min(width - cw - 12, anchor.x - cw / 2)), y = Math.max(12, Math.min(height - ch - 30, anchor.y - ch / 2 + i * 50));
ctx.fillStyle = on ? '#e9d7b6' : '#f8efdfe8'; ctx.strokeStyle = '#ad854b'; ctx.lineWidth = on ? 2 : 1;
ctx.beginPath(); ctx.roundRect(x, y, cw, ch, 8); ctx.fill(); ctx.stroke();
ctx.fillStyle = '#936825'; ctx.font = '10px system-ui'; ctx.fillText(`W${i + 1} · 跨故事经验 · 待确认`, x + 10, y + 16);
ctx.fillStyle = '#574522'; ctx.font = '12px system-ui'; ctx.fillText(w.lesson, x + 10, y + 33);
hits.push({ kind: 'wisdom', w, rect: [x, y, cw, ch] });
}
// ---- side panel ------------------------------------------------------------
const dateOf = t => new Date(range[0] + t * (range[1] - range[0]));
const pad2 = n => String(n).padStart(2, '0');
const fmt = t => { const d = dateOf(t); return `${d.getMonth() + 1}.${pad2(d.getDate())}`; };
const fmtTime = t => { const d = dateOf(t); return `${fmt(t)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; };
const fmtTick = t => win.turnHours < 24 ? fmtTime(t) : win.turnHours >= 720 ? `${dateOf(t).getFullYear()}.${dateOf(t).getMonth() + 1}` : fmt(t);
const turnName = h => ({ 3: '3 小时', 6: '6 小时', 12: '12 小时', 24: '1 天', 168: '1 周', 720: '1 个月', 2160: '1 个季度', 8760: '1 年' })[h] ?? `${h} 小时`;
const typeName = { decision: '决定', change: '变更', milestone: '里程碑', discussion: '讨论' };
function el(tag, props = {}, ...kids) { const n = document.createElement(tag); Object.assign(n, props); n.append(...kids); return n; }
// Staves split in place when opening a level and merge back when returning, instead of jumping to a new layout.
function planSplit(oldLevel, newLevel) {
const kids = newLevel.children ?? [], to = new Map(kids.map(k => [k, [k.a0, k.a1]]));
let from;
if (newLevel.parent === oldLevel) { // opening: children start inside the opened stave's old sector
const [c0, c1] = oldAngles.get(newLevel) ?? [0, tau], f = (c1 - c0) / tau;
from = new Map(kids.map(k => [k, [c0 + k.a0 * f, c0 + k.a1 * f]]));
} else if (oldLevel?.parent === newLevel) { // returning: the stave we came from starts as the whole circle
const [x0, x1] = to.get(oldLevel);
from = new Map(kids.map(k => [k, k === oldLevel ? [0, tau] : k.a0 < x0 ? [0, 0] : [tau, tau]]));
}
return from && { from, to };
}
let oldAngles = new Map();
function runSplit(plan) {
if (!plan || reducedMotion) return;
const started = performance.now(), apply = k => plan.to.forEach(([b0, b1], node) => { const [a0, a1] = plan.from.get(node); node.a0 = a0 + (b0 - a0) * k; node.a1 = a1 + (b1 - a1) * k; });
split = { k: 0, started }; apply(0);
const step = now => {
if (split?.started !== started) return;
const t = Math.min(1, (now - started) / 520), k = t < .5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2;
split.k = k; apply(k);
if (t < 1) { draw(); requestAnimationFrame(step); } else { split = undefined; draw(); }
};
requestAnimationFrame(step);
}
function setFocus(node) {
const oldLevel = level();
oldAngles = new Map((oldLevel.children ?? []).map(k => [k, [k.a0, k.a1]]));
focus = node; hovered = undefined; layout(level(), 0, tau); selectedEvent = undefined; selectedWisdom = undefined; selectedCluster = undefined;
const plan = level() !== oldLevel ? planSplit(oldLevel, level()) : undefined;
runSplit(plan);
render();
if (focus !== level()) face(focus); else if (mode === 'free') draw(); else animateTo({ ...VIEWS[mode], pan: 0 });
}
function render() {
const crumbs = document.querySelector('#crumbs'); crumbs.replaceChildren();
const trail = []; for (let n = focus; n; n = n.parent) trail.unshift(n);
trail.forEach((n, i) => { if (i) crumbs.append(' › '); crumbs.append(i === trail.length - 1 ? el('strong', { textContent: n.name }) : el('button', { textContent: n.name, onclick: () => setFocus(n), className: 'crumb' })); });
const levelName = focus.cross ? '跨项目故事' : focus.ref ? '关联的项目功能' : ['全部项目', '项目木片', '功能木片', '子线索'][focus.depth];
document.querySelector('#period').textContent = selectedWisdom ? '跨故事经验 · 模型归纳 · 待确认' : `${levelName} · ${fmt(focus.start)} – ${fmt(focus.end)}`;
document.querySelector('#story-title').textContent = selectedWisdom ? selectedWisdom.lesson : focus.name;
const desc = selectedWisdom ? '由真实图谱事件归纳,形成与应用都对应具体事件;需要用户确认后才作为经验使用。'
: focus === root ? `${projects.length} 个项目,${root.events.length} 个事件${crossToggle.checked ? `,${crossStories.length} 条跨项目故事` : ''}。木片越宽,事件越多;选择项目后拆分为功能。`
: focus.cross ? `关联 ${focus.children.length} 个项目功能、${focus.events.length} 个事件,跨越 ${new Set(focus.children.map(c => c.ref.parent.name)).size} 个项目。事件仍归属原项目,这里只做关联。`
: `${focus.events.length} 个事件 · ${focus.entities ?? focus.children.reduce((n, c) => n + (c.entities ?? 0), 0)} 个知识实体${focus.scattered ? ` · ${focus.scattered} 个零散实体未归入功能` : ''}${focus.unthreaded ? ` · ${focus.unthreaded} 个实体留在功能层` : ''}`;
document.querySelector('#story-description').textContent = desc;
const list = document.querySelector('#stories'); list.replaceChildren();
if (!selectedWisdom) (focus.children ?? []).forEach(c => {
const b = el('button', { onclick: () => setFocus(c), onmouseenter: () => { hovered = c; draw(); }, onmouseleave: () => { hovered = undefined; draw(); } },
el('i'), el('span', { textContent: c.name }), el('small', { textContent: `${c.events.length} 事件 · ${fmt(c.start)}–${fmt(c.end)}${c.children?.length ? ` · ${c.children.length} 片` : ''}` }));
b.style.setProperty('--story-color', colorOf(c)); list.append(b);
});
const events = document.querySelector('#events'); events.replaceChildren();
const detail = document.querySelector('#detail'); detail.replaceChildren();
if (selectedWisdom) {
[['适用条件', selectedWisdom.conditions], ['边界', selectedWisdom.boundary], ['形成与应用依据', selectedWisdom.why]].forEach(([h, p]) => detail.append(el('section', {}, el('strong', { textContent: h }), el('p', { textContent: p }))));
[['形成', selectedWisdom.formedIn], ['应用', selectedWisdom.appliedIn]].forEach(([label, ids]) => ids.forEach(id => {
const s = staveFor(id); if (!s) return;
detail.append(el('button', { className: 'jump', textContent: `${label} · ${fmt(s.event.t)} · ${s.node.name}`, onclick: () => { setFocus(s.node); selectEvent(s.event); } }));
}));
} else if (focus.depth >= 2) {
const detailed = (focus.ref?.parent ?? projectOf(focus)).detailed;
const listed = (selectedCluster ? selectedCluster.events : focus.events.filter(e => inWindow(e.t))).slice().reverse();
if (selectedCluster) events.append(el('div', { className: 'cluster-head' }, el('span', { textContent: `${fmtSpan(selectedCluster)} · ${selectedCluster.n} 个事件` }), el('button', { className: 'crumb', textContent: '显示全部', onclick: () => { selectedCluster = undefined; render(); } })));
listed.slice(0, 60).forEach(e => events.append(el('button', { 'aria-pressed': String(e === selectedEvent), onclick: () => selectEvent(e) },
el('span', { textContent: detailed ? e.title : `${typeName[e.type]}事件` }), el('small', { textContent: `${fmtTime(e.t)} · ${typeName[e.type]}` }))));
if (listed.length > 60) events.append(el('small', { textContent: `另有 ${listed.length - 60} 个更早的事件` }));
if (!listed.length) events.append(el('small', { textContent: '这段时间没有该故事的事件' }));
if (selectedEvent) detail.append(el('strong', { textContent: detailed ? selectedEvent.title : '该项目只显示时间与类型' }), el('p', { textContent: detailed ? selectedEvent.text : '' }), el('small', { textContent: new Date(selectedEvent.at).toLocaleString('zh-CN') }));
}
document.querySelector('#wisdom-list').replaceChildren(...visibleWisdom().map((w, i) => el('button', { className: 'wisdom-card', 'aria-pressed': String(selectedWisdom === w), onclick: () => { selectedWisdom = w; showWisdom.checked = true; render(); } },
el('span', { textContent: `W${i + 1} · 跨故事经验 · 待确认` }), el('strong', { textContent: w.lesson }))));
draw();
}
function selectEvent(e) {
if (!inWindow(e.t)) { selectedCluster = undefined; applyRange('all'); }
selectedEvent = e; render(); if (mode !== 'free') animateTo({ pan: yOf(e.t) * .5 });
}
function openCluster(c) {
if (c.n === 1) { selectedCluster = undefined; selectEvent(c.events[0]); return; }
const span = c.t1 - c.t0, minSpan = 6 / totalHours;
if (win.t1 - win.t0 > minSpan * 1.01 && span > 0) {
const pad = Math.max(span * .6, minSpan / 2), mid = (c.t0 + c.t1) / 2, half = Math.max(span / 2 + pad, minSpan / 2);
selectedCluster = undefined; zoomWindow(mid - half, mid + half);
} else { selectedCluster = c; selectedEvent = undefined; render(); }
}
// ---- time window and grain ---------------------------------------------------------
const rangeSelect = document.querySelector('#range'), turnSelect = document.querySelector('#turn'), rangeReset = document.querySelector('#range-reset');
function applyWindow(t0, t1) {
const fixed = turnSelect.value === 'auto' ? undefined : Number(turnSelect.value);
setWindow(t0, t1, fixed);
if (fixed && win.turnHours !== fixed) turnSelect.value = 'auto';
document.querySelector('#scale-note').textContent = `高度为时间,一圈 ${turnName(win.turnHours)}(共 ${Math.round(turns * 10) / 10} 圈)· 半径为当时的对话密度`;
document.querySelector('#axis-start').textContent = win.turnHours < 24 ? fmtTime(win.t0) : fmt(win.t0);
document.querySelector('#axis-end').textContent = win.turnHours < 24 ? fmtTime(win.t1) : fmt(win.t1);
rangeReset.hidden = win.t0 <= 0 && win.t1 >= 1;
}
function zoomWindow(t0, t1) {
const len = t1 - t0;
if (t0 < 0) { t0 = 0; t1 = Math.min(1, len); } if (t1 > 1) { t1 = 1; t0 = Math.max(0, 1 - len); }
rangeSelect.value = 'custom';
applyWindow(t0, t1); render(); if (mode !== 'free') animateTo({ pan: 0 });
}
function applyRange(value) {
rangeSelect.value = value;
if (value === 'all') applyWindow(0, 1); else if (value !== 'custom') { const h = Number(value); applyWindow(Math.max(0, 1 - h / totalHours), 1); }
}
rangeSelect.addEventListener('change', () => { selectedCluster = undefined; applyRange(rangeSelect.value); render(); });
turnSelect.addEventListener('change', () => { applyWindow(win.t0, win.t1); render(); });
rangeReset.addEventListener('click', () => { selectedCluster = undefined; applyRange('all'); render(); });
applyRange('all');
// ---- input -----------------------------------------------------------------
const viewButtons = [...document.querySelectorAll('[data-view]')];
function syncViewButtons() { viewButtons.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.view === mode))); }
function syncZoom() { zoom.value = String(Math.round(cam.zoom * 100)); }
viewButtons.forEach(b => b.addEventListener('click', () => {
if (b.dataset.view === 'free') { freeView(); return; }
animateTo({ ...VIEWS[b.dataset.view], zoom: 1, pan: 0 }, b.dataset.view);
}));
zoom.addEventListener('input', () => { tween = undefined; cam.zoom = Number(zoom.value) / 100; draw(); });
showWisdom.addEventListener('input', draw);
crossToggle.addEventListener('change', () => { setCrossProject(crossToggle.checked); focus = root; hovered = undefined; layout(root, 0, tau); selectedEvent = selectedWisdom = selectedCluster = undefined; render(); });
document.querySelector('#reset').onclick = () => animateTo({ ...VIEWS.oblique, zoom: 1, pan: 0 }, 'oblique');
const clampTilt = t => Math.max(-Math.PI / 2 + 1e-3, Math.min(Math.PI / 2 - 1e-3, t));
const clampPan = p => Math.max(-HEIGHT / 2, Math.min(HEIGHT / 2, p));
const clampZoom = z => Math.max(.5, Math.min(2.5, z));
const inRect = (x, y, r) => x >= r[0] && x <= r[0] + r[2] && y >= r[1] && y <= r[1] + r[3];
function staveAt(x, y) {
raycaster.setFromCamera(new THREE.Vector2(x / width * 2 - 1, 1 - y / height * 2), camera);
const hit = raycaster.intersectObjects(staveMeshes, false)[0];
return hit && { kind: 'stave', node: hit.object.userData.node };
}
function hitAt(x, y) {
return hits.find(h => h.kind === 'wisdom' && inRect(x, y, h.rect))
?? hits.filter(h => h.kind === 'cluster' && Math.hypot(h.x - x, h.y - y) < h.r).sort((a, b) => b.z - a.z)[0]
?? hits.find(h => h.kind === 'label' && inRect(x, y, h.rect))
?? staveAt(x, y);
}
let drag;
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('pointerdown', e => {
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, pan: e.button === 2 || e.shiftKey };
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointermove', e => {
const r = canvas.getBoundingClientRect();
if (!drag) {
const h = hitAt(e.clientX - r.left, e.clientY - r.top), node = h && (h.kind === 'stave' || h.kind === 'label') ? h.node : undefined;
if (node !== hovered) { hovered = node; draw(); }
canvas.style.cursor = h ? 'pointer' : '';
return;
}
if (drag.id !== e.pointerId) return;
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) >= 5) freeView();
if (mode === 'free') {
if (drag.pan) cam.pan = clampPan(cam.pan + dy / scaleNow());
else { cam.yaw += dx * .008; cam.tilt = clampTilt(cam.tilt + dy * .006); }
draw();
}
drag.x = e.clientX; drag.y = e.clientY;
});
canvas.addEventListener('pointerup', e => {
if (drag && Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) < 5) {
const r = canvas.getBoundingClientRect(), h = hitAt(e.clientX - r.left, e.clientY - r.top);
if (h?.kind === 'wisdom') { selectedWisdom = h.w; render(); }
else if (h?.kind === 'cluster') openCluster(h.cluster);
else if (h) setFocus(h.node);
}
drag = undefined;
});
canvas.addEventListener('pointercancel', () => { drag = undefined; });
canvas.addEventListener('pointerleave', () => { if (!drag && hovered) { hovered = undefined; draw(); } });
canvas.addEventListener('wheel', e => {
e.preventDefault(); freeView();
if (e.shiftKey) cam.pan = clampPan(cam.pan - e.deltaY * .3);
else cam.zoom = clampZoom(cam.zoom * Math.exp(-e.deltaY * .0012));
syncZoom(); draw();
}, { passive: false });
canvas.addEventListener('keydown', e => {
if (e.key === 'Escape' && focus.parent) { setFocus(focus.parent); return; }
const preset = { 1: 'oblique', 2: 'side', 3: 'top' }[e.key];
if (preset) { animateTo({ ...VIEWS[preset], zoom: 1, pan: 0 }, preset); return; }
const moves = { ArrowLeft: () => { cam.yaw -= .12; }, ArrowRight: () => { cam.yaw += .12; }, ArrowUp: () => { cam.tilt = clampTilt(cam.tilt + .06); }, ArrowDown: () => { cam.tilt = clampTilt(cam.tilt - .06); },
PageUp: () => { cam.pan = clampPan(cam.pan + 20); }, PageDown: () => { cam.pan = clampPan(cam.pan - 20); }, '+': () => { cam.zoom = clampZoom(cam.zoom * 1.1); }, '=': () => { cam.zoom = clampZoom(cam.zoom * 1.1); }, '-': () => { cam.zoom = clampZoom(cam.zoom / 1.1); } };
if (!moves[e.key]) return;
e.preventDefault(); freeView(); moves[e.key](); syncZoom(); draw();
});
new ResizeObserver(() => {
const r = canvas.getBoundingClientRect(); width = r.width; height = r.height;
if (!width || !height) return;
renderer.setSize(width, height, false);
const dpr = Math.min(devicePixelRatio || 1, 2); overlay.width = Math.round(width * dpr); overlay.height = Math.round(height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
draw();
}).observe(canvas);
// Read-only hook for layout checks.
window.storyDemo = { renderer: 'webgl', cam, win, get turns() { return turns; }, get mode() { return mode; }, get focus() { return focus.name; },
get splitting() { return !!split; },
// Worst ratio of largest to smallest radius within any single turn: 1 means perfectly round.
roundness: () => { let worst = 1; for (let d = 0; d < Math.floor(turns); d++) { const r = Array.from({ length: 64 }, (_, i) => radius(tAt((d + i / 64) / turns))); worst = Math.max(worst, Math.max(...r) / Math.min(...r)); } return +worst.toFixed(3); },
maxStep: () => { let m = 0, prev = radius(tAt(0)); for (let i = 1; i <= 4000; i++) { const r = radius(tAt(i / 4000)); m = Math.max(m, Math.abs(r - prev)); prev = r; } return +m.toFixed(3); },
wisdomNodes: () => overlayItems.wisdom.map(w => +Math.hypot(w.at.x, w.at.z).toFixed(1)),
angles: () => visibleStaves().map(s => [+s.a0.toFixed(3), +s.a1.toFixed(3)]),
get meshes() { let n = 0; world.traverse(o => { if (o.isMesh || o.isLine) n++; }); return n; },
clustersOf: name => { const find = n => n.name === name ? n : (n.children ?? []).map(find).find(Boolean); const n = find(root); return n ? clusters(n.events).map(c => c.n) : []; },
clusterHits: () => hits.filter(h => h.kind === 'cluster').map(h => ({ n: h.cluster.n, x: Math.round(h.x), y: Math.round(h.y) })),
staveAt: (x, y) => staveAt(x, y)?.node.name, labels: () => hits.filter(h => h.kind === 'label').map(h => h.node.name),
setWindow: (t0, t1) => zoomWindow(t0, t1), setFocusByName: name => { const find = n => n.name === name ? n : (n.children ?? []).map(find).find(Boolean); const n = find(root); if (n) setFocus(n); return !!n; } };
syncViewButtons(); syncZoom();
render();
})();
+8
View File
@@ -0,0 +1,8 @@
:root{color-scheme:light;--paper:#f5f4ef;--ink:#273b36;--muted:#748078;--border:#dde1d7;--green:#357e6d}
*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}button,input{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible,canvas:focus-visible,a:focus-visible{outline:3px solid #ac7638;outline-offset:3px}.page-header{max-width:1540px;margin:auto;padding:36px 38px 25px;display:flex;align-items:flex-start;justify-content:space-between;gap:20px}.eyebrow{color:var(--green);font-size:11px;letter-spacing:2px;text-decoration:none}h1{font-size:clamp(23px,2.6vw,35px);letter-spacing:-1px;font-weight:550;margin:9px 0}p{color:var(--muted);margin:0}.badge{white-space:nowrap;border:1px solid var(--border);border-radius:30px;padding:7px 12px;font-size:11px;color:var(--muted);margin-top:8px}main{max-width:1540px;margin:auto;padding:0 38px;display:grid;grid-template-columns:minmax(0,1fr) 306px;gap:22px}.stage{min-width:0;background:#fcfcf8;border:1px solid var(--border);border-radius:15px;overflow:hidden}.stage-header{display:flex;justify-content:space-between;align-items:center;padding:15px 22px;border-bottom:1px solid #e9ebe3;font-size:12px}.stage-header i{display:inline-block;background:var(--green);width:6px;height:6px;border-radius:50%;margin-right:8px}button{border:1px solid var(--border);background:transparent;color:inherit;border-radius:7px;padding:7px 12px}.viewport{position:relative;height:560px;background:radial-gradient(ellipse at 50% 50%,#eef1e8 0,transparent 70%)}canvas{display:block;width:100%;height:100%;touch-action:none;cursor:grab}canvas:active{cursor:grabbing}.axis-note{position:absolute;left:19px;top:35%;text-align:center;font-size:11px;pointer-events:none;color:#879188}.axis-note span{font-size:10px;line-height:2.5}.canvas-hint{position:absolute;bottom:13px;left:0;right:0;text-align:center;font-size:11px;color:#8a948a;pointer-events:none}.controls{border-top:1px solid #e9ebe3;display:flex;align-items:center;gap:18px;flex-wrap:wrap;padding:15px 22px;font-size:12px}.controls label{display:flex;align-items:center;gap:6px}.controls input{accent-color:var(--green)}.range input{width:84px}.legend{display:flex;flex-wrap:wrap;gap:15px;padding:0 22px 17px;font-size:10px;color:var(--muted)}.legend span{display:flex;align-items:center;gap:5px}.legend b{display:inline-block;width:18px;height:0;border-top:2px solid #687b71}.legend .surface{height:9px;background:#65a08a44;border:1px solid #65a08a66}.legend .dashed{border-top:2px dashed #ad7939}aside{padding-top:6px;min-width:0}.section-label{font-size:11px;letter-spacing:1px;color:var(--muted)}.stories{display:grid;gap:8px;margin:13px 0 24px}.stories button{display:flex;text-align:left;align-items:center;gap:12px;padding:13px;background:#fafaf6}.stories button[aria-pressed=true]{background:white;border-color:var(--story-color);box-shadow:0 3px 15px #30433208}.stories i{width:5px;height:29px;border-radius:3px;background:var(--story-color)}.stories span{flex:1}.stories small{color:var(--muted);font-size:10px}.inspector h2{font-size:22px;font-weight:550;margin:6px 0 9px}.inspector p{font-size:12px}.events{display:grid;margin-top:18px;gap:6px}.events button{text-align:left;border-color:transparent;border-left:2px solid #d9e0d5;border-radius:0;padding:8px 12px;font-size:12px}.events button[aria-pressed=true]{border-left-color:var(--green);background:#e7ede3}.events small{color:var(--muted);display:block;font-size:10px}.detail{font-size:12px;padding:14px 0;color:#53665b;min-height:80px}.detail strong{display:block;color:var(--ink);margin-bottom:5px}.wisdom-card{display:grid;gap:7px;text-align:left;width:100%;padding:17px;background:#f0e9db;border-color:#e0d3bc;margin-top:12px}.wisdom-card span{font-size:10px;letter-spacing:1px;color:#996b2c}.wisdom-card strong{font-size:13px;font-weight:550}.wisdom-card small{font-size:11px;color:#817259}footer{max-width:1540px;padding:24px 38px 30px;margin:auto;font-size:11px;color:var(--muted);line-height:1.9}footer strong{font-weight:500;color:#4a6051}
@media(min-width:1500px){.viewport{height:640px}}@media(max-width:900px){main{grid-template-columns:minmax(0,1fr) 250px;gap:15px;padding:0 20px}.page-header{padding:25px 20px}.badge{display:none}.viewport{height:520px}.controls{gap:10px;padding:14px}.legend{padding:0 14px 15px}.axis-note{left:8px}}@media(max-width:680px){main{grid-template-columns:1fr}.viewport{height:460px}.page-header h1{font-size:24px}.page-header p{font-size:12px}aside{padding:14px 0}.stories{grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.stories button{padding:9px 7px;gap:5px;font-size:11px}.stories small{display:none}.controls{font-size:11px}.range input{width:70px}footer{padding:15px 20px}.axis-note{font-size:10px}.canvas-hint{font-size:10px}}
.crumbs{font-size:12px;color:var(--muted);margin-bottom:14px;line-height:2}.crumb{border:0;padding:0 2px;color:var(--green);text-decoration:underline;text-underline-offset:3px}.crumbs strong{color:var(--ink);font-weight:550}.stories{margin:12px 0 10px;max-height:340px;overflow:auto}.stories button{padding:10px 12px}.stories small{white-space:nowrap}.events{max-height:300px;overflow:auto;margin-top:6px}.detail section{margin-bottom:12px}.detail p{margin:0}.detail .jump{display:block;width:100%;text-align:left;margin-top:6px;font-size:12px}.wisdom-list{display:grid;gap:8px}.wisdom-card{margin-top:0;padding:12px 14px}.wisdom-card[aria-pressed=true]{border-color:#ad854b;background:#e9d7b6}
.views{display:flex;gap:4px;flex-wrap:wrap;justify-content:flex-end}.views button{padding:5px 10px;font-size:12px}.views button[aria-pressed=true]{background:var(--ink);border-color:var(--ink);color:#fcfcf8}.views #reset{margin-left:6px}.legend .link{width:12px;border-top:1px solid #687b71}@media(max-width:680px){.stage-header{flex-direction:column;align-items:flex-start;gap:8px}.views{justify-content:flex-start}}
.controls select{font:inherit;font-size:12px;border:1px solid var(--border);border-radius:6px;background:transparent;color:inherit;padding:3px 6px}#range-reset{padding:4px 10px;font-size:12px}.cluster-head{display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:12px;color:var(--muted);margin-bottom:6px}
.overlay{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>时间与故事 · 3D Story Graph</title>
<link rel="stylesheet" href="3d-demo.css">
</head>
<body>
<header class="page-header">
<div><a href="README.md" class="eyebrow">GOODBUDDY / STORY GRAPH LAB</a><h1>时间只有一条,故事交错生长。</h1><p>沿螺旋看一周的经历;木片可以层层拆开:项目 › 功能 › 子线索;跨木片的经验来自真实事件。</p></div>
<span class="badge">真实图谱 · 09.19 – 09.26 · 归组由模型提出,待确认</span>
</header>
<main>
<section class="stage" aria-label="三维故事图谱">
<div class="stage-header"><span><i></i> <span id="scale-note">高度为时间,一圈一天 · 半径为当时的对话密度</span> · <span id="renderer-note">WebGL</span></span>
<div class="views" role="group" aria-label="视角">
<button data-view="oblique" title="看整体结构(1)">斜俯视</button><button data-view="side" title="看起止与间隔(2)">侧视</button><button data-view="top" title="看注意力密度(3)">俯视</button><button data-view="free" title="拖动、滚轮自由查看">自由</button>
<button id="reset">重置</button>
</div>
</div>
<div class="viewport">
<canvas id="scene" tabindex="0" aria-label="三维故事图谱。数字键 1、2、3 切换视角,方向键旋转,PageUp/PageDown 沿时间移动,加减号缩放,Esc 返回上一层。木片也可通过右侧列表选择。"></canvas>
<div class="axis-note"><span id="axis-end">09.26</span><br><span>↑<br>时间等速推进<br>↑</span><br><span id="axis-start">09.19</span></div>
<canvas id="overlay" class="overlay" aria-hidden="true"></canvas>
<div class="canvas-hint">拖动旋转 · 右键或 Shift 拖动沿时间移动 · 滚轮缩放 · 点击木片拆开 · Esc 返回</div>
</div>
<div class="controls">
<label><input id="wisdom" type="checkbox" checked> 跨故事经验</label>
<label title="把多个项目里属于同一件事的功能串成一片全局木片;事件仍归属原项目"><input id="cross" type="checkbox"> 跨项目故事</label>
<label>范围 <select id="range"><option value="all">全部</option><option value="72">最近 3 天</option><option value="24">最近 1 天</option><option value="custom" hidden>所选时段</option></select></label>
<label>一圈 <select id="turn"><option value="auto">自动</option><option value="3">3 小时</option><option value="6">6 小时</option><option value="12">12 小时</option><option value="24">1 天</option><option value="168">1 周</option><option value="720">1 个月</option></select></label>
<button id="range-reset" hidden>返回全部时间</button>
<label class="range">缩放 <input id="zoom" type="range" min="50" max="250" value="100"></label>
</div>
<div class="legend"><span><b class="line"></b>时间:唯一的连续螺旋</span><span><b class="surface"></b>故事:木片,上下跨度为起止时间,短横为同一时段的事件,越粗越多</span><span><b class="link"></b>事件:同一高度连到所属木片,数字为聚合数量,点击放大拆开</span><span><b class="dashed"></b>经验:从形成到应用</span></div>
</section>
<aside>
<nav id="crumbs" class="crumbs" aria-label="木片层级"></nav>
<section class="inspector" aria-live="polite"><span id="period" class="section-label"></span><h2 id="story-title"></h2><p id="story-description"></p></section>
<div id="stories" class="stories" role="group" aria-label="下一层木片"></div>
<div id="events" class="events" aria-label="事件"></div>
<div id="detail" class="detail"></div>
<div id="wisdom-list" class="wisdom-list"></div>
</aside>
</main>
<footer><strong>数据来源:</strong>本机已生成的故事线图谱(2 次回顾,268 个实体;两次回顾重复提取的事件已按时间与类型合并,剩 358 个)的只读导出。项目由事件引用的会话确定;功能与子线索、跨故事经验由 deepseek-flash 根据实体与事件提出,未经确认。半径为每 3 小时的对话轮次,只表示投入集中程度,不代表效率。goodbuddy 以外的项目只显示时间与类型。“跨项目故事”默认关闭;打开后,按功能名称把多个项目中的同一件事(如果蝇神经仿真、Agent 运行环境)串成全局木片,与项目木片并列;事件仍归属原项目。</footer>
<script src="3d-demo-data.js"></script>
<script src="vendor/three.min.js"></script>
<script src="3d-demo-webgl.js"></script>
</body>
</html>
+472
View File
@@ -0,0 +1,472 @@
/* 3D Story Graph prototype on a real export: one time helix, nested story staves (project > feature > sub-thread).
* Geometry: height = time (uniform), helix radius = attention density, staves sit on a fixed outer cylinder at a
* stable angle, and each event joins its stave by a horizontal connector at the same height. */
const data = window.STORY_DATA;
const canvas = document.querySelector('#scene');
const ctx = canvas.getContext('2d');
const tau = Math.PI * 2;
const palette = ['#367e70', '#bb8743', '#8675a6', '#b5604f', '#4f7fa8', '#7a8f3b', '#a35d86', '#3f8f97', '#9a7a55', '#5f6fb0', '#c07a3a', '#6b8d6f'];
const range = data.range.map(d => Date.parse(d + 'T00:00:00+08:00'));
const totalHours = (range[1] - range[0]) / 3600000;
const zoom = document.querySelector('#zoom');
const showWisdom = document.querySelector('#wisdom');
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
// ---- model ---------------------------------------------------------------
// The two saved reviews overlap and re-extracted the same moments; show each (time, type) once.
const canonical = new Map();
data.projects.forEach(p => p.features.forEach(f => {
const seen = new Map();
f.events = f.events.filter(e => { const k = e.at + e.type; if (seen.has(k)) { canonical.set(e.id, seen.get(k)); return false; } seen.set(k, e.id); canonical.set(e.id, e.id); return true; });
}));
const canon = id => canonical.get(id) ?? id;
data.wisdom.forEach(w => { w.formedIn = [...new Set(w.formedIn.map(canon))]; w.appliedIn = [...new Set(w.appliedIn.map(canon))]; });
const root = { name: '全部工作', children: data.projects.map(p => ({
name: p.name, detailed: p.detailed, scattered: p.scattered,
children: p.features.map(f => ({ ...f, children: f.subThreads.map(s => ({ ...s,
events: [...new Set(s.eventIds.map(canon))].map(id => f.events.find(e => e.id === id)).filter(Boolean) })) }))
})) };
const eventIndex = new Map();
function finish(node, parent, depth) {
node.parent = parent; node.depth = depth;
(node.children ?? []).forEach(c => finish(c, node, depth + 1));
if (!node.events) node.events = [...new Map(node.children.flatMap(c => c.events).map(e => [e.id, e])).values()].sort((a, b) => a.t - b.t);
node.start = node.events[0]?.t ?? 0; node.end = node.events.at(-1)?.t ?? 0;
if (depth === 2) node.events.forEach(e => eventIndex.set(e.id, { event: e, feature: node }));
}
finish(root, undefined, 0);
root.children.forEach((p, i) => { p.color = palette[i % palette.length]; });
let focus = root, selectedEvent, selectedWisdom, hovered, hits = [];
// Stable angular layout: siblings keep the order of their first event, each takes a sector by sqrt(event count).
function layout(node, from, to) {
node.a0 = from; node.a1 = to;
const kids = (node.children ?? []).slice().sort((a, b) => a.start - b.start);
const weight = kids.map(k => Math.max(1.2, Math.sqrt(k.events.length)));
const total = weight.reduce((a, b) => a + b, 0);
let at = from;
kids.forEach((k, i) => { const w = (to - from) * weight[i] / total; layout(k, at, at + w); at += w; });
}
layout(root, 0, tau);
// ---- geometry ----------------------------------------------------------------
// Event times `t` are normalised over the whole export. The viewed window [win.t0, win.t1] maps to the full height,
// and one turn covers `turnHours`, chosen so the helix keeps a readable number of turns at any range.
const HEIGHT = 430, STAVE_R = 188, FLOOR = -HEIGHT / 2 - 34;
const TURN_STEPS = [3, 6, 12, 24, 168, 720, 2160, 8760]; // hours: 3h … 1 year
const TARGET_TURNS = 8, MAX_TURNS = 14;
const win = { t0: 0, t1: 1, turnHours: 24, auto: true };
let turns = 8, bins = [], binMax = 1;
const hoursOf = t => t * totalHours;
const clamp01 = t => Math.max(0, Math.min(1, t));
const inWindow = t => t >= win.t0 - 1e-9 && t <= win.t1 + 1e-9;
const uOf = t => (t - win.t0) / (win.t1 - win.t0);
function autoTurnHours(spanHours) { return TURN_STEPS.find(h => spanHours / h <= TARGET_TURNS) ?? TURN_STEPS.at(-1); }
// Attention density is re-counted at the current grain: one bin is an eighth of a turn.
const slotTimes = data.density.map((n, i) => ({ t: (i + .5) / data.density.length, n }));
function setWindow(t0, t1, turnHours) {
win.t0 = clamp01(t0); win.t1 = clamp01(t1);
const span = hoursOf(win.t1 - win.t0);
win.turnHours = turnHours ?? autoTurnHours(span);
turns = Math.max(.5, span / win.turnHours);
if (turns > MAX_TURNS) { win.turnHours = TURN_STEPS.find(h => span / h <= MAX_TURNS) ?? TURN_STEPS.at(-1); turns = span / win.turnHours; }
const count = Math.max(4, Math.round(turns * 8));
bins = new Array(count).fill(0);
slotTimes.forEach(s => { if (inWindow(s.t)) bins[Math.min(count - 1, Math.floor(uOf(s.t) * count))] += s.n; });
binMax = Math.max(1, ...bins);
smoothR = Array.from({ length: 401 }, (_, i) => { let s = 0; for (let k = -3; k <= 3; k++) s += density(clamp01((i + k) / 400)); return s / 7; });
}
function density(u) {
const x = u * bins.length - .5, i = Math.floor(x), f = x - i, at = j => bins[Math.max(0, Math.min(bins.length - 1, j))] / binMax;
return at(i) * (1 - f) + at(i + 1) * f;
}
let smoothR = [];
const radius = t => 52 + 100 * smoothR[Math.round(clamp01(uOf(t)) * 400)];
const yOf = t => (uOf(t) - .5) * HEIGHT;
const helixAngle = t => uOf(t) * turns * tau;
const helixPoint = t => { const r = radius(t), a = helixAngle(t); return [r * Math.cos(a), yOf(t), r * Math.sin(a)]; };
const cylinder = (t, a, r = STAVE_R) => [r * Math.cos(a), yOf(t), r * Math.sin(a)];
const centerAngle = node => (node.a0 + node.a1) / 2;
// ---- camera ------------------------------------------------------------------
const VIEWS = { oblique: { yaw: -.4, tilt: .38 }, side: { tilt: 0 }, top: { tilt: Math.PI / 2 } };
const cam = { yaw: -.4, tilt: .38, zoom: 1, pan: 0 };
let mode = 'oblique', tween;
let width = 0, height = 0;
function project(p) {
const y0 = p[1] - cam.pan;
const x = p[0] * Math.cos(cam.yaw) + p[2] * Math.sin(cam.yaw);
const z = -p[0] * Math.sin(cam.yaw) + p[2] * Math.cos(cam.yaw);
const y = y0 * Math.cos(cam.tilt) - z * Math.sin(cam.tilt);
const depth = y0 * Math.sin(cam.tilt) + z * Math.cos(cam.tilt);
const scale = Math.min(width / 500, height / 560) * cam.zoom * 900 / (900 - depth);
return { x: width / 2 + x * scale, y: height / 2 - y * scale, z: depth };
}
const shortest = (from, to) => from + ((((to - from) % tau) + tau * 1.5) % tau - Math.PI);
function animateTo(target, nextMode = mode) {
mode = nextMode; syncViewButtons();
const goal = { ...cam, ...target };
goal.yaw = shortest(cam.yaw, goal.yaw);
if (reducedMotion) { Object.assign(cam, goal); syncZoom(); draw(); return; }
const from = { ...cam }, started = performance.now();
tween = { from, goal, started };
const step = now => {
if (tween?.started !== started) return;
const k = Math.min(1, (now - started) / 480), e = k < .5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2;
for (const key of ['yaw', 'tilt', 'zoom', 'pan']) cam[key] = from[key] + (goal[key] - from[key]) * e;
syncZoom(); draw();
if (k < 1) requestAnimationFrame(step); else tween = undefined;
};
requestAnimationFrame(step);
}
// Turn the camera so a stave faces the viewer.
function face(node) { if (node && node.depth > 0) animateTo({ yaw: centerAngle(node) - Math.PI / 2, pan: (yOf(Math.max(win.t0, node.start)) + yOf(Math.min(win.t1, node.end))) / 2 * .35 }); }
function freeView() { tween = undefined; if (mode !== 'free') { mode = 'free'; syncViewButtons(); } }
// ---- colour and depth cue -------------------------------------------------------
const hexRgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
const GREY = [150, 158, 154];
// k: 0 = farthest, 1 = nearest. Far geometry loses saturation and opacity instead of gaining an outline.
function shade(hex, alpha, k) {
const [r, g, b] = hexRgb(hex), d = (1 - k) * .65;
const mix = (c, i) => Math.round(c * (1 - d) + GREY[i] * d);
return `rgba(${mix(r, 0)},${mix(g, 1)},${mix(b, 2)},${(alpha * (.22 + .78 * k)).toFixed(3)})`;
}
const projectOf = n => { while (n.depth > 1) n = n.parent; return n; };
function colorOf(node) {
if (node.depth <= 1) return node.color ?? '#6f807b';
const p = projectOf(node);
return palette[(node.parent.children.indexOf(node) + root.children.indexOf(p) * 3) % palette.length];
}
function shown(node) { const set = visibleStaves(); for (let n = node; n; n = n.parent) if (set.includes(n)) return n; }
function level() { return focus.children?.length ? focus : focus.parent; }
function visibleStaves() { return level().children; }
function emphasisOf(s) { return focus === level() ? (s === hovered ? 'focus' : 'normal') : s === focus ? 'focus' : 'dim'; }
// Staves whose events are joined to the helix: the focused leaf, or the stave under the pointer.
function connected() { return visibleStaves().filter(s => (focus !== level() && s === focus) || s === hovered); }
// ---- rendering ---------------------------------------------------------------
const tAt = u => win.t0 + u * (win.t1 - win.t0);
function drawFloor() {
// A faint footprint on the floor helps read front and back.
const ring = [];
for (let i = 0; i <= 96; i++) ring.push(project(cylinder(win.t0, i / 96 * tau, STAVE_R).map((v, j) => j === 1 ? FLOOR : v)));
line(ring, 'rgba(120,132,124,.16)', 1);
const shadow = [];
for (let i = 0; i <= 900; i++) { const p = helixPoint(tAt(i / 900)); p[1] = FLOOR; shadow.push(project(p)); }
line(shadow, 'rgba(110,122,114,.10)', 4);
}
function line(points, color, size = 1, dash = []) {
ctx.beginPath(); points.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
ctx.setLineDash(dash); ctx.lineWidth = size; ctx.strokeStyle = color; ctx.lineCap = 'round'; ctx.stroke(); ctx.setLineDash([]);
}
function polygon(points, color) { ctx.beginPath(); points.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); ctx.closePath(); ctx.fillStyle = color; ctx.fill(); }
// Events closer than this on screen are merged into one cluster (≈ 9 px of height).
const CLUSTER_PX = 9;
function scaleNow() { return Math.min(width / 500, height / 560) * cam.zoom; }
function clusterSpan() { return CLUSTER_PX / (HEIGHT * scaleNow()) * (win.t1 - win.t0); }
// Groups a story's in-window events into clusters on the time axis, at the current grain and zoom.
function clusters(events) {
const span = clusterSpan(), out = [];
events.filter(e => inWindow(e.t)).forEach(e => {
const last = out.at(-1);
if (last && e.t - last.t0 <= span) { last.events.push(e); last.t1 = e.t; } else out.push({ t0: e.t, t1: e.t, events: [e] });
});
out.forEach(c => { c.t = (c.t0 + c.t1) / 2; c.n = c.events.length; });
return out;
}
const fmtSpan = c => c.n === 1 ? fmtTime(c.t) : `${fmtTime(c.t0)} – ${fmtTime(c.t1)}`;
function draw() {
if (!width) return;
ctx.clearRect(0, 0, width, height); hits = [];
drawFloor();
const prims = [], staves = visibleStaves().filter(s => s.events.some(e => inWindow(e.t))), links = connected();
const cl = new Map(staves.map(s => [s, clusters(s.events)]));
// Helix: one continuous line split into short segments so it can interleave with staves by depth.
const owner = new Array(600).fill(undefined);
staves.filter(s => emphasisOf(s) !== 'dim').forEach(s => cl.get(s).forEach(c => {
const i0 = Math.round(uOf(c.t0) * 599), i1 = Math.round(uOf(c.t1) * 599);
for (let i = i0 - 1; i <= i1 + 1; i++) if (i >= 0 && i < 600 && owner[i] === undefined) owner[i] = colorOf(s);
}));
const N = 1800;
let previous = project(helixPoint(tAt(0)));
for (let i = 1; i <= N; i++) {
const u = i / N, next = project(helixPoint(tAt(u))), c = owner[Math.round(u * 599)];
const a = previous, b = next;
prims.push({ z: (a.z + b.z) / 2, draw: k => line([a, b], shade(c ?? '#6d7c76', c ? 1 : .8, k), c ? 2.4 : 1.25) });
previous = next;
}
// Staves: vertical strips of a fixed outer cylinder, spanning the story's first to last event in the window.
staves.forEach(s => {
const em = emphasisOf(s), color = colorOf(s), gap = Math.min(.014, (s.a1 - s.a0) * .08);
const inside = s.events.filter(e => inWindow(e.t)), pad = (win.t1 - win.t0) * .006;
const a0 = s.a0 + gap, a1 = s.a1 - gap;
const t0 = Math.max(win.t0, inside[0].t - pad), t1 = Math.min(win.t1, inside.at(-1).t + pad);
const fill = em === 'focus' ? .26 : em === 'dim' ? .05 : .13, edge = em === 'dim' ? .28 : .75;
const strips = Math.max(2, Math.ceil((a1 - a0) / .07));
for (let j = 0; j < strips; j++) {
const b0 = a0 + (a1 - a0) * j / strips, b1 = a0 + (a1 - a0) * (j + 1) / strips;
const quad = [cylinder(t0, b0), cylinder(t0, b1), cylinder(t1, b1), cylinder(t1, b0)].map(project);
const z = quad.reduce((n, p) => n + p.z, 0) / 4;
prims.push({ z, draw: k => polygon(quad, shade(color, fill, k)) });
if (em !== 'dim' && (j === 0 || j === strips - 1)) {
const edgeAngle = j === 0 ? b0 : b1;
prims.push({ z: z + .1, draw: k => line([project(cylinder(t0, edgeAngle)), project(cylinder(t1, edgeAngle))], shade(color, edge, k), 1) });
}
hits.push({ kind: 'stave', node: s, poly: quad, z });
}
// One band per cluster: thickness grows with the number of events; quiet periods stay as gaps.
cl.get(s).forEach(c => {
const band = []; for (let i = 0; i <= 8; i++) band.push(project(cylinder(c.t, a0 + (a1 - a0) * i / 8, STAVE_R + .5)));
const z = band[4].z + .2, active = c.events.includes(selectedEvent);
const size = Math.min(6, 1.3 + Math.log2(c.n) * 1.1) + (active ? 2 : 0);
prims.push({ z, draw: k => line(band, shade(color, em === 'dim' ? .3 : .85, k), size) });
});
s.labelAt = project(cylinder(t1, centerAngle(s), STAVE_R + 2));
s.labelZ = s.labelAt.z;
});
// Connectors: helix -> stave at the same height, one per cluster; clusters show their size and open on click.
links.filter(s => cl.has(s)).forEach(s => {
const color = colorOf(s), a = centerAngle(s);
cl.get(s).forEach(c => {
const p = project(helixPoint(c.t)), q = project(cylinder(c.t, a)), active = c.events.includes(selectedEvent);
const r = c.n === 1 ? (active ? 6 : 3.4) : Math.min(11, 6 + Math.log2(c.n) * 1.4);
prims.push({ z: (p.z + q.z) / 2, draw: k => line([p, q], shade(color, active ? .95 : .45, k), active ? 2 : 1) });
prims.push({ z: p.z + .3, draw: k => {
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, tau); ctx.fillStyle = shade(color, 1, k); ctx.fill();
ctx.strokeStyle = active ? '#273b36' : 'rgba(252,252,248,.9)'; ctx.lineWidth = 1.4; ctx.stroke();
if (c.n > 1) { ctx.fillStyle = '#fcfcf8'; ctx.font = '600 9px system-ui'; const label = String(c.n); ctx.fillText(label, p.x - ctx.measureText(label).width / 2, p.y + 3); }
} });
if (focus.depth >= 2 || s === focus) hits.push({ kind: 'cluster', cluster: c, node: s, x: p.x, y: p.y, r: Math.max(9, r + 2), z: p.z });
});
});
// Turn ticks on the helix: equal time per turn, labelled at the current grain.
const every = Math.max(1, Math.ceil(turns / 10));
for (let d = 0; d <= Math.floor(turns + 1e-6); d++) {
const t = tAt(d / turns), p = project(helixPoint(t));
prims.push({ z: p.z + .4, draw: k => {
ctx.fillStyle = shade('#3f514a', 1, k); ctx.beginPath(); ctx.arc(p.x, p.y, 2.3, 0, tau); ctx.fill();
if (d % every === 0 && d / turns < .999) { ctx.font = '10px system-ui'; ctx.fillText(fmtTick(t), p.x + 6, p.y + 3); }
} });
}
let zmin = Infinity, zmax = -Infinity;
prims.forEach(p => { zmin = Math.min(zmin, p.z); zmax = Math.max(zmax, p.z); });
const norm = z => zmax > zmin ? (z - zmin) / (zmax - zmin) : 1;
prims.sort((a, b) => a.z - b.z).forEach(p => p.draw(norm(p.z)));
drawLabels(staves, norm);
if (showWisdom.checked) data.wisdom.forEach((w, i) => drawWisdom(w, i));
}
// Labels: the focused or hovered stave always; otherwise only staves facing the viewer, without overlaps.
function drawLabels(staves, norm) {
const placed = [];
staves.map(s => ({ s, k: norm(s.labelZ), em: emphasisOf(s) }))
.sort((a, b) => (b.em === 'focus') - (a.em === 'focus') || b.k - a.k)
.forEach(({ s, k, em }) => {
if (em === 'dim' || (em !== 'focus' && k < .45)) return;
ctx.font = (em === 'focus' ? '600 ' : '') + '11px system-ui';
const w = ctx.measureText(s.name).width, x = s.labelAt.x - w / 2, y = s.labelAt.y - 8;
const box = [x - 4, y - 12, w + 8, 16];
if (placed.some(b => box[0] < b[0] + b[2] && b[0] < box[0] + box[2] && box[1] < b[1] + b[3] && b[1] < box[1] + box[3])) return;
placed.push(box);
ctx.fillStyle = 'rgba(252,252,248,.82)'; ctx.fillRect(...box);
ctx.fillStyle = shade(colorOf(s), 1, Math.max(k, em === 'focus' ? 1 : 0)); ctx.fillText(s.name, x, y);
hits.push({ kind: 'label', node: s, rect: box });
});
}
function staveFor(eventId) { const hit = eventIndex.get(eventId); if (!hit) return; const sub = hit.feature.children.find(s => s.events.some(e => e.id === eventId)); return { event: hit.event, node: sub ?? hit.feature }; }
function drawWisdom(w, i) {
const a = staveFor(w.formedIn[0]), b = staveFor(w.appliedIn.at(-1)); if (!a || !b) return;
const sa = shown(a.node), sb = shown(b.node); if (!sa || !sb || sa === sb) return;
if (!inWindow(a.event.t) || !inWindow(b.event.t)) return; // either end lies outside the viewed time window
const pa = cylinder(a.event.t, centerAngle(sa)), pb = cylinder(b.event.t, centerAngle(sb));
const mid = [(pa[0] + pb[0]) * .9, (pa[1] + pb[1]) / 2, (pa[2] + pb[2]) * .9 + 120];
const curve = []; for (let k = 0; k <= 50; k++) { const t = k / 50; curve.push(project(pa.map((v, j) => (1 - t) ** 2 * v + 2 * (1 - t) * t * mid[j] + t * t * pb[j]))); }
const on = selectedWisdom === w;
line(curve, '#a47837', on ? 2.6 : 1.3, [5, 5]);
const end = curve[50], prev = curve[47], ang = Math.atan2(end.y - prev.y, end.x - prev.x);
line([{ x: end.x - 8 * Math.cos(ang - .45), y: end.y - 8 * Math.sin(ang - .45) }, end, { x: end.x - 8 * Math.cos(ang + .45), y: end.y - 8 * Math.sin(ang + .45) }], '#a47837', 1.8);
const anchor = curve[25], cw = Math.min(176, width - 24), ch = 44;
const x = Math.max(12, Math.min(width - cw - 12, anchor.x - cw / 2)), y = Math.max(12, Math.min(height - ch - 30, anchor.y - ch / 2 + i * 50));
ctx.fillStyle = on ? '#e9d7b6' : '#f8efdfe8'; ctx.strokeStyle = '#ad854b'; ctx.lineWidth = on ? 2 : 1;
ctx.beginPath(); ctx.roundRect(x, y, cw, ch, 8); ctx.fill(); ctx.stroke();
ctx.fillStyle = '#936825'; ctx.font = '10px system-ui'; ctx.fillText(`W${i + 1} · 跨故事经验 · 待确认`, x + 10, y + 16);
ctx.fillStyle = '#574522'; ctx.font = '12px system-ui'; ctx.fillText(w.lesson, x + 10, y + 33);
hits.push({ kind: 'wisdom', w, rect: [x, y, cw, ch] });
}
// ---- side panel ------------------------------------------------------------
const dateOf = t => new Date(range[0] + t * (range[1] - range[0]));
const pad2 = n => String(n).padStart(2, '0');
const fmt = t => { const d = dateOf(t); return `${d.getMonth() + 1}.${pad2(d.getDate())}`; };
const fmtTime = t => { const d = dateOf(t); return `${fmt(t)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; };
// Tick labels follow the grain: hours for sub-day turns, dates for daily turns, months beyond.
const fmtTick = t => win.turnHours < 24 ? fmtTime(t) : win.turnHours >= 720 ? `${dateOf(t).getFullYear()}.${dateOf(t).getMonth() + 1}` : fmt(t);
const turnName = h => ({ 3: '3 小时', 6: '6 小时', 12: '12 小时', 24: '1 天', 168: '1 周', 720: '1 个月', 2160: '1 个季度', 8760: '1 年' })[h] ?? `${h} 小时`;
const typeName = { decision: '决定', change: '变更', milestone: '里程碑', discussion: '讨论' };
function el(tag, props = {}, ...kids) { const n = document.createElement(tag); Object.assign(n, props); n.append(...kids); return n; }
function setFocus(node) {
focus = node; hovered = undefined; layout(level(), 0, tau); selectedEvent = undefined; selectedWisdom = undefined; selectedCluster = undefined; render();
if (focus !== level()) face(focus); else if (mode === 'free') draw(); else animateTo({ ...VIEWS[mode], pan: 0 });
}
function render() {
const crumbs = document.querySelector('#crumbs'); crumbs.replaceChildren();
const trail = []; for (let n = focus; n; n = n.parent) trail.unshift(n);
trail.forEach((n, i) => { if (i) crumbs.append(' › '); crumbs.append(i === trail.length - 1 ? el('strong', { textContent: n.name }) : el('button', { textContent: n.name, onclick: () => setFocus(n), className: 'crumb' })); });
const levelName = ['全部项目', '项目木片', '功能木片', '子线索'][focus.depth];
document.querySelector('#period').textContent = selectedWisdom ? '跨故事经验 · 模型归纳 · 待确认' : `${levelName} · ${fmt(focus.start)} – ${fmt(focus.end)}`;
document.querySelector('#story-title').textContent = selectedWisdom ? selectedWisdom.lesson : focus.name;
const desc = selectedWisdom ? '由真实图谱事件归纳,形成与应用都对应具体事件;需要用户确认后才作为经验使用。'
: focus === root ? `${root.children.length} 个项目,${root.events.length} 个事件。木片越宽,事件越多;选择项目后拆分为功能。`
: `${focus.events.length} 个事件 · ${focus.entities ?? focus.children.reduce((n, c) => n + (c.entities ?? 0), 0)} 个知识实体${focus.scattered ? ` · ${focus.scattered} 个零散实体未归入功能` : ''}${focus.unthreaded ? ` · ${focus.unthreaded} 个实体留在功能层` : ''}`;
document.querySelector('#story-description').textContent = desc;
const list = document.querySelector('#stories'); list.replaceChildren();
if (!selectedWisdom) (focus.children ?? []).forEach(c => {
const b = el('button', { onclick: () => setFocus(c), onmouseenter: () => { hovered = c; draw(); }, onmouseleave: () => { hovered = undefined; draw(); } },
el('i'), el('span', { textContent: c.name }), el('small', { textContent: `${c.events.length} 事件 · ${fmt(c.start)}–${fmt(c.end)}${c.children?.length ? ` · ${c.children.length} 片` : ''}` }));
b.style.setProperty('--story-color', colorOf(c)); list.append(b);
});
const events = document.querySelector('#events'); events.replaceChildren();
const detail = document.querySelector('#detail'); detail.replaceChildren();
if (selectedWisdom) {
[['适用条件', selectedWisdom.conditions], ['边界', selectedWisdom.boundary], ['形成与应用依据', selectedWisdom.why]].forEach(([h, p]) => detail.append(el('section', {}, el('strong', { textContent: h }), el('p', { textContent: p }))));
[['形成', selectedWisdom.formedIn], ['应用', selectedWisdom.appliedIn]].forEach(([label, ids]) => ids.forEach(id => {
const s = staveFor(id); if (!s) return;
detail.append(el('button', { className: 'jump', textContent: `${label} · ${fmt(s.event.t)} · ${s.node.name}`, onclick: () => { setFocus(s.node); selectEvent(s.event); } }));
}));
} else if (focus.depth >= 2) {
const detailed = projectOf(focus).detailed;
// The list follows the viewed window; an opened cluster narrows it to that cluster's events.
const listed = (selectedCluster ? selectedCluster.events : focus.events.filter(e => inWindow(e.t))).slice().reverse();
if (selectedCluster) events.append(el('div', { className: 'cluster-head' }, el('span', { textContent: `${fmtSpan(selectedCluster)} · ${selectedCluster.n} 个事件` }), el('button', { className: 'crumb', textContent: '显示全部', onclick: () => { selectedCluster = undefined; render(); } })));
listed.slice(0, 60).forEach(e => events.append(el('button', { 'aria-pressed': String(e === selectedEvent), onclick: () => selectEvent(e) },
el('span', { textContent: detailed ? e.title : `${typeName[e.type]}事件` }), el('small', { textContent: `${fmtTime(e.t)} · ${typeName[e.type]}` }))));
if (listed.length > 60) events.append(el('small', { textContent: `另有 ${listed.length - 60} 个更早的事件` }));
if (!listed.length) events.append(el('small', { textContent: '这段时间没有该故事的事件' }));
if (selectedEvent) detail.append(el('strong', { textContent: detailed ? selectedEvent.title : '该项目只显示时间与类型' }), el('p', { textContent: detailed ? selectedEvent.text : '' }), el('small', { textContent: new Date(selectedEvent.at).toLocaleString('zh-CN') }));
}
document.querySelector('#wisdom-list').replaceChildren(...data.wisdom.map((w, i) => el('button', { className: 'wisdom-card', 'aria-pressed': String(selectedWisdom === w), onclick: () => { selectedWisdom = w; showWisdom.checked = true; render(); } },
el('span', { textContent: `W${i + 1} · 跨故事经验 · 待确认` }), el('strong', { textContent: w.lesson }))));
draw();
}
function selectEvent(e) {
if (!inWindow(e.t)) { selectedCluster = undefined; applyRange('all'); }
selectedEvent = e; render(); if (mode !== 'free') animateTo({ pan: yOf(e.t) * .5 });
}
// A single event opens directly. A cluster zooms the window into its time span so it splits apart;
// when it cannot split further (same moment) the list narrows to its events instead.
function openCluster(c) {
if (c.n === 1) { selectedCluster = undefined; selectEvent(c.events[0]); return; }
const span = c.t1 - c.t0, minSpan = 6 / totalHours; // never zoom below ~6 hours
if (win.t1 - win.t0 > minSpan * 1.01 && span > 0) {
const pad = Math.max(span * .6, minSpan / 2), mid = (c.t0 + c.t1) / 2, half = Math.max(span / 2 + pad, minSpan / 2);
selectedCluster = undefined; zoomWindow(mid - half, mid + half);
} else { selectedCluster = c; selectedEvent = undefined; render(); }
}
// ---- time window and grain ---------------------------------------------------------
const rangeSelect = document.querySelector('#range'), turnSelect = document.querySelector('#turn'), rangeReset = document.querySelector('#range-reset');
let selectedCluster;
function applyWindow(t0, t1) {
const fixed = turnSelect.value === 'auto' ? undefined : Number(turnSelect.value);
setWindow(t0, t1, fixed);
if (fixed && win.turnHours !== fixed) turnSelect.value = 'auto'; // the chosen turn would make the helix unreadable
document.querySelector('#scale-note').textContent = `高度为时间,一圈 ${turnName(win.turnHours)}(共 ${Math.round(turns * 10) / 10} 圈)· 半径为当时的对话密度`;
document.querySelector('#axis-start').textContent = win.turnHours < 24 ? fmtTime(win.t0) : fmt(win.t0);
document.querySelector('#axis-end').textContent = win.turnHours < 24 ? fmtTime(win.t1) : fmt(win.t1);
rangeReset.hidden = win.t0 <= 0 && win.t1 >= 1;
}
function zoomWindow(t0, t1) {
const len = t1 - t0;
if (t0 < 0) { t0 = 0; t1 = Math.min(1, len); } if (t1 > 1) { t1 = 1; t0 = Math.max(0, 1 - len); }
rangeSelect.value = 'custom';
applyWindow(t0, t1); render(); if (mode !== 'free') animateTo({ pan: 0 });
}
function applyRange(value) {
rangeSelect.value = value;
if (value === 'all') applyWindow(0, 1); else if (value !== 'custom') { const h = Number(value); applyWindow(Math.max(0, 1 - h / totalHours), 1); }
}
rangeSelect.addEventListener('change', () => { selectedCluster = undefined; applyRange(rangeSelect.value); render(); });
turnSelect.addEventListener('change', () => { applyWindow(win.t0, win.t1); render(); });
rangeReset.addEventListener('click', () => { selectedCluster = undefined; applyRange('all'); render(); });
applyRange('all');
// ---- input -----------------------------------------------------------------
const viewButtons = [...document.querySelectorAll('[data-view]')];
function syncViewButtons() { viewButtons.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.view === mode))); }
function syncZoom() { zoom.value = String(Math.round(cam.zoom * 100)); }
viewButtons.forEach(b => b.addEventListener('click', () => {
if (b.dataset.view === 'free') { freeView(); return; }
animateTo({ ...VIEWS[b.dataset.view], zoom: 1, pan: 0 }, b.dataset.view);
}));
zoom.addEventListener('input', () => { tween = undefined; cam.zoom = Number(zoom.value) / 100; draw(); });
showWisdom.addEventListener('input', draw);
document.querySelector('#reset').onclick = () => animateTo({ ...VIEWS.oblique, zoom: 1, pan: 0 }, 'oblique');
const clampTilt = t => Math.max(-Math.PI / 2, Math.min(Math.PI / 2, t));
const clampPan = p => Math.max(-HEIGHT / 2, Math.min(HEIGHT / 2, p));
const clampZoom = z => Math.max(.5, Math.min(2.5, z));
function inPoly(x, y, poly) { let inside = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const a = poly[i], b = poly[j]; if ((a.y > y) !== (b.y > y) && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside; } return inside; }
const inRect = (x, y, r) => x >= r[0] && x <= r[0] + r[2] && y >= r[1] && y <= r[1] + r[3];
function hitAt(x, y) {
return hits.find(h => h.kind === 'wisdom' && inRect(x, y, h.rect))
?? hits.filter(h => h.kind === 'cluster' && Math.hypot(h.x - x, h.y - y) < h.r).sort((a, b) => b.z - a.z)[0]
?? hits.find(h => h.kind === 'label' && inRect(x, y, h.rect))
?? hits.filter(h => h.kind === 'stave' && inPoly(x, y, h.poly)).sort((a, b) => b.z - a.z)[0];
}
let drag;
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('pointerdown', e => {
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, pan: e.button === 2 || e.shiftKey };
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointermove', e => {
const r = canvas.getBoundingClientRect();
if (!drag) {
const h = hitAt(e.clientX - r.left, e.clientY - r.top), node = h && (h.kind === 'stave' || h.kind === 'label') ? h.node : undefined;
if (node !== hovered) { hovered = node; draw(); }
canvas.style.cursor = h ? 'pointer' : '';
return;
}
if (drag.id !== e.pointerId) return;
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) >= 5) freeView();
if (mode === 'free') {
if (drag.pan) cam.pan = clampPan(cam.pan + dy / (Math.min(width / 500, height / 560) * cam.zoom));
else { cam.yaw += dx * .008; cam.tilt = clampTilt(cam.tilt + dy * .006); }
draw();
}
drag.x = e.clientX; drag.y = e.clientY;
});
canvas.addEventListener('pointerup', e => {
if (drag && Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) < 5) {
const r = canvas.getBoundingClientRect(), h = hitAt(e.clientX - r.left, e.clientY - r.top);
if (h?.kind === 'wisdom') { selectedWisdom = h.w; render(); }
else if (h?.kind === 'cluster') openCluster(h.cluster);
else if (h) setFocus(h.node);
}
drag = undefined;
});
canvas.addEventListener('pointercancel', () => { drag = undefined; });
canvas.addEventListener('pointerleave', () => { if (!drag && hovered) { hovered = undefined; draw(); } });
canvas.addEventListener('wheel', e => {
e.preventDefault(); freeView();
if (e.shiftKey) cam.pan = clampPan(cam.pan - e.deltaY * .3);
else cam.zoom = clampZoom(cam.zoom * Math.exp(-e.deltaY * .0012));
syncZoom(); draw();
}, { passive: false });
canvas.addEventListener('keydown', e => {
if (e.key === 'Escape' && focus.parent) { setFocus(focus.parent); return; }
const preset = { 1: 'oblique', 2: 'side', 3: 'top' }[e.key];
if (preset) { animateTo({ ...VIEWS[preset], zoom: 1, pan: 0 }, preset); return; }
const moves = { ArrowLeft: () => { cam.yaw -= .12; }, ArrowRight: () => { cam.yaw += .12; }, ArrowUp: () => { cam.tilt = clampTilt(cam.tilt + .06); }, ArrowDown: () => { cam.tilt = clampTilt(cam.tilt - .06); },
PageUp: () => { cam.pan = clampPan(cam.pan + 20); }, PageDown: () => { cam.pan = clampPan(cam.pan - 20); }, '+': () => { cam.zoom = clampZoom(cam.zoom * 1.1); }, '=': () => { cam.zoom = clampZoom(cam.zoom * 1.1); }, '-': () => { cam.zoom = clampZoom(cam.zoom / 1.1); } };
if (!moves[e.key]) return;
e.preventDefault(); freeView(); moves[e.key](); syncZoom(); draw();
});
new ResizeObserver(() => { const r = canvas.getBoundingClientRect(); width = r.width; height = r.height; const dpr = Math.min(devicePixelRatio || 1, 2); canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); draw(); }).observe(canvas);
// Read-only hook for layout checks.
window.storyDemo = { cam, win, get turns() { return turns; }, get mode() { return mode; }, get focus() { return focus.name; },
clustersOf: name => { const find = n => n.name === name ? n : (n.children ?? []).map(find).find(Boolean); const n = find(root); return n ? clusters(n.events).map(c => c.n) : []; },
clusterHits: () => hits.filter(h => h.kind === 'cluster').map(h => ({ n: h.cluster.n, x: Math.round(h.x), y: Math.round(h.y) })),
setWindow: (t0, t1) => zoomWindow(t0, t1), simulateSpan: hours => { const t = totalHours; return { hours, turnHours: autoTurnHours(hours), turns: hours / autoTurnHours(hours), total: t }; }, setFocusByName: name => { const find = n => n.name === name ? n : (n.children ?? []).map(find).find(Boolean); const n = find(root); if (n) setFocus(n); return !!n; } };
syncViewButtons(); syncZoom();
render();
+4
View File
@@ -1,5 +1,9 @@
# 故事线图谱(Story Graph)
下一版视觉方向见 [3D 故事线图谱概念](./3d-concept.md):记录变半径时间螺旋、长短不同的故事木片与跨故事智慧复用,以及概念图生成引用。该方案尚未实现,不替代下述现有演示。
[打开 3D 网页 Demo](./3d-demo.html):基于本机故事线图谱的只读导出(`3d-demo-data.js`),木片按项目 › 功能 › 子线索分层。默认用 WebGL 渲染(`3d-demo-webgl.js`,依赖仓库内的 `vendor/three.min.js`,Three.js r160,MIT 许可见 `vendor/three.LICENSE`),由 GPU 深度缓冲处理遮挡;浏览器不支持 WebGL 时自动改用 Canvas 投影版本(`3d-demo.js`)。直接打开 HTML 即可,不接入生产图谱,不是已上线的产品界面。
这是[监督者应用](../conversation-supervision/supervisor-prd.md)的核心视图。目标对象与交互由[产品设计](./prd.md)定义,用户验收见[User Stories](../conversation-supervision/user-stories.md)。时间事件、知识实体与阶段总结在目标设计中分别建模;下述演示仍使用阶段节点。
[打开交互演示](./demo.html)。这是监督者故事图谱的独立设计原型,使用模拟数据,尚未接入产品或笔记存储。
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright © 2010-2023 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because one or more lines are too long