Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
Search,
Trash2,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useLocale } from '../../../../i18n/LocaleContext';

import type { ContextRoomRecord } from '../types';
Expand All @@ -18,13 +18,15 @@ import { RoomCard } from './RoomCard';
import { RoomCreationStudio } from './RoomCreationStudio';
import { RoomLifecycleDialogs } from './RoomDialogs';
import { isMergeRecommendationCandidate, RoomDuplicateCenter } from './RoomDuplicateCenter';
import { GraphFitButton } from './GraphFitButton';
import { RoomGraphCanvas, type RoomGraphCanvasHandle } from './RoomGraphCanvas';
import { RoomGraphCanvas } from './RoomGraphCanvas';
import { RoomNodeInspector } from './RoomGraphInspector';
import { RoomRelationInspector } from './RoomRelationControls';
import { useRoomRelationGraph } from '../hooks/useRoomRelationGraph';
import { roomKindIcon, roomKindTone } from './utils';

/** 首页 Room 关系图谱:缩放/拖拽后鼠标静止多久自动复位到自适应视野。 */
const HOME_GRAPH_AUTO_FIT_IDLE_MS = 5_000;

function RoomGraph({
rooms,
onOpen,
Expand All @@ -38,11 +40,10 @@ function RoomGraph({
const { error, graph, loading, reload } = useRoomRelationGraph(null);
const selected = rooms.find((room) => room.id === selectedId);
const selectedRelation = (graph?.edges ?? []).find((edge) => edge.id === selectedRelationId) ?? null;
const graphRef = useRef<RoomGraphCanvasHandle>(null);

// 首页图谱对齐原型:无工具栏(搜索/筛选/关系管理收进 Room 详情的关系面板),
// 只保留标题 + 画布 + 点选节点/关系出检查器;另加一颗常驻画布的自适应按钮,
// 因为视口能自由缩放平移,缩到最小或拖走后整张图会看不见。
// 只保留标题 + 画布 + 点选节点/关系出检查器。视口可自由缩放平移,
// 不设「适应画布」按钮:缩放/拖拽后鼠标静止满 5 秒自动弹性复位。
return (
<section className="context-room-home-section context-room-home-graph-section">
<div className="context-room-home-section-title context-room-graph-heading">
Expand All @@ -52,7 +53,7 @@ function RoomGraph({
<div className="context-room-room-graph-canvas">
{loading && !graph ? <div className="context-room-graph-state">{t('contextRoom:relations.loadingGraph')}</div> : (
<RoomGraphCanvas
ref={graphRef}
autoFitOnIdleMs={HOME_GRAPH_AUTO_FIT_IDLE_MS}
rooms={rooms}
relations={graph?.edges ?? []}
selectedId={selectedId}
Expand All @@ -62,7 +63,6 @@ function RoomGraph({
onOpenRoom={onOpen}
/>
)}
{graph ? <GraphFitButton onFit={() => void graphRef.current?.fitView()} /> : null}
{error ? <div className="context-room-graph-degraded">{t('contextRoom:relations.degradedNoSyntheticEdges')}</div> : null}
</div>
{selectedRelation ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
Expand Down Expand Up @@ -37,6 +38,14 @@ interface RoomGraphCanvasProps {
onOpenRoom: (roomId: string) => void
onSelectRelation?: (relationId: string) => void
onSelectRoom: (roomId: string | null) => void
/**
* 「无操作自动复位」的空闲阈值(毫秒);不传 = 关闭。
*
* 语义:用户改过视口(缩放 / 平移拖拽)之后,鼠标**离开画布**满 N 毫秒 →
* 弹性补间回到自适应视野。画布内图谱保持不动;只点击节点(没移动、视口没变)
* 不会武装;移回画布会取消待触发的复位并停掉在播补间。
*/
autoFitOnIdleMs?: number
}

const ROOM_KIND_COLORS: Record<ContextRoomKind, number> = {
Expand Down Expand Up @@ -67,6 +76,7 @@ function RoomGraphCanvasComponent(
onOpenRoom,
onSelectRelation,
onSelectRoom,
autoFitOnIdleMs = 0,
}: RoomGraphCanvasProps,
ref: React.ForwardedRef<RoomGraphCanvasHandle>,
) {
Expand Down Expand Up @@ -151,6 +161,78 @@ function RoomGraphCanvasComponent(
canvasRef,
settleFit,
})
// 「无操作自动复位」:只有视口真被用户改动过(缩放 / 平移拖拽)才武装;
// 复位时机:鼠标**离开**关系图谱画布满 autoFitOnIdleMs → 弹性补间回自适应视野。
// 鼠标在画布内时图谱必须保持不动(不排倒计时);回进画布会取消待触发的复位和正在播放的补间。
const autoFitTimer = useRef<number | null>(null)
const autoFitArmed = useRef(false)
const pointerInside = useRef(false)
const pointerStart = useRef<{ x: number; y: number } | null>(null)
const pointerMoved = useRef(false)
const draggingNode = useRef(false)
const autoFitFire = useCallback(() => {
autoFitTimer.current = null
autoFitArmed.current = false
const canvas = canvasRef.current
// 1s 慢弹簧(一大一小交替)补间归位;不支持补间的画布退回瞬时对准。
if (canvas?.animateFitView) canvas.animateFitView({ durationMs: 1000 })
else canvas?.fitView()
}, [])
const clearAutoFitTimer = useCallback(() => {
if (autoFitTimer.current === null) return
window.clearTimeout(autoFitTimer.current)
autoFitTimer.current = null
}, [])
const armAutoFit = useCallback(() => {
// 只武装,不排倒计时:倒计时一律等鼠标离开画布才开始
autoFitArmed.current = true
}, [])
useEffect(() => clearAutoFitTimer, [clearAutoFitTimer])

const handlePointerEnter = useCallback(() => {
pointerInside.current = true
// 鼠标回到画布内:取消待触发的复位;正在播放的补间也立刻停(画布内图谱必须静止)。
// 若确实停掉了一段在播的补间,重新武装——等下次离开时把它补完。
clearAutoFitTimer()
if (canvasRef.current?.cancelFitAnimation?.()) autoFitArmed.current = true
}, [clearAutoFitTimer])
const handlePointerLeave = useCallback(() => {
pointerInside.current = false
pointerStart.current = null
// 离开画布才开始倒计时(视口被改过才需要复位)
clearAutoFitTimer()
if (autoFitOnIdleMs <= 0 || !autoFitArmed.current) return
autoFitTimer.current = window.setTimeout(autoFitFire, autoFitOnIdleMs)
}, [autoFitFire, autoFitOnIdleMs, clearAutoFitTimer])
const handlePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
pointerInside.current = true
pointerStart.current = { x: event.clientX, y: event.clientY }
pointerMoved.current = false
draggingNode.current = false
// 用户伸手了:正在播放的复位补间立刻停(否则会跟拖拽抢镜头)
canvasRef.current?.cancelFitAnimation?.()
}, [])
const handlePointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
pointerInside.current = true
const start = pointerStart.current
if (start && !pointerMoved.current) {
// 3px 死区:点击节点时的轻微抖动不算「改动视口」
if (Math.abs(event.clientX - start.x) > 3 || Math.abs(event.clientY - start.y) > 3) {
pointerMoved.current = true
// 拖的是节点(不是平移画布)→ 视口没变,不武装
if (!draggingNode.current) armAutoFit()
}
}
}, [armAutoFit])
const handlePointerEnd = useCallback(() => {
pointerStart.current = null
}, [])
const handleWheel = useCallback(() => {
// 滚轮 = 缩放,视口一定变了;正在播放的补间同样让位
canvasRef.current?.cancelFitAnimation?.()
armAutoFit()
}, [armAutoFit])

const resizeLayout = useCallback((width: number, height: number) => {
const dimensions = roomGraphLayoutDimensions({
compact,
Expand All @@ -171,7 +253,16 @@ function RoomGraphCanvasComponent(
}), [])

return (
<div className="context-room-graph-shell nx-graph-shell">
<div
className="context-room-graph-shell nx-graph-shell"
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onWheel={handleWheel}
>
<PixiForceGraphCanvas
ref={canvasRef}
ariaLabel={t('contextRoom:graphs.roomRelationsCanvas')}
Expand All @@ -186,7 +277,10 @@ function RoomGraphCanvasComponent(
selectedEdgeId={selectedRelationId}
onResize={resizeLayout}
onUserGesture={layout.cancelAutoFit}
onDragNode={layout.drag}
onDragNode={(nodeId, x, y) => {
draggingNode.current = true
layout.drag(nodeId, x, y)
}}
onReleaseNode={layout.release}
onOpenNode={onOpenRoom}
onSelectEdge={onSelectRelation}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { Link2, Network } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useLocale } from '../../../../../i18n/LocaleContext'
import type { RoomDocument } from '@nxcore/agent-contract'

import type { KnowledgeFileDto } from '../../../../../../../shared/knowledge'
import type { ContextRoomRecord, ContextRoomResource } from '../../types'
import { createContextRoomResourceLibrary } from '../../resources'
import { useRoomRelationGraph } from '../../hooks/useRoomRelationGraph'
import { RoomGraphCanvas, type RoomGraphCanvasHandle } from '../RoomGraphCanvas'
import { RoomGraphCanvas } from '../RoomGraphCanvas'
import { RoomNodeInspector } from '../RoomGraphInspector'
import { CreateRoomRelationDialog, relationTypeLabel, RoomRelationInspector } from '../RoomRelationControls'
import { GraphFitButton } from '../GraphFitButton'
import { PanelEmptyState } from './PanelEmptyState'

/** 用户缩放/拖拽后,鼠标在关系图谱上静止多久自动弹性复位到自适应视野。 */
const RELATIONS_AUTO_FIT_IDLE_MS = 5_000

export function RelationsPane({
room,
rooms,
Expand All @@ -31,7 +33,6 @@ export function RelationsPane({
const { locale, t } = useLocale()
const [visibility, setVisibility] = useState<'active' | 'hidden'>('active')
const { error, graph, loading, reload } = useRoomRelationGraph(room.id, visibility)
const graphRef = useRef<RoomGraphCanvasHandle>(null)
const [selectedGraphRoomId, setSelectedGraphRoomId] = useState(room.id)
const [selectedRelationId, setSelectedRelationId] = useState<string | null>(null)
const [inspectorOpen, setInspectorOpen] = useState(true)
Expand Down Expand Up @@ -77,22 +78,18 @@ export function RelationsPane({
</header>
<div className="context-room-related-graph-canvas">
{loading && !graph ? <div className="context-room-graph-state">{t('contextRoom:relations.loadingGraph')}</div> : hasRelations ? (
<>
<RoomGraphCanvas
ref={graphRef}
compact
rooms={graphRooms}
relations={graph?.edges ?? []}
selectedId={selectedGraphRoomId}
selectedRelationId={selectedRelationId}
onSelectRoom={(roomId) => { setSelectedRelationId(null); setSelectedGraphRoomId(roomId ?? room.id); setInspectorOpen(Boolean(roomId)) }}
onSelectRelation={(relationId) => { setSelectedRelationId(relationId); setSelectedGraphRoomId(room.id); setInspectorOpen(Boolean(relationId)) }}
onOpenRoom={onOpenRoom}
/>
{/* 视口可自由缩放平移,缩到最小或拖出视野后图会看不见;
浮层按钮常驻画布右上角,一键把图谱自适应缩放并居中。 */}
<GraphFitButton onFit={() => void graphRef.current?.fitView()} />
</>
<RoomGraphCanvas
compact
rooms={graphRooms}
relations={graph?.edges ?? []}
selectedId={selectedGraphRoomId}
selectedRelationId={selectedRelationId}
// 不设按钮:缩放/拖拽后鼠标静止满 5 秒,弹性补间回自适应视野。
autoFitOnIdleMs={RELATIONS_AUTO_FIT_IDLE_MS}
onSelectRoom={(roomId) => { setSelectedRelationId(null); setSelectedGraphRoomId(roomId ?? room.id); setInspectorOpen(Boolean(roomId)) }}
onSelectRelation={(relationId) => { setSelectedRelationId(relationId); setSelectedGraphRoomId(room.id); setInspectorOpen(Boolean(relationId)) }}
onOpenRoom={onOpenRoom}
/>
) : (
<PanelEmptyState
icon={Network}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export interface PixiForceGraphCanvasNode extends PixiForceGraphNode {

export interface PixiForceGraphCanvasHandle {
fitView(minScale?: number): void
/** 带 1s 弹簧补间(一大一小交替衰减)回到自适应视野(关系图谱「无操作自动复位」用)。 */
animateFitView?(options?: { minScale?: number; durationMs?: number }): void
/** 用户接管画布(拖拽/滚轮/移回画布)时取消复位补间;返回是否真的取消了在播补间。 */
cancelFitAnimation?(): boolean
}

interface PixiForceGraphCanvasProps {
Expand Down Expand Up @@ -83,6 +87,12 @@ export const PixiForceGraphCanvas = forwardRef<PixiForceGraphCanvasHandle, PixiF
fitView(minScale?: number) {
rendererRef.current?.fitView(minScale)
},
animateFitView(options?: { minScale?: number; durationMs?: number }) {
rendererRef.current?.animateFitView(options)
},
cancelFitAnimation() {
return rendererRef.current?.cancelFitAnimation() ?? false
},
}), [])

// 首帧遮罩:Worker 发布首个有效布局(revision > 0)前盖住画布,避免
Expand Down
Loading
Loading