From 78463492c3e3e5f52645b503f98cdeaa72ff62b4 Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Mon, 31 Aug 2026 21:42:23 +0800 Subject: [PATCH 01/32] The derived toggle should not look like a tenth class Co-Authored-By: Claude Opus 5 --- web/src/pages/Graph.tsx | 229 +++++++++++++++++++++++++++++----------- 1 file changed, 166 insertions(+), 63 deletions(-) diff --git a/web/src/pages/Graph.tsx b/web/src/pages/Graph.tsx index 60b32c358..10a785e67 100644 --- a/web/src/pages/Graph.tsx +++ b/web/src/pages/Graph.tsx @@ -59,6 +59,9 @@ const DERIVED_PULSE_MS = 2200; // 超过这个数就只上色不动画。**写出来而不是悄悄降级**:每帧重算几千条边的颜色, // 换来的是拖不动图,而那时候用户要的是能拖得动 const DERIVED_ANIMATE_MAX = 400; +// 开关的淡入淡出时长。**比 FADE_MS(320) 略长**:播放淡入是一批边陆续到位, +// 这个是一整批边同时进出,走慢一点才看得清「那批金线是一起退场的」 +const DERIVED_TOGGLE_MS = 420; // 注意:sigma 边着色器在预乘混合(ONE, ONE_MINUS_SRC_ALPHA)下不预乘 RGB, // alpha 无法压暗边——暗度必须编码进 RGB(不透明近背景色) const EDGE_DIM = "#141414"; @@ -463,6 +466,53 @@ export function Graph() { sigmaRef.current?.refresh(); }, [hiddenTypes, showDerived]); + /* 开关的淡入淡出:{ 起始时刻, 朝哪个方向 };null = 没有过渡在飞 */ + const derivedToggleRef = useRef<{ at: number; on: boolean } | null>(null); + const derivedRafRef = useRef(0); + /* 上一次的开关值。**判「是不是真的切换了」只能靠它**——effect 的依赖里 + 还有 derivedCount,而「Run now 推出新边」会改 count 却没碰开关; + 只看 effect 触发就淡一次,那是一次没人要求的动画 */ + const prevShowDerived = useRef(showDerived); + + // 切换时走一段渐变,而不是瞬间消失。**得自己驱动重绘**——关掉时下面那个 + // 呼吸定时器不转了,没人推 sigma 重画,淡出就会卡在第一帧 + useEffect(() => { + const changed = prevShowDerived.current !== showDerived; + prevShowDerived.current = showDerived; + // 首次挂载与「只有 count 变了」都不是切换: + // 进页面时、以及推理跑完刷新计数时,都不该看到一段莫名其妙的淡入 + if (!changed) return; + // 数量太多时不淡:与呼吸同一条线——每帧重算几千条边的颜色换来的是卡顿。 + // **写出来而不是悄悄降级** + if (derivedCount > DERIVED_ANIMATE_MAX) return; + + const now = performance.now(); + const prev = derivedToggleRef.current; + // 半途反向(用户连点两下):从当前进度接着走,而不是从头开始—— + // 否则会看见一次亮度的跳变 + const at = + prev && prev.on !== showDerived + ? now - Math.max(0, DERIVED_TOGGLE_MS - (now - prev.at)) + : now; + derivedToggleRef.current = { at, on: showDerived }; + + const step = () => { + const tr = derivedToggleRef.current; + const done = !tr || performance.now() - tr.at >= DERIVED_TOGGLE_MS; + if (done) derivedToggleRef.current = null; + sigmaRef.current?.refresh(); + derivedRafRef.current = done ? 0 : requestAnimationFrame(step); + }; + cancelAnimationFrame(derivedRafRef.current); + derivedRafRef.current = requestAnimationFrame(step); + // **不在这里挂清理**:清理会在依赖变化时也跑一遍,而依赖里有 derivedCount + // ——推理恰好在这 420ms 中途跑完,动画就被掐在半路(画面停在一半亮度, + // 要等下一次任意重绘才归位)。循环自己会终止;取消只该发生在卸载时 + }, [showDerived, derivedCount]); + + // 卸载时收掉可能在飞的那一帧 + useEffect(() => () => cancelAnimationFrame(derivedRafRef.current), []); + // 派生边的呼吸。**只在有派生边、且开着显示、且数量不多时才转**—— // 一个没开推理的库不该为这件事每两秒重画一次 useEffect(() => { @@ -738,11 +788,23 @@ export function Graph() { // **放在最前面**——藏起来的边不必再算后面那些提亮/压暗 const isDerived = attrs.derived === true; if (isDerived) { + const tr = derivedToggleRef.current; + const k = tr + ? Math.min(1, (performance.now() - tr.at) / DERIVED_TOGGLE_MS) + : 1; + // 关掉了:只有「淡出尚未走完」这一种情况还留着不藏 if (!f.showDerived) { - res.hidden = true; + if (!tr || tr.on || k >= 1) { + res.hidden = true; + return res; + } + // 由金渐灭到近背景色。**暗度必须编码进 RGB**(见 EDGE_DIM 处的注释: + // 预乘混合下 alpha 压不暗边),所以是往 EDGE_DIM 混而不是降 alpha + res.color = lerpColor(EDGE_COLOR_DERIVED, EDGE_DIM, k); + res.label = ""; return res; } - res.color = lerpColor( + const pulse = lerpColor( EDGE_COLOR_DERIVED_DIM, EDGE_COLOR_DERIVED, // 三角波而不是正弦:两端各停一瞬,看起来是「呼吸」不是「闪」 @@ -751,6 +813,9 @@ export function Graph() { 1, ), ); + // 打开:从近背景色亮起来,接上呼吸 + res.color = + tr && tr.on && k < 1 ? lerpColor(EDGE_DIM, pulse, k) : pulse; } // hover: 只提亮关联边;selected: 提亮关联边 + 压暗其余 const hov = hoverRef.current; @@ -992,69 +1057,105 @@ export function Graph() { {/* 推出来的边:**自成一组,不进类型图例。** 图例回答「显示哪些类」,一排全是本体里的类;这个回答的是 「显不显示推出来的边」——不是同一个问题,混进那一排它就像是 - 多出来的一个类。为零时整组不出现 */} - {derivedCount > 0 && ( -
- - {/* 展开成一个小窗:这批边是什么时候推的、现在还推不推、手动再跑一次。 - **与开关分成两个按钮**——「藏起来」是每天要点的,「什么时候推的」 - 是偶尔才问的,合成一个会让常用动作多一步 */} - - {derivedPanel && kb && ( - setDerivedPanel(false)} - /> - )} -
- )} - -
- {stabilizing && ( - {S.graph.stabilizing} · + 多出来的一个类。为零时整组不出现。 + + **这个意图曾经只写在这条注释里,没落到像素上**:它当初就排在图例 + 末尾,长着同样的 glass 胶囊、同样的色点、同样的字号,唯一的区隔是 + 一条 `border-white/10` 的竖线——在这个底色上等于没有。结果就是 + 没人找得到它。所以现在做两件事,缺一件都不够: + + 1. **挪到顶栏最右**,与统计文字为邻——那段本来就是「你在看什么」 + 的地盘。但别指望位置本身能解决问题:类胶囊那一排很占地方, + 实测 1600px 宽下它与最后一个类之间只剩 69px,**不是大片空白**。 + 2. **换成开关形态**(轨道+滑块),这一条才是决定性的。只要还长得 + 像胶囊,它就仍会被当成第 10 个类——类胶囊是「筛掉哪些类」的 + 滤镜,这个是开关,形状就该不一样。 */} + {/* 右侧组:开关 + 统计。**必须是一个容器整体靠右,不能各给一个 + `ml-auto`**——flex 里多个 auto 左边距是**平分**剩余空间的,不是 + 第一个吃光。实测过:两边各分到 35.8px,开关就卡在图例与统计中间, + 既没贴右也没贴左,反而更难找。包一层,两个都到最右; + 且派生组不渲染时,统计仍然靠右 */} +
+ {derivedCount > 0 && ( +
+ + {/* 展开成一个小窗:这批边是什么时候推的、现在还推不推、手动再跑一次。 + **与开关分成两个按钮**——「藏起来」是每天要点的,「什么时候推的」 + 是偶尔才问的,合成一个会让常用动作多一步 */} + + {derivedPanel && kb && ( + setDerivedPanel(false)} + /> + )} +
)} - {/* 画满上限时说清「画了多少 / 共多少」。**这个数从前是上限冒充规模**—— - 一个上万实体的库右上角永远写着 150 */} - {capped ? ( - - {S.graph.statsCapped( + +
+ {stabilizing && ( + {S.graph.stabilizing} · + )} + {/* 画满上限时说清「画了多少 / 共多少」。**这个数从前是上限冒充规模**—— + 一个上万实体的库右上角永远写着 150 */} + {capped ? ( + + {S.graph.statsCapped( + nodeCount, + totalNodes, + totalEdges, + timeT === null ? edgeCount : activeCount, + )} + + ) : ( + S.graph.stats( nodeCount, - totalNodes, - totalEdges, + edgeCount, timeT === null ? edgeCount : activeCount, - )} - - ) : ( - S.graph.stats( - nodeCount, - edgeCount, - timeT === null ? edgeCount : activeCount, - ) - )} + ) + )} +
@@ -1468,8 +1569,10 @@ function DerivedPanel({ ? Math.round((Date.now() - new Date(last).getTime()) / 60000) : null; + // **right-0 而不是 left-0**:这一组现在贴着顶栏右缘, + // 左对齐的 w-72 会整块溢出到视口外 return ( -
+
{S.graph.derivedPanel} From 3e35ce893b0c1daf9748f9879f9cb05c3bfd1b01 Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Mon, 31 Aug 2026 22:10:09 +0800 Subject: [PATCH 02/32] A legend with a hundred classes is not a legend Co-Authored-By: Claude Opus 5 --- web/src/i18n/en.ts | 5 ++ web/src/i18n/zh.ts | 4 ++ web/src/pages/Graph.tsx | 126 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 126 insertions(+), 9 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 4660cfed2..4df1ec99c 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -547,6 +547,11 @@ export const en = { graph: { // 还没判出类型的实体(0009)。不是一个类,是"这一格还空着" untyped: "Untyped", + legendMore: (n: number) => `+${n} classes`, + legendSearch: "Filter classes", + legendNone: "No class matches", + legendAllHint: + "Every class on screen, most common first. Click to show or hide.", searchMore: (n: number) => `${n} more — load 20`, zoomIn: "Zoom in", zoomOut: "Zoom out", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 769ffeeac..63b39c04c 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -512,6 +512,10 @@ export const zh: Strings = { }, graph: { untyped: "未分类", + legendMore: (n: number) => `+${n} 个类`, + legendSearch: "筛选类", + legendNone: "没有匹配的类", + legendAllHint: "画面上的全部类,按出现次数排。点一下显示或隐藏。", searchMore: (n: number) => `还有 ${n} 个——再加载 20`, zoomIn: "放大", zoomOut: "缩小", diff --git a/web/src/pages/Graph.tsx b/web/src/pages/Graph.tsx index 10a785e67..f74f2e6dd 100644 --- a/web/src/pages/Graph.tsx +++ b/web/src/pages/Graph.tsx @@ -62,6 +62,10 @@ const DERIVED_ANIMATE_MAX = 400; // 开关的淡入淡出时长。**比 FADE_MS(320) 略长**:播放淡入是一批边陆续到位, // 这个是一整批边同时进出,走慢一点才看得清「那批金线是一起退场的」 const DERIVED_TOGGLE_MS = 420; +// 图例最多摆几个胶囊,其余收进「+N 个类」。**这一排是横向排布的, +// 类一多就会换行、把画布顶到下面去**;而且十几个同样的胶囊排开, +// 谁也读不出哪个重要。收起来的那些从「+N」里搜得到 +const LEGEND_MAX = 6; // 注意:sigma 边着色器在预乘混合(ONE, ONE_MINUS_SRC_ALPHA)下不预乘 RGB, // alpha 无法压暗边——暗度必须编码进 RGB(不透明近背景色) const EDGE_DIM = "#141414"; @@ -287,6 +291,8 @@ export function Graph() { const [showDerived, setShowDerived] = useState(true); // 信息窗默认收起:它答的是「什么时候推的」,那是偶尔才问的问题 const [derivedPanel, setDerivedPanel] = useState(false); + const [legendPanel, setLegendPanel] = useState(false); + const [legendQ, setLegendQ] = useState(""); /** null = 全时段;数值 = as-of 时刻(ms)。 默认 as-of 今天:时态平台的图谱默认呈现"现在的世界", 已闭合的事实不该与现行事实无差别并列(All time 是显式选择) */ @@ -389,22 +395,37 @@ export function Graph() { const types = useMemo(() => { const map = new Map< string, - { label: string; color: string; shape: string } + { label: string; color: string; shape: string; count: number } >(); for (const n of data.data?.nodes ?? []) { // 没判出类型的归到空 key 一档(0009)。真实 key 由 IRI 派生,不可能为空, // 所以它撞不着任何一个类;标签走 i18n,别把 null 画到图例上 const key = n.type_key ?? ""; - if (!map.has(key)) + const cur = map.get(key); + if (cur) cur.count++; + else map.set(key, { label: n.type_label ?? S.graph.untyped, color: n.color, shape: n.shape, + count: 1, }); } - return [...map.entries()]; + // **按出现次数排,不是按遇到的先后**。图例只摆得下几个,那几个位置该给 + // 画面上最多的类;从前是节点到达顺序,等于随机。次数相同按标签排—— + // 否则同样的数据每次刷新顺序都在抖 + return [...map.entries()].sort( + (a, b) => b[1].count - a[1].count || a[1].label.localeCompare(b[1].label), + ); }, [data.data]); + /* 摆得下的 / 收起来的。收起来的那些仍然可以在「+N」里搜到并切换 */ + const legendShown = types.slice(0, LEGEND_MAX); + const legendRest = types.slice(LEGEND_MAX); + // 被收起来的类里有没有正被隐藏的。**没有这个标记就是无声过滤**—— + // 在面板里关掉一个类、把面板一收,界面上再没有任何东西说它被关了 + const hiddenInRest = legendRest.filter(([k]) => hiddenTypes.has(k)).length; + // 有几条推出来的边。**为零时那个开关整个不出现**——一个没开推理的库不该 // 看到一个永远切换不出任何变化的按钮 const derivedCount = useMemo( @@ -1028,9 +1049,11 @@ export function Graph() { )} - {/* 图例(点击切换类型显隐) */} + {/* 图例(点击切换类型显隐)。**只摆前 LEGEND_MAX 个**,其余收进 + 「+N 个类」——那一排横着长,类一多就换行把画布顶下去;而且十几个 + 一模一样的胶囊排开,谁重要也读不出来 */}
- {types.map(([key, t]) => ( + {legendShown.map(([key, t]) => ( ))} + + {legendRest.length > 0 && ( +
+ + {legendPanel && ( +
+ setLegendQ(e.target.value)} + placeholder={S.graph.legendSearch} + className="input-dark mb-1.5 w-full px-2 py-1 text-[12px]" + /> + {/* **列的是全部类,不只是收起来的那些**:想找一个类的时候, + 没人记得它是不是恰好排进了前几个 */} +
+ {types + .filter(([, t]) => + t.label.toLowerCase().includes(legendQ.toLowerCase()), + ) + .map(([key, t]) => ( + + ))} + {types.every( + ([, t]) => + !t.label.toLowerCase().includes(legendQ.toLowerCase()), + ) && ( +
+ {S.graph.legendNone} +
+ )} +
+
+ )} +
+ )}
{/* 推出来的边:**自成一组,不进类型图例。** @@ -1083,10 +1187,14 @@ export function Graph() { role="switch" aria-checked={showDerived} title={S.graph.derivedHint} - className={`glass rounded-full py-1 pl-1 pr-2.5 text-[11px] flex items-center gap-1.5 border transition-colors ${ + /* **形状语言要跟类胶囊分家**:那一排全是 rounded-full 的 + 中性玻璃椭圆;只要这个还长成同样的胶囊,挪到哪儿都会被当成 + 第 10 个类(用户看过一版就是这么说的)。所以这里是方角、 + 带金色底、不用 glass——一眼就不是同一族东西 */ + className={`rounded-md py-1 pl-1 pr-2.5 text-[11px] flex items-center gap-1.5 border transition-colors ${ showDerived - ? "border-[rgba(231,197,124,0.45)]" - : "border-white/10 opacity-60" + ? "border-[rgba(231,197,124,0.5)] bg-[rgba(231,197,124,0.14)]" + : "border-white/10 bg-white/[0.03] opacity-70" }`} > {/* 轨道+滑块。开时上金色——金是派生边自己的色相, @@ -1117,7 +1225,7 @@ export function Graph() { onClick={() => setDerivedPanel((v) => !v)} title={S.graph.derivedPanel} aria-expanded={derivedPanel} - className={`glass rounded-full h-[22px] w-[22px] text-[11px] leading-none text-neutral-400 hover:text-neutral-100 transition-colors ${ + className={`rounded-md h-[22px] w-[22px] border border-white/10 bg-white/[0.03] text-[11px] leading-none text-neutral-400 hover:text-neutral-100 transition-colors ${ derivedPanel ? "text-neutral-100" : "" }`} > From 34aaa9b9086965bd6f08b8b13f5513ef66d4ce22 Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Mon, 31 Aug 2026 22:33:45 +0800 Subject: [PATCH 03/32] Chrome stays grey, so the toggle moves instead of turning gold Co-Authored-By: Claude Opus 5 --- crates/utopia-server/src/api/graph_routes.rs | 16 +- web/src/api.ts | 6 +- web/src/i18n/en.ts | 3 + web/src/i18n/zh.ts | 3 + web/src/pages/Graph.tsx | 246 +++++++++++-------- 5 files changed, 161 insertions(+), 113 deletions(-) diff --git a/crates/utopia-server/src/api/graph_routes.rs b/crates/utopia-server/src/api/graph_routes.rs index c28a8648a..48081132e 100644 --- a/crates/utopia-server/src/api/graph_routes.rs +++ b/crates/utopia-server/src/api/graph_routes.rs @@ -32,14 +32,22 @@ fn parse_at(raw: Option<&str>) -> Result>, .map_err(|_| AppError::Validation("Invalid `at` (expected YYYY-MM-DD or RFC3339)".into())) } -/// 总览一次画多少个节点。**上限本身是合理的**——画一万个点没人看得懂; +/// 总览一次画多少个节点的**默认值**。上限本身是合理的——画一万个点没人看得懂; /// 骗人的是把它当成规模显示,所以接口同时回总数 const GRAPH_NODE_CAP: i64 = 150; +/// 调得再高也得有个天花板。**这个数不是拍的**:节点按度数降序取,越往后越是 +/// 边缘节点,而力导布局是 O(n²) 量级的——超过这个数,先垮的是「拖得动」 +/// 而不是「看得清」。要真看上万个点,那是另一种视图,不是把这个调大 +const GRAPH_NODE_CAP_MAX: i64 = 1000; #[derive(Deserialize)] pub struct OverviewQuery { #[serde(default)] pub at: Option, + /// 画多少个。不给就用默认值;给了也钳在 [10, GRAPH_NODE_CAP_MAX]—— + /// 界面上的按钮只给几档,但接口是公开的,别让一个 `limit=999999` 把库拖垮 + #[serde(default)] + pub limit: Option, } pub async fn overview( @@ -52,8 +60,12 @@ pub async fn overview( let at = parse_at(q.at.as_deref())?; // 画多少个是渲染的事,库里有多少是知识库的事——两个数都回,界面才说得出 // 「画了 150 个,共 325 个」而不是把上限说成规模 + let limit = q + .limit + .unwrap_or(GRAPH_NODE_CAP) + .clamp(10, GRAPH_NODE_CAP_MAX); let (nodes, edges, total_nodes, total_edges) = - utopia_store::graph::overview(&state.pool, kb_id, GRAPH_NODE_CAP, at).await?; + utopia_store::graph::overview(&state.pool, kb_id, limit, at).await?; Ok(Json(json!({ "nodes": nodes, "edges": edges, "total_nodes": total_nodes, "total_edges": total_edges, diff --git a/web/src/api.ts b/web/src/api.ts index 7c3c9ae6a..e714e1847 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1174,7 +1174,7 @@ export const api = { method: "PUT", body: JSON.stringify(body), }), - graphOverview: (kbId: string) => + graphOverview: (kbId: string, limit?: number) => request<{ nodes: GraphNode[]; edges: GraphEdge[]; @@ -1182,7 +1182,9 @@ export const api = { * 那一批,把上限当成规模显示是这个接口从前最误导人的地方 */ total_nodes?: number; total_edges?: number; - }>(`/api/v1/kbs/${kbId}/graph/overview`), + }>( + `/api/v1/kbs/${kbId}/graph/overview${limit ? `?limit=${limit}` : ""}`, + ), /** 邻域视图**没有总数**:它本来就只是一小片,说「共 325 个」没有意义。 * 两个字段声明成可选,好让调用方与总览共用一个类型 */ graphNeighborhood: (kbId: string, entityId: string) => diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 4df1ec99c..3f0b4ed6c 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -548,6 +548,9 @@ export const en = { // 还没判出类型的实体(0009)。不是一个类,是"这一格还空着" untyped: "Untyped", legendMore: (n: number) => `+${n} classes`, + nodeBudget: "How many entities to draw", + nodeBudgetMore: "Draw more", + nodeBudgetLess: "Draw fewer", legendSearch: "Filter classes", legendNone: "No class matches", legendAllHint: diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 63b39c04c..d518e5da1 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -513,6 +513,9 @@ export const zh: Strings = { graph: { untyped: "未分类", legendMore: (n: number) => `+${n} 个类`, + nodeBudget: "画多少个实体", + nodeBudgetMore: "多画一些", + nodeBudgetLess: "少画一些", legendSearch: "筛选类", legendNone: "没有匹配的类", legendAllHint: "画面上的全部类,按出现次数排。点一下显示或隐藏。", diff --git a/web/src/pages/Graph.tsx b/web/src/pages/Graph.tsx index f74f2e6dd..c76c269cf 100644 --- a/web/src/pages/Graph.tsx +++ b/web/src/pages/Graph.tsx @@ -20,6 +20,7 @@ import { Pause, Pencil, Play, + Sparkles, X, ZoomIn, ZoomOut, @@ -66,10 +67,19 @@ const DERIVED_TOGGLE_MS = 420; // 类一多就会换行、把画布顶到下面去**;而且十几个同样的胶囊排开, // 谁也读不出哪个重要。收起来的那些从「+N」里搜得到 const LEGEND_MAX = 6; +/* 画多少个节点的可选档位。**给档位而不是给输入框**:这个数没有「精确」可言 + ——它只影响看得清还是拖得动,用户要的是「多点/少点」,不是 237 这个数。 + 最大值与后端 GRAPH_NODE_CAP_MAX 对齐;再高先垮的是拖动,不是清晰度 */ +const NODE_BUDGETS: number[] = [150, 300, 600, 1000]; // 注意:sigma 边着色器在预乘混合(ONE, ONE_MINUS_SRC_ALPHA)下不预乘 RGB, // alpha 无法压暗边——暗度必须编码进 RGB(不透明近背景色) const EDGE_DIM = "#141414"; const EDGE_FOCUS = "rgba(255,255,255,0.55)"; +// 选中/悬停时的派生边。**不能跟着走白**:选中恰恰是看得最仔细的时候, +// 而这时候「这条边是推出来的、没人写过」比任何时候都该说清楚。 +// 从前一律 EDGE_FOCUS,一选中金线就变白,等于把来历抹掉了。 +// 比常态的金更亮更实——它同样要表达「被选中了」 +const EDGE_FOCUS_DERIVED = "rgba(255,214,140,0.95)"; const MUTED_SHELL = "#151515"; const PILL_BG = "rgba(12,12,12,0.9)"; const PILL_BORDER = "rgba(255,255,255,0.14)"; @@ -307,12 +317,16 @@ export function Graph() { const layoutModeRef = useRef("force"); const layoutCtlRef = useRef<{ apply: (m: LayoutMode) => void } | null>(null); + /* 画多少个。**进 queryKey**——不进的话调了档位不会重新取数, + 界面看着变了实际还是老数据 */ + const [nodeBudget, setNodeBudget] = useState(NODE_BUDGETS[0]); + const data = useQuery({ - queryKey: ["graph", kb?.id, focusEntity], + queryKey: ["graph", kb?.id, focusEntity, nodeBudget], queryFn: () => focusEntity ? api.graphNeighborhood(kb!.id, focusEntity) - : api.graphOverview(kb!.id), + : api.graphOverview(kb!.id, nodeBudget), enabled: !!kb, }); @@ -845,7 +859,7 @@ export function Graph() { ? selectedRef.current : null; const boost = () => { - res.color = EDGE_FOCUS; + res.color = isDerived ? EDGE_FOCUS_DERIVED : EDGE_FOCUS; res.size = Math.max((attrs.size as number) * 1.42, 1.85); res.zIndex = 5; }; @@ -1158,111 +1172,65 @@ export function Graph() { )}
- {/* 推出来的边:**自成一组,不进类型图例。** - 图例回答「显示哪些类」,一排全是本体里的类;这个回答的是 - 「显不显示推出来的边」——不是同一个问题,混进那一排它就像是 - 多出来的一个类。为零时整组不出现。 - - **这个意图曾经只写在这条注释里,没落到像素上**:它当初就排在图例 - 末尾,长着同样的 glass 胶囊、同样的色点、同样的字号,唯一的区隔是 - 一条 `border-white/10` 的竖线——在这个底色上等于没有。结果就是 - 没人找得到它。所以现在做两件事,缺一件都不够: - - 1. **挪到顶栏最右**,与统计文字为邻——那段本来就是「你在看什么」 - 的地盘。但别指望位置本身能解决问题:类胶囊那一排很占地方, - 实测 1600px 宽下它与最后一个类之间只剩 69px,**不是大片空白**。 - 2. **换成开关形态**(轨道+滑块),这一条才是决定性的。只要还长得 - 像胶囊,它就仍会被当成第 10 个类——类胶囊是「筛掉哪些类」的 - 滤镜,这个是开关,形状就该不一样。 */} - {/* 右侧组:开关 + 统计。**必须是一个容器整体靠右,不能各给一个 - `ml-auto`**——flex 里多个 auto 左边距是**平分**剩余空间的,不是 - 第一个吃光。实测过:两边各分到 35.8px,开关就卡在图例与统计中间, - 既没贴右也没贴左,反而更难找。包一层,两个都到最右; - 且派生组不渲染时,统计仍然靠右 */} -
- {derivedCount > 0 && ( -
- - {/* 展开成一个小窗:这批边是什么时候推的、现在还推不推、手动再跑一次。 - **与开关分成两个按钮**——「藏起来」是每天要点的,「什么时候推的」 - 是偶尔才问的,合成一个会让常用动作多一步 */} - - {derivedPanel && kb && ( - setDerivedPanel(false)} - /> - )} -
- )} - + {/* 右上:能调「画多少个」+ 统计。**统计说的正是这个数** + (「画了 150 个,共 548 个」),把调节放在它旁边,改的是谁一目了然。 + 外壳保持中性——这一片是 chrome,彩色只属于数据 */} +
+
+ + {/* **画满了就别再给「多画」**:库里一共就这么多,再调高什么也不会变, + 而一个点了没反应的按钮比没有这个按钮更糟 */} + +
- {stabilizing && ( - {S.graph.stabilizing} · - )} - {/* 画满上限时说清「画了多少 / 共多少」。**这个数从前是上限冒充规模**—— - 一个上万实体的库右上角永远写着 150 */} - {capped ? ( - - {S.graph.statsCapped( - nodeCount, - totalNodes, - totalEdges, - timeT === null ? edgeCount : activeCount, - )} - - ) : ( - S.graph.stats( + {stabilizing && ( + {S.graph.stabilizing} · + )} + {/* 画满上限时说清「画了多少 / 共多少」。**这个数从前是上限冒充规模**—— + 一个上万实体的库右上角永远写着 150 */} + {capped ? ( + + {S.graph.statsCapped( nodeCount, - edgeCount, + totalNodes, + totalEdges, timeT === null ? edgeCount : activeCount, - ) - )} + )} + + ) : ( + S.graph.stats( + nodeCount, + edgeCount, + timeT === null ? edgeCount : activeCount, + ) + )}
@@ -1273,8 +1241,68 @@ export function Graph() {
- {/* 左下控件塔:布局切换 + 相机(右下归实体侧栏,底部中央归时间岛) */} + {/* 左下控件塔:推出来的边 + 布局切换 + 相机(右下归实体侧栏,底部中央归时间岛) */}
+ {/* 推出来的边:**自成一组,也不进类型图例。** + 图例回答「显示哪些类」,一排全是本体里的类;这个回答的是 + 「显不显示推出来的边」——不是同一个问题。为零时整组不出现。 + + **摆到这座塔上,是绕开一对矛盾走的**:放在顶栏图例旁边,它长得 + 像第 10 个类;想靠颜色把它区分开,又撞上这文件开头那条既定原则 + ——「chrome 零色偏、彩色只属于数据」(见调色板那段注释)。 + 往框架里塞一块高饱和金底,是整个界面唯一的彩色色块,扎眼且不成体系。 + + 这座塔本来就是「视图怎么看」的地盘(布局、缩放), + 「显不显示推出来的边」正是同一族问题。外壳保持中性, + 金色只出现在图标本身——与色点用在类胶囊上是同一个做法。 */} + {derivedCount > 0 && ( + /* **两层**:外层只负责定位,内层才有 overflow-hidden。 + 那个类是给按钮堆裁圆角的,可面板是同一个盒子的子元素—— + 合成一层的话面板会被一起裁掉,实测只剩塔本身那 32px 宽 */ +
+
+ +
+ {/* 展开成一个小窗:这批边是什么时候推的、现在还推不推、手动再跑一次。 + **与开关分成两个按钮**——「藏起来」是每天要点的,「什么时候推的」 + 是偶尔才问的,合成一个会让常用动作多一步 */} + +
+ {derivedPanel && kb && ( + setDerivedPanel(false)} + /> + )} +
+ )}
{( [ @@ -1677,10 +1705,10 @@ function DerivedPanel({ ? Math.round((Date.now() - new Date(last).getTime()) / 60000) : null; - // **right-0 而不是 left-0**:这一组现在贴着顶栏右缘, - // 左对齐的 w-72 会整块溢出到视口外 + // **从塔的右侧展开**:塔在左下角贴着边,往下或往左都出视口; + // bottom-0 对齐让面板与那一组齐底,不会盖住下面的缩放按钮 return ( -
+
{S.graph.derivedPanel} From 2237e6ce138b8995ba2c51093b64640c6edb34ac Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Mon, 31 Aug 2026 22:37:43 +0800 Subject: [PATCH 04/32] Only, and a way back Co-Authored-By: Claude Opus 5 --- web/src/i18n/en.ts | 2 + web/src/i18n/zh.ts | 2 + web/src/pages/Graph.tsx | 88 ++++++++++++++++++++++++++++------------- 3 files changed, 65 insertions(+), 27 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 3f0b4ed6c..b59a3ce3a 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -553,6 +553,8 @@ export const en = { nodeBudgetLess: "Draw fewer", legendSearch: "Filter classes", legendNone: "No class matches", + legendOnly: "Only", + legendShowAll: (n: number) => `Show all (${n} hidden)`, legendAllHint: "Every class on screen, most common first. Click to show or hide.", searchMore: (n: number) => `${n} more — load 20`, diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index d518e5da1..b08ff55cd 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -518,6 +518,8 @@ export const zh: Strings = { nodeBudgetLess: "少画一些", legendSearch: "筛选类", legendNone: "没有匹配的类", + legendOnly: "只看", + legendShowAll: (n: number) => `显示全部(隐藏了 ${n} 个)`, legendAllHint: "画面上的全部类,按出现次数排。点一下显示或隐藏。", searchMore: (n: number) => `还有 ${n} 个——再加载 20`, zoomIn: "放大", diff --git a/web/src/pages/Graph.tsx b/web/src/pages/Graph.tsx index c76c269cf..db4709d72 100644 --- a/web/src/pages/Graph.tsx +++ b/web/src/pages/Graph.tsx @@ -1090,6 +1090,17 @@ export function Graph() { ))} + {/* 复位。**只要存在隐藏就给一步到位的出口**——「只看」很容易把 + 画面收得很窄,没有这个就得挨个点回来 */} + {hiddenTypes.size > 0 && ( + + )} + {legendRest.length > 0 && (
+ {/* 「只看这个」:类一多时最想要的动作。**给显式按钮而不是 + 修饰键**——alt+点击没人猜得到,这里横向有地方 */} + + {t.count} - +
))} {types.every( ([, t]) => From b0895c6a2901ef859dd1760590c894183adfb872 Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Mon, 31 Aug 2026 22:42:41 +0800 Subject: [PATCH 05/32] The ring says which node, not which colour we picked Co-Authored-By: Claude Opus 5 --- web/src/pages/Graph.tsx | 41 ++++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/web/src/pages/Graph.tsx b/web/src/pages/Graph.tsx index db4709d72..6d9ce3784 100644 --- a/web/src/pages/Graph.tsx +++ b/web/src/pages/Graph.tsx @@ -15,6 +15,7 @@ import { ChevronRight, CircleDashed, Grape, + Loader2, Maximize2, Orbit, Pause, @@ -45,8 +46,19 @@ const NODE_CORE_BASE = "#767676"; // 节点核心灰(原 #5A7A9E 的中性化 const NODE_BORDER_BASE = "#909090"; // 节点描边(原 #7A92AE 的中性化) const NODE_TINT_MIX = 0.14; // 类型色只按 14% 混入外壳(高级感的关键) const NODE_CORE_MIX = 0.5; // 核心向类型色的混入比例 -const RING_SELECTED = "#E7C57C"; // 选中金环 -const RING_HOVERED = "#8FE7FF"; // 悬停冰青环 +/* 状态环取**节点自己的类型色**,不是两个写死的色相。 + + 换掉的直接原因是一次撞色:原来的选中金环 `#E7C57C` 就是 + `rgb(231,197,124)`,与 `EDGE_COLOR_DERIVED` 逐位相同——「这个节点被选中了」 + 和「这条边是推出来的」用同一个颜色说话,而这两件事毫无关系。 + 金色现在专属于「推出来的」。 + + 往白里混而不是直接用原色:环画在节点自己身上,同色同亮度就看不出是个环。 + **悬停混得更白、选中混得更少**——悬停时全图不压暗,环要在一片乱线里 + 立刻跳出来;选中时其余都压暗了,节点本来就孤立着,这时候环该说的是 + 「它是谁」,所以更贴近它自己的颜色。 */ +const RING_HOVER_MIX = 0.7; // 悬停:偏白,为的是跳出来 +const RING_SELECT_MIX = 0.35; // 选中:偏本色,为的是认得出 const EDGE_COLOR = "rgba(163,163,163,0.2)"; // 纯灰(应用户要求,不用钢蓝) // 本体没认下的关系:同色更淡。名字来自原文,不该跟词表里的关系看着一样重 const EDGE_COLOR_INFERRED = "rgba(163,163,163,0.1)"; @@ -733,6 +745,8 @@ export function Graph() { const f = filterRef.current; const res = { ...attrs }; const base = attrs.size as number; + // 状态环取节点自己的类型色(见 RING_*_MIX 处的理由) + const ownColor = (attrs.typeColor as string) ?? NODE_CORE_BASE; if (f.hiddenTypes.has(attrs.typeKey as string)) { res.hidden = true; return res; @@ -750,7 +764,7 @@ export function Graph() { // hover 只提亮自身(不压暗全图);压暗聚焦只属于点击选中 if (hoverRef.current === node) { res.size = Math.max(base * 1.08, 10.4); - res.ringColor = RING_HOVERED; + res.ringColor = mix(ownColor, "#ffffff", RING_HOVER_MIX); // 悬浮卡接管标签展示;label 本身保留(悬浮卡靠它渲染标题) res.hideBaseLabel = true; res.zIndex = 4; @@ -764,7 +778,7 @@ export function Graph() { if (sel) { if (node === sel) { res.size = Math.max(base * 1.02, 9.2); - res.ringColor = RING_SELECTED; + res.ringColor = mix(ownColor, "#ffffff", RING_SELECT_MIX); res.forceLabel = true; res.zIndex = 3; return res; @@ -1209,8 +1223,9 @@ export function Graph() { {/* 右上:能调「画多少个」+ 统计。**统计说的正是这个数** (「画了 150 个,共 548 个」),把调节放在它旁边,改的是谁一目了然。 外壳保持中性——这一片是 chrome,彩色只属于数据 */} -
-
+
+
+
- {stabilizing && ( - {S.graph.stabilizing} · - )} {/* 画满上限时说清「画了多少 / 共多少」。**这个数从前是上限冒充规模**—— 一个上万实体的库右上角永远写着 150 */} {capped ? ( @@ -1265,7 +1277,18 @@ export function Graph() { timeT === null ? edgeCount : activeCount, ) )} +
+ {/* **单独一行,不做统计文字的前缀。** + 当前缀时它一出现就把整块撑宽,而这一块是靠右的—— + 于是每次重新布局,左边的档位按钮都会被挤着跳一下。 + 自己占一行,第一行的宽度就不再随它变 */} + {stabilizing && ( +
+ + {S.graph.stabilizing} +
+ )}
From 55e935f6262330a9c4f3864ab07f10654e31210b Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Mon, 31 Aug 2026 22:52:31 +0800 Subject: [PATCH 06/32] Two idioms in one sentence made the count look broken Co-Authored-By: Claude Opus 5 --- web/src/i18n/en.ts | 14 +++- web/src/i18n/zh.ts | 14 +++- web/src/pages/Graph.tsx | 181 +++++++++++++++++++++++++++++++--------- 3 files changed, 160 insertions(+), 49 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index b59a3ce3a..42b55c85b 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -675,12 +675,18 @@ export const en = { /* 必须跟 ongoing 看得出区别:混淆这两个正是迁移 0046 要修的东西—— 原文说 "former CEO",界面却显示 now */ endedUnknown: "ended, date unknown", - stats: (n: number, e: number, active: number) => - `${n} entities · ${e} facts · ${active} active`, + stats: (n: number, e: number, active: number | null) => + `${n} entities · ${e} facts${active === null ? "" : ` · ${active} active`}`, /** 画布只画度数最高的一批。**说清楚画了多少、共多少**——从前这里写的是 * 上限,一个上万实体的库右上角永远是 150 */ - statsCapped: (shown: number, total: number, e: number, active: number) => - `showing ${shown} of ${total} entities · ${e} facts · ${active} active`, + statsCapped: ( + shown: number, + total: number, + shownE: number, + totalE: number, + active: number | null, + ) => + `showing ${shown} of ${total} entities · ${shownE} of ${totalE} facts${active === null ? "" : ` · ${active} active`}`, cappedHint: (shown: number, total: number) => `The canvas draws the ${shown} best-connected entities of ${total}. Search to reach the rest.`, stabilizing: "Stabilizing layout", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index b08ff55cd..c34ef3ab3 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -619,10 +619,16 @@ export const zh: Strings = { "被取代的那条断言仍留在台账里。", ongoing: "至今", endedUnknown: "已结束(时间不详)", - stats: (n: number, e: number, active: number) => - `${n} 个实体 · ${e} 条事实 · ${active} 条现行`, - statsCapped: (shown: number, total: number, e: number, active: number) => - `已画 ${shown} / 共 ${total} 个实体 · ${e} 条事实 · ${active} 条现行`, + stats: (n: number, e: number, active: number | null) => + `${n} 个实体 · ${e} 条事实${active === null ? "" : ` · ${active} 条现行`}`, + statsCapped: ( + shown: number, + total: number, + shownE: number, + totalE: number, + active: number | null, + ) => + `已画 ${shown} / 共 ${total} 个实体 · ${shownE} / 共 ${totalE} 条事实${active === null ? "" : ` · ${active} 条现行`}`, cappedHint: (shown: number, total: number) => `画布只画连接最密的 ${shown} 个,库里共 ${total} 个。其余的用搜索找。`, stabilizing: "布局收敛中", diff --git a/web/src/pages/Graph.tsx b/web/src/pages/Graph.tsx index 6d9ce3784..8c4dcea0a 100644 --- a/web/src/pages/Graph.tsx +++ b/web/src/pages/Graph.tsx @@ -21,7 +21,7 @@ import { Pause, Pencil, Play, - Sparkles, + Waypoints, X, ZoomIn, ZoomOut, @@ -1263,18 +1263,23 @@ export function Graph() { 一个上万实体的库右上角永远写着 150 */} {capped ? ( + {/* **事实也用「已画 / 共」的口径**:从前这里给的是库里的总数, + 而实体给的是「画了多少 / 共多少」——同一句话里两套口径, + 于是调档位时实体数在变、事实数纹丝不动,看着像坏了。 + 没有时间筛选时 active 恒等于已画条数,那就不说 */} {S.graph.statsCapped( nodeCount, totalNodes, + edgeCount, totalEdges, - timeT === null ? edgeCount : activeCount, + timeT === null ? null : activeCount, )} ) : ( S.graph.stats( nodeCount, edgeCount, - timeT === null ? edgeCount : activeCount, + timeT === null ? null : activeCount, ) )}
@@ -1332,7 +1337,7 @@ export function Graph() { showDerived ? { color: "rgba(231,197,124,0.95)" } : undefined } > - +
{/* 展开成一个小窗:这批边是什么时候推的、现在还推不推、手动再跑一次。 @@ -1826,47 +1831,77 @@ function DerivedPanel({ ); } +/** 推出来的一条边。**行式样与 FactRow 对齐**:同样的圆角行、同样的 + * chevron 展开、同样的 role="link" 跳转(避免按钮套按钮)。 + * + * 从前这里是一张 `glass rounded-xl p-3` 卡片、证明常驻展开——在一列 + * Relations/Timeline/History 的紧凑行里显得是另一个产品的东西,而且十几条 + * 推导堆起来是一面墙。证明是「问了才看」的东西,收进展开区正合适。 */ function DerivedRow({ d, + otherId, + otherName, + open, + onToggle, onNavigate, }: { d: DerivedFact; + otherId: string; + otherName: string; + open: boolean; + onToggle: () => void; onNavigate: (entityId: string) => void; }) { return ( -
-
- - {d.predicate} - - - {d.rule === "transitive" - ? S.graph.ruleTransitive - : S.graph.ruleSymmetric} + {otherName} -
- {/* 证明:前提按推导顺序,缩进一格。看得出链是怎么走的 */} -
    - {d.premises.map((p, i) => ( -
  1. - {p} -
  2. - ))} -
- {d.premises.length === 0 && ( -

- {S.graph.derivedNoProof} -

+ + {d.premises.length} + + + {/* 证明:前提按推导顺序。**边框与 EvidenceList 同一档**—— + 两者是同一件事的两种形态:一个给出处,一个给推理链 */} + {open && ( +
+
    + {d.premises.map((p, i) => ( +
  1. + {p} +
  2. + ))} +
+ {d.premises.length === 0 && ( +

+ {S.graph.derivedNoProof} +

+ )} +
)}
); @@ -1891,6 +1926,33 @@ function EntityPanel({ // 推出来的那些。**单独一个键,不掺进 facts**——混在一个列表里,用户看不出 // 「文档里写的」和「引擎推的」的区别 const derived = detail.data?.derived ?? []; + /* 按「方向 + 谓词 + 规则」分组,骨架与 Relations 的 groups 一致。 + 规则挂在组上而不是每一行:它对整组都成立,逐行重复既冗余, + 那个琥珀色小字还会跟派生边抢色相 */ + const derivedGroups = useMemo(() => { + const map = new Map< + string, + { + key: string; + direction: "in" | "out"; + predicate: string; + rule: string; + rows: DerivedFact[]; + } + >(); + for (const d of derived) { + const direction = d.subject_id === entityId ? "out" : "in"; + const rule = + d.rule === "transitive" + ? S.graph.ruleTransitive + : S.graph.ruleSymmetric; + const key = `${direction}|${d.predicate}|${d.rule}`; + const cur = map.get(key); + if (cur) cur.rows.push(d); + else map.set(key, { key, direction, predicate: d.predicate, rule, rows: [d] }); + } + return [...map.values()]; + }, [derived, entityId]); // Relations = 按关系分组(查关系);Timeline = 有效时间轴(事情何时成立); // History = 记录时间轴(我们何时这么认为、又何时改了主意) const [view, setView] = useState< @@ -2264,15 +2326,52 @@ function EntityPanel({ {view === "history" && ( )} - {view === "derived" && ( -
-

+{view === "derived" && ( + <> +

{S.graph.derivedHint}

- {derived.map((d) => ( - + {/* **与 Relations 同一个骨架**:方向箭头 + 谓词 + 条数的小标题, + 底下是紧凑行。规则(传递/对称)并进标题——它对整组都成立, + 挂在每一行上是重复,而且那个 `--u-warn` 琥珀色又是一处 + 与派生边抢色相的地方 */} + {derivedGroups.map((gr) => ( +
+
+ {gr.direction === "in" ? ( + + ) : ( + + )} + {gr.predicate} + {gr.rule} + {gr.rows.length > 1 && ( + + {gr.rows.length} + + )} +
+
+ {gr.rows.map((d) => { + const out = d.subject_id === entityId; + return ( + + setOpenFact(openFact === d.id ? null : d.id) + } + onNavigate={onNavigate} + /> + ); + })} +
+
))} -
+ )} {view !== "history" && view !== "derived" && From 7617c4fec9f225be799a42377fe07044fc7bccc0 Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Mon, 31 Aug 2026 23:05:18 +0800 Subject: [PATCH 07/32] Say how long one step is, and let it be chosen Co-Authored-By: Claude Opus 5 --- web/src/i18n/en.ts | 5 ++ web/src/i18n/zh.ts | 5 ++ web/src/pages/Graph.tsx | 150 +++++++++++++++++++++++++++++++++------- 3 files changed, 136 insertions(+), 24 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 42b55c85b..e413a0762 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -690,6 +690,11 @@ export const en = { cappedHint: (shown: number, total: number) => `The canvas draws the ${shown} best-connected entities of ${total}. Search to reach the rest.`, stabilizing: "Stabilizing layout", + scrubUnitHint: "Step size for playback and for each bar", + scrubUnitYear: "Yr", + scrubUnitMonth: "Mo", + scrubUnitDay: "Dy", + scrubBarMerged: (n: number) => `each bar covers ${n} steps`, allTime: "All time", nowBtn: "Now", play: "Play timeline", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index c34ef3ab3..7988cdc98 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -632,6 +632,11 @@ export const zh: Strings = { cappedHint: (shown: number, total: number) => `画布只画连接最密的 ${shown} 个,库里共 ${total} 个。其余的用搜索找。`, stabilizing: "布局收敛中", + scrubUnitHint: "播放的步长,也是每根柱子的跨度", + scrubUnitYear: "年", + scrubUnitMonth: "月", + scrubUnitDay: "日", + scrubBarMerged: (n: number) => `每根柱子含 ${n} 步`, allTime: "全部时间", nowBtn: "现在", play: "播放时间线", diff --git a/web/src/pages/Graph.tsx b/web/src/pages/Graph.tsx index 8c4dcea0a..00c6dcced 100644 --- a/web/src/pages/Graph.tsx +++ b/web/src/pages/Graph.tsx @@ -98,7 +98,6 @@ const PILL_BORDER = "rgba(255,255,255,0.14)"; const PILL_TEXT = "#ededed"; const TRANSPARENT = "rgba(0,0,0,0)"; const DAY_MS = 24 * 3600 * 1000; -const MONTH_MS = 30 * DAY_MS; function hexToRgb(hex: string): [number, number, number] { const m = /^#?([0-9a-f]{6})$/i.exec(hex); @@ -1483,6 +1482,33 @@ function scrubValueAt( return Math.min(maxTs, minTs + Math.round((raw - minTs) / DAY_MS) * DAY_MS); } +/** 播放/柱子的步长。**这两件事本来就该是同一个单位**——从前柱子按年、 + * 播放按天,界面上没有任何地方说得出「一格是多久」。 */ +type ScrubUnit = "year" | "month" | "day"; + +/** 一根柱子最多画多少根。超过就把相邻的桶并起来画——**只影响画,不影响 + * 播放步长**:日单位下 15 年有五千多个桶,一根一像素也画不下, + * 但播放仍然是一天一步。并了几个会在提示里说出来,不闷着 */ +const SCRUB_MAX_BARS = 220; +/** 整条轨走完的目标时长。**与单位无关**——单位换的是颗粒度与密度, + * 不该顺带把「等多久」也换掉:日单位若按「一天一拍」走,15 年要放二十分钟 */ +const SCRUB_PLAY_MS = 18000; + +function bucketStart(ts: number, unit: ScrubUnit): number { + const d = new Date(ts); + if (unit === "year") return Date.UTC(d.getUTCFullYear(), 0, 1); + if (unit === "month") + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +} +function bucketNext(ts: number, unit: ScrubUnit): number { + const d = new Date(ts); + if (unit === "year") return Date.UTC(d.getUTCFullYear() + 1, 0, 1); + if (unit === "month") + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1); + return ts + DAY_MS; +} + function TimeScrubber({ edges, value, @@ -1498,10 +1524,12 @@ function TimeScrubber({ onPlayingChange: (v: boolean) => void; }) { const setPlaying = onPlayingChange; + /* 默认年:**大多数库跨度都以年计**,一进来先给能一眼看全的那一档 */ + const [unit, setUnit] = useState("year"); const trackRef = useRef(null); const draggingRef = useRef(false); - const { minTs, maxTs, bars } = useMemo(() => { + const { minTs, maxTs, bars, merged, trackW } = useMemo(() => { const now = Date.now(); const froms = edges .map((e) => (e.valid_from ? Date.parse(e.valid_from) : NaN)) @@ -1509,26 +1537,49 @@ function TimeScrubber({ const min = froms.length ? Math.min(...froms) : now - 5 * 365 * 24 * 3600 * 1000; - const minYear = new Date(min).getUTCFullYear(); - const maxYear = new Date(now).getUTCFullYear(); + // 起点对齐到单位边界:否则第一根柱子是半格,读起来像数据缺了一块 + const start = bucketStart(min, unit); + const counts = new Map(); for (const t of froms) { - const y = new Date(t).getUTCFullYear(); - counts.set(y, (counts.get(y) ?? 0) + 1); + const k = bucketStart(t, unit); + counts.set(k, (counts.get(k) ?? 0) + 1); } - const peak = Math.max(1, ...counts.values()); - const bars: { year: number; h: number }[] = []; - for (let y = minYear; y <= maxYear; y++) { - bars.push({ year: y, h: (counts.get(y) ?? 0) / peak }); + const raw: { ts: number; n: number }[] = []; + for (let t = start; t <= now; t = bucketNext(t, unit)) + raw.push({ ts: t, n: counts.get(t) ?? 0 }); + + // 画不下就并桶。**并的是画,不是步长** + const group = Math.max(1, Math.ceil(raw.length / SCRUB_MAX_BARS)); + const cells: { ts: number; n: number }[] = []; + for (let i = 0; i < raw.length; i += group) { + const slice = raw.slice(i, i + group); + cells.push({ + ts: slice[0].ts, + n: slice.reduce((a, b) => a + b.n, 0), + }); } - return { minTs: Date.UTC(minYear, 0, 1), maxTs: now, bars }; - }, [edges]); + const peak = Math.max(1, ...cells.map((c) => c.n)); + + // 单位越大 → 桶越少 → 轨道越短;越小 → 越长越密。上下都夹住: + // 太短点不准,太长会顶到画布两边 + const w = Math.min(760, Math.max(320, cells.length * 3 + 120)); + + return { + minTs: start, + maxTs: now, + bars: cells.map((c) => ({ ts: c.ts, h: c.n / peak, n: c.n })), + merged: group, + trackW: w, + }; + }, [edges, unit]); // 播放按日推进(数据即 day 精度),日子快速翻过;整体节奏仍 ≈ 一个月/260ms。 // rAF 时间驱动:帧率无关,内部浮点累加避免取整漂移,值只在跨天时才下发 useEffect(() => { if (!playing) return; - const SPEED = MONTH_MS / 260; // 每毫秒真实时间推进的时间线毫秒数 + // 整条走完约 SCRUB_PLAY_MS,与单位无关;单位只决定落点取整到哪一格 + const SPEED = (maxTs - minTs) / SCRUB_PLAY_MS; let raf = 0; let last = performance.now(); let acc = value ?? minTs; @@ -1541,7 +1592,7 @@ function TimeScrubber({ onChange(null); return; } - const snapped = minTs + Math.round((acc - minTs) / DAY_MS) * DAY_MS; + const snapped = bucketStart(acc, unit); if (snapped !== lastSnapped) { lastSnapped = snapped; onChange(snapped); @@ -1552,7 +1603,7 @@ function TimeScrubber({ return () => cancelAnimationFrame(raf); // 只随播放开关重启:acc 在循环内自持,value 帧帧变不应重建循环 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playing, minTs, maxTs]); + }, [playing, minTs, maxTs, unit]); // 展示到日:与数据的 day 级 valid_precision 对齐 const label = (() => { @@ -1560,17 +1611,37 @@ function TimeScrubber({ const d = new Date(value); const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); const dd = String(d.getUTCDate()).padStart(2, "0"); + // 精度跟着单位:年单位下写出「2019-01-01」是假精确 + if (unit === "year") return `${d.getUTCFullYear()}`; + if (unit === "month") return `${d.getUTCFullYear()}-${mm}`; return `${d.getUTCFullYear()}-${mm}-${dd}`; })(); - const minYear = bars[0]?.year; - const maxYear = bars[bars.length - 1]?.year; + const minYear = bars.length + ? new Date(bars[0].ts).getUTCFullYear() + : undefined; + const maxYear = bars.length + ? new Date(bars[bars.length - 1].ts).getUTCFullYear() + : undefined; return ( -
+ /* 宽度随单位变:单位大 → 桶少 → 短;单位小 → 桶多 → 长而密。 + 仍夹在视口内(calc 那一项),窄屏不会顶出去。 + + **别给它加 `transition-[width]`**:加了之后宽度会卡在旧值上一直不动, + 连 `width: …px !important` 都推不动(同一容器里放个同宽的探针 div 却是对的)。 + 这组件每秒重渲很多次,过渡似乎每帧都被重新起头。实测:去掉过渡后 + 年 320 / 月 648 / 日 760,立刻就对。 */ +
+ {/* 步长。**播放与柱子共用它**——从前柱子按年、播放按天, + 界面上没有一处说得出「一格是多久」 */} +
+ {(["year", "month", "day"] as const).map((u) => ( + + ))} +
+ {minYear} @@ -1590,14 +1686,20 @@ function TimeScrubber({ >
{bars.map((b) => { - // 进入即亮(年初为判据):播放头脚下的柱子即已覆盖——进度条通用语义 - const barTs = Date.UTC(b.year, 0, 1); - const past = value !== null && barTs <= value; + // 进入即亮(桶起点为判据):播放头脚下的柱子即已覆盖——进度条通用语义 + const past = value !== null && b.ts <= value; + const d = new Date(b.ts); + const stamp = + unit === "year" + ? `${d.getUTCFullYear()}` + : unit === "month" + ? `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}` + : d.toISOString().slice(0, 10); return (
1 ? ` · ${S.graph.scrubBarMerged(merged)}` : ""}`} >
Date: Mon, 31 Aug 2026 23:16:37 +0800 Subject: [PATCH 08/32] A tower of icons should be able to say its own names Co-Authored-By: Claude Opus 5 --- web/src/pages/Graph.tsx | 44 +++++++++++++++++++++++++---------------- web/src/styles.css | 34 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/web/src/pages/Graph.tsx b/web/src/pages/Graph.tsx index 00c6dcced..121bc0c20 100644 --- a/web/src/pages/Graph.tsx +++ b/web/src/pages/Graph.tsx @@ -1321,13 +1321,13 @@ export function Graph() { 那个类是给按钮堆裁圆角的,可面板是同一个盒子的子元素—— 合成一层的话面板会被一起裁掉,实测只剩塔本身那 32px 宽 */
-
+
{/* 展开成一个小窗:这批边是什么时候推的、现在还推不推、手动再跑一次。 @@ -1346,13 +1347,16 @@ export function Graph() { onClick={() => setDerivedPanel((v) => !v)} title={S.graph.derivedPanel} aria-expanded={derivedPanel} - className={`p-2 text-[11px] leading-none transition-colors ${ + className={`flex items-center gap-2 p-2 text-[11px] leading-none transition-colors ${ derivedPanel ? "text-white bg-white/[0.1]" : "text-neutral-400 hover:text-white hover:bg-white/[0.06]" }`} > - ⋯ + + ⋯ + + {S.graph.derivedPanel}
{derivedPanel && kb && ( @@ -1364,7 +1368,7 @@ export function Graph() { )}
)} -
+
{( [ { key: "force", Icon: Orbit, label: S.graph.layoutForce }, @@ -1384,34 +1388,37 @@ export function Graph() { layoutModeRef.current = key; layoutCtlRef.current?.apply(key); }} - className={`p-2 transition-colors ${ + className={`flex items-center gap-2 p-2 transition-colors ${ layoutMode === key ? "text-white bg-white/[0.1]" : "text-neutral-400 hover:text-white hover:bg-white/[0.06]" }`} > + {label} ))}
-
+
@@ -1627,13 +1635,9 @@ function TimeScrubber({ return ( /* 宽度随单位变:单位大 → 桶少 → 短;单位小 → 桶多 → 长而密。 仍夹在视口内(calc 那一项),窄屏不会顶出去。 - - **别给它加 `transition-[width]`**:加了之后宽度会卡在旧值上一直不动, - 连 `width: …px !important` 都推不动(同一容器里放个同宽的探针 div 却是对的)。 - 这组件每秒重渲很多次,过渡似乎每帧都被重新起头。实测:去掉过渡后 - 年 320 / 月 648 / 日 760,立刻就对。 */ + 实测宽度:年 320 / 月 648 / 日 760。 */
- {legendPanel && ( -
+ {legendPop.open && ( +
+ {/* 面板盖在 chip 原位,所以**第一行就长成那个 chip 的样子**, + 点它收回去——「哪儿展开的就从哪儿收回去」, + 与通知/用户卡片的关闭键跟触发键原位重合是同一个道理 */} + () { +export function usePopoverFlip( + /** 变形的锚点角。**面板贴哪边就写哪边**:顶栏右侧的面板贴右上角, + * 贴左边的面板(比如图例的「+N 个类」)要写 "top left", + * 否则它会从右边缘往左长出来,看着像从别处飞过来的 */ + origin: "top right" | "top left" = "top right", +) { const [open, setOpen] = useState(false); const rootRef = useRef(null); const anchorRef = useRef(null); @@ -33,7 +38,7 @@ export function usePopoverFlip() { const a = anchor.getBoundingClientRect(); const p = panel.getBoundingClientRect(); if (p.width < 1 || p.height < 1) return; - panel.style.transformOrigin = "top right"; + panel.style.transformOrigin = origin; panel.style.transform = `scale(${a.width / p.width}, ${a.height / p.height})`; panel.style.borderRadius = "999px"; panel.style.opacity = "0.35"; @@ -61,7 +66,7 @@ export function usePopoverFlip() { cancelAnimationFrame(raf); if (done !== undefined) window.clearTimeout(done); }; - }, [open]); + }, [open, origin]); const close = () => { const panel = panelRef.current; @@ -72,6 +77,7 @@ export function usePopoverFlip() { return; } closingRef.current = true; + panel.style.transformOrigin = origin; const a = anchor.getBoundingClientRect(); // offsetWidth/Height 是布局尺寸,不受当前 transform 影响—— // 用 getBoundingClientRect 会拿到已经缩过的值,越缩越小 From 98404cd428581ac43b163e55eb20a01649afb84d Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Mon, 31 Aug 2026 23:40:58 +0800 Subject: [PATCH 10/32] One group expands, the others keep their squares Co-Authored-By: Claude Opus 5 --- web/src/pages/Graph.tsx | 17 ++++++++++------- web/src/styles.css | 8 ++++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/web/src/pages/Graph.tsx b/web/src/pages/Graph.tsx index 4376e671e..9a31f61f0 100644 --- a/web/src/pages/Graph.tsx +++ b/web/src/pages/Graph.tsx @@ -1324,7 +1324,10 @@ export function Graph() {
{/* 左下控件塔:推出来的边 + 布局切换 + 相机(右下归实体侧栏,底部中央归时间岛) */} -
+ {/* **items-start**:列内项目默认 stretch,一组展开就会把其余几组 + 一起拉到同宽——那几组的字还收着,于是看着是几个莫名其妙的空白长条。 + 各自按内容收放,才是「一组一组展开,不牵连别人」 */} +
{/* 推出来的边:**自成一组,也不进类型图例。** 图例回答「显示哪些类」,一排全是本体里的类;这个回答的是 「显不显示推出来的边」——不是同一个问题。为零时整组不出现。 @@ -1348,7 +1351,7 @@ export function Graph() { role="switch" aria-checked={showDerived} title={`${S.graph.derivedEdges(derivedCount)} · ${S.graph.derivedHint}`} - className={`flex items-center gap-2 p-2 transition-colors ${ + className={`flex items-center p-2 transition-colors ${ showDerived ? "bg-white/[0.1]" : "text-neutral-500 hover:bg-white/[0.06]" @@ -1368,7 +1371,7 @@ export function Graph() { onClick={() => setDerivedPanel((v) => !v)} title={S.graph.derivedPanel} aria-expanded={derivedPanel} - className={`flex items-center gap-2 p-2 text-[11px] leading-none transition-colors ${ + className={`flex items-center p-2 text-[11px] leading-none transition-colors ${ derivedPanel ? "text-white bg-white/[0.1]" : "text-neutral-400 hover:text-white hover:bg-white/[0.06]" @@ -1409,7 +1412,7 @@ export function Graph() { layoutModeRef.current = key; layoutCtlRef.current?.apply(key); }} - className={`flex items-center gap-2 p-2 transition-colors ${ + className={`flex items-center p-2 transition-colors ${ layoutMode === key ? "text-white bg-white/[0.1]" : "text-neutral-400 hover:text-white hover:bg-white/[0.06]" @@ -1426,7 +1429,7 @@ export function Graph() { onClick={() => sigmaRef.current?.getCamera().animatedZoom({ duration: 220 }) } - className="flex items-center gap-2 p-2 text-neutral-400 hover:text-white hover:bg-white/[0.06] transition-colors" + className="flex items-center p-2 text-neutral-400 hover:text-white hover:bg-white/[0.06] transition-colors" > {S.graph.zoomIn} @@ -1436,7 +1439,7 @@ export function Graph() { onClick={() => sigmaRef.current?.getCamera().animatedUnzoom({ duration: 220 }) } - className="flex items-center gap-2 p-2 text-neutral-400 hover:text-white hover:bg-white/[0.06] transition-colors" + className="flex items-center p-2 text-neutral-400 hover:text-white hover:bg-white/[0.06] transition-colors" > {S.graph.zoomOut} @@ -1447,7 +1450,7 @@ export function Graph() { onClick={() => sigmaRef.current?.getCamera().animatedReset({ duration: 300 }) } - className="flex items-center gap-2 p-2 text-neutral-400 hover:text-white hover:bg-white/[0.06] transition-colors" + className="flex items-center p-2 text-neutral-400 hover:text-white hover:bg-white/[0.06] transition-colors" > {S.graph.fitView} diff --git a/web/src/styles.css b/web/src/styles.css index 7c72e0262..dd2815777 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -724,17 +724,25 @@ a:hover > .u-mark-arrow { opacity: 0; font-size: 11px; line-height: 1; + /* 图标与文字之间的空当用 **margin**,既不能用按钮上的 gap,也不能用 padding: + gap 对宽度为 0 的项目照样生效;而 padding 不会被 max-width 压缩 + (border-box 下宽度最小就等于那点 padding)——两种写法都会让收起态 + 凭空多出 8px,方块就不方了。实测 39x31,改成 margin 后才是 31x31 */ + margin-left: 0; transition: max-width 220ms ease, + margin-left 220ms ease, opacity 160ms ease; } .u-tower:hover .u-tower-label { max-width: 9rem; + margin-left: 0.5rem; opacity: 1; } /* 键盘走焦点时也要出来:只认 hover 的话,用键盘的人永远看不到名字 */ .u-tower:focus-within .u-tower-label { max-width: 9rem; + margin-left: 0.5rem; opacity: 1; } @media (prefers-reduced-motion: reduce) { From 16d47829978df6d0844ee62a50b9ffb90faf21a8 Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Mon, 31 Aug 2026 23:45:55 +0800 Subject: [PATCH 11/32] A track squeezed to nothing shows no bars at all Co-Authored-By: Claude Opus 5 --- web/src/pages/Graph.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/web/src/pages/Graph.tsx b/web/src/pages/Graph.tsx index 9a31f61f0..66767e883 100644 --- a/web/src/pages/Graph.tsx +++ b/web/src/pages/Graph.tsx @@ -1593,9 +1593,14 @@ function TimeScrubber({ } const peak = Math.max(1, ...cells.map((c) => c.n)); - // 单位越大 → 桶越少 → 轨道越短;越小 → 越长越密。上下都夹住: - // 太短点不准,太长会顶到画布两边 - const w = Math.min(760, Math.max(320, cells.length * 3 + 120)); + // 单位越大 → 桶越少 → 岛越短;越小 → 越长。**但下限要抬得够高**: + // 岛里那排固定控件(播放键 + 单位选择器 + 两个年份 + 日期 + All time/Now) + // 本身就要四百多像素,岛只有 320 时 flex-1 的轨道被压成 0—— + // 实测柱子一根都看不见,整条是空的。 + // + // 抬高之后单位主要改变的是**每根柱子的粗细**:同一条轨道, + // 年是十几根粗块,日是两百多根细线。这比整条伸缩更说明问题 + const w = Math.min(780, Math.max(660, 380 + cells.length * 2)); return { minTs: start, @@ -1710,7 +1715,7 @@ function TimeScrubber({ {/* 密度带轨道:内嵌浅色井 + 每年事实量柱 */}
{/* **间隙必须随密度收**:写死 2px 时,日单位下 216 根柱子有 215 个间隙 ≈ 430px,而轨道内宽才 ~455px——柱子被挤成 0.1px,整条看起来是空的。 From c9a46bd955ae5e4b0309f88f03bdeece4b14ea80 Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Tue, 1 Sep 2026 00:04:06 +0800 Subject: [PATCH 12/32] Time should move, not step; and what is ahead is not lit Co-Authored-By: Claude Opus 5 --- web/src/pages/Graph.tsx | 56 +++++++++++++++++++++++++++------------ web/src/ui/popoverFlip.ts | 2 +- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/web/src/pages/Graph.tsx b/web/src/pages/Graph.tsx index 66767e883..5fafa7ec7 100644 --- a/web/src/pages/Graph.tsx +++ b/web/src/pages/Graph.tsx @@ -312,7 +312,11 @@ export function Graph() { // 推出来的边显不显示。默认显示——推理默认关着,有派生就意味着用户开过开关 const [showDerived, setShowDerived] = useState(true); // 信息窗默认收起:它答的是「什么时候推的」,那是偶尔才问的问题 - const [derivedPanel, setDerivedPanel] = useState(false); + /* Inference 也用原地展开,与「+N 个类」、通知、用户菜单同一套。 + **贴左下角**:塔在画布左下,面板要从那个 ⋯ 按钮往右上长开 */ + const derivedPop = usePopoverFlip( + "bottom left", + ); /* 「+N 个类」用与通知/用户卡片同一套原地展开:面板压到 chip 的真实边界 (圆角 999px)再长成卡片。**贴左边,所以锚点角是 top left** */ const legendPop = usePopoverFlip( @@ -1344,7 +1348,7 @@ export function Graph() { /* **两层**:外层只负责定位,内层才有 overflow-hidden。 那个类是给按钮堆裁圆角的,可面板是同一个盒子的子元素—— 合成一层的话面板会被一起裁掉,实测只剩塔本身那 32px 宽 */ -
+
- {derivedPanel && kb && ( + {derivedPop.open && kb && ( setDerivedPanel(false)} + onClose={() => derivedPop.close()} /> )}
@@ -1620,7 +1628,7 @@ function TimeScrubber({ let raf = 0; let last = performance.now(); let acc = value ?? minTs; - let lastSnapped = Number.NaN; + let lastPushed = 0; const step = (now: number) => { acc += (now - last) * SPEED; last = now; @@ -1629,10 +1637,15 @@ function TimeScrubber({ onChange(null); return; } - const snapped = bucketStart(acc, unit); - if (snapped !== lastSnapped) { - lastSnapped = snapped; - onChange(snapped); + // **连续推进,不按桶跳。** 从前按 `bucketStart` 取整下发,年单位下 + // 一次就是一年——播放头一格一格蹦,看着像卡顿而不是在走。 + // 单位现在只管**显示**(标签精度、柱子跨度),不再管推进的步长。 + // + // 代价是下发变密(每帧一次),而每次下发都要重算全图的现行边, + // 所以限到 ~30fps:肉眼看不出与 60fps 的差别,重算量减半 + if (now - lastPushed >= 33) { + lastPushed = now; + onChange(Math.round(acc)); } raf = requestAnimationFrame(step); }; @@ -1744,13 +1757,17 @@ function TimeScrubber({ className="w-full rounded-[1px] transition-colors" style={{ height: `${Math.max(10, b.h * 100)}%`, - // 播放中已扫过的年份提亮,停止后回到常规亮度 + // 播放中已扫过的提亮,停止后回到常规亮度。 + // **还没走到的压到近乎不可见**:它们本来是 0.09, + // 在这个底色上仍看得清,于是播放头右边跟左边一样"亮着", + // 走到哪儿就看不出来了。留一点点而不是归零—— + // 归零等于假装那段没有数据,而它只是还没到 background: value !== null && past && playing ? "rgba(255,255,255,0.62)" : value === null || past ? "rgba(255,255,255,0.32)" - : "rgba(255,255,255,0.09)", + : "rgba(255,255,255,0.04)", }} />
@@ -1879,10 +1896,12 @@ function fmtInterval(f: EntityFact): string { * 手动按钮留在这里而不是别处:想重推的人正是刚看完这三行、觉得数字太旧的那个人。 */ function DerivedPanel({ + panelRef, kbId, count, onClose, }: { + panelRef: React.Ref; kbId: string; count: number; onClose: () => void; @@ -1908,10 +1927,13 @@ function DerivedPanel({ ? Math.round((Date.now() - new Date(last).getTime()) / 60000) : null; - // **从塔的右侧展开**:塔在左下角贴着边,往下或往左都出视口; - // bottom-0 对齐让面板与那一组齐底,不会盖住下面的缩放按钮 + // **盖在触发器原位往右上长开**(bottom-0 left-0),而不是在旁边挂一扇窗。 + // 面与圆角跟通知/用户卡片对齐:u-menu-glass + rounded-xl return ( -
+
{S.graph.derivedPanel} diff --git a/web/src/ui/popoverFlip.ts b/web/src/ui/popoverFlip.ts index 3e38bbecc..a81b00f4b 100644 --- a/web/src/ui/popoverFlip.ts +++ b/web/src/ui/popoverFlip.ts @@ -22,7 +22,7 @@ export function usePopoverFlip( /** 变形的锚点角。**面板贴哪边就写哪边**:顶栏右侧的面板贴右上角, * 贴左边的面板(比如图例的「+N 个类」)要写 "top left", * 否则它会从右边缘往左长出来,看着像从别处飞过来的 */ - origin: "top right" | "top left" = "top right", + origin: "top right" | "top left" | "bottom left" = "top right", ) { const [open, setOpen] = useState(false); const rootRef = useRef(null); From cde5bbc94bbc5a6db486009fb10b6b597fbd0637 Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Tue, 1 Sep 2026 00:10:59 +0800 Subject: [PATCH 13/32] Chrome that says it has no hue should actually have none Co-Authored-By: Claude Opus 5 --- web/src/i18n/en.ts | 1 + web/src/i18n/zh.ts | 1 + web/src/pages/Graph.tsx | 53 ++++++++++++++++++++++++++++------------- web/src/styles.css | 9 +++++-- 4 files changed, 46 insertions(+), 18 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index e413a0762..ba73ffdd1 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -625,6 +625,7 @@ export const en = { "Edges no one asserted — the engine worked them out from axioms your ontology declares. Each one shows the premises it came from.", derivedNoProof: "The premises are gone.", derivedPanel: "Inference", + derivedRunConfirm: "Confirm?", derivedCountLabel: "Edges derived", derivedStateLabel: "Schedule", derivedLastLabel: "Last run", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 7988cdc98..8caa32fa5 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -575,6 +575,7 @@ export const zh: Strings = { "没有人断言过的边——引擎按本体声明的公理推出来的。每一条都附着它用到的前提。", derivedNoProof: "前提已经不在了。", derivedPanel: "推理", + derivedRunConfirm: "确认重跑?", derivedCountLabel: "推出来的边", derivedStateLabel: "定时", derivedLastLabel: "上次推理", diff --git a/web/src/pages/Graph.tsx b/web/src/pages/Graph.tsx index 5fafa7ec7..5ecf429e8 100644 --- a/web/src/pages/Graph.tsx +++ b/web/src/pages/Graph.tsx @@ -1700,16 +1700,19 @@ function TimeScrubber({ 界面上没有一处说得出「一格是多久」 */}
{(["year", "month", "day"] as const).map((u) => ( +
); } @@ -2574,7 +2595,7 @@ function TimelineView({
{dated.map((f) => (
- + Date: Tue, 1 Sep 2026 00:14:44 +0800 Subject: [PATCH 14/32] Two clicks on one control means close, so ask on another Co-Authored-By: Claude Opus 5 --- web/src/i18n/en.ts | 4 ++- web/src/i18n/zh.ts | 4 ++- web/src/pages/Graph.tsx | 75 ++++++++++++++++++++++++++--------------- 3 files changed, 53 insertions(+), 30 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index ba73ffdd1..bdaa3d686 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -625,7 +625,9 @@ export const en = { "Edges no one asserted — the engine worked them out from axioms your ontology declares. Each one shows the premises it came from.", derivedNoProof: "The premises are gone.", derivedPanel: "Inference", - derivedRunConfirm: "Confirm?", + derivedRunAsk: "Re-run inference for the whole base?", + derivedRunGo: "Run", + derivedRunCancel: "Cancel", derivedCountLabel: "Edges derived", derivedStateLabel: "Schedule", derivedLastLabel: "Last run", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 8caa32fa5..f4e9360ee 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -575,7 +575,9 @@ export const zh: Strings = { "没有人断言过的边——引擎按本体声明的公理推出来的。每一条都附着它用到的前提。", derivedNoProof: "前提已经不在了。", derivedPanel: "推理", - derivedRunConfirm: "确认重跑?", + derivedRunAsk: "对整个库重跑一遍推理?", + derivedRunGo: "跑", + derivedRunCancel: "取消", derivedCountLabel: "推出来的边", derivedStateLabel: "定时", derivedLastLabel: "上次推理", diff --git a/web/src/pages/Graph.tsx b/web/src/pages/Graph.tsx index 5ecf429e8..ee5655470 100644 --- a/web/src/pages/Graph.tsx +++ b/web/src/pages/Graph.tsx @@ -1914,10 +1914,14 @@ function DerivedPanel({ queryKey: ["kbOne", kbId], queryFn: () => api.kbDetail(kbId), }); - /* 重跑要点两下。**面板正是从左下那个 ⋯ 按钮长出来的**, - 所以指针点完 ⋯ 就停在面板的左下角——而重跑从前是一条通栏大按钮 - 铺在最下面,展开的下一下就误触了。挪到最远的那一角、缩到次要动作的尺寸, - 再加一道确认:这个动作会重算全库的推理 */ + /* 重跑要确认,但**确认的第二下必须落在另一个按钮上**。 + 这产品的手势约定是「同一个控件连点两下 = 收回去」——开关、⋯、图例胶囊 + 都是这么用的。把「再点一次就执行」压在同一个按钮上,等于让同一个手势 + 在这里意外地变成了「执行」,而别处它一直是「取消」。 + 所以点一下只是**问一句**,问句下面给 取消 / 跑 两个目标。 + + 也没有用全站的 DangerConfirm:那是红标题、可要求逐字输入的危险级, + 留给删库那类不可逆操作。重跑推理重但可重复,够不上那一档 */ const [armed, setArmed] = useState(false); const run = useMutation({ mutationFn: () => api.runInference(kbId), @@ -1946,31 +1950,18 @@ function DerivedPanel({ {S.graph.derivedPanel} + {!armed && ( + + )} -
+ {/* 问句 + 两个目标。**取消排在前面**:从「跑」那一下移过来最先碰到的 + 是取消,误触的代价小的那个该更近 */} + {armed && ( +
+

+ {S.graph.derivedRunAsk} +

+
+ + +
+
+ )} +
{S.graph.derivedCountLabel}
From ca97a932a1b2f9875b76b217c22cdfba5a93a49e Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Tue, 1 Sep 2026 00:21:22 +0800 Subject: [PATCH 15/32] Glass is for when you are not reading it Co-Authored-By: Claude Opus 5 --- web/src/i18n/en.ts | 2 +- web/src/i18n/zh.ts | 2 +- web/src/pages/Graph.tsx | 15 +++++++++++---- web/src/styles.css | 29 ++++++++++++++++++++++++++--- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index bdaa3d686..faa8799e5 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -547,7 +547,7 @@ export const en = { graph: { // 还没判出类型的实体(0009)。不是一个类,是"这一格还空着" untyped: "Untyped", - legendMore: (n: number) => `+${n} classes`, + legendMore: (n: number) => `All ${n} classes`, nodeBudget: "How many entities to draw", nodeBudgetMore: "Draw more", nodeBudgetLess: "Draw fewer", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index f4e9360ee..9d087103c 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -512,7 +512,7 @@ export const zh: Strings = { }, graph: { untyped: "未分类", - legendMore: (n: number) => `+${n} 个类`, + legendMore: (n: number) => `全部 ${n} 个类`, nodeBudget: "画多少个实体", nodeBudgetMore: "多画一些", nodeBudgetLess: "少画一些", diff --git a/web/src/pages/Graph.tsx b/web/src/pages/Graph.tsx index ee5655470..79d5f02b3 100644 --- a/web/src/pages/Graph.tsx +++ b/web/src/pages/Graph.tsx @@ -1112,6 +1112,8 @@ export function Graph() { ))} + {/* chip 上的数是**全部类**,不是被收起来的那几个—— + 点开看到的就是全部(搜得到任何一个),写「+3」等于承诺了另一件事 */} {/* 复位。**只要存在隐藏就给一步到位的出口**——「只看」很容易把 画面收得很窄,没有这个就得挨个点回来 */} {hiddenTypes.size > 0 && ( @@ -1136,7 +1138,7 @@ export function Graph() { legendPop.open ? "text-neutral-100" : "text-neutral-400" } hover:text-neutral-100`} > - {S.graph.legendMore(legendRest.length)} + {S.graph.legendMore(types.length)} {/* 收起来的类里有正被隐藏的就点一下。**不点就是无声过滤**: 在面板里关掉一个类、把面板一收,界面上再没有任何东西说它被关了 */} {hiddenInRest > 0 && ( @@ -1155,7 +1157,7 @@ export function Graph() { onClick={() => legendPop.close()} className="mb-1.5 flex w-full items-center gap-1.5 rounded-full px-1.5 py-0.5 text-[11px] text-neutral-300 transition-colors hover:text-neutral-100" > - {S.graph.legendMore(legendRest.length)} + {S.graph.legendMore(types.length)} -
+ {/* items-center 而不是 baseline:标题旁边站着一个按钮和一个关闭键, + 按基线对齐会让那两个看着往上飘 */} +
{S.graph.derivedPanel} {!armed && ( )} + {/* 固定 18px 方格:**别让关闭键撑起标题行的高**——一撑高, + 行里最矮的标题就被居中挤出上下空当,看着像上边距过大 */}