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

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