@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 0.6.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/README_zh.md +2 -1
- package/assets/nexusDist/assets/index-CaA76j0F.js +9 -0
- package/assets/nexusDist/index.html +1 -1
- package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
- package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
- package/assets/stageDist/index.html +12 -12
- package/cli/a2a.mjs +24 -4
- package/lib/canvas-store.js +249 -17
- package/lib/client.js +62 -72
- package/lib/client.js.map +1 -1
- package/lib/config.js +1 -0
- package/lib/faces/admin.js +103 -63
- package/lib/faces/wire.js +3 -1
- package/lib/index.js +116 -22
- package/lib/origin-claim.js +4 -0
- package/lib/skill/SKILL.md +117 -117
- package/lib/skill/references/collaboration.md +428 -428
- package/lib/skill/references/discovery.md +78 -78
- package/lib/skill/references/intake.md +107 -107
- package/lib/skill/references/membership.md +73 -73
- package/lib/skill/references/pitfalls.md +51 -51
- package/lib/skill/references/router.md +96 -96
- package/lib/skill/references/routing.md +65 -65
- package/lib/types/canvas-store.d.ts +48 -1
- package/lib/types/config.d.ts +2 -0
- package/lib/types/faces/admin.d.ts +2 -0
- package/lib/types/origin-claim.d.ts +2 -0
- package/nexus-stage/package.json +21 -21
- package/nexus-stage/scripts/mockhost.py +261 -261
- package/nexus-stage/src/canvas-ops.ts +191 -191
- package/nexus-stage/src/canvas-wire.ts +164 -164
- package/nexus-stage/src/cli-home-picker.ts +42 -42
- package/nexus-stage/src/fault.ts +56 -56
- package/nexus-stage/src/federation-overlay.ts +103 -103
- package/nexus-stage/src/federation.ts +241 -241
- package/nexus-stage/src/layout-doc.ts +145 -145
- package/nexus-stage/src/main.ts +520 -513
- package/nexus-stage/src/planning-card-text.ts +73 -73
- package/nexus-stage/src/planning-card.ts +63 -63
- package/nexus-stage/src/planning-edges.ts +26 -26
- package/nexus-stage/src/planning-fit.ts +45 -45
- package/nexus-stage/src/planning-frame-hue.ts +13 -13
- package/nexus-stage/src/planning-legend.ts +39 -39
- package/nexus-stage/src/planning-member-handles.ts +61 -61
- package/nexus-stage/src/planning-menus.ts +422 -422
- package/nexus-stage/src/planning-mirror.ts +75 -75
- package/nexus-stage/src/planning-nodelist.ts +294 -294
- package/nexus-stage/src/planning-overlap.ts +159 -159
- package/nexus-stage/src/planning-reachability.ts +28 -28
- package/nexus-stage/src/planning-relayout.ts +121 -121
- package/nexus-stage/src/planning-roster.ts +15 -15
- package/nexus-stage/src/planning-search.ts +168 -168
- package/nexus-stage/src/planning-types.ts +115 -115
- package/nexus-stage/src/planning-undo.ts +68 -68
- package/nexus-stage/src/planning-view.ts +1499 -1499
- package/nexus-stage/src/planning.css +340 -340
- package/nexus-stage/src/seat-name-editor.ts +31 -31
- package/nexus-stage/src/session-drag.ts +64 -64
- package/nexus-stage/src/viewport.ts +106 -106
- package/nexus-stage/src/wb-chat.ts +684 -684
- package/nexus-stage/src/wb-directory.ts +237 -237
- package/nexus-stage/src/wb-ledger.ts +279 -279
- package/nexus-stage/src/wb-names.ts +43 -43
- package/nexus-stage/src/wb-rail.ts +147 -147
- package/nexus-stage/src/workbench.css +388 -388
- package/nexus-stage/src/workbench.ts +1294 -1272
- package/nexus-stage/src/world.ts +509 -509
- package/nexus-stage/tsconfig.json +24 -24
- package/nexus-stage/vite.config.ts +15 -15
- package/package.json +1 -1
- package/assets/nexusDist/assets/index-NkNSeIx6.js +0 -9
|
@@ -1,159 +1,159 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Board overlap rules (owner, 2026-10-27: 团队方框需要避免遮盖和覆盖 — frames
|
|
3
|
-
* must not overlap each other, and a card must not sit on a frame it is not
|
|
4
|
-
* a member of).
|
|
5
|
-
*
|
|
6
|
-
* settleBoard() resolves both, deterministically:
|
|
7
|
-
* - Frame vs frame: one frame of an overlapping pair steps aside along the
|
|
8
|
-
* shortest of the four directions, carrying its members like a drag. The
|
|
9
|
-
* frame the user just touched stays where it was put; a remote org frame
|
|
10
|
-
* never moves on its own (gh#44: its rect is the user's), so a local frame
|
|
11
|
-
* yields to it; two remote frames are left alone.
|
|
12
|
-
* - Card vs frame: a card lying on a frame it does not belong to goes home
|
|
13
|
-
* first — the first free grid seat inside its own frame (memberships in
|
|
14
|
-
* state order) — and only a card with no frame (or no free seat) goes
|
|
15
|
-
* outside: right of that frame, else below it, else down a column right of
|
|
16
|
-
* every frame. No landing spot covers another card or a foreign frame, and
|
|
17
|
-
* frames are never moved or resized by this step. Members are never moved
|
|
18
|
-
* off their own frame; a member merely outside its frame (PR#85: a frame
|
|
19
|
-
* moves only itself) is left where it is.
|
|
20
|
-
* Callers run it after a frame drag or resize, after 整理成员 / 一键重排,
|
|
21
|
-
* and once when the board first has content — never on every poll (a poll
|
|
22
|
-
* does not reposition what the user arranged).
|
|
23
|
-
*
|
|
24
|
-
* tidyMembers() is the gh#48 整理成员 grid, moved here from planning-view.
|
|
25
|
-
* @module nexus-stage/planning-overlap
|
|
26
|
-
*/
|
|
27
|
-
import { NODE_W, NODE_H, nodeRect, type FrameGeom, type WorldModel } from './world'
|
|
28
|
-
|
|
29
|
-
/** Clearance kept between a moved frame or card and what it stepped off. */
|
|
30
|
-
export const OVERLAP_GAP = 24
|
|
31
|
-
/** Bound on resolution passes (a dense board settles in a few). */
|
|
32
|
-
const MAX_PASSES = 200
|
|
33
|
-
|
|
34
|
-
type Rect = { readonly x: number; readonly y: number; readonly w: number; readonly h: number }
|
|
35
|
-
const hit = (a: Rect, b: Rect): boolean => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
|
|
36
|
-
|
|
37
|
-
/** The shortest translation that takes `m` clear of `f` (plus the gap). */
|
|
38
|
-
function stepAside(m: Rect, f: Rect): { dx: number; dy: number } {
|
|
39
|
-
const right = f.x + f.w + OVERLAP_GAP - m.x
|
|
40
|
-
const left = f.x - OVERLAP_GAP - (m.x + m.w)
|
|
41
|
-
const down = f.y + f.h + OVERLAP_GAP - m.y
|
|
42
|
-
const up = f.y - OVERLAP_GAP - (m.y + m.h)
|
|
43
|
-
const dx = Math.abs(right) <= Math.abs(left) ? right : left
|
|
44
|
-
const dy = Math.abs(down) <= Math.abs(up) ? down : up
|
|
45
|
-
return Math.abs(dx) <= Math.abs(dy) ? { dx, dy: 0 } : { dx: 0, dy }
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function moveFrame(model: WorldModel, name: string, dx: number, dy: number): void {
|
|
49
|
-
const snap = model.beginFrameDrag(name)
|
|
50
|
-
if (snap !== undefined) model.applyFrameDrag(snap, dx, dy)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Separate overlapping frames; true when anything moved. */
|
|
54
|
-
export function separateFrames(model: WorldModel, remote: ReadonlySet<string>, touched?: string): boolean {
|
|
55
|
-
let changed = false
|
|
56
|
-
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
57
|
-
const frames = model.allFrames()
|
|
58
|
-
let pair: [string, FrameGeom, string, FrameGeom] | undefined
|
|
59
|
-
for (let i = 0; i < frames.length && pair === undefined; i++) {
|
|
60
|
-
for (let j = i + 1; j < frames.length; j++) {
|
|
61
|
-
const [a, ra] = frames[i]!
|
|
62
|
-
const [b, rb] = frames[j]!
|
|
63
|
-
if (remote.has(a) && remote.has(b)) continue
|
|
64
|
-
if (hit(ra, rb)) { pair = [a, ra, b, rb]; break }
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
if (pair === undefined) return changed
|
|
68
|
-
const [a, ra, b, rb] = pair
|
|
69
|
-
// Who yields: the lower stay score moves — remote 2 (never moves on its
|
|
70
|
-
// own), the frame just placed 1, any other 0; a tie moves the later one
|
|
71
|
-
// in board order. (A plain "a stays" check moved a remote b whenever a
|
|
72
|
-
// was the frame just placed — order-dependent: PR #83 review.)
|
|
73
|
-
const stay = (n: string): number => remote.has(n) ? 2 : n === touched ? 1 : 0
|
|
74
|
-
const [mover, mr, fixed] = stay(a) < stay(b) ? [a, ra, rb] : [b, rb, ra]
|
|
75
|
-
const { dx, dy } = stepAside(mr, fixed)
|
|
76
|
-
moveFrame(model, mover, dx, dy)
|
|
77
|
-
changed = true
|
|
78
|
-
}
|
|
79
|
-
return changed
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Grid of the 整理成员 layout, shared by eviction seats. */
|
|
83
|
-
const PAD = 28
|
|
84
|
-
const GAP_X = 16
|
|
85
|
-
const GAP_Y = 14
|
|
86
|
-
|
|
87
|
-
/** Card-centred seats inside a frame, row by row (the 整理成员 grid). */
|
|
88
|
-
function seatsIn(f: Rect): Array<{ x: number; y: number }> {
|
|
89
|
-
const out: Array<{ x: number; y: number }> = []
|
|
90
|
-
for (let y = f.y + PAD; y + NODE_H <= f.y + f.h - PAD; y += NODE_H + GAP_Y) {
|
|
91
|
-
for (let x = f.x + PAD; x + NODE_W <= f.x + f.w - PAD; x += NODE_W + GAP_X) out.push({ x: x + NODE_W / 2, y: y + NODE_H / 2 })
|
|
92
|
-
}
|
|
93
|
-
return out
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Move cards off frames they do not belong to; true when anything moved. */
|
|
97
|
-
export function evictStrangers(model: WorldModel): boolean {
|
|
98
|
-
let changed = false
|
|
99
|
-
const frames = model.allFrames()
|
|
100
|
-
const rectOf = new Map(frames)
|
|
101
|
-
for (const n of model.allNodes()) {
|
|
102
|
-
const own = new Set(n.memberships.map(m => m.team))
|
|
103
|
-
const host = frames.find(([name, f]) => !own.has(name) && hit(nodeRect(n), f))
|
|
104
|
-
if (host === undefined) continue
|
|
105
|
-
const f = host[1]
|
|
106
|
-
// Free = no foreign frame and no other card under it (cards read live: earlier evictions count).
|
|
107
|
-
const free = (p: { x: number; y: number }): boolean => {
|
|
108
|
-
const r = nodeRect(p)
|
|
109
|
-
return frames.every(([name, g]) => own.has(name) || !hit(r, g)) &&
|
|
110
|
-
model.allNodes().every(o => o.id === n.id || !hit(r, nodeRect(o)))
|
|
111
|
-
}
|
|
112
|
-
const home = n.memberships.map(m => rectOf.get(m.team)).filter((g): g is FrameGeom => g !== undefined)
|
|
113
|
-
const seat = home.flatMap(seatsIn).find(free)
|
|
114
|
-
const right = { x: f.x + f.w + OVERLAP_GAP + NODE_W / 2, y: n.y }
|
|
115
|
-
const below = { x: n.x, y: f.y + f.h + OVERLAP_GAP + NODE_H / 2 }
|
|
116
|
-
// Last resort: a column right of every frame, top-down from this frame. A card blocks
|
|
117
|
-
// at most two seats of this pitch, so 2·cards+1 seats always hold a free one.
|
|
118
|
-
const farX = Math.max(...frames.map(([, g]) => g.x + g.w)) + OVERLAP_GAP + NODE_W / 2
|
|
119
|
-
const column = Array.from({ length: 2 * model.allNodes().length + 1 }, (_, i) => ({ x: farX, y: f.y + NODE_H / 2 + i * (NODE_H + GAP_Y) }))
|
|
120
|
-
const to = seat ?? [right, below, ...column].find(free) ?? column[column.length - 1]!
|
|
121
|
-
model.dragNodes([n.id], to.x - n.x, to.y - n.y)
|
|
122
|
-
changed = true
|
|
123
|
-
}
|
|
124
|
-
return changed
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** Both rules, frames first (moving a frame can uncover or cover cards). */
|
|
128
|
-
export function settleBoard(model: WorldModel, remote: ReadonlySet<string>, touched?: string): boolean {
|
|
129
|
-
const framesMoved = separateFrames(model, remote, touched)
|
|
130
|
-
const cardsMoved = evictStrangers(model)
|
|
131
|
-
return framesMoved || cardsMoved
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* gh#48 整理成员: inside every frame, the members that belong to exactly that
|
|
136
|
-
* one team re-seat into a padded grid; the frame grows downward (bounded) to
|
|
137
|
-
* fit them. Members of 2+ teams keep their positions.
|
|
138
|
-
*/
|
|
139
|
-
export function tidyMembers(model: WorldModel): void {
|
|
140
|
-
for (const [name, rect] of model.allFrames()) {
|
|
141
|
-
const sole = model.teamMemberIds(name)
|
|
142
|
-
.map(id => model.getNode(id))
|
|
143
|
-
.filter((n): n is NonNullable<typeof n> => n !== undefined)
|
|
144
|
-
.filter(n => n.memberships.length === 1)
|
|
145
|
-
if (sole.length === 0) continue // empty frame / only shared members: untouched
|
|
146
|
-
const perRow = Math.max(1, Math.floor((rect.w - 2 * PAD + GAP_X) / (NODE_W + GAP_X)))
|
|
147
|
-
const rows = Math.ceil(sole.length / perRow)
|
|
148
|
-
const needed = PAD * 2 + rows * NODE_H + (rows - 1) * GAP_Y
|
|
149
|
-
if (needed > rect.h) model.setFrame(name, { ...rect, h: Math.min(needed, rect.h + 400) })
|
|
150
|
-
const startX = rect.x + PAD + NODE_W / 2
|
|
151
|
-
let y = rect.y + PAD + NODE_H / 2
|
|
152
|
-
sole.forEach((n, i) => {
|
|
153
|
-
const col = i % perRow
|
|
154
|
-
if (i > 0 && col === 0) y += NODE_H + GAP_Y
|
|
155
|
-
const x = startX + col * (NODE_W + GAP_X)
|
|
156
|
-
model.dragNodes([n.id], x - n.x, y - n.y)
|
|
157
|
-
})
|
|
158
|
-
}
|
|
159
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Board overlap rules (owner, 2026-10-27: 团队方框需要避免遮盖和覆盖 — frames
|
|
3
|
+
* must not overlap each other, and a card must not sit on a frame it is not
|
|
4
|
+
* a member of).
|
|
5
|
+
*
|
|
6
|
+
* settleBoard() resolves both, deterministically:
|
|
7
|
+
* - Frame vs frame: one frame of an overlapping pair steps aside along the
|
|
8
|
+
* shortest of the four directions, carrying its members like a drag. The
|
|
9
|
+
* frame the user just touched stays where it was put; a remote org frame
|
|
10
|
+
* never moves on its own (gh#44: its rect is the user's), so a local frame
|
|
11
|
+
* yields to it; two remote frames are left alone.
|
|
12
|
+
* - Card vs frame: a card lying on a frame it does not belong to goes home
|
|
13
|
+
* first — the first free grid seat inside its own frame (memberships in
|
|
14
|
+
* state order) — and only a card with no frame (or no free seat) goes
|
|
15
|
+
* outside: right of that frame, else below it, else down a column right of
|
|
16
|
+
* every frame. No landing spot covers another card or a foreign frame, and
|
|
17
|
+
* frames are never moved or resized by this step. Members are never moved
|
|
18
|
+
* off their own frame; a member merely outside its frame (PR#85: a frame
|
|
19
|
+
* moves only itself) is left where it is.
|
|
20
|
+
* Callers run it after a frame drag or resize, after 整理成员 / 一键重排,
|
|
21
|
+
* and once when the board first has content — never on every poll (a poll
|
|
22
|
+
* does not reposition what the user arranged).
|
|
23
|
+
*
|
|
24
|
+
* tidyMembers() is the gh#48 整理成员 grid, moved here from planning-view.
|
|
25
|
+
* @module nexus-stage/planning-overlap
|
|
26
|
+
*/
|
|
27
|
+
import { NODE_W, NODE_H, nodeRect, type FrameGeom, type WorldModel } from './world'
|
|
28
|
+
|
|
29
|
+
/** Clearance kept between a moved frame or card and what it stepped off. */
|
|
30
|
+
export const OVERLAP_GAP = 24
|
|
31
|
+
/** Bound on resolution passes (a dense board settles in a few). */
|
|
32
|
+
const MAX_PASSES = 200
|
|
33
|
+
|
|
34
|
+
type Rect = { readonly x: number; readonly y: number; readonly w: number; readonly h: number }
|
|
35
|
+
const hit = (a: Rect, b: Rect): boolean => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
|
|
36
|
+
|
|
37
|
+
/** The shortest translation that takes `m` clear of `f` (plus the gap). */
|
|
38
|
+
function stepAside(m: Rect, f: Rect): { dx: number; dy: number } {
|
|
39
|
+
const right = f.x + f.w + OVERLAP_GAP - m.x
|
|
40
|
+
const left = f.x - OVERLAP_GAP - (m.x + m.w)
|
|
41
|
+
const down = f.y + f.h + OVERLAP_GAP - m.y
|
|
42
|
+
const up = f.y - OVERLAP_GAP - (m.y + m.h)
|
|
43
|
+
const dx = Math.abs(right) <= Math.abs(left) ? right : left
|
|
44
|
+
const dy = Math.abs(down) <= Math.abs(up) ? down : up
|
|
45
|
+
return Math.abs(dx) <= Math.abs(dy) ? { dx, dy: 0 } : { dx: 0, dy }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function moveFrame(model: WorldModel, name: string, dx: number, dy: number): void {
|
|
49
|
+
const snap = model.beginFrameDrag(name)
|
|
50
|
+
if (snap !== undefined) model.applyFrameDrag(snap, dx, dy)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Separate overlapping frames; true when anything moved. */
|
|
54
|
+
export function separateFrames(model: WorldModel, remote: ReadonlySet<string>, touched?: string): boolean {
|
|
55
|
+
let changed = false
|
|
56
|
+
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
57
|
+
const frames = model.allFrames()
|
|
58
|
+
let pair: [string, FrameGeom, string, FrameGeom] | undefined
|
|
59
|
+
for (let i = 0; i < frames.length && pair === undefined; i++) {
|
|
60
|
+
for (let j = i + 1; j < frames.length; j++) {
|
|
61
|
+
const [a, ra] = frames[i]!
|
|
62
|
+
const [b, rb] = frames[j]!
|
|
63
|
+
if (remote.has(a) && remote.has(b)) continue
|
|
64
|
+
if (hit(ra, rb)) { pair = [a, ra, b, rb]; break }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (pair === undefined) return changed
|
|
68
|
+
const [a, ra, b, rb] = pair
|
|
69
|
+
// Who yields: the lower stay score moves — remote 2 (never moves on its
|
|
70
|
+
// own), the frame just placed 1, any other 0; a tie moves the later one
|
|
71
|
+
// in board order. (A plain "a stays" check moved a remote b whenever a
|
|
72
|
+
// was the frame just placed — order-dependent: PR #83 review.)
|
|
73
|
+
const stay = (n: string): number => remote.has(n) ? 2 : n === touched ? 1 : 0
|
|
74
|
+
const [mover, mr, fixed] = stay(a) < stay(b) ? [a, ra, rb] : [b, rb, ra]
|
|
75
|
+
const { dx, dy } = stepAside(mr, fixed)
|
|
76
|
+
moveFrame(model, mover, dx, dy)
|
|
77
|
+
changed = true
|
|
78
|
+
}
|
|
79
|
+
return changed
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Grid of the 整理成员 layout, shared by eviction seats. */
|
|
83
|
+
const PAD = 28
|
|
84
|
+
const GAP_X = 16
|
|
85
|
+
const GAP_Y = 14
|
|
86
|
+
|
|
87
|
+
/** Card-centred seats inside a frame, row by row (the 整理成员 grid). */
|
|
88
|
+
function seatsIn(f: Rect): Array<{ x: number; y: number }> {
|
|
89
|
+
const out: Array<{ x: number; y: number }> = []
|
|
90
|
+
for (let y = f.y + PAD; y + NODE_H <= f.y + f.h - PAD; y += NODE_H + GAP_Y) {
|
|
91
|
+
for (let x = f.x + PAD; x + NODE_W <= f.x + f.w - PAD; x += NODE_W + GAP_X) out.push({ x: x + NODE_W / 2, y: y + NODE_H / 2 })
|
|
92
|
+
}
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Move cards off frames they do not belong to; true when anything moved. */
|
|
97
|
+
export function evictStrangers(model: WorldModel): boolean {
|
|
98
|
+
let changed = false
|
|
99
|
+
const frames = model.allFrames()
|
|
100
|
+
const rectOf = new Map(frames)
|
|
101
|
+
for (const n of model.allNodes()) {
|
|
102
|
+
const own = new Set(n.memberships.map(m => m.team))
|
|
103
|
+
const host = frames.find(([name, f]) => !own.has(name) && hit(nodeRect(n), f))
|
|
104
|
+
if (host === undefined) continue
|
|
105
|
+
const f = host[1]
|
|
106
|
+
// Free = no foreign frame and no other card under it (cards read live: earlier evictions count).
|
|
107
|
+
const free = (p: { x: number; y: number }): boolean => {
|
|
108
|
+
const r = nodeRect(p)
|
|
109
|
+
return frames.every(([name, g]) => own.has(name) || !hit(r, g)) &&
|
|
110
|
+
model.allNodes().every(o => o.id === n.id || !hit(r, nodeRect(o)))
|
|
111
|
+
}
|
|
112
|
+
const home = n.memberships.map(m => rectOf.get(m.team)).filter((g): g is FrameGeom => g !== undefined)
|
|
113
|
+
const seat = home.flatMap(seatsIn).find(free)
|
|
114
|
+
const right = { x: f.x + f.w + OVERLAP_GAP + NODE_W / 2, y: n.y }
|
|
115
|
+
const below = { x: n.x, y: f.y + f.h + OVERLAP_GAP + NODE_H / 2 }
|
|
116
|
+
// Last resort: a column right of every frame, top-down from this frame. A card blocks
|
|
117
|
+
// at most two seats of this pitch, so 2·cards+1 seats always hold a free one.
|
|
118
|
+
const farX = Math.max(...frames.map(([, g]) => g.x + g.w)) + OVERLAP_GAP + NODE_W / 2
|
|
119
|
+
const column = Array.from({ length: 2 * model.allNodes().length + 1 }, (_, i) => ({ x: farX, y: f.y + NODE_H / 2 + i * (NODE_H + GAP_Y) }))
|
|
120
|
+
const to = seat ?? [right, below, ...column].find(free) ?? column[column.length - 1]!
|
|
121
|
+
model.dragNodes([n.id], to.x - n.x, to.y - n.y)
|
|
122
|
+
changed = true
|
|
123
|
+
}
|
|
124
|
+
return changed
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Both rules, frames first (moving a frame can uncover or cover cards). */
|
|
128
|
+
export function settleBoard(model: WorldModel, remote: ReadonlySet<string>, touched?: string): boolean {
|
|
129
|
+
const framesMoved = separateFrames(model, remote, touched)
|
|
130
|
+
const cardsMoved = evictStrangers(model)
|
|
131
|
+
return framesMoved || cardsMoved
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* gh#48 整理成员: inside every frame, the members that belong to exactly that
|
|
136
|
+
* one team re-seat into a padded grid; the frame grows downward (bounded) to
|
|
137
|
+
* fit them. Members of 2+ teams keep their positions.
|
|
138
|
+
*/
|
|
139
|
+
export function tidyMembers(model: WorldModel): void {
|
|
140
|
+
for (const [name, rect] of model.allFrames()) {
|
|
141
|
+
const sole = model.teamMemberIds(name)
|
|
142
|
+
.map(id => model.getNode(id))
|
|
143
|
+
.filter((n): n is NonNullable<typeof n> => n !== undefined)
|
|
144
|
+
.filter(n => n.memberships.length === 1)
|
|
145
|
+
if (sole.length === 0) continue // empty frame / only shared members: untouched
|
|
146
|
+
const perRow = Math.max(1, Math.floor((rect.w - 2 * PAD + GAP_X) / (NODE_W + GAP_X)))
|
|
147
|
+
const rows = Math.ceil(sole.length / perRow)
|
|
148
|
+
const needed = PAD * 2 + rows * NODE_H + (rows - 1) * GAP_Y
|
|
149
|
+
if (needed > rect.h) model.setFrame(name, { ...rect, h: Math.min(needed, rect.h + 400) })
|
|
150
|
+
const startX = rect.x + PAD + NODE_W / 2
|
|
151
|
+
let y = rect.y + PAD + NODE_H / 2
|
|
152
|
+
sole.forEach((n, i) => {
|
|
153
|
+
const col = i % perRow
|
|
154
|
+
if (i > 0 && col === 0) y += NODE_H + GAP_Y
|
|
155
|
+
const x = startX + col * (NODE_W + GAP_X)
|
|
156
|
+
model.dragNodes([n.id], x - n.x, y - n.y)
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -1,28 +1,28 @@
|
|
|
1
|
-
/** Project the peer's last missed card read onto existing remote cards and frames. */
|
|
2
|
-
import { peerHostOf, type RemoteTeamRow } from './federation'
|
|
3
|
-
|
|
4
|
-
export const UNREACHABLE_HINT = '对端未应答(显示的是最后已知状态)'
|
|
5
|
-
|
|
6
|
-
export function unreachableRefs(input: {
|
|
7
|
-
remoteTeams?: ReadonlyArray<RemoteTeamRow>
|
|
8
|
-
peerOrg?: ReadonlyArray<{ url: string; unreachable?: boolean }>
|
|
9
|
-
}): { hosts: ReadonlySet<string>; members: ReadonlySet<string> } {
|
|
10
|
-
const hosts = new Set<string>()
|
|
11
|
-
for (const row of input.remoteTeams ?? []) if (row.unreachable === true && row.via) hosts.add(peerHostOf(row.via))
|
|
12
|
-
for (const row of input.peerOrg ?? []) if (row.unreachable === true) hosts.add(peerHostOf(row.url))
|
|
13
|
-
const members = new Set<string>()
|
|
14
|
-
for (const row of input.remoteTeams ?? []) if (row.via && hosts.has(peerHostOf(row.via))) members.add(`member-${row.team.replace(/[^a-z0-9-]/gi, '-')}`)
|
|
15
|
-
return { hosts, members }
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** A mirror keeps its last known contents visible but warns until its owner responds again. */
|
|
19
|
-
export function showFrameReachability(frame: HTMLElement, lost: boolean): void {
|
|
20
|
-
frame.classList.toggle('is-unreachable', lost)
|
|
21
|
-
const existing = frame.querySelector('.p-frame-unreachable')
|
|
22
|
-
if (lost && existing === null) {
|
|
23
|
-
const hint = document.createElement('span')
|
|
24
|
-
hint.className = 'p-frame-unreachable'
|
|
25
|
-
hint.textContent = UNREACHABLE_HINT
|
|
26
|
-
frame.appendChild(hint)
|
|
27
|
-
} else if (!lost) existing?.remove()
|
|
28
|
-
}
|
|
1
|
+
/** Project the peer's last missed card read onto existing remote cards and frames. */
|
|
2
|
+
import { peerHostOf, type RemoteTeamRow } from './federation'
|
|
3
|
+
|
|
4
|
+
export const UNREACHABLE_HINT = '对端未应答(显示的是最后已知状态)'
|
|
5
|
+
|
|
6
|
+
export function unreachableRefs(input: {
|
|
7
|
+
remoteTeams?: ReadonlyArray<RemoteTeamRow>
|
|
8
|
+
peerOrg?: ReadonlyArray<{ url: string; unreachable?: boolean }>
|
|
9
|
+
}): { hosts: ReadonlySet<string>; members: ReadonlySet<string> } {
|
|
10
|
+
const hosts = new Set<string>()
|
|
11
|
+
for (const row of input.remoteTeams ?? []) if (row.unreachable === true && row.via) hosts.add(peerHostOf(row.via))
|
|
12
|
+
for (const row of input.peerOrg ?? []) if (row.unreachable === true) hosts.add(peerHostOf(row.url))
|
|
13
|
+
const members = new Set<string>()
|
|
14
|
+
for (const row of input.remoteTeams ?? []) if (row.via && hosts.has(peerHostOf(row.via))) members.add(`member-${row.team.replace(/[^a-z0-9-]/gi, '-')}`)
|
|
15
|
+
return { hosts, members }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A mirror keeps its last known contents visible but warns until its owner responds again. */
|
|
19
|
+
export function showFrameReachability(frame: HTMLElement, lost: boolean): void {
|
|
20
|
+
frame.classList.toggle('is-unreachable', lost)
|
|
21
|
+
const existing = frame.querySelector('.p-frame-unreachable')
|
|
22
|
+
if (lost && existing === null) {
|
|
23
|
+
const hint = document.createElement('span')
|
|
24
|
+
hint.className = 'p-frame-unreachable'
|
|
25
|
+
hint.textContent = UNREACHABLE_HINT
|
|
26
|
+
frame.appendChild(hint)
|
|
27
|
+
} else if (!lost) existing?.remove()
|
|
28
|
+
}
|
|
@@ -1,121 +1,121 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 「一键重排」board relayout (supervisor order v2, upgrade of gh#48): a
|
|
3
|
-
* deterministic whole-board re-seat — every LOCAL canvas team frame is
|
|
4
|
-
* resized from its content (sole-member count drives cols/rows; width AND
|
|
5
|
-
* height both derive from the grid), then the frames tile in a row flow
|
|
6
|
-
* (constructively non-overlapping, no collision pass). Multi-team members
|
|
7
|
-
* never enter the size derivation and never move (their dashed edges
|
|
8
|
-
* express membership); remote org frames and frameless nodes are out of
|
|
9
|
-
* scope. Pure geometry: same input, same output — pressing twice is
|
|
10
|
-
* stable by construction.
|
|
11
|
-
* @module nexus-stage/planning-relayout
|
|
12
|
-
*/
|
|
13
|
-
import { NODE_W, NODE_H, type FrameGeom, type WorldModel } from './world'
|
|
14
|
-
|
|
15
|
-
/** Inner padding between frame edge and the card grid (gh#48 family). */
|
|
16
|
-
export const RELAYOUT_PAD = 28
|
|
17
|
-
/** Card gaps inside a frame (same family as the tidy grid). */
|
|
18
|
-
export const RELAYOUT_GAP_X = 16
|
|
19
|
-
export const RELAYOUT_GAP_Y = 14
|
|
20
|
-
/** Gap between neighbouring frames in the row-flow tiling. */
|
|
21
|
-
export const FRAME_GAP = 48
|
|
22
|
-
/** Column cap: perRow = min(4, ceil(sqrt(n))). */
|
|
23
|
-
export const PER_ROW_CAP = 4
|
|
24
|
-
/** Floor widths/heights: keeps the floating title band legible. */
|
|
25
|
-
export const FRAME_MIN_W = 180
|
|
26
|
-
export const FRAME_MIN_H = 90
|
|
27
|
-
/** Default world-space wrap width of one tiling row. */
|
|
28
|
-
export const RELAYOUT_WRAP = 1600
|
|
29
|
-
|
|
30
|
-
/** One local frame to re-seat: its current rect and its SOLE member ids. */
|
|
31
|
-
export interface RelayoutFrameInput {
|
|
32
|
-
readonly name: string
|
|
33
|
-
readonly rect: FrameGeom
|
|
34
|
-
readonly soleIds: readonly string[]
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** The deterministic outcome: new frame rects and absolute card centers. */
|
|
38
|
-
export interface RelayoutOutput {
|
|
39
|
-
readonly frames: ReadonlyArray<{ name: string; rect: FrameGeom }>
|
|
40
|
-
readonly seats: ReadonlyArray<{ id: string; x: number; y: number }>
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Derive the whole-board layout. Frame order in = tiling order out (the
|
|
45
|
-
* caller passes a stable order, e.g. the model's insertion order, which
|
|
46
|
-
* keeps the result deterministic across presses).
|
|
47
|
-
*/
|
|
48
|
-
export function planBoardRelayout(input: {
|
|
49
|
-
frames: ReadonlyArray<RelayoutFrameInput>
|
|
50
|
-
wrapWidth?: number
|
|
51
|
-
}): RelayoutOutput {
|
|
52
|
-
const wrap = input.wrapWidth ?? RELAYOUT_WRAP
|
|
53
|
-
// Pass 1 — content-derived size per frame (multi-team members excluded
|
|
54
|
-
// upstream: soleIds carries exactly the re-seatable cards).
|
|
55
|
-
const sized = input.frames.map(f => {
|
|
56
|
-
const n = Math.max(f.soleIds.length, 1)
|
|
57
|
-
const perRow = Math.min(PER_ROW_CAP, Math.max(1, Math.ceil(Math.sqrt(n))))
|
|
58
|
-
const rows = Math.ceil(n / perRow)
|
|
59
|
-
const w = Math.max(FRAME_MIN_W, RELAYOUT_PAD * 2 + perRow * NODE_W + (perRow - 1) * RELAYOUT_GAP_X)
|
|
60
|
-
const h = Math.max(FRAME_MIN_H, RELAYOUT_PAD * 2 + rows * NODE_H + (rows - 1) * RELAYOUT_GAP_Y)
|
|
61
|
-
return { name: f.name, w, h, rows, perRow, soleIds: f.soleIds }
|
|
62
|
-
})
|
|
63
|
-
// Pass 2 — row-flow tiling: left→right until the wrap width is full,
|
|
64
|
-
// then advance one row by the tallest frame of the band. Constructive
|
|
65
|
-
// disjointness: boxes on one band share the y band and never overlap in
|
|
66
|
-
// x; bands stack with FRAME_GAP.
|
|
67
|
-
const frames: Array<{ name: string; rect: FrameGeom }> = []
|
|
68
|
-
const seats: Array<{ id: string; x: number; y: number }> = []
|
|
69
|
-
let x = 0
|
|
70
|
-
let y = 0
|
|
71
|
-
let bandH = 0
|
|
72
|
-
for (const f of sized) {
|
|
73
|
-
if (x > 0 && x + f.w > wrap) {
|
|
74
|
-
x = 0
|
|
75
|
-
y += bandH + FRAME_GAP
|
|
76
|
-
bandH = 0
|
|
77
|
-
}
|
|
78
|
-
const rect: FrameGeom = { x, y, w: f.w, h: f.h }
|
|
79
|
-
frames.push({ name: f.name, rect })
|
|
80
|
-
// Pass 3 — seat the sole cards on the new rect's grid (the gh#48 rule,
|
|
81
|
-
// driven by the NEW frame geometry).
|
|
82
|
-
const startX = rect.x + RELAYOUT_PAD + NODE_W / 2
|
|
83
|
-
let cy = rect.y + RELAYOUT_PAD + NODE_H / 2
|
|
84
|
-
f.soleIds.forEach((id, i) => {
|
|
85
|
-
const col = i % f.perRow
|
|
86
|
-
if (i > 0 && col === 0) cy += NODE_H + RELAYOUT_GAP_Y
|
|
87
|
-
seats.push({ id, x: startX + col * (NODE_W + RELAYOUT_GAP_X), y: cy })
|
|
88
|
-
})
|
|
89
|
-
x += f.w + FRAME_GAP
|
|
90
|
-
bandH = Math.max(bandH, f.h)
|
|
91
|
-
}
|
|
92
|
-
return { frames, seats }
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* The model adapter (supervisor order v2): collect the LOCAL frames and
|
|
97
|
-
* their sole members (remote org frames and multi-team members are out of
|
|
98
|
-
* scope by spec), run the pure planner, apply the rects and seat moves
|
|
99
|
-
* through the model's own mutation seam. Returns false when nothing is in
|
|
100
|
-
* scope (the caller decides whether to dirty the layout).
|
|
101
|
-
*/
|
|
102
|
-
export function applyBoardRelayout(model: WorldModel, remoteNames: ReadonlySet<string>): boolean {
|
|
103
|
-
const frames: Array<{ name: string; rect: FrameGeom; soleIds: string[] }> = []
|
|
104
|
-
for (const [name, rect] of model.allFrames()) {
|
|
105
|
-
if (remoteNames.has(name)) continue // remote org frames: untouched
|
|
106
|
-
const soleIds = model.teamMemberIds(name)
|
|
107
|
-
.map(id => model.getNode(id))
|
|
108
|
-
.filter((n): n is NonNullable<typeof n> => n !== undefined)
|
|
109
|
-
.filter(n => n.memberships.length === 1)
|
|
110
|
-
.map(n => n.id)
|
|
111
|
-
frames.push({ name, rect, soleIds })
|
|
112
|
-
}
|
|
113
|
-
if (frames.length === 0) return false
|
|
114
|
-
const out = planBoardRelayout({ frames })
|
|
115
|
-
for (const f of out.frames) model.setFrame(f.name, f.rect)
|
|
116
|
-
for (const seat of out.seats) {
|
|
117
|
-
const n = model.getNode(seat.id)
|
|
118
|
-
if (n !== undefined) model.dragNodes([seat.id], seat.x - n.x, seat.y - n.y)
|
|
119
|
-
}
|
|
120
|
-
return true
|
|
121
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 「一键重排」board relayout (supervisor order v2, upgrade of gh#48): a
|
|
3
|
+
* deterministic whole-board re-seat — every LOCAL canvas team frame is
|
|
4
|
+
* resized from its content (sole-member count drives cols/rows; width AND
|
|
5
|
+
* height both derive from the grid), then the frames tile in a row flow
|
|
6
|
+
* (constructively non-overlapping, no collision pass). Multi-team members
|
|
7
|
+
* never enter the size derivation and never move (their dashed edges
|
|
8
|
+
* express membership); remote org frames and frameless nodes are out of
|
|
9
|
+
* scope. Pure geometry: same input, same output — pressing twice is
|
|
10
|
+
* stable by construction.
|
|
11
|
+
* @module nexus-stage/planning-relayout
|
|
12
|
+
*/
|
|
13
|
+
import { NODE_W, NODE_H, type FrameGeom, type WorldModel } from './world'
|
|
14
|
+
|
|
15
|
+
/** Inner padding between frame edge and the card grid (gh#48 family). */
|
|
16
|
+
export const RELAYOUT_PAD = 28
|
|
17
|
+
/** Card gaps inside a frame (same family as the tidy grid). */
|
|
18
|
+
export const RELAYOUT_GAP_X = 16
|
|
19
|
+
export const RELAYOUT_GAP_Y = 14
|
|
20
|
+
/** Gap between neighbouring frames in the row-flow tiling. */
|
|
21
|
+
export const FRAME_GAP = 48
|
|
22
|
+
/** Column cap: perRow = min(4, ceil(sqrt(n))). */
|
|
23
|
+
export const PER_ROW_CAP = 4
|
|
24
|
+
/** Floor widths/heights: keeps the floating title band legible. */
|
|
25
|
+
export const FRAME_MIN_W = 180
|
|
26
|
+
export const FRAME_MIN_H = 90
|
|
27
|
+
/** Default world-space wrap width of one tiling row. */
|
|
28
|
+
export const RELAYOUT_WRAP = 1600
|
|
29
|
+
|
|
30
|
+
/** One local frame to re-seat: its current rect and its SOLE member ids. */
|
|
31
|
+
export interface RelayoutFrameInput {
|
|
32
|
+
readonly name: string
|
|
33
|
+
readonly rect: FrameGeom
|
|
34
|
+
readonly soleIds: readonly string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The deterministic outcome: new frame rects and absolute card centers. */
|
|
38
|
+
export interface RelayoutOutput {
|
|
39
|
+
readonly frames: ReadonlyArray<{ name: string; rect: FrameGeom }>
|
|
40
|
+
readonly seats: ReadonlyArray<{ id: string; x: number; y: number }>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Derive the whole-board layout. Frame order in = tiling order out (the
|
|
45
|
+
* caller passes a stable order, e.g. the model's insertion order, which
|
|
46
|
+
* keeps the result deterministic across presses).
|
|
47
|
+
*/
|
|
48
|
+
export function planBoardRelayout(input: {
|
|
49
|
+
frames: ReadonlyArray<RelayoutFrameInput>
|
|
50
|
+
wrapWidth?: number
|
|
51
|
+
}): RelayoutOutput {
|
|
52
|
+
const wrap = input.wrapWidth ?? RELAYOUT_WRAP
|
|
53
|
+
// Pass 1 — content-derived size per frame (multi-team members excluded
|
|
54
|
+
// upstream: soleIds carries exactly the re-seatable cards).
|
|
55
|
+
const sized = input.frames.map(f => {
|
|
56
|
+
const n = Math.max(f.soleIds.length, 1)
|
|
57
|
+
const perRow = Math.min(PER_ROW_CAP, Math.max(1, Math.ceil(Math.sqrt(n))))
|
|
58
|
+
const rows = Math.ceil(n / perRow)
|
|
59
|
+
const w = Math.max(FRAME_MIN_W, RELAYOUT_PAD * 2 + perRow * NODE_W + (perRow - 1) * RELAYOUT_GAP_X)
|
|
60
|
+
const h = Math.max(FRAME_MIN_H, RELAYOUT_PAD * 2 + rows * NODE_H + (rows - 1) * RELAYOUT_GAP_Y)
|
|
61
|
+
return { name: f.name, w, h, rows, perRow, soleIds: f.soleIds }
|
|
62
|
+
})
|
|
63
|
+
// Pass 2 — row-flow tiling: left→right until the wrap width is full,
|
|
64
|
+
// then advance one row by the tallest frame of the band. Constructive
|
|
65
|
+
// disjointness: boxes on one band share the y band and never overlap in
|
|
66
|
+
// x; bands stack with FRAME_GAP.
|
|
67
|
+
const frames: Array<{ name: string; rect: FrameGeom }> = []
|
|
68
|
+
const seats: Array<{ id: string; x: number; y: number }> = []
|
|
69
|
+
let x = 0
|
|
70
|
+
let y = 0
|
|
71
|
+
let bandH = 0
|
|
72
|
+
for (const f of sized) {
|
|
73
|
+
if (x > 0 && x + f.w > wrap) {
|
|
74
|
+
x = 0
|
|
75
|
+
y += bandH + FRAME_GAP
|
|
76
|
+
bandH = 0
|
|
77
|
+
}
|
|
78
|
+
const rect: FrameGeom = { x, y, w: f.w, h: f.h }
|
|
79
|
+
frames.push({ name: f.name, rect })
|
|
80
|
+
// Pass 3 — seat the sole cards on the new rect's grid (the gh#48 rule,
|
|
81
|
+
// driven by the NEW frame geometry).
|
|
82
|
+
const startX = rect.x + RELAYOUT_PAD + NODE_W / 2
|
|
83
|
+
let cy = rect.y + RELAYOUT_PAD + NODE_H / 2
|
|
84
|
+
f.soleIds.forEach((id, i) => {
|
|
85
|
+
const col = i % f.perRow
|
|
86
|
+
if (i > 0 && col === 0) cy += NODE_H + RELAYOUT_GAP_Y
|
|
87
|
+
seats.push({ id, x: startX + col * (NODE_W + RELAYOUT_GAP_X), y: cy })
|
|
88
|
+
})
|
|
89
|
+
x += f.w + FRAME_GAP
|
|
90
|
+
bandH = Math.max(bandH, f.h)
|
|
91
|
+
}
|
|
92
|
+
return { frames, seats }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The model adapter (supervisor order v2): collect the LOCAL frames and
|
|
97
|
+
* their sole members (remote org frames and multi-team members are out of
|
|
98
|
+
* scope by spec), run the pure planner, apply the rects and seat moves
|
|
99
|
+
* through the model's own mutation seam. Returns false when nothing is in
|
|
100
|
+
* scope (the caller decides whether to dirty the layout).
|
|
101
|
+
*/
|
|
102
|
+
export function applyBoardRelayout(model: WorldModel, remoteNames: ReadonlySet<string>): boolean {
|
|
103
|
+
const frames: Array<{ name: string; rect: FrameGeom; soleIds: string[] }> = []
|
|
104
|
+
for (const [name, rect] of model.allFrames()) {
|
|
105
|
+
if (remoteNames.has(name)) continue // remote org frames: untouched
|
|
106
|
+
const soleIds = model.teamMemberIds(name)
|
|
107
|
+
.map(id => model.getNode(id))
|
|
108
|
+
.filter((n): n is NonNullable<typeof n> => n !== undefined)
|
|
109
|
+
.filter(n => n.memberships.length === 1)
|
|
110
|
+
.map(n => n.id)
|
|
111
|
+
frames.push({ name, rect, soleIds })
|
|
112
|
+
}
|
|
113
|
+
if (frames.length === 0) return false
|
|
114
|
+
const out = planBoardRelayout({ frames })
|
|
115
|
+
for (const f of out.frames) model.setFrame(f.name, f.rect)
|
|
116
|
+
for (const seat of out.seats) {
|
|
117
|
+
const n = model.getNode(seat.id)
|
|
118
|
+
if (n !== undefined) model.dragNodes([seat.id], seat.x - n.x, seat.y - n.y)
|
|
119
|
+
}
|
|
120
|
+
return true
|
|
121
|
+
}
|