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:
@@ -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 能按故事接手工作、按经验给出建议 |
|
||||
@@ -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
@@ -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();
|
||||
})();
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user