Files
goodbuddy/docs/features/story-graph/3d-demo-webgl.js
T
mesalogo 5dc65457d9 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.
2026-10-02 15:21:48 +08:00

609 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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();
})();