@@ -80,6 +80,59 @@ const LAYOUT = {
8080 CATEGORY_SPACING : 600 ,
8181}
8282
83+ // === 遮挡检测 ===
84+ // 计算节点的 absolute bbox (考虑 parentNode 嵌套)
85+ function getNodeAbsoluteBox ( node , allNodes ) {
86+ let x = node . position ?. x || 0
87+ let y = node . position ?. y || 0
88+ let parentId = node . parentNode
89+ while ( parentId ) {
90+ const parent = allNodes . find ( ( n ) => n . id === parentId )
91+ if ( ! parent ) break
92+ x += parent . position ?. x || 0
93+ y += parent . position ?. y || 0
94+ parentId = parent . parentNode
95+ }
96+ // 节点尺寸: group 用 style.width/height, 普通节点估算
97+ const w = Number ( node . style ?. width ) || node . width || node . measured ?. width
98+ || ( node . type === 'group' ? 800 : node . type === 'challengeNode' ? 280 : node . type === 'ontologyNode' ? 220 : 200 )
99+ const h = Number ( node . style ?. height ) || node . height || node . measured ?. height
100+ || ( node . type === 'group' ? 600 : node . type === 'challengeNode' ? 200 : 120 )
101+ return { x, y, w, h }
102+ }
103+
104+ // 判断两 box 是否撞 (留 SAFE_GAP 间距)
105+ function boxOverlap ( a , b , gap = 30 ) {
106+ return ! ( a . x + a . w + gap <= b . x || b . x + b . w + gap <= a . x || a . y + a . h + gap <= b . y || b . y + b . h + gap <= a . y )
107+ }
108+
109+ /**
110+ * 在已有节点旁边找一个不撞的位置. 优先按 strategy 偏移
111+ * @param {{x,y,w,h} } desired 期望的 box
112+ * @param {Array } allNodes 所有节点
113+ * @param {Array<string> } excludeIds 排除自身/同时新增的节点 ids
114+ * @param {'down'|'right' } strategy 撞了之后向哪个方向偏移
115+ * @returns {{x,y} } 不撞的目标位置
116+ */
117+ function findFreePosition ( desired , allNodes , excludeIds = [ ] , strategy = 'down' ) {
118+ const STEP = strategy === 'down' ? 80 : 120
119+ const MAX_TRIES = 50
120+ let { x, y } = desired
121+ for ( let i = 0 ; i < MAX_TRIES ; i ++ ) {
122+ const cur = { x, y, w : desired . w , h : desired . h }
123+ const collided = allNodes . find ( ( n ) => {
124+ if ( excludeIds . includes ( n . id ) ) return false
125+ if ( n . parentNode ) return false // 子节点的位置已经体现在父级 bbox 内, 跳过避免重复检测
126+ const box = getNodeAbsoluteBox ( n , allNodes )
127+ return boxOverlap ( cur , box )
128+ } )
129+ if ( ! collided ) return { x, y }
130+ if ( strategy === 'down' ) y += STEP
131+ else x += STEP
132+ }
133+ return { x, y } // 兜底, 50 次还撞就只能这样
134+ }
135+
83136// 通用节点工厂,确保创建一致性
84137const createNodeFactory = ( get , set ) => ( type , idPrefix , data , position = null ) => {
85138 const pos = position || get ( ) . getNextGridPosition ( )
@@ -2266,6 +2319,17 @@ const useCanvasStore = create(
22662319 }
22672320
22682321 const existing = nodes . find ( ( n ) => n . id === challengeGroupId )
2322+
2323+ // 遮挡检测: 新建容器时避开已有节点 (PDF / 别的 challengeGroup / 其他 projectGroup)
2324+ // 仅对新建容器做; 复用已存在的不动 (用户可能已经手动调过位置)
2325+ if ( ! existing ) {
2326+ const containerH = CHALLENGE_GROUP_PAD * 2 + Math . max ( challenges . length , 3 ) * CHALLENGE_CARD_H
2327+ const free = findFreePosition (
2328+ { x : challengeGroupPos . x , y : challengeGroupPos . y , w : CHALLENGE_GROUP_W , h : containerH } ,
2329+ nodes , [ ] , 'down' ,
2330+ )
2331+ challengeGroupPos = free
2332+ }
22692333 // 已有反驳卡片数 (用于 append 时的 y 偏移)
22702334 const existingChallenges = nodes . filter ( ( n ) => n . parentNode === challengeGroupId && n . type === 'challengeNode' )
22712335 const startIdx = existingChallenges . length
@@ -2404,6 +2468,16 @@ const useCanvasStore = create(
24042468 }
24052469
24062470 const existing = nodes . find ( ( n ) => n . id === dgId )
2471+
2472+ // 遮挡检测: 新建 decompose 容器时避开已有内容
2473+ if ( ! existing ) {
2474+ const tentativeW = Math . max ( COL_W * 5 + PAD * 2 , COL_W * subitems . length + PAD * 2 )
2475+ const free = findFreePosition (
2476+ { x : dgPos . x , y : dgPos . y , w : tentativeW , h : CARD_H + PAD * 2 + 24 } ,
2477+ nodes , [ ] , 'down' ,
2478+ )
2479+ dgPos = free
2480+ }
24072481 const existingChildren = nodes . filter ( ( n ) => n . parentNode === dgId && n . type === 'ontologyNode' )
24082482 const startIdx = existingChildren . length
24092483 const totalAfter = startIdx + subitems . length
0 commit comments